Skip to content

Build Spec: Self-serve FIN Bridge (SimpleFIN) connect for multi-tenant Finances

URL: https://mkdocs.justinsforge.com/memory/plans/finances-fin-connect-build-spec-2026-07-07/

Date: 2026-07-07 Status: SPEC (no code yet). Deepens the P0-P2 sketch in finances-public-release-2026-07-07 into a buildable plan. Approve before building. One-line: any workspace owner connects their OWN bank via a SimpleFIN Bridge setup token; the app claims it, stores the access URL encrypted per-workspace, and the sync pipeline fans out one run per connected workspace.

The good news (verified in-code 2026-07-07)

The finance data layer and job pipeline are ALREADY workspace-clean: every finance.* table is workspace_id + RLS, every job body is where workspace_id = ${ctx.workspaceId}, and runJob(job, {me, workspaceId}) already threads a workspace. Multi-tenancy is NOT a rewrite. It concentrates in four hardcoded-to-Justin spots: 1. lib/finance-simplefin.ts:44 — one global SIMPLEFIN_ACCESS_URL from ~/.forge-secrets/simplefin.env; fetchAccounts() has no URL/workspace arg. 2. lib/finance-jobs.ts:40 jobIdentity() = CAPTURE_EMAIL ?? [email protected] (single identity); :78 ownerWorkspaceId() LIMIT 1 (single workspace). 3. isPersonalAppsOwner gate (PERSONAL_APPS_OWNER_EMAIL ?? [email protected]) on every finance door. 4. No in-app token claim (only the Python CLI forge/scripts/forge_simplefin_client.py) and no per-workspace connection record.

Architecture

A. Per-workspace encrypted credential store

  • New table finance.simplefin_connections (mirror the app.api_connections workspace-scope precedent, migration 20260706T181332): id, workspace_id bigint NOT NULL REFERENCES core.workspaces(id) ON DELETE CASCADE, ciphertext text, iv text, tag text (the encrypted access URL), status text ('active'|'needs_reauth'|'error'), org_name text, last_sync_at timestamptz, last_error text, created_by bigint, created_at, updated_at, UNIQUE(workspace_id) (one bank connection per workspace for v1). RLS: reuse core.apply_workspace_rls('finance.simplefin_connections') (same helper the other finance tables use), so only workspace members read/write it. GRANT ... TO lifeos_app.
  • Encryption: REUSE the existing keystore, do NOT invent crypto. lib/llm/keystore.ts already does AES-256-GCM with key = SHA-256(AUTH_SECRET || "::" || "ct-llm-key-v1"). New thin module lib/finance-simplefin-store.ts calls encryptKey(accessUrl) / decryptKey(rec) from keystore (or a finance-namespaced key derivation ct-simplefin-v1 for domain separation) and does the connection-table CRUD. Master key stays AUTH_SECRET in ~/.forge-secrets/creatortrack-auth.env (no new secret file). Access URLs NEVER touch an env file again.
  • Blast radius: a leaked AUTH_SECRET decrypts all stored URLs, identical risk profile to the LLM keys already stored this way (accepted precedent). Mitigation noted: SimpleFIN access URLs are READ-ONLY (cannot move money), and rotation = re-encrypt all rows.

B. In-app token claim (port the Python claim into TS)

  • Add to lib/finance-simplefin.ts: export async function claimSetupToken(setupToken: string): Promise<string> = base64-decode the token to a claim URL, POST it (empty body), response body IS the access URL. (Direct port of forge_simplefin_client.py:claim_setup_token.)
  • Refactor accessParts(url) + fetchAccounts(accessUrl, opts) to take the access URL as an ARGUMENT instead of reading the global secret. A back-compat overload can fall back to the global secret while Justin's row is migrated (Task order below).

C. Connect flow (user-facing)

  • New route app/finances/connect/token/route.ts (POST { setupToken }): gate to the CURRENT workspace's owner/admin (NOT isPersonalAppsOwner — this is the general path), claimSetupToken -> encryptKey -> upsert finance.simplefin_connections for the caller's workspace -> run an initial sync for that workspace -> return account count. Drop the FINANCE_CONNECT_PASSPHRASE speed-bump for this path (real per-workspace auth replaces it).
  • UI: extend components/finance/ConnectPanel.tsx with a "Connect a bank" step: link out to https://bridge.simplefin.org (user connects their bank there, copies the one-time setup token), a paste box, submit. Show connection status + a "Reconnect" affordance when status='needs_reauth'.

D. Per-workspace sync fan-out

  • New jobIdentityForWorkspace(workspaceId) / iterate: replace the single jobIdentity() + ownerWorkspaceId() LIMIT 1 with a loop over all rows in finance.simplefin_connections (status='active'). For each: resolve an identity for that workspace's owner, decrypt its access URL, run the pipeline (sync -> categorize -> recurring -> holdings -> alerts) with ctx.workspaceId = that workspace and the decrypted URL passed into fetchAccounts.
  • app/api/finance/jobs/[job]/route.ts (bearer FINANCE_JOBS_TOKEN): accept an optional ?workspace=<id> to run one, else fan out over all connections. Failures are per-workspace isolated (one bad token does not fail the batch); a SimpleFIN 403/expired -> set status='needs_reauth', last_error, and notify THAT workspace's owner (not Justin).
  • Respect SimpleFIN's ~24 req/day/connection: keep the 07/13/19h cadence (3/day) and stagger fan-out.

E. Retire the personal-apps-owner gate FOR FINANCES ONLY

  • Today Finances is a "personal-data app" gated to one server-owner email because it reads Justin's server-level SimpleFIN secret. Once each workspace owns its own connection, Finances becomes a NORMAL workspace-scoped app: the gate changes from isPersonalAppsOwner(me) to "member of this workspace" (RLS already enforces data isolation). Fitness + Calendar STAY personal-apps-owner-gated (they read Justin's Garmin/Hevy/Google, genuinely his server data). The Krystal co-owner share (finance sharing) still works: it grants #67 access; unrelated to a user's own connection.
  • Migrate Justin's existing setup: move SIMPLEFIN_ACCESS_URL from ~/.forge-secrets/simplefin.env into an encrypted finance.simplefin_connections row for workspace #67 (one-time script), so his sync uses the identical per-workspace path. Keep the env var as a read-only fallback for one release, then delete.

Security & privacy

  • Access URLs encrypted at rest (AES-256-GCM, AUTH_SECRET-derived key), never in env, never logged. Per-workspace RLS on the connection table.
  • SimpleFIN access URLs are read-only bank access; a leak exposes transaction data, not money movement.
  • Re-auth/expiry handled per-connection with owner notification; no cross-tenant data path (RLS + per-workspace URL).
  • Doctrine fit: no plaintext credentials outside ~/.forge-secrets; the encrypted DB store IS the sanctioned pattern (same as core.user_llm_keys).

Phased task list (each = buildable, verifiable, one commit)

P0 — foundation (make the credential per-workspace): 1. Migration: finance.simplefin_connections + RLS + grants. Verify: apply on a dev clone, \d+, RLS policy present. 2. lib/finance-simplefin-store.ts: encrypt/store/read/delete a workspace's access URL (reuse keystore). Verify: tsx roundtrip encrypt->store->decrypt for a test workspace. 3. Refactor accessParts/fetchAccounts to take an access-URL arg (global-secret fallback retained). Verify: tsx fetchAccounts(url) against Justin's URL returns accounts. 4. TS claimSetupToken. Verify: unit-decode a known token to the expected claim URL (mock the POST). 5. One-time migrate Justin's SIMPLEFIN_ACCESS_URL into a #67 connection row; point his sync at the connection. Verify: a #67 sync run reads the DB connection (not env) and still lands transactions.

P1 — self-serve connect (user-facing): 6. app/finances/connect/token/route.ts (claim -> store -> initial sync), workspace-owner gated. Verify: POST a real setup token on a test workspace stores an encrypted row + syncs accounts. 7. ConnectPanel.tsx connect-a-bank UI + status/reconnect. Verify: dev-slot screenshot of the flow on a non-Justin workspace.

P2 — per-workspace fan-out: 8. Job runner + [job] route fan out over all active connections; ?workspace= for one. Verify: two connected test workspaces both sync in one trigger; a bad token marks only its own needs_reauth. 9. Re-auth detection + per-owner notify. Verify: simulate a 403, assert status='needs_reauth' + notification to that owner.

P3 — normalize Finances as a workspace app: 10. Swap the Finances doors from isPersonalAppsOwner to workspace-membership (keep Fitness/Calendar personal-gated; keep the Krystal co-owner path). Verify: a second user sees ONLY their own connected finances; Justin + Krystal unaffected. 11. Delete the env-file fallback + FINANCE_CONNECT_PASSPHRASE. Register + document (reference_creatortrack_finance_fin_connect.md, MEMORY index), eval.

Out of scope (separate decisions)

  • Billing / free-manual vs paid-sync tiers (release plan covers the pricing sketch).
  • Multiple bank connections per workspace (v1 = one; the UNIQUE(workspace_id) relaxes later).
  • The manual/CSV-only tier UI.
  • Retiring Fitness/Calendar's personal-apps-owner model (they genuinely read Justin's server data).

Open questions for Justin

  1. One bank connection per workspace for v1, or allow several immediately? (Affects the unique constraint + UI.)
  2. Free manual tier gated the same as sync, or is bank-sync a paid gate from day one?
  3. Keep #67 ("Personal") as your normal workspace after normalization, or keep any special-casing?
  4. SimpleFIN Bridge cost (~$1.50/user/mo per their pricing) — pass through, absorb, or paid-tier only?