Compare commits

...

20 Commits

Author SHA1 Message Date
null b08c054f6a chore: QA plan recon update, dep bumps, export/server/vite tweaks (post-v0.41.0) 2026-07-10 17:47:50 -05:00
null 4294a1da8c Fix glitched brand artwork 2026-07-10 17:44:33 -05:00
null 750dfebd15 chore(deps): react-router-dom 6 -> 7 (4g)
BrowserRouter/Routes/Route/Navigate surface unchanged; typecheck + build
clean; probe suite (login, navigation, authz, reconciliation, a11y,
webauthn) 18/18 over real HTTP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:37:47 -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
62 changed files with 4390 additions and 5996 deletions

View File

@ -61,6 +61,14 @@ NODE_ENV=production
# #
# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-string-at-least-32-chars # TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-string-at-least-32-chars
# WebAuthn / passkeys Relying Party ID. REQUIRED for passkeys to work behind a
# real domain: credentials are cryptographically bound to this hostname, so the
# "localhost" default only works in local dev. Use the bare hostname users sign
# in on (no scheme, no port) — e.g. bills.example.com. Changing it later
# invalidates already-registered keys.
#
# WEBAUTHN_RP_ID=bills.example.com
# ── Bank Sync (SimpleFIN) ───────────────────────────────────────────────────── # ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
# Enable/disable bank sync from the Admin panel. Users connect their own # Enable/disable bank sync from the Admin panel. Users connect their own
# SimpleFIN Bridge from the Data page. No environment config required. # SimpleFIN Bridge from the Data page. No environment config required.

View File

@ -30,8 +30,10 @@ jobs:
| tar -xz -C /usr/local/bin gitleaks | tar -xz -C /usr/local/bin gitleaks
gitleaks detect --source=. --redact --no-banner gitleaks detect --source=. --redact --no-banner
- name: Dependency audit (fail on critical) - name: Dependency audit (fail on high or critical)
run: npm audit --omit=dev --audit-level=critical # Raised from critical after clearing the nodemailer/xlsx highs
# (QA-B0-02) — high vulns must fail CI, not pass silently.
run: npm audit --omit=dev --audit-level=high
- name: Format check (Prettier) - name: Format check (Prettier)
run: npm run format:check run: npm run format:check

View File

@ -1,5 +1,23 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.41.1
### 🔐 Passkeys / security keys — WebAuthn finally reachable
- **[Auth] Passkey & hardware-security-key sign-in shipped end to end.** The WebAuthn backend existed since v0.36 but no UI ever called it — a ghost feature (was QA-B1-01). Now: the login page runs a security-key second step that mirrors TOTP (prompt fires right after the password; cancel/retry handled), and Profile gains a **Passkeys & Security Keys** card (enroll with a name, list keys, password-gated remove / remove-all). Deploy note: set **`WEBAUTHN_RP_ID`** (see `.env.example`) — keys bind to the hostname, and a boot warning now fires if it's unset in production. Origin verification also accepts the browser's real origin when its hostname matches the RP ID, fixing enrollment behind the Vite dev/e2e proxy. Covered end to end by `e2e/webauthn.probe.spec.js` using Playwright's virtual authenticator — no human with a YubiKey required.
### 🐛 QA Fixes (2026-07-10 blind-spot recon)
- **[Auth] Login self-lockout for WebAuthn-enabled users.** `authService.login` returned a `requires_webauthn` payload, but `POST /login` only handled `requires_totp` and crashed into a 500 on every attempt — anyone who enabled WebAuthn via the API was permanently locked out. The route now returns the two-step payload, and a stale `webauthn_enabled` flag with no usable credentials falls back to password login instead of hard-failing. Test: `tests/authLoginWebauthnRoute.test.js`. (was QA-B1-01, lockout part)
- **[Client] Session expiry mid-use now redirects to login.** `useAuth` only checked `/auth/me` on mount, so an expired session left the app half-alive with raw 401 toasts. `api.ts` dispatches `auth:expired` on a 401 outside `/auth/*` (under `/auth/*` a 401 means wrong credential input and must not log you out); `AuthProvider` clears the user and `RequireAuth` redirects preserving the return path. Test: `client/api.sessionExpiry.test.ts`. (error-handling audit)
- **[API] Raw `err.message` no longer leaks on 5xx.** Ten call sites returned internal error text to clients on server failures (admin bank-sync/rollback, notification test-sends, spending, bulk unmatch, import); the conversion sweep found ten more of the same class (dataSources, subscriptions, bills). All masked now — intentional user-facing messages survive only via explicit `ApiError` wraps or 4xx service statuses. (was QA-B13-02)
- **[Auth] Dead duplicate admin routes removed from `routes/auth.cts`.** The "MOUNTED AT /api/admin" section was false — the canonical copies live in `admin.cts`; the duplicates were unreachable and additionally exposed `/api/auth/has-users` without authentication. (was QA-B17-01)
- **[Version] `package.json` synced with the release (0.40.0 → 0.41.x drift).** `/api/version`, the release-notes badge, and the update checker all read package.json and were under-reporting. `tests/versionSync.test.js` now fails the suite if package.json and the top `HISTORY.md` heading ever drift again. (was QA-B0-03)
### 🧹 QA — standards unification (throw-pattern complete + enforced)
- **[Server] All 29 route files now emit errors through the canonical throw-pattern** (`utils/apiError.cts` factories → terminal handler), finishing the program CODE_STANDARDS tracked at 6/29. Wire shapes preserved (probe suite proves it over real HTTP); client-visible codes kept (`ALREADY_MATCHED`, `DUPLICATE_SUSPECTED`, `TRANSACTION_PAYMENT_LOCKED`, `NO_DEBTS`, `INVALID_PLAN_STATE`, …); deliberate exceptions documented inline and in CODE_STANDARDS.md. Enforced from here on by an eslint `no-restricted-imports` ratchet on `routes/**`, plus `no-console` on the server (logger-only, two sanctioned bootstrap exceptions). `client/api.ts` builds every thrown error through a single `toApiError()`.
## v0.41.0 ## v0.41.0
### 🚀 Express 4 → 5 (Phase 2) — modern framework + native async error handling ### 🚀 Express 4 → 5 (Phase 2) — modern framework + native async error handling

View File

@ -0,0 +1,51 @@
// @vitest-environment jsdom
// 1e (QA follow-up) — a 401 outside /auth/* means the session died mid-use:
// api.ts must dispatch 'auth:expired' (AuthProvider clears the user and
// RequireAuth redirects). A 401 under /auth/* is "wrong credential input"
// (e.g. change-password with a bad current password) and must NOT dispatch.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { api } from '@/api';
function mock401(): typeof fetch {
return vi.fn(
async () =>
new Response(
JSON.stringify({ error: 'AUTH_ERROR', message: 'Unauthorized', code: 'AUTH_ERROR' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
},
),
) as unknown as typeof fetch;
}
describe('session-expiry dispatch on 401', () => {
let expiredEvents: number;
const onExpired = () => {
expiredEvents += 1;
};
beforeEach(() => {
expiredEvents = 0;
window.addEventListener('auth:expired', onExpired);
vi.stubGlobal('fetch', mock401());
});
afterEach(() => {
window.removeEventListener('auth:expired', onExpired);
vi.unstubAllGlobals();
});
it('dispatches auth:expired for a 401 on a data endpoint', async () => {
await expect(api.bills()).rejects.toMatchObject({ status: 401 });
expect(expiredEvents).toBe(1);
});
it('does NOT dispatch for a 401 under /auth/* (wrong credential input)', async () => {
await expect(
api.changePassword({ current_password: 'wrong', new_password: 'longenough1' }),
).rejects.toMatchObject({ status: 401 });
expect(expiredEvents).toBe(0);
});
});

View File

@ -51,6 +51,19 @@ export interface ApiError extends Error {
code?: string; code?: string;
} }
/** Build the ApiError every non-ok response throws — one shape, one place. */
function toApiError(
res: Response,
data: { message?: string; error?: string; code?: string; details?: unknown[] } | null,
): ApiError {
const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data || {};
err.details = data?.details || [];
err.code = data?.code;
return err;
}
/** Result of toggling a tracker row paid/unpaid. */ /** Result of toggling a tracker row paid/unpaid. */
export interface TogglePaidResult { export interface TogglePaidResult {
paymentId?: number; paymentId?: number;
@ -134,12 +147,14 @@ async function _fetch<T = unknown>(
_csrfFetch = null; _csrfFetch = null;
return _fetch<T>(method, path, body, true); return _fetch<T>(method, path, body, true);
} }
const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError; // Session expired mid-use: a 401 outside /auth/* means the session cookie
err.status = res.status; // died (under /auth/* a 401 is "wrong credential input" — e.g. bad current
err.data = data || {}; // password — and must NOT log the user out). AuthProvider listens and
err.details = data?.details || []; // clears the user, so RequireAuth redirects to /login preserving state.from.
err.code = data?.code; if (res.status === 401 && !path.startsWith('/auth/')) {
throw err; window.dispatchEvent(new Event('auth:expired'));
}
throw toApiError(res, data);
} }
return (data ?? {}) as T; return (data ?? {}) as T;
} }
@ -174,7 +189,13 @@ export const api = {
get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'), get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
authMode: () => get('/auth/mode'), authMode: () => get('/auth/mode'),
login: (data: Body) => login: (data: Body) =>
post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data), post<{
requires_totp?: boolean;
requires_webauthn?: boolean;
challenge_token?: string;
webauthn_options?: unknown;
user?: User;
}>('/auth/login', data),
logout: () => post('/auth/logout'), logout: () => post('/auth/logout'),
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'), logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
@ -262,12 +283,7 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; throw toApiError(res, data);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },
@ -504,12 +520,7 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; throw toApiError(res, data);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },
@ -528,12 +539,7 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; throw toApiError(res, data);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },
@ -552,12 +558,7 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; throw toApiError(res, data);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },
@ -621,12 +622,7 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; throw toApiError(res, data);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },

View File

@ -56,6 +56,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.catch(() => setUser(null)); .catch(() => setUser(null));
}, []); }, []);
// Session expired mid-use (api.ts dispatches on a 401 outside /auth/*):
// clearing the user makes RequireAuth redirect to /login with state.from.
useEffect(() => {
const onExpired = () => setUser(null);
window.addEventListener('auth:expired', onExpired);
return () => window.removeEventListener('auth:expired', onExpired);
}, []);
const logout = async () => { const logout = async () => {
await api.logout(); await api.logout();
setUser(null); setUser(null);

View File

@ -2,6 +2,7 @@ import { useState, useEffect, type ComponentType, type ReactNode } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react'; import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { startAuthentication } from '@simplewebauthn/browser';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils'; import { errMessage } from '@/lib/utils';
import { useAuth, type User } from '@/hooks/useAuth'; import { useAuth, type User } from '@/hooks/useAuth';
@ -81,6 +82,12 @@ export default function LoginPage() {
const [totpLoading, setTotpLoading] = useState(false); const [totpLoading, setTotpLoading] = useState(false);
const [useRecovery, setUseRecovery] = useState(false); const [useRecovery, setUseRecovery] = useState(false);
// WebAuthn second step — mirrors the TOTP flow: password first, then the key.
const [webauthnChallenge, setWebauthnChallenge] = useState<string | null>(null);
const [webauthnOptions, setWebauthnOptions] = useState<unknown>(null);
const [webauthnError, setWebauthnError] = useState('');
const [webauthnLoading, setWebauthnLoading] = useState(false);
const [newPw, setNewPw] = useState(''); const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState(''); const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false); const [pwLoading, setPwLoading] = useState(false);
@ -155,6 +162,14 @@ export default function LoginPage() {
setUseRecovery(false); setUseRecovery(false);
return; return;
} }
if (data.requires_webauthn) {
setWebauthnChallenge(data.challenge_token ?? null);
setWebauthnOptions(data.webauthn_options ?? null);
setWebauthnError('');
// Prompt for the key immediately — the browser dialog is the whole step.
void verifyWebauthn(data.challenge_token ?? null, data.webauthn_options ?? null);
return;
}
handlePostLogin(data.user!); handlePostLogin(data.user!);
} catch (err) { } catch (err) {
setError(errMessage(err, 'Login failed.')); setError(errMessage(err, 'Login failed.'));
@ -163,6 +178,32 @@ export default function LoginPage() {
} }
}; };
const verifyWebauthn = async (challengeToken: string | null, options: unknown) => {
if (!challengeToken || !options) {
setWebauthnError('Security-key sign-in is unavailable. Please sign in again.');
return;
}
setWebauthnError('');
setWebauthnLoading(true);
try {
const response = await startAuthentication({
optionsJSON: options as Parameters<typeof startAuthentication>[0]['optionsJSON'],
});
const data = await api.webauthnChallenge({ challenge_token: challengeToken, response });
handlePostLogin((data as { user?: User }).user!);
} catch (err) {
// NotAllowedError = user cancelled / timed out at the browser prompt.
const cancelled = (err as Error)?.name === 'NotAllowedError';
setWebauthnError(
cancelled
? 'Security key prompt was cancelled. Try again, or go back and sign in again.'
: errMessage(err, 'Security key verification failed.'),
);
} finally {
setWebauthnLoading(false);
}
};
const handleTotpSubmit = async (e: React.FormEvent) => { const handleTotpSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setTotpError(''); setTotpError('');
@ -340,8 +381,52 @@ export default function LoginPage() {
</div> </div>
</div> </div>
)} )}
{/* Sign-in card — hidden while auth mode resolves or during TOTP step */} {/* WebAuthn step — shown after password is accepted for key-protected accounts */}
{webauthnChallenge && (
<div className="surface-elevated p-8 space-y-5">
<div className="flex gap-3">
<BrandGlyph name="security" className="h-11 w-11 rounded-2xl" />
<div>
<h1 className="text-lg font-semibold">Security key</h1>
<p className="text-sm text-muted-foreground mt-1">
Confirm sign-in with your passkey or hardware security key.
</p>
</div>
</div>
{webauthnError && (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{webauthnError}
</div>
)}
<Button
type="button"
className="w-full"
disabled={webauthnLoading}
onClick={() => void verifyWebauthn(webauthnChallenge, webauthnOptions)}
>
{webauthnLoading ? 'Waiting for your key…' : 'Use security key'}
</Button>
<div className="text-center">
<button
type="button"
className="text-xs text-muted-foreground underline-offset-4 hover:underline hover:text-foreground transition-colors"
onClick={() => {
setWebauthnChallenge(null);
setWebauthnOptions(null);
setWebauthnError('');
}}
>
Back to sign in
</button>
</div>
</div>
)}
{/* Sign-in card — hidden while auth mode resolves or during a 2FA step */}
{!totpChallenge && {!totpChallenge &&
!webauthnChallenge &&
(authMode === null ? ( (authMode === null ? (
<div className="surface-elevated flex min-h-[120px] items-center justify-center p-8"> <div className="surface-elevated flex min-h-[120px] items-center justify-center p-8">
<span className="text-sm text-muted-foreground">Loading</span> <span className="text-sm text-muted-foreground">Loading</span>

View File

@ -1040,6 +1040,7 @@ function ChangePassword() {
return ( return (
<> <>
<TotpSection /> <TotpSection />
<PasskeySection />
<SectionCard <SectionCard
title="Change Password" title="Change Password"
icon={KeyRound} icon={KeyRound}
@ -1367,6 +1368,235 @@ function TotpSection() {
); );
} }
interface PasskeyCredential {
id: number;
credential_id: string;
credential_name: string;
created_at?: string;
}
function PasskeySection() {
const { singleUserMode } = useAuth();
const [enabled, setEnabled] = useState<boolean | null>(null); // null = loading
const [credentials, setCredentials] = useState<PasskeyCredential[]>([]);
const [keyName, setKeyName] = useState('');
const [naming, setNaming] = useState(false); // show the name-your-key form
const [password, setPassword] = useState('');
const [removeTarget, setRemoveTarget] = useState<'all' | string | null>(null); // credential_id or 'all'
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
if (singleUserMode) return;
api
.webauthnStatus()
.then((raw) => setEnabled(!!(raw as { enabled?: boolean }).enabled))
.catch(() => setEnabled(false));
api
.webauthnCredentials()
.then((raw) =>
setCredentials((raw as { credentials?: PasskeyCredential[] }).credentials ?? []),
)
.catch(() => setCredentials([]));
}, [singleUserMode]);
useEffect(() => {
load();
}, [load]);
if (singleUserMode) return null;
if (enabled === null) return null;
const enroll = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const { startRegistration } = await import('@simplewebauthn/browser');
const setup = (await api.webauthnSetup()) as { options?: unknown; challengeId?: string };
const response = await startRegistration({
optionsJSON: setup.options as Parameters<typeof startRegistration>[0]['optionsJSON'],
});
await api.webauthnEnable({
challengeId: setup.challengeId,
response,
credential_name: keyName.trim() || 'Security Key',
});
toast.success('Security key added.');
setNaming(false);
setKeyName('');
load();
} catch (err) {
const cancelled = (err as Error)?.name === 'NotAllowedError';
toast.error(
cancelled
? 'Security key prompt was cancelled.'
: errMessage(err, 'Failed to add security key.'),
);
} finally {
setSaving(false);
}
};
const confirmRemove = async (e: React.FormEvent) => {
e.preventDefault();
if (!removeTarget) return;
setSaving(true);
try {
if (removeTarget === 'all') {
await api.webauthnDisable({ password });
toast.success('Security keys removed.');
} else {
await api.webauthnDeleteCred(removeTarget, { password });
toast.success('Security key removed.');
}
setRemoveTarget(null);
setPassword('');
load();
} catch (err) {
toast.error(errMessage(err, 'Failed to remove security key.'));
} finally {
setSaving(false);
}
};
return (
<SectionCard
title="Passkeys & Security Keys"
icon={KeyRound}
subtitle="Phishing-resistant sign-in with a passkey, fingerprint, or hardware key. If an authenticator app is also enabled, the app code is used at sign-in."
>
<div className="px-6 py-5 space-y-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div
className={`h-2.5 w-2.5 rounded-full ${enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`}
/>
<span className="text-sm">
{enabled
? `${credentials.length} security ${credentials.length === 1 ? 'key' : 'keys'} registered`
: 'Not configured'}
</span>
</div>
<div className="flex gap-2">
{enabled && credentials.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setPassword('');
setRemoveTarget('all');
}}
>
Remove all
</Button>
)}
<Button
size="sm"
onClick={() => {
setKeyName('');
setNaming(true);
}}
disabled={saving}
>
Add key
</Button>
</div>
</div>
{credentials.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border">
{credentials.map((cred) => (
<li
key={cred.credential_id}
className="flex items-center justify-between px-4 py-2.5"
>
<div>
<div className="text-sm">{cred.credential_name || 'Security Key'}</div>
{cred.created_at && (
<div className="text-xs text-muted-foreground">
Added {new Date(cred.created_at).toLocaleDateString()}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPassword('');
setRemoveTarget(cred.credential_id);
}}
>
Remove
</Button>
</li>
))}
</ul>
)}
{naming && (
<form onSubmit={enroll} className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<label htmlFor="passkey-name" className="text-xs font-medium text-muted-foreground">
Key name
</label>
<Input
id="passkey-name"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
placeholder="e.g. YubiKey, This laptop"
maxLength={60}
autoFocus
/>
</div>
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Waiting for your key…' : 'Register key'}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setNaming(false)}>
Cancel
</Button>
</form>
)}
{removeTarget !== null && (
<form onSubmit={confirmRemove} className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<label
htmlFor="passkey-remove-password"
className="text-xs font-medium text-muted-foreground"
>
Confirm with your password to{' '}
{removeTarget === 'all' ? 'remove all keys' : 'remove this key'}
</label>
<Input
id="passkey-remove-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
/>
</div>
<Button type="submit" variant="destructive" size="sm" disabled={saving || !password}>
{saving ? 'Removing…' : 'Remove'}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setRemoveTarget(null);
setPassword('');
}}
>
Cancel
</Button>
</form>
)}
</div>
</SectionCard>
);
}
function PrivacySettings({ function PrivacySettings({
settings, settings,
onSaved, onSaved,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 220" role="img" aria-labelledby="title desc"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 220" role="img" aria-labelledby="title desc">
<title id="title">Bill Tracker logo</title> <title id="title">Bill Tracker logo</title>
<desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc> <desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc>
<defs> <defs>
@ -11,7 +11,7 @@
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.12"/> <feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.12"/>
</filter> </filter>
</defs> </defs>
<rect width="720" height="220" rx="34" fill="none"/> <rect width="760" height="220" rx="34" fill="none"/>
<g filter="url(#bt-soft-shadow)"> <g filter="url(#bt-soft-shadow)">
<rect x="50" y="111" width="25" height="53" rx="7" fill="url(#bt-trust)" opacity="0.86"/> <rect x="50" y="111" width="25" height="53" rx="7" fill="url(#bt-trust)" opacity="0.86"/>
<rect x="87" y="84" width="25" height="80" rx="7" fill="url(#bt-trust)" opacity="0.94"/> <rect x="87" y="84" width="25" height="80" rx="7" fill="url(#bt-trust)" opacity="0.94"/>
@ -19,7 +19,7 @@
<path d="M50 69 84 103 171 36" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> <path d="M50 69 84 103 171 36" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
<path d="M50 69 84 103 171 36" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/> <path d="M50 69 84 103 171 36" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/>
</g> </g>
<text x="205" y="119" fill="#101417" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="58" font-weight="760" letter-spacing="-1">Bill</text> <text x="205" y="118" fill="#101417" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="56" font-weight="700" letter-spacing="0">Bill</text>
<text x="312" y="119" fill="#1f9f62" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="58" font-weight="760" letter-spacing="-1">Tracker</text> <text x="307" y="118" fill="#1f9f62" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="56" font-weight="700" letter-spacing="0">Tracker</text>
<text x="208" y="154" fill="#5f716d" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="17" font-weight="560" letter-spacing="1.8">CALM COMMAND FOR HOUSEHOLD MONEY</text> <text x="208" y="154" fill="#5f716d" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="0">CALM COMMAND FOR HOUSEHOLD MONEY</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 220" role="img" aria-labelledby="title desc"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 220" role="img" aria-labelledby="title desc">
<title id="title">Bill Tracker logo</title> <title id="title">Bill Tracker logo</title>
<desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc> <desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc>
<defs> <defs>
@ -11,7 +11,7 @@
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.18"/> <feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.18"/>
</filter> </filter>
</defs> </defs>
<rect width="720" height="220" rx="34" fill="none"/> <rect width="760" height="220" rx="34" fill="none"/>
<g filter="url(#bt-soft-shadow)"> <g filter="url(#bt-soft-shadow)">
<rect x="50" y="111" width="25" height="53" rx="7" fill="url(#bt-trust)" opacity="0.86"/> <rect x="50" y="111" width="25" height="53" rx="7" fill="url(#bt-trust)" opacity="0.86"/>
<rect x="87" y="84" width="25" height="80" rx="7" fill="url(#bt-trust)" opacity="0.94"/> <rect x="87" y="84" width="25" height="80" rx="7" fill="url(#bt-trust)" opacity="0.94"/>
@ -19,7 +19,7 @@
<path d="M50 69 84 103 171 36" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> <path d="M50 69 84 103 171 36" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
<path d="M50 69 84 103 171 36" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/> <path d="M50 69 84 103 171 36" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/>
</g> </g>
<text x="205" y="119" fill="#f8faf9" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="58" font-weight="760" letter-spacing="-1">Bill</text> <text x="205" y="118" fill="#f8faf9" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="56" font-weight="700" letter-spacing="0">Bill</text>
<text x="312" y="119" fill="#40c878" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="58" font-weight="760" letter-spacing="-1">Tracker</text> <text x="307" y="118" fill="#40c878" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="56" font-weight="700" letter-spacing="0">Tracker</text>
<text x="208" y="154" fill="#90a39d" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="17" font-weight="560" letter-spacing="1.8">CALM COMMAND FOR HOUSEHOLD MONEY</text> <text x="208" y="154" fill="#90a39d" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="0">CALM COMMAND FOR HOUSEHOLD MONEY</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 178 KiB

View File

@ -2,14 +2,6 @@
<title id="title">Bill Tracker social preview</title> <title id="title">Bill Tracker social preview</title>
<desc id="desc">Premium calm Bill Tracker brand preview with the logo and tagline.</desc> <desc id="desc">Premium calm Bill Tracker brand preview with the logo and tagline.</desc>
<defs> <defs>
<radialGradient id="glow-a" cx="28%" cy="18%" r="58%">
<stop offset="0" stop-color="#40c878" stop-opacity="0.26"/>
<stop offset="1" stop-color="#40c878" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow-b" cx="80%" cy="80%" r="52%">
<stop offset="0" stop-color="#23b6a8" stop-opacity="0.18"/>
<stop offset="1" stop-color="#23b6a8" stop-opacity="0"/>
</radialGradient>
<linearGradient id="card" x1="150" x2="1050" y1="105" y2="525" gradientUnits="userSpaceOnUse"> <linearGradient id="card" x1="150" x2="1050" y1="105" y2="525" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1b2428"/> <stop offset="0" stop-color="#1b2428"/>
<stop offset="1" stop-color="#11181b"/> <stop offset="1" stop-color="#11181b"/>
@ -21,9 +13,10 @@
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="1200" height="630" fill="#101417"/> <rect width="1200" height="630" fill="#101417"/>
<rect width="1200" height="630" fill="url(#glow-a)"/>
<rect width="1200" height="630" fill="url(#glow-b)"/>
<rect x="92" y="72" width="1016" height="486" rx="44" fill="url(#card)" stroke="#314047"/> <rect x="92" y="72" width="1016" height="486" rx="44" fill="url(#card)" stroke="#314047"/>
<path d="M164 479h872" stroke="#26333a" stroke-width="2" stroke-linecap="round"/>
<path d="M164 448h872" stroke="#26333a" stroke-width="2" stroke-linecap="round" opacity="0.62"/>
<path d="M164 417h872" stroke="#26333a" stroke-width="2" stroke-linecap="round" opacity="0.36"/>
<g transform="translate(390 168)"> <g transform="translate(390 168)">
<rect x="0" y="126" width="32" height="70" rx="9" fill="url(#trust)" opacity="0.86"/> <rect x="0" y="126" width="32" height="70" rx="9" fill="url(#trust)" opacity="0.86"/>
<rect x="47" y="91" width="32" height="105" rx="9" fill="url(#trust)" opacity="0.94"/> <rect x="47" y="91" width="32" height="105" rx="9" fill="url(#trust)" opacity="0.94"/>
@ -31,8 +24,8 @@
<path d="M1 72 43 114 153 28" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="20"/> <path d="M1 72 43 114 153 28" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="20"/>
<path d="M1 72 43 114 153 28" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/> <path d="M1 72 43 114 153 28" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
</g> </g>
<text x="575" y="290" fill="#f8faf9" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="78" font-weight="760" letter-spacing="-1.5">Bill</text> <text x="575" y="290" fill="#f8faf9" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="76" font-weight="700" letter-spacing="0">Bill</text>
<text x="728" y="290" fill="#40c878" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="78" font-weight="760" letter-spacing="-1.5">Tracker</text> <text x="716" y="290" fill="#40c878" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="76" font-weight="700" letter-spacing="0">Tracker</text>
<text x="575" y="348" fill="#9cafaa" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="27" font-weight="560">Calm command for household money.</text> <text x="575" y="348" fill="#9cafaa" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="27" font-weight="600" letter-spacing="0">Calm command for household money.</text>
<text x="575" y="411" fill="#6f837e" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="18" font-weight="650" letter-spacing="3">SELF-HOSTED BILL PLANNING</text> <text x="575" y="411" fill="#6f837e" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="18" font-weight="700" letter-spacing="0">SELF-HOSTED BILL PLANNING</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -33,15 +33,28 @@ rolled out. Update this doc when a standard changes.
- **Wire format:** JSON, **snake_case** field names (mirrors DB columns), money as integer - **Wire format:** JSON, **snake_case** field names (mirrors DB columns), money as integer
cents. cents.
- **Error responses (target):** every error body is `{ error, message, code, field? }` with - **Error responses (enforced):** every error body is `{ error, message, code, field? }` with
the correct HTTP status. Throw the factories from `utils/apiError.cts` the correct HTTP status. Throw the factories from `utils/apiError.cts`
(`ValidationError`, `NotFoundError`, `ConflictError`, …) and let the single terminal error (`ValidationError`, `NotFoundError`, `ConflictError`, `AuthError`, `ForbiddenError`, or
handler format them — do not hand-roll `res.status().json({ error })`. Express 5 forwards `new ApiError(code, message, status, { field })` for anything else) and let the single
rejected async handlers automatically, so route bodies should not wrap everything in terminal error handler format them — do not hand-roll `res.status().json({ error })`.
`try/catch`. Clients read `message`/`code` + status. **Canonical example:** Express 5 forwards rejected async handlers automatically, so route bodies do not wrap
`routes/categories.cts`. (The terminal handler already normalizes any legacy everything in `try/catch`; a thrown `ApiError` keeps its message on the wire while any
`standardizeError`/`{ error }` body to the same shape, so converted and not-yet-converted other 5xx is masked + logged by the terminal handler (that masking is the whitelist:
routes emit identical responses — convert opportunistically.) wrap a message in `ApiError` only when it is deliberately user-facing). Clients read
`message`/`code` + status. **Canonical example:** `routes/categories.cts`.
**Enforced by:** the eslint `no-restricted-imports` ratchet on `routes/**` (all 29 route
files converted 2026-07). **Documented exceptions** (each commented at the site):
`routes/transactions.cts` (internal `{ error }` parse-result convention, rethrown at the
boundary via `throwStandardized`), `routes/import.cts` (`sendImportError` error-id
envelope), `routes/admin.cts` `sendError` (backup download can fail mid-stream after
headers are sent), payments' `DUPLICATE_SUSPECTED` 409 and transactions' bulk-unmatch 500
(both carry extra payload fields the client renders), `routes/calendarFeed.cts`
(text/plain for ICS clients).
- **Client error shape:** `client/api.ts` builds every thrown error through `toApiError()`
one construction site; on a 401 outside `/auth/*` it dispatches `auth:expired` so
`AuthProvider`/`RequireAuth` redirect to login. Mutations follow the React Query patterns
in `client/hooks/useQueries.ts` / `usePaymentActions.ts` (invalidate + toast on error).
- **Success envelope (target):** reads return the bare resource/list; mutations return - **Success envelope (target):** reads return the bare resource/list; mutations return
`{ success: true, … }`. `{ success: true, … }`.
- **Validation (target):** validate inputs with the shared validators (throwing - **Validation (target):** validate inputs with the shared validators (throwing
@ -56,7 +69,7 @@ rolled out. Update this doc when a standard changes.
- Never log secrets, tokens, or raw financial values. - Never log secrets, tokens, or raw financial values.
- Fail fast at boot on missing required config (e.g. `TOKEN_ENCRYPTION_KEY` in production). - Fail fast at boot on missing required config (e.g. `TOKEN_ENCRYPTION_KEY` in production).
## Logging (target) ## Logging (enforced)
- Use `utils/logger.cts` (`const { log } = require('.../utils/logger.cts')`) — `log.debug/info/warn/error`, - Use `utils/logger.cts` (`const { log } = require('.../utils/logger.cts')`) — `log.debug/info/warn/error`,
console-compatible. Level via `LOG_LEVEL` (default: debug in dev, info in prod). It **redacts** console-compatible. Level via `LOG_LEVEL` (default: debug in dev, info in prod). It **redacts**

View File

@ -1,14 +1,16 @@
# BillTracker — Master QA Plan (living document) # BillTracker — Master QA Plan (living document)
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-05 **Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-10
**Cycle 1: COMPLETE ✅** — all 18 batches (B0→B16 + B-UI) run; **15 findings fixed**, verified & archived (3× S2); automated re-run of existing batches clean (0 new); the added **B16** (migrations/secrets/deploy) surfaced + fixed 1 (version-check opt-out). Guard suite green. External-infra items (live TOTP/WebAuthn/OIDC, SMTP delivery, cross-browser, PWA-offline, load, container build) carried to Cycle 2 as non-blocking. **Cycle 1: COMPLETE ✅** — all 18 batches (B0→B16 + B-UI) run; **15 findings fixed**, verified & archived (3× S2); automated re-run of existing batches clean (0 new); the added **B16** (migrations/secrets/deploy) surfaced + fixed 1 (version-check opt-out). Guard suite green. External-infra items (live TOTP/WebAuthn/OIDC, SMTP delivery, cross-browser, PWA-offline, load, container build) carried to Cycle 2 as non-blocking.
**2026-07-10 blind-spot recon (pre-Cycle 2):** a dedicated deps/dead-code/coverage audit logged **6 open findings** (1× S2 — high-severity `xlsx`/`nodemailer` vulns invisible to CI's critical-only audit gate; plus version drift, the WebAuthn ghost feature, vacuous `@ts-nocheck` typecheck coverage, `err.message` 5xx leaks, dead duplicate admin routes) and **4 IMP** rows — see §2/§2.1 and the Cycle Log. B0 gained four standing recon steps so these classes can't hide again.
This is a **living, operational** QA document, not a static spec. Claude runs it, This is a **living, operational** QA document, not a static spec. Claude runs it,
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
**archiving** each fixed finding to `HISTORY.md`. Update this document whenever a **archiving** each fixed finding to `HISTORY.md`. Update this document whenever a
better approach, a new risk area, or a missed surface is discovered. better approach, a new risk area, or a missed surface is discovered.
> **The prime directive:** don't just confirm the happy path — try to *break* > **The prime directive:** don't just confirm the happy path — try to _break_
> the product. Every batch should end with the tree green, the Findings Log > the product. Every batch should end with the tree green, the Findings Log
> up to date, and any fixes archived to `HISTORY.md`. > up to date, and any fixes archived to `HISTORY.md`.
@ -30,7 +32,7 @@ better approach, a new risk area, or a missed surface is discovered.
## 0. Execution model — find, then fix, then repeat ## 0. Execution model — find, then fix, then repeat
**Separate finding from fixing.** During a QA pass we *hunt and log* — we do **not** **Separate finding from fixing.** During a QA pass we _hunt and log_ — we do **not**
fix as we go (except show-stoppers, see below). Only after the whole plan has run fix as we go (except show-stoppers, see below). Only after the whole plan has run
do we enter a dedicated **fix phase** and fix **every** logged finding. Then we run do we enter a dedicated **fix phase** and fix **every** logged finding. Then we run
the **entire** QA plan again from the top. Repeat until a full pass finds **zero** the **entire** QA plan again from the top. Repeat until a full pass finds **zero**
@ -60,7 +62,7 @@ errors. Two nested loops:
mark batch status in §1 → next batch. (No fixing here.) mark batch status in §1 → next batch. (No fixing here.)
``` ```
**Show-stopper exception.** A *show-stopper* is a finding that **blocks continued **Show-stopper exception.** A _show-stopper_ is a finding that **blocks continued
QA** — the app won't boot, you can't log in, or a page crashes so hard you can't QA** — the app won't boot, you can't log in, or a page crashes so hard you can't
test the rest of it. Only these get fixed immediately (mid-pass), because you test the rest of it. Only these get fixed immediately (mid-pass), because you
can't proceed otherwise. Log it, fix it, verify, and note it was a mid-pass fix; can't proceed otherwise. Log it, fix it, verify, and note it was a mid-pass fix;
@ -68,28 +70,30 @@ then continue the find pass. **Everything else is logged and left for Phase 2**
no matter how tempting or trivial. no matter how tempting or trivial.
**Discipline (for best results)** **Discipline (for best results)**
- **Phase 1 is log-only.** Resist fixing. A clean, complete inventory of findings beats a scattered fix-as-you-go pass and produces better batching. - **Phase 1 is log-only.** Resist fixing. A clean, complete inventory of findings beats a scattered fix-as-you-go pass and produces better batching.
- Keep each find batch tight and focused — one batch per session — so probing stays thorough. - Keep each find batch tight and focused — one batch per session — so probing stays thorough.
- **Phase 2 fixes everything**, not just S1/S2. Root-cause over surface patch; add/extend a test in `tests/` or `client/**/*.test.*` for every logic bug so it can't silently return. - **Phase 2 fixes everything**, not just S1/S2. Root-cause over surface patch; add/extend a test in `tests/` or `client/**/*.test.*` for every logic bug so it can't silently return.
- Never leave the repo red at the end of Phase 3 — `npm run ci` must be green before archiving. - Never leave the repo red at the end of Phase 3 — `npm run ci` must be green before archiving.
- Touch product behavior? Run the `/verify` skill on the affected flow before archiving. - Touch product behavior? Run the `/verify` skill on the affected flow before archiving.
- **The exit is empirical:** you're done only when an entire find pass (B0→B15) turns up zero new findings — not when you *think* it's clean. Log the cycle result in the [Cycle Log](#11-qa-cycle-log) each time. - **The exit is empirical:** you're done only when an entire find pass (B0→B15) turns up zero new findings — not when you _think_ it's clean. Log the cycle result in the [Cycle Log](#11-qa-cycle-log) each time.
- Improve THIS plan whenever a pass reveals a missed surface, a better repro, or a batch that should be reordered/split. - Improve THIS plan whenever a pass reveals a missed surface, a better repro, or a batch that should be reordered/split.
**Improvement lens (not just bug-hunting).** QA here is also about making the product **Improvement lens (not just bug-hunting).** QA here is also about making the product
*better*, not only *correct*. On every batch, in addition to logging bugs, actively _better_, not only _correct_. On every batch, in addition to logging bugs, actively
look through three improvement lenses and log what you find as **IMP** items in the look through three improvement lenses and log what you find as **IMP** items in the
[Improvement Backlog (§2.1)](#21-improvement-backlog): [Improvement Backlog (§2.1)](#21-improvement-backlog):
- **Code health & consolidation** — duplication to DRY up, dead code to delete, - **Code health & consolidation** — duplication to DRY up, dead code to delete,
overlapping modules to merge, oversized files to split, one canonical path per overlapping modules to merge, oversized files to split, one canonical path per
concern. *Consolidate only where it genuinely reduces surface area and is concern. _Consolidate only where it genuinely reduces surface area and is
behavior-preserving.* (Dedicated pass: **B17**.) behavior-preserving._ (Dedicated pass: **B17**.)
- **User experience** — friction in core flows, unclear states (empty/loading/error), - **User experience** — friction in core flows, unclear states (empty/loading/error),
weak feedback/affordances, inconsistent patterns, mobile parity. (Dedicated pass: **B18**.) weak feedback/affordances, inconsistent patterns, mobile parity. (Dedicated pass: **B18**.)
- **Information architecture / menus** — features that are buried or only reachable by - **Information architecture / menus** — features that are buried or only reachable by
URL, actions that belong in a menu (nav, overflow, context, settings groupings), and URL, actions that belong in a menu (nav, overflow, context, settings groupings), and
groupings that would make the app more discoverable. *Put things where a user would groupings that would make the app more discoverable. _Put things where a user would
look for them.* (Dedicated pass: **B18**.) look for them._ (Dedicated pass: **B18**.)
IMP items are **proposals**, not silent changes: log the candidate with a concrete IMP items are **proposals**, not silent changes: log the candidate with a concrete
recommendation, agree the direction, then implement behind a test. They **don't block recommendation, agree the direction, then implement behind a test. They **don't block
@ -106,7 +110,7 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
**Status key:** ⬜ Not started · 🔄 In progress · ✅ Done (green, findings archived) · 🔁 Needs recheck **Status key:** ⬜ Not started · 🔄 In progress · ✅ Done (green, findings archived) · 🔁 Needs recheck
| # | Batch | Primary surface | Data state | Status | Open / Fixed | | # | Batch | Primary surface | Data state | Status | Open / Fixed |
|---|-------|-----------------|-----------|--------|--------------| | ---- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | ------ | ------------ |
| B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | ✅ | 0 / 1 | | B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | ✅ | 0 / 1 |
| B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | ✅ | 0 / 0 | | B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | ✅ | 0 / 0 |
| B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ✅ | 0 / 0 | | B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ✅ | 0 / 0 |
@ -141,10 +145,10 @@ human** and were **not** exercised — they are **non-blocking** for Cycle 1 sig
carried to Cycle 2: carried to Cycle 2:
- **B1** — live TOTP enrollment, WebAuthn/passkeys (browser/OS prompts), OIDC SSO round-trip. (Password login, roles, CSRF, data-isolation, admin authz **are** covered.) - **B1** — live TOTP enrollment, WebAuthn/passkeys (browser/OS prompts), OIDC SSO round-trip. (Password login, roles, CSRF, data-isolation, admin authz **are** covered.)
- **B10** — real SMTP *delivery* (push delivery + email-HTML building/escaping **are** covered by `tests/notificationDelivery.test.js`). - **B10** — real SMTP _delivery_ (push delivery + email-HTML building/escaping **are** covered by `tests/notificationDelivery.test.js`).
- **B11** — backup create/restore on a scratch instance (authorization + last-admin guards **are** covered). - **B11** — backup create/restore on a scratch instance (authorization + last-admin guards **are** covered).
- **B14** — Firefox/Safari cross-browser, PWA install/offline, and large/stress load+perf. (axe a11y on 8 pages, XSS/escaping, and prod-bundle perf **are** covered.) - **B14** — Firefox/Safari cross-browser, PWA install/offline, and large/stress load+perf. (axe a11y on 8 pages, XSS/escaping, and prod-bundle perf **are** covered.)
- **B9** — spreadsheet/CSV import *from real files* end-to-end (money-unit handling + SQLite export→import round-trip **are** covered by tests). - **B9** — spreadsheet/CSV import _from real files_ end-to-end (money-unit handling + SQLite export→import round-trip **are** covered by tests).
### 1.1 QA Cycle Log ### 1.1 QA Cycle Log
@ -153,12 +157,13 @@ cycle is only "clean" when its **find pass logged zero findings**. Keep going
until you get a clean cycle. until you get a clean cycle.
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result | | Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
|-------|---------|----------------|-----------------|------------------|--------| | ---------------- | ---------- | -------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | 2026-07-02 | `bdbf231`→`5ffe2db` (dev) | 14 | **14 → all fixed, verified & archived** (3× S2 incl. broken "Send test push", email XSS, reconciliation family, seed 100× cents) | 🔁 Phase 2 complete — 0 open. Every batch B0→B15 (+B-UI) run; 16 QA commits; guard suite green. | | 1 | 2026-07-02 | `bdbf231`→`5ffe2db` (dev) | 14 | **14 → all fixed, verified & archived** (3× S2 incl. broken "Send test push", email XSS, reconciliation family, seed 100× cents) | 🔁 Phase 2 complete — 0 open. Every batch B0→B15 (+B-UI) run; 16 QA commits; guard suite green. |
| 1·re-run | 2026-07-02 | `5ffe2db` (dev) | **0 new** | — | ✅ **Automated re-run clean.** CI (server 109 + client 34, build), UI E2E 27, probe 16 (authz 403, Tracker↔Summary↔Analytics reconcile exactly, seed guard, a11y 8/8), prod-smoke PASS. **All 17 batches ✅ for automatable scope; external-infra residuals listed below are non-blocking and carried to Cycle 2.** | | 1·re-run | 2026-07-02 | `5ffe2db` (dev) | **0 new** | — | ✅ **Automated re-run clean.** CI (server 109 + client 34, build), UI E2E 27, probe 16 (authz 403, Tracker↔Summary↔Analytics reconcile exactly, seed guard, a11y 8/8), prod-smoke PASS. **All 17 batches ✅ for automatable scope; external-infra residuals listed below are non-blocking and carried to Cycle 2.** |
| 1·simplefin-live | 2026-07-03 | `5ffe2db` (dev) vs prod DB | **1** (QA-B5-04) | **1 → fixed, verified & archived** | 🔁 Probed a **copy of the live SimpleFIN DB** (19 MB, v1.06: 3 users, 44 bills, 1,159 txns, 19 accounts, active SimpleFIN source). Integrity checks: dedup (1159/1159 distinct), money=integer cents, no double-match, pending have provider ids, no orphan-account txns — all pass **except** 3 matched txns with NULL bill → QA-B5-04 (retention GC + `ON DELETE SET NULL`). Fixed in `cleanupService`; healing verified on a DB copy (3→0, 0 txns lost). **Also ran a real end-to-end sync** (`syncDataSource`, the Sync-button path) against the live connection off a working copy: token decrypted via db-key fallback (no env key), bridge fetch OK (2.2s), 18 accounts upserted, 145 fetched txns **skipped not duplicated**, 0 new, 1159→1159 distinct — **dedup/upsert idempotency proven on the real connection.** | | 1·simplefin-live | 2026-07-03 | `5ffe2db` (dev) vs prod DB | **1** (QA-B5-04) | **1 → fixed, verified & archived** | 🔁 Probed a **copy of the live SimpleFIN DB** (19 MB, v1.06: 3 users, 44 bills, 1,159 txns, 19 accounts, active SimpleFIN source). Integrity checks: dedup (1159/1159 distinct), money=integer cents, no double-match, pending have provider ids, no orphan-account txns — all pass **except** 3 matched txns with NULL bill → QA-B5-04 (retention GC + `ON DELETE SET NULL`). Fixed in `cleanupService`; healing verified on a DB copy (3→0, 0 txns lost). **Also ran a real end-to-end sync** (`syncDataSource`, the Sync-button path) against the live connection off a working copy: token decrypted via db-key fallback (no env key), bridge fetch OK (2.2s), 18 accounts upserted, 145 fetched txns **skipped not duplicated**, 0 new, 1159→1159 distinct — **dedup/upsert idempotency proven on the real connection.** |
| 1·data-overhaul | 2026-07-03 | `78ad63d`→`d53a64b` (dev) | **0 defects** (feature work) | — | ✅ **Data page overhaul, 7 batches, all green.** Two-pane goal layout + 5-state connection hero + `?section=` deep-linking + nav badges/health dots + lazy panes (B0B2); **new features** OFX/QFX import (B3), richer export JSON + date-range (B4), "Erase my data" danger zone (B5). Verified: server 139 tests, client 46, build; **axe on `/data` zero critical/serious** (added to `a11y.authed`); full probe suite 17/17. Section internals + the 7 SimpleFIN buttons untouched. | | 1·data-overhaul | 2026-07-03 | `78ad63d`→`d53a64b` (dev) | **0 defects** (feature work) | — | ✅ **Data page overhaul, 7 batches, all green.** Two-pane goal layout + 5-state connection hero + `?section=` deep-linking + nav badges/health dots + lazy panes (B0B2); **new features** OFX/QFX import (B3), richer export JSON + date-range (B4), "Erase my data" danger zone (B5). Verified: server 139 tests, client 46, build; **axe on `/data` zero critical/serious** (added to `a11y.authed`); full probe suite 17/17. Section internals + the 7 SimpleFIN buttons untouched. |
| 2·recon | 2026-07-10 | `d9545d6` (dev) | **6** (1× S2, 4× S3, 1× S4) + 4 IMP | 0 (find-only) | 🔄 **Blind-spot recon pass** (deps/dead-code/coverage audit, no UI batches). Logged: high-vuln deps invisible to CI's critical-only gate (QA-B0-02) · package.json 0.40.0 vs HISTORY v0.41.0 drift (QA-B0-03) · WebAuthn ghost feature — backend + wrappers, zero UI (QA-B1-01) · 38 `@ts-nocheck` files incl. all 29 routes ⇒ vacuous typecheck (QA-B0-04) · 10 raw `err.message` 5xx leaks (QA-B13-02) · dead duplicate admin routes in auth.cts (QA-B17-01). IMP: dead files/exports (2.2 MB unused image, broken legacy test scripts, knip items) · oversized pages · runtime seed JSONs in `docs/` · automated control census. **Plan updated:** stale `.js`→`.cts` refs fixed; B0 gained dependency-freshness/dead-code/ts-nocheck/version-coherence recon steps; B13 gained error-shape + orphan-endpoint sweeps; Appendix C gained `/admin/about`, `/admin/roadmap`, `/status`-redirect + ICS-feed rows; Appendix E marked never-filled. Verified-clean along the way: no SQL-injection paths found (whitelists + params), no `dangerouslySetInnerHTML`/`as any` in client, calendar feed token-gated, no data/build artifacts tracked in git, all 29 route files mounted. |
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete) **Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
@ -176,8 +181,13 @@ fixing. Keep only **Open / Fixing / Fixed** rows here. Once a finding is
**Status:** 🔴 Open → 🟡 Fixing → 🟢 Fixed (verified, awaiting archive) → then remove on 📦 Archive. **Status:** 🔴 Open → 🟡 Fixing → 🟢 Fixed (verified, awaiting archive) → then remove on 📦 Archive.
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro | | ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|----|-----|--------------------|---------|--------|---------------| | --------- | --- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| _(none — all Cycle 1 findings fixed, verified & archived to `HISTORY.md` v0.41.0)_ | | | | | | | QA-B0-02 | S2 | `package.json` deps | **Known high-severity vulns ship in prod deps and CI can't see them**: `nodemailer` <9 (raw-option file-read/SSRF, GHSA-p6gq-j5cr-w38f; fix = major bump to 9.x) and `xlsx` (prototype pollution GHSA-4r6h-8v6p-xvw6 + ReDoS GHSA-5pgg-2g8v-p4x9, **no registry fix**; it parses **user-uploaded** spreadsheets in `spreadsheetImportService`). CI audit gate is `--audit-level=critical` so high vulns pass silently. | 🔴 Open | `npm audit` (2026-07-10: 4 vulns 3 high, 1 moderate). Fix: bump nodemailer9 (check API changes at call sites in `notificationService`); for xlsx either move to the maintained SheetJS dist/`exceljs`, or document risk-acceptance + tighten import validation. Raise CI gate to `--audit-level=high` once clean. |
| QA-B0-03 | S3 | `package.json:3` vs `HISTORY.md:1` | **Version drift**: `package.json` says `0.40.0` but `HISTORY.md`'s latest release is `v0.41.0`. `/api/version`, `/api/auth/me` (`has_new_version`/release-notes badge) and `updateCheckService` all read `package.json`, so the app under-reports its version and the release-notes "new version" logic keys off the wrong value. | 🔴 Open | `grep version package.json` vs `head HISTORY.md`. Fix: bump package.json to match the shipped release; add a check (test or CI step) that `package.json` version == top `HISTORY.md` heading. |
| QA-B1-01 | S2 | `routes/auth.cts:600-824` + `auth.cts:82-111`, `services/authService.cts:86-94`, `client/api.ts:203-210` | **WebAuthn is a ghost feature — and a live self-lockout hazard**: full server implementation (routes, service, DB tables) and client API wrappers exist, but **no UI component ever calls them** (commit `99abca9` shipped backend only). Worse: `authService.login` returns `{requires_webauthn, challenge_token, webauthn_options}` when `webauthn_enabled=1`, but **POST /login has no `requires_webauthn` branch** — it falls through to `result.user.id` (undefined) → TypeError → 500 "Login failed" on **every** login attempt. The enable endpoint is reachable today (any session + CSRF via curl), so a user can permanently brick their own login; recovery requires a DB edit. | 🔴 Open | `grep -rn webauthn client --include='*.tsx'` → 0 hits; `grep -n requires_webauthn routes/auth.cts` → 0 hits. Decide: build the Login/Profile passkey UI (client work mirrors `TotpSection`; `@simplewebauthn/browser` already installed; Playwright's CDP **virtual authenticator** makes it CI-testable — it does NOT need a human/external infra as Cycle 1 assumed), **or** remove/feature-flag the server surface + dep. **Either way, fix or guard the login fall-through first** — it's a lockout bug independent of the UI decision. |
| QA-B0-04 | S3 | 38 server files (`grep -rl @ts-nocheck`) | **Server typecheck gate is vacuous where it matters most**: all 29 `routes/*.cts` + `server.cts` + `db/database.cts` + both migration modules + 4 large services carry `@ts-nocheck`, so `npm run typecheck:server` green ≠ routes type-checked. The "TS migration complete" claim is about file extensions, not checking. | 🔴 Open | Burn-down: remove `@ts-nocheck` file-by-file (routes are thin and mostly typed already — start with small routes like `version`, `privacy`, `about`); track the count in B0 each cycle (**must only go down**). |
| QA-B13-02 | S3 | `routes/admin.cts:515,550,553` · `notifications.cts:82,190` · `spending.cts:85,198` · `transactions.cts:999` · `import.cts:45` | **Raw `err.message` returned to clients on 5xx** in 10 places — leaks internal error text and breaks the standardized error shape (`standardizeError`/`ApiError`). | 🔴 Open | `grep -rn "error: err.message" routes`. Fold into the route throw-pattern standardization (28 routes remaining); where the message is intentionally user-facing (e.g. SMTP test-send feedback) whitelist it explicitly. |
| QA-B17-01 | S4 | `routes/auth.cts:530-598` | **Dead duplicate admin routes**: auth.cts's "ADMIN ROUTES (MOUNTED AT /api/admin)" section duplicates `admin.cts` (`/has-users`, GET/POST `/users`) but auth.cts is only mounted at `/api/auth` — the client calls the `/api/admin/*` copies. Side effect: `/api/auth/has-users` is reachable **unauthenticated** (minor "instance has users" disclosure). | 🔴 Open | Delete the section (and the stale comment); verify nothing calls `/api/auth/has-users` / `/api/auth/users`; keep the admin.cts copies as canonical. |
**Finding template** (paste a new row above; keep the full write-up here until archived): **Finding template** (paste a new row above; keep the full write-up here until archived):
@ -215,13 +225,17 @@ graduate to `roadmap.md`/`FUTURE.md`.
**Status:** 🔵 Noted (proposal) → 🟡 Doing → then archive to `HISTORY.md` on implement. **Status:** 🔵 Noted (proposal) → 🟡 Doing → then archive to `HISTORY.md` on implement.
| ID | Lens | Area (`file`/page) | Proposal (what & why) | Effort | Status | | ID | Lens | Area (`file`/page) | Proposal (what & why) | Effort | Status |
|----|------|--------------------|-----------------------|--------|--------| | ----------- | ---- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- |
| IMP-CODE-01 | Code | `client/lib/money.js` (+16 files) | ~~No shared client money formatter.~~ **Shipped `a15f00c`:** added `client/lib/money.js` (`formatUSD`/`formatUSDWhole`/`formatCentsUSD`); `lib/utils.fmt` delegates to it and 15 local formatters were removed. `null`/`NaN`/`-0` all handled. Test `client/lib/money.test.js`; full client suite + build green. | M | ✅ Shipped | | IMP-CODE-01 | Code | `client/lib/money.js` (+16 files) | ~~No shared client money formatter.~~ **Shipped `a15f00c`:** added `client/lib/money.js` (`formatUSD`/`formatUSDWhole`/`formatCentsUSD`); `lib/utils.fmt` delegates to it and 15 local formatters were removed. `null`/`NaN`/`-0` all handled. Test `client/lib/money.test.js`; full client suite + build green. | M | ✅ Shipped |
| IMP-CODE-02 | Code | `db/database.js` (4,174→1,297 ln) | ~~Oversized module.~~ **Shipped `7f2faea`,`026c6a5`,`12d9d4c`:** split into `subscriptionCatalogSeed.js` (data), `migrations/versionedMigrations.js` (~1,740 ln factory), `migrations/legacyReconcileMigrations.js` (~830 ln factory) — db + helpers injected, behavior byte-identical. Verified: suite 125, fresh DB 79 migrations idempotent, real prod DB no-op with data intact. Test `tests/migrationModules.test.js`. | L | ✅ Shipped | | IMP-CODE-02 | Code | `db/database.js` (4,174→1,297 ln) | ~~Oversized module.~~ **Shipped `7f2faea`,`026c6a5`,`12d9d4c`:** split into `subscriptionCatalogSeed.js` (data), `migrations/versionedMigrations.js` (~1,740 ln factory), `migrations/legacyReconcileMigrations.js` (~830 ln factory) — db + helpers injected, behavior byte-identical. Verified: suite 125, fresh DB 79 migrations idempotent, real prod DB no-op with data intact. Test `tests/migrationModules.test.js`. | L | ✅ Shipped |
| IMP-CODE-03 | Code | `services/transactionMatchState.js` | ~~Overlapping match logic.~~ **Shipped `fa24322`:** added canonical `markMatched`/`markUnmatched`/`markIgnored`; routed the 6 single-transaction transitions through it (guarded bulk sweeps keep their own queries by design). Test `tests/transactionMatchState.test.js`. | M | ✅ Shipped | | IMP-CODE-03 | Code | `services/transactionMatchState.js` | ~~Overlapping match logic.~~ **Shipped `fa24322`:** added canonical `markMatched`/`markUnmatched`/`markIgnored`; routed the 6 single-transaction transitions through it (guarded bulk sweeps keep their own queries by design). Test `tests/transactionMatchState.test.js`. | M | ✅ Shipped |
| IMP-IA-01 | IA | Sidebar · `/data` | ~~Central features under an overflow menu.~~ **Shipped `0b1c6a8`:** Data moved into the main app nav (desktop dropdown + mobile) alongside Bills/Categories/Spending; same default-admin gate preserved; removed the redundant account-dropdown entry. | S | ✅ Shipped | | IMP-IA-01 | IA | Sidebar · `/data` | ~~Central features under an overflow menu.~~ **Shipped `0b1c6a8`:** Data moved into the main app nav (desktop dropdown + mobile) alongside Bills/Categories/Spending; same default-admin gate preserved; removed the redundant account-dropdown entry. | S | ✅ Shipped |
| IMP-UX-01 | UX | Bills delete | ~~Retention isn't surfaced.~~ **Shipped `aace5a4`:** Bills shows a "Recently deleted (N)" button opening a restore dialog (amount, category, days-left); `GET /api/bills/deleted` (30-day window). Test `tests/billsDeletedRoute.test.js`. _Categories/payments could get the same treatment later._ | M | ✅ Shipped | | IMP-UX-01 | UX | Bills delete | ~~Retention isn't surfaced.~~ **Shipped `aace5a4`:** Bills shows a "Recently deleted (N)" button opening a restore dialog (amount, category, days-left); `GET /api/bills/deleted` (30-day window). Test `tests/billsDeletedRoute.test.js`. _Categories/payments could get the same treatment later._ | M | ✅ Shipped |
| IMP-UX-02 | UX | all list pages | **Audited — no gaps.** Checked all 11 main pages (Tracker/Bills/Subscriptions/Categories/Spending/Banking/Snowball/Payoff/Analytics/Summary/Calendar): each has a skeleton/`animate-pulse` **loading** state, an **empty** state, and a **rendered recoverable error** panel with a Retry/Try-again button (e.g. Summary 459-463, Calendar 899-903, BankTransactions 671-750, Payoff 276-302). No dead ends found; no change warranted. Keep as a standing B18/B14 check for new pages. | M | ✅ Audited | | IMP-UX-02 | UX | all list pages | **Audited — no gaps.** Checked all 11 main pages (Tracker/Bills/Subscriptions/Categories/Spending/Banking/Snowball/Payoff/Analytics/Summary/Calendar): each has a skeleton/`animate-pulse` **loading** state, an **empty** state, and a **rendered recoverable error** panel with a Retry/Try-again button (e.g. Summary 459-463, Calendar 899-903, BankTransactions 671-750, Payoff 276-302). No dead ends found; no change warranted. Keep as a standing B18/B14 check for new pages. | M | ✅ Audited |
| IMP-CODE-04 | Code | dead files/exports (knip + manual, 2026-07-10) | **Delete dead weight**: `client/public/img/doingmypart.jpg` (2.2 MB, referenced nowhere, copied into every build) · root `test-functional.js` (imports bare `playwright`, not a dependency — broken) + `run-functional-test.js` (legacy pre-Playwright harness) · unused exports `dollarsToCents`/`SUPPORTED_CURRENCIES` (`client/lib/money.ts`) and type `BillDraft` (`client/lib/billDrafts.ts`) · `@simplewebauthn/browser` dep (unused until QA-B1-01 is resolved — remove or keep per that decision) · apply knip.json config hints (redundant entry patterns, `types/**` ignore). | S | 🔵 Noted |
| IMP-CODE-05 | Code | `client/pages/SubscriptionsPage.tsx` (2,371 ln), `TrackerPage.tsx` (1,943 ln) | Largest two pages exceed the size BillModal was decomposed at (1,733). Same treatment: extract section components/hooks behind existing tests, behavior-preserving. SpendingPage (1,642) and BillsPage (1,586) next tier. | L | 🔵 Noted |
| IMP-CODE-06 | Code | `docs/` runtime data | `advisory_non_bill_transaction_filters_us_ms_5000.json` (4.3 MB) + `merchant_store_match_us_nems_online_5k_v0_2.json` (3.1 MB) are **runtime seed data** loaded by `db/database.cts` but live in `docs/` next to actual documentation. Move to `db/data/` (or similar) so docs stays docs and the Docker COPY surface is explicit. | S | 🔵 Noted |
| IMP-CODE-07 | Code | `e2e/` + Appendix E | **Automate the control census**: Appendix E has never been filled in manually (see Cycle-Log 2·recon). Add an e2e spec that walks each page and snapshots the list of interactive elements (role/name via `getByRole` enumeration) — a new/removed/renamed control then shows up as a reviewable diff every cycle instead of relying on a hand-maintained table. | M | 🔵 Noted |
--- ---
@ -246,10 +260,11 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
``` ```
**Rules** **Rules**
- One bullet per finding; include the old `QA-B?-??` id in parentheses for traceability. - One bullet per finding; include the old `QA-B?-??` id in parentheses for traceability.
- If a fix added/changed a test, say which (`tests/…` or `client/…test.*`). - If a fix added/changed a test, say which (`tests/…` or `client/…test.*`).
- Don't archive until the fix is verified (repro gone + `npm run ci` green). - Don't archive until the fix is verified (repro gone + `npm run ci` green).
- IMP items that were implemented are archived the same way; IMP items merely *noted* stay in the Findings Log (or graduate to `FUTURE.md`/`roadmap.md` if deferred). - IMP items that were implemented are archived the same way; IMP items merely _noted_ stay in the Findings Log (or graduate to `FUTURE.md`/`roadmap.md` if deferred).
--- ---
@ -258,7 +273,7 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
### 4.1 Running the app ### 4.1 Running the app
| Mode | Command | URL | | Mode | Command | URL |
|------|---------|-----| | -------------------------- | -------------------------------- | -------------------------------------------------- |
| Dev (API + UI, hot reload) | `npm run dev` | UI `http://localhost:5173` (proxies API → `:3000`) | | Dev (API + UI, hot reload) | `npm run dev` | UI `http://localhost:5173` (proxies API → `:3000`) |
| API only | `npm run dev:api` | `http://localhost:3000` | | API only | `npm run dev:api` | `http://localhost:3000` |
| Production build | `npm run build` then `npm start` | `http://localhost:3000` | | Production build | `npm run build` then `npm start` | `http://localhost:3000` |
@ -274,7 +289,7 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
Full functional pass across reasonable combinations; smoke (B15) across all. Full functional pass across reasonable combinations; smoke (B15) across all.
| Dimension | Values | | Dimension | Values |
|-----------|--------| | ---------- | --------------------------------------------------------------- |
| Browser | Chrome/Chromium, Firefox, Safari (WebAuthn differs per browser) | | Browser | Chrome/Chromium, Firefox, Safari (WebAuthn differs per browser) |
| Viewport | Desktop ≥1280, tablet ~768, mobile ~375 (iPhone SE), ~414 | | Viewport | Desktop ≥1280, tablet ~768, mobile ~375 (iPhone SE), ~414 |
| Theme | Light, Dark, system-follow | | Theme | Light, Dark, system-follow |
@ -285,6 +300,7 @@ Full functional pass across reasonable combinations; smoke (B15) across all.
| Data state | Empty, seeded demo, large/stress, adversarial | | Data state | Empty, seeded demo, large/stress, adversarial |
### 4.3 Accounts to prepare ### 4.3 Accounts to prepare
- `admin`, `user`, a **second** `user` (data-isolation), a single-user-mode instance (separate DB). - `admin`, `user`, a **second** `user` (data-isolation), a single-user-mode instance (separate DB).
- Demo reference: `guest / guest123` (do not run destructive flows on any shared demo server). - Demo reference: `guest / guest123` (do not run destructive flows on any shared demo server).
@ -293,11 +309,11 @@ Full functional pass across reasonable combinations; smoke (B15) across all.
Manual passes prove a button works **once**; they don't stop it regressing next cycle. The Playwright suite is the regression net — it drives real clicks in a real browser, and it's where visual-regression, axe-a11y, and fault-injection (§B14) are wired so they re-run every cycle for free. Manual passes prove a button works **once**; they don't stop it regressing next cycle. The Playwright suite is the regression net — it drives real clicks in a real browser, and it's where visual-regression, axe-a11y, and fault-injection (§B14) are wired so they re-run every cycle for free.
| Command | What it does | | Command | What it does |
|---------|--------------| | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run test:e2e` | run the E2E suite headless (boots the app via `webServer`) | | `npm run test:e2e` | run the E2E suite headless (boots the app via `webServer`) |
| `npm run test:e2e:ui` | Playwright UI mode — watch/debug interactively | | `npm run test:e2e:ui` | Playwright UI mode — watch/debug interactively |
| `npm run test:e2e:update` | re-baseline visual-regression screenshots (review the diff before committing) | | `npm run test:e2e:update` | re-baseline visual-regression screenshots (review the diff before committing) |
| `npm run smoke:prod` | **B15 production-build smoke** — builds, boots `node server.js` (dist/), drives the real artifact so the split vendor chunks are validated at runtime | | `npm run smoke:prod` | **B15 production-build smoke** — builds, boots `node server.cts` (serving dist/), drives the real artifact so the split vendor chunks are validated at runtime |
- **Setup (one-time):** `npm install` then `npx playwright install chromium`. Config: `playwright.config.js`; specs in `e2e/`. - **Setup (one-time):** `npm install` then `npx playwright install chromium`. Config: `playwright.config.js`; specs in `e2e/`.
- **Scope:** the suite is a **thin critical-path smoke**, not a replacement for the manual playbooks — it locks the happy paths (login → pay bill → skip → note → reconcile), the primitive state matrix, per-page axe scans, and page screenshots. Grow it whenever a manual pass finds a UI regression that a click-test could have caught. - **Scope:** the suite is a **thin critical-path smoke**, not a replacement for the manual playbooks — it locks the happy paths (login → pay bill → skip → note → reconcile), the primitive state matrix, per-page axe scans, and page screenshots. Grow it whenever a manual pass finds a UI regression that a click-test could have caught.
@ -349,42 +365,51 @@ Run on **every** page during its batch — don't assume a shared component behav
Each batch below is the detailed script for the matching row in [§1](#1-batch-plan--progress-tracker). Apply [§6](#6-cross-cutting-checks-every-page) throughout. Each batch below is the detailed script for the matching row in [§1](#1-batch-plan--progress-tracker). Apply [§6](#6-cross-cutting-checks-every-page) throughout.
### B0 — Baseline, tooling & coverage recon ### B0 — Baseline, tooling & coverage recon
**Run FIRST in every cycle.** This is where the plan re-syncs with reality — new **Run FIRST in every cycle.** This is where the plan re-syncs with reality — new
pages, routes, endpoints, or features added since the last cycle get discovered pages, routes, endpoints, or features added since the last cycle get discovered
and folded in **before** testing, so coverage never silently rots. and folded in **before** testing, so coverage never silently rots.
**Tooling baseline** **Tooling baseline**
- [ ] `npm run ci` — record any failing server/client test or build error as a finding (S1/S2). - [ ] `npm run ci` — record any failing server/client test or build error as a finding (S1/S2).
- [ ] `npm run check` — server syntax + build clean. - [ ] `npm run check` — server syntax + build clean.
- [ ] App boots via `npm run dev` **and** production `npm start`; note startup warnings. - [ ] App boots via `npm run dev` **and** production `npm start`; note startup warnings.
- [ ] Load the app; browser console + server logs clean on first load and first navigation. - [ ] Load the app; browser console + server logs clean on first load and first navigation.
- [ ] Confirm which auth mode / seed state the DB is in; snapshot a backup before proceeding. - [ ] Confirm which auth mode / seed state the DB is in; snapshot a backup before proceeding.
**Coverage recon — enumerate the *actual* product and diff it against this plan.** **Coverage recon — enumerate the _actual_ product and diff it against this plan.**
Run these, then compare the output to the batch playbooks (§7) and the [route map](#appendix-c--page--route--api-quick-map): Run these, then compare the output to the batch playbooks (§7) and the [route map](#appendix-c--page--route--api-quick-map):
- [ ] **Client routes**`grep -nE "<Route" client/App.tsx` — every path present here must appear in a batch playbook and Appendix C. *(The whole client is now TypeScript — `find client -name '*.jsx'` returns 0 — so recon greps target `.tsx`/`.ts`; `npm run typecheck` is a guard alongside build/lint/tests.)*
- [ ] **Client routes**`grep -nE "<Route" client/App.tsx` — every path present here must appear in a batch playbook and Appendix C. _(The whole client is now TypeScript — `find client -name '*.jsx'` returns 0 — so recon greps target `.tsx`/`.ts`; `npm run typecheck` is a guard alongside build/lint/tests.)_
- [ ] **Pages**`ls client/pages/` — every page has an owning batch. - [ ] **Pages**`ls client/pages/` — every page has an owning batch.
- [ ] **Sidebar / nav entries**`grep -nE "to:|label:|Only" client/components/layout/Sidebar.tsx` — new nav links (incl. conditional ones like `simplefinOnly`) are covered. - [ ] **Sidebar / nav entries**`grep -nE "to:|label:|Only" client/components/layout/Sidebar.tsx` — new nav links (incl. conditional ones like `simplefinOnly`) are covered.
- [ ] **API route mounts**`grep -nE "app.use\('/api" server.js` — every mounted route group is in B13's list and mapped in Appendix C. - [ ] **API route mounts**`grep -nE "require\('\./routes/" server.cts` (mounts are multi-line; grepping `app.use\('/api` alone misses ~half) — every mounted route group is in B13's list and mapped in Appendix C.
- [ ] **Services & components**`ls services/` and `ls client/components/**/` — new service/component families have a home in a playbook. - [ ] **Services & components**`ls services/` and `ls client/components/**/` — new service/component families have a home in a playbook.
- [ ] **UI primitives**`ls client/components/ui/` — every shared primitive is covered by the [B-UI](#b-ui--design-system-primitives) playbook; a new primitive gets a row there. - [ ] **UI primitives**`ls client/components/ui/` — every shared primitive is covered by the [B-UI](#b-ui--design-system-primitives) playbook; a new primitive gets a row there.
- [ ] **Middleware & workers**`ls middleware/ workers/` (+ `services/*Worker*`, `*Scheduler*`) — each is covered (csrf/rateLimiter/securityHeaders/requireAuth → B13; dailyWorker/bankSyncWorker/backupScheduler → B10). - [ ] **Middleware & workers**`ls middleware/ workers/` (+ `services/*Worker*`, `*Scheduler*`) — each is covered (csrf/rateLimiter/securityHeaders/requireAuth → B13; dailyWorker/bankSyncWorker/backupScheduler → B10).
- [ ] **Migrations & deploy** — new `db/database.js` migrations, `Dockerfile`/`docker-entrypoint.sh` changes, and `encryptionService`/`updateCheckService` behavior are covered by [B16](#b16--migrations-secrets--deployment). - [ ] **Migrations & deploy** — new migrations (`db/database.cts` + `db/migrations/versionedMigrations.cts`/`legacyReconcileMigrations.cts`), `Dockerfile`/`docker-entrypoint.sh` changes, and `encryptionService`/`updateCheckService` behavior are covered by [B16](#b16--migrations-secrets--deployment).
- [ ] **Interactive-control census (makes "every button tested" *provable*)** — for each page, enumerate every button, link, toggle/switch, checkbox, select, text/number/date/file input, tab, menu, and filter control, and record it in a per-page control checklist (template: [Appendix E](#appendix-e--per-page-control-census)). A control that isn't on a checklist hasn't been tested — the census is the completeness guarantee the batch playbooks alone don't give you. Quick starting inventory: `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages client/components` and `grep -rn "onClick=" client/pages/<Page>.tsx`. - [ ] **Interactive-control census (makes "every button tested" _provable_)** — for each page, enumerate every button, link, toggle/switch, checkbox, select, text/number/date/file input, tab, menu, and filter control, and record it in a per-page control checklist (template: [Appendix E](#appendix-e--per-page-control-census)). A control that isn't on a checklist hasn't been tested — the census is the completeness guarantee the batch playbooks alone don't give you. Quick starting inventory: `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages client/components` and `grep -rn "onClick=" client/pages/<Page>.tsx`.
- [ ] **Feature flags / conditional surfaces** — search for `Only`, `enabled`, `featureFlag`, env gates that hide/show pages; ensure each state is tested. - [ ] **Feature flags / conditional surfaces** — search for `Only`, `enabled`, `featureFlag`, env gates that hide/show pages; ensure each state is tested.
- [ ] **What changed since last cycle** — skim `git log`/`HISTORY.md` since the previous cycle's commit (see [Cycle Log](#11-qa-cycle-log)) for new features/pages. - [ ] **What changed since last cycle** — skim `git log`/`HISTORY.md` since the previous cycle's commit (see [Cycle Log](#11-qa-cycle-log)) for new features/pages.
- [ ] **Dependency freshness (added 2026-07-10)** — run `npm outdated` and `npm audit` (full, not just CI's `--audit-level=critical` gate). Log every **high+ vulnerability** and every **major-version lag on a security-relevant dep** (auth, crypto, parsers of untrusted input: `xlsx`, `nodemailer`, `openid-client`, `express`, `better-sqlite3`) as a finding. CI passing ≠ deps healthy.
- [ ] **Dead-code scan (added 2026-07-10)** — run `npx knip`; every new unused file/export/dependency is an IMP-CODE candidate. Also grep for **API wrappers with no client callers** (`client/api.ts` methods never referenced in `client/**/*.tsx`) — that's how the WebAuthn ghost feature (QA-B1-01) hid for 4 cycles: the server surface existed, tests touched it, but no user could ever reach it.
- [ ] **Type-check debt census (added 2026-07-10)**`grep -rl "@ts-nocheck" routes services db workers middleware utils server.cts | wc -l` and record the count in the Cycle Log. It must be **≤ last cycle's** (baseline 2026-07-10: **38**, incl. all 29 routes — see QA-B0-04). A green `typecheck:server` means nothing for files on this list.
- [ ] **Version coherence (added 2026-07-10)**`package.json` version == top `HISTORY.md` release heading (`/api/version`, the release-notes badge and `updateCheckService` all read package.json — see QA-B0-03).
**Update the plan (do this now, not later)** — for anything the recon surfaced that isn't already covered: **Update the plan (do this now, not later)** — for anything the recon surfaced that isn't already covered:
- [ ] Add it to the relevant batch playbook (or create a new batch and a row in the [§1 table](#1-batch-plan--progress-tracker)). - [ ] Add it to the relevant batch playbook (or create a new batch and a row in the [§1 table](#1-batch-plan--progress-tracker)).
- [ ] Add/adjust its entry in [Appendix C](#appendix-c--page--route--api-quick-map). - [ ] Add/adjust its entry in [Appendix C](#appendix-c--page--route--api-quick-map).
- [ ] Note the plan update in the [Cycle Log](#11-qa-cycle-log) row for this cycle. - [ ] Note the plan update in the [Cycle Log](#11-qa-cycle-log) row for this cycle.
- [ ] If a whole surface is *missing* from the product that the plan expected (page removed/renamed), reconcile the plan too — don't test ghosts. - [ ] If a whole surface is _missing_ from the product that the plan expected (page removed/renamed), reconcile the plan too — don't test ghosts.
### B-UI — Design-system primitives ### B-UI — Design-system primitives
**Test each shared control once, thoroughly, in isolation — a bug here breaks every page at once.** Drive them wherever they're already mounted (or a scratch page); run each against the [per-control state matrix](#6-cross-cutting-checks-every-page) × light/dark × keyboard-only. One finding row per primitive. **Test each shared control once, thoroughly, in isolation — a bug here breaks every page at once.** Drive them wherever they're already mounted (or a scratch page); run each against the [per-control state matrix](#6-cross-cutting-checks-every-page) × light/dark × keyboard-only. One finding row per primitive.
| Primitive (`client/components/ui/`) | Must verify | | Primitive (`client/components/ui/`) | Must verify |
|---|---| | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `button.tsx` | every variant (default/destructive/outline/ghost/link) + size; **disabled truly blocks click**; loading state; focus ring; Enter/Space activate | | `button.tsx` | every variant (default/destructive/outline/ghost/link) + size; **disabled truly blocks click**; loading state; focus ring; Enter/Space activate |
| `input.tsx` | text/number/password/date/search/file types; placeholder; disabled/read-only; error styling; paste/autofill; number-input rules above | | `input.tsx` | text/number/password/date/search/file types; placeholder; disabled/read-only; error styling; paste/autofill; number-input rules above |
| `select.tsx` (Radix) | opens by mouse **and** keyboard; type-ahead; long lists scroll; onChange fires in **Firefox+Safari**; disabled options; value persists; Esc closes | | `select.tsx` (Radix) | opens by mouse **and** keyboard; type-ahead; long lists scroll; onChange fires in **Firefox+Safari**; disabled options; value persists; Esc closes |
@ -405,16 +430,18 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
- [ ] Axe scan (see B14) on a page densely using primitives → zero critical violations. - [ ] Axe scan (see B14) on a page densely using primitives → zero critical violations.
### B1 — Auth & authorization ### B1 — Auth & authorization
- [ ] **Password:** valid login → correct landing (Tracker for `user`, `/admin` for default admin); wrong password → clear error, no user-enumeration timing/message difference; logout clears session; expired session redirects and preserves `state.from`; session persists across refresh. - [ ] **Password:** valid login → correct landing (Tracker for `user`, `/admin` for default admin); wrong password → clear error, no user-enumeration timing/message difference; logout clears session; expired session redirects and preserves `state.from`; session persists across refresh.
- [ ] **Rate limiting:** repeated failed logins throttled (`loginLimiter`/`loginUsernameLimiter`), clear message, resets. - [ ] **Rate limiting:** repeated failed logins throttled (`loginLimiter`/`loginUsernameLimiter`), clear message, resets.
- [ ] **TOTP:** enroll (QR + secret), code accepted, backup codes work once, login prompts for TOTP, wrong code rejected+throttled, disable requires re-auth. - [ ] **TOTP:** enroll (QR + secret), code accepted, backup codes work once, login prompts for TOTP, wrong code rejected+throttled, disable requires re-auth.
- [ ] **WebAuthn:** register/login/remove passkey in Chrome, Firefox, Safari; password fallback works. - [ ] **WebAuthn:** **currently untestable — no UI exists** (QA-B1-01: backend + `api.ts` wrappers only, no component calls them). Blocked until the passkey UI ships or the surface is removed. When it ships: register/login/remove passkey in Chrome, Firefox, Safari; password fallback works.
- [ ] **OIDC/Authentik:** SSO flow creates/links account; admin config errors surface cleanly; `oidcLimiter` throttles. - [ ] **OIDC/Authentik:** SSO flow creates/links account; admin config errors surface cleanly; `oidcLimiter` throttles.
- [ ] **Roles/guards:** `user` blocked from `/admin*`, `/status` (redirect) and admin APIs (403); default admin forced to `/admin`; single-user bypass correct but admin surfaces still protected; unauth API → 401. - [ ] **Roles/guards:** `user` blocked from `/admin*`, `/status` (redirect) and admin APIs (403); default admin forced to `/admin`; single-user bypass correct but admin surfaces still protected; unauth API → 401.
- [ ] **Data isolation (critical):** user A cannot read/modify user B's bills, payments, transactions, categories, snowball plans — test by ID enumeration on the API. - [ ] **Data isolation (critical):** user A cannot read/modify user B's bills, payments, transactions, categories, snowball plans — test by ID enumeration on the API.
- [ ] **CSRF:** state-changing request without a valid token → rejected. - [ ] **CSRF:** state-changing request without a valid token → rejected.
### B2 — Tracker (`/`) ### B2 — Tracker (`/`)
- [ ] Month nav (prev/next/jump), current month highlighted, data reloads per month. - [ ] Month nav (prev/next/jump), current month highlighted, data reloads per month.
- [ ] Bills land in correct `114` / `1531` bucket by due date; pin-due sorting works. - [ ] Bills land in correct `114` / `1531` bucket by due date; pin-due sorting works.
- [ ] Quick pay marks paid + updates balance cards/progress; undo works; no double-count. - [ ] Quick pay marks paid + updates balance cards/progress; undo works; no double-count.
@ -428,6 +455,7 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
- [ ] Editable cells autosave; Esc cancels; invalid input handled. Mobile rows equal desktop actions. Compact mode intact. - [ ] Editable cells autosave; Esc cancels; invalid input handled. Mobile rows equal desktop actions. Compact mode intact.
### B3 — Bills (`/bills`) ### B3 — Bills (`/bills`)
- [ ] Create with all fields (name, amount, due date, category, schedule, account, autopay, active range). - [ ] Create with all fields (name, amount, due date, category, schedule, account, autopay, active range).
- [ ] Edit propagates to Tracker/Summary/Calendar/Analytics; delete confirms + handles orphan payments/history. - [ ] Edit propagates to Tracker/Summary/Calendar/Analytics; delete confirms + handles orphan payments/history.
- [ ] Custom schedules (weekly/biweekly/monthly/quarterly/annual/custom): next-due & occurrences correct across month/year boundaries. - [ ] Custom schedules (weekly/biweekly/monthly/quarterly/annual/custom): next-due & occurrences correct across month/year boundaries.
@ -436,44 +464,52 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
- [ ] BillModal open/close, validation, cancel discards unsaved changes. - [ ] BillModal open/close, validation, cancel discards unsaved changes.
### B4 — Subscriptions & Categories ### B4 — Subscriptions & Categories
- [ ] Subscriptions: add/edit/delete, active/cancelled, renewal & annual→monthly normalization; totals feed Tracker/Summary/Analytics. - [ ] Subscriptions: add/edit/delete, active/cancelled, renewal & annual→monthly normalization; totals feed Tracker/Summary/Analytics.
- [ ] Catalog: browse/search, add-from-catalog pre-fills. - [ ] Catalog: browse/search, add-from-catalog pre-fills.
- [ ] Categories: create/edit/delete (in-use handled: reassign/prevent); groups create/assign/reorder (`categoryGroups`/`categoryReorder` tests); colors/icons consistent on Tracker/Spending/Analytics. - [ ] Categories: create/edit/delete (in-use handled: reassign/prevent); groups create/assign/reorder (`categoryGroups`/`categoryReorder` tests); colors/icons consistent on Tracker/Spending/Analytics.
### B5 — Reporting reconciliation ### B5 — Reporting reconciliation
- [ ] Summary totals (paid/unpaid/overdue/remaining) reconcile with Tracker for the same month; income breakdown modal matches. - [ ] Summary totals (paid/unpaid/overdue/remaining) reconcile with Tracker for the same month; income breakdown modal matches.
- [ ] Calendar plots bills/payments on correct days (**timezone**: a bill due on the 1st must not render on the 31st); day totals correct. - [ ] Calendar plots bills/payments on correct days (**timezone**: a bill due on the 1st must not render on the 31st); day totals correct.
- [ ] Analytics charts render with data AND empty (no broken SVG/`NaN` axes); period selectors update all charts; figures reconcile with Summary/Tracker; large dataset perf OK. - [ ] Analytics charts render with data AND empty (no broken SVG/`NaN` axes); period selectors update all charts; figures reconcile with Summary/Tracker; large dataset perf OK.
- [ ] Health indicators compute from real data, no crash on empty; recommendations sane. - [ ] Health indicators compute from real data, no crash on empty; recommendations sane.
### B6 — Spending (`/spending`) ### B6 — Spending (`/spending`)
- [ ] Category-group view assigned/spent/available math correct; 3-month averages correct. - [ ] Category-group view assigned/spent/available math correct; 3-month averages correct.
- [ ] Cover-overspending reallocates funds correctly and is reversible. - [ ] Cover-overspending reallocates funds correctly and is reversible.
- [ ] Safe-to-spend matches Tracker (`safeToSpend.test.js`); month nav; empty/partial months handled. - [ ] Safe-to-spend matches Tracker (`safeToSpend.test.js`); month nav; empty/partial months handled.
### B7 — Debt planning (`/snowball`, `/payoff`) ### B7 — Debt planning (`/snowball`, `/payoff`)
- [ ] Add debts (balance/APR/min); snowball vs avalanche ordering correct. - [ ] Add debts (balance/APR/min); snowball vs avalanche ordering correct.
- [ ] Projection + amortization vs a **hand-calculated** example; APR=0 and already-paid debts correct. - [ ] Projection + amortization vs a **hand-calculated** example; APR=0 and already-paid debts correct.
- [ ] Extra-payment/budget updates payoff date + total interest; chart renders; plan history saves/restores; status banner accurate. - [ ] Extra-payment/budget updates payoff date + total interest; chart renders; plan history saves/restores; status banner accurate.
- [ ] Edge: single debt, many debts, `$0` debt, negative/absurd inputs rejected. - [ ] Edge: single debt, many debts, `$0` debt, negative/absurd inputs rejected.
### B8 — Banking (`/bank-transactions`) ### B8 — Banking (`/bank-transactions`)
- [ ] Ledger loads/virtualizes/filters (date/account/amount/merchant/status). - [ ] Ledger loads/virtualizes/filters (date/account/amount/merchant/status).
- [ ] Transaction matching (match/unmatch), auto-match review approve/reject, no double-match (`transactionMatchService.test.js`). - [ ] Transaction matching (match/unmatch), auto-match review approve/reject, no double-match (`transactionMatchService.test.js`).
- [ ] Merchant/store matching rules + confidence/duplicates; advisory non-bill filter flags/hides with override. - [ ] Merchant/store matching rules + confidence/duplicates; advisory non-bill filter flags/hides with override.
- [ ] Matched payments reflect on Tracker/ledger without double-counting; category picker persists. - [ ] Matched payments reflect on Tracker/ledger without double-counting; category picker persists.
### B9 — Data lifecycle (`/data`) ### B9 — Data lifecycle (`/data`)
- [ ] Imports: spreadsheet (XLSX/CSV) map/preview/commit, malformed rejected, dup/partial handled; transaction CSV (`csvTransactionImportService.test.js`) dedupe + parsing; SQLite user import version-checked + confirms overwrite; seed demo data safe; import history lists + rollback. - [ ] Imports: spreadsheet (XLSX/CSV) map/preview/commit, malformed rejected, dup/partial handled; transaction CSV (`csvTransactionImportService.test.js`) dedupe + parsing; SQLite user import version-checked + confirms overwrite; seed demo data safe; import history lists + rollback.
- [ ] Exports: download SQLite **round-trips** (export → fresh account → import → matches); Excel export opens uncorrupted; ICS calendar feed valid in a client AND properly **token-gated** (route mounts before auth — verify not open). - [ ] Exports: download SQLite **round-trips** (export → fresh account → import → matches); Excel export opens uncorrupted; ICS calendar feed valid in a client AND properly **token-gated** (route mounts before auth — verify not open).
- [ ] Backups: manual + scheduled restorable on a scratch instance; permissions not world-readable; old backups pruned (`backupAndCleanup.test.js`). - [ ] Backups: manual + scheduled restorable on a scratch instance; permissions not world-readable; old backups pruned (`backupAndCleanup.test.js`).
### B10 — Notifications & workers ### B10 — Notifications & workers
- [ ] Each channel (email/SMTP, ntfy, Gotify, Discord, Telegram): test message delivers; bad token/URL → clear error, logged, no secret leak. - [ ] Each channel (email/SMTP, ntfy, Gotify, Discord, Telegram): test message delivers; bad token/URL → clear error, logged, no secret leak.
- [ ] Reminders fire at configured lead time for upcoming/overdue; no duplicates; paid/skipped excluded; respects per-user prefs. - [ ] Reminders fire at configured lead time for upcoming/overdue; no duplicates; paid/skipped excluded; respects per-user prefs.
- [ ] Workers: `dailyWorker`, `bankSyncWorker` (interval + guardrails), `backupScheduler` run on schedule; errors caught/logged, don't crash server, next run unblocked. - [ ] Workers: `dailyWorker`, `bankSyncWorker` (interval + guardrails), `backupScheduler` run on schedule; errors caught/logged, don't crash server, next run unblocked.
### B11 — Admin panel (`/admin`) ### B11 — Admin panel (`/admin`)
- [ ] Onboarding wizard completes without a broken state. - [ ] Onboarding wizard completes without a broken state.
- [ ] Users table: add/edit-role/reset-pw/disable/delete; **cannot remove the last admin**. - [ ] Users table: add/edit-role/reset-pw/disable/delete; **cannot remove the last admin**.
- [ ] Login mode switch single↔multi verified live, no lockout; auth-methods enable/disable + bad config surfaced. - [ ] Login mode switch single↔multi verified live, no lockout; auth-methods enable/disable + bad config surfaced.
@ -482,23 +518,29 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
- [ ] Privacy admin edits reflect on public `/privacy`; system status metrics/versions/jobs accurate (`statusService.test.js`); admin actions rate-limited + audited (`auditService` — spot-check log). - [ ] Privacy admin edits reflect on public `/privacy`; system status metrics/versions/jobs accurate (`statusService.test.js`); admin actions rate-limited + audited (`auditService` — spot-check log).
### B12 — Settings, Profile & global UI ### B12 — Settings, Profile & global UI
- [ ] Settings: theme (light/dark/system) persists; notification prefs save + reflect in B10; display/density/period/search-panel prefs persist; invalid rejected. - [ ] Settings: theme (light/dark/system) persists; notification prefs save + reflect in B10; display/density/period/search-panel prefs persist; invalid rejected.
- [ ] Profile: change password (current required, invalidates sessions), manage 2FA/passkeys, sessions revoke (`profileRoute.test.js`). - [ ] Profile: change password (current required, invalidates sessions), manage 2FA/passkeys, sessions revoke (`profileRoute.test.js`).
- [ ] Static: About (public + admin, version shown), Privacy, Release Notes (dialog once per `user`, dismiss persists), Roadmap (admin), NotFound friendly + way home. - [ ] Static: About (public + admin, version shown), Privacy, Release Notes (dialog once per `user`, dismiss persists), Roadmap (admin), NotFound friendly + way home.
- [ ] Global: command palette (`Ctrl+K`) search/keyboard/Esc, hidden for default admin; sidebar collapse/expand + mobile overlay (check overflow issue in `docs/UI_IMPROVEMENTS.md`); toasts stack/dismiss; page transitions no flash/double-fetch; theme applied before first paint. - [ ] Global: command palette (`Ctrl+K`) search/keyboard/Esc, hidden for default admin; sidebar collapse/expand + mobile overlay (check overflow issue in `docs/UI_IMPROVEMENTS.md`); toasts stack/dismiss; page transitions no flash/double-fetch; theme applied before first paint.
### B13 — API / backend direct ### B13 — API / backend direct
Route groups: `auth`, `auth/oidc`, `admin`, `tracker`, `bills`, `subscriptions`, `payments`, `data-sources`, `transactions`, `matches`, `categories`, `settings`, `user`, `calendar`, `summary`, `monthly-starting-amounts`, `analytics`, `spending`, `snowball`, `notifications`, `status`, `about`, `about-admin`, `privacy`, `version`, `profile`, `export`, `import`/`imports`. Route groups: `auth`, `auth/oidc`, `admin`, `tracker`, `bills`, `subscriptions`, `payments`, `data-sources`, `transactions`, `matches`, `categories`, `settings`, `user`, `calendar`, `summary`, `monthly-starting-amounts`, `analytics`, `spending`, `snowball`, `notifications`, `status`, `about`, `about-admin`, `privacy`, `version`, `profile`, `export`, `import`/`imports`.
- [ ] Auth: unauth → 401, wrong role → 403, right role → 200. - [ ] Auth: unauth → 401, wrong role → 403, right role → 200.
- [ ] CSRF: state-changing without valid token rejected; with token succeeds (`middleware/csrf.js`). - [ ] CSRF: state-changing without valid token rejected; with token succeeds (`middleware/csrf.cts`).
- [ ] Validation: bad/missing body → structured 4xx (`middleware/errorFormatter.js`, `utils/apiError.js`), never a raw 500 stack. - [ ] Validation: bad/missing body → structured 4xx (`middleware/errorFormatter.cts`, `utils/apiError.cts`), never a raw 500 stack.
- [ ] **Error-shape audit (added 2026-07-10):** no route returns raw `err.message` on a 5xx — `grep -rn "err.message" routes` and verify each hit is either a whitelisted user-facing message or converted to `standardizeError`/`ApiError` (QA-B13-02 found 10 leaks).
- [ ] IDOR/isolation: other user's resource by id → 403/404, no leak. - [ ] IDOR/isolation: other user's resource by id → 403/404, no leak.
- [ ] Rate limits: login/admin/export/import/OIDC limiters trigger + reset (`middleware/rateLimiter.js`). - [ ] Rate limits: login/admin/export/import/OIDC limiters trigger + reset (`middleware/rateLimiter.cts`).
- [ ] **Orphan-endpoint sweep (added 2026-07-10):** for each route file, confirm each endpoint has a caller (client `api.ts`, worker, or documented external consumer like the ICS feed). Endpoints with no caller are dead surface to delete (found: auth.cts's duplicate admin block, QA-B17-01) — and note any that are **less protected** than their canonical twin.
- [ ] Money in **integer cents** end-to-end (per `docs/cents-migration-plan.md`); API and DB agree; no float drift. - [ ] Money in **integer cents** end-to-end (per `docs/cents-migration-plan.md`); API and DB agree; no float drift.
- [ ] Idempotency: repeated create doesn't duplicate; concurrent edits resolve sanely. - [ ] Idempotency: repeated create doesn't duplicate; concurrent edits resolve sanely.
- [ ] Consistent error JSON + correct status codes; security headers present (`middleware/securityHeaders.js`); public routes (`about`/`privacy`/`version`/calendar feed) leak nothing sensitive. - [ ] Consistent error JSON + correct status codes; security headers present (`middleware/securityHeaders.cts`); public routes (`about`/`privacy`/`version`/calendar feed) leak nothing sensitive.
### B14 — Non-functional ### B14 — Non-functional
- [ ] **a11y (manual):** keyboard-only reach/operate every control, visible focus, skip-link works; screen-reader labels/roles (Radix `aria-*`); WCAG-AA contrast light+dark; modals trap+restore focus, Esc closes; errors announced not color-only. - [ ] **a11y (manual):** keyboard-only reach/operate every control, visible focus, skip-link works; screen-reader labels/roles (Radix `aria-*`); WCAG-AA contrast light+dark; modals trap+restore focus, Esc closes; errors announced not color-only.
- [ ] **a11y (automated):** run **axe-core** on every page (`@axe-core/playwright`, or `jest-axe` for component-level) — **zero critical/serious** violations; triage moderate. Wire it into the E2E suite so it re-runs every cycle, not just once. - [ ] **a11y (automated):** run **axe-core** on every page (`@axe-core/playwright`, or `jest-axe` for component-level) — **zero critical/serious** violations; triage moderate. Wire it into the E2E suite so it re-runs every cycle, not just once.
- [ ] **Visual regression:** capture a baseline screenshot per page × {desktop, mobile} × {light, dark} (Playwright `toHaveScreenshot`); diff against baseline each cycle. Every non-trivial pixel diff is either an intended change (update the baseline in the same commit) or a finding — never ignore it. This is what makes "every page looks right" repeatable instead of eyeballed. - [ ] **Visual regression:** capture a baseline screenshot per page × {desktop, mobile} × {light, dark} (Playwright `toHaveScreenshot`); diff against baseline each cycle. Every non-trivial pixel diff is either an intended change (update the baseline in the same commit) or a finding — never ignore it. This is what makes "every page looks right" repeatable instead of eyeballed.
@ -510,7 +552,9 @@ Route groups: `auth`, `auth/oidc`, `admin`, `tracker`, `bills`, `subscriptions`,
- [ ] **Timezone/locale:** non-UTC tz + DST boundary — due dates and calendar stay correct. - [ ] **Timezone/locale:** non-UTC tz + DST boundary — due dates and calendar stay correct.
### B15 — Regression & sign-off ### B15 — Regression & sign-off
Run on the **production build** (`npm start`), not dev: Run on the **production build** (`npm start`), not dev:
- [ ] `npm run ci` green. Log in as `user` and `admin`. - [ ] `npm run ci` green. Log in as `user` and `admin`.
- [ ] `npm run test:e2e` green (Playwright smoke + axe + visual-regression baselines match, §4.4). - [ ] `npm run test:e2e` green (Playwright smoke + axe + visual-regression baselines match, §4.4).
- [ ] Tracker: create bill → quick-pay → skip another → add note; reflected on Summary/Calendar/Analytics. - [ ] Tracker: create bill → quick-pay → skip another → add note; reflected on Summary/Calendar/Analytics.
@ -522,47 +566,54 @@ Run on the **production build** (`npm start`), not dev:
- [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria). - [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria).
### B16 — Migrations, secrets & deployment ### B16 — Migrations, secrets & deployment
Added Cycle 1 (previously uncovered). These run on every boot / container start and Added Cycle 1 (previously uncovered). These run on every boot / container start and
touch money columns and at-rest secrets — a bug here corrupts data or leaks/breaks touch money columns and at-rest secrets — a bug here corrupts data or leaks/breaks
secrets silently. secrets silently.
**Migrations** (`db/database.js` migration system, `scripts/migrate-db.js`, `schema_migrations`, `rollbackMigration`) **Migrations** (`db/database.cts` + `db/migrations/*.cts`, `scripts/migrate-db.js`, `schema_migrations`, `rollbackMigration`)
- [ ] **Idempotent:** boot twice on the same DB → second run applies nothing ("Skipping already applied"), no errors, no duplicate rows/columns. - [ ] **Idempotent:** boot twice on the same DB → second run applies nothing ("Skipping already applied"), no errors, no duplicate rows/columns.
- [ ] **Fresh == migrated:** a brand-new DB (schema.sql + all migrations) has the same schema as a DB migrated up from an old version — same tables/columns/indexes, money columns are **integer cents**. - [ ] **Fresh == migrated:** a brand-new DB (schema.sql + all migrations) has the same schema as a DB migrated up from an old version — same tables/columns/indexes, money columns are **integer cents**.
- [ ] **Rollback:** `rollbackMigration` on the latest migration reverts cleanly and re-applying works; partial/failed migration leaves the DB consistent (transactions per migration). - [ ] **Rollback:** `rollbackMigration` on the latest migration reverts cleanly and re-applying works; partial/failed migration leaves the DB consistent (transactions per migration).
- [ ] **Money conversions correct:** v1.03 (dollars→cents) and v1.04 (template JSON) convert exact values, no ×100 drift, run once only. - [ ] **Money conversions correct:** v1.03 (dollars→cents) and v1.04 (template JSON) convert exact values, no ×100 drift, run once only.
- [ ] Migrating a large/real DB doesn't lose or duplicate bills/payments/categories. - [ ] Migrating a large/real DB doesn't lose or duplicate bills/payments/categories.
**Encryption-key lifecycle** (`services/encryptionService.js`, `TOKEN_ENCRYPTION_KEY`, HKDF v1/v2) **Encryption-key lifecycle** (`services/encryptionService.cts`, `TOKEN_ENCRYPTION_KEY`, HKDF v1/v2)
- [ ] **Key present:** secrets (SMTP pw, OIDC secret, push tokens, login IP/UA) encrypt at rest and decrypt correctly. - [ ] **Key present:** secrets (SMTP pw, OIDC secret, push tokens, login IP/UA) encrypt at rest and decrypt correctly.
- [ ] **Key missing:** app boots; secret features degrade gracefully (no crash); confirm secrets are **not** silently stored/served in plaintext. - [ ] **Key missing:** app boots; secret features degrade gracefully (no crash); confirm secrets are **not** silently stored/served in plaintext.
- [ ] **Key rotated/wrong:** old ciphertext fails to decrypt **gracefully** (no crash, no stack leak); `safeDecrypt` fallback path is sane; re-encryption migrations (v0.770.79) behave. - [ ] **Key rotated/wrong:** old ciphertext fails to decrypt **gracefully** (no crash, no stack leak); `safeDecrypt` fallback path is sane; re-encryption migrations (v0.770.79) behave.
- [ ] Encryption key is never committed, logged, or returned in any API response. - [ ] Encryption key is never committed, logged, or returned in any API response.
**Container / deploy** (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `deploy.sh`) **Container / deploy** (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `deploy.sh`)
- [ ] Image **builds**; container **starts**; app reachable; `/api/version` responds. - [ ] Image **builds**; container **starts**; app reachable; `/api/version` responds.
- [ ] Entrypoint: creates `DATA_DIR`/`DB_DIR`/`BACKUP_DIR`, sets **`chmod 700`** (not world-readable), `chown`s to the non-root `bill` user, runs migrations when `RUN_DB_MIGRATIONS=true`. - [ ] Entrypoint: creates `DATA_DIR`/`DB_DIR`/`BACKUP_DIR`, sets **`chmod 700`** (not world-readable), `chown`s to the non-root `bill` user, runs migrations when `RUN_DB_MIGRATIONS=true`.
- [ ] Data **persists** across container restart (mounted volume); DB not re-created. - [ ] Data **persists** across container restart (mounted volume); DB not re-created.
- [ ] Runs as **non-root**; secrets come from env, not baked into the image. - [ ] Runs as **non-root**; secrets come from env, not baked into the image.
**Update check / phone-home** (`services/updateCheckService.js`) **Update check / phone-home** (`services/updateCheckService.cts`)
- [ ] Confirm the external request to `REPO_API_URL` (default `dream.scheller.ltd`) is **disclosed** (privacy page) and **opt-out-able**; it must send no user data, only fetch the latest release; failure/offline degrades silently. - [ ] Confirm the external request to `REPO_API_URL` (default `dream.scheller.ltd`) is **disclosed** (privacy page) and **opt-out-able**; it must send no user data, only fetch the latest release; failure/offline degrades silently.
**Rate-limiter completeness** (`middleware/rateLimiter.js`) — beyond B13's list **Rate-limiter completeness** (`middleware/rateLimiter.cts`) — beyond B13's list
- [ ] `backupOperationLimiter` throttles admin backup/restore/cleanup; `skipRateLimitIfNoUsers` only relaxes limits on a genuinely empty instance (first-run), never afterward. - [ ] `backupOperationLimiter` throttles admin backup/restore/cleanup; `skipRateLimitIfNoUsers` only relaxes limits on a genuinely empty instance (first-run), never afterward.
### B17 — Code health & consolidation (IMP) ### B17 — Code health & consolidation (IMP)
An **improvement** batch: hunt for ways to make the codebase smaller, clearer, and more An **improvement** batch: hunt for ways to make the codebase smaller, clearer, and more
consistent — *without changing behavior*. Every candidate is logged as an `IMP-CODE-*` consistent — _without changing behavior_. Every candidate is logged as an `IMP-CODE-*`
row in §2.1 with a concrete proposal; nothing is refactored silently. Consolidation row in §2.1 with a concrete proposal; nothing is refactored silently. Consolidation
lands only when it's behavior-preserving **and** covered by existing or added tests. lands only when it's behavior-preserving **and** covered by existing or added tests.
- [ ] **Duplication / DRY:** find logic copy-pasted across services/routes/components and - [ ] **Duplication / DRY:** find logic copy-pasted across services/routes/components and
propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.js` propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.mts`
vs inline), the `resolveDueDate` occurrence gate (must stay one implementation), vs inline), the `resolveDueDate` occurrence gate (must stay one implementation),
error-response shaping (`utils/apiError.js` vs ad-hoc), React data-fetch patterns error-response shaping (`utils/apiError.cts` vs ad-hoc), React data-fetch patterns
(repeated `useQuery` + toast + error handling → shared hooks). (repeated `useQuery` + toast + error handling → shared hooks), **duplicate route
handlers across files** (auth.cts vs admin.cts, QA-B17-01).
- [ ] **Dead / unused code:** unused exports, unreachable branches, orphaned files, - [ ] **Dead / unused code:** unused exports, unreachable branches, orphaned files,
commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover
scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first). scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first).
@ -572,8 +623,9 @@ lands only when it's behavior-preserving **and** covered by existing or added te
`bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities; `bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities;
propose a consolidation only where it removes real duplication, not just moves it. propose a consolidation only where it removes real duplication, not just moves it.
- [ ] **Oversized / low-cohesion files:** split by concern where it aids navigation - [ ] **Oversized / low-cohesion files:** split by concern where it aids navigation
(e.g. `db/database.js` is very large — migrations vs query helpers vs settings could (`db/database` was split in IMP-CODE-02; current largest: `SubscriptionsPage.tsx`
be separate modules). Propose the seams; don't split for its own sake. 2,371 ln and `TrackerPage.tsx` 1,943 ln — see IMP-CODE-05). Propose the seams;
don't split for its own sake.
- [ ] **One canonical path per concern:** cents handling, date/tz, CSRF, error shape, - [ ] **One canonical path per concern:** cents handling, date/tz, CSRF, error shape,
pagination — confirm there's a single blessed way and flag divergences. pagination — confirm there's a single blessed way and flag divergences.
- [ ] **Consistency:** naming, file layout, async patterns, import ordering; a lint rule - [ ] **Consistency:** naming, file layout, async patterns, import ordering; a lint rule
@ -584,12 +636,13 @@ lands only when it's behavior-preserving **and** covered by existing or added te
### B18 — UX & information architecture / menus (IMP) ### B18 — UX & information architecture / menus (IMP)
An **improvement** batch focused on the person using the app: is every feature An **improvement** batch focused on the person using the app: is every feature
*discoverable*, is every core flow *smooth*, and does every action live *where a user _discoverable_, is every core flow _smooth_, and does every action live _where a user
would look for it*? Candidates are logged as `IMP-UX-*` or `IMP-IA-*` in §2.1 with a would look for it_? Candidates are logged as `IMP-UX-*` or `IMP-IA-*` in §2.1 with a
concrete before/after proposal. Walk the app as a real user (both `user` and `admin`, concrete before/after proposal. Walk the app as a real user (both `user` and `admin`,
desktop and mobile, light and dark), not just as a tester. desktop and mobile, light and dark), not just as a tester.
**Information architecture & menus** **Information architecture & menus**
- [ ] **Discoverability:** is any feature buried, orphaned, or reachable only by typing a - [ ] **Discoverability:** is any feature buried, orphaned, or reachable only by typing a
URL? Everything should be reachable from the nav, a menu, or a clear in-page entry. URL? Everything should be reachable from the nav, a menu, or a clear in-page entry.
- [ ] **Navigation structure:** sidebar/nav grouping is logical; related pages sit - [ ] **Navigation structure:** sidebar/nav grouping is logical; related pages sit
@ -604,6 +657,7 @@ desktop and mobile, light and dark), not just as a tester.
two pages that do nearly the same thing — propose consolidating. two pages that do nearly the same thing — propose consolidating.
**Experience quality** **Experience quality**
- [ ] **Core-flow friction:** count the clicks for the top tasks (pay a bill, add a bill, - [ ] **Core-flow friction:** count the clicks for the top tasks (pay a bill, add a bill,
connect SimpleFIN, run a sync, import data). Propose shortcuts where a step is wasted. connect SimpleFIN, run a sync, import data). Propose shortcuts where a step is wasted.
- [ ] **States:** every list/page has a clear **empty state with a next-step CTA**, a - [ ] **States:** every list/page has a clear **empty state with a next-step CTA**, a
@ -626,7 +680,7 @@ desktop and mobile, light and dark), not just as a tester.
### Appendix A — Severity definitions ### Appendix A — Severity definitions
| Level | Definition | | Level | Definition |
|-------|------------| | --------------------- | -------------------------------------------------------------------------------------------- |
| **S1 Critical** | Data loss/corruption, security hole, crash/blank page, wrong money math, cannot log in/save. | | **S1 Critical** | Data loss/corruption, security hole, crash/blank page, wrong money math, cannot log in/save. |
| **S2 Major** | Feature broken/unusable, wrong results, broken navigation, unhandled error. | | **S2 Major** | Feature broken/unusable, wrong results, broken navigation, unhandled error. |
| **S3 Minor** | Works but wrong edge behavior, confusing UX, missing validation message. | | **S3 Minor** | Works but wrong edge behavior, confusing UX, missing validation message. |
@ -636,6 +690,7 @@ desktop and mobile, light and dark), not just as a tester.
### Appendix B — Exit / sign-off criteria ### Appendix B — Exit / sign-off criteria
A cycle is release-ready when: **(Cycle 1 — all met ✅)** A cycle is release-ready when: **(Cycle 1 — all met ✅)**
- [x] All batches B0B15 ✅ (Chromium desktop + mobile via the E2E projects; light + dark, `user` + `admin` exercised). Cross-browser Firefox/Safari carried to Cycle 2. - [x] All batches B0B15 ✅ (Chromium desktop + mobile via the E2E projects; light + dark, `user` + `admin` exercised). Cross-browser Firefox/Safari carried to Cycle 2.
- [x] B15 smoke green on the **production build** (`npm run smoke:prod`). - [x] B15 smoke green on the **production build** (`npm run smoke:prod`).
- [x] **Zero open S1/S2** in the Findings Log; S3/S4/IMP all fixed & archived. - [x] **Zero open S1/S2** in the Findings Log; S3/S4/IMP all fixed & archived.
@ -648,7 +703,7 @@ A cycle is release-ready when: **(Cycle 1 — all met ✅)**
### Appendix C — Page ↔ route ↔ API quick map ### Appendix C — Page ↔ route ↔ API quick map
| Page | Route | Primary API | | Page | Route | Primary API |
|------|-------|-------------| | ------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Tracker | `/` | `/api/tracker`, `/api/bills`, `/api/payments`, `/api/monthly-starting-amounts` | | Tracker | `/` | `/api/tracker`, `/api/bills`, `/api/payments`, `/api/monthly-starting-amounts` |
| Calendar | `/calendar` | `/api/calendar` | | Calendar | `/calendar` | `/api/calendar` |
| Summary | `/summary` | `/api/summary` | | Summary | `/summary` | `/api/summary` |
@ -663,22 +718,30 @@ A cycle is release-ready when: **(Cycle 1 — all met ✅)**
| Settings | `/settings` | `/api/settings`, `/api/notifications` | | Settings | `/settings` | `/api/settings`, `/api/notifications` |
| Profile | `/profile` | `/api/profile`, `/api/user` | | Profile | `/profile` | `/api/profile`, `/api/user` |
| Data | `/data` | `/api/import`, `/api/export`, `/api/data-sources` | | Data | `/data` | `/api/import`, `/api/export`, `/api/data-sources` |
| Admin | `/admin`, `/admin/status` | `/api/admin`, `/api/status`, `/api/about-admin` | | Admin | `/admin`, `/admin/status` (`/status` → redirect), `/admin/about`, `/admin/roadmap` | `/api/admin`, `/api/status`, `/api/about-admin` |
| About / Privacy / Release Notes / Roadmap | `/about`, `/privacy`, `/release-notes`, `/roadmap` | `/api/about`, `/api/privacy`, `/api/version` | | About / Privacy / Release Notes / Roadmap | `/about`, `/privacy`, `/release-notes`, `/roadmap` (admin-gated) | `/api/about`, `/api/privacy`, `/api/version` |
| Calendar feed (no page — external ICS consumers) | — | `/api/calendar/feed.ics?token=…` (token-gated, mounted before auth) |
### Appendix D — Reference docs ### Appendix D — Reference docs
`SECURITY_AUDIT.md` (security depth) · `docs/UI_IMPROVEMENTS.md` (known UI issues) · `docs/cents-migration-plan.md` (money-as-cents) · `docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md` (sync limits) · `docs/CSRF-SPA-Setup.md`, `docs/RATE_LIMITING_ENHANCEMENT.md` (security middleware) · `REVIEW.md`, `DEVELOPMENT_LOG.md`, `roadmap.md`, `FUTURE.md` (context/known gaps) · `HISTORY.md` (changelog / fix archive) · `playwright.config.js` + `e2e/` (automated E2E/visual/a11y harness, §4.4). `SECURITY_AUDIT.md` (security depth) · `docs/UI_IMPROVEMENTS.md` (known UI issues) · `docs/cents-migration-plan.md` (money-as-cents) · `docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md` (sync limits) · `docs/CSRF-SPA-Setup.md`, `docs/RATE_LIMITING_ENHANCEMENT.md` (security middleware) · `REVIEW.md`, `DEVELOPMENT_LOG.md`, `roadmap.md`, `FUTURE.md` (context/known gaps) · `HISTORY.md` (changelog / fix archive) · `playwright.config.js` + `e2e/` (automated E2E/visual/a11y harness, §4.4).
### Appendix E — Per-page control census ### Appendix E — Per-page control census
The completeness ledger behind "every button, textbox, slider is right." Fill one table **per page** during [B0](#b0--baseline-tooling--coverage-recon) and check every control off during that page's batch. A control not listed here is a control not tested. Build the starting list with `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages/<Page>.tsx` + `grep -n "onClick=\|<Button\|<Select\|<Switch\|<Checkbox" client/pages/<Page>.tsx`. The completeness ledger behind "every button, textbox, slider is right." Fill one table **per page** during [B0](#b0--baseline-tooling--coverage-recon) and check every control off during that page's batch. A control not listed here is a control not tested.
> ⚠ **Status (2026-07-10): no census has ever been filled in** — Cycle 1 signed off on
> playbooks alone. Either fill these during Cycle 2's B0, or (preferred) implement the
> automated control-census e2e spec (IMP-CODE-07) and let the diff be the ledger.
> Density for planning: SpendingPage has 28 `onClick` handlers, Tracker 25,
> Subscriptions 23, Bills 22, Banking 18 — the manual version is a multi-day job. Build the starting list with `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages/<Page>.tsx` + `grep -n "onClick=\|<Button\|<Select\|<Switch\|<Checkbox" client/pages/<Page>.tsx`.
**Template** (copy per page): **Template** (copy per page):
| Control | Type | Expected action | States checked (default/focus/disabled/error/loading) | Keyboard | Result | | Control | Type | Expected action | States checked (default/focus/disabled/error/loading) | Keyboard | Result |
|---------|------|-----------------|-------------------------------------------------------|----------|--------| | ----------------------- | ------ | ------------------------------------------------------ | ----------------------------------------------------- | --------- | --------------- |
| *e.g.* Quick-pay button | button | marks bill paid, updates balance cards, undo available | default ✓ · disabled-while-saving ✓ | Enter ✓ | ✅ / finding id | | _e.g._ Quick-pay button | button | marks bill paid, updates balance cards, undo available | default ✓ · disabled-while-saving ✓ | Enter ✓ | ✅ / finding id |
| *e.g.* Amount input | number | per-month override, cents only, no wheel-scroll change | default ✓ · error-on-letters ✓ | Tab/Esc ✓ | ✅ / finding id | | _e.g._ Amount input | number | per-month override, cents only, no wheel-scroll change | default ✓ · error-on-letters ✓ | Tab/Esc ✓ | ✅ / finding id |
**Pages to census** (from `client/pages/`, keep in sync with [Appendix C](#appendix-c--page--route--api-quick-map)): Tracker, Calendar, Summary, Bills, Subscriptions, SubscriptionCatalog, Categories, Health, Analytics, Spending, Snowball, Payoff, BankTransactions, Data, Settings, Profile, Admin, Status, About, Privacy, ReleaseNotes, Roadmap, Login, NotFound — plus the shared **Sidebar/command-palette/header** chrome once. **Pages to census** (from `client/pages/`, keep in sync with [Appendix C](#appendix-c--page--route--api-quick-map)): Tracker, Calendar, Summary, Bills, Subscriptions, SubscriptionCatalog, Categories, Health, Analytics, Spending, Snowball, Payoff, BankTransactions, Data, Settings, Profile, Admin, Status, About, Privacy, ReleaseNotes, Roadmap, Login, NotFound — plus the shared **Sidebar/command-palette/header** chrome once.
</content> </content>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,76 @@
// WebAuthn passkey flow (QA-B1-01) — driven end-to-end with Playwright's CDP
// virtual authenticator, which retires Cycle 1's "needs a human with a key"
// assumption: enroll on /profile → sign out → password + key sign-in → remove
// the key (restoring the seeded user to password-only for the other specs).
const { test, expect } = require('@playwright/test');
const { E2E_USER, E2E_PASS } = require('./constants');
// Fresh context: this spec exercises the real login flow, not saved state.
test.use({ storageState: { cookies: [], origins: [] } });
test('enroll passkey → sign in with key → remove key', async ({ page }) => {
// Virtual authenticator: an "internal" (platform) CTAP2 key that auto-approves
// presence/verification prompts, so the browser dialog never blocks CI.
const cdp = await page.context().newCDPSession(page);
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
// 1. Password sign-in.
await page.goto('/login');
await page.locator('#username').fill(E2E_USER);
await page.locator('#password').fill(E2E_PASS);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
await page
.getByRole('button', { name: 'Got it' })
.click({ timeout: 5000 })
.catch(() => {});
// 2. Enroll a key on /profile.
await page.goto('/profile');
const passkeyCard = page.locator('section, div').filter({
has: page.getByRole('heading', { name: /passkeys & security keys/i }),
});
await page.getByRole('button', { name: 'Add key' }).click();
await page.locator('#passkey-name').fill('Virtual E2E Key');
await page.getByRole('button', { name: 'Register key' }).click();
await expect(page.getByText('Security key added.')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Virtual E2E Key')).toBeVisible();
// 3. Sign out (API logout keeps the flow deterministic), then key sign-in.
const csrf = await (await page.request.get('/api/auth/csrf-token')).json();
await page.request.post('/api/auth/logout', { headers: { 'x-csrf-token': csrf.token } });
await page.goto('/login');
await page.locator('#username').fill(E2E_USER);
await page.locator('#password').fill(E2E_PASS);
await page.getByRole('button', { name: /sign in/i }).click();
// The security-key step auto-fires startAuthentication; the virtual
// authenticator approves it and we land authenticated.
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
await page
.getByRole('button', { name: 'Got it' })
.click({ timeout: 5000 })
.catch(() => {});
await expect(page.getByRole('button', { name: 'Add Bill' })).toBeVisible({ timeout: 10_000 });
// 4. Remove the key (password-gated) so the seeded user is password-only
// again for every other spec in the run.
await page.goto('/profile');
await passkeyCard.getByRole('button', { name: 'Remove', exact: true }).first().click();
await page.locator('#passkey-remove-password').fill(E2E_PASS);
await page.getByRole('button', { name: 'Remove', exact: true }).last().click();
await expect(page.getByText('Security key removed.')).toBeVisible({ timeout: 10_000 });
// Both the TOTP and the passkey card now read "Not configured" (the seeded
// user has neither) — count is the unambiguous assertion.
await expect(page.getByText('Not configured')).toHaveCount(2);
});

View File

@ -51,6 +51,39 @@ export default [
'no-irregular-whitespace': 'warn', // CSV/import parsers handle exotic whitespace 'no-irregular-whitespace': 'warn', // CSV/import parsers handle exotic whitespace
'no-misleading-character-class': 'warn', 'no-misleading-character-class': 'warn',
'no-extra-boolean-cast': 'warn', 'no-extra-boolean-cast': 'warn',
// Pillar D ratchet: server code logs through utils/logger.cts only.
// The two sanctioned exceptions (logger itself + env bootstrap, which runs
// before the logger exists) are carved out in the override block below.
'no-console': 'error',
},
},
{
// Sanctioned console users: the logger's own sink and fatal-config
// bootstrap output that must work before the logger is importable.
files: ['utils/logger.cts', 'utils/env.cts', 'utils/apiError.cts'],
rules: { 'no-console': 'off' },
},
{
// Error-shape ratchet (CODE_STANDARDS "Error responses", now enforced):
// routes throw utils/apiError.cts factories; hand-rolled standardizeError
// bodies can't come back. transactions.cts (internal {error} result
// convention rethrown at the boundary) and import.cts (sendImportError
// error-id envelope) are the two documented exceptions.
files: ['routes/**/*.cts'],
ignores: ['routes/transactions.cts', 'routes/import.cts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '../middleware/errorFormatter.cts',
message:
'Routes emit errors by throwing utils/apiError.cts factories (see CODE_STANDARDS.md); the terminal handler formats them.',
},
],
},
],
}, },
}, },
{ {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 27 KiB

5971
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.40.0", "version": "0.41.1",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.cts", "main": "server.cts",
"engines": { "engines": {
@ -48,7 +48,7 @@
"@tanstack/react-query": "^5.100.9", "@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.9", "@tanstack/react-query-devtools": "^5.100.9",
"@tanstack/react-virtual": "^3.14.2", "@tanstack/react-virtual": "^3.14.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -58,35 +58,36 @@
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"kysely": "^0.29.3", "kysely": "^0.29.3",
"lucide-react": "^0.456.0", "lucide-react": "^1.24.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^8.0.9", "nodemailer": "^9.0.3",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"otplib": "^13.4.1", "otplib": "^13.4.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^6.26.2", "react-router-dom": "^7.18.1",
"sonner": "^1.7.1", "sonner": "^2.0.7",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"xlsx": "^0.18.5" "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.10.1", "@axe-core/playwright": "^4.10.1",
"@eslint/js": "^9.39.4", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^26.1.0", "@types/node": "^26.1.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.1.0", "concurrently": "^10.0.3",
"eslint": "^9.39.4", "eslint": "^10.7.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.5.3",
"fast-check": "^4.8.0", "fast-check": "^4.8.0",
"globals": "^17.7.0", "globals": "^17.7.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
@ -97,7 +98,7 @@
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.62.1", "typescript-eslint": "^8.62.1",
"vite": "^5.4.10", "vite": "^8.1.4",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.8" "vitest": "^4.1.8"
}, },

View File

@ -59,7 +59,7 @@ module.exports = defineConfig({
// findings are fixed (it becomes a passing regression guard). // findings are fixed (it becomes a passing regression guard).
{ {
name: 'probe', name: 'probe',
testMatch: /(api\.probe|a11y\.authed)\.spec\.js/, testMatch: /(api\.probe|a11y\.authed|webauthn\.probe)\.spec\.js/,
use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'], dependencies: ['setup'],
}, },

View File

@ -5,6 +5,7 @@ const { log } = require('../utils/logger.cts');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
const { ValidationError } = require('../utils/apiError.cts');
const router = express.Router(); const router = express.Router();
@ -594,7 +595,7 @@ router.get('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: R
router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => { router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => {
const { enabled } = req.body || {}; const { enabled } = req.body || {};
if (typeof enabled !== 'boolean') { if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: 'enabled must be a boolean' }); throw ValidationError('enabled must be a boolean', 'enabled');
} }
setSetting('update_check_enabled', enabled ? 'true' : 'false'); setSetting('update_check_enabled', enabled ? 'true' : 'false');
res.json({ enabled }); res.json({ enabled });

View File

@ -4,6 +4,12 @@ const express = require('express');
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database.cts'); const { getDb, rollbackMigration } = require('../db/database.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { const {
getBankSyncConfig, getBankSyncConfig,
setBankSyncEnabled, setBankSyncEnabled,
@ -43,6 +49,9 @@ function parseUserId(params) {
return Number.isInteger(n) && n > 0 ? n : null; return Number.isInteger(n) && n > 0 ? n : null;
} }
// Backup-ops error path. Kept res-based (not throw-pattern) deliberately: the
// download route can fail mid-stream after headers are sent, where a throw
// can't produce a response. Masks 500s; 4xx service messages pass through.
function sendError(res, err) { function sendError(res, err) {
const status = err.status || 500; const status = err.status || 500;
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message }); res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
@ -170,15 +179,14 @@ router.delete('/backups/:id', backupOperationLimiter, (req: Req, res: Res) => {
router.post('/users', async (req: Req, res: Res) => { router.post('/users', async (req: Req, res: Res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || username.length < 3) if (!username || username.length < 3)
return res.status(400).json({ error: 'Username must be at least 3 characters' }); throw ValidationError('Username must be at least 3 characters');
if (!password || password.length < 8) if (!password || password.length < 8)
return res.status(400).json({ error: 'Password must be at least 8 characters' }); throw ValidationError('Password must be at least 8 characters');
const db = getDb(); const db = getDb();
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) if (db.prepare('SELECT id FROM users WHERE username = ?').get(username))
return res.status(409).json({ error: 'Username already taken' }); throw ConflictError('Username already taken', 'username');
try {
const hash = await hashPassword(password); const hash = await hashPassword(password);
const result = db const result = db
.prepare( .prepare(
@ -203,26 +211,21 @@ router.post('/users', async (req: Req, res: Res) => {
}); });
res.status(201).json(created); res.status(201).json(created);
} catch (err) {
log.error('[admin] create-user error:', err.message);
res.status(500).json({ error: 'Failed to create user' });
}
}); });
// PUT /api/admin/users/:id/password // PUT /api/admin/users/:id/password
router.put('/users/:id/password', async (req: Req, res: Res) => { router.put('/users/:id/password', async (req: Req, res: Res) => {
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); if (!targetId) throw ValidationError('Invalid user ID');
const { password } = req.body; const { password } = req.body;
if (!password || password.length < 8) if (!password || password.length < 8)
return res.status(400).json({ error: 'Password must be at least 8 characters' }); throw ValidationError('Password must be at least 8 characters');
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) throw NotFoundError('User not found');
try {
const hash = await hashPassword(password); const hash = await hashPassword(password);
db.prepare( db.prepare(
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?", "UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
@ -240,10 +243,6 @@ router.put('/users/:id/password', async (req: Req, res: Res) => {
}); });
res.json({ success: true }); res.json({ success: true });
} catch (err) {
log.error('[admin] reset-password error:', err.message);
res.status(500).json({ error: 'Failed to reset password' });
}
}); });
// PUT /api/admin/users/:id/role // PUT /api/admin/users/:id/role
@ -251,24 +250,24 @@ router.put('/users/:id/password', async (req: Req, res: Res) => {
// changing your own role mid-session. // changing your own role mid-session.
router.put('/users/:id/role', (req: Req, res: Res) => { router.put('/users/:id/role', (req: Req, res: Res) => {
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); if (!targetId) throw ValidationError('Invalid user ID');
const { role } = req.body; const { role } = req.body;
if (!['admin', 'user'].includes(role)) { if (!['admin', 'user'].includes(role)) {
return res.status(400).json({ error: 'role must be "admin" or "user"' }); throw ValidationError('role must be "admin" or "user"');
} }
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) throw NotFoundError('User not found');
if (req.user?.id === targetId) { if (req.user?.id === targetId) {
return res.status(400).json({ error: 'You cannot change your own admin role.' }); throw ValidationError('You cannot change your own admin role.');
} }
if (user.role === 'admin' && role === 'user') { if (user.role === 'admin' && role === 'user') {
const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n; const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n;
if (adminCount <= 1) { if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot remove the last admin account.' }); throw ValidationError('Cannot remove the last admin account.');
} }
} }
@ -305,14 +304,14 @@ router.put('/users/:id/role', (req: Req, res: Res) => {
// PUT /api/admin/users/:id/active // PUT /api/admin/users/:id/active
router.put('/users/:id/active', (req: Req, res: Res) => { router.put('/users/:id/active', (req: Req, res: Res) => {
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); if (!targetId) throw ValidationError('Invalid user ID');
const active = req.body?.active ? 1 : 0; const active = req.body?.active ? 1 : 0;
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) throw NotFoundError('User not found');
if (req.user?.id === targetId) { if (req.user?.id === targetId) {
return res.status(400).json({ error: 'You cannot deactivate your own account.' }); throw ValidationError('You cannot deactivate your own account.');
} }
db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run( db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run(
@ -334,27 +333,27 @@ router.put('/users/:id/active', (req: Req, res: Res) => {
router.put('/users/:id/username', (req: Req, res: Res) => { router.put('/users/:id/username', (req: Req, res: Res) => {
const { username } = req.body; const { username } = req.body;
if (!username || typeof username !== 'string') { if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'username is required' }); throw ValidationError('username is required');
} }
const trimmed = username.trim(); const trimmed = username.trim();
if (trimmed.length < 3) { if (trimmed.length < 3) {
return res.status(400).json({ error: 'Username must be at least 3 characters' }); throw ValidationError('Username must be at least 3 characters');
} }
if (trimmed.length > 50) { if (trimmed.length > 50) {
return res.status(400).json({ error: 'Username must be 50 characters or fewer' }); throw ValidationError('Username must be 50 characters or fewer');
} }
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); if (!targetId) throw ValidationError('Invalid user ID');
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) throw NotFoundError('User not found');
const taken = db const taken = db
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?') .prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
.get(trimmed, targetId); .get(trimmed, targetId);
if (taken) return res.status(409).json({ error: 'Username already taken' }); if (taken) throw ConflictError('Username already taken', 'username');
const previousUsername = user.username; const previousUsername = user.username;
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run( db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
@ -384,13 +383,12 @@ router.put('/users/:id/username', (req: Req, res: Res) => {
// DELETE /api/admin/users/:id // DELETE /api/admin/users/:id
router.delete('/users/:id', (req: Req, res: Res) => { router.delete('/users/:id', (req: Req, res: Res) => {
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); if (!targetId) throw ValidationError('Invalid user ID');
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) throw NotFoundError('User not found');
if (req.user?.id === user.id) if (req.user?.id === user.id) throw ValidationError('You cannot delete your own account.');
return res.status(400).json({ error: 'You cannot delete your own account.' });
const deleteUser = db.transaction(() => { const deleteUser = db.transaction(() => {
// These three tables have no FK/CASCADE to users — must delete explicitly. // These three tables have no FK/CASCADE to users — must delete explicitly.
@ -502,25 +500,19 @@ router.get('/bank-sync-config', (req: Req, res: Res) => {
// PUT /api/admin/bank-sync-config // PUT /api/admin/bank-sync-config
router.put('/bank-sync-config', (req: Req, res: Res) => { router.put('/bank-sync-config', (req: Req, res: Res) => {
const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {}; const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
try {
let config = getBankSyncConfig(); let config = getBankSyncConfig();
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled); if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours); if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
if (sync_days !== undefined) config = setSyncDays(sync_days); if (sync_days !== undefined) config = setSyncDays(sync_days);
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging); if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
res.json(config); res.json(config);
} catch (err) {
res
.status(err.status || 500)
.json({ error: err.message || 'Failed to update bank sync config' });
}
}); });
// ── Migration Rollback ──────────────────────────────────────────────────────── // ── Migration Rollback ────────────────────────────────────────────────────────
router.post('/migrations/rollback', async (req: Req, res: Res) => { router.post('/migrations/rollback', async (req: Req, res: Res) => {
const { version } = req.body; const { version } = req.body;
if (!version) { if (!version) {
return res.status(400).json({ error: 'Version is required' }); throw ValidationError('Version is required');
} }
try { try {
@ -546,13 +538,13 @@ router.post('/migrations/rollback', async (req: Req, res: Res) => {
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
if (err.code === 'NOT_APPLIED') { // Coded rollback errors are intentional user-facing messages; anything
return res.status(404).json({ error: err.message }); // else propagates raw and the terminal handler masks + logs it as a 5xx
} // (the audit log above keeps err.message either way).
if (err.code === 'ROLLBACK_NOT_SUPPORTED') { if (err.code === 'NOT_APPLIED') throw NotFoundError(err.message);
return res.status(422).json({ error: err.message }); if (err.code === 'ROLLBACK_NOT_SUPPORTED')
} throw new ApiError('ROLLBACK_NOT_SUPPORTED', err.message, 422);
res.status(500).json({ error: 'Rollback failed', details: err.message }); throw err;
} }
}); });

View File

@ -1,7 +1,6 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
let _appVersion; let _appVersion;
@ -31,10 +30,15 @@ const {
} = require('../services/authService.cts'); } = require('../services/authService.cts');
const { decryptSecret } = require('../services/encryptionService.cts'); const { decryptSecret } = require('../services/encryptionService.cts');
const { getCsrfToken } = require('../middleware/csrf.cts'); const { getCsrfToken } = require('../middleware/csrf.cts');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts'); const { requireAuth } = require('../middleware/requireAuth.cts');
const { getPublicOidcInfo } = require('../services/oidcService.cts'); const { getPublicOidcInfo } = require('../services/oidcService.cts');
const { ValidationError, formatError } = require('../utils/apiError.cts'); const {
const { standardizeError } = require('../middleware/errorFormatter.cts'); ApiError,
ValidationError,
AuthError,
ForbiddenError,
NotFoundError,
} = require('../utils/apiError.cts');
const { passwordLimiter } = require('../middleware/rateLimiter.cts'); const { passwordLimiter } = require('../middleware/rateLimiter.cts');
const { logAudit } = require('../services/auditService.cts'); const { logAudit } = require('../services/auditService.cts');
@ -54,30 +58,17 @@ router.post(
async (req: Req, res: Res) => { async (req: Req, res: Res) => {
// Respect admin-configured login method toggle // Respect admin-configured login method toggle
if (getSetting('local_login_enabled') === 'false') { if (getSetting('local_login_enabled') === 'false') {
return res throw ForbiddenError('Local username/password login is not enabled on this server.');
.status(403)
.json(
standardizeError(
'Local username/password login is not enabled on this server.',
'FORBIDDEN',
),
);
} }
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) { if (!username || !password) {
return res throw ValidationError(
.status(400)
.json(
standardizeError(
'Username and password are required', 'Username and password are required',
'VALIDATION_ERROR',
!username ? 'username' : 'password', !username ? 'username' : 'password',
),
); );
} }
try {
const result = await login(username, password); const result = await login(username, password);
if (!result || result.error) { if (!result || result.error) {
logAudit({ logAudit({
@ -91,7 +82,7 @@ router.post(
if (result?.error === 'bad_password') { if (result?.error === 'bad_password') {
recordFailedLogin(result.userId, req.ip, req.get('user-agent')); recordFailedLogin(result.userId, req.ip, req.get('user-agent'));
} }
return res.status(401).json(standardizeError('Invalid username or password', 'AUTH_ERROR')); throw AuthError('Invalid username or password');
} }
// TOTP required — don't create a session yet // TOTP required — don't create a session yet
@ -99,6 +90,16 @@ router.post(
return res.json({ requires_totp: true, challenge_token: result.challenge_token }); return res.json({ requires_totp: true, challenge_token: result.challenge_token });
} }
// WebAuthn required — same two-step shape as TOTP; the session is only
// created after POST /webauthn/challenge verifies the key.
if (result.requires_webauthn) {
return res.json({
requires_webauthn: true,
challenge_token: result.challenge_token,
webauthn_options: result.webauthn_options,
});
}
logAudit({ logAudit({
user_id: result.user.id, user_id: result.user.id,
action: 'login.success', action: 'login.success',
@ -109,10 +110,6 @@ router.post(
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req)); res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
res.json({ user: result.user }); res.json({ user: result.user });
} catch (err) {
log.error('Login error:', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
}
}, },
); );
@ -256,20 +253,14 @@ const { encryptSecret: encTotpSecret } = require('../services/encryptionService.
router.post('/totp/challenge', async (req: Req, res: Res) => { router.post('/totp/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true; req.csrfSkip = true;
const { challenge_token, code, recovery_code } = req.body || {}; const { challenge_token, code, recovery_code } = req.body || {};
if (!challenge_token) if (!challenge_token) throw ValidationError('challenge_token is required');
return res
.status(400)
.json(standardizeError('challenge_token is required', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const userId = consumeChallenge(db, challenge_token); const userId = consumeChallenge(db, challenge_token);
if (!userId) if (!userId) throw AuthError('Challenge expired or invalid. Please sign in again.');
return res
.status(401)
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId); const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId);
if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR')); if (!user) throw AuthError('User not found.');
let verified = false; let verified = false;
if (recovery_code) { if (recovery_code) {
@ -289,14 +280,12 @@ router.post('/totp/challenge', async (req: Req, res: Res) => {
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR')); throw AuthError('Invalid authenticator code.');
} }
try {
const { createSession } = require('../services/authService.cts'); const { createSession } = require('../services/authService.cts');
const session = await createSession(userId); const session = await createSession(userId);
if (!session) if (!session) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
logAudit({ logAudit({
user_id: userId, user_id: userId,
@ -308,51 +297,25 @@ router.post('/totp/challenge', async (req: Req, res: Res) => {
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req)); res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
res.json({ user: session.user }); res.json({ user: session.user });
} catch (err) {
log.error('[totp/challenge]', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
}
}); });
// GET /api/auth/totp/setup — generate a new pending secret + QR code for the authenticated user. // GET /api/auth/totp/setup — generate a new pending secret + QR code for the authenticated user.
// The secret is NOT saved yet; the user must confirm a valid code via /totp/enable. // The secret is NOT saved yet; the user must confirm a valid code via /totp/enable.
router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => { router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
return res
.status(400)
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
try {
const secret = generateSecret(); const secret = generateSecret();
const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id); const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id);
const { uri, qr_data_url } = await generateQrCode(secret, user.username); const { uri, qr_data_url } = await generateQrCode(secret, user.username);
res.json({ secret, uri, qr_data_url }); res.json({ secret, uri, qr_data_url });
} catch (err) {
log.error('[totp/setup]', err);
res.status(500).json(standardizeError('Failed to generate setup data', 'SERVER_ERROR'));
}
}); });
// POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP. // POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP.
router.post('/totp/enable', requireAuth, (req: Req, res: Res) => { router.post('/totp/enable', requireAuth, (req: Req, res: Res) => {
if (req.singleUserMode) if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
return res
.status(400)
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
const { secret, code } = req.body || {}; const { secret, code } = req.body || {};
if (!secret || !code) if (!secret || !code) throw ValidationError('secret and code are required');
return res
.status(400)
.json(standardizeError('secret and code are required', 'VALIDATION_ERROR'));
if (!verifyTokenRaw(secret, code)) if (!verifyTokenRaw(secret, code))
return res throw ValidationError('Invalid authenticator code. Check your app and try again.', 'code');
.status(400)
.json(
standardizeError(
'Invalid authenticator code. Check your app and try again.',
'VALIDATION_ERROR',
'code',
),
);
const plainCodes = generateRecoveryCodes(); const plainCodes = generateRecoveryCodes();
const hashedCodes = plainCodes.map(hashRecoveryCode); const hashedCodes = plainCodes.map(hashRecoveryCode);
@ -378,8 +341,7 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
const { code, recovery_code } = req.body || {}; const { code, recovery_code } = req.body || {};
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!user?.totp_enabled) if (!user?.totp_enabled) throw ValidationError('TOTP is not enabled.');
return res.status(400).json(standardizeError('TOTP is not enabled.', 'VALIDATION_ERROR'));
let verified = false; let verified = false;
if (recovery_code) { if (recovery_code) {
@ -388,9 +350,7 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
verified = verifyToken(user.totp_secret, code); verified = verifyToken(user.totp_secret, code);
} }
if (!verified) if (!verified)
return res throw new ApiError('AUTH_ERROR', 'Invalid authenticator code.', 401, { field: 'code' });
.status(401)
.json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR', 'code'));
db.prepare( db.prepare(
`UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`, `UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`,
@ -442,9 +402,7 @@ router.get('/mode', (req: Req, res: Res) => {
// login without needing access to Admin routes. // login without needing access to Admin routes.
router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => { router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => {
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') { if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
return res throw ValidationError('Single-user mode is not enabled.', 'auth_mode');
.status(400)
.json(standardizeError('Single-user mode is not enabled.', 'VALIDATION_ERROR', 'auth_mode'));
} }
setSetting('auth_mode', 'multi'); setSetting('auth_mode', 'multi');
@ -469,30 +427,19 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, r
const { current_password, new_password } = req.body; const { current_password, new_password } = req.body;
if (!new_password || new_password.length < 8) { if (!new_password || new_password.length < 8) {
return res throw ValidationError('New password must be at least 8 characters', 'new_password');
.status(400)
.json(
standardizeError(
'New password must be at least 8 characters',
'VALIDATION_ERROR',
'new_password',
),
);
} }
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
try {
if (!user.must_change_password) { if (!user.must_change_password) {
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const valid = await bcrypt.compare(current_password || '', user.password_hash); const valid = await bcrypt.compare(current_password || '', user.password_hash);
if (!valid) if (!valid)
return res throw new ApiError('AUTH_ERROR', 'Current password is incorrect', 401, {
.status(401) field: 'current_password',
.json( });
standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'),
);
} }
const hash = await hashPassword(new_password); const hash = await hashPassword(new_password);
@ -521,80 +468,6 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, r
}); });
res.json({ success: true }); res.json({ success: true });
} catch (err) {
log.error('[auth] change-password error:', err.message);
res.status(500).json(standardizeError('Password change failed', 'SERVER_ERROR'));
}
});
// ─────────────────────────────────────────
// ADMIN ROUTES (MOUNTED AT /api/admin)
// ─────────────────────────────────────────
// GET /api/admin/has-users
router.get('/has-users', (req: Req, res: Res) => {
const count = getDb().prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'").get().n;
res.json({ has_users: count > 0 });
});
// GET /api/admin/users
router.get('/users', requireAuth, requireAdmin, (req: Req, res: Res) => {
const users = getDb()
.prepare(
'SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY role DESC, username ASC',
)
.all();
res.json(users);
});
// POST /api/admin/users
router.post('/users', requireAuth, requireAdmin, async (req: Req, res: Res) => {
const { username, password } = req.body;
if (!username || username.length < 3) {
return res
.status(400)
.json(
standardizeError('Username must be at least 3 characters', 'VALIDATION_ERROR', 'username'),
);
}
if (!password || password.length < 8) {
return res
.status(400)
.json(
standardizeError('Password must be at least 8 characters', 'VALIDATION_ERROR', 'password'),
);
}
const db = getDb();
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing)
return res.status(409).json(standardizeError('Username already taken', 'CONFLICT', 'username'));
try {
const hash = await hashPassword(password);
const result = db
.prepare(
"INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)",
)
.run(username, hash, getAppVersion());
const created = db
.prepare(
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?',
)
.get(result.lastInsertRowid);
res.status(201).json(created);
} catch (err) {
log.error('[auth] create-user error:', err.message);
res.status(500).json(standardizeError('Failed to create user', 'SERVER_ERROR'));
}
}); });
// ── WebAuthn / FIDO2 security key ───────────────────────────────────────────── // ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
@ -628,36 +501,21 @@ router.get('/webauthn/credentials', requireAuth, (req: Req, res: Res) => {
// GET /api/auth/webauthn/setup — begin registration // GET /api/auth/webauthn/setup — begin registration
router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => { router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
return res
.status(400)
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
try {
const { options, challengeId } = await createRegistrationChallenge( const { options, challengeId } = await createRegistrationChallenge(
getDb(), getDb(),
req.user.id, req.user.id,
req.user.username, req.user.username,
); );
res.json({ options, challengeId }); res.json({ options, challengeId });
} catch (err) {
log.error('[webauthn/setup]', err);
res.status(500).json(standardizeError('Failed to generate setup options', 'SERVER_ERROR'));
}
}); });
// POST /api/auth/webauthn/enable — complete registration // POST /api/auth/webauthn/enable — complete registration
router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => { router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
return res
.status(400)
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
const { challengeId, response, credential_name } = req.body || {}; const { challengeId, response, credential_name } = req.body || {};
if (!challengeId || !response) if (!challengeId || !response) throw ValidationError('challengeId and response are required');
return res
.status(400)
.json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR'));
try {
const db = getDb(); const db = getDb();
const result = await verifyRegistration( const result = await verifyRegistration(
db, db,
@ -665,11 +523,9 @@ router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
challengeId, challengeId,
response, response,
credential_name, credential_name,
req.get('origin'),
); );
if (!result.verified) if (!result.verified) throw ValidationError(result.error || 'Registration failed');
return res
.status(400)
.json(standardizeError(result.error || 'Registration failed', 'VALIDATION_ERROR'));
db.prepare( db.prepare(
"UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?", "UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?",
@ -682,28 +538,21 @@ router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
res.json({ enabled: true, credential_id: result.credentialId }); res.json({ enabled: true, credential_id: result.credentialId });
} catch (err) {
log.error('[webauthn/enable]', err);
res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR'));
}
}); });
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key // DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => { router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {}; const { password } = req.body || {};
if (!password) if (!password) throw ValidationError('password is required');
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
try {
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
if (!(await bcrypt.compare(password, user.password_hash))) if (!(await bcrypt.compare(password, user.password_hash)))
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR')); throw AuthError('Password is incorrect');
const result = deleteCredential(db, req.params.credentialId, req.user.id); const result = deleteCredential(db, req.params.credentialId, req.user.id);
if (result.changes === 0) if (result.changes === 0) throw NotFoundError('Credential not found');
return res.status(404).json(standardizeError('Credential not found', 'NOT_FOUND'));
const { n } = db const { n } = db
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?') .prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
@ -720,29 +569,22 @@ router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Re
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
res.json({ success: true, webauthn_enabled: n > 0 }); res.json({ success: true, webauthn_enabled: n > 0 });
} catch (err) {
log.error('[webauthn/credentials/delete]', err);
res.status(500).json(standardizeError('Failed to remove credential', 'SERVER_ERROR'));
}
}); });
// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn // POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn
router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => { router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {}; const { password } = req.body || {};
if (!password) if (!password) throw ValidationError('password is required');
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const user = db const user = db
.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?') .prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?')
.get(req.user.id); .get(req.user.id);
if (!user?.webauthn_enabled) if (!user?.webauthn_enabled) throw ValidationError('WebAuthn is not enabled.');
return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR'));
try {
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
if (!(await bcrypt.compare(password, user.password_hash))) if (!(await bcrypt.compare(password, user.password_hash)))
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR')); throw AuthError('Password is incorrect');
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id); db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
db.prepare( db.prepare(
@ -756,10 +598,6 @@ router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
res.json({ enabled: false }); res.json({ enabled: false });
} catch (err) {
log.error('[webauthn/disable]', err);
res.status(500).json(standardizeError('Failed to disable WebAuthn', 'SERVER_ERROR'));
}
}); });
// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled. // POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled.
@ -768,26 +606,21 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true; req.csrfSkip = true;
const { challenge_token, response } = req.body || {}; const { challenge_token, response } = req.body || {};
if (!challenge_token || !response) if (!challenge_token || !response)
return res throw ValidationError('challenge_token and response are required');
.status(400)
.json(standardizeError('challenge_token and response are required', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const session = consumeLoginChallenge(db, challenge_token); const session = consumeLoginChallenge(db, challenge_token);
if (!session) if (!session) throw AuthError('Challenge expired or invalid. Please sign in again.');
return res
.status(401)
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId); const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId);
if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR')); if (!user) throw AuthError('User not found.');
try {
const result = await verifyAuthentication( const result = await verifyAuthentication(
db, db,
session.userId, session.userId,
session.authChallengeId, session.authChallengeId,
response, response,
req.get('origin'),
); );
if (!result.verified) { if (!result.verified) {
logAudit({ logAudit({
@ -796,15 +629,12 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
return res throw AuthError('Security key verification failed.');
.status(401)
.json(standardizeError('Security key verification failed.', 'AUTH_ERROR'));
} }
const { createSession } = require('../services/authService.cts'); const { createSession } = require('../services/authService.cts');
const s = await createSession(session.userId); const s = await createSession(session.userId);
if (!s) if (!s) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
logAudit({ logAudit({
user_id: session.userId, user_id: session.userId,
@ -817,10 +647,6 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req)); res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req));
res.json({ user: s.user }); res.json({ user: s.user });
} catch (err) {
log.error('[webauthn/challenge]', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
}
}); });
module.exports = router; module.exports = router;

View File

@ -16,7 +16,12 @@ const {
applyBalanceDelta, applyBalanceDelta,
} = require('../services/billsService.cts'); } = require('../services/billsService.cts');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
const { const {
addMerchantRule, addMerchantRule,
@ -100,9 +105,7 @@ router.put('/reorder', (req: Req, res: Res) => {
})); }));
if (entries.length === 0) { if (entries.length === 0) {
return res throw ValidationError('At least one bill order is required', 'reorder');
.status(400)
.json(standardizeError('At least one bill order is required', 'VALIDATION_ERROR', 'reorder'));
} }
const invalid = entries.find( const invalid = entries.find(
@ -110,14 +113,9 @@ router.put('/reorder', (req: Req, res: Res) => {
!Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0, !Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0,
); );
if (invalid) { if (invalid) {
return res throw ValidationError(
.status(400)
.json(
standardizeError(
'Reorder payload must map bill ids to non-negative integer positions', 'Reorder payload must map bill ids to non-negative integer positions',
'VALIDATION_ERROR',
'reorder', 'reorder',
),
); );
} }
@ -133,9 +131,7 @@ router.put('/reorder', (req: Req, res: Res) => {
) )
.all(req.user.id, ...ids); .all(req.user.id, ...ids);
if (owned.length !== ids.length) { if (owned.length !== ids.length) {
return res throw NotFoundError('One or more bills were not found', 'bill_id');
.status(404)
.json(standardizeError('One or more bills were not found', 'NOT_FOUND', 'bill_id'));
} }
const update = db.prepare( const update = db.prepare(
@ -172,16 +168,11 @@ router.get('/audit', (req: Req, res: Res) => {
// ── GET /api/bills/drift-report ────────────────────────────────────────────── // ── GET /api/bills/drift-report ──────────────────────────────────────────────
router.get('/drift-report', (req: Req, res: Res) => { router.get('/drift-report', (req: Req, res: Res) => {
const { getDriftReport } = require('../services/driftService.cts'); const { getDriftReport } = require('../services/driftService.cts');
try {
res.json(getDriftReport(req.user.id)); res.json(getDriftReport(req.user.id));
} catch (err) {
res.status(500).json({ error: 'Failed to compute drift report' });
}
}); });
// GET /api/bills/merchant-rules — all rules for this user across all bills // GET /api/bills/merchant-rules — all rules for this user across all bills
router.get('/merchant-rules', (req: Req, res: Res) => { router.get('/merchant-rules', (req: Req, res: Res) => {
try {
const rules = getDb() const rules = getDb()
.prepare( .prepare(
` `
@ -195,10 +186,6 @@ router.get('/merchant-rules', (req: Req, res: Res) => {
) )
.all(req.user.id); .all(req.user.id);
res.json({ rules }); res.json({ rules });
} catch (err) {
log.error('[bills/merchant-rules GET]', err.message);
res.status(500).json({ error: 'Failed to load merchant rules' });
}
}); });
// ── POST /api/bills/:id/snooze-drift ───────────────────────────────────────── // ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
@ -206,11 +193,11 @@ router.get('/merchant-rules', (req: Req, res: Res) => {
router.post('/:id/snooze-drift', (req: Req, res: Res) => { router.post('/:id/snooze-drift', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid id');
const bill = db const bill = db
.prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL') .prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL')
.get(id); .get(id);
if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' }); if (!bill || bill.user_id !== req.user.id) throw NotFoundError('Not found');
const until = new Date(); const until = new Date();
until.setDate(until.getDate() + 30); until.setDate(until.getDate() + 30);
const untilStr = localDateString(until); const untilStr = localDateString(until);
@ -257,36 +244,20 @@ router.post('/templates', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
if (name.length < 2) { if (name.length < 2) {
return res throw ValidationError('Template name must be at least 2 characters', 'name');
.status(400)
.json(
standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'),
);
} }
const data = sanitizeTemplateData(req.body.data || {}); const data = sanitizeTemplateData(req.body.data || {});
if (Object.keys(data).length === 0) { if (Object.keys(data).length === 0) {
return res throw ValidationError('Template data is required', 'data');
.status(400)
.json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data'));
} }
const validation = validateBillData(data); const validation = validateBillData(data);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
return res throw ValidationError(firstError.message, `data.${firstError.field}`);
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', `data.${firstError.field}`));
} }
if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) {
return res throw ValidationError('category_id is invalid for this user', 'data.category_id');
.status(400)
.json(
standardizeError(
'category_id is invalid for this user',
'VALIDATION_ERROR',
'data.category_id',
),
);
} }
const normalizedData = sanitizeTemplateData(validation.normalized); const normalizedData = sanitizeTemplateData(validation.normalized);
@ -323,15 +294,12 @@ router.delete('/templates/:templateId', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const templateId = parseInt(req.params.templateId, 10); const templateId = parseInt(req.params.templateId, 10);
if (!Number.isInteger(templateId)) { if (!Number.isInteger(templateId)) {
return res throw ValidationError('template_id must be an integer', 'template_id');
.status(400)
.json(standardizeError('template_id must be an integer', 'VALIDATION_ERROR', 'template_id'));
} }
const result = db const result = db
.prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?') .prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?')
.run(templateId, req.user.id); .run(templateId, req.user.id);
if (result.changes === 0) if (result.changes === 0) throw NotFoundError('Template not found', 'template_id');
return res.status(404).json(standardizeError('Template not found', 'NOT_FOUND', 'template_id'));
res.json({ success: true }); res.json({ success: true });
}); });
@ -341,15 +309,12 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
const body = req.body || {}; const body = req.body || {};
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
return res throw ValidationError('bill_id must be an integer', 'bill_id');
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const source = db const source = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!source) if (!source) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const draft = { const draft = {
...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(serializeBill(source)),
@ -359,17 +324,11 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
const validation = validateBillData(draft); const validation = validateBillData(draft);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
return res throw ValidationError(firstError.message, firstError.field);
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; const { normalized } = validation;
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res throw ValidationError('category_id is invalid for this user', 'category_id');
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
} }
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
@ -384,26 +343,14 @@ router.get('/:id/monthly-state', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const year = parseInt(req.query.year, 10); const year = parseInt(req.query.year, 10);
const month = parseInt(req.query.month, 10); const month = parseInt(req.query.month, 10);
if (isNaN(year) || year < 2000 || year > 2100) if (isNaN(year) || year < 2000 || year > 2100)
return res throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
if (isNaN(month) || month < 1 || month > 12) if (isNaN(month) || month < 1 || month > 12)
return res throw ValidationError('month must be an integer between 1 and 12', 'month');
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
const mbs = db const mbs = db
.prepare( .prepare(
@ -430,53 +377,28 @@ router.put('/:id/monthly-state', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body; const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body;
const y = parseInt(year, 10); const y = parseInt(year, 10);
const m = parseInt(month, 10); const m = parseInt(month, 10);
if (isNaN(y) || y < 2000 || y > 2100) if (isNaN(y) || y < 2000 || y > 2100)
return res throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
if (isNaN(m) || m < 1 || m > 12) if (isNaN(m) || m < 1 || m > 12)
return res throw ValidationError('month must be an integer between 1 and 12', 'month');
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
if (actual_amount !== undefined && actual_amount !== null) { if (actual_amount !== undefined && actual_amount !== null) {
const amt = parseFloat(actual_amount); const amt = parseFloat(actual_amount);
if (isNaN(amt) || amt < 0) if (isNaN(amt) || amt < 0)
return res throw ValidationError('actual_amount must be a non-negative number or null', 'actual_amount');
.status(400)
.json(
standardizeError(
'actual_amount must be a non-negative number or null',
'VALIDATION_ERROR',
'actual_amount',
),
);
} }
if (snoozed_until !== undefined && snoozed_until !== null) { if (snoozed_until !== undefined && snoozed_until !== null) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until)) if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until))
return res throw ValidationError(
.status(400)
.json(
standardizeError(
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null', 'snoozed_until must be an ISO date string (YYYY-MM-DD) or null',
'VALIDATION_ERROR',
'snoozed_until', 'snoozed_until',
),
); );
} }
@ -551,8 +473,7 @@ router.get('/:id', (req: Req, res: Res) => {
`, `,
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
let autopay_stats = null; let autopay_stats = null;
if (bill.autopay_enabled) { if (bill.autopay_enabled) {
@ -584,25 +505,16 @@ router.post('/:id/verify-autopay', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id')); throw ValidationError('Invalid id', 'bill_id');
} }
const bill = db const bill = db
.prepare( .prepare(
'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL', 'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
) )
.get(id, req.user.id); .get(id, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill.autopay_enabled) { if (!bill.autopay_enabled) {
return res throw ValidationError('Bill does not have autopay enabled', 'autopay_enabled');
.status(400)
.json(
standardizeError(
'Bill does not have autopay enabled',
'VALIDATION_ERROR',
'autopay_enabled',
),
);
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
db.prepare( db.prepare(
@ -624,23 +536,12 @@ router.post('/', (req: Req, res: Res) => {
) { ) {
const sourceBillId = parseInt(body.source_bill_id, 10); const sourceBillId = parseInt(body.source_bill_id, 10);
if (!Number.isInteger(sourceBillId)) { if (!Number.isInteger(sourceBillId)) {
return res throw ValidationError('source_bill_id must be an integer', 'source_bill_id');
.status(400)
.json(
standardizeError(
'source_bill_id must be an integer',
'VALIDATION_ERROR',
'source_bill_id',
),
);
} }
const source = db const source = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(sourceBillId, req.user.id); .get(sourceBillId, req.user.id);
if (!source) if (!source) throw NotFoundError('Source bill not found', 'source_bill_id');
return res
.status(404)
.json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
payload = { payload = {
...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(serializeBill(source)),
...sanitizeTemplateData(body), ...sanitizeTemplateData(body),
@ -652,20 +553,14 @@ router.post('/', (req: Req, res: Res) => {
const validation = validateBillData(payload); const validation = validateBillData(payload);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
return res throw ValidationError(firstError.message, firstError.field);
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user // Validate category_id exists for this user
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res throw ValidationError('category_id is invalid for this user', 'category_id');
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
} }
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
@ -677,27 +572,20 @@ router.put('/:id', (req: Req, res: Res) => {
const existing = db const existing = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!existing) if (!existing) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// Validate and normalize bill data // Validate and normalize bill data
const validation = validateBillData(req.body, existing); const validation = validateBillData(req.body, existing);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
return res throw ValidationError(firstError.message, firstError.field);
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user if changed // Validate category_id exists for this user if changed
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res throw ValidationError('category_id is invalid for this user', 'category_id');
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
} }
const inactiveReason = const inactiveReason =
@ -772,13 +660,12 @@ router.put('/:id/archived', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id')); throw ValidationError('Invalid id', 'bill_id');
} }
const bill = db const bill = db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(id, req.user.id); .get(id, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const archived = !!req.body?.archived; const archived = !!req.body?.archived;
db.prepare( db.prepare(
@ -797,8 +684,7 @@ router.delete('/:id', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
db.prepare( db.prepare(
"UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
@ -818,8 +704,7 @@ router.post('/:id/restore', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Deleted bill not found', 'bill_id');
return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id'));
db.prepare( db.prepare(
"UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
@ -839,19 +724,14 @@ router.post('/:id/restore', (req: Req, res: Res) => {
// backfill any missing payments. // backfill any missing payments.
router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => { router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) if (!Number.isInteger(billId)) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const bill = db const bill = db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); if (!bill) throw NotFoundError('Bill not found');
try {
const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId); const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
res.json(result); res.json(result);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Sync failed', 'SYNC_ERROR'));
}
}); });
// ── GET /api/bills/:id/payments?page=1&limit=20 ─────────────────────────────── // ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
@ -860,8 +740,7 @@ router.get('/:id/payments', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100); const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
const page = Math.max(parseInt(req.query.page || '1', 10), 1); const page = Math.max(parseInt(req.query.page || '1', 10), 1);
@ -893,16 +772,13 @@ router.get('/:id/transactions', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
return res throw ValidationError('bill_id must be an integer', 'bill_id');
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const rows = db const rows = db
.prepare( .prepare(
@ -971,40 +847,19 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => {
) )
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// Scope to year/month if provided // Scope to year/month if provided
const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null; const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null;
const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null; const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null;
if ((year === null) !== (month === null)) { if ((year === null) !== (month === null)) {
return res throw ValidationError('year and month must both be provided or both omitted', 'year');
.status(400)
.json(
standardizeError(
'year and month must both be provided or both omitted',
'VALIDATION_ERROR',
'year',
),
);
} }
if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) { if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) {
return res throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
} }
if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) { if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) {
return res throw ValidationError('month must be an integer between 1 and 12', 'month');
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
} }
let currentPayment; let currentPayment;
@ -1068,11 +923,8 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => {
{ amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' }, { amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' },
{ requireBillId: false }, { requireBillId: false },
); );
if (paymentValidation.error) { if (paymentValidation.error)
return res throw ValidationError(paymentValidation.error, paymentValidation.field);
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const payment = paymentValidation.normalized; const payment = paymentValidation.normalized;
// Compute balance delta for debt bills before inserting // Compute balance delta for debt bills before inserting
@ -1112,7 +964,7 @@ router.get('/:id/history-ranges', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const ranges = db const ranges = db
.prepare( .prepare(
@ -1139,77 +991,40 @@ router.post('/:id/history-ranges', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const { start_year, start_month, end_year, end_month, label } = req.body; const { start_year, start_month, end_year, end_month, label } = req.body;
const sy = parseInt(start_year, 10); const sy = parseInt(start_year, 10);
const sm = parseInt(start_month, 10); const sm = parseInt(start_month, 10);
if (isNaN(sy) || sy < 2000 || sy > 2100) if (isNaN(sy) || sy < 2000 || sy > 2100)
return res throw ValidationError('start_year must be between 2000 and 2100', 'start_year');
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
if (isNaN(sm) || sm < 1 || sm > 12) if (isNaN(sm) || sm < 1 || sm > 12)
return res throw ValidationError('start_month must be between 1 and 12', 'start_month');
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
let ey = null, let ey = null,
em = null; em = null;
if (end_year != null) { if (end_year != null) {
ey = parseInt(end_year, 10); ey = parseInt(end_year, 10);
if (isNaN(ey) || ey < 2000 || ey > 2100) if (isNaN(ey) || ey < 2000 || ey > 2100)
return res throw ValidationError('end_year must be between 2000 and 2100', 'end_year');
.status(400)
.json(
standardizeError(
'end_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'end_year',
),
);
} }
if (end_month != null) { if (end_month != null) {
em = parseInt(end_month, 10); em = parseInt(end_month, 10);
if (isNaN(em) || em < 1 || em > 12) if (isNaN(em) || em < 1 || em > 12)
return res throw ValidationError('end_month must be between 1 and 12', 'end_month');
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
} }
if ((ey == null) !== (em == null)) { if ((ey == null) !== (em == null)) {
return res throw ValidationError(
.status(400)
.json(
standardizeError(
'end_year and end_month must both be provided or both omitted', 'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year', 'end_year',
),
); );
} }
if (ey != null) { if (ey != null) {
const startVal = sy * 12 + sm; const startVal = sy * 12 + sm;
const endVal = ey * 12 + em; const endVal = ey * 12 + em;
if (endVal < startVal) if (endVal < startVal)
return res throw ValidationError('end date must be on or after start date', 'end_year');
.status(400)
.json(
standardizeError(
'end date must be on or after start date',
'VALIDATION_ERROR',
'end_year',
),
);
} }
const result = db const result = db
@ -1235,36 +1050,21 @@ router.put('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const range = db const range = db
.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
.get(req.params.rangeId, req.params.id); .get(req.params.rangeId, req.params.id);
if (!range) if (!range) throw NotFoundError('History range not found', 'rangeId');
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
const { start_year, start_month, end_year, end_month, label } = req.body; const { start_year, start_month, end_year, end_month, label } = req.body;
const sy = start_year != null ? parseInt(start_year, 10) : range.start_year; const sy = start_year != null ? parseInt(start_year, 10) : range.start_year;
const sm = start_month != null ? parseInt(start_month, 10) : range.start_month; const sm = start_month != null ? parseInt(start_month, 10) : range.start_month;
if (isNaN(sy) || sy < 2000 || sy > 2100) if (isNaN(sy) || sy < 2000 || sy > 2100)
return res throw ValidationError('start_year must be between 2000 and 2100', 'start_year');
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
if (isNaN(sm) || sm < 1 || sm > 12) if (isNaN(sm) || sm < 1 || sm > 12)
return res throw ValidationError('start_month must be between 1 and 12', 'start_month');
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
let ey = range.end_year; let ey = range.end_year;
let em = range.end_month; let em = range.end_month;
@ -1272,33 +1072,16 @@ router.put('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
if (end_month !== undefined) em = end_month != null ? parseInt(end_month, 10) : null; if (end_month !== undefined) em = end_month != null ? parseInt(end_month, 10) : null;
if (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100)) if (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100))
return res throw ValidationError('end_year must be between 2000 and 2100', 'end_year');
.status(400)
.json(
standardizeError('end_year must be between 2000 and 2100', 'VALIDATION_ERROR', 'end_year'),
);
if (em != null && (isNaN(em) || em < 1 || em > 12)) if (em != null && (isNaN(em) || em < 1 || em > 12))
return res throw ValidationError('end_month must be between 1 and 12', 'end_month');
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
if ((ey == null) !== (em == null)) if ((ey == null) !== (em == null))
return res throw ValidationError(
.status(400)
.json(
standardizeError(
'end_year and end_month must both be provided or both omitted', 'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year', 'end_year',
),
); );
if (ey != null && ey * 12 + em < sy * 12 + sm) if (ey != null && ey * 12 + em < sy * 12 + sm)
return res throw ValidationError('end date must be on or after start date', 'end_year');
.status(400)
.json(
standardizeError('end date must be on or after start date', 'VALIDATION_ERROR', 'end_year'),
);
db.prepare( db.prepare(
` `
@ -1331,15 +1114,12 @@ router.delete('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
const range = db const range = db
.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
.get(req.params.rangeId, req.params.id); .get(req.params.rangeId, req.params.id);
if (!range) if (!range) throw NotFoundError('History range not found', 'rangeId');
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run( db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run(
req.params.rangeId, req.params.rangeId,
@ -1356,8 +1136,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const balance = fromCents(Number(bill.current_balance)); const balance = fromCents(Number(bill.current_balance));
const apr = Number(bill.interest_rate) || 0; const apr = Number(bill.interest_rate) || 0;
@ -1368,9 +1147,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
if (req.query.payment !== undefined) { if (req.query.payment !== undefined) {
const qp = parseFloat(req.query.payment); const qp = parseFloat(req.query.payment);
if (!Number.isFinite(qp) || qp <= 0) { if (!Number.isFinite(qp) || qp <= 0) {
return res throw ValidationError('payment must be a positive number', 'payment');
.status(400)
.json(standardizeError('payment must be a positive number', 'VALIDATION_ERROR', 'payment'));
} }
payment = qp; payment = qp;
} }
@ -1424,7 +1201,7 @@ router.patch('/:id/snowball', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) { ) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
} }
const include = const include =
req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined; req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined;
@ -1440,8 +1217,7 @@ router.patch('/:id/snowball', (req: Req, res: Res) => {
parts.push('snowball_exempt = ?'); parts.push('snowball_exempt = ?');
vals.push(exempt); vals.push(exempt);
} }
if (parts.length === 0) if (parts.length === 0) throw ValidationError('Nothing to update');
return res.status(400).json(standardizeError('Nothing to update', 'VALIDATION_ERROR'));
parts.push("updated_at = datetime('now')"); parts.push("updated_at = datetime('now')");
db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run( db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run(
...vals, ...vals,
@ -1460,7 +1236,7 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) { ) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
} }
const raw = req.body.current_balance; const raw = req.body.current_balance;
@ -1468,15 +1244,7 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
if (raw !== null && raw !== '' && raw !== undefined) { if (raw !== null && raw !== '' && raw !== undefined) {
val = parseFloat(raw); val = parseFloat(raw);
if (!Number.isFinite(val) || val < 0) { if (!Number.isFinite(val) || val < 0) {
return res throw ValidationError('current_balance must be a non-negative number', 'current_balance');
.status(400)
.json(
standardizeError(
'current_balance must be a non-negative number',
'VALIDATION_ERROR',
'current_balance',
),
);
} }
val = roundMoney(val); val = roundMoney(val);
} }
@ -1538,10 +1306,8 @@ function findConflicts(db, userId, billId, normalized) {
router.get('/:id/merchant-rules', (req: Req, res: Res) => { router.get('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const rules = db const rules = db
.prepare( .prepare(
@ -1591,10 +1357,8 @@ router.get('/:id/merchant-rules', (req: Req, res: Res) => {
router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => { router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.query.merchant || '').trim(); const raw = String(req.query.merchant || '').trim();
const normalized = normalizeMerchant(raw); const normalized = normalizeMerchant(raw);
@ -1612,27 +1376,16 @@ router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
router.post('/:id/merchant-rules', (req: Req, res: Res) => { router.post('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR')); if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.body?.merchant || '').trim(); const raw = String(req.body?.merchant || '').trim();
const normalized = normalizeMerchant(raw); const normalized = normalizeMerchant(raw);
if (!normalized || normalized.length < 2) if (!normalized || normalized.length < 2)
return res throw ValidationError('merchant must be at least 2 characters after normalisation', 'merchant');
.status(400)
.json(
standardizeError(
'merchant must be at least 2 characters after normalisation',
'VALIDATION_ERROR',
'merchant',
),
);
const conflicts = findConflicts(db, req.user.id, billId, normalized); const conflicts = findConflicts(db, req.user.id, billId, normalized);
try {
db.prepare( db.prepare(
` `
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant) INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
@ -1640,9 +1393,6 @@ router.post('/:id/merchant-rules', (req: Req, res: Res) => {
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
`, `,
).run(req.user.id, billId, normalized); ).run(req.user.id, billId, normalized);
} catch (err) {
return res.status(500).json(standardizeError('Failed to save rule', 'DB_ERROR'));
}
// Retroactively apply the new rule to existing unmatched transactions // Retroactively apply the new rule to existing unmatched transactions
const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId); const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
@ -1665,10 +1415,9 @@ router.post('/:id/merchant-rules', (req: Req, res: Res) => {
router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => { router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const bill = requireBill(db, billId, req.user.id); const bill = requireBill(db, billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); if (!bill) throw NotFoundError('Bill not found');
const rules = db const rules = db
.prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?') .prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?')
@ -1750,22 +1499,16 @@ router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => { router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const bill = requireBill(db, billId, req.user.id); const bill = requireBill(db, billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); if (!bill) throw NotFoundError('Bill not found');
const ids = req.body?.transaction_ids; const ids = req.body?.transaction_ids;
if (!Array.isArray(ids) || ids.length === 0) if (!Array.isArray(ids) || ids.length === 0)
return res throw ValidationError('transaction_ids must be a non-empty array');
.status(400)
.json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR'));
const validIds = ids.filter((id) => Number.isInteger(id) && id > 0); const validIds = ids.filter((id) => Number.isInteger(id) && id > 0);
if (validIds.length === 0) if (validIds.length === 0) throw ValidationError('No valid transaction ids provided');
return res
.status(400)
.json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR'));
const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL'); const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
const getTx = db.prepare( const getTx = db.prepare(
@ -1837,7 +1580,7 @@ router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
})(); })();
} catch (err) { } catch (err) {
log.error('[import-historical] Transaction failed:', err.message); log.error('[import-historical] Transaction failed:', err.message);
return res.status(500).json(standardizeError('Import failed', 'DB_ERROR')); throw new ApiError('DB_ERROR', 'Import failed', 500);
} }
res.json({ imported, late_attributions: lateAttributions }); res.json({ imported, late_attributions: lateAttributions });
@ -1850,9 +1593,8 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res)
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10); const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1) if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); throw ValidationError('Invalid id');
if (!requireBill(db, billId, req.user.id)) if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const enabled = req.body?.enabled ? 1 : 0; const enabled = req.body?.enabled ? 1 : 0;
const changes = db const changes = db
@ -1861,7 +1603,7 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res)
) )
.run(enabled, ruleId, req.user.id, billId).changes; .run(enabled, ruleId, req.user.id, billId).changes;
if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND')); if (changes === 0) throw NotFoundError('Rule not found');
res.json({ id: ruleId, auto_attribute_late: enabled === 1 }); res.json({ id: ruleId, auto_attribute_late: enabled === 1 });
}); });
@ -1870,15 +1612,14 @@ router.delete('/:id/merchant-rules/:ruleId', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10); const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1) if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); throw ValidationError('Invalid id');
if (!requireBill(db, billId, req.user.id)) if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const changes = db const changes = db
.prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?') .prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?')
.run(ruleId, req.user.id, billId).changes; .run(ruleId, req.user.id, billId).changes;
if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND')); if (changes === 0) throw NotFoundError('Rule not found');
res.json({ success: true }); res.json({ success: true });
}); });

View File

@ -4,7 +4,7 @@ import type { Req, Res, Next } from '../types/http';
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
const { const {
decorateDataSource, decorateDataSource,
ensureManualDataSource, ensureManualDataSource,
@ -26,10 +26,19 @@ function cleanFilter(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function safeError(err, fallback) { // SimpleFIN/service errors are deliberately surfaced to the user — but only
// after sanitizeErrorMessage strips tokens/URLs. Wrapping in ApiError keeps
// the sanitized message through the terminal handler (raw 5xx would be masked).
function toSourceError(err, fallback, code = 'SIMPLEFIN_ERROR') {
const msg = sanitizeErrorMessage(err?.message || fallback); const msg = sanitizeErrorMessage(err?.message || fallback);
const status = typeof err?.status === 'number' ? err.status : 500; const status = typeof err?.status === 'number' ? err.status : 500;
return { msg, status }; return new ApiError(err?.code || code, msg, status);
}
function requireBankSyncEnabled() {
if (!getBankSyncConfig().enabled) {
throw new ApiError('BANK_SYNC_DISABLED', 'Bank sync is not enabled on this server', 503);
}
} }
// ─── GET /api/data-sources/accounts/all ────────────────────────────────────── // ─── GET /api/data-sources/accounts/all ──────────────────────────────────────
@ -37,7 +46,6 @@ function safeError(err, fallback) {
// Used by the bank tracking account picker. // Used by the bank tracking account picker.
router.get('/accounts/all', (req: Req, res: Res) => { router.get('/accounts/all', (req: Req, res: Res) => {
try {
const db = getDb(); const db = getDb();
const accounts = db const accounts = db
.prepare( .prepare(
@ -61,9 +69,6 @@ router.get('/accounts/all', (req: Req, res: Res) => {
balance_dollars: a.balance !== null ? a.balance / 100 : null, balance_dollars: a.balance !== null ? a.balance / 100 : null,
})), })),
); );
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
}
}); });
// ─── GET /api/data-sources ──────────────────────────────────────────────────── // ─── GET /api/data-sources ────────────────────────────────────────────────────
@ -76,21 +81,9 @@ router.get('/', (req: Req, res: Res) => {
const status = cleanFilter(req.query.status); const status = cleanFilter(req.query.status);
if (type && !VALID_TYPES.has(type)) if (type && !VALID_TYPES.has(type))
return res throw ValidationError('type must be manual, file_import, or provider_sync', 'type');
.status(400)
.json(
standardizeError(
'type must be manual, file_import, or provider_sync',
'VALIDATION_ERROR',
'type',
),
);
if (status && !VALID_STATUSES.has(status)) if (status && !VALID_STATUSES.has(status))
return res throw ValidationError('status must be active, inactive, or error', 'status');
.status(400)
.json(
standardizeError('status must be active, inactive, or error', 'VALIDATION_ERROR', 'status'),
);
let query = ` let query = `
SELECT SELECT
@ -159,17 +152,11 @@ router.get('/simplefin/status', (req: Req, res: Res) => {
// ─── POST /api/data-sources/simplefin/connect ──────────────────────────────── // ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
router.post('/simplefin/connect', async (req: Req, res: Res) => { router.post('/simplefin/connect', async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : ''; const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : '';
if (!setupToken) { if (!setupToken) {
return res throw ValidationError('setupToken is required', 'setupToken');
.status(400)
.json(standardizeError('setupToken is required', 'VALIDATION_ERROR', 'setupToken'));
} }
try { try {
@ -177,8 +164,7 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
const result = await connectSimplefin(db, req.user.id, setupToken); const result = await connectSimplefin(db, req.user.id, setupToken);
res.status(201).json(result); res.status(201).json(result);
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Failed to connect SimpleFIN'); throw toSourceError(err, 'Failed to connect SimpleFIN');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
} }
}); });
@ -187,19 +173,15 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
router.get('/:sourceId/accounts', (req: Req, res: Res) => { router.get('/:sourceId/accounts', (req: Req, res: Res) => {
const sourceId = parseInt(req.params.sourceId, 10); const sourceId = parseInt(req.params.sourceId, 10);
if (!Number.isInteger(sourceId) || sourceId < 1) { if (!Number.isInteger(sourceId) || sourceId < 1) {
return res throw ValidationError('Invalid data source id', 'sourceId');
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId'));
} }
try {
const db = getDb(); const db = getDb();
const source = db const source = db
.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?') .prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?')
.get(sourceId, req.user.id); .get(sourceId, req.user.id);
if (!source) if (!source) throw NotFoundError('Data source not found');
return res.status(404).json(standardizeError('Data source not found', 'NOT_FOUND'));
const accounts = db const accounts = db
.prepare( .prepare(
@ -236,9 +218,6 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
})); }));
res.json(result); res.json(result);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
}
}); });
// ─── PUT /api/data-sources/:sourceId/accounts/:accountId ───────────────────── // ─── PUT /api/data-sources/:sourceId/accounts/:accountId ─────────────────────
@ -252,15 +231,12 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
!Number.isInteger(accountId) || !Number.isInteger(accountId) ||
accountId < 1 accountId < 1
) { ) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR')); throw ValidationError('Invalid id');
} }
if (typeof req.body?.monitored !== 'boolean') { if (typeof req.body?.monitored !== 'boolean') {
return res throw ValidationError('monitored must be a boolean', 'monitored');
.status(400)
.json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored'));
} }
try {
const db = getDb(); const db = getDb();
const result = db const result = db
.prepare( .prepare(
@ -272,32 +248,22 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
) )
.run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id); .run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id);
if (result.changes === 0) if (result.changes === 0) throw NotFoundError('Account not found');
return res.status(404).json(standardizeError('Account not found', 'NOT_FOUND'));
const account = db const account = db
.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?') .prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?')
.get(accountId); .get(accountId);
res.json({ ...account, monitored: account.monitored === 1 }); res.json({ ...account, monitored: account.monitored === 1 });
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to update account', 'DB_ERROR'));
}
}); });
// ─── POST /api/data-sources/:id/sync ───────────────────────────────────────── // ─── POST /api/data-sources/:id/sync ─────────────────────────────────────────
router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => { router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) { if (!Number.isInteger(id) || id < 1) {
return res throw ValidationError('Invalid data source id', 'id');
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
} }
try { try {
@ -305,8 +271,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
const result = await syncDataSource(db, req.user.id, id); const result = await syncDataSource(db, req.user.id, id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Sync failed'); throw toSourceError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
} }
}); });
@ -314,11 +279,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
// Syncs every SimpleFIN source for the current user. Returns aggregated stats. // Syncs every SimpleFIN source for the current user. Returns aggregated stats.
router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => { router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
try { try {
const db = getDb(); const db = getDb();
@ -329,7 +290,7 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
.all(req.user.id); .all(req.user.id);
if (sources.length === 0) { if (sources.length === 0) {
return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_FOUND')); throw NotFoundError('No SimpleFIN connections found');
} }
let accountsUpserted = 0; let accountsUpserted = 0;
@ -364,25 +325,18 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
errors, errors,
}); });
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Sync failed'); throw toSourceError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, 'SIMPLEFIN_ERROR'));
} }
}); });
// ─── POST /api/data-sources/:id/backfill ───────────────────────────────────── // ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => { router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) { if (!Number.isInteger(id) || id < 1) {
return res throw ValidationError('Invalid data source id', 'id');
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
} }
try { try {
@ -390,33 +344,25 @@ router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
const result = await backfillDataSource(db, req.user.id, id); const result = await backfillDataSource(db, req.user.id, id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Backfill failed'); throw toSourceError(err, 'Backfill failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
} }
}); });
// ─── DELETE /api/data-sources/:id ──────────────────────────────────────────── // ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
router.delete('/:id', (req: Req, res: Res) => { router.delete('/:id', (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) { requireBankSyncEnabled();
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) { if (!Number.isInteger(id) || id < 1) {
return res throw ValidationError('Invalid data source id', 'id');
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
} }
try { try {
disconnectDataSource(getDb(), req.user.id, id); disconnectDataSource(getDb(), req.user.id, id);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
const { msg, status } = safeError(err, 'Failed to disconnect'); throw toSourceError(err, 'Failed to disconnect', 'DISCONNECT_ERROR');
res.status(status).json(standardizeError(msg, err?.code || 'DISCONNECT_ERROR'));
} }
}); });

View File

@ -342,11 +342,19 @@ function buildUserDbExportFile(userId) {
router.get('/user-db', (req: Req, res: Res) => { router.get('/user-db', (req: Req, res: Res) => {
const file = buildUserDbExportFile(req.user.id); const file = buildUserDbExportFile(req.user.id);
res.download(file, 'bill-tracker-user-export.sqlite', () => { // root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
res.download(
path.basename(file),
'bill-tracker-user-export.sqlite',
{
root: path.dirname(file),
},
() => {
try { try {
fs.unlinkSync(file); fs.unlinkSync(file);
} catch {} } catch {}
}); },
);
}); });
// Full portable JSON export of the user's data (same assembly as SQLite/Excel). // Full portable JSON export of the user's data (same assembly as SQLite/Excel).

View File

@ -39,7 +39,9 @@ function makeErrorId() {
} }
function sendImportError(res, err, fallback, defaultCode) { function sendImportError(res, err, fallback, defaultCode) {
if (err.status) { // Only 4xx service errors carry a user-facing message; a thrown error with
// an explicit 5xx status must fall through to the masked error-id branch.
if (err.status && err.status < 500) {
return res.status(err.status).json({ return res.status(err.status).json({
error: fallback, error: fallback,
message: err.message, message: err.message,

View File

@ -1,9 +1,13 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const router = require('express').Router(); const router = require('express').Router();
const { log } = require('../utils/logger.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { const {
applyBankPaymentAsSourceOfTruth, applyBankPaymentAsSourceOfTruth,
reactivatePaymentsOverriddenBy, reactivatePaymentsOverriddenBy,
@ -17,32 +21,23 @@ const { markMatched, markUnmatched } = require('../services/transactionMatchStat
const { serializePayment } = require('../services/paymentValidation.cts'); const { serializePayment } = require('../services/paymentValidation.cts');
const { todayLocal } = require('../utils/dates.mts'); const { todayLocal } = require('../utils/dates.mts');
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') { // 4xx service errors keep their message/code on the wire; anything else is
if (err.status) { // rethrown raw so the terminal handler masks + logs it as a 5xx.
return res function toMatchError(err) {
.status(err.status) if (err.status && err.status < 500) {
.json(standardizeError(err.message, err.code || 'MATCH_ERROR', err.field)); return new ApiError(err.code || 'MATCH_ERROR', err.message, err.status, { field: err.field });
} }
log.error('[matches] service error:', err.stack || err.message); return err;
return res.status(500).json(standardizeError(fallbackMessage, 'MATCH_ERROR'));
} }
// GET /api/matches/suggestions // GET /api/matches/suggestions
router.get('/suggestions', (req: Req, res: Res) => { router.get('/suggestions', (req: Req, res: Res) => {
try {
res.json(listMatchSuggestions(req.user.id, req.query)); res.json(listMatchSuggestions(req.user.id, req.query));
} catch (err) {
return sendMatchError(res, err, 'Match suggestions failed');
}
}); });
// POST /api/matches/:id/reject // POST /api/matches/:id/reject
router.post('/:id/reject', (req: Req, res: Res) => { router.post('/:id/reject', (req: Req, res: Res) => {
try {
res.json(rejectMatchSuggestion(req.user.id, req.params.id)); res.json(rejectMatchSuggestion(req.user.id, req.params.id));
} catch (err) {
return sendMatchError(res, err, 'Rejecting match suggestion failed');
}
}); });
// POST /api/matches/confirm — link a transaction to a bill and record a payment // POST /api/matches/confirm — link a transaction to a bill and record a payment
@ -50,46 +45,36 @@ router.post('/confirm', (req: Req, res: Res) => {
const txId = parseInt(req.body?.transaction_id, 10); const txId = parseInt(req.body?.transaction_id, 10);
const billId = parseInt(req.body?.bill_id, 10); const billId = parseInt(req.body?.bill_id, 10);
if (!Number.isInteger(txId) || !Number.isInteger(billId)) { if (!Number.isInteger(txId) || !Number.isInteger(billId)) {
return res throw ValidationError('transaction_id and bill_id are required integers');
.status(400)
.json(
standardizeError('transaction_id and bill_id are required integers', 'VALIDATION_ERROR'),
);
} }
const db = getDb(); const db = getDb();
const tx = db const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
.get(txId, req.user.id); .get(txId, req.user.id);
if (!tx) if (!tx) throw NotFoundError('Transaction not found', 'transaction_id');
return res
.status(404)
.json(standardizeError('Transaction not found', 'NOT_FOUND', 'transaction_id'));
if (tx.match_status === 'matched') { if (tx.match_status === 'matched') {
return res throw ConflictError(
.status(409)
.json(
standardizeError(
'Transaction is already matched to a bill', 'Transaction is already matched to a bill',
'ALREADY_MATCHED',
'transaction_id', 'transaction_id',
), 'ALREADY_MATCHED',
); );
} }
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const existing = db const existing = db
.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL')
.get(txId); .get(txId);
if (existing) if (existing)
return res throw ConflictError(
.status(409) 'A payment is already linked to this transaction',
.json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH')); undefined,
'DUPLICATE_MATCH',
);
const paidDate = const paidDate =
tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal()); tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal());
@ -134,7 +119,7 @@ router.post('/confirm', (req: Req, res: Res) => {
try { try {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
} catch {} } catch {}
return sendMatchError(res, err, 'Failed to confirm match'); throw toMatchError(err);
} }
}); });
@ -142,18 +127,16 @@ router.post('/confirm', (req: Req, res: Res) => {
router.post('/:transactionId/unmatch', (req: Req, res: Res) => { router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
const txId = parseInt(req.params.transactionId, 10); const txId = parseInt(req.params.transactionId, 10);
if (!Number.isInteger(txId)) { if (!Number.isInteger(txId)) {
return res throw ValidationError('transactionId must be an integer');
.status(400)
.json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR'));
} }
const db = getDb(); const db = getDb();
const tx = db const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
.get(txId, req.user.id); .get(txId, req.user.id);
if (!tx) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND')); if (!tx) throw NotFoundError('Transaction not found');
if (tx.match_status !== 'matched') { if (tx.match_status !== 'matched') {
return res.status(409).json(standardizeError('Transaction is not matched', 'NOT_MATCHED')); throw ConflictError('Transaction is not matched', undefined, 'NOT_MATCHED');
} }
try { try {
@ -183,7 +166,7 @@ router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
try { try {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
} catch {} } catch {}
return sendMatchError(res, err, 'Failed to unmatch transaction'); throw toMatchError(err);
} }
}); });

View File

@ -9,6 +9,7 @@ const { sendTestEmail } = require('../services/notificationService.cts');
const { sendTestPush } = require('../services/notificationService.cts')._push || {}; const { sendTestPush } = require('../services/notificationService.cts')._push || {};
const { decryptSecret } = require('../services/encryptionService.cts'); const { decryptSecret } = require('../services/encryptionService.cts');
const { encryptSecret } = require('../services/encryptionService.cts'); const { encryptSecret } = require('../services/encryptionService.cts');
const { ApiError, ValidationError } = require('../utils/apiError.cts');
// ── Admin: SMTP configuration ───────────────────────────────────────────────── // ── Admin: SMTP configuration ─────────────────────────────────────────────────
@ -74,13 +75,16 @@ router.put('/admin', requireAuth, requireAdmin, (req: Req, res: Res) => {
// POST /api/notifications/test — send a test email // POST /api/notifications/test — send a test email
router.post('/test', requireAuth, requireAdmin, async (req: Req, res: Res) => { router.post('/test', requireAuth, requireAdmin, async (req: Req, res: Res) => {
const { to } = req.body; const { to } = req.body;
if (!to) return res.status(400).json({ error: 'Recipient address required' }); if (!to) throw ValidationError('Recipient address required', 'to');
try { try {
await sendTestEmail(to); await sendTestEmail(to);
res.json({ success: true });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); // Deliberate pass-through: the SMTP error text is the admin's own config
// feedback (bad host/auth/TLS). ApiError messages survive formatError;
// anything not wrapped would be masked as a generic 5xx.
throw new ApiError('NOTIFICATION_TEST_FAILED', err.message || 'Test email failed', 502);
} }
res.json({ success: true });
}); });
// ── User: notification preferences ─────────────────────────────────────────── // ── User: notification preferences ───────────────────────────────────────────
@ -164,7 +168,7 @@ router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) =
.get(req.user.id); .get(req.user.id);
if (!user?.push_channel || !user?.push_url) { if (!user?.push_channel || !user?.push_url) {
return res.status(400).json({ error: 'Push notification channel not configured' }); throw ValidationError('Push notification channel not configured', 'push_channel');
} }
// Decrypt for sending // Decrypt for sending
@ -185,10 +189,12 @@ router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) =
try { try {
if (!sendTestPush) throw new Error('Push service not initialised'); if (!sendTestPush) throw new Error('Push service not initialised');
await sendTestPush(userForPush); await sendTestPush(userForPush);
res.json({ success: true });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); // Deliberate pass-through: the push-provider error is the user's own
// channel-config feedback (bad URL/token) — same rationale as /test above.
throw new ApiError('NOTIFICATION_TEST_FAILED', err.message || 'Test push failed', 502);
} }
res.json({ success: true });
}); });
module.exports = router; module.exports = router;

View File

@ -1,8 +1,13 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts');
@ -27,15 +32,11 @@ function isTransactionLinkedPayment(payment) {
return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null; return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null;
} }
function rejectTransactionLinkedPayment(res) { function rejectTransactionLinkedPayment() {
return res throw ConflictError(
.status(409)
.json(
standardizeError(
'Transaction-linked payments must be changed through transaction match controls', 'Transaction-linked payments must be changed through transaction match controls',
'TRANSACTION_PAYMENT_LOCKED',
'transaction_id', 'transaction_id',
), 'TRANSACTION_PAYMENT_LOCKED',
); );
} }
@ -43,22 +44,10 @@ function parseYearMonth(body) {
const year = parseInt(body.year, 10); const year = parseInt(body.year, 10);
const month = parseInt(body.month, 10); const month = parseInt(body.month, 10);
if (!Number.isInteger(year) || year < 2000 || year > 2100) { if (!Number.isInteger(year) || year < 2000 || year > 2100) {
return { throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
error: standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
};
} }
if (!Number.isInteger(month) || month < 1 || month > 12) { if (!Number.isInteger(month) || month < 1 || month > 12) {
return { throw ValidationError('month must be an integer between 1 and 12', 'month');
error: standardizeError(
'month must be an integer between 1 and 12',
'VALIDATION_ERROR',
'month',
),
};
} }
return { year, month }; return { year, month };
} }
@ -73,17 +62,9 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
`, `,
) )
.get(billId, userId); .get(billId, userId);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 };
if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') { if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
return { throw ValidationError('Bill is not eligible for autopay suggestions', 'bill_id');
error: standardizeError(
'Bill is not eligible for autopay suggestions',
'VALIDATION_ERROR',
'bill_id',
),
status: 400,
};
} }
const state = db const state = db
@ -96,26 +77,12 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
) )
.get(bill.id, year, month); .get(bill.id, year, month);
if (state?.is_skipped) { if (state?.is_skipped) {
return { throw ValidationError('Skipped bills cannot be suggested for payment', 'bill_id');
error: standardizeError(
'Skipped bills cannot be suggested for payment',
'VALIDATION_ERROR',
'bill_id',
),
status: 400,
};
} }
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) { if (!dueDate) {
return { throw ValidationError('Bill does not occur in the selected month', 'month');
error: standardizeError(
'Bill does not occur in the selected month',
'VALIDATION_ERROR',
'month',
),
status: 400,
};
} }
const amount = fromCents(state?.actual_amount ?? bill.expected_amount); const amount = fromCents(state?.actual_amount ?? bill.expected_amount);
return { bill, dueDate, amount }; return { bill, dueDate, amount };
@ -128,15 +95,7 @@ router.get('/', (req: Req, res: Res) => {
// Validate year/month when provided // Validate year/month when provided
if ((year || month) && !(year && month)) { if ((year || month) && !(year && month)) {
return res throw ValidationError('Both year and month are required when filtering by date', 'year');
.status(400)
.json(
standardizeError(
'Both year and month are required when filtering by date',
'VALIDATION_ERROR',
'year',
),
);
} }
let y, m; let y, m;
@ -144,26 +103,10 @@ router.get('/', (req: Req, res: Res) => {
y = parseInt(year, 10); y = parseInt(year, 10);
m = parseInt(month, 10); m = parseInt(month, 10);
if (!Number.isInteger(y) || y < 2000 || y > 2100) { if (!Number.isInteger(y) || y < 2000 || y > 2100) {
return res throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
} }
if (!Number.isInteger(m) || m < 1 || m > 12) { if (!Number.isInteger(m) || m < 1 || m > 12) {
return res throw ValidationError('month must be an integer between 1 and 12', 'month');
.status(400)
.json(
standardizeError(
'month must be an integer between 1 and 12',
'VALIDATION_ERROR',
'month',
),
);
} }
} }
@ -228,8 +171,7 @@ router.get('/:id', (req: Req, res: Res) => {
`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!payment) if (!payment) throw NotFoundError('Payment not found', 'id');
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
res.json(serializePayment(payment)); res.json(serializePayment(payment));
}); });
@ -246,20 +188,18 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => {
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!payment) if (!payment) throw NotFoundError('Payment not found', 'id');
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (payment.payment_source !== 'provider_sync') { if (payment.payment_source !== 'provider_sync') {
return res throw ConflictError(
.status(409) 'Only provider_sync payments can be undone here',
.json(standardizeError('Only provider_sync payments can be undone here', 'NOT_AUTO_MATCH')); undefined,
'NOT_AUTO_MATCH',
);
} }
if (!payment.transaction_id) { if (!payment.transaction_id) {
return res throw ConflictError('Payment has no linked transaction', undefined, 'NO_TRANSACTION');
.status(409)
.json(standardizeError('Payment has no linked transaction', 'NO_TRANSACTION'));
} }
try {
db.transaction(() => { db.transaction(() => {
// Restore balance (same logic as DELETE /:id) // Restore balance (same logic as DELETE /:id)
if (!payment.accounting_excluded && payment.balance_delta != null) { if (!payment.accounting_excluded && payment.balance_delta != null) {
@ -267,10 +207,7 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => {
.prepare('SELECT current_balance FROM bills WHERE id = ?') .prepare('SELECT current_balance FROM bills WHERE id = ?')
.get(payment.bill_id); .get(payment.bill_id);
if (bill?.current_balance != null) { if (bill?.current_balance != null) {
const restored = Math.max( const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta));
0,
Number(bill.current_balance) - Number(payment.balance_delta),
);
db.prepare( db.prepare(
` `
UPDATE bills UPDATE bills
@ -287,10 +224,6 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => {
markUnmatched(db, req.user.id, payment.transaction_id); markUnmatched(db, req.user.id, payment.transaction_id);
})(); })();
res.json({ success: true }); res.json({ success: true });
} catch (err) {
log.error('[payments] undo-auto error:', err.message);
res.status(500).json(standardizeError('Failed to undo auto-match', 'SERVER_ERROR'));
}
}); });
// POST /api/payments — create single payment // POST /api/payments — create single payment
@ -313,18 +246,13 @@ router.post('/', (req: Req, res: Res) => {
paid_date, paid_date,
payment_source: payment_source ?? 'manual', payment_source: payment_source ?? 'manual',
}); });
if (validation.error) { if (validation.error) throw ValidationError(validation.error, validation.field);
return res
.status(400)
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const payment = validation.normalized; const payment = validation.normalized;
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(payment.bill_id, req.user.id); .get(payment.bill_id, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// The deliberate manual add must not *silently* drop a same-key payment — a user // The deliberate manual add must not *silently* drop a same-key payment — a user
// may legitimately record two identical payments on the same day, and dropping // may legitimately record two identical payments on the same day, and dropping
@ -392,17 +320,12 @@ router.post('/quick', (req: Req, res: Res) => {
{ bill_id }, { bill_id },
{ requireAmount: false, requirePaidDate: false }, { requireAmount: false, requirePaidDate: false },
); );
if (billValidation.error) { if (billValidation.error) throw ValidationError(billValidation.error, billValidation.field);
return res
.status(400)
.json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
}
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billValidation.normalized.bill_id, req.user.id); .get(billValidation.normalized.bill_id, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
{ {
@ -412,11 +335,8 @@ router.post('/quick', (req: Req, res: Res) => {
}, },
{ requireBillId: false }, { requireBillId: false },
); );
if (paymentValidation.error) { if (paymentValidation.error)
return res throw ValidationError(paymentValidation.error, paymentValidation.field);
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const payAmount = paymentValidation.normalized.amount; const payAmount = paymentValidation.normalized.amount;
const payDate = paymentValidation.normalized.paid_date; const payDate = paymentValidation.normalized.paid_date;
const paySource = paymentValidation.normalized.payment_source; const paySource = paymentValidation.normalized.payment_source;
@ -468,32 +388,23 @@ router.post('/quick', (req: Req, res: Res) => {
router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => { router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const ym = parseYearMonth(req.body); const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10); const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
return res throw ValidationError('bill_id must be an integer', 'bill_id');
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month); const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month);
if (context.error) return res.status(context.status).json(context.error);
const { bill, dueDate, amount } = context; const { bill, dueDate, amount } = context;
if (dueDate > todayLocal()) { if (dueDate > todayLocal()) {
return res throw ValidationError('Autopay suggestion is not due yet', 'paid_date');
.status(400)
.json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
} }
const paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
{ amount, paid_date: dueDate }, { amount, paid_date: dueDate },
{ requireBillId: false }, { requireBillId: false },
); );
if (paymentValidation.error) { if (paymentValidation.error)
return res throw ValidationError(paymentValidation.error, paymentValidation.field);
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const suggestedPayment = paymentValidation.normalized; const suggestedPayment = paymentValidation.normalized;
const suggestionRange = getCycleRange(ym.year, ym.month, bill); const suggestionRange = getCycleRange(ym.year, ym.month, bill);
@ -560,20 +471,17 @@ router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => { router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const ym = parseYearMonth(req.body); const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10); const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
return res throw ValidationError('bill_id must be an integer', 'bill_id');
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
if ( if (
!db !db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) { ) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); throw NotFoundError('Bill not found', 'bill_id');
} }
db.prepare( db.prepare(
@ -602,23 +510,11 @@ router.post('/bulk', (req: Req, res: Res) => {
// Validate request body has payments array // Validate request body has payments array
if (!payments || !Array.isArray(payments)) if (!payments || !Array.isArray(payments))
return res throw ValidationError('Request body must contain a `payments` array', 'payments');
.status(400)
.json(
standardizeError(
'Request body must contain a `payments` array',
'VALIDATION_ERROR',
'payments',
),
);
// Validate max items per request (50) // Validate max items per request (50)
if (payments.length > 50) if (payments.length > 50)
return res throw ValidationError('Maximum 50 items allowed per request', 'payments');
.status(400)
.json(
standardizeError('Maximum 50 items allowed per request', 'VALIDATION_ERROR', 'payments'),
);
// Validate each payment item // Validate each payment item
for (let i = 0; i < payments.length; i++) { for (let i = 0; i < payments.length; i++) {
@ -628,15 +524,7 @@ router.post('/bulk', (req: Req, res: Res) => {
{ fieldPrefix: `payments[${i}].` }, { fieldPrefix: `payments[${i}].` },
); );
if (validation.error) { if (validation.error) {
return res throw ValidationError(`Payment at index ${i}: ${validation.error}`, validation.field);
.status(400)
.json(
standardizeError(
`Payment at index ${i}: ${validation.error}`,
'VALIDATION_ERROR',
validation.field,
),
);
} }
} }
@ -716,20 +604,15 @@ router.put('/:id', (req: Req, res: Res) => {
`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!existing) if (!existing) throw NotFoundError('Payment not found', 'id');
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (isTransactionLinkedPayment(existing)) rejectTransactionLinkedPayment();
if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res);
const { amount, paid_date, method, notes, payment_source } = req.body; const { amount, paid_date, method, notes, payment_source } = req.body;
const validation = validatePaymentInput( const validation = validatePaymentInput(
{ amount, paid_date, payment_source }, { amount, paid_date, payment_source },
{ requireBillId: false, requireAmount: false, requirePaidDate: false }, { requireBillId: false, requireAmount: false, requirePaidDate: false },
); );
if (validation.error) { if (validation.error) throw ValidationError(validation.error, validation.field);
return res
.status(400)
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const nextAmount = validation.normalized.amount ?? existing.amount; const nextAmount = validation.normalized.amount ?? existing.amount;
const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date; const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date;
@ -824,9 +707,8 @@ router.delete('/:id', (req: Req, res: Res) => {
`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!payment) if (!payment) throw NotFoundError('Payment not found', 'id');
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Atomic: reverse the balance delta and soft-delete the payment together, so a // Atomic: reverse the balance delta and soft-delete the payment together, so a
// failure can't leave the balance restored while the payment is still active // failure can't leave the balance restored while the payment is still active
@ -868,9 +750,8 @@ router.post('/:id/restore', (req: Req, res: Res) => {
'SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL', 'SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL',
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!payment) if (!payment) throw NotFoundError('Deleted payment not found', 'id');
return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id')); if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Atomic: re-apply the balance delta and un-delete the payment together, so a // Atomic: re-apply the balance delta and un-delete the payment together, so a
// failure can't leave the balance re-applied while the payment stays deleted // failure can't leave the balance re-applied while the payment stays deleted
@ -923,26 +804,19 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const paymentId = parseInt(req.params.id, 10); const paymentId = parseInt(req.params.id, 10);
if (!Number.isInteger(paymentId) || paymentId < 1) { if (!Number.isInteger(paymentId) || paymentId < 1) {
return res.status(400).json(standardizeError('Invalid payment id', 'VALIDATION_ERROR')); throw ValidationError('Invalid payment id');
} }
const { paid_date } = req.body; const { paid_date } = req.body;
if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) { if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) {
return res throw ValidationError('paid_date must be YYYY-MM-DD', 'paid_date');
.status(400)
.json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date'));
} }
// Validate it is a real calendar date // Validate it is a real calendar date
const newDate = new Date(paid_date + 'T00:00:00Z'); const newDate = new Date(paid_date + 'T00:00:00Z');
if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) { if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
return res throw ValidationError('paid_date is not a valid calendar date', 'paid_date');
.status(400)
.json(
standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'),
);
} }
try {
const payment = db const payment = db
.prepare( .prepare(
` `
@ -953,17 +827,14 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
) )
.get(paymentId, req.user.id); .get(paymentId, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND')); if (!payment) throw NotFoundError('Payment not found');
// Only allow date-only reclassification for provider_sync payments // Only allow date-only reclassification for provider_sync payments
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') { if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
return res throw ConflictError(
.status(409)
.json(
standardizeError(
'Only bank-synced payments can be reclassified to a different month', 'Only bank-synced payments can be reclassified to a different month',
undefined,
'RECLASSIFY_ONLY_SYNC', 'RECLASSIFY_ONLY_SYNC',
),
); );
} }
@ -972,21 +843,17 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
const origYM = orig.getFullYear() * 12 + orig.getMonth(); const origYM = orig.getFullYear() * 12 + orig.getMonth();
const newYM = newDate.getFullYear() * 12 + newDate.getMonth(); const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
if (newYM !== origYM - 1) { if (newYM !== origYM - 1) {
return res throw ValidationError(
.status(400)
.json(
standardizeError(
'The new paid_date must be in the month immediately before the original payment date', 'The new paid_date must be in the month immediately before the original payment date',
'VALIDATION_ERROR',
'paid_date', 'paid_date',
),
); );
} }
db.transaction(() => { db.transaction(() => {
db.prepare( db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?").run(
"UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?", paid_date,
).run(paid_date, paymentId); paymentId,
);
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
@ -1003,10 +870,6 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
.get(paymentId, req.user.id), .get(paymentId, req.user.id),
), ),
); );
} catch (err) {
log.error('[payments] attribute-to-month error:', err.message);
res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));
}
}); });
module.exports = router; module.exports = router;

View File

@ -3,7 +3,7 @@ import type { Req, Res, Next } from '../types/http';
('use strict'); ('use strict');
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { passwordLimiter } = require('../middleware/rateLimiter.cts'); const { passwordLimiter } = require('../middleware/rateLimiter.cts');
@ -18,7 +18,14 @@ const {
} = require('../services/authService.cts'); } = require('../services/authService.cts');
const { getImportHistory } = require('../services/spreadsheetImportService.cts'); const { getImportHistory } = require('../services/spreadsheetImportService.cts');
const { logAudit } = require('../services/auditService.cts'); const { logAudit } = require('../services/auditService.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const {
ApiError,
ValidationError,
AuthError,
ForbiddenError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts'); const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts');
// All profile routes require authentication — enforced in server.js. // All profile routes require authentication — enforced in server.js.
@ -30,9 +37,7 @@ function dataImportEnabled() {
function requireDataImportEnabled(req, res, next) { function requireDataImportEnabled(req, res, next) {
if (!dataImportEnabled()) { if (!dataImportEnabled()) {
return res throw ForbiddenError('Data import is disabled by DATA_IMPORT_ENABLED=false');
.status(403)
.json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN'));
} }
next(); next();
} }
@ -73,7 +78,7 @@ router.get('/', (req: Req, res: Res) => {
) )
.get(req.user.id); .get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) throw NotFoundError('User not found');
res.json({ res.json({
...profileResponse(user), ...profileResponse(user),
@ -108,20 +113,20 @@ router.patch('/', (req: Req, res: Res) => {
if (username !== undefined) { if (username !== undefined) {
if (typeof username !== 'string') { if (typeof username !== 'string') {
return res.status(400).json({ error: 'username must be a string' }); throw ValidationError('username must be a string', 'username');
} }
const trimmedUsername = username.trim(); const trimmedUsername = username.trim();
if (trimmedUsername.length < 3) { if (trimmedUsername.length < 3) {
return res.status(400).json({ error: 'username must be at least 3 characters' }); throw ValidationError('username must be at least 3 characters', 'username');
} }
if (trimmedUsername.length > 50) { if (trimmedUsername.length > 50) {
return res.status(400).json({ error: 'username must be 50 characters or fewer' }); throw ValidationError('username must be 50 characters or fewer', 'username');
} }
const taken = db const taken = db
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?') .prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
.get(trimmedUsername, req.user.id); .get(trimmedUsername, req.user.id);
if (taken) { if (taken) {
return res.status(409).json({ error: 'Username already taken' }); throw ConflictError('Username already taken', 'username');
} }
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run( db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
trimmedUsername, trimmedUsername,
@ -137,11 +142,11 @@ router.patch('/', (req: Req, res: Res) => {
if (displayNameInput !== undefined) { if (displayNameInput !== undefined) {
if (typeof displayNameInput !== 'string') { if (typeof displayNameInput !== 'string') {
return res.status(400).json({ error: 'display_name must be a string' }); throw ValidationError('display_name must be a string', 'display_name');
} }
const trimmed = displayNameInput.trim(); const trimmed = displayNameInput.trim();
if (trimmed.length > 100) { if (trimmed.length > 100) {
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' }); throw ValidationError('display_name must be 100 characters or fewer', 'display_name');
} }
db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run( db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(
@ -188,7 +193,7 @@ router.get('/settings', (req: Req, res: Res) => {
) )
.get(req.user.id); .get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) throw NotFoundError('User not found');
// Decrypt push secrets — return masked indicator for token (never the raw value) // Decrypt push secrets — return masked indicator for token (never the raw value)
const pushUrlDecrypted = user.push_url const pushUrlDecrypted = user.push_url
@ -246,10 +251,10 @@ router.patch('/settings', (req: Req, res: Res) => {
if (nextEmail !== undefined && nextEmail !== null) { if (nextEmail !== undefined && nextEmail !== null) {
if (typeof nextEmail !== 'string') { if (typeof nextEmail !== 'string') {
return res.status(400).json({ error: 'notification_email must be a string' }); throw ValidationError('notification_email must be a string', 'notification_email');
} }
if (nextEmail.trim().length > 255) { if (nextEmail.trim().length > 255) {
return res.status(400).json({ error: 'notification_email is too long' }); throw ValidationError('notification_email is too long', 'notification_email');
} }
} }
@ -350,29 +355,30 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
const { current_password, new_password, confirm_new_password } = req.body; const { current_password, new_password, confirm_new_password } = req.body;
if (!current_password) { if (!current_password) {
return res.status(400).json({ error: 'current_password is required' }); throw ValidationError('current_password is required', 'current_password');
} }
if (!new_password) { if (!new_password) {
return res.status(400).json({ error: 'new_password is required' }); throw ValidationError('new_password is required', 'new_password');
} }
if (!confirm_new_password) { if (!confirm_new_password) {
return res.status(400).json({ error: 'confirm_new_password is required' }); throw ValidationError('confirm_new_password is required', 'confirm_new_password');
} }
if (new_password !== confirm_new_password) { if (new_password !== confirm_new_password) {
return res.status(400).json({ error: 'new passwords do not match' }); throw ValidationError('new passwords do not match', 'confirm_new_password');
} }
if (new_password.length < 8) { if (new_password.length < 8) {
return res.status(400).json({ error: 'new password must be at least 8 characters' }); throw ValidationError('new password must be at least 8 characters', 'new_password');
} }
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) throw NotFoundError('User not found');
try {
const valid = await bcrypt.compare(current_password, user.password_hash); const valid = await bcrypt.compare(current_password, user.password_hash);
if (!valid) { if (!valid) {
return res.status(401).json({ error: 'current password is incorrect' }); throw new ApiError('AUTH_ERROR', 'current password is incorrect', 401, {
field: 'current_password',
});
} }
const hash = await hashPassword(new_password); const hash = await hashPassword(new_password);
@ -407,10 +413,6 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
}); });
res.json({ success: true }); res.json({ success: true });
} catch (err) {
log.error('[profile] change-password error:', err.message);
res.status(500).json({ error: 'Password change failed' });
}
}); });
// ── GET /api/profile/exports ────────────────────────────────────────────────── // ── GET /api/profile/exports ──────────────────────────────────────────────────
@ -441,13 +443,8 @@ router.get('/exports', (req: Req, res: Res) => {
// Returns the signed-in user's import history. // Returns the signed-in user's import history.
// Delegates to the same service as GET /api/import/history. // Delegates to the same service as GET /api/import/history.
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => { router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
try {
const history = getImportHistory(req.user.id); const history = getImportHistory(req.user.id);
res.json({ history }); res.json({ history });
} catch (err) {
log.error('[profile] import-history error:', err.message);
res.status(500).json({ error: 'Failed to load import history' });
}
}); });
module.exports = router; module.exports = router;

View File

@ -1,10 +1,9 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { ValidationError, NotFoundError } = require('../utils/apiError.cts');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts'); const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts');
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts'); const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts');
const { serializeBill } = require('../services/billsService.cts'); const { serializeBill } = require('../services/billsService.cts');
@ -122,15 +121,7 @@ router.patch('/settings', (req: Req, res: Res) => {
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') { if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
val = parseFloat(extra_payment); val = parseFloat(extra_payment);
if (!Number.isFinite(val) || val < 0) { if (!Number.isFinite(val) || val < 0) {
return res throw ValidationError('extra_payment must be a non-negative number', 'extra_payment');
.status(400)
.json(
standardizeError(
'extra_payment must be a non-negative number',
'VALIDATION_ERROR',
'extra_payment',
),
);
} }
} }
@ -261,9 +252,7 @@ function buildComparison(snowball, minimum_only) {
router.patch('/order', (req: Req, res: Res) => { router.patch('/order', (req: Req, res: Res) => {
const items = req.body; const items = req.body;
if (!Array.isArray(items)) { if (!Array.isArray(items)) {
return res throw ValidationError('Request body must be an array');
.status(400)
.json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
} }
if (items.length === 0) { if (items.length === 0) {
return res.json({ success: true, updated: 0 }); return res.json({ success: true, updated: 0 });
@ -276,23 +265,11 @@ router.patch('/order', (req: Req, res: Res) => {
const id = parseInt(row?.id, 10); const id = parseInt(row?.id, 10);
const order = parseInt(row?.snowball_order, 10); const order = parseInt(row?.snowball_order, 10);
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
return res throw ValidationError(`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`);
.status(400)
.json(
standardizeError(
`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`,
'VALIDATION_ERROR',
),
);
} }
if (!Number.isInteger(order) || order < 0) { if (!Number.isInteger(order) || order < 0) {
return res throw ValidationError(
.status(400)
.json(
standardizeError(
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`, `Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
'VALIDATION_ERROR',
),
); );
} }
parsed.push({ id, order }); parsed.push({ id, order });
@ -373,7 +350,6 @@ function enrichPlanWithProgress(db, plan) {
// POST /api/snowball/plans — start a new snowball plan // POST /api/snowball/plans — start a new snowball plan
router.post('/plans', (req: Req, res: Res) => { router.post('/plans', (req: Req, res: Res) => {
try {
const db = getDb(); const db = getDb();
const userId = req.user.id; const userId = req.user.id;
const { name, method, notes } = req.body; const { name, method, notes } = req.body;
@ -386,13 +362,10 @@ router.post('/plans', (req: Req, res: Res) => {
const debts = getDebtBills(userId, ramseyMode); const debts = getDebtBills(userId, ramseyMode);
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0); const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0);
if (activeDebts.length === 0) { if (activeDebts.length === 0) {
return res throw ValidationError(
.status(400)
.json(
standardizeError(
'No debts with a balance found. Add a balance to at least one bill.', 'No debts with a balance found. Add a balance to at least one bill.',
undefined,
'NO_DEBTS', 'NO_DEBTS',
),
); );
} }
@ -461,19 +434,12 @@ router.post('/plans', (req: Req, res: Res) => {
) )
.run(userId, planName, planMethod, extraCents, planSnapshot, notes || null); .run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
const plan = db const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
.prepare('SELECT * FROM snowball_plans WHERE id = ?')
.get(result.lastInsertRowid);
res.status(201).json(enrichPlanWithProgress(db, plan)); res.status(201).json(enrichPlanWithProgress(db, plan));
} catch (err) {
log.error('[snowball plans] POST error:', err.message);
res.status(500).json(standardizeError('Failed to start plan', 'PLAN_CREATE_ERROR'));
}
}); });
// GET /api/snowball/plans — list all plans for user // GET /api/snowball/plans — list all plans for user
router.get('/plans', (req: Req, res: Res) => { router.get('/plans', (req: Req, res: Res) => {
try {
const db = getDb(); const db = getDb();
const plans = db const plans = db
.prepare( .prepare(
@ -483,15 +449,10 @@ router.get('/plans', (req: Req, res: Res) => {
) )
.all(req.user.id); .all(req.user.id);
res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) }); res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) });
} catch (err) {
log.error('[snowball plans] GET /plans error:', err.message);
res.status(500).json(standardizeError('Failed to load plans', 'PLAN_LIST_ERROR'));
}
}); });
// GET /api/snowball/plans/active — return the active or paused plan (or null) // GET /api/snowball/plans/active — return the active or paused plan (or null)
router.get('/plans/active', (req: Req, res: Res) => { router.get('/plans/active', (req: Req, res: Res) => {
try {
const db = getDb(); const db = getDb();
const plan = db const plan = db
.prepare( .prepare(
@ -503,23 +464,17 @@ router.get('/plans/active', (req: Req, res: Res) => {
) )
.get(req.user.id); .get(req.user.id);
res.json(plan ? enrichPlanWithProgress(db, plan) : null); res.json(plan ? enrichPlanWithProgress(db, plan) : null);
} catch (err) {
log.error('[snowball plans] GET /plans/active error:', err.message);
res.status(500).json(standardizeError('Failed to load active plan', 'PLAN_ACTIVE_ERROR'));
}
}); });
// PATCH /api/snowball/plans/:id — update name or notes // PATCH /api/snowball/plans/:id — update name or notes
router.patch('/plans/:id', (req: Req, res: Res) => { router.patch('/plans/:id', (req: Req, res: Res) => {
try {
const db = getDb(); const db = getDb();
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid plan id', 'id');
return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id'));
const plan = db const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?') .prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id); .get(id, req.user.id);
if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND')); if (!plan) throw NotFoundError('Plan not found');
const { name, notes } = req.body; const { name, notes } = req.body;
const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name; const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name;
@ -533,44 +488,32 @@ router.patch('/plans/:id', (req: Req, res: Res) => {
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated)); res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
log.error('[snowball plans] PATCH error:', err.message);
res.status(500).json(standardizeError('Failed to update plan', 'PLAN_UPDATE_ERROR'));
}
}); });
// Shared status-transition handler for pause / resume / complete / abandon. // Shared status-transition handler for pause / resume / complete / abandon.
// `setSql` is a fixed constant per route (never user input). // `setSql` is a fixed constant per route (never user input).
function transitionPlan(req, res, { allowedFrom, setSql, action, past }) { function transitionPlan(req, res, { allowedFrom, setSql, past }) {
try {
const db = getDb(); const db = getDb();
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id')); throw ValidationError('Invalid plan id', 'id');
} }
const plan = db const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?') .prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id); .get(id, req.user.id);
if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND')); if (!plan) throw NotFoundError('Plan not found');
if (!allowedFrom.includes(plan.status)) { if (!allowedFrom.includes(plan.status)) {
return res throw ValidationError(
.status(400)
.json(
standardizeError(
`Only ${allowedFrom.join(' or ')} plans can be ${past}`, `Only ${allowedFrom.join(' or ')} plans can be ${past}`,
undefined,
'INVALID_PLAN_STATE', 'INVALID_PLAN_STATE',
),
); );
} }
db.prepare( db.prepare(`UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`).run(
`UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`, id,
).run(id); );
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated)); res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
log.error(`[snowball plans] ${action} error:`, err.message);
res.status(500).json(standardizeError(`Failed to ${action} plan`, 'PLAN_TRANSITION_ERROR'));
}
} }
// POST /api/snowball/plans/:id/{pause,resume,complete,abandon} // POST /api/snowball/plans/:id/{pause,resume,complete,abandon}

View File

@ -3,9 +3,9 @@ import type { Req, Res, Next } from '../types/http';
('use strict'); ('use strict');
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { ValidationError } = require('../utils/apiError.cts');
const { const {
getSpendingSummary, getSpendingSummary,
getSpendingTransactions, getSpendingTransactions,
@ -18,31 +18,27 @@ const {
getIncomeTransactions, getIncomeTransactions,
} = require('../services/spendingService.cts'); } = require('../services/spendingService.cts');
// Throws ValidationError on bad input; service errors propagate to the
// terminal handler (4xx with .status pass their message through, 5xx are
// masked + logged there — no per-route try/catch, per CODE_STANDARDS.md).
function parseYM(source) { function parseYM(source) {
const now = new Date(); const now = new Date();
const year = parseInt(source.year || now.getFullYear(), 10); const year = parseInt(source.year || now.getFullYear(), 10);
const month = parseInt(source.month || now.getMonth() + 1, 10); const month = parseInt(source.month || now.getMonth() + 1, 10);
if (isNaN(year) || year < 2000 || year > 2100) return { error: 'Invalid year' }; if (isNaN(year) || year < 2000 || year > 2100) throw ValidationError('Invalid year', 'year');
if (isNaN(month) || month < 1 || month > 12) return { error: 'Invalid month' }; if (isNaN(month) || month < 1 || month > 12) throw ValidationError('Invalid month', 'month');
return { year, month }; return { year, month };
} }
// GET /api/spending/summary?year=&month= // GET /api/spending/summary?year=&month=
router.get('/summary', (req: Req, res: Res) => { router.get('/summary', (req: Req, res: Res) => {
const ym = parseYM(req.query); const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
try {
res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month)); res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month));
} catch (err) {
log.error('[spending/summary]', err.message);
res.status(500).json({ error: 'Failed to load spending summary' });
}
}); });
// GET /api/spending/transactions?year=&month=&category_id=&page=&limit= // GET /api/spending/transactions?year=&month=&category_id=&page=&limit=
router.get('/transactions', (req: Req, res: Res) => { router.get('/transactions', (req: Req, res: Res) => {
const ym = parseYM(req.query); const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
const { category_id, page, limit } = req.query; const { category_id, page, limit } = req.query;
const categoryId = const categoryId =
@ -52,7 +48,6 @@ router.get('/transactions', (req: Req, res: Res) => {
? parseInt(category_id, 10) ? parseInt(category_id, 10)
: undefined; : undefined;
try {
res.json( res.json(
getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, { getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, {
categoryId, categoryId,
@ -61,47 +56,30 @@ router.get('/transactions', (req: Req, res: Res) => {
limit: Math.min(parseInt(limit || '50', 10), 200), limit: Math.min(parseInt(limit || '50', 10), 200),
}), }),
); );
} catch (err) {
log.error('[spending/transactions]', err.message);
res.status(500).json({ error: 'Failed to load transactions' });
}
}); });
// PATCH /api/spending/transactions/:id/category // PATCH /api/spending/transactions/:id/category
router.patch('/transactions/:id/category', (req: Req, res: Res) => { router.patch('/transactions/:id/category', (req: Req, res: Res) => {
const txId = parseInt(req.params.id, 10); const txId = parseInt(req.params.id, 10);
if (isNaN(txId)) return res.status(400).json({ error: 'Invalid transaction ID' }); if (isNaN(txId)) throw ValidationError('Invalid transaction ID', 'id');
const { category_id, save_rule } = req.body || {}; const { category_id, save_rule } = req.body || {};
const categoryId = const categoryId =
category_id === null || category_id === undefined ? null : parseInt(category_id, 10); category_id === null || category_id === undefined ? null : parseInt(category_id, 10);
try {
categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule); categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) {
res
.status(err.status || 500)
.json({ error: err.message || 'Failed to categorize transaction' });
}
}); });
// GET /api/spending/budgets?year=&month= // GET /api/spending/budgets?year=&month=
router.get('/budgets', (req: Req, res: Res) => { router.get('/budgets', (req: Req, res: Res) => {
const ym = parseYM(req.query); const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
try {
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) }); res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) });
} catch (err) {
log.error('[spending/budgets GET]', err.message);
res.status(500).json({ error: 'Failed to load budgets' });
}
}); });
// POST /api/spending/budgets/copy — copy all budgets from previous month into target month // POST /api/spending/budgets/copy — copy all budgets from previous month into target month
router.post('/budgets/copy', (req: Req, res: Res) => { router.post('/budgets/copy', (req: Req, res: Res) => {
const ym = parseYM(req.body || {}); const ym = parseYM(req.body || {});
if (ym.error) return res.status(400).json({ error: ym.error });
// Previous month // Previous month
let prevYear = ym.year, let prevYear = ym.year,
@ -111,7 +89,6 @@ router.post('/budgets/copy', (req: Req, res: Res) => {
prevYear--; prevYear--;
} }
try {
const db = getDb(); const db = getDb();
const prev = db const prev = db
.prepare( .prepare(
@ -138,28 +115,21 @@ router.post('/budgets/copy', (req: Req, res: Res) => {
`); `);
db.transaction(() => { db.transaction(() => {
for (const row of prev) for (const row of prev) upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
})(); })();
res.json({ res.json({
copied: prev.length, copied: prev.length,
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month), budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
}); });
} catch (err) {
log.error('[spending/budgets/copy]', err.message);
res.status(500).json({ error: 'Failed to copy budgets' });
}
}); });
// PUT /api/spending/budgets — { category_id, year, month, amount } // PUT /api/spending/budgets — { category_id, year, month, amount }
router.put('/budgets', (req: Req, res: Res) => { router.put('/budgets', (req: Req, res: Res) => {
const { category_id, year, month, amount } = req.body || {}; const { category_id, year, month, amount } = req.body || {};
if (!category_id) return res.status(400).json({ error: 'category_id required' }); if (!category_id) throw ValidationError('category_id required', 'category_id');
const ym = parseYM({ year, month }); const ym = parseYM({ year, month });
if (ym.error) return res.status(400).json({ error: ym.error });
try {
setSpendingBudget( setSpendingBudget(
getDb(), getDb(),
req.user.id, req.user.id,
@ -169,52 +139,34 @@ router.put('/budgets', (req: Req, res: Res) => {
amount ?? null, amount ?? null,
); );
res.json({ ok: true }); res.json({ ok: true });
} catch (err) {
log.error('[spending/budgets PUT]', err.message);
res.status(500).json({ error: 'Failed to save budget' });
}
}); });
// GET /api/spending/category-rules // GET /api/spending/category-rules
router.get('/category-rules', (req: Req, res: Res) => { router.get('/category-rules', (req: Req, res: Res) => {
try {
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) }); res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
} catch (err) {
log.error('[spending/category-rules GET]', err.message);
res.status(500).json({ error: 'Failed to load rules' });
}
}); });
// POST /api/spending/category-rules — { category_id, merchant } // POST /api/spending/category-rules — { category_id, merchant }
router.post('/category-rules', (req: Req, res: Res) => { router.post('/category-rules', (req: Req, res: Res) => {
const { category_id, merchant } = req.body || {}; const { category_id, merchant } = req.body || {};
if (!category_id || !merchant) if (!category_id || !merchant)
return res.status(400).json({ error: 'category_id and merchant required' }); throw ValidationError(
try { 'category_id and merchant required',
!category_id ? 'category_id' : 'merchant',
);
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant); addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) {
log.error('[spending/category-rules POST]', err.message);
res.status(err.status || 500).json({ error: err.message || 'Failed to save rule' });
}
}); });
// DELETE /api/spending/category-rules/:id // DELETE /api/spending/category-rules/:id
router.delete('/category-rules/:id', (req: Req, res: Res) => { router.delete('/category-rules/:id', (req: Req, res: Res) => {
try {
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10)); deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
res.json({ ok: true }); res.json({ ok: true });
} catch (err) {
log.error('[spending/category-rules DELETE]', err.message);
res.status(500).json({ error: 'Failed to delete rule' });
}
}); });
// GET /api/spending/income?year=&month=&page= // GET /api/spending/income?year=&month=&page=
router.get('/income', (req: Req, res: Res) => { router.get('/income', (req: Req, res: Res) => {
const ym = parseYM(req.query); const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
try {
res.json( res.json(
getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, { getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
page: parseInt(req.query.page || '1', 10), page: parseInt(req.query.page || '1', 10),
@ -222,10 +174,6 @@ router.get('/income', (req: Req, res: Res) => {
includeIgnored: req.query.include_ignored === 'true', includeIgnored: req.query.include_ignored === 'true',
}), }),
); );
} catch (err) {
log.error('[spending/income]', err.message);
res.status(500).json({ error: 'Failed to load income transactions' });
}
}); });
module.exports = router; module.exports = router;

View File

@ -3,7 +3,12 @@ import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database.cts'); const { getDb, ensureUserDefaultCategories } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { fromCents } = require('../utils/money.mts'); const { fromCents } = require('../utils/money.mts');
const { const {
createSubscriptionFromRecommendation, createSubscriptionFromRecommendation,
@ -36,30 +41,16 @@ router.get('/recommendations', (req: Req, res: Res) => {
}); });
router.get('/transaction-matches', (req: Req, res: Res) => { router.get('/transaction-matches', (req: Req, res: Res) => {
try {
res.json({ res.json({
transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query), transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query),
}); });
} catch (err) {
res
.status(500)
.json(
standardizeError(
err.message || 'Failed to search subscription transactions',
'SUBSCRIPTION_SEARCH_ERROR',
),
);
}
}); });
router.post('/recommendations/decline', (req: Req, res: Res) => { router.post('/recommendations/decline', (req: Req, res: Res) => {
const { decline_key } = req.body || {}; const { decline_key } = req.body || {};
if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) { if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) {
return res throw ValidationError('decline_key is required', 'decline_key');
.status(400)
.json(standardizeError('decline_key is required', 'VALIDATION_ERROR', 'decline_key'));
} }
try {
const db = getDb(); const db = getDb();
declineRecommendation(db, req.user.id, decline_key); declineRecommendation(db, req.user.id, decline_key);
recordSubscriptionFeedback(db, req.user.id, { recordSubscriptionFeedback(db, req.user.id, {
@ -70,11 +61,6 @@ router.post('/recommendations/decline', (req: Req, res: Res) => {
metadata: { decline_key }, metadata: { decline_key },
}); });
res.json({ ok: true }); res.json({ ok: true });
} catch (err) {
res
.status(500)
.json(standardizeError(err.message || 'Failed to decline recommendation', 'DECLINE_ERROR'));
}
}); });
// POST /api/subscriptions/recommendations/match-bill // POST /api/subscriptions/recommendations/match-bill
@ -88,20 +74,10 @@ router.post('/recommendations/match-bill', (req: Req, res: Res) => {
.slice(0, 50); .slice(0, 50);
if (!Number.isInteger(billId) || billId < 1) { if (!Number.isInteger(billId) || billId < 1) {
return res throw ValidationError('bill_id is required', 'bill_id');
.status(400)
.json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id'));
} }
if (txIds.length === 0) { if (txIds.length === 0) {
return res throw ValidationError('transaction_ids must be a non-empty array', 'transaction_ids');
.status(400)
.json(
standardizeError(
'transaction_ids must be a non-empty array',
'VALIDATION_ERROR',
'transaction_ids',
),
);
} }
const db = getDb(); const db = getDb();
@ -110,8 +86,7 @@ router.post('/recommendations/match-bill', (req: Req, res: Res) => {
'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL', 'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
) )
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) if (!bill) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const catalogId = parseInt(req.body?.catalog_id, 10); const catalogId = parseInt(req.body?.catalog_id, 10);
const catalogEntry = const catalogEntry =
Number.isInteger(catalogId) && catalogId > 0 Number.isInteger(catalogId) && catalogId > 0
@ -187,15 +162,7 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
.get(categoryId, req.user.id) .get(categoryId, req.user.id)
: null; : null;
if (!category) { if (!category) {
return res throw ValidationError('category_id is invalid for this user', 'category_id');
.status(400)
.json(
standardizeError(
'category_id is invalid for this user',
'VALIDATION_ERROR',
'category_id',
),
);
} }
} }
try { try {
@ -212,14 +179,13 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
}); });
res.status(201).json(created); res.status(201).json(created);
} catch (err) { } catch (err) {
res // Service validation errors are user-facing at 4xx (parity with the old
.status(err.status || 400) // behavior: no-status service throws surfaced at 400, never as masked 5xx).
.json( throw new ApiError(
standardizeError(
err.message || 'Could not create subscription',
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
err.field || null, err.message || 'Could not create subscription',
), err.status || 400,
{ field: err.field || null },
); );
} }
}); });
@ -228,7 +194,6 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
router.get('/catalog', (req: Req, res: Res) => { router.get('/catalog', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
try {
const catalogEntries = db const catalogEntries = db
.prepare( .prepare(
` `
@ -263,9 +228,7 @@ router.get('/catalog', (req: Req, res: Res) => {
let userDescriptors = []; let userDescriptors = [];
try { try {
userDescriptors = db userDescriptors = db
.prepare( .prepare('SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?')
'SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?',
)
.all(req.user.id); .all(req.user.id);
} catch { } catch {
/* pre-v0.96 */ /* pre-v0.96 */
@ -299,11 +262,6 @@ router.get('/catalog', (req: Req, res: Res) => {
}); });
res.json({ catalog }); res.json({ catalog });
} catch (err) {
res
.status(500)
.json(standardizeError(err.message || 'Failed to load catalog', 'CATALOG_ERROR'));
}
}); });
// Update which catalog entry a bill is linked to (or unlink with null) // Update which catalog entry a bill is linked to (or unlink with null)
@ -311,7 +269,7 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) { if (!Number.isInteger(billId) || billId < 1) {
return res.status(400).json(standardizeError('Invalid bill ID', 'VALIDATION_ERROR')); throw ValidationError('Invalid bill ID');
} }
const bill = db const bill = db
@ -319,7 +277,7 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL', 'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
) )
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); if (!bill) throw NotFoundError('Bill not found');
const rawCatalogId = req.body?.catalog_id; const rawCatalogId = req.body?.catalog_id;
@ -338,23 +296,12 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
const catalogId = parseInt(rawCatalogId, 10); const catalogId = parseInt(rawCatalogId, 10);
if (!Number.isInteger(catalogId) || catalogId < 1) { if (!Number.isInteger(catalogId) || catalogId < 1) {
return res throw ValidationError('catalog_id must be a positive integer or null', 'catalog_id');
.status(400)
.json(
standardizeError(
'catalog_id must be a positive integer or null',
'VALIDATION_ERROR',
'catalog_id',
),
);
} }
const catalogEntry = db const catalogEntry = db
.prepare('SELECT id FROM subscription_catalog WHERE id = ?') .prepare('SELECT id FROM subscription_catalog WHERE id = ?')
.get(catalogId); .get(catalogId);
if (!catalogEntry) if (!catalogEntry) throw NotFoundError('Catalog entry not found', 'catalog_id');
return res
.status(404)
.json(standardizeError('Catalog entry not found', 'NOT_FOUND', 'catalog_id'));
db.prepare( db.prepare(
"UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
@ -374,34 +321,22 @@ router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const catalogId = parseInt(req.params.catalogId, 10); const catalogId = parseInt(req.params.catalogId, 10);
if (!Number.isInteger(catalogId) || catalogId < 1) { if (!Number.isInteger(catalogId) || catalogId < 1) {
return res.status(400).json(standardizeError('Invalid catalog ID', 'VALIDATION_ERROR')); throw ValidationError('Invalid catalog ID');
} }
const catalogEntry = db const catalogEntry = db
.prepare('SELECT id FROM subscription_catalog WHERE id = ?') .prepare('SELECT id FROM subscription_catalog WHERE id = ?')
.get(catalogId); .get(catalogId);
if (!catalogEntry) if (!catalogEntry) throw NotFoundError('Catalog entry not found');
return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND'));
const descriptor = String(req.body?.descriptor ?? '').trim(); const descriptor = String(req.body?.descriptor ?? '').trim();
if (!descriptor) { if (!descriptor) {
return res throw ValidationError('descriptor is required', 'descriptor');
.status(400)
.json(standardizeError('descriptor is required', 'VALIDATION_ERROR', 'descriptor'));
} }
if (descriptor.length > 100) { if (descriptor.length > 100) {
return res throw ValidationError('descriptor must be 100 characters or less', 'descriptor');
.status(400)
.json(
standardizeError(
'descriptor must be 100 characters or less',
'VALIDATION_ERROR',
'descriptor',
),
);
} }
try {
// Check for case-insensitive duplicate // Check for case-insensitive duplicate
const exists = db const exists = db
.prepare( .prepare(
@ -409,14 +344,10 @@ router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => {
) )
.get(req.user.id, catalogId, descriptor); .get(req.user.id, catalogId, descriptor);
if (exists) { if (exists) {
return res throw ConflictError(
.status(409)
.json(
standardizeError(
'Descriptor already exists for this service', 'Descriptor already exists for this service',
'DUPLICATE_ERROR',
'descriptor', 'descriptor',
), 'DUPLICATE_ERROR',
); );
} }
@ -433,11 +364,6 @@ router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => {
}); });
res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId }); res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId });
} catch (err) {
res
.status(500)
.json(standardizeError(err.message || 'Failed to add descriptor', 'DESCRIPTOR_ADD_ERROR'));
}
}); });
// Delete a user-added catalog descriptor // Delete a user-added catalog descriptor
@ -445,10 +371,9 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const descriptorId = parseInt(req.params.id, 10); const descriptorId = parseInt(req.params.id, 10);
if (!Number.isInteger(descriptorId) || descriptorId < 1) { if (!Number.isInteger(descriptorId) || descriptorId < 1) {
return res.status(400).json(standardizeError('Invalid descriptor ID', 'VALIDATION_ERROR')); throw ValidationError('Invalid descriptor ID');
} }
try {
const existing = db const existing = db
.prepare( .prepare(
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?', 'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?',
@ -458,7 +383,7 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
.prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?') .prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?')
.run(descriptorId, req.user.id); .run(descriptorId, req.user.id);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json(standardizeError('Descriptor not found', 'NOT_FOUND')); throw NotFoundError('Descriptor not found');
} }
if (existing) { if (existing) {
recordSubscriptionFeedback(db, req.user.id, { recordSubscriptionFeedback(db, req.user.id, {
@ -469,29 +394,19 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
}); });
} }
res.json({ ok: true }); res.json({ ok: true });
} catch (err) {
res
.status(500)
.json(
standardizeError(err.message || 'Failed to delete descriptor', 'DESCRIPTOR_DELETE_ERROR'),
);
}
}); });
router.patch('/:id', (req: Req, res: Res) => { router.patch('/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
return res throw ValidationError('bill_id must be an integer', 'bill_id');
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const existing = db const existing = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!existing) if (!existing) throw NotFoundError('Bill not found', 'bill_id');
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const allowedTypes = new Set([ const allowedTypes = new Set([
'streaming', 'streaming',
@ -530,15 +445,7 @@ router.patch('/:id', (req: Req, res: Res) => {
next.reminder_days_before < 0 || next.reminder_days_before < 0 ||
next.reminder_days_before > 30 next.reminder_days_before > 30
) { ) {
return res throw ValidationError('reminder_days_before must be between 0 and 30', 'reminder_days_before');
.status(400)
.json(
standardizeError(
'reminder_days_before must be between 0 and 30',
'VALIDATION_ERROR',
'reminder_days_before',
),
);
} }
db.prepare( db.prepare(

View File

@ -8,6 +8,7 @@ const { getCycleRange, resolveDueDate } = require('../services/statusService.cts
const { getUserSettings } = require('../services/userSettings.cts'); const { getUserSettings } = require('../services/userSettings.cts');
const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { toCents, fromCents } = require('../utils/money.mts'); const { toCents, fromCents } = require('../utils/money.mts');
const { ValidationError } = require('../utils/apiError.cts');
const DEFAULT_INCOME_LABEL = 'Salary'; const DEFAULT_INCOME_LABEL = 'Salary';
const DEFAULT_PENDING_DAYS = 3; const DEFAULT_PENDING_DAYS = 3;
@ -127,10 +128,10 @@ function parseYearMonth(source) {
const month = parseInt(source.month || now.getMonth() + 1, 10); const month = parseInt(source.month || now.getMonth() + 1, 10);
if (Number.isNaN(year) || year < 2000 || year > 2100) { if (Number.isNaN(year) || year < 2000 || year > 2100) {
return { error: 'year must be a 4-digit integer between 2000 and 2100' }; throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
} }
if (Number.isNaN(month) || month < 1 || month > 12) { if (Number.isNaN(month) || month < 1 || month > 12) {
return { error: 'month must be an integer between 1 and 12' }; throw ValidationError('month must be an integer between 1 and 12', 'month');
} }
return { year, month }; return { year, month };
@ -447,7 +448,6 @@ function buildSummary(db, userId, year, month) {
router.get('/', (req: Req, res: Res) => { router.get('/', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.query); const parsed = parseYearMonth(req.query);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const db = getDb(); const db = getDb();
res.json(buildSummary(db, req.user.id, parsed.year, parsed.month)); res.json(buildSummary(db, req.user.id, parsed.year, parsed.month));
@ -455,11 +455,10 @@ router.get('/', (req: Req, res: Res) => {
router.put('/income', (req: Req, res: Res) => { router.put('/income', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.body || {}); const parsed = parseYearMonth(req.body || {});
if (parsed.error) return res.status(400).json({ error: parsed.error });
const amount = Number(req.body?.amount); const amount = Number(req.body?.amount);
if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) { if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) {
return res.status(400).json({ error: 'amount must be a number between 0 and 1000000000' }); throw ValidationError('amount must be a number between 0 and 1000000000', 'amount');
} }
const label = const label =

View File

@ -4,6 +4,16 @@ const router = require('express').Router();
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts');
const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
// Boundary adapter: the internal parse/normalize helpers return
// { error: <standardized body> } result objects; at the HTTP boundary we
// rethrow them as ApiError so the terminal handler emits the same wire shape.
function throwStandardized(errBody, status = 400) {
throw new ApiError(errBody.code || 'VALIDATION_ERROR', errBody.message, status, {
field: errBody.field,
});
}
const { const {
decorateTransaction, decorateTransaction,
ensureManualDataSource, ensureManualDataSource,
@ -356,14 +366,15 @@ function rejectDirectMatchState(body = {}) {
); );
} }
function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') { // 4xx service errors keep their message/code on the wire; anything else is
if (err.status) { // rethrown raw so the terminal handler masks + logs it as a 5xx.
return res function toTransactionServiceError(err) {
.status(err.status) if (err.status && err.status < 500) {
.json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field)); return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, {
field: err.field,
});
} }
log.error('[transactions] service error:', err.stack || err.message); return err;
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
} }
function emptyBankLedger(enabled, hasConnections = false) { function emptyBankLedger(enabled, hasConnections = false) {
@ -460,7 +471,7 @@ router.get('/', (req: Req, res: Res) => {
ensureManualDataSource(db, req.user.id); ensureManualDataSource(db, req.user.id);
const page = parseLimitOffset(req.query); const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error); if (page.error) throwStandardized(page.error);
const SORT_COLUMNS = { const SORT_COLUMNS = {
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)', date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
@ -534,7 +545,7 @@ router.get('/', (req: Req, res: Res) => {
for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) { for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) {
if (req.query[field] !== undefined) { if (req.query[field] !== undefined) {
const parsed = parseInteger(req.query[field], field); const parsed = parseInteger(req.query[field], field);
if (parsed.error) return res.status(400).json(parsed.error); if (parsed.error) throwStandardized(parsed.error);
where.push(`t.${field} = ?`); where.push(`t.${field} = ?`);
params.push(parsed.value); params.push(parsed.value);
} }
@ -542,13 +553,13 @@ router.get('/', (req: Req, res: Res) => {
if (req.query.start_date) { if (req.query.start_date) {
const parsed = parseDate(req.query.start_date, 'start_date'); const parsed = parseDate(req.query.start_date, 'start_date');
if (parsed.error) return res.status(400).json(parsed.error); if (parsed.error) throwStandardized(parsed.error);
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?'); where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?');
params.push(parsed.value); params.push(parsed.value);
} }
if (req.query.end_date) { if (req.query.end_date) {
const parsed = parseDate(req.query.end_date, 'end_date'); const parsed = parseDate(req.query.end_date, 'end_date');
if (parsed.error) return res.status(400).json(parsed.error); if (parsed.error) throwStandardized(parsed.error);
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?'); where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?');
params.push(parsed.value); params.push(parsed.value);
} }
@ -607,7 +618,7 @@ router.get('/', (req: Req, res: Res) => {
router.get('/bank-ledger', (req: Req, res: Res) => { router.get('/bank-ledger', (req: Req, res: Res) => {
const config = getBankSyncConfig(); const config = getBankSyncConfig();
const page = parseLimitOffset(req.query); const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error); if (page.error) throwStandardized(page.error);
const db = getDb(); const db = getDb();
const sources = db const sources = db
@ -653,7 +664,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
.all(req.user.id); .all(req.user.id);
const filtered = bankLedgerWhere(req.query, req.user.id); const filtered = bankLedgerWhere(req.query, req.user.id);
if (filtered.error) return res.status(400).json(filtered.error); if (filtered.error) throwStandardized(filtered.error);
const SORT_COLUMNS = { const SORT_COLUMNS = {
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)', date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
@ -758,14 +769,14 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
router.post('/manual', (req: Req, res: Res) => { router.post('/manual', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const directMatchState = rejectDirectMatchState(req.body); const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState); if (directMatchState) throwStandardized(directMatchState);
const validation = normalizeTransactionFields(db, req.user.id, req.body); const validation = normalizeTransactionFields(db, req.user.id, req.body);
if (validation.error) return res.status(validation.status || 400).json(validation.error); if (validation.error) throwStandardized(validation.error, validation.status || 400);
const tx = validation.normalized; const tx = validation.normalized;
const source = ensureManualDataSource(db, req.user.id); const source = ensureManualDataSource(db, req.user.id);
const state = resolveTransactionState(tx); const state = resolveTransactionState(tx);
if (state.error) return res.status(400).json(state.error); if (state.error) throwStandardized(state.error);
Object.assign(tx, state); Object.assign(tx, state);
const result = db const result = db
@ -803,20 +814,19 @@ router.post('/manual', (req: Req, res: Res) => {
router.put('/:id', (req: Req, res: Res) => { router.put('/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const directMatchState = rejectDirectMatchState(req.body); const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState); if (directMatchState) throwStandardized(directMatchState);
const id = parseInteger(req.params.id, 'id'); const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error); if (id.error) throwStandardized(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) if (!existing) throw NotFoundError('Transaction not found', 'id');
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true }); const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true });
if (validation.error) return res.status(validation.status || 400).json(validation.error); if (validation.error) throwStandardized(validation.error, validation.status || 400);
const tx = validation.normalized; const tx = validation.normalized;
const state = resolveTransactionState(tx, existing); const state = resolveTransactionState(tx, existing);
if (state.error) return res.status(400).json(state.error); if (state.error) throwStandardized(state.error);
Object.assign(tx, state); Object.assign(tx, state);
const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date; const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date;
@ -880,11 +890,10 @@ router.put('/:id', (req: Req, res: Res) => {
router.delete('/:id', (req: Req, res: Res) => { router.delete('/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const id = parseInteger(req.params.id, 'id'); const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error); if (id.error) throwStandardized(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) if (!existing) throw NotFoundError('Transaction not found', 'id');
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
db.transaction(() => { db.transaction(() => {
unmatchTransaction(req.user.id, id.value); unmatchTransaction(req.user.id, id.value);
@ -905,7 +914,7 @@ router.post('/:id/match', (req: Req, res: Res) => {
); );
res.json(result); res.json(result);
} catch (err) { } catch (err) {
return sendTransactionServiceError(res, err, 'Transaction match failed'); throw toTransactionServiceError(err);
} }
}); });
@ -915,7 +924,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
const result = unmatchTransaction(req.user.id, req.params.id); const result = unmatchTransaction(req.user.id, req.params.id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
return sendTransactionServiceError(res, err, 'Transaction unmatch failed'); throw toTransactionServiceError(err);
} }
}); });
@ -926,7 +935,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
router.post('/unmatch-bulk', (req: Req, res: Res) => { router.post('/unmatch-bulk', (req: Req, res: Res) => {
const matches = req.body?.matches; const matches = req.body?.matches;
if (!Array.isArray(matches) || matches.length === 0) { if (!Array.isArray(matches) || matches.length === 0) {
return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR')); throw ValidationError('matches array required');
} }
if (matches.length > 50) { if (matches.length > 50) {
return res return res
@ -996,13 +1005,19 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
results.push({ transaction_id: txId, ok: true }); results.push({ transaction_id: txId, ok: true });
} }
} catch (err) { } catch (err) {
results.push({ transaction_id: txId, ok: false, error: err.message }); // Per-item feedback: service 4xx messages are user-facing; anything
// else is masked — 500-class internals must not leak into the results.
const msg = err.status && err.status < 500 ? err.message : 'Unmatch failed';
results.push({ transaction_id: txId, ok: false, error: msg });
} }
} }
})(); })();
const failed = results.filter((r) => !r.ok); const failed = results.filter((r) => !r.ok);
if (failed.length > 0 && failed.length === results.length) { if (failed.length > 0 && failed.length === results.length) {
// Deliberate hand-rolled body: carries the per-item `results` payload the
// client renders; formatError has no extras channel (same as payments'
// DUPLICATE_SUSPECTED exception).
return res.status(500).json({ error: 'All unmatches failed', results }); return res.status(500).json({ error: 'All unmatches failed', results });
} }
res.json({ results, unmatched: results.filter((r) => r.ok).length }); res.json({ results, unmatched: results.filter((r) => r.ok).length });
@ -1014,7 +1029,7 @@ router.post('/:id/ignore', (req: Req, res: Res) => {
const result = ignoreTransaction(req.user.id, req.params.id); const result = ignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction); res.json(result.transaction);
} catch (err) { } catch (err) {
return sendTransactionServiceError(res, err, 'Transaction ignore failed'); throw toTransactionServiceError(err);
} }
}); });
@ -1024,7 +1039,7 @@ router.post('/:id/unignore', (req: Req, res: Res) => {
const result = unignoreTransaction(req.user.id, req.params.id); const result = unignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction); res.json(result.transaction);
} catch (err) { } catch (err) {
return sendTransactionServiceError(res, err, 'Transaction unignore failed'); throw toTransactionServiceError(err);
} }
}); });
@ -1033,11 +1048,10 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
try { try {
const db = getDb(); const db = getDb();
const id = parseInteger(req.params.id, 'id'); const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error); if (id.error) throwStandardized(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) if (!existing) throw NotFoundError('Transaction not found', 'id');
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const match = findMerchantMatch( const match = findMerchantMatch(
db, db,
@ -1045,7 +1059,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
); );
res.json({ match }); res.json({ match });
} catch (err) { } catch (err) {
return sendTransactionServiceError(res, err, 'Failed to look up merchant match'); throw toTransactionServiceError(err);
} }
}); });
@ -1054,11 +1068,10 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
try { try {
const db = getDb(); const db = getDb();
const id = parseInteger(req.params.id, 'id'); const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error); if (id.error) throwStandardized(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) if (!existing) throw NotFoundError('Transaction not found', 'id');
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const match = findMerchantMatch( const match = findMerchantMatch(
db, db,
@ -1075,7 +1088,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
display_name: match.display_name, display_name: match.display_name,
}); });
} catch (err) { } catch (err) {
return sendTransactionServiceError(res, err, 'Failed to apply merchant match'); throw toTransactionServiceError(err);
} }
}); });
@ -1088,7 +1101,7 @@ router.post('/auto-categorize', (req: Req, res: Res) => {
}); });
res.json(result); res.json(result);
} catch (err) { } catch (err) {
return sendTransactionServiceError(res, err, 'Failed to auto-categorize transactions'); throw toTransactionServiceError(err);
} }
}); });

View File

@ -60,7 +60,7 @@ router.get('/history', (req: Req, res: Res) => {
if (!fs.existsSync(HISTORY_PATH)) throw NotFoundError('Release history not found'); if (!fs.existsSync(HISTORY_PATH)) throw NotFoundError('Release history not found');
const history = fs.readFileSync(HISTORY_PATH, 'utf8'); const history = fs.readFileSync(HISTORY_PATH, 'utf8');
let updatedAt = null; let updatedAt;
try { try {
updatedAt = fs.statSync(HISTORY_PATH).mtime.toISOString(); updatedAt = fs.statSync(HISTORY_PATH).mtime.toISOString();
} catch { } catch {

View File

@ -273,7 +273,8 @@ const { getCsrfToken } = require('./middleware/csrf.cts');
app.get('/{*splat}', (req, res) => { app.get('/{*splat}', (req, res) => {
// Set CSRF cookie if not present (needed for SPA to read token) // Set CSRF cookie if not present (needed for SPA to read token)
getCsrfToken(req, res); getCsrfToken(req, res);
res.sendFile(path.join(DIST, 'index.html')); // root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
res.sendFile('index.html', { root: DIST });
}); });
// ── Global error handler ────────────────────────────────────────────────────── // ── Global error handler ──────────────────────────────────────────────────────

View File

@ -89,9 +89,17 @@ async function login(username: any, password: any) {
createAuthenticationChallenge, createAuthenticationChallenge,
createLoginChallenge, createLoginChallenge,
} = require('./webauthnService.cts'); } = require('./webauthnService.cts');
try {
const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id); const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id);
const loginToken = createLoginChallenge(getDb(), user.id, challengeId); const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options }; return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options };
} catch (err: any) {
// Stale flag (enabled but no usable credentials): a hard failure here would
// lock the account out entirely — fall through to password-only login instead.
log.warn(
`[auth] WebAuthn enabled for user ${user.id} but challenge creation failed (${err.message}); proceeding with password login`,
);
}
} }
// Clean up expired sessions for this user before creating new session // Clean up expired sessions for this user before creating new session

View File

@ -50,7 +50,7 @@ async function claimSetupToken(setupToken: unknown): Promise<string> {
const token = setupToken.trim(); const token = setupToken.trim();
// Base64-decode to get the claim URL; handle both raw base64 and data-URLs // Base64-decode to get the claim URL; handle both raw base64 and data-URLs
let claimUrl = token; let claimUrl;
try { try {
const decoded = Buffer.from(token, 'base64').toString('utf8').trim(); const decoded = Buffer.from(token, 'base64').toString('utf8').trim();
claimUrl = decoded.startsWith('http') ? decoded : token; claimUrl = decoded.startsWith('http') ? decoded : token;
@ -67,7 +67,7 @@ async function claimSetupToken(setupToken: unknown): Promise<string> {
throw new Error('Setup token must use a secure HTTPS URL'); throw new Error('Setup token must use a secure HTTPS URL');
} }
let accessUrl = ''; let accessUrl;
try { try {
const res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(30000) }); const res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(30000) });
if (res.status === 403) { if (res.status === 403) {

View File

@ -18,11 +18,24 @@ function getRpId(): string {
return process.env.WEBAUTHN_RP_ID || 'localhost'; return process.env.WEBAUTHN_RP_ID || 'localhost';
} }
function getOrigin(): string { // WEBAUTHN_ORIGIN is authoritative when set. Otherwise accept the API origin
// plus the browser's actual origin when its hostname equals the RP ID — this
// covers dev/e2e where the Vite UI port differs from the API port. It does not
// widen the trust boundary: the browser already refuses a ceremony whose RP ID
// is not a registrable suffix of the page origin.
function getOrigin(requestOrigin?: string): string | string[] {
const o = process.env.WEBAUTHN_ORIGIN; const o = process.env.WEBAUTHN_ORIGIN;
if (o) return o; if (o) return o;
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
return `http://localhost:${port}`; const accepted = [`http://localhost:${port}`];
if (requestOrigin) {
try {
if (new URL(requestOrigin).hostname === getRpId()) accepted.push(requestOrigin);
} catch {
/* malformed Origin header — ignore */
}
}
return accepted;
} }
// ── Registration ────────────────────────────────────────────────────────────── // ── Registration ──────────────────────────────────────────────────────────────
@ -80,6 +93,7 @@ async function verifyRegistration(
challengeId: string, challengeId: string,
response: any, response: any,
credentialName: any, credentialName: any,
requestOrigin?: string,
) { ) {
const row = db const row = db
.prepare( .prepare(
@ -92,7 +106,7 @@ async function verifyRegistration(
const verification = await verifyRegistrationResponse({ const verification = await verifyRegistrationResponse({
response, response,
expectedChallenge: row.challenge, expectedChallenge: row.challenge,
expectedOrigin: getOrigin(), expectedOrigin: getOrigin(requestOrigin),
expectedRPID: getRpId(), expectedRPID: getRpId(),
}); });
@ -162,7 +176,13 @@ async function createAuthenticationChallenge(db: Db, userId: number) {
return { options, challengeId }; return { options, challengeId };
} }
async function verifyAuthentication(db: Db, userId: number, challengeId: string, response: any) { async function verifyAuthentication(
db: Db,
userId: number,
challengeId: string,
response: any,
requestOrigin?: string,
) {
const row = db const row = db
.prepare( .prepare(
"SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')", "SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')",
@ -181,7 +201,7 @@ async function verifyAuthentication(db: Db, userId: number, challengeId: string,
const verification = await verifyAuthenticationResponse({ const verification = await verifyAuthenticationResponse({
response, response,
expectedChallenge: row.challenge, expectedChallenge: row.challenge,
expectedOrigin: getOrigin(), expectedOrigin: getOrigin(requestOrigin),
expectedRPID: getRpId(), expectedRPID: getRpId(),
credential: { credential: {
id: cred.credential_id, id: cred.credential_id,

View File

@ -0,0 +1,138 @@
'use strict';
// QA-B1-01 — POST /login for a webauthn-enabled user must return the
// requires_webauthn two-step payload (not fall through and 500), and a user
// whose webauthn_enabled flag is stale (no credential rows) must still be able
// to log in with their password instead of being locked out.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-webauthn-login-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const { hashPassword } = require('../services/authService.cts');
const router = require('../routes/auth.cts');
const PASSWORD = 'correct-horse-battery';
function handler(method, routePath) {
const layer = router.stack.find((l) => l.route?.path === routePath && l.route.methods[method]);
assert.ok(layer, `${method.toUpperCase()} ${routePath} exists`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function callLogin(body) {
const h = handler('post', '/login');
return new Promise((resolve, reject) => {
const req = {
body,
ip: '127.0.0.1',
cookies: {},
headers: {},
secure: false,
get: () => 'node-test-agent',
};
const cookies = [];
const res = {
statusCode: 200,
status(code) {
this.statusCode = code;
return this;
},
cookie(name, value, opts) {
cookies.push({ name, value, opts });
},
json(data) {
resolve({ status: this.statusCode, body: data, cookies });
},
};
// Routes follow the throw-pattern: mirror the app's terminal error handler
// so thrown/rejected ApiErrors resolve to the same wire shape.
Promise.resolve(h(req, res)).catch((err) => {
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, body: formatError(err), cookies });
} else {
reject(err);
}
});
});
}
let webauthnUserId;
let staleFlagUserId;
test.before(async () => {
const db = getDb();
const hash = await hashPassword(PASSWORD);
const u1 = db
.prepare(
"INSERT INTO users (username, password_hash, role, webauthn_enabled) VALUES (?, ?, 'user', 1)",
)
.run('webauthn-user', hash);
webauthnUserId = u1.lastInsertRowid;
db.prepare(
'INSERT INTO webauthn_credentials (user_id, credential_id, public_key, sign_count, transports) VALUES (?, ?, ?, 0, ?)',
).run(webauthnUserId, 'dGVzdC1jcmVkLWlk', 'dGVzdC1wdWJsaWMta2V5', JSON.stringify(['internal']));
const u2 = db
.prepare(
"INSERT INTO users (username, password_hash, role, webauthn_enabled) VALUES (?, ?, 'user', 1)",
)
.run('stale-flag-user', hash);
staleFlagUserId = u2.lastInsertRowid;
});
test.after(() => {
closeDb();
for (const suffix of ['', '-shm', '-wal']) {
try {
fs.unlinkSync(dbPath + suffix);
} catch {}
}
});
test('webauthn-enabled user gets the two-step payload, not a 500', async () => {
const { status, body, cookies } = await callLogin({
username: 'webauthn-user',
password: PASSWORD,
});
assert.equal(status, 200);
assert.equal(body.requires_webauthn, true);
assert.equal(typeof body.challenge_token, 'string');
assert.ok(body.challenge_token.length > 0);
assert.ok(body.webauthn_options, 'webauthn_options present');
assert.equal(typeof body.webauthn_options.challenge, 'string');
assert.equal(cookies.length, 0, 'no session cookie before the key verifies');
assert.equal(body.user, undefined, 'no user payload before the key verifies');
const challenge = getDb()
.prepare(
"SELECT id FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'authentication'",
)
.get(webauthnUserId);
assert.ok(challenge, 'authentication challenge persisted');
});
test('stale webauthn flag (no credentials) falls through to password login, not lockout', async () => {
const { status, body, cookies } = await callLogin({
username: 'stale-flag-user',
password: PASSWORD,
});
assert.equal(status, 200);
assert.equal(body.requires_webauthn, undefined);
assert.ok(body.user, 'password login succeeded despite the stale flag');
assert.equal(body.user.id, staleFlagUserId);
assert.equal(cookies.length, 1, 'session cookie issued');
});
test('wrong password still 401s for a webauthn-enabled user', async () => {
const { status, body } = await callLogin({ username: 'webauthn-user', password: 'nope-wrong' });
assert.equal(status, 401);
assert.equal(body.code, 'AUTH_ERROR');
});

View File

@ -8,6 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-reorder-test-${process.pid}.
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const { getTracker } = require('../services/trackerService.cts'); const { getTracker } = require('../services/trackerService.cts');
function createUser(db, suffix) { function createUser(db, suffix) {
@ -60,8 +61,14 @@ function callBillsRoute(routePath, method, { userId, params = {}, query = {}, bo
try { try {
handler(req, res); handler(req, res);
} catch (err) { } catch (err) {
// Routes follow the throw-pattern: mirror the app's terminal error
// handler so thrown ApiErrors resolve to the same wire shape.
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, data: formatError(err) });
} else {
reject(err); reject(err);
} }
}
}); });
} }

View File

@ -15,6 +15,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-payments-${process.pid}.sqli
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const router = require('../routes/payments.cts'); const router = require('../routes/payments.cts');
function handler(method, routePath) { function handler(method, routePath) {
@ -36,7 +37,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
resolve({ status: this.statusCode, data: d }); resolve({ status: this.statusCode, data: d });
}, },
}; };
// Routes follow the throw-pattern: mirror the app's terminal error handler
// (server.cts) so thrown ApiErrors resolve to the same wire shape.
try {
h(req, res); h(req, res);
} catch (err) {
resolve({ status: err.status || 500, data: formatError(err) });
}
}); });
} }
function balanceOf(billId) { function balanceOf(billId) {

View File

@ -13,6 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-snowball-plan-${process.pid}
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const router = require('../routes/snowball.cts'); const router = require('../routes/snowball.cts');
function handler(method, routePath) { function handler(method, routePath) {
@ -34,7 +35,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
resolve({ status: this.statusCode, data: d }); resolve({ status: this.statusCode, data: d });
}, },
}; };
// Routes follow the throw-pattern: mirror the app's terminal error handler
// (server.cts) so thrown ApiErrors resolve to the same wire shape.
try {
h(req, res); h(req, res);
} catch (err) {
resolve({ status: err.status || 500, data: formatError(err) });
}
}); });
} }

View File

@ -8,6 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-transaction-match-test-${pro
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const { ensureManualDataSource } = require('../services/transactionService.cts'); const { ensureManualDataSource } = require('../services/transactionService.cts');
const { getTracker } = require('../services/trackerService.cts'); const { getTracker } = require('../services/trackerService.cts');
const { const {
@ -131,8 +132,14 @@ function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
try { try {
handler(req, res); handler(req, res);
} catch (err) { } catch (err) {
// Routes follow the throw-pattern: mirror the app's terminal error
// handler so thrown ApiErrors resolve to the same wire shape.
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, data: formatError(err) });
} else {
reject(err); reject(err);
} }
}
}); });
} }
@ -164,8 +171,14 @@ function callPaymentsRoute(routePath, method, { userId, params = {}, query = {},
try { try {
handler(req, res); handler(req, res);
} catch (err) { } catch (err) {
// Routes follow the throw-pattern: mirror the app's terminal error
// handler so thrown ApiErrors resolve to the same wire shape.
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, data: formatError(err) });
} else {
reject(err); reject(err);
} }
}
}); });
} }

26
tests/versionSync.test.js Normal file
View File

@ -0,0 +1,26 @@
'use strict';
// QA-B0-03 — package.json is the single source of truth for the app version
// (/api/version, the release-notes badge, updateCheckService all read it), and
// HISTORY.md's top heading is the release record. They drifted once (0.40.0 vs
// v0.41.0); this guard makes the drift a test failure instead of a silent lie.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
test('package.json version matches the top HISTORY.md release heading', () => {
const pkg = require('../package.json');
const history = fs.readFileSync(path.join(__dirname, '..', 'HISTORY.md'), 'utf8');
const match = history.match(/^## v(\d+\.\d+(?:\.\d+)?)/m);
assert.ok(match, 'HISTORY.md has a "## vX.Y[.Z]" release heading');
// Normalize: HISTORY sometimes uses two-part versions (v0.41) for a series.
const historyVersion = match[1];
const pkgVersion = pkg.version;
assert.ok(
pkgVersion === historyVersion || pkgVersion.startsWith(`${historyVersion}.`),
`package.json version (${pkgVersion}) must match the top HISTORY.md heading (v${historyVersion})`,
);
});

View File

@ -51,6 +51,17 @@ function validateEnv(): void {
'the DB) to a long random string.', 'the DB) to a long random string.',
); );
} }
// WebAuthn credentials are cryptographically bound to the Relying Party ID.
// With the localhost default, passkeys registered in production would bind to
// "localhost" and silently fail to verify on the real domain.
if (isProd() && !process.env.WEBAUTHN_RP_ID) {
console.warn(
'[config] WARNING: WEBAUTHN_RP_ID is not set in production (defaults to "localhost"). ' +
'Passkeys/security keys will not work behind your real domain. Set it to the bare ' +
'hostname users sign in on, e.g. WEBAUTHN_RP_ID=bills.example.com.',
);
}
} }
module.exports = { validateEnv }; module.exports = { validateEnv };

View File

@ -103,18 +103,15 @@ export default defineConfig({
output: { output: {
// QA-B0-01: split the shared vendor code out of the ~659 kB index chunk // QA-B0-01: split the shared vendor code out of the ~659 kB index chunk
// so large libs load/cache independently and the main bundle shrinks. // so large libs load/cache independently and the main bundle shrinks.
manualChunks: { // Function form: vite 8 (rolldown core) dropped the object form.
'vendor-react': ['react', 'react-dom', 'react-router-dom'], manualChunks(id) {
'vendor-radix': [ if (!id.includes('node_modules')) return undefined;
'@radix-ui/react-dialog', if (/node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/.test(id))
'@radix-ui/react-select', return 'vendor-react';
'@radix-ui/react-dropdown-menu', if (/node_modules[\\/]@radix-ui[\\/]/.test(id)) return 'vendor-radix';
'@radix-ui/react-tabs', if (/node_modules[\\/]framer-motion[\\/]/.test(id)) return 'vendor-motion';
'@radix-ui/react-tooltip', if (/node_modules[\\/]@tanstack[\\/]/.test(id)) return 'vendor-query';
'@radix-ui/react-alert-dialog', return undefined;
],
'vendor-motion': ['framer-motion'],
'vendor-query': ['@tanstack/react-query', '@tanstack/react-virtual'],
}, },
}, },
}, },