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>
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>
- 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>
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>
From the repo-wide dead-code audit (High-confidence, re-verified against the live
tree; 0 references each):
- Delete client/components/SummaryCard.tsx — orphan superseded by the tracker's
components/tracker/SummaryCards.tsx (the one actually imported).
- Remove the unused withErrorBoundary HOC from ErrorBoundary.tsx (the
ErrorBoundary component itself stays; it's used app-wide).
- Remove 17 dead api.ts client wrappers with no caller (spendingBudgets,
upcomingBills, billAmortization, billMonthlyState, archiveBill,
deleteBillTemplate, updateSnowballPlan, aboutAdmin, exportUrl, notifMe,
saveNotifMe, profileExports, profileImportHistory, createManualTransaction,
updateTransaction, deleteTransaction, transactionMerchantMatch).
Deliberately left: StatusBadge.tsx orphan (has uncommitted parallel edits — will
remove once that lands) and the WebAuthn feature (a build-or-bin product decision,
not dead-code cleanup).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New POST /auth/logout-others keeps the current session and invalidates all
others (invalidateOtherSessions with the current session id), writes a
logout.others audit entry, and returns the count ended.
- api.logoutOthers(); a "Sign out of other devices" button in the Login History
dialog, guarded by an AlertDialog confirmation. On success: toast with the
count and refetch the history so it collapses to just this device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Header "Export" menu: Export CSV (via the existing /api/export?from&to date
range for the viewed month) and Print (window.print()). Extract the shared
downloadFile blob helper to lib/download.ts (reused by DownloadMyDataSection).
- Add api.exportRangeUrl(from, to, fmt).
- Tracker-scoped @media print CSS: hide interactive chrome (.tracker-print-hide),
keep the month title + summary + buckets, avoid splitting a row across pages.
- Header "Calendar" button deep-links to /calendar?year&month; CalendarPage now
reads those params as its initial month (falls back to today).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parseJsonSafe/upload-body → a structured ResponseBody type (typed error
envelope + index sig) instead of any, no cast churn; declineRecommendation
→ Recommendation; snowballPlans endpoint typed so the hook drops its (d: any).
Annotated the Spending settings-load catch as best-effort (Finding #8) —
cosmetic options fall back to defaults, no mount-time nag. Client 0 any's.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client: _fetch had no timeout — a hung request (server accepted the
connection but never responds) spun the UI forever with no error. Now
wrapped in AbortSignal.timeout(120s, generous enough for the slowest
legit import/sync/backup on a LAN) and both timeout and network failure
map to a clean ApiError (REQUEST_TIMEOUT / NETWORK_ERROR) so they surface
as a normal error toast instead of a freeze or raw 'Failed to fetch'.
Server: the global error handler now returns next(err) when res.headersSent,
so an error after a partial response can't throw 'cannot set headers'.
(The async-handler-hang gap is folded into the Express 5 upgrade in Phase
2.2, which forwards async rejections natively — no throwaway wrapper.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Track C (auth): User lived in @/hooks/useAuth; moved it to @/types
(re-exported from useAuth for the 7 importers) and typed me/login/
totpChallenge/hasUsers, dropping those Admin/Login casts. Left the
single-consumer non-money casts (adminUsers, version/about/privacy/
health docs) and the optimistic-UI rewrite as deliberate low-value/
high-churn stops.
Track D: global 500 handler now emits code:'INTERNAL_ERROR' so an
unexpected error carries the same {error,code} field as structured 4xx.
typecheck/lint(0 err)/build/server-193 all green; probe 17/17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promoted SnowballProjection (+details) and SnowballSettings into @/types;
wired snowball (Bill[]), snowballSettings/saveSnowballSettings, and
snowballProjection with get<T>/_fetch<T>. Removed the projection/settings/
bills casts. Plan-lifecycle SnowballPlan casts kept (component-owned type).
typecheck/lint/build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promoted SubscriptionsPage's Subscription/SubData/Recommendation/
CatalogMatch/SubTx (+ deps) into @/types and wired subscriptions/
recommendations/transaction-matches/create/match-bill with get<T>/
post<T>. Removed the (r: any) in useSubscriptionRecommendations, the 5
page casts, and an unused CatalogMatch import. typecheck/lint/build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promoted SpendingPage's local shapes into @/types (SpendingCategoryEntry,
SpendingSummary, SpendingTransaction(s), SpendingIncome*, SpendingRule,
CategoryGroup, CopyBudgetsResponse) and wired the /spending/* + category-
groups endpoints with get<T>/post<T>. Hooks return typed data; 6 casts
removed; 2 orphaned type aliases dropped. typecheck/lint/build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promoted BankTransactionsPage's local Tx/Account/Ledger/... interfaces
into @/types (BankLedgerTransaction extends BankTransaction so amounts
stay branded Cents) and wired bankTransactionsLedger/match/unmatch/
ignore/unignore/apply-merchant-match/auto-categorize with get<T>/post<T>.
useBankLedger data is now typed; all 9 as {...} casts in the page removed.
Dropped a dead Cents import. typecheck/lint/build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds Category (+ CategoryBillSummary) to @/types and wires categories/
createCategory/updateCategory. Category has no money field of its own (budgets
live on the spending endpoints); the nested per-bill total_paid is a raw SQL sum,
so it's deliberately left unbranded. With categories now typed, useSpending
Categories drops its `any` cast and dead d.categories branch.
Category, Bill, Payment, and the Tracker envelope are the cross-cutting types
used by many components — worth defining upfront. The remaining single-consumer
page responses (summary, analytics, spending, snowball, subscriptions, bank
ledger) are typed alongside their page conversions in phase B, where the exact
fields consumed are visible (more accurate than a speculative interface).
typecheck 0, build green, 48 client tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds Bill and reuses Payment domain types (money → Dollars, mirroring
serializeBill/serializePayment which spread the DB row and convert the money
columns). Wired: bills/allBills/deletedBills → Bill[], bill/createBill/updateBill
→ Bill; quickPay/createPayment/updatePayment/restorePayment → Payment. Replaced
api.ts's minimal PaymentRecord with the richer @/types Payment (usePaymentActions
still resolves payment.id). typecheck 0, build green, 48 client tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New client/types.ts domain-types module. Types the full /tracker response —
TrackerResponse { summary, bank_tracking, cashflow, rows } — with every money
field branded `Dollars` (the server serializes cents→dollars, verified against
statusService.buildTrackerRow + trackerService.getTracker + buildBankTracking +
buildSafeToSpend). api.tracker() now returns Promise<TrackerResponse>.
TrackerRow + TrackerStatus move to @/types (canonical) and are re-exported from
@/lib/trackerUtils for compat; trackerUtils' row money fields upgrade from plain
`number` to branded `Dollars` (rowThreshold -> Dollars, paymentSummary re-brands
its computed amounts via asDollars so callers can fmt() them directly). This is
the first endpoint where the money brand flows end-to-end: fetch layer -> row
helpers, and (in phase B) into the components that format them.
Nested payloads no typed consumer reads yet (summary.trend, autopay_suggestion/
_stats, sparkline) are left `unknown`, to refine when consumed. asDollars is an
identity at runtime, so behavior is unchanged: typecheck 0, lint 0 errors, build
green, 48 client tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hooks/useQueries.js -> .ts. Typed params and QueryParams-typed query builders
(QueryParams now exported from api.ts). Query result data stays unknown until
endpoint response shapes are typed.
TypeScript caught dead config: three hooks passed `cacheTime` to useQuery, which
React Query v5 renamed to `gcTime` and silently ignores — so those caches were
garbage-collected at the 5-min default instead of the intended 30 min / 2 hr.
Renamed to gcTime so the intended retention takes effect (memory-retention only;
no fetch or correctness change).
Verified: typecheck 0, lint 0 errors (47 warns), build green, 48 client tests,
17/17 e2e probe (every page renders).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
client/api.js -> api.ts. Types the fetch infrastructure: generic _fetch<T>
and get/post/put/patch/del<T> helpers, an ApiError interface (status/code/
details/data), a QueryParams type for the query-string builder, and File-typed
upload helpers. Endpoint response shapes are typed incrementally — most methods
default to Promise<unknown>; the ones consumed by typed .ts files get real
shapes (quickPay -> PaymentRecord, togglePaid -> TogglePaidResult, settings ->
settings map).
Typing togglePaid's result surfaced a latent narrowing bug in usePaymentActions:
the un-pay Undo closure read result.paymentId (number|undefined) inside a
deferred async callback where TS re-widens the if-narrowed value — fixed by
capturing the id in a const before the closure.
The 16 files importing '@/api.js' with an explicit extension were normalized to
'@/api' so Vite/TS resolve the .ts.
Verified: typecheck 0, lint 0 errors (47 warns), build green, 48 client tests,
and 17/17 e2e probe (every page renders, all API paths respond) — the central
fetch-module rename is runtime-safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>