Skip to content

feat: per-user access, stored bank sync, fail-closed production - #1

Merged
SamsonNegedu merged 11 commits into
mainfrom
feat/per-user-bank-sync
Nov 1, 2026
Merged

SamsonNegedu merged 11 commits into
mainfrom
feat/per-user-bank-sync

Conversation

@SamsonNegedu

Copy link
Copy Markdown
Owner

Summary

request
  AccessGuard            prod: Cloudflare token required, else the server won't start
    CsrfGuard            X-FinKit + exact Origin (ALLOWED_ORIGIN, required in prod)
      every query filtered by user_id

bank-connections/
├── config/         env, tokens
├── connections/    connect flow, callback, connection store
├── sync/           manual sync, scheduled catch-up (1 min after start, then every 30 min check)
├── transactions/   stored per user, one encrypted value per row
├── providers/      Enable Banking, test bank
└── admin/          back office
 sync (manual)
   fetch from bank
+    PSU headers (person's IP + browser) when the bank requires exactly those
+    first fetch: strategy=longest, fallback to the normal window
-  return to browser
+  store (encrypted, deduped by keyed fingerprint)
+  return to browser
+ scheduled catch-up (twice a day, no PSU headers; more than 500 pages fails instead of truncating)

Also: Sheets input validation (tab names, column letters), generic export errors, /terms and /privacy routes, privacy and terms pages and docs/privacy-facts.md updated, .env.example rewritten (required first), ./secrets mounted read-only for key files, CI check job gates the image builds.

Evidence

  • Before: production without Cloudflare Access settings or ALLOWED_ORIGIN booted in shared dev@localhost mode with open CORS; a missing Origin passed CSRF; export errors leaked Google messages; stored transactions did not exist.
    After: API 106 tests, web 35 tests, lint, format and types pass locally.
make({ NODE_ENV: 'production' })                       -> rejects /CF_ACCESS/
make({ NODE_ENV: 'production', CF_*, no origin })      -> rejects /ALLOWED_ORIGIN/
POST /api/learn/mapping, ALLOWED_ORIGIN set, no Origin -> 403
user B GET/DELETE /api/banking/transactions            -> sees/deletes none of user A's
SELECT * FROM bank_transactions                        -> only ids, ext_key (HMAC), payload (v1. ciphertext)

Merge Danger

Door: one-way

The database upgrades itself on start: rows from before accounts existed are given to the first address in ALLOWED_USER_EMAILS, and the earlier bank_transactions layout is rewritten into the encrypted one. Take a backup first.

Blast Radius: Deployment

  • Existing deployments will not start until ALLOWED_ORIGIN, CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD are set. The tunnel variable is TUNNEL_TOKEN (the old example said CLOUDFLARE_TUNNEL_TOKEN).
  • BANKING_ENCRYPTION_KEY now protects all stored transactions; losing it loses them. Back it up apart from the database.
  • Deploy no longer logs in to ghcr.io: the two image packages must be public.
  • Real Enable Banking sync is still untested against the live API (fake-server tests only).
  • Privacy and terms pages need a qualified review before being relied on.

🤖 Generated with Claude Code

SamsonNegedu and others added 11 commits November 1, 2026 17:35
…cess auth

Replace the Hono API with NestJS, organised as business feature modules:
access, bank-connections, merchant-rules, settings, sheet-export, health,
plus shared (database, crypto, http). Behaviour and response shapes of the
existing endpoints are unchanged (POSTs still return 200, errors still {error}).

Bank sync (Enable Banking, with a built-in test bank for local use):
- off by default; misconfiguration fails closed (503), test bank refused in production
- Cloudflare Access JWT verified server-side, admin allow-list for the back office
- CSRF header on mutating requests, single-use state + HttpOnly cookie on the callback
- session ids encrypted at rest (AES-256-GCM); transactions are never stored server-side
- sync throttle, audit log, log redaction, no-store responses
- reconnecting an already connected bank replaces the old connection
- each synced row carries bank, account label and a stable account reference

Tooling and housekeeping:
- oxlint + oxfmt configs; vitest + supertest tests (60)
- load api/.env on local runs (real environment variables still win)
- api/.env.example and a refreshed root example.env
- remove the AI categorization feature and the OpenAI dependency
- Dockerfile: PORT=3000, node dist/main.js
- README rewritten for the self-hosted, privacy-first setup

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d month filters

Restructure the single dashboard into routed pages under /app, with a left
sidebar (drawer and bottom tab bar on phones), breadcrumbs and one global
period picker.

Pages and hierarchy:
- Overview, Banks, Connect, a bank (accounts), Transactions, merchant,
  Review, Income, Analytics, Trends, Export, Settings (sheet, columns,
  rules, back office for admins)
- banks -> accounts -> transactions -> merchant groups -> one merchant, each a link
- filters live in the address (?cat=Groceries&bank=N26&view=merchants), so views
  can be bookmarked and survive the back button; saved views and removable chips
- bank pages redirect to the overview when banking is off; clear messages when
  FinKit's own server is unreachable

Bank sync (replaces the modal): connect flow, status and expiry bars, sync,
sync all, reconnect, disconnect, back office; rows carry bank and account.

Period handling: year-first picker (all time, quick picks, years with months)
and year/month tabs on Analytics, Trends, Income, Review and Transactions.
Export picks a year, then a month, and copies that year's rows.

Mobile: transactions as cards with a sort menu, bottom tab bar, no horizontal
overflow on any route.

Also: review queue "up next" list and counts, category legend with amounts,
income vs spending running totals, category-chart and trend colours that match
their meaning, vendor chunks (react, charts, xlsx, tanstack), serve.json rewrites
for /app/* routes, oxlint/oxfmt, tests for period summaries (28).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… phase machines

Reorganize the SPA into features/* and shared/* with docs/web-architecture.md; split AppState into workspace hooks, modularize parser/categorizer/sheet layout, and model import, settings, banking, review, and table flows with explicit phases.

Co-authored-by: Cursor <cursoragent@cursor.com>
Allowlist users with verified Cloudflare Access, production fail-closed env checks, and user-scoped rules, settings, and banking; store encrypted synced transactions server-side with delete/disconnect flows. Reorganize bank-connections, validate Sheets A1 ranges, add root package scripts, stylistic lint/format rules, module constants, and updated docs/README.

Co-authored-by: Cursor <cursoragent@cursor.com>
…istory on first fetch

Manual syncs now tell the bank a person is present (their IP and browser, only when the
bank's required PSU headers are ones we can supply), so they don't use up the daily
background-fetch limit. Scheduled syncs send none. A connection's first fetch asks for the
bank's longest history and falls back to the normal window if refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…update privacy and terms

- Stored transactions: date, amount, text, names and IBANs are one encrypted value per row; the
  repeat-skipping key is a keyed fingerprint. Older databases are upgraded on start.
- example.env: required production settings first, optional ones grouped below; drop the GitHub
  registry login (images are pulled anonymously); TUNNEL_TOKEN matches docker-compose.
- docker-compose.prod: mount ./secrets read-only at /run/secrets for the Enable Banking and Google
  key files; deploy.sh creates the folder; README documents generating the key in place.
- Privacy and terms pages and docs/privacy-facts.md reflect stored transactions, multiple users,
  forwarding the person's IP and browser to the bank on manual syncs, retention and deletion.
- serve.json: /terms and /privacy resolve to the .html pages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Move to Node 26 in Docker and engines; upgrade web to React 19 and TypeScript 6 with TS 6 deprecations silenced; pin direct dependency versions; use Nest SWC builder with forked typecheck; harden better-sqlite3 postinstall and run it before dev/start so native binaries match the host Node.

Co-authored-by: Cursor <cursoragent@cursor.com>
…nt and a stale key name

- docker-build.yml: a check job (lint/format, types for web, tests) for api and web; the image
  builds wait for it.
- install-better-sqlite3.cjs: blank lines the linter wants; api/package.json key order from the formatter.
- enable-banking-setup.md: the key file is enablebanking.pem, as everywhere else.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The app is stopped and started on a schedule, so a connection can be days out of date when it
comes back. Run one catch-up shortly after start instead of waiting for the first 30 minute check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…encies

The API image build took its node_modules from a published dependency image, which on a PR comes
from main and can be stale (it lacked the nest CLI), and the deps job pushed images from PRs.

- api/Dockerfile: the builder installs devDependencies itself; production dependencies come from
  the published image only when CI passes one, otherwise they are installed in the build.
- docker-build.yml: PRs skip the deps job, log in nowhere and push nothing; main and tags publish
  as before.
- api: build script calls the Nest CLI directly; better-sqlite3 12.11.1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@SamsonNegedu
SamsonNegedu merged commit 24e93c0 into main Nov 1, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant