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 theapp.api_connectionsworkspace-scope precedent, migration20260706T181332):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: reusecore.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.tsalready does AES-256-GCM with key =SHA-256(AUTH_SECRET || "::" || "ct-llm-key-v1"). New thin modulelib/finance-simplefin-store.tscallsencryptKey(accessUrl)/decryptKey(rec)from keystore (or a finance-namespaced key derivationct-simplefin-v1for domain separation) and does the connection-table CRUD. Master key staysAUTH_SECRETin~/.forge-secrets/creatortrack-auth.env(no new secret file). Access URLs NEVER touch an env file again. - Blast radius: a leaked
AUTH_SECRETdecrypts 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,POSTit (empty body), response body IS the access URL. (Direct port offorge_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 (NOTisPersonalAppsOwner— this is the general path),claimSetupToken->encryptKey-> upsertfinance.simplefin_connectionsfor the caller's workspace -> run an initialsyncfor that workspace -> return account count. Drop theFINANCE_CONNECT_PASSPHRASEspeed-bump for this path (real per-workspace auth replaces it). - UI: extend
components/finance/ConnectPanel.tsxwith a "Connect a bank" step: link out tohttps://bridge.simplefin.org(user connects their bank there, copies the one-time setup token), a paste box, submit. Show connection status + a "Reconnect" affordance whenstatus='needs_reauth'.
D. Per-workspace sync fan-out¶
- New
jobIdentityForWorkspace(workspaceId)/ iterate: replace the singlejobIdentity()+ownerWorkspaceId() LIMIT 1with a loop over all rows infinance.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) withctx.workspaceId= that workspace and the decrypted URL passed intofetchAccounts. app/api/finance/jobs/[job]/route.ts(bearerFINANCE_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 -> setstatus='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_URLfrom~/.forge-secrets/simplefin.envinto an encryptedfinance.simplefin_connectionsrow 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 ascore.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¶
- One bank connection per workspace for v1, or allow several immediately? (Affects the unique constraint + UI.)
- Free manual tier gated the same as sync, or is bank-sync a paid gate from day one?
- Keep #67 ("Personal") as your normal workspace after normalization, or keep any special-casing?
- SimpleFIN Bridge cost (~$1.50/user/mo per their pricing) — pass through, absorb, or paid-tier only?