Commit Graph

625 Commits

Author SHA1 Message Date
null 345a159288 refactor(types): type-check batch 1 — 8 tail routes (spending/auth/dataSources/status/profile/import/notifications/categories)
Dropped @ts-nocheck and annotated the fallout (~54 errors, all mechanical:
implicit-any helper/callback params + dynamic response-assembly objects
in status.cts typed `any` per the shared db.d.ts philosophy). No behavior
change — pure annotation. Zero 'possibly undefined' cases in this batch.
@ts-nocheck server count: 29 -> 21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 06:53:27 -05:00
null 25404ec4ed docs(qa): record /code-review outcome — 8 fixes + 5 backlog refactors (IMP-CODE-08..11, IMP-UX-03)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:58:41 -05:00
null a17ce13f07 fix(integrity): transaction-wrap three multi-statement writes + review cleanups
CODE_STANDARDS 'every multi-statement write runs inside db.transaction()'
hits from the review: snowball plan create (abandon-existing + insert),
webauthn disable (credential delete + flag clear — a failure between them
recreates the stale-flag state), admin password reset (hash update +
session invalidation). Cleanups: dead ApiError import (payments), dead
action: params on the four snowball transition callers, createSession
consolidated into auth.cts's top-level import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:57:10 -05:00
null 2a07e762a2 fix: four review-confirmed regressions in auth + OIDC flows
All found by the multi-angle /code-review over the branch and verified
against the installed libraries:

- 401 discriminator: the session-expiry redirect keyed off a path prefix
  (!/auth/*) and falsely logged users out on /profile/change-password
  with a wrong current password. requireAuth's no-session 401 now carries
  the distinct AUTH_REQUIRED code — the only signal api.ts dispatches
  auth:expired on; wrong-credential 401s keep AUTH_ERROR on any path.
  Test updated to pin the code-based contract on both directions.
- OIDC RFC 9207: exchangeAndVerifyTokens rebuilt the callback URL with
  only code+state, dropping the iss parameter openid-client v6 validates
  when the provider advertises support (authentik does) — every OIDC
  login would fail post-upgrade. The full callback query is now forwarded.
- OIDC http issuers: v6 enforces HTTPS on discovery/endpoints (v5
  didn't) — breaking internal http:// providers on Docker networks.
  OIDC_ALLOW_INSECURE_HTTP=true opts back in (documented in .env.example).
- 2FA downgrade: authService's webauthn fallback caught ANY challenge
  error and silently downgraded to password-only login; now only the
  stale-flag 'No registered WebAuthn credentials' case falls through,
  everything else rethrows.
- Burned-challenge retry: the server consumes the single-use 2FA
  challenge before verifying (anti-replay), so after a server rejection
  a retry can only ever return 'Challenge expired'. LoginPage now resets
  to sign-in on server failure (WebAuthn AND the pre-existing TOTP trap);
  a browser-prompt cancel keeps retry (token still live). Also lazy-loads
  @simplewebauthn/browser out of the entry bundle, matching ProfilePage.

Server 252/252, client 52/52, OIDC smoke 44/44, probe 33/33, e2e 27.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:57:07 -05:00
null a00013d778 fix(ui): restore row-status tints lost in the tailwind 4 codemod
The v4 upgrade codemod rewrote arbitrary-value opacity modifiers like
bg-emerald-500/[0.025] to bare fractional form (/2.5), but v4 emits NO
rule for fractional bare opacity — the tracker/calendar row tints and
two banner backgrounds silently disappeared (6 classes, 4 files). Found
by the code-review line-scan; invisible to the screenshot baseline
(login page only) and the text-based census. Restored the /[0.025]
arbitrary form; compiled CSS verified to emit all six selectors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:49:35 -05:00
null 044bdbe1f1 docs(qa): archive the 2026-07-10 recon+fix cycle; release v0.42.0
QA_PLAN: findings archived (only QA-B0-04 stays open as the @ts-nocheck
burn-down program, 38 -> 29 with the B0 counter); IMP-CODE-04/-06/-07
flipped to Shipped (incl. the doingmypart.jpg correction — kept, it's
referenced from HISTORY.md markdown; lesson added to the B0 dead-code
recon); B0 gained the four standing recon steps (deps freshness,
dead-code w/ markdown-content rule, ts-nocheck counter, version
coherence); B1 WebAuthn now automated via virtual authenticator; B13
gained error-shape + orphan-endpoint sweeps; Appendix C completed
(/admin/about, /admin/roadmap, /status redirect, ICS feed row);
Appendix E marked AUTOMATED by the census project; Cycle Log rows
2·recon + 2·fix; stale server.js/.js refs -> .cts.

(Reconstructed: the original recon-day plan edits were uncommitted
working-tree changes lost to a parallel session checkout — written back
here in final post-fix state.)

HISTORY.md: v0.42.0 entry (dependency modernization, census, knip
blocking, ts-nocheck starter, deploy checklist); package.json 0.42.0
(versionSync guard green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:31:18 -05:00
null 787d3cf694 chore(ops): deploy checklist + standing dependency guards (Phase 8)
- docs/DEPLOY_CHECKLIST.md: the prod-config items that only lived in
  session notes are now in the repo — TOKEN_ENCRYPTION_KEY (v2:->e2:
  migration), WEBAUTHN_RP_ID/_ORIGIN, simplefin_debug_logging off,
  cdn.sheetjs.com egress, Node-version alignment, TS7 deferral
- renovate.json: weekly grouped non-majors, dashboard-approval majors,
  xlsx (CDN-pinned) excluded, typescript capped <7 until
  typescript-eslint supports it
- .forgejo/workflows/deps-audit.yml: weekly scheduled audit that FAILS
  on high+ prod vulns + prints npm outdated — the durable QA-B0-02 fix
  that works even before Renovate is enabled on the Forgejo instance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:24:42 -05:00
null 64ab37ae39 test(e2e): census gets its own project, ordered before the mutating probes
The census baselines were captured after api.probe had already created
its adversarial bills (Zero/Big/<script>) in the scratch DB, so snapshot
content depended on spec ordering — the first census diffs were probe
artifacts, not product changes. Now: a dedicated 'census' project
(read-only, pristine seed data) runs via project dependencies before
'probe' (api.probe/webauthn mutate state), and the default UI projects
ignore the spec. Re-baselined from a prepare-db-fresh DB; probe chain
33/33 across three consecutive runs; default e2e 27 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:23:35 -05:00
null e7974cf4c0 Polish brand presentation 2026-07-10 19:49:20 -05:00
null 562da73a76 Polish product artwork and branding 2026-07-10 19:10:42 -05:00
null 92439e74e6 test(e2e): automated per-page control census (Phase 7, IMP-CODE-07)
QA_PLAN Appendix E was never filled in manually — Cycle 1 signed off on
playbooks alone, which is how the WebAuthn ghost feature hid. This spec
enumerates every visible interactive control (role + accessible name) on
all 15 authed pages and snapshots the normalized, deduped, sorted list;
a new/removed/renamed control is now a reviewable diff every probe run.
Numbers normalize to '#' (dates/amounts/counts), and a self-stabilizing
settle loop samples the control count until steady (fixed waits raced
the data queries). Probe suite: 33 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:21:46 -05:00
null db539545e3 refactor(types): drop @ts-nocheck from the 9 smallest routes (Phase 6 starter)
analytics, settings, tracker, about, calendarFeed, privacy, version,
user, matches now fully type-checked by tsconfig.server.json. Fallout
was 4 errors total: calendarFeed's res.set(object) -> per-header calls
(+ the test mock now accepts both Express set() forms), and two error
adapters gained parameter types. @ts-nocheck census: 38 -> 29 files;
the B0 recon counter ratchets this down each cycle (QA-B0-04).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:17:13 -05:00
null a31a774f95 chore: dead-code sweep — knip now clean and blocking in CI (Phase 5)
- delete root test-functional.js (imported uninstalled 'playwright' —
  could never run) and run-functional-test.js (legacy pre-Playwright)
- delete postcss.config.js + autoprefixer/postcss devDeps (tailwind v4's
  vite plugin owns the pipeline; the config had no plugins left)
- client/lib/money.ts: drop unused SUPPORTED_CURRENCIES; keep
  dollarsToCents exported + @public-tagged (API symmetry with
  centsToDollars — the sanctioned unit crossing)
- client/lib/billDrafts.ts: drop unused BillDraft type export
- move the two runtime seed JSONs (advisory filters, merchant-store
  matches; 7 MB) out of docs/ into db/data/ next to their loader;
  fresh-DB seed verified (5000+5000 rows); Dockerfile COPY . covers it
- knip.json: apply config hints, ignore the two CSS-side tailwind deps;
  npx knip exits 0 — added to CI as a BLOCKING step so unused
  files/exports/deps fail instead of rotting (this is how the WebAuthn
  ghost feature survived four QA cycles)

NOT deleted: client/public/img/doingmypart.jpg — flagged unused by the
code grep but actually referenced from HISTORY.md markdown that the
release-notes renderer serves (user catch). Lesson encoded in the QA
plan: asset-reference sweeps must include rendered markdown content.

Full ci green (252 server / 52 client / build / knip 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:15:04 -05:00
null 10eaf73e90 chore(deps): pin typescript at 6.x (4k deferred) + oidc header comment v5 -> v6
TypeScript 7 typechecks this codebase clean, but typescript-eslint (peer:
'>=4.8.4 <6.1.0') cannot parse under it — eslint hard-fails. Pinned back
to the TS 6 line per the plan's incompatibility contingency; revisit when
typescript-eslint ships TS 7 support (tracked in the QA plan backlog).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:08:06 -05:00
null 1627e75b29 chore(deps)!: openid-client 5 -> 6 (4j)
v6 replaced the Issuer/Client classes with a functional API. The four
touchpoints rewritten: discovery() + ClientSecretBasic/Post for the cached
configuration, serverMetadata() for the admin config test,
buildAuthorizationUrl() (returns URL -> .href), and authorizationCodeGrant()
with pkceCodeVerifier/expectedNonce/expectedState for the callback exchange —
same validation surface (JWKS signature, iss/aud/exp/nbf, PKCE, state, nonce),
now enforced by the library's single grant call.

scripts/test-oidc-smoke.js: 44/44. Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:05:57 -05:00
null 522eaaf101 chore(deps)!: tailwind 3 -> 4 via @tailwindcss/vite (4i)
Official codemod (run in a clean worktree; docker-owned data/ blocks it
in-tree): tailwind.config.js inlined into client/index.css as CSS-first
@theme/@utility (config file deleted — v4 auto-detects content, which
also retires the .tsx-content-glob failure class from the TS migration),
33 template files' class renames applied, tailwindcss-animate loads via
the v4 @plugin bridge. Build runs through @tailwindcss/vite; postcss
config is now plugin-free. tailwind-merge -> 3 (v4 class model).

Visual-regression gate: only diffs were a ~2px global preflight shift +
the version string (baseline predated 0.41.1); re-baselined both login
screenshots in this commit after review. webauthn.probe confined to the
probe project (it enables WebAuthn for the seeded user, racing the
parallel UI projects' password logins).

e2e 27 passed, probe 18/18, PROD SMOKE PASS, typecheck/lint/tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:03:15 -05:00
null d8365d0e22 chore(deps)!: vite 8 (rolldown) + @vitejs/plugin-react 6 (4h)
Build drops 6.5s -> 0.65s. Three breakages found by the prod-smoke gate,
all fixed:

- rolldown removed object-form manualChunks -> function form in
  vite.config.mjs (same vendor split, all @radix/@tanstack grouped)
- the 4a 'send' bump rejects bare absolute paths: SPA fallback sendFile
  and both download routes (admin backup, user-db export) now use the
  root-option form
- rolldown minifies inline HTML scripts, breaking any CSP hash approach:
  the pre-paint theme script moved to client/public/theme-init.js so
  script-src 'self' holds with no inline hash to maintain

@testing-library/dom added (peer no longer auto-installed). Server 252,
client 52, probe 18/18, PROD SMOKE: PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:53:27 -05:00
null a1b7011149 fix(standards): make the error-shape ratchet see require(); convert the 4 files it exposed
no-restricted-imports never fires on require() calls, so the ratchet was
toothless — switched to no-restricted-syntax on the require literal. It
immediately flagged four residual files whose multi-line res.status chains
the conversion greps had missed: user.cts (four err.message-at-500 leaks —
same class as QA-B13-02), calendar.cts, export.cts, and payments.cts's
DUPLICATE_SUSPECTED body (now a hand-built literal of the standard shape,
import dropped). Lint enforces the ratchet for real now; 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:53:24 -05:00
null bdff5ad932 refactor(routes/calendar): replace errorFormatter with ValidationError throws 2026-07-10 17:52:47 -05:00
null e9f52b7472 refactor(routes/user): replace errorFormatter with ApiError/ValidationError throws 2026-07-10 17:52:32 -05:00
null a325c94dd4 chore(brand): refresh SVG lockup + regenerate all PNG assets 2026-07-10 17:52:14 -05:00
null 4e3523b2e7 chore(eslint): replace no-restricted-imports with no-restricted-syntax for require() compat 2026-07-10 17:51:59 -05:00
null b76772b8eb chore(deps): eslint 10 stack — @eslint/js 10, react-hooks 7, react-refresh 0.5 (4f)
eslint 10's recommended set adds no-useless-assignment; fixed the three
real dead initial assignments it caught (version.cts updatedAt,
simplefinService claimUrl/accessUrl). Lint 0 errors; ratchet rules from
the standards batch carry over unchanged. Server 252, client 52.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:36:50 -05:00
null 8248575b1e chore(deps): low-risk majors — bcryptjs 3, sonner 2, lucide-react 1.x, concurrently 10 (4e)
bcryptjs 3 verifies existing $2a/$2b hashes (authService tests 7/7);
sonner 2 + lucide 1.x compile clean (typecheck + build); no icon renames
in our set. Server 252/252, client 52/52.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:35:39 -05:00
null af20d8de07 fix(deps)!: clear both high-severity vulns; CI audit gate raised to high
- nodemailer 8 -> 9 (GHSA-p6gq-j5cr-w38f, raw-option file-read/SSRF; we
  never use the raw option — notificationDelivery tests 13/13)
- xlsx -> official SheetJS dist 0.20.3 (prototype pollution
  GHSA-4r6h-8v6p-xvw6 + ReDoS GHSA-5pgg-2g8v-p4x9; npm registry line is
  abandoned at 0.18.5 with no fix). API-identical for our 8 call sites;
  import/export round-trip tests green. NOTE: the lockfile now pins
  https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz — CI runners and
  Docker builds need egress to cdn.sheetjs.com
- CI: npm audit --audit-level critical -> high (0 vulns as of this
  commit; highs can no longer pass silently — QA-B0-02)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:34:51 -05:00
null 94ed0b63f3 chore(deps): semver-safe update sweep (4a)
npm update across the Wanted column: Radix x11, react-query 5.101,
better-sqlite3 12.11, node-cron 4.6, express-rate-limit, framer-motion,
postcss/autoprefixer, fast-check, knip, lefthook, typescript-eslint,
vitest 4.1.10, @types/*. Full ci green (lint 0 errors, server 252,
client 52, build + PWA emit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:33:34 -05:00
null 55b2baee5f release: v0.41.1 — passkeys shipped + QA blind-spot fixes + standards unification
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:31:27 -05:00
null 88a32a983f feat(auth): passkey / security-key UI — WebAuthn ships end to end
The backend existed since 99abca9 with no way to reach it (QA-B1-01).
Now wired:

- LoginPage: requires_webauthn second step mirrors TOTP — the key prompt
  fires automatically after the password; cancel/retry + back-to-sign-in
- ProfilePage: PasskeySection (mirrors TotpSection) — enroll with a name,
  list keys, password-gated per-key remove + remove-all
- webauthnService: WEBAUTHN_ORIGIN stays authoritative; otherwise accept
  the browser's origin when its hostname equals the RP ID — fixes real
  dev/e2e enrollment where the Vite UI port differs from the API port
  (the browser already enforces RP ID ⊆ origin, so no widened trust)
- Deploy safety: WEBAUTHN_RP_ID documented in .env.example + a boot-time
  prod warning in utils/env.cts (localhost-bound keys silently fail
  behind a real domain)
- e2e/webauthn.probe.spec.js: CDP virtual authenticator drives the full
  lifecycle (enroll -> key sign-in -> password-gated removal), retiring
  Cycle 1's 'needs a human with a key' assumption. Probe suite 18/18.

Server 252/252, client 52/52, typecheck + build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:30:14 -05:00
null 68ca7cbb43 feat(standards): enforcement ratchets — error-shape + no-console; client toApiError
- eslint: no-console error on all server dirs (sanctioned exceptions:
  utils/logger.cts sink, utils/env.cts bootstrap, utils/apiError.cts
  fallback); no-restricted-imports bans errorFormatter in routes/** so
  hand-rolled standardizeError bodies can't return (transactions/import
  are the two documented exception files)
- client/api.ts: 6 copy-pasted ApiError constructions collapsed into one
  toApiError() helper
- CODE_STANDARDS.md: 'Error responses' and 'Logging' flipped target ->
  ENFORCED, with the full documented-exceptions list and the client
  error-shape/mutation-pattern conventions written down

Lint 0 errors, client 52/52, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:20:42 -05:00
null c481a5bdbf refactor(routes): throw-pattern for profile, transactions, admin, aboutAdmin
Standards-unification batch 4b (completes the route conversion program —
all 29 route files now emit errors through the canonical path or a
documented exception):

- profile.cts: full conversion; try/catch-500 wrappers removed
- transactions.cts: boundary adapter throwStandardized() rethrows the
  internal {error} parse/normalize results as ApiError (internal result
  convention unchanged); sendTransactionServiceError -> throwing
  toTransactionServiceError (4xx keep message/code, 5xx masked)
- admin.cts: user-management + bank-sync-config + rollback converted;
  coded rollback errors (NOT_APPLIED 404 / ROLLBACK_NOT_SUPPORTED 422)
  preserved as intentional messages
- aboutAdmin.cts: loose 400 converted; static no-path-disclosure 500s kept

Documented exceptions (intentional, commented inline): admin sendError
(backup download can fail mid-stream after headers sent), import.cts
sendImportError pipeline (error-id envelope), transactions bulk-unmatch
500 (carries per-item results), payments DUPLICATE_SUSPECTED 409 (carries
existing payment), calendarFeed text/plain 404 (ICS clients), authOidc
static 501/502 (rewritten wholesale in the openid-client v6 batch).

Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:18:28 -05:00
null d0ab375f9a refactor(auth): throw-pattern for auth.cts (login, TOTP, WebAuthn flows)
Standards-unification batch 4a. Login, TOTP challenge/setup/enable/disable,
change-password, and all five WebAuthn handlers converted: inline
standardizeError bodies -> ApiError factory throws; try/catch-500 wrappers
removed (Express 5 forwards async rejections; terminal handler logs + masks).
Audit logging on failure paths unchanged. AUTH_ERROR/FORBIDDEN codes and
field hints preserved (change-password current_password 401, TOTP code 401).

Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:06:08 -05:00
null c0c3c2f347 refactor(routes): throw-pattern for bills.cts (largest route, 43+ sites)
Standards-unification batch 3. All inline standardizeError bodies (404s,
year/month/history-range/merchant validation, reorder payload checks) and
the drift/merchant-rule/sync try/catch-500 wrappers converted to thrown
ApiError factories. Two more err.message-on-500 leaks killed (SYNC_ERROR
wrappers). The import-historical transaction keeps its try/catch for the
log line but rethrows a coded ApiError instead of hand-rolling the body.

billReorder harness mirrors the terminal handler. Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:00:01 -05:00
null 543e94288e refactor(routes): throw-pattern for payments + subscriptions
Standards-unification batch 2. All inline 4xx bodies and try/catch-500
wrappers converted to thrown ApiError factories; validation helpers
(parseYearMonth, getAutopaySuggestionContext, rejectTransactionLinkedPayment)
now throw. Client-visible codes preserved (TRANSACTION_PAYMENT_LOCKED,
NOT_AUTO_MATCH, NO_TRANSACTION, RECLASSIFY_ONLY_SYNC, DUPLICATE_ERROR,
NO_DEBTS). Kills five more err.message-on-500 leaks in subscriptions.cts
(search/decline/catalog/descriptor wrappers).

Two deliberate exceptions, documented inline:
- payments POST / keeps the hand-rolled DUPLICATE_SUSPECTED 409 (carries the
  'existing' payment payload the client's add-anyway flow reads)
- subscriptions /recommendations/create keeps 400-parity pass-through of
  service validation messages via an ApiError wrap

Test harnesses (paymentsRoute, transactionMatchService) mirror the terminal
handler for thrown errors. Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:53:20 -05:00
null c4219b6020 refactor(routes): throw-pattern for summary, matches, snowball, dataSources
Standards-unification batch 1 (CODE_STANDARDS 'Error responses', exemplar
categories.cts): inline res.status().json(standardizeError(...)) bodies and
try/catch-500 wrappers replaced with thrown ApiError factories; the terminal
handler formats 4xx and masks+logs 5xx. Wire shapes preserved, including
client-visible codes (ALREADY_MATCHED, DUPLICATE_MATCH, NOT_MATCHED,
NO_DEBTS, INVALID_PLAN_STATE, BANK_SYNC_DISABLED).

Also kills three more err.message-on-500 leaks in dataSources.cts (DB_ERROR
wrappers) that the QA-B13-02 grep missed; SimpleFIN errors keep their
deliberate sanitized pass-through via ApiError wraps (tokens/URLs stripped).

snowballPlanRoute.test.js harness now mirrors the terminal handler for
thrown errors (same wire shape as production). Server suite 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:44:19 -05:00
null a8f8446743 fix(routes): stop leaking raw err.message on 5xx responses
Ten call sites returned internal error text to clients on server failures.
Intentionally user-facing messages survive, but only through controlled
paths: 4xx service errors (err.status < 500) and explicit ApiError wraps
(notification test-send SMTP/push feedback, coded rollback errors). All
5xx bodies are now generic; internals go to the server log / audit trail.

- notifications.cts, spending.cts: full throw-pattern conversion
  (CODE_STANDARDS exemplar shape; terminal handler formats + masks)
- transactions.cts: bulk-unmatch per-item results mask 500-class messages
- import.cts: sendImportError user-facing branch tightened to 4xx-only
- admin.cts: bank-sync-config + migration-rollback mask unknown failures

(QA-B13-02; full conversion of the remaining routes continues in the
standards-unification phase)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:36:27 -05:00
null 9efa74e12b fix(client): redirect to login when the session expires mid-use
useAuth only checked /auth/me on mount, so a session that expired later left
the app half-alive: mutations failed with raw 401 toasts and no way back to
login. api.ts now dispatches 'auth:expired' on any 401 outside /auth/*
(under /auth/* a 401 means wrong credential input — e.g. change-password —
and must not log the user out); AuthProvider listens and clears the user, so
the existing RequireAuth redirect kicks in preserving state.from.

Test: client/api.sessionExpiry.test.ts (both directions).

(QA follow-up 1e, error-handling audit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:33:01 -05:00
null e73f51e91b fix(version): sync package.json to v0.41.0 + guard against future drift
package.json said 0.40.0 while HISTORY.md's shipped release was v0.41.0, so
/api/version, the release-notes badge (has_new_version), and the update
checker all reported the wrong version. tests/versionSync.test.js now fails
the suite whenever the two drift again.

(QA-B0-03)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:30:41 -05:00
null 73915d38ce refactor(auth): remove dead duplicate admin routes from auth.cts
The 'ADMIN ROUTES (MOUNTED AT /api/admin)' section was false — auth.cts is
mounted only at /api/auth, and the client calls the canonical copies in
admin.cts via /api/admin/*. The duplicates were unreachable dead code, and
/api/auth/has-users was additionally exposed without authentication.
Verified: no callers of /api/auth/has-users or /api/auth/users anywhere.

(QA-B17-01)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:30:04 -05:00
null 3c82c30f3c fix(auth): handle requires_webauthn in POST /login — was a 500 self-lockout
authService.login returns a requires_webauthn payload for webauthn-enabled
users, but the login route only handled requires_totp and fell through to
result.user.id (undefined) -> TypeError -> 500 on every attempt. Any user who
enabled WebAuthn via the API (reachable today) was permanently locked out.

- routes/auth.cts: return the two-step requires_webauthn payload (mirrors TOTP)
- services/authService.cts: stale-flag guard — webauthn_enabled with no usable
  credentials falls through to password login instead of hard-failing
- tests/authLoginWebauthnRoute.test.js: pins both paths + wrong-password 401

(QA-B1-01, lockout part)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:29:08 -05:00
null d9545d6fd5 refactor(categories): adopt Kysely for the /groups query (real production use)
Proves the POC in production: the category-groups list is now built with
the typed Kysely builder (category_groups added to the schema; COLLATE
NOCASE via sql``) and executed synchronously via all(). Byte-identical
behavior — categoryGroups test 4/4, suite 248/248, typecheck:server clean.
Establishes the migration recipe for rolling typed SQL out query-by-query.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:25:30 -05:00
null 35a0e4262e feat(db): Kysely typed-SQL POC — sync builder, proven parity (Pillar E)
De-risks the flagship. The app is synchronous (better-sqlite3) but Kysely
executes async, so db/kysely.cts uses Kysely as a TYPE-SAFE BUILDER only:
build the query (columns + user_id/deleted_at scoping + types checked at
compile time), `.compile()`, then run via sync helpers (all/get/run) on the
shared connection. No async ripple, no change to the sync model.

- Schema typed for `categories` (extend as adopted).
- tests/kyselyPoc.test.js proves byte-identical rows vs the raw SQL and
  that compiled SQL is parameterized (injection-safe).
- Pattern documented in CODE_STANDARDS; adopt for new/refactored queries;
  it type-enforces user-scoping (the biggest data-isolation win) and
  removes Row=any at the call site.

typecheck:server + check:server clean; 248/248 server tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:23:50 -05:00
null 0cad1626b1 test(money): property-based proof of cent-exact invariants (fast-check, Pillar F)
A financial app must prove its money math, not sample it. tests/
moneyProperties.test.js fuzzes utils/money.mts: toCents always integer;
dollars→cents→dollars == roundMoney (no drift); sumMoney is cent-exact;
integer cents round-trip; classic float-drift cases exact; invalid inputs
never yield a bogus number. 6 properties, all hold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:08:50 -05:00
null b4634a8080 feat(logging): redacted, level-gated logger; migrate all server console.* (Pillar D)
utils/logger.cts — console-compatible log.{debug,info,warn,error} with:
- level gating (LOG_LEVEL; debug in dev / info in prod), and
- REDACTION of secrets/PII (sensitive keys + bearer/JWT) so tokens,
  passwords, and encrypted values never reach the logs (financial app).
Backend is abstracted (console now, swappable to pino later).

Swept all 317 server-runtime console.* across 35 files → log.* + inserted
the import at correct scope. tests/logger.test.js pins the redaction
contract (4 tests). CODE_STANDARDS updated; raw console.* is now banned in
server code.

Verified: check:server + typecheck:server + lint clean; 240/240 server
tests; client 50/50; build; e2e probe 17/17; boot smoke logs flow through
the logger and graceful shutdown intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:05:18 -05:00
null a75ff1a466 refactor(routes): monthly-starting-amounts → throw pattern; doc exemplar
5 ad-hoc {error} validations → throw ValidationError(msg, field) (adds
proper code + field). CODE_STANDARDS references routes/categories.cts as
the canonical error-pattern example and notes converted/unconverted routes
emit identical output (terminal handler normalizes both). 236/236.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:03:52 -05:00
null 3b016d4876 refactor(routes): version → throw pattern; keep graceful update-status fallback
/history 404 → throw NotFoundError (body was already normalized); drop the
generic 500 swallow. The /update-status catch is intentional graceful
degradation (returns a valid 200 fallback) — kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:01:35 -05:00
null 59c2917e1d refactor(routes): analytics + tracker → throw pattern (batch 1)
Drop try/catch swallows + standardizeError; throw ValidationError/ApiError
(tracker preserves result.status via ApiError). Test harness updated to
simulate the terminal handler. 236/236.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:59:29 -05:00
null 26d976e272 feat(config): boot-time env validation — fail-fast + prod key warning (Pillar D)
utils/env.cts `validateEnv()` runs first in main(): exits 1 on clearly
invalid config (non-integer PORT / SESSION_CLEANUP_INTERVAL_MS, too-short
TOKEN_ENCRYPTION_KEY), and warns loudly in production when
TOKEN_ENCRYPTION_KEY is unset (secrets fall back to a DB-stored key).
Resolves the standing prod-hardening TODO. Verified: bad PORT → exit 1;
prod without key → warns but boots (health 200).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:51:04 -05:00
null 7390c458ed refactor(routes): categories → uniform Express-5 throw pattern (Pillar B template)
Establishes the canonical route error pattern (see docs/CODE_STANDARDS.md):
handlers `throw ValidationError/NotFoundError/ConflictError(...)` and let
Express 5 forward to the single terminal handler — no per-handler
try/catch swallows, no ad-hoc `res.status().json({error})`. Only the
UNIQUE-constraint → 409 mapping keeps a tight try/catch (`asConflict`).

- apiError factories NotFoundError/ConflictError now take an optional
  `field` (parity with ValidationError) so form-field info is preserved.
- routes/categories.cts fully converted; identical status/messages, now
  uniform `{ error, message, code, field }` bodies.
- Test harnesses (categoryGroups, categoryReorder) updated to simulate the
  terminal handler (thrown ApiError → formatted response).

Verified: 236/236 server tests; e2e probe 17/17; live smoke confirms
400 VALIDATION_ERROR / 404 NOT_FOUND with correct field + status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:46:32 -05:00
null aee23d6025 feat(security): CI secret-scan + dep-audit, SECURITY.md, auth-coverage test (Pillar C)
- CI now runs gitleaks (secret scanning, .gitleaks.toml allowlists
  fixtures/data/generated) and `npm audit --audit-level=critical`.
- SECURITY.md: disclosure policy, the controls in place, operational
  hardening, and the two assessed HIGH audit exceptions (nodemailer `raw`
  unused → not exploitable; xlsx confined to import w/ raw:false + limits).
- tests/securityCoverage.test.js: static invariant that every non-public
  router mount carries requireAuth (+ CSRF for authed, requireAdmin for
  admin) — a new unprotected route now fails CI. 4 tests; suite 236/236.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:40:17 -05:00
null e7196abbdf fix(security): patch react-router open-redirect (non-breaking audit fix)
`npm audit fix`: react-router/react-router-dom 6.30.3 → 6.30.4 (moderate
open-redirect via protocol-relative URL), nodemailer 8.0.9 → 8.0.11.
Verified: server 232/232, client 50/50, build green.

Remaining audit findings are assessed non-actionable/mitigated (see
SECURITY.md): nodemailer `raw` send option is not used; xlsx is confined
to the import service with raw:false + size/content-type limits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:36:18 -05:00