Skip to content

CreatorTrack multi-tenant FIN connect (SimpleFIN)

URL: https://mkdocs.justinsforge.com/memory/general/reference_creatortrack_finance_fin_connect/

Makes Finances a real multi-tenant app: every user connects THEIR OWN SimpleFIN Bridge (users self-provision at bridge.simplefin.org and pay SimpleFIN directly; the platform has zero per-user cost). Branch feat/fin-connect-p0 (NOT merged, NOT deployed). Spec: fin-connect build spec. The finance data layer + job pipeline were already workspace-clean; this fills the four hardcoded-to-Justin gaps.

What's built (P0-P3, each committed + verified)

  • P0 credential store. finance.simplefin_connections (one per workspace, UNIQUE(workspace_id), workspace-membership RLS) holds the access URL ENCRYPTED via the existing AES-256-GCM AUTH_SECRET keystore (lib/llm/keystore encryptKey/decryptKey, reused, no new secret). Store lib lib/finance-simplefin-store.ts (store/get/meta/status/markSynced/delete). Client lib/finance-simplefin.ts now takes a per-connection URL (fetchAccounts({accessUrl}), accessParts(url)) with global-secret fallback, and claimSetupToken() ports the Python one-time token claim into TS. syncJob reads the per-workspace URL (getAccessUrl), stamps last_sync_at. One-time scripts/fin-connect-adopt-global-url.mts <ws> moves Justin's global URL into a #67 connection on deploy.
  • P1 self-serve connect. POST /finances/connect/token (workspace owner/admin): claim -> validate via a balances probe -> storeConnection -> initial sync. UI components/finance/BankConnectPanel.tsx (bridge.simplefin.org -> paste token -> connect), wired into the Finances sidebar.
  • P2 per-workspace sync fan-out. finance.active_sync_targets() (SECURITY DEFINER; enumerates active connections + their owner, bypassing RLS). runJobForWorkspace + runJobAllConnections fan a job over all connections (+ the legacy global-secret owner until adopted); failures isolate per-workspace; a SimpleFIN 403 flips the connection to needs_reauth. /api/finance/jobs/[job] fans out by default, ?workspace=<id> for one.
  • P3 gate flip. Every finance read/edit door (14 files) now gates on financeAccessAllowed (lib/finance-access.ts, the single policy chokepoint = any authenticated member) instead of the isPersonalAppsOwner email lock. RLS + resolveFinanceWorkspace scope each user to their own workspace's data; a stranger sees empty finances until they connect. Fitness/Calendar keep the email gate (they read Justin's server data). Legacy finances/connect (finance.db migration) stays owner-only. The Krystal co-owner share still works.

Verified (dev clone, real asUser/HTTP-less paths)

Encrypt->store->decrypt exact roundtrip; fetchAccounts vs SimpleFIN public demo = 3 accounts / 60 txns; claim surfaces the one-time 403 + rejects invalid tokens; adopt stores an active connection; fan-out enumerated the target and synced the demo connection (3 accts / 172 txns, ok:true, isolated); a non-member sees 0 of #67's rows. tsc + webpack build clean.

Deploy checklist (when Justin approves)

  1. Merge feat/fin-connect-p0; deploy (runs the 3 migrations).
  2. Run scripts/fin-connect-adopt-global-url.mts 67 on prod to move Justin's global URL into a #67 connection.
  3. Post-adopt cleanup (own commit): delete the global-secret fallback in finance-simplefin.ts + the FINANCE_CONNECT_PASSPHRASE legacy path.
  4. Live end-to-end needs a real one-time setup token from the interactive bridge (can't automate).

Open product decisions (NOT blockers)

Bank-sync is open + self-serve (users bring their own SimpleFIN key; no paid tier, no per-user cost). If ever wanted, restrict in ONE place: financeAccessAllowed (e.g. workspace-owner-only, invite gate).

[Claude Code, 2026-07-07]