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
# 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) ─────────────────────────────────────────────────────
# Enable/disable bank sync from the Admin panel. Users connect their own
# SimpleFIN Bridge from the Data page. No environment config required.

View File

@ -30,8 +30,10 @@ jobs:
| tar -xz -C /usr/local/bin gitleaks
gitleaks detect --source=. --redact --no-banner
- name: Dependency audit (fail on critical)
run: npm audit --omit=dev --audit-level=critical
- name: Dependency audit (fail on high or 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)
run: npm run format:check

View File

@ -1,5 +1,23 @@
# 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
### 🚀 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;
}
/** 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. */
export interface TogglePaidResult {
paymentId?: number;
@ -134,12 +147,14 @@ async function _fetch<T = unknown>(
_csrfFetch = null;
return _fetch<T>(method, path, body, true);
}
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;
throw err;
// Session expired mid-use: a 401 outside /auth/* means the session cookie
// died (under /auth/* a 401 is "wrong credential input" — e.g. bad current
// password — and must NOT log the user out). AuthProvider listens and
// clears the user, so RequireAuth redirects to /login preserving state.from.
if (res.status === 401 && !path.startsWith('/auth/')) {
window.dispatchEvent(new Event('auth:expired'));
}
throw toApiError(res, data);
}
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'),
authMode: () => get('/auth/mode'),
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'),
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
@ -262,12 +283,7 @@ export const api = {
});
const data = await res.json();
if (!res.ok) {
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;
throw err;
throw toApiError(res, data);
}
return data;
},
@ -504,12 +520,7 @@ export const api = {
});
const data = await res.json();
if (!res.ok) {
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;
throw err;
throw toApiError(res, data);
}
return data;
},
@ -528,12 +539,7 @@ export const api = {
});
const data = await res.json();
if (!res.ok) {
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;
throw err;
throw toApiError(res, data);
}
return data;
},
@ -552,12 +558,7 @@ export const api = {
});
const data = await res.json();
if (!res.ok) {
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;
throw err;
throw toApiError(res, data);
}
return data;
},
@ -621,12 +622,7 @@ export const api = {
});
const data = await res.json();
if (!res.ok) {
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;
throw err;
throw toApiError(res, data);
}
return data;
},

View File

@ -56,6 +56,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.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 () => {
await api.logout();
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 { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { startAuthentication } from '@simplewebauthn/browser';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { useAuth, type User } from '@/hooks/useAuth';
@ -81,6 +82,12 @@ export default function LoginPage() {
const [totpLoading, setTotpLoading] = 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 [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false);
@ -155,6 +162,14 @@ export default function LoginPage() {
setUseRecovery(false);
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!);
} catch (err) {
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) => {
e.preventDefault();
setTotpError('');
@ -340,8 +381,52 @@ export default function LoginPage() {
</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 &&
!webauthnChallenge &&
(authMode === null ? (
<div className="surface-elevated flex min-h-[120px] items-center justify-center p-8">
<span className="text-sm text-muted-foreground">Loading</span>

View File

@ -1040,6 +1040,7 @@ function ChangePassword() {
return (
<>
<TotpSection />
<PasskeySection />
<SectionCard
title="Change Password"
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({
settings,
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>
<desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc>
<defs>
@ -11,7 +11,7 @@
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.12"/>
</filter>
</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)">
<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"/>
@ -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="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/>
</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="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="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="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="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="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>

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>
<desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc>
<defs>
@ -11,7 +11,7 @@
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.18"/>
</filter>
</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)">
<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"/>
@ -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="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/>
</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="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="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="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="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="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>

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>
<desc id="desc">Premium calm Bill Tracker brand preview with the logo and tagline.</desc>
<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">
<stop offset="0" stop-color="#1b2428"/>
<stop offset="1" stop-color="#11181b"/>
@ -21,9 +13,10 @@
</linearGradient>
</defs>
<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"/>
<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)">
<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"/>
@ -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="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
</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="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="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="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="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="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="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="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="18" font-weight="700" letter-spacing="0">SELF-HOSTED BILL PLANNING</text>
</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
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`
(`ValidationError`, `NotFoundError`, `ConflictError`, …) and let the single terminal error
handler format them — do not hand-roll `res.status().json({ error })`. Express 5 forwards
rejected async handlers automatically, so route bodies should not wrap everything in
`try/catch`. Clients read `message`/`code` + status. **Canonical example:**
`routes/categories.cts`. (The terminal handler already normalizes any legacy
`standardizeError`/`{ error }` body to the same shape, so converted and not-yet-converted
routes emit identical responses — convert opportunistically.)
(`ValidationError`, `NotFoundError`, `ConflictError`, `AuthError`, `ForbiddenError`, or
`new ApiError(code, message, status, { field })` for anything else) and let the single
terminal error handler format them — do not hand-roll `res.status().json({ error })`.
Express 5 forwards rejected async handlers automatically, so route bodies do not wrap
everything in `try/catch`; a thrown `ApiError` keeps its message on the wire while any
other 5xx is masked + logged by the terminal handler (that masking is the whitelist:
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: true, … }`.
- **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.
- 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`,
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)
**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.
**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,
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
**archiving** each fixed finding to `HISTORY.md`. Update this document whenever a
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
> 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
**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
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**
@ -60,7 +62,7 @@ errors. Two nested loops:
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
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;
@ -68,28 +70,30 @@ then continue the find pass. **Everything else is logged and left for Phase 2**
no matter how tempting or trivial.
**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.
- 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.
- 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.
- **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.
**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
[Improvement Backlog (§2.1)](#21-improvement-backlog):
- **Code health & consolidation** — duplication to DRY up, dead code to delete,
overlapping modules to merge, oversized files to split, one canonical path per
concern. *Consolidate only where it genuinely reduces surface area and is
behavior-preserving.* (Dedicated pass: **B17**.)
concern. _Consolidate only where it genuinely reduces surface area and is
behavior-preserving._ (Dedicated pass: **B17**.)
- **User experience** — friction in core flows, unclear states (empty/loading/error),
weak feedback/affordances, inconsistent patterns, mobile parity. (Dedicated pass: **B18**.)
- **Information architecture / menus** — features that are buried or only reachable by
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
look for them.* (Dedicated pass: **B18**.)
groupings that would make the app more discoverable. _Put things where a user would
look for them._ (Dedicated pass: **B18**.)
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
@ -105,28 +109,28 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
**Status key:** ⬜ Not started · 🔄 In progress · ✅ Done (green, findings archived) · 🔁 Needs recheck
| # | 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 |
| 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 |
| B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ✅ | 0 / 0 |
| B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ✅ | 0 / 0 |
| B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ✅ | 0 / 0 |
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large + **live SimpleFIN DB** | ✅ | 0 / 4 |
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | ✅ | 0 / 1 |
| B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | ✅ | 0 / 2 |
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns + **live SimpleFIN sync** | ✅ | 0 / 0 |
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | ✅ | 0 / 1 |
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | ✅ | 0 / 1 |
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ✅ | 0 / 0 |
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ✅ | 0 / 0 |
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | ✅ | 0 / 1 |
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | ✅ | 0 / 4 |
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ✅ | 0 / 0 |
| B16 | Migrations, secrets & deploy | migration idempotency/rollback/fresh==migrated, encryption-key lifecycle, `docker-entrypoint` (perms/first-run/migrate), update-check phone-home | scratch + docker | ✅ | 0 / 1 |
| B17 | **Code health & consolidation** (IMP) | duplication/DRY, dead code, overlapping modules to merge, oversized files to split, one canonical path per concern | whole repo | ⬜ | 0 / 0 |
| B18 | **UX & information architecture** (IMP) | core-flow friction, empty/loading/error states, feedback/affordances, nav/menu discoverability, surfacing actions into sensible menus | any | ⬜ | 0 / 0 |
| # | 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 |
| 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 |
| B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ✅ | 0 / 0 |
| B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ✅ | 0 / 0 |
| B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ✅ | 0 / 0 |
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large + **live SimpleFIN DB** | ✅ | 0 / 4 |
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | ✅ | 0 / 1 |
| B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | ✅ | 0 / 2 |
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns + **live SimpleFIN sync** | ✅ | 0 / 0 |
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | ✅ | 0 / 1 |
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | ✅ | 0 / 1 |
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ✅ | 0 / 0 |
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ✅ | 0 / 0 |
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | ✅ | 0 / 1 |
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | ✅ | 0 / 4 |
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ✅ | 0 / 0 |
| B16 | Migrations, secrets & deploy | migration idempotency/rollback/fresh==migrated, encryption-key lifecycle, `docker-entrypoint` (perms/first-run/migrate), update-check phone-home | scratch + docker | ✅ | 0 / 1 |
| B17 | **Code health & consolidation** (IMP) | duplication/DRY, dead code, overlapping modules to merge, oversized files to split, one canonical path per concern | whole repo | ⬜ | 0 / 0 |
| B18 | **UX & information architecture** (IMP) | core-flow friction, empty/loading/error states, feedback/affordances, nav/menu discoverability, surfacing actions into sensible menus | any | ⬜ | 0 / 0 |
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
> cycle from B0 against the next build/version.
@ -141,10 +145,10 @@ human** and were **not** exercised — they are **non-blocking** for Cycle 1 sig
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.)
- **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).
- **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
@ -152,13 +156,14 @@ One row per full QA cycle (Phase 1 find → Phase 2 fix → … → Phase 5 re-r
cycle is only "clean" when its **find pass logged zero findings**. Keep going
until you get a clean cycle.
| 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·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.** |
| 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·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·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)
@ -175,9 +180,14 @@ fixing. Keep only **Open / Fixing / Fixed** rows here. Once a finding is
**Severity:** S1 Critical · S2 Major · S3 Minor · S4 Cosmetic · IMP Improvement (see [Appendix A](#appendix-a--severity-definitions)).
**Status:** 🔴 Open → 🟡 Fixing → 🟢 Fixed (verified, awaiting archive) → then remove on 📦 Archive.
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|----|-----|--------------------|---------|--------|---------------|
| _(none — all Cycle 1 findings fixed, verified & archived to `HISTORY.md` v0.41.0)_ | | | | | |
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
| --------- | --- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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):
@ -214,14 +224,18 @@ graduate to `roadmap.md`/`FUTURE.md`.
**Status:** 🔵 Noted (proposal) → 🟡 Doing → then archive to `HISTORY.md` on implement.
| 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-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-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-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 |
| 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-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-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-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**
- 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.*`).
- 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).
---
@ -257,12 +272,12 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
### 4.1 Running the app
| Mode | Command | URL |
|------|---------|-----|
| Dev (API + UI, hot reload) | `npm run dev` | UI `http://localhost:5173` (proxies API → `:3000`) |
| API only | `npm run dev:api` | `http://localhost:3000` |
| Production build | `npm run build` then `npm start` | `http://localhost:3000` |
| Docker | `docker-compose up` | per compose config |
| Mode | Command | URL |
| -------------------------- | -------------------------------- | -------------------------------------------------- |
| Dev (API + UI, hot reload) | `npm run dev` | UI `http://localhost:5173` (proxies API → `:3000`) |
| API only | `npm run dev:api` | `http://localhost:3000` |
| Production build | `npm run build` then `npm start` | `http://localhost:3000` |
| Docker | `docker-compose up` | per compose config |
- Backend: Node/Express on `PORT` (default `3000`). Frontend dev: Vite on `5173`.
- Data: SQLite at `db/bills.db` (WAL). **Back it up before destructive tests** (`backups/` or a manual copy). Prefer a scratch DB for B9/B11 restore tests.
@ -273,18 +288,19 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
Full functional pass across reasonable combinations; smoke (B15) across all.
| Dimension | Values |
|-----------|--------|
| Browser | Chrome/Chromium, Firefox, Safari (WebAuthn differs per browser) |
| Viewport | Desktop ≥1280, tablet ~768, mobile ~375 (iPhone SE), ~414 |
| Theme | Light, Dark, system-follow |
| Role | `user`, `admin`, default admin (first-run) |
| Auth mode | Multi-user, single-user |
| Density | Normal + compact desktop |
| Network | Online, Slow 3G, offline (PWA shell) |
| Data state | Empty, seeded demo, large/stress, adversarial |
| Dimension | Values |
| ---------- | --------------------------------------------------------------- |
| Browser | Chrome/Chromium, Firefox, Safari (WebAuthn differs per browser) |
| Viewport | Desktop ≥1280, tablet ~768, mobile ~375 (iPhone SE), ~414 |
| Theme | Light, Dark, system-follow |
| Role | `user`, `admin`, default admin (first-run) |
| Auth mode | Multi-user, single-user |
| Density | Normal + compact desktop |
| Network | Online, Slow 3G, offline (PWA shell) |
| Data state | Empty, seeded demo, large/stress, adversarial |
### 4.3 Accounts to prepare
- `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).
@ -292,12 +308,12 @@ 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.
| Command | What it does |
|---------|--------------|
| `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: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 |
| Command | What it does |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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:update` | re-baseline visual-regression screenshots (review the diff before committing) |
| `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/`.
- **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,72 +365,83 @@ 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.
### B0 — Baseline, tooling & coverage recon
**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
and folded in **before** testing, so coverage never silently rots.
**Tooling baseline**
- [ ] `npm run ci` — record any failing server/client test or build error as a finding (S1/S2).
- [ ] `npm run check` — server syntax + build clean.
- [ ] 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.
- [ ] 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):
- [ ] **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.
- [ ] **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.
- [ ] **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).
- [ ] **Migrations & deploy** — new `db/database.js` migrations, `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`.
- [ ] **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`.
- [ ] **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.
- [ ] **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:
- [ ] 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).
- [ ] 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
**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 |
|---|---|
| `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 |
| `select.tsx` (Radix) | opens by mouse **and** keyboard; type-ahead; long lists scroll; onChange fires in **Firefox+Safari**; disabled options; value persists; Esc closes |
| `checkbox.tsx` / `switch.tsx` | toggles by click **and** Space; indeterminate (if used); disabled; label click toggles; controlled value round-trips |
| 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 |
| `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 |
| `checkbox.tsx` / `switch.tsx` | toggles by click **and** Space; indeterminate (if used); disabled; label click toggles; controlled value round-trips |
| `dialog.tsx` / `alert-dialog.tsx` / `confirm-dialog.tsx` / `input-dialog.tsx` | open/close; **focus trap + restore**; Esc closes; overlay click behaves; **Cancel actually cancels (no side effect)**; Confirm fires once; scroll-lock releases |
| `dropdown-menu.tsx` | keyboard arrow nav; Esc; submenu; disabled items; click-outside closes; no clipping at viewport edge |
| `tabs.tsx` | arrow-key nav; active state; content swaps; deep-link/refresh keeps tab (if applicable) |
| `tooltip.tsx` | hover **and** keyboard-focus show it; dismiss on blur; touch behavior; not a11y-only info trap |
| `table.tsx` | header/zebra/hover; horizontal scroll on narrow viewport (no page h-scroll); empty state |
| `collapsible.tsx` | expand/collapse animation; state persists; keyboard operable |
| `sonner.tsx` (toast) | success/error/loading; **stack + dismiss**; auto-dismiss timing; doesn't cover primary actions; announced to SR |
| `save-status.tsx` | idle/saving/saved/error transitions reflect real autosave (`useAutoSave.test.ts`) |
| `Skeleton.tsx` | matches final layout (no jump); no infinite skeleton on error |
| `badge.tsx` / `card.tsx` / `separator.tsx` / `label.tsx` | contrast in dark mode; label `htmlFor` focuses its control; no overflow on long text |
| `theme-toggle.tsx` | light↔dark↔system; applied **before first paint** (no flash); persists across reload |
| `dropdown-menu.tsx` | keyboard arrow nav; Esc; submenu; disabled items; click-outside closes; no clipping at viewport edge |
| `tabs.tsx` | arrow-key nav; active state; content swaps; deep-link/refresh keeps tab (if applicable) |
| `tooltip.tsx` | hover **and** keyboard-focus show it; dismiss on blur; touch behavior; not a11y-only info trap |
| `table.tsx` | header/zebra/hover; horizontal scroll on narrow viewport (no page h-scroll); empty state |
| `collapsible.tsx` | expand/collapse animation; state persists; keyboard operable |
| `sonner.tsx` (toast) | success/error/loading; **stack + dismiss**; auto-dismiss timing; doesn't cover primary actions; announced to SR |
| `save-status.tsx` | idle/saving/saved/error transitions reflect real autosave (`useAutoSave.test.ts`) |
| `Skeleton.tsx` | matches final layout (no jump); no infinite skeleton on error |
| `badge.tsx` / `card.tsx` / `separator.tsx` / `label.tsx` | contrast in dark mode; label `htmlFor` focuses its control; no overflow on long text |
| `theme-toggle.tsx` | light↔dark↔system; applied **before first paint** (no flash); persists across reload |
- [ ] Every primitive above passes its row in light **and** dark, keyboard-only, at mobile width.
- [ ] Axe scan (see B14) on a page densely using primitives → zero critical violations.
### 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.
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **CSRF:** state-changing request without a valid token → rejected.
### B2 — Tracker (`/`)
- [ ] 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.
- [ ] 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.
### B3 — Bills (`/bills`)
- [ ] 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.
- [ ] 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.
### B4 — Subscriptions & Categories
- [ ] Subscriptions: add/edit/delete, active/cancelled, renewal & annual→monthly normalization; totals feed Tracker/Summary/Analytics.
- [ ] 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.
### B5 — Reporting reconciliation
- [ ] 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.
- [ ] 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.
### B6 — Spending (`/spending`)
- [ ] Category-group view assigned/spent/available math correct; 3-month averages correct.
- [ ] Cover-overspending reallocates funds correctly and is reversible.
- [ ] Safe-to-spend matches Tracker (`safeToSpend.test.js`); month nav; empty/partial months handled.
### B7 — Debt planning (`/snowball`, `/payoff`)
- [ ] Add debts (balance/APR/min); snowball vs avalanche ordering 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.
- [ ] Edge: single debt, many debts, `$0` debt, negative/absurd inputs rejected.
### B8 — Banking (`/bank-transactions`)
- [ ] Ledger loads/virtualizes/filters (date/account/amount/merchant/status).
- [ ] 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.
- [ ] Matched payments reflect on Tracker/ledger without double-counting; category picker persists.
### 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.
- [ ] 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`).
### B10 — Notifications & workers
- [ ] 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.
- [ ] Workers: `dailyWorker`, `bankSyncWorker` (interval + guardrails), `backupScheduler` run on schedule; errors caught/logged, don't crash server, next run unblocked.
### B11 — Admin panel (`/admin`)
- [ ] Onboarding wizard completes without a broken state.
- [ ] 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.
@ -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).
### 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.
- [ ] 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.
- [ ] 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
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.
- [ ] CSRF: state-changing without valid token rejected; with token succeeds (`middleware/csrf.js`).
- [ ] Validation: bad/missing body → structured 4xx (`middleware/errorFormatter.js`, `utils/apiError.js`), never a raw 500 stack.
- [ ] CSRF: state-changing without valid token rejected; with token succeeds (`middleware/csrf.cts`).
- [ ] 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.
- [ ] 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.
- [ ] 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
- [ ] **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.
- [ ] **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.
### B15 — Regression & sign-off
Run on the **production build** (`npm start`), not dev:
- [ ] `npm run ci` green. Log in as `user` and `admin`.
- [ ] `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.
@ -522,102 +566,112 @@ Run on the **production build** (`npm start`), not dev:
- [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria).
### B16 — Migrations, secrets & deployment
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
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.
- [ ] **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).
- [ ] **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.
**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 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.
- [ ] Encryption key is never committed, logged, or returned in any API response.
**Container / deploy** (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `deploy.sh`)
- [ ] 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`.
- [ ] Data **persists** across container restart (mounted volume); DB not re-created.
- [ ] 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.
**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.
### B17 — Code health & consolidation (IMP)
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
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
propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.js`
vs inline), the `resolveDueDate` occurrence gate (must stay one implementation),
error-response shaping (`utils/apiError.js` vs ad-hoc), React data-fetch patterns
(repeated `useQuery` + toast + error handling → shared hooks).
propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.mts`
vs inline), the `resolveDueDate` occurrence gate (must stay one implementation),
error-response shaping (`utils/apiError.cts` vs ad-hoc), React data-fetch patterns
(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,
commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover
scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first).
commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover
scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first).
- [ ] **Overlapping modules:** services that do similar work and could merge or share a
core — e.g. the matching family (`matchSuggestionService`, `transactionMatchService`,
`merchantStoreMatchService`), the bank-sync family (`bankSyncService`,
`bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities;
propose a consolidation only where it removes real duplication, not just moves it.
core — e.g. the matching family (`matchSuggestionService`, `transactionMatchService`,
`merchantStoreMatchService`), the bank-sync family (`bankSyncService`,
`bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities;
propose a consolidation only where it removes real duplication, not just moves it.
- [ ] **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
be separate modules). Propose the seams; don't split for its own sake.
(`db/database` was split in IMP-CODE-02; current largest: `SubscriptionsPage.tsx`
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,
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
that would prevent a class of the bugs found in earlier batches is itself an IMP.
that would prevent a class of the bugs found in earlier batches is itself an IMP.
- [ ] **Test/infra dedupe:** repeated test setup → shared fixtures/helpers; flag coverage
gaps a consolidation would risk.
gaps a consolidation would risk.
### B18 — UX & information architecture / menus (IMP)
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
would look for it*? Candidates are logged as `IMP-UX-*` or `IMP-IA-*` in §2.1 with a
_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
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.
**Information architecture & menus**
- [ ] **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
together; admin vs user separation is clear; active state + page titles are correct.
together; admin vs user separation is clear; active state + page titles are correct.
- [ ] **Menus where they belong:** actions that today are loose buttons or hidden should
be grouped into sensible menus — overflow (`⋯`) menus on rows/cards, context menus,
a consolidated **Settings** grouping, an account menu. Put related actions in one menu
rather than scattering them. Flag anything that would be easier to find as a menu item.
be grouped into sensible menus — overflow (`⋯`) menus on rows/cards, context menus,
a consolidated **Settings** grouping, an account menu. Put related actions in one menu
rather than scattering them. Flag anything that would be easier to find as a menu item.
- [ ] **Command palette (`Ctrl+K`) coverage:** every page/primary action is reachable;
no dead entries.
no dead entries.
- [ ] **Redundancy:** the same action offered in three places with different labels, or
two pages that do nearly the same thing — propose consolidating.
two pages that do nearly the same thing — propose consolidating.
**Experience quality**
- [ ] **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
**loading** state (skeleton/spinner, no layout jump), and a **recoverable error**
state — no dead ends, no silent failures.
**loading** state (skeleton/spinner, no layout jump), and a **recoverable error**
state — no dead ends, no silent failures.
- [ ] **Feedback & safety:** state changes confirm (toast); destructive actions confirm
and, where feasible, offer undo (bills already soft-delete — surface a restore path);
long actions show progress.
and, where feasible, offer undo (bills already soft-delete — surface a restore path);
long actions show progress.
- [ ] **Consistency:** primary-action placement, button hierarchy, iconography,
terminology, and confirmation patterns are consistent across pages.
terminology, and confirmation patterns are consistent across pages.
- [ ] **Mobile parity:** every action available on desktop is reachable on mobile; touch
targets adequate; menus/overflow work on touch.
targets adequate; menus/overflow work on touch.
- [ ] **Onboarding:** first-run and empty-account guidance explains the next step; advanced
features (SimpleFIN, debt planning, backups) have a short in-context explanation.
features (SimpleFIN, debt planning, backups) have a short in-context explanation.
---
@ -625,17 +679,18 @@ desktop and mobile, light and dark), not just as a tester.
### Appendix A — Severity definitions
| Level | Definition |
|-------|------------|
| **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. |
| **S3 Minor** | Works but wrong edge behavior, confusing UX, missing validation message. |
| **S4 Cosmetic** | Visual/copy/alignment/dark-mode-contrast, non-blocking. |
| **IMP Improvement** | Not a bug; enhancement or polish idea. |
| Level | Definition |
| --------------------- | -------------------------------------------------------------------------------------------- |
| **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. |
| **S3 Minor** | Works but wrong edge behavior, confusing UX, missing validation message. |
| **S4 Cosmetic** | Visual/copy/alignment/dark-mode-contrast, non-blocking. |
| **IMP Improvement** | Not a bug; enhancement or polish idea. |
### Appendix B — Exit / sign-off criteria
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] 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.
@ -647,38 +702,46 @@ A cycle is release-ready when: **(Cycle 1 — all met ✅)**
### Appendix C — Page ↔ route ↔ API quick map
| Page | Route | Primary API |
|------|-------|-------------|
| Tracker | `/` | `/api/tracker`, `/api/bills`, `/api/payments`, `/api/monthly-starting-amounts` |
| Calendar | `/calendar` | `/api/calendar` |
| Summary | `/summary` | `/api/summary` |
| Bills | `/bills` | `/api/bills`, `/api/categories`, `/api/matches` |
| Subscriptions / Catalog | `/subscriptions`, `/subscriptions/catalog` | `/api/subscriptions` |
| Categories | `/categories` | `/api/categories` |
| Health | `/health` | `/api/analytics`, `/api/summary` |
| Analytics | `/analytics` | `/api/analytics` |
| Spending | `/spending` | `/api/spending` |
| Banking | `/bank-transactions` | `/api/transactions`, `/api/matches`, `/api/data-sources` |
| Snowball / Payoff | `/snowball`, `/payoff` | `/api/snowball` |
| Settings | `/settings` | `/api/settings`, `/api/notifications` |
| Profile | `/profile` | `/api/profile`, `/api/user` |
| Data | `/data` | `/api/import`, `/api/export`, `/api/data-sources` |
| Admin | `/admin`, `/admin/status` | `/api/admin`, `/api/status`, `/api/about-admin` |
| About / Privacy / Release Notes / Roadmap | `/about`, `/privacy`, `/release-notes`, `/roadmap` | `/api/about`, `/api/privacy`, `/api/version` |
| Page | Route | Primary API |
| ------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Tracker | `/` | `/api/tracker`, `/api/bills`, `/api/payments`, `/api/monthly-starting-amounts` |
| Calendar | `/calendar` | `/api/calendar` |
| Summary | `/summary` | `/api/summary` |
| Bills | `/bills` | `/api/bills`, `/api/categories`, `/api/matches` |
| Subscriptions / Catalog | `/subscriptions`, `/subscriptions/catalog` | `/api/subscriptions` |
| Categories | `/categories` | `/api/categories` |
| Health | `/health` | `/api/analytics`, `/api/summary` |
| Analytics | `/analytics` | `/api/analytics` |
| Spending | `/spending` | `/api/spending` |
| Banking | `/bank-transactions` | `/api/transactions`, `/api/matches`, `/api/data-sources` |
| Snowball / Payoff | `/snowball`, `/payoff` | `/api/snowball` |
| Settings | `/settings` | `/api/settings`, `/api/notifications` |
| Profile | `/profile` | `/api/profile`, `/api/user` |
| Data | `/data` | `/api/import`, `/api/export`, `/api/data-sources` |
| 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` (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
`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
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):
| 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.* Amount input | number | per-month override, cents only, no wheel-scroll change | default ✓ · error-on-letters ✓ | Tab/Esc ✓ | ✅ / finding id |
| 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._ 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.
</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-misleading-character-class': '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

5973
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -5,6 +5,7 @@ const { log } = require('../utils/logger.cts');
const fs = require('fs');
const path = require('path');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
const { ValidationError } = require('../utils/apiError.cts');
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) => {
const { enabled } = req.body || {};
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');
res.json({ enabled });

View File

@ -4,6 +4,12 @@ const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const {
getBankSyncConfig,
setBankSyncEnabled,
@ -43,6 +49,9 @@ function parseUserId(params) {
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) {
const status = err.status || 500;
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
@ -170,80 +179,70 @@ router.delete('/backups/:id', backupOperationLimiter, (req: Req, res: Res) => {
router.post('/users', async (req: Req, res: Res) => {
const { username, password } = req.body;
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)
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();
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 result = db
.prepare(
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)",
)
.run(username, hash);
const hash = await hashPassword(password);
const result = db
.prepare(
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)",
)
.run(username, hash);
const created = db
.prepare(
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?',
)
.get(result.lastInsertRowid);
const created = db
.prepare(
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?',
)
.get(result.lastInsertRowid);
logAudit({
user_id: req.user.id,
action: 'admin.user.create',
entity_type: 'user',
entity_id: created.id,
details: { created_username: username },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
logAudit({
user_id: req.user.id,
action: 'admin.user.create',
entity_type: 'user',
entity_id: created.id,
details: { created_username: username },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.status(201).json(created);
} catch (err) {
log.error('[admin] create-user error:', err.message);
res.status(500).json({ error: 'Failed to create user' });
}
res.status(201).json(created);
});
// PUT /api/admin/users/:id/password
router.put('/users/:id/password', async (req: Req, res: Res) => {
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;
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 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);
db.prepare(
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
).run(hash, targetId);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
const hash = await hashPassword(password);
db.prepare(
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
).run(hash, targetId);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
logAudit({
user_id: req.user.id,
action: 'admin.password.reset',
entity_type: 'user',
entity_id: targetId,
details: { target_username: user.username },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
logAudit({
user_id: req.user.id,
action: 'admin.password.reset',
entity_type: 'user',
entity_id: targetId,
details: { target_username: user.username },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true });
} catch (err) {
log.error('[admin] reset-password error:', err.message);
res.status(500).json({ error: 'Failed to reset password' });
}
res.json({ success: true });
});
// 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.
router.put('/users/:id/role', (req: Req, res: Res) => {
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;
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 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) {
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') {
const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n;
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
router.put('/users/:id/active', (req: Req, res: Res) => {
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 db = getDb();
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) {
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(
@ -334,27 +333,27 @@ router.put('/users/:id/active', (req: Req, res: Res) => {
router.put('/users/:id/username', (req: Req, res: Res) => {
const { username } = req.body;
if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'username is required' });
throw ValidationError('username is required');
}
const trimmed = username.trim();
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) {
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);
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
if (!targetId) throw ValidationError('Invalid user ID');
const db = getDb();
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
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
.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;
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
router.delete('/users/:id', (req: Req, res: Res) => {
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 user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' });
if (req.user?.id === user.id)
return res.status(400).json({ error: 'You cannot delete your own account.' });
if (!user) throw NotFoundError('User not found');
if (req.user?.id === user.id) throw ValidationError('You cannot delete your own account.');
const deleteUser = db.transaction(() => {
// 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
router.put('/bank-sync-config', (req: Req, res: Res) => {
const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
try {
let config = getBankSyncConfig();
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
if (sync_days !== undefined) config = setSyncDays(sync_days);
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
res.json(config);
} catch (err) {
res
.status(err.status || 500)
.json({ error: err.message || 'Failed to update bank sync config' });
}
let config = getBankSyncConfig();
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
if (sync_days !== undefined) config = setSyncDays(sync_days);
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
res.json(config);
});
// ── Migration Rollback ────────────────────────────────────────────────────────
router.post('/migrations/rollback', async (req: Req, res: Res) => {
const { version } = req.body;
if (!version) {
return res.status(400).json({ error: 'Version is required' });
throw ValidationError('Version is required');
}
try {
@ -546,13 +538,13 @@ router.post('/migrations/rollback', async (req: Req, res: Res) => {
user_agent: req.get('user-agent'),
});
if (err.code === 'NOT_APPLIED') {
return res.status(404).json({ error: err.message });
}
if (err.code === 'ROLLBACK_NOT_SUPPORTED') {
return res.status(422).json({ error: err.message });
}
res.status(500).json({ error: 'Rollback failed', details: err.message });
// Coded rollback errors are intentional user-facing messages; anything
// 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 === 'NOT_APPLIED') throw NotFoundError(err.message);
if (err.code === 'ROLLBACK_NOT_SUPPORTED')
throw new ApiError('ROLLBACK_NOT_SUPPORTED', err.message, 422);
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).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router();
let _appVersion;
@ -31,10 +30,15 @@ const {
} = require('../services/authService.cts');
const { decryptSecret } = require('../services/encryptionService.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 { ValidationError, formatError } = require('../utils/apiError.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const {
ApiError,
ValidationError,
AuthError,
ForbiddenError,
NotFoundError,
} = require('../utils/apiError.cts');
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
const { logAudit } = require('../services/auditService.cts');
@ -54,65 +58,58 @@ router.post(
async (req: Req, res: Res) => {
// Respect admin-configured login method toggle
if (getSetting('local_login_enabled') === 'false') {
return res
.status(403)
.json(
standardizeError(
'Local username/password login is not enabled on this server.',
'FORBIDDEN',
),
);
throw ForbiddenError('Local username/password login is not enabled on this server.');
}
const { username, password } = req.body;
if (!username || !password) {
return res
.status(400)
.json(
standardizeError(
'Username and password are required',
'VALIDATION_ERROR',
!username ? 'username' : 'password',
),
);
throw ValidationError(
'Username and password are required',
!username ? 'username' : 'password',
);
}
try {
const result = await login(username, password);
if (!result || result.error) {
logAudit({
user_id: null,
action: 'login.failure',
details: { username },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
// Track failed attempt against known accounts (wrong password only — not unknown usernames)
if (result?.error === 'bad_password') {
recordFailedLogin(result.userId, req.ip, req.get('user-agent'));
}
return res.status(401).json(standardizeError('Invalid username or password', 'AUTH_ERROR'));
}
// TOTP required — don't create a session yet
if (result.requires_totp) {
return res.json({ requires_totp: true, challenge_token: result.challenge_token });
}
const result = await login(username, password);
if (!result || result.error) {
logAudit({
user_id: result.user.id,
action: 'login.success',
user_id: null,
action: 'login.failure',
details: { username },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(result.user.id, req.ip, req.get('user-agent'), result.sessionId);
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
res.json({ user: result.user });
} catch (err) {
log.error('Login error:', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
// Track failed attempt against known accounts (wrong password only — not unknown usernames)
if (result?.error === 'bad_password') {
recordFailedLogin(result.userId, req.ip, req.get('user-agent'));
}
throw AuthError('Invalid username or password');
}
// TOTP required — don't create a session yet
if (result.requires_totp) {
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({
user_id: result.user.id,
action: 'login.success',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(result.user.id, req.ip, req.get('user-agent'), result.sessionId);
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
res.json({ user: result.user });
},
);
@ -256,20 +253,14 @@ const { encryptSecret: encTotpSecret } = require('../services/encryptionService.
router.post('/totp/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true;
const { challenge_token, code, recovery_code } = req.body || {};
if (!challenge_token)
return res
.status(400)
.json(standardizeError('challenge_token is required', 'VALIDATION_ERROR'));
if (!challenge_token) throw ValidationError('challenge_token is required');
const db = getDb();
const userId = consumeChallenge(db, challenge_token);
if (!userId)
return res
.status(401)
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
if (!userId) throw AuthError('Challenge expired or invalid. Please sign in again.');
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;
if (recovery_code) {
@ -289,70 +280,42 @@ router.post('/totp/challenge', async (req: Req, res: Res) => {
ip_address: req.ip,
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 session = await createSession(userId);
if (!session)
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
const { createSession } = require('../services/authService.cts');
const session = await createSession(userId);
if (!session) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
logAudit({
user_id: userId,
action: 'login.success',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId);
logAudit({
user_id: userId,
action: 'login.success',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId);
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
res.json({ user: session.user });
} catch (err) {
log.error('[totp/challenge]', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
}
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
res.json({ user: session.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.
router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode)
return res
.status(400)
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
try {
const secret = generateSecret();
const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id);
const { uri, qr_data_url } = await generateQrCode(secret, user.username);
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'));
}
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
const secret = generateSecret();
const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id);
const { uri, qr_data_url } = await generateQrCode(secret, user.username);
res.json({ secret, uri, qr_data_url });
});
// POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP.
router.post('/totp/enable', requireAuth, (req: Req, res: Res) => {
if (req.singleUserMode)
return res
.status(400)
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
const { secret, code } = req.body || {};
if (!secret || !code)
return res
.status(400)
.json(standardizeError('secret and code are required', 'VALIDATION_ERROR'));
if (!secret || !code) throw ValidationError('secret and code are required');
if (!verifyTokenRaw(secret, code))
return res
.status(400)
.json(
standardizeError(
'Invalid authenticator code. Check your app and try again.',
'VALIDATION_ERROR',
'code',
),
);
throw ValidationError('Invalid authenticator code. Check your app and try again.', 'code');
const plainCodes = generateRecoveryCodes();
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 db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!user?.totp_enabled)
return res.status(400).json(standardizeError('TOTP is not enabled.', 'VALIDATION_ERROR'));
if (!user?.totp_enabled) throw ValidationError('TOTP is not enabled.');
let verified = false;
if (recovery_code) {
@ -388,9 +350,7 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
verified = verifyToken(user.totp_secret, code);
}
if (!verified)
return res
.status(401)
.json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR', 'code'));
throw new ApiError('AUTH_ERROR', 'Invalid authenticator code.', 401, { field: 'code' });
db.prepare(
`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.
router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => {
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
return res
.status(400)
.json(standardizeError('Single-user mode is not enabled.', 'VALIDATION_ERROR', 'auth_mode'));
throw ValidationError('Single-user mode is not enabled.', 'auth_mode');
}
setSetting('auth_mode', 'multi');
@ -469,132 +427,47 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, r
const { current_password, new_password } = req.body;
if (!new_password || new_password.length < 8) {
return res
.status(400)
.json(
standardizeError(
'New password must be at least 8 characters',
'VALIDATION_ERROR',
'new_password',
),
);
throw ValidationError('New password must be at least 8 characters', 'new_password');
}
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
try {
if (!user.must_change_password) {
const bcrypt = require('bcryptjs');
const valid = await bcrypt.compare(current_password || '', user.password_hash);
if (!valid)
return res
.status(401)
.json(
standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'),
);
if (!user.must_change_password) {
const bcrypt = require('bcryptjs');
const valid = await bcrypt.compare(current_password || '', user.password_hash);
if (!valid)
throw new ApiError('AUTH_ERROR', 'Current password is incorrect', 401, {
field: 'current_password',
});
}
const hash = await hashPassword(new_password);
db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
).run(hash, req.user.id);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
const hash = await hashPassword(new_password);
db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
).run(hash, req.user.id);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
}
logAudit({
user_id: req.user.id,
action: 'password.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
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'),
);
}
logAudit({
user_id: req.user.id,
action: 'password.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
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'));
}
res.json({ success: true });
});
// ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
@ -628,138 +501,103 @@ router.get('/webauthn/credentials', requireAuth, (req: Req, res: Res) => {
// GET /api/auth/webauthn/setup — begin registration
router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode)
return res
.status(400)
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
try {
const { options, challengeId } = await createRegistrationChallenge(
getDb(),
req.user.id,
req.user.username,
);
res.json({ options, challengeId });
} catch (err) {
log.error('[webauthn/setup]', err);
res.status(500).json(standardizeError('Failed to generate setup options', 'SERVER_ERROR'));
}
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
const { options, challengeId } = await createRegistrationChallenge(
getDb(),
req.user.id,
req.user.username,
);
res.json({ options, challengeId });
});
// POST /api/auth/webauthn/enable — complete registration
router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode)
return res
.status(400)
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
const { challengeId, response, credential_name } = req.body || {};
if (!challengeId || !response)
return res
.status(400)
.json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR'));
if (!challengeId || !response) throw ValidationError('challengeId and response are required');
try {
const db = getDb();
const result = await verifyRegistration(
db,
req.user.id,
challengeId,
response,
credential_name,
);
if (!result.verified)
return res
.status(400)
.json(standardizeError(result.error || 'Registration failed', 'VALIDATION_ERROR'));
const db = getDb();
const result = await verifyRegistration(
db,
req.user.id,
challengeId,
response,
credential_name,
req.get('origin'),
);
if (!result.verified) throw ValidationError(result.error || 'Registration failed');
db.prepare(
"UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id);
db.prepare(
"UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id);
logAudit({
user_id: req.user.id,
action: 'webauthn.credential_added',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ enabled: true, credential_id: result.credentialId });
} catch (err) {
log.error('[webauthn/enable]', err);
res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR'));
}
logAudit({
user_id: req.user.id,
action: 'webauthn.credential_added',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ enabled: true, credential_id: result.credentialId });
});
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {};
if (!password)
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
if (!password) throw ValidationError('password is required');
const db = getDb();
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
try {
const bcrypt = require('bcryptjs');
if (!(await bcrypt.compare(password, user.password_hash)))
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
const bcrypt = require('bcryptjs');
if (!(await bcrypt.compare(password, user.password_hash)))
throw AuthError('Password is incorrect');
const result = deleteCredential(db, req.params.credentialId, req.user.id);
if (result.changes === 0)
return res.status(404).json(standardizeError('Credential not found', 'NOT_FOUND'));
const result = deleteCredential(db, req.params.credentialId, req.user.id);
if (result.changes === 0) throw NotFoundError('Credential not found');
const { n } = db
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
.get(req.user.id);
if (n === 0)
db.prepare(
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id);
const { n } = db
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
.get(req.user.id);
if (n === 0)
db.prepare(
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id);
logAudit({
user_id: req.user.id,
action: 'webauthn.credential_removed',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
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'));
}
logAudit({
user_id: req.user.id,
action: 'webauthn.credential_removed',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true, webauthn_enabled: n > 0 });
});
// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn
router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {};
if (!password)
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
if (!password) throw ValidationError('password is required');
const db = getDb();
const user = db
.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?')
.get(req.user.id);
if (!user?.webauthn_enabled)
return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR'));
if (!user?.webauthn_enabled) throw ValidationError('WebAuthn is not enabled.');
try {
const bcrypt = require('bcryptjs');
if (!(await bcrypt.compare(password, user.password_hash)))
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
const bcrypt = require('bcryptjs');
if (!(await bcrypt.compare(password, user.password_hash)))
throw AuthError('Password is incorrect');
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
db.prepare(
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id);
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
db.prepare(
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id);
logAudit({
user_id: req.user.id,
action: 'webauthn.disabled',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ enabled: false });
} catch (err) {
log.error('[webauthn/disable]', err);
res.status(500).json(standardizeError('Failed to disable WebAuthn', 'SERVER_ERROR'));
}
logAudit({
user_id: req.user.id,
action: 'webauthn.disabled',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ enabled: false });
});
// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled.
@ -768,59 +606,47 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true;
const { challenge_token, response } = req.body || {};
if (!challenge_token || !response)
return res
.status(400)
.json(standardizeError('challenge_token and response are required', 'VALIDATION_ERROR'));
throw ValidationError('challenge_token and response are required');
const db = getDb();
const session = consumeLoginChallenge(db, challenge_token);
if (!session)
return res
.status(401)
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
if (!session) throw AuthError('Challenge expired or invalid. Please sign in again.');
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'));
try {
const result = await verifyAuthentication(
db,
session.userId,
session.authChallengeId,
response,
);
if (!result.verified) {
logAudit({
user_id: session.userId,
action: 'webauthn.failure',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
return res
.status(401)
.json(standardizeError('Security key verification failed.', 'AUTH_ERROR'));
}
const { createSession } = require('../services/authService.cts');
const s = await createSession(session.userId);
if (!s)
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
if (!user) throw AuthError('User not found.');
const result = await verifyAuthentication(
db,
session.userId,
session.authChallengeId,
response,
req.get('origin'),
);
if (!result.verified) {
logAudit({
user_id: session.userId,
action: 'login.success',
details: { method: 'webauthn' },
action: 'webauthn.failure',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(session.userId, req.ip, req.get('user-agent'), s.sessionId);
res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req));
res.json({ user: s.user });
} catch (err) {
log.error('[webauthn/challenge]', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
throw AuthError('Security key verification failed.');
}
const { createSession } = require('../services/authService.cts');
const s = await createSession(session.userId);
if (!s) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
logAudit({
user_id: session.userId,
action: 'login.success',
details: { method: 'webauthn' },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(session.userId, req.ip, req.get('user-agent'), s.sessionId);
res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req));
res.json({ user: s.user });
});
module.exports = router;

View File

@ -16,7 +16,12 @@ const {
applyBalanceDelta,
} = require('../services/billsService.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 {
addMerchantRule,
@ -100,9 +105,7 @@ router.put('/reorder', (req: Req, res: Res) => {
}));
if (entries.length === 0) {
return res
.status(400)
.json(standardizeError('At least one bill order is required', 'VALIDATION_ERROR', 'reorder'));
throw ValidationError('At least one bill order is required', 'reorder');
}
const invalid = entries.find(
@ -110,15 +113,10 @@ router.put('/reorder', (req: Req, res: Res) => {
!Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0,
);
if (invalid) {
return res
.status(400)
.json(
standardizeError(
'Reorder payload must map bill ids to non-negative integer positions',
'VALIDATION_ERROR',
'reorder',
),
);
throw ValidationError(
'Reorder payload must map bill ids to non-negative integer positions',
'reorder',
);
}
const ids = entries.map((item) => item.billId);
@ -133,9 +131,7 @@ router.put('/reorder', (req: Req, res: Res) => {
)
.all(req.user.id, ...ids);
if (owned.length !== ids.length) {
return res
.status(404)
.json(standardizeError('One or more bills were not found', 'NOT_FOUND', 'bill_id'));
throw NotFoundError('One or more bills were not found', 'bill_id');
}
const update = db.prepare(
@ -172,19 +168,14 @@ router.get('/audit', (req: Req, res: Res) => {
// ── GET /api/bills/drift-report ──────────────────────────────────────────────
router.get('/drift-report', (req: Req, res: Res) => {
const { getDriftReport } = require('../services/driftService.cts');
try {
res.json(getDriftReport(req.user.id));
} catch (err) {
res.status(500).json({ error: 'Failed to compute drift report' });
}
res.json(getDriftReport(req.user.id));
});
// GET /api/bills/merchant-rules — all rules for this user across all bills
router.get('/merchant-rules', (req: Req, res: Res) => {
try {
const rules = getDb()
.prepare(
`
const rules = getDb()
.prepare(
`
SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at,
b.id AS bill_id, b.name AS bill_name
FROM bill_merchant_rules bmr
@ -192,13 +183,9 @@ router.get('/merchant-rules', (req: Req, res: Res) => {
WHERE bmr.user_id = ?
ORDER BY b.name COLLATE NOCASE ASC, LENGTH(bmr.merchant) DESC, bmr.merchant ASC
`,
)
.all(req.user.id);
res.json({ rules });
} catch (err) {
log.error('[bills/merchant-rules GET]', err.message);
res.status(500).json({ error: 'Failed to load merchant rules' });
}
)
.all(req.user.id);
res.json({ rules });
});
// ── 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) => {
const db = getDb();
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
.prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL')
.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();
until.setDate(until.getDate() + 30);
const untilStr = localDateString(until);
@ -257,36 +244,20 @@ router.post('/templates', (req: Req, res: Res) => {
const db = getDb();
const name = String(req.body.name || '').trim();
if (name.length < 2) {
return res
.status(400)
.json(
standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'),
);
throw ValidationError('Template name must be at least 2 characters', 'name');
}
const data = sanitizeTemplateData(req.body.data || {});
if (Object.keys(data).length === 0) {
return res
.status(400)
.json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data'));
throw ValidationError('Template data is required', 'data');
}
const validation = validateBillData(data);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', `data.${firstError.field}`));
throw ValidationError(firstError.message, `data.${firstError.field}`);
}
if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) {
return res
.status(400)
.json(
standardizeError(
'category_id is invalid for this user',
'VALIDATION_ERROR',
'data.category_id',
),
);
throw ValidationError('category_id is invalid for this user', 'data.category_id');
}
const normalizedData = sanitizeTemplateData(validation.normalized);
@ -323,15 +294,12 @@ router.delete('/templates/:templateId', (req: Req, res: Res) => {
const db = getDb();
const templateId = parseInt(req.params.templateId, 10);
if (!Number.isInteger(templateId)) {
return res
.status(400)
.json(standardizeError('template_id must be an integer', 'VALIDATION_ERROR', 'template_id'));
throw ValidationError('template_id must be an integer', 'template_id');
}
const result = db
.prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?')
.run(templateId, req.user.id);
if (result.changes === 0)
return res.status(404).json(standardizeError('Template not found', 'NOT_FOUND', 'template_id'));
if (result.changes === 0) throw NotFoundError('Template not found', 'template_id');
res.json({ success: true });
});
@ -341,15 +309,12 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
const body = req.body || {};
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) {
return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
throw ValidationError('bill_id must be an integer', 'bill_id');
}
const source = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id);
if (!source)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!source) throw NotFoundError('Bill not found', 'bill_id');
const draft = {
...sanitizeTemplateData(serializeBill(source)),
@ -359,17 +324,11 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
const validation = validateBillData(draft);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
throw ValidationError(firstError.message, firstError.field);
}
const { normalized } = validation;
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
throw ValidationError('category_id is invalid for this user', 'category_id');
}
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')
.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 month = parseInt(req.query.month, 10);
if (isNaN(year) || year < 2000 || year > 2100)
return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
if (isNaN(month) || month < 1 || month > 12)
return res
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
throw ValidationError('month must be an integer between 1 and 12', 'month');
const mbs = db
.prepare(
@ -430,54 +377,29 @@ router.put('/:id/monthly-state', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.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 y = parseInt(year, 10);
const m = parseInt(month, 10);
if (isNaN(y) || y < 2000 || y > 2100)
return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
if (isNaN(m) || m < 1 || m > 12)
return res
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
throw ValidationError('month must be an integer between 1 and 12', 'month');
if (actual_amount !== undefined && actual_amount !== null) {
const amt = parseFloat(actual_amount);
if (isNaN(amt) || amt < 0)
return res
.status(400)
.json(
standardizeError(
'actual_amount must be a non-negative number or null',
'VALIDATION_ERROR',
'actual_amount',
),
);
throw ValidationError('actual_amount must be a non-negative number or null', 'actual_amount');
}
if (snoozed_until !== undefined && snoozed_until !== null) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until))
return res
.status(400)
.json(
standardizeError(
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null',
'VALIDATION_ERROR',
'snoozed_until',
),
);
throw ValidationError(
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null',
'snoozed_until',
);
}
// Partial-update semantics: fields omitted from the request keep their
@ -551,8 +473,7 @@ router.get('/:id', (req: Req, res: Res) => {
`,
)
.get(req.params.id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
let autopay_stats = null;
if (bill.autopay_enabled) {
@ -584,25 +505,16 @@ router.post('/:id/verify-autopay', (req: Req, res: Res) => {
const db = getDb();
const id = parseInt(req.params.id, 10);
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
.prepare(
'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
)
.get(id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
if (!bill.autopay_enabled) {
return res
.status(400)
.json(
standardizeError(
'Bill does not have autopay enabled',
'VALIDATION_ERROR',
'autopay_enabled',
),
);
throw ValidationError('Bill does not have autopay enabled', 'autopay_enabled');
}
const now = new Date().toISOString();
db.prepare(
@ -624,23 +536,12 @@ router.post('/', (req: Req, res: Res) => {
) {
const sourceBillId = parseInt(body.source_bill_id, 10);
if (!Number.isInteger(sourceBillId)) {
return res
.status(400)
.json(
standardizeError(
'source_bill_id must be an integer',
'VALIDATION_ERROR',
'source_bill_id',
),
);
throw ValidationError('source_bill_id must be an integer', 'source_bill_id');
}
const source = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(sourceBillId, req.user.id);
if (!source)
return res
.status(404)
.json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
if (!source) throw NotFoundError('Source bill not found', 'source_bill_id');
payload = {
...sanitizeTemplateData(serializeBill(source)),
...sanitizeTemplateData(body),
@ -652,20 +553,14 @@ router.post('/', (req: Req, res: Res) => {
const validation = validateBillData(payload);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
throw ValidationError(firstError.message, firstError.field);
}
const { normalized } = validation;
// Validate category_id exists for this user
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
throw ValidationError('category_id is invalid for this user', 'category_id');
}
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
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id);
if (!existing)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!existing) throw NotFoundError('Bill not found', 'bill_id');
// Validate and normalize bill data
const validation = validateBillData(req.body, existing);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
throw ValidationError(firstError.message, firstError.field);
}
const { normalized } = validation;
// Validate category_id exists for this user if changed
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
throw ValidationError('category_id is invalid for this user', 'category_id');
}
const inactiveReason =
@ -772,13 +660,12 @@ router.put('/:id/archived', (req: Req, res: Res) => {
const db = getDb();
const id = parseInt(req.params.id, 10);
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
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
const archived = !!req.body?.archived;
db.prepare(
@ -797,8 +684,7 @@ router.delete('/:id', (req: Req, res: Res) => {
const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
db.prepare(
"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
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL')
.get(req.params.id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Deleted bill not found', 'bill_id');
db.prepare(
"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.
router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId))
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!Number.isInteger(billId)) throw ValidationError('Invalid bill id');
const db = getDb();
const bill = db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
try {
const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
res.json(result);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Sync failed', 'SYNC_ERROR'));
}
if (!bill) throw NotFoundError('Bill not found');
const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
res.json(result);
});
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
@ -860,8 +740,7 @@ router.get('/:id/payments', (req: Req, res: Res) => {
const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
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 billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) {
return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
throw ValidationError('bill_id must be an integer', 'bill_id');
}
const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
const rows = db
.prepare(
@ -971,40 +847,19 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => {
)
.get(billId, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
// Scope to year/month if provided
const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null;
const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null;
if ((year === null) !== (month === null)) {
return res
.status(400)
.json(
standardizeError(
'year and month must both be provided or both omitted',
'VALIDATION_ERROR',
'year',
),
);
throw ValidationError('year and month must both be provided or both omitted', 'year');
}
if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) {
return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
}
if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) {
return res
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
throw ValidationError('month must be an integer between 1 and 12', 'month');
}
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' },
{ requireBillId: false },
);
if (paymentValidation.error) {
return res
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
if (paymentValidation.error)
throw ValidationError(paymentValidation.error, paymentValidation.field);
const payment = paymentValidation.normalized;
// 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')
.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
.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')
.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 sy = parseInt(start_year, 10);
const sm = parseInt(start_month, 10);
if (isNaN(sy) || sy < 2000 || sy > 2100)
return res
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
throw ValidationError('start_year must be between 2000 and 2100', 'start_year');
if (isNaN(sm) || sm < 1 || sm > 12)
return res
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
throw ValidationError('start_month must be between 1 and 12', 'start_month');
let ey = null,
em = null;
if (end_year != null) {
ey = parseInt(end_year, 10);
if (isNaN(ey) || ey < 2000 || ey > 2100)
return res
.status(400)
.json(
standardizeError(
'end_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'end_year',
),
);
throw ValidationError('end_year must be between 2000 and 2100', 'end_year');
}
if (end_month != null) {
em = parseInt(end_month, 10);
if (isNaN(em) || em < 1 || em > 12)
return res
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
throw ValidationError('end_month must be between 1 and 12', 'end_month');
}
if ((ey == null) !== (em == null)) {
return res
.status(400)
.json(
standardizeError(
'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year',
),
);
throw ValidationError(
'end_year and end_month must both be provided or both omitted',
'end_year',
);
}
if (ey != null) {
const startVal = sy * 12 + sm;
const endVal = ey * 12 + em;
if (endVal < startVal)
return res
.status(400)
.json(
standardizeError(
'end date must be on or after start date',
'VALIDATION_ERROR',
'end_year',
),
);
throw ValidationError('end date must be on or after start date', 'end_year');
}
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')
.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
.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
.get(req.params.rangeId, req.params.id);
if (!range)
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
if (!range) throw NotFoundError('History range not found', 'rangeId');
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 sm = start_month != null ? parseInt(start_month, 10) : range.start_month;
if (isNaN(sy) || sy < 2000 || sy > 2100)
return res
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
throw ValidationError('start_year must be between 2000 and 2100', 'start_year');
if (isNaN(sm) || sm < 1 || sm > 12)
return res
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
throw ValidationError('start_month must be between 1 and 12', 'start_month');
let ey = range.end_year;
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 (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100))
return res
.status(400)
.json(
standardizeError('end_year must be between 2000 and 2100', 'VALIDATION_ERROR', 'end_year'),
);
throw ValidationError('end_year must be between 2000 and 2100', 'end_year');
if (em != null && (isNaN(em) || em < 1 || em > 12))
return res
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
throw ValidationError('end_month must be between 1 and 12', 'end_month');
if ((ey == null) !== (em == null))
return res
.status(400)
.json(
standardizeError(
'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year',
),
);
throw ValidationError(
'end_year and end_month must both be provided or both omitted',
'end_year',
);
if (ey != null && ey * 12 + em < sy * 12 + sm)
return res
.status(400)
.json(
standardizeError('end date must be on or after start date', 'VALIDATION_ERROR', 'end_year'),
);
throw ValidationError('end date must be on or after start date', 'end_year');
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')
.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
.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
.get(req.params.rangeId, req.params.id);
if (!range)
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
if (!range) throw NotFoundError('History range not found', 'rangeId');
db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run(
req.params.rangeId,
@ -1356,8 +1136,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
const balance = fromCents(Number(bill.current_balance));
const apr = Number(bill.interest_rate) || 0;
@ -1368,9 +1147,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
if (req.query.payment !== undefined) {
const qp = parseFloat(req.query.payment);
if (!Number.isFinite(qp) || qp <= 0) {
return res
.status(400)
.json(standardizeError('payment must be a positive number', 'VALIDATION_ERROR', 'payment'));
throw ValidationError('payment must be a positive number', 'payment');
}
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')
.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 =
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 = ?');
vals.push(exempt);
}
if (parts.length === 0)
return res.status(400).json(standardizeError('Nothing to update', 'VALIDATION_ERROR'));
if (parts.length === 0) throw ValidationError('Nothing to update');
parts.push("updated_at = datetime('now')");
db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run(
...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')
.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;
@ -1468,15 +1244,7 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
if (raw !== null && raw !== '' && raw !== undefined) {
val = parseFloat(raw);
if (!Number.isFinite(val) || val < 0) {
return res
.status(400)
.json(
standardizeError(
'current_balance must be a non-negative number',
'VALIDATION_ERROR',
'current_balance',
),
);
throw ValidationError('current_balance must be a non-negative number', 'current_balance');
}
val = roundMoney(val);
}
@ -1538,10 +1306,8 @@ function findConflicts(db, userId, billId, normalized) {
router.get('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
const rules = db
.prepare(
@ -1591,10 +1357,8 @@ router.get('/:id/merchant-rules', (req: Req, res: Res) => {
router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
const raw = String(req.query.merchant || '').trim();
const normalized = normalizeMerchant(raw);
@ -1612,37 +1376,23 @@ router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
router.post('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
const raw = String(req.body?.merchant || '').trim();
const normalized = normalizeMerchant(raw);
if (!normalized || normalized.length < 2)
return res
.status(400)
.json(
standardizeError(
'merchant must be at least 2 characters after normalisation',
'VALIDATION_ERROR',
'merchant',
),
);
throw ValidationError('merchant must be at least 2 characters after normalisation', 'merchant');
const conflicts = findConflicts(db, req.user.id, billId, normalized);
try {
db.prepare(
`
db.prepare(
`
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
VALUES (?, ?, ?)
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
`,
).run(req.user.id, billId, normalized);
} catch (err) {
return res.status(500).json(standardizeError('Failed to save rule', 'DB_ERROR'));
}
).run(req.user.id, billId, normalized);
// Retroactively apply the new rule to existing unmatched transactions
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) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill 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
.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) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill 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;
if (!Array.isArray(ids) || ids.length === 0)
return res
.status(400)
.json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR'));
throw ValidationError('transaction_ids must be a non-empty array');
const validIds = ids.filter((id) => Number.isInteger(id) && id > 0);
if (validIds.length === 0)
return res
.status(400)
.json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR'));
if (validIds.length === 0) throw ValidationError('No valid transaction ids provided');
const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
const getTx = db.prepare(
@ -1837,7 +1580,7 @@ router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
})();
} catch (err) {
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 });
@ -1850,9 +1593,8 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res)
const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
throw ValidationError('Invalid id');
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
const enabled = req.body?.enabled ? 1 : 0;
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;
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 });
});
@ -1870,15 +1612,14 @@ router.delete('/:id/merchant-rules/:ruleId', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
throw ValidationError('Invalid id');
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
const changes = db
.prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?')
.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 });
});

View File

@ -4,7 +4,7 @@ import type { Req, Res, Next } from '../types/http';
const router = require('express').Router();
const { getDb } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
const {
decorateDataSource,
ensureManualDataSource,
@ -26,10 +26,19 @@ function cleanFilter(value) {
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 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 ──────────────────────────────────────
@ -37,11 +46,10 @@ function safeError(err, fallback) {
// Used by the bank tracking account picker.
router.get('/accounts/all', (req: Req, res: Res) => {
try {
const db = getDb();
const accounts = db
.prepare(
`
const db = getDb();
const accounts = db
.prepare(
`
SELECT
fa.id, fa.name, fa.org_name, fa.account_type,
fa.balance, fa.available_balance, fa.currency,
@ -51,19 +59,16 @@ router.get('/accounts/all', (req: Req, res: Res) => {
WHERE fa.user_id = ?
ORDER BY fa.org_name COLLATE NOCASE ASC, fa.name COLLATE NOCASE ASC
`,
)
.all(req.user.id);
)
.all(req.user.id);
res.json(
accounts.map((a) => ({
...a,
monitored: a.monitored === 1,
balance_dollars: a.balance !== null ? a.balance / 100 : null,
})),
);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
}
res.json(
accounts.map((a) => ({
...a,
monitored: a.monitored === 1,
balance_dollars: a.balance !== null ? a.balance / 100 : null,
})),
);
});
// ─── GET /api/data-sources ────────────────────────────────────────────────────
@ -76,21 +81,9 @@ router.get('/', (req: Req, res: Res) => {
const status = cleanFilter(req.query.status);
if (type && !VALID_TYPES.has(type))
return res
.status(400)
.json(
standardizeError(
'type must be manual, file_import, or provider_sync',
'VALIDATION_ERROR',
'type',
),
);
throw ValidationError('type must be manual, file_import, or provider_sync', 'type');
if (status && !VALID_STATUSES.has(status))
return res
.status(400)
.json(
standardizeError('status must be active, inactive, or error', 'VALIDATION_ERROR', 'status'),
);
throw ValidationError('status must be active, inactive, or error', 'status');
let query = `
SELECT
@ -159,17 +152,11 @@ router.get('/simplefin/status', (req: Req, res: Res) => {
// ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
router.post('/simplefin/connect', async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : '';
if (!setupToken) {
return res
.status(400)
.json(standardizeError('setupToken is required', 'VALIDATION_ERROR', 'setupToken'));
throw ValidationError('setupToken is required', 'setupToken');
}
try {
@ -177,8 +164,7 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
const result = await connectSimplefin(db, req.user.id, setupToken);
res.status(201).json(result);
} catch (err) {
const { msg, status } = safeError(err, 'Failed to connect SimpleFIN');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
throw toSourceError(err, 'Failed to connect SimpleFIN');
}
});
@ -187,23 +173,19 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
router.get('/:sourceId/accounts', (req: Req, res: Res) => {
const sourceId = parseInt(req.params.sourceId, 10);
if (!Number.isInteger(sourceId) || sourceId < 1) {
return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId'));
throw ValidationError('Invalid data source id', 'sourceId');
}
try {
const db = getDb();
const db = getDb();
const source = db
.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?')
.get(sourceId, req.user.id);
if (!source)
return res.status(404).json(standardizeError('Data source not found', 'NOT_FOUND'));
const source = db
.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?')
.get(sourceId, req.user.id);
if (!source) throw NotFoundError('Data source not found');
const accounts = db
.prepare(
`
const accounts = db
.prepare(
`
SELECT
fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type,
fa.balance, fa.available_balance, fa.currency, fa.monitored,
@ -215,10 +197,10 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
GROUP BY fa.id
ORDER BY fa.name COLLATE NOCASE ASC
`,
)
.all(sourceId, req.user.id);
)
.all(sourceId, req.user.id);
const txStmt = db.prepare(`
const txStmt = db.prepare(`
SELECT t.id, t.posted_date, t.transacted_at, t.amount, t.currency,
t.payee, t.description, t.memo, t.match_status, t.ignored,
t.matched_bill_id, b.name AS matched_bill_name
@ -229,16 +211,13 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
LIMIT 50
`);
const result = accounts.map((acc) => ({
...acc,
monitored: acc.monitored === 1,
transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [],
}));
const result = accounts.map((acc) => ({
...acc,
monitored: acc.monitored === 1,
transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [],
}));
res.json(result);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
}
res.json(result);
});
// ─── PUT /api/data-sources/:sourceId/accounts/:accountId ─────────────────────
@ -252,52 +231,39 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
!Number.isInteger(accountId) ||
accountId < 1
) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
throw ValidationError('Invalid id');
}
if (typeof req.body?.monitored !== 'boolean') {
return res
.status(400)
.json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored'));
throw ValidationError('monitored must be a boolean', 'monitored');
}
try {
const db = getDb();
const result = db
.prepare(
`
const db = getDb();
const result = db
.prepare(
`
UPDATE financial_accounts
SET monitored = ?, updated_at = datetime('now')
WHERE id = ? AND data_source_id = ? AND user_id = ?
`,
)
.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)
return res.status(404).json(standardizeError('Account not found', 'NOT_FOUND'));
if (result.changes === 0) throw NotFoundError('Account not found');
const account = db
.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?')
.get(accountId);
res.json({ ...account, monitored: account.monitored === 1 });
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to update account', 'DB_ERROR'));
}
const account = db
.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?')
.get(accountId);
res.json({ ...account, monitored: account.monitored === 1 });
});
// ─── POST /api/data-sources/:id/sync ─────────────────────────────────────────
router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) {
return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
throw ValidationError('Invalid data source id', 'id');
}
try {
@ -305,8 +271,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
const result = await syncDataSource(db, req.user.id, id);
res.json(result);
} catch (err) {
const { msg, status } = safeError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
throw toSourceError(err, 'Sync failed');
}
});
@ -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.
router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
try {
const db = getDb();
@ -329,7 +290,7 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
.all(req.user.id);
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;
@ -364,25 +325,18 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
errors,
});
} catch (err) {
const { msg, status } = safeError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, 'SIMPLEFIN_ERROR'));
throw toSourceError(err, 'Sync failed');
}
});
// ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) {
return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
throw ValidationError('Invalid data source id', 'id');
}
try {
@ -390,33 +344,25 @@ router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
const result = await backfillDataSource(db, req.user.id, id);
res.json(result);
} catch (err) {
const { msg, status } = safeError(err, 'Backfill failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
throw toSourceError(err, 'Backfill failed');
}
});
// ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
router.delete('/:id', (req: Req, res: Res) => {
if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
requireBankSyncEnabled();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) {
return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
throw ValidationError('Invalid data source id', 'id');
}
try {
disconnectDataSource(getDb(), req.user.id, id);
res.json({ ok: true });
} catch (err) {
const { msg, status } = safeError(err, 'Failed to disconnect');
res.status(status).json(standardizeError(msg, err?.code || 'DISCONNECT_ERROR'));
throw toSourceError(err, 'Failed to disconnect', 'DISCONNECT_ERROR');
}
});

View File

@ -342,11 +342,19 @@ function buildUserDbExportFile(userId) {
router.get('/user-db', (req: Req, res: Res) => {
const file = buildUserDbExportFile(req.user.id);
res.download(file, 'bill-tracker-user-export.sqlite', () => {
try {
fs.unlinkSync(file);
} catch {}
});
// 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 {
fs.unlinkSync(file);
} catch {}
},
);
});
// 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) {
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({
error: fallback,
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).
import type { Req, Res, Next } from '../types/http';
const router = require('express').Router();
const { log } = require('../utils/logger.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const { getDb } = require('../db/database.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const {
applyBankPaymentAsSourceOfTruth,
reactivatePaymentsOverriddenBy,
@ -17,32 +21,23 @@ const { markMatched, markUnmatched } = require('../services/transactionMatchStat
const { serializePayment } = require('../services/paymentValidation.cts');
const { todayLocal } = require('../utils/dates.mts');
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
if (err.status) {
return res
.status(err.status)
.json(standardizeError(err.message, err.code || 'MATCH_ERROR', err.field));
// 4xx service errors keep their message/code on the wire; anything else is
// rethrown raw so the terminal handler masks + logs it as a 5xx.
function toMatchError(err) {
if (err.status && err.status < 500) {
return new ApiError(err.code || 'MATCH_ERROR', err.message, err.status, { field: err.field });
}
log.error('[matches] service error:', err.stack || err.message);
return res.status(500).json(standardizeError(fallbackMessage, 'MATCH_ERROR'));
return err;
}
// GET /api/matches/suggestions
router.get('/suggestions', (req: Req, res: Res) => {
try {
res.json(listMatchSuggestions(req.user.id, req.query));
} catch (err) {
return sendMatchError(res, err, 'Match suggestions failed');
}
res.json(listMatchSuggestions(req.user.id, req.query));
});
// POST /api/matches/:id/reject
router.post('/:id/reject', (req: Req, res: Res) => {
try {
res.json(rejectMatchSuggestion(req.user.id, req.params.id));
} catch (err) {
return sendMatchError(res, err, 'Rejecting match suggestion failed');
}
res.json(rejectMatchSuggestion(req.user.id, req.params.id));
});
// 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 billId = parseInt(req.body?.bill_id, 10);
if (!Number.isInteger(txId) || !Number.isInteger(billId)) {
return res
.status(400)
.json(
standardizeError('transaction_id and bill_id are required integers', 'VALIDATION_ERROR'),
);
throw ValidationError('transaction_id and bill_id are required integers');
}
const db = getDb();
const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
.get(txId, req.user.id);
if (!tx)
return res
.status(404)
.json(standardizeError('Transaction not found', 'NOT_FOUND', 'transaction_id'));
if (!tx) throw NotFoundError('Transaction not found', 'transaction_id');
if (tx.match_status === 'matched') {
return res
.status(409)
.json(
standardizeError(
'Transaction is already matched to a bill',
'ALREADY_MATCHED',
'transaction_id',
),
);
throw ConflictError(
'Transaction is already matched to a bill',
'transaction_id',
'ALREADY_MATCHED',
);
}
const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
const existing = db
.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL')
.get(txId);
if (existing)
return res
.status(409)
.json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH'));
throw ConflictError(
'A payment is already linked to this transaction',
undefined,
'DUPLICATE_MATCH',
);
const paidDate =
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 {
db.exec('ROLLBACK');
} 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) => {
const txId = parseInt(req.params.transactionId, 10);
if (!Number.isInteger(txId)) {
return res
.status(400)
.json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR'));
throw ValidationError('transactionId must be an integer');
}
const db = getDb();
const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND 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') {
return res.status(409).json(standardizeError('Transaction is not matched', 'NOT_MATCHED'));
throw ConflictError('Transaction is not matched', undefined, 'NOT_MATCHED');
}
try {
@ -183,7 +166,7 @@ router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
try {
db.exec('ROLLBACK');
} 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 { decryptSecret } = require('../services/encryptionService.cts');
const { encryptSecret } = require('../services/encryptionService.cts');
const { ApiError, ValidationError } = require('../utils/apiError.cts');
// ── Admin: SMTP configuration ─────────────────────────────────────────────────
@ -74,13 +75,16 @@ router.put('/admin', requireAuth, requireAdmin, (req: Req, res: Res) => {
// POST /api/notifications/test — send a test email
router.post('/test', requireAuth, requireAdmin, async (req: Req, res: Res) => {
const { to } = req.body;
if (!to) return res.status(400).json({ error: 'Recipient address required' });
if (!to) throw ValidationError('Recipient address required', 'to');
try {
await sendTestEmail(to);
res.json({ success: true });
} 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 ───────────────────────────────────────────
@ -164,7 +168,7 @@ router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) =
.get(req.user.id);
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
@ -185,10 +189,12 @@ router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) =
try {
if (!sendTestPush) throw new Error('Push service not initialised');
await sendTestPush(userForPush);
res.json({ success: true });
} 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;

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).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const { log } = require('../utils/logger.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const router = require('express').Router();
const { getDb } = require('../db/database.cts');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts');
@ -27,38 +32,22 @@ function isTransactionLinkedPayment(payment) {
return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null;
}
function rejectTransactionLinkedPayment(res) {
return res
.status(409)
.json(
standardizeError(
'Transaction-linked payments must be changed through transaction match controls',
'TRANSACTION_PAYMENT_LOCKED',
'transaction_id',
),
);
function rejectTransactionLinkedPayment() {
throw ConflictError(
'Transaction-linked payments must be changed through transaction match controls',
'transaction_id',
'TRANSACTION_PAYMENT_LOCKED',
);
}
function parseYearMonth(body) {
const year = parseInt(body.year, 10);
const month = parseInt(body.month, 10);
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
return {
error: standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
};
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
}
if (!Number.isInteger(month) || month < 1 || month > 12) {
return {
error: standardizeError(
'month must be an integer between 1 and 12',
'VALIDATION_ERROR',
'month',
),
};
throw ValidationError('month must be an integer between 1 and 12', 'month');
}
return { year, month };
}
@ -73,17 +62,9 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
`,
)
.get(billId, userId);
if (!bill)
return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 };
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
return {
error: standardizeError(
'Bill is not eligible for autopay suggestions',
'VALIDATION_ERROR',
'bill_id',
),
status: 400,
};
throw ValidationError('Bill is not eligible for autopay suggestions', 'bill_id');
}
const state = db
@ -96,26 +77,12 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
)
.get(bill.id, year, month);
if (state?.is_skipped) {
return {
error: standardizeError(
'Skipped bills cannot be suggested for payment',
'VALIDATION_ERROR',
'bill_id',
),
status: 400,
};
throw ValidationError('Skipped bills cannot be suggested for payment', 'bill_id');
}
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) {
return {
error: standardizeError(
'Bill does not occur in the selected month',
'VALIDATION_ERROR',
'month',
),
status: 400,
};
throw ValidationError('Bill does not occur in the selected month', 'month');
}
const amount = fromCents(state?.actual_amount ?? bill.expected_amount);
return { bill, dueDate, amount };
@ -128,15 +95,7 @@ router.get('/', (req: Req, res: Res) => {
// Validate year/month when provided
if ((year || month) && !(year && month)) {
return res
.status(400)
.json(
standardizeError(
'Both year and month are required when filtering by date',
'VALIDATION_ERROR',
'year',
),
);
throw ValidationError('Both year and month are required when filtering by date', 'year');
}
let y, m;
@ -144,26 +103,10 @@ router.get('/', (req: Req, res: Res) => {
y = parseInt(year, 10);
m = parseInt(month, 10);
if (!Number.isInteger(y) || y < 2000 || y > 2100) {
return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
}
if (!Number.isInteger(m) || m < 1 || m > 12) {
return res
.status(400)
.json(
standardizeError(
'month must be an integer between 1 and 12',
'VALIDATION_ERROR',
'month',
),
);
throw ValidationError('month must be an integer between 1 and 12', '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`,
)
.get(req.params.id, req.user.id);
if (!payment)
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (!payment) throw NotFoundError('Payment not found', 'id');
res.json(serializePayment(payment));
});
@ -246,51 +188,42 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => {
)
.get(req.params.id, req.user.id);
if (!payment)
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (!payment) throw NotFoundError('Payment not found', 'id');
if (payment.payment_source !== 'provider_sync') {
return res
.status(409)
.json(standardizeError('Only provider_sync payments can be undone here', 'NOT_AUTO_MATCH'));
throw ConflictError(
'Only provider_sync payments can be undone here',
undefined,
'NOT_AUTO_MATCH',
);
}
if (!payment.transaction_id) {
return res
.status(409)
.json(standardizeError('Payment has no linked transaction', 'NO_TRANSACTION'));
throw ConflictError('Payment has no linked transaction', undefined, 'NO_TRANSACTION');
}
try {
db.transaction(() => {
// Restore balance (same logic as DELETE /:id)
if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db
.prepare('SELECT current_balance FROM bills WHERE id = ?')
.get(payment.bill_id);
if (bill?.current_balance != null) {
const restored = Math.max(
0,
Number(bill.current_balance) - Number(payment.balance_delta),
);
db.prepare(
`
db.transaction(() => {
// Restore balance (same logic as DELETE /:id)
if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db
.prepare('SELECT current_balance FROM bills WHERE id = ?')
.get(payment.bill_id);
if (bill?.current_balance != null) {
const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta));
db.prepare(
`
UPDATE bills
SET current_balance = ?,
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
updated_at = datetime('now')
WHERE id = ?
`,
).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
}
).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
}
reactivatePaymentsOverriddenBy(db, payment.id);
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
markUnmatched(db, req.user.id, payment.transaction_id);
})();
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'));
}
}
reactivatePaymentsOverriddenBy(db, payment.id);
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
markUnmatched(db, req.user.id, payment.transaction_id);
})();
res.json({ success: true });
});
// POST /api/payments — create single payment
@ -313,18 +246,13 @@ router.post('/', (req: Req, res: Res) => {
paid_date,
payment_source: payment_source ?? 'manual',
});
if (validation.error) {
return res
.status(400)
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
if (validation.error) throw ValidationError(validation.error, validation.field);
const payment = validation.normalized;
const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(payment.bill_id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
// 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
@ -392,17 +320,12 @@ router.post('/quick', (req: Req, res: Res) => {
{ bill_id },
{ requireAmount: false, requirePaidDate: false },
);
if (billValidation.error) {
return res
.status(400)
.json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
}
if (billValidation.error) throw ValidationError(billValidation.error, billValidation.field);
const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billValidation.normalized.bill_id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
const paymentValidation = validatePaymentInput(
{
@ -412,11 +335,8 @@ router.post('/quick', (req: Req, res: Res) => {
},
{ requireBillId: false },
);
if (paymentValidation.error) {
return res
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
if (paymentValidation.error)
throw ValidationError(paymentValidation.error, paymentValidation.field);
const payAmount = paymentValidation.normalized.amount;
const payDate = paymentValidation.normalized.paid_date;
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) => {
const db = getDb();
const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) {
return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
throw ValidationError('bill_id must be an integer', 'bill_id');
}
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;
if (dueDate > todayLocal()) {
return res
.status(400)
.json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
throw ValidationError('Autopay suggestion is not due yet', 'paid_date');
}
const paymentValidation = validatePaymentInput(
{ amount, paid_date: dueDate },
{ requireBillId: false },
);
if (paymentValidation.error) {
return res
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
if (paymentValidation.error)
throw ValidationError(paymentValidation.error, paymentValidation.field);
const suggestedPayment = paymentValidation.normalized;
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) => {
const db = getDb();
const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) {
return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
throw ValidationError('bill_id must be an integer', 'bill_id');
}
if (
!db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.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(
@ -602,23 +510,11 @@ router.post('/bulk', (req: Req, res: Res) => {
// Validate request body has payments array
if (!payments || !Array.isArray(payments))
return res
.status(400)
.json(
standardizeError(
'Request body must contain a `payments` array',
'VALIDATION_ERROR',
'payments',
),
);
throw ValidationError('Request body must contain a `payments` array', 'payments');
// Validate max items per request (50)
if (payments.length > 50)
return res
.status(400)
.json(
standardizeError('Maximum 50 items allowed per request', 'VALIDATION_ERROR', 'payments'),
);
throw ValidationError('Maximum 50 items allowed per request', 'payments');
// Validate each payment item
for (let i = 0; i < payments.length; i++) {
@ -628,15 +524,7 @@ router.post('/bulk', (req: Req, res: Res) => {
{ fieldPrefix: `payments[${i}].` },
);
if (validation.error) {
return res
.status(400)
.json(
standardizeError(
`Payment at index ${i}: ${validation.error}`,
'VALIDATION_ERROR',
validation.field,
),
);
throw ValidationError(`Payment at index ${i}: ${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`,
)
.get(req.params.id, req.user.id);
if (!existing)
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res);
if (!existing) throw NotFoundError('Payment not found', 'id');
if (isTransactionLinkedPayment(existing)) rejectTransactionLinkedPayment();
const { amount, paid_date, method, notes, payment_source } = req.body;
const validation = validatePaymentInput(
{ amount, paid_date, payment_source },
{ requireBillId: false, requireAmount: false, requirePaidDate: false },
);
if (validation.error) {
return res
.status(400)
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
if (validation.error) throw ValidationError(validation.error, validation.field);
const nextAmount = validation.normalized.amount ?? existing.amount;
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`,
)
.get(req.params.id, req.user.id);
if (!payment)
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
if (!payment) throw NotFoundError('Payment not found', 'id');
if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
// 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
@ -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',
)
.get(req.params.id, req.user.id);
if (!payment)
return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
if (!payment) throw NotFoundError('Deleted payment not found', 'id');
if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
// 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
@ -923,90 +804,72 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
const db = getDb();
const paymentId = parseInt(req.params.id, 10);
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;
if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) {
return res
.status(400)
.json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date'));
throw ValidationError('paid_date must be YYYY-MM-DD', 'paid_date');
}
// Validate it is a real calendar date
const newDate = new Date(paid_date + 'T00:00:00Z');
if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
return res
.status(400)
.json(
standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'),
);
throw ValidationError('paid_date is not a valid calendar date', 'paid_date');
}
try {
const payment = db
.prepare(
`
const payment = db
.prepare(
`
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(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
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
return res
.status(409)
.json(
standardizeError(
'Only bank-synced payments can be reclassified to a different month',
'RECLASSIFY_ONLY_SYNC',
),
);
}
// Sanity check: new date must be in the month immediately before the original date
const orig = new Date(payment.paid_date + 'T00:00:00');
const origYM = orig.getFullYear() * 12 + orig.getMonth();
const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
if (newYM !== origYM - 1) {
return res
.status(400)
.json(
standardizeError(
'The new paid_date must be in the month immediately before the original payment date',
'VALIDATION_ERROR',
'paid_date',
),
);
}
db.transaction(() => {
db.prepare(
"UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?",
).run(paid_date, paymentId);
const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(payment.bill_id, req.user.id);
if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
})();
res.json(
serializePayment(
db
.prepare(
`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(paymentId, req.user.id),
),
// Only allow date-only reclassification for provider_sync payments
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
throw ConflictError(
'Only bank-synced payments can be reclassified to a different month',
undefined,
'RECLASSIFY_ONLY_SYNC',
);
} catch (err) {
log.error('[payments] attribute-to-month error:', err.message);
res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));
}
// Sanity check: new date must be in the month immediately before the original date
const orig = new Date(payment.paid_date + 'T00:00:00');
const origYM = orig.getFullYear() * 12 + orig.getMonth();
const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
if (newYM !== origYM - 1) {
throw ValidationError(
'The new paid_date must be in the month immediately before the original payment date',
'paid_date',
);
}
db.transaction(() => {
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?").run(
paid_date,
paymentId,
);
const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(payment.bill_id, req.user.id);
if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
})();
res.json(
serializePayment(
db
.prepare(
`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(paymentId, req.user.id),
),
);
});
module.exports = router;

View File

@ -3,7 +3,7 @@ import type { Req, Res, Next } from '../types/http';
('use strict');
const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
@ -18,7 +18,14 @@ const {
} = require('../services/authService.cts');
const { getImportHistory } = require('../services/spreadsheetImportService.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');
// All profile routes require authentication — enforced in server.js.
@ -30,9 +37,7 @@ function dataImportEnabled() {
function requireDataImportEnabled(req, res, next) {
if (!dataImportEnabled()) {
return res
.status(403)
.json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN'));
throw ForbiddenError('Data import is disabled by DATA_IMPORT_ENABLED=false');
}
next();
}
@ -73,7 +78,7 @@ router.get('/', (req: Req, res: Res) => {
)
.get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (!user) throw NotFoundError('User not found');
res.json({
...profileResponse(user),
@ -108,20 +113,20 @@ router.patch('/', (req: Req, res: Res) => {
if (username !== undefined) {
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();
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) {
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
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
.get(trimmedUsername, req.user.id);
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(
trimmedUsername,
@ -137,11 +142,11 @@ router.patch('/', (req: Req, res: Res) => {
if (displayNameInput !== undefined) {
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();
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(
@ -188,7 +193,7 @@ router.get('/settings', (req: Req, res: Res) => {
)
.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)
const pushUrlDecrypted = user.push_url
@ -246,10 +251,10 @@ router.patch('/settings', (req: Req, res: Res) => {
if (nextEmail !== undefined && nextEmail !== null) {
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) {
return res.status(400).json({ error: 'notification_email is too long' });
throw ValidationError('notification_email is too long', 'notification_email');
}
}
@ -350,67 +355,64 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
const { current_password, new_password, confirm_new_password } = req.body;
if (!current_password) {
return res.status(400).json({ error: 'current_password is required' });
throw ValidationError('current_password is required', 'current_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) {
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) {
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) {
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 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);
if (!valid) {
return res.status(401).json({ error: 'current password is incorrect' });
}
const valid = await bcrypt.compare(current_password, user.password_hash);
if (!valid) {
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);
db.prepare(
`
db.prepare(
`
UPDATE users
SET password_hash = ?, must_change_password = 0,
last_password_change_at = datetime('now'),
updated_at = datetime('now')
WHERE id = ?
`,
).run(hash, req.user.id);
).run(hash, req.user.id);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
logAudit({
user_id: req.user.id,
action: 'password.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true });
} catch (err) {
log.error('[profile] change-password error:', err.message);
res.status(500).json({ error: 'Password change failed' });
}
logAudit({
user_id: req.user.id,
action: 'password.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true });
});
// ── GET /api/profile/exports ──────────────────────────────────────────────────
@ -441,13 +443,8 @@ router.get('/exports', (req: Req, res: Res) => {
// Returns the signed-in user's import history.
// Delegates to the same service as GET /api/import/history.
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
try {
const history = getImportHistory(req.user.id);
res.json({ history });
} catch (err) {
log.error('[profile] import-history error:', err.message);
res.status(500).json({ error: 'Failed to load import history' });
}
const history = getImportHistory(req.user.id);
res.json({ history });
});
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).
import type { Req, Res, Next } from '../types/http';
const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router();
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 { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.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 !== '') {
val = parseFloat(extra_payment);
if (!Number.isFinite(val) || val < 0) {
return res
.status(400)
.json(
standardizeError(
'extra_payment must be a non-negative number',
'VALIDATION_ERROR',
'extra_payment',
),
);
throw ValidationError('extra_payment must be a non-negative number', 'extra_payment');
}
}
@ -261,9 +252,7 @@ function buildComparison(snowball, minimum_only) {
router.patch('/order', (req: Req, res: Res) => {
const items = req.body;
if (!Array.isArray(items)) {
return res
.status(400)
.json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
throw ValidationError('Request body must be an array');
}
if (items.length === 0) {
return res.json({ success: true, updated: 0 });
@ -276,24 +265,12 @@ router.patch('/order', (req: Req, res: Res) => {
const id = parseInt(row?.id, 10);
const order = parseInt(row?.snowball_order, 10);
if (!Number.isInteger(id) || id <= 0) {
return res
.status(400)
.json(
standardizeError(
`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`,
'VALIDATION_ERROR',
),
);
throw ValidationError(`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`);
}
if (!Number.isInteger(order) || order < 0) {
return res
.status(400)
.json(
standardizeError(
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
'VALIDATION_ERROR',
),
);
throw ValidationError(
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
);
}
parsed.push({ id, order });
}
@ -373,204 +350,170 @@ function enrichPlanWithProgress(db, plan) {
// POST /api/snowball/plans — start a new snowball plan
router.post('/plans', (req: Req, res: Res) => {
try {
const db = getDb();
const userId = req.user.id;
const { name, method, notes } = req.body;
const db = getDb();
const userId = req.user.id;
const { name, method, notes } = req.body;
const planName =
typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan';
const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball';
const planName =
typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan';
const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball';
const ramseyMode = isRamseyMode(userId);
const debts = getDebtBills(userId, ramseyMode);
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0);
if (activeDebts.length === 0) {
return res
.status(400)
.json(
standardizeError(
'No debts with a balance found. Add a balance to at least one bill.',
'NO_DEBTS',
),
);
}
// Money fields on `debts` are stored as integer cents; the snowball/APR
// math and plan_snapshot are dollar-denominated, so convert before computing.
const debtsForMath = debts.map((b) => ({
...b,
current_balance: fromCents(b.current_balance),
minimum_payment: fromCents(b.minimum_payment),
}));
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
const extraCents = user?.snowball_extra_payment ?? 0;
const extra = fromCents(extraCents);
const now = new Date();
const snowball =
planMethod === 'avalanche'
? calculateAvalanche(debtsForMath, extra, now)
: calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debtsForMath, now);
const interestSaved = Math.max(
0,
Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) /
100,
const ramseyMode = isRamseyMode(userId);
const debts = getDebtBills(userId, ramseyMode);
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0);
if (activeDebts.length === 0) {
throw ValidationError(
'No debts with a balance found. Add a balance to at least one bill.',
undefined,
'NO_DEBTS',
);
}
const debtSnaps = debtsForMath.map((b, i) => {
const proj = snowball.debts?.find((d) => d.id === b.id);
return {
bill_id: b.id,
name: b.name,
starting_balance: b.current_balance ?? 0,
minimum_payment: b.minimum_payment ?? 0,
interest_rate: b.interest_rate ?? 0,
projected_payoff_month: proj?.payoff_month ?? null,
projected_payoff_date: proj?.payoff_date ?? null,
projected_total_interest: proj?.total_interest ?? null,
order: i,
};
});
// Money fields on `debts` are stored as integer cents; the snowball/APR
// math and plan_snapshot are dollar-denominated, so convert before computing.
const debtsForMath = debts.map((b) => ({
...b,
current_balance: fromCents(b.current_balance),
minimum_payment: fromCents(b.minimum_payment),
}));
const planSnapshot = JSON.stringify({
projected_payoff_date: snowball.payoff_date ?? null,
projected_months: snowball.months_to_freedom ?? null,
projected_total_interest: snowball.total_interest_paid ?? null,
minimum_only_months: minOnly.months_to_freedom ?? null,
interest_saved: interestSaved,
debts: debtSnaps,
});
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
const extraCents = user?.snowball_extra_payment ?? 0;
const extra = fromCents(extraCents);
const now = new Date();
// Abandon any existing active/paused plan first
db.prepare(
`
const snowball =
planMethod === 'avalanche'
? calculateAvalanche(debtsForMath, extra, now)
: calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debtsForMath, now);
const interestSaved = Math.max(
0,
Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) /
100,
);
const debtSnaps = debtsForMath.map((b, i) => {
const proj = snowball.debts?.find((d) => d.id === b.id);
return {
bill_id: b.id,
name: b.name,
starting_balance: b.current_balance ?? 0,
minimum_payment: b.minimum_payment ?? 0,
interest_rate: b.interest_rate ?? 0,
projected_payoff_month: proj?.payoff_month ?? null,
projected_payoff_date: proj?.payoff_date ?? null,
projected_total_interest: proj?.total_interest ?? null,
order: i,
};
});
const planSnapshot = JSON.stringify({
projected_payoff_date: snowball.payoff_date ?? null,
projected_months: snowball.months_to_freedom ?? null,
projected_total_interest: snowball.total_interest_paid ?? null,
minimum_only_months: minOnly.months_to_freedom ?? null,
interest_saved: interestSaved,
debts: debtSnaps,
});
// Abandon any existing active/paused plan first
db.prepare(
`
UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now')
WHERE user_id = ? AND status IN ('active', 'paused')
`,
).run(userId);
).run(userId);
const result = db
.prepare(
`
const result = db
.prepare(
`
INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))
`,
)
.run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
)
.run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ?')
.get(result.lastInsertRowid);
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'));
}
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(enrichPlanWithProgress(db, plan));
});
// GET /api/snowball/plans — list all plans for user
router.get('/plans', (req: Req, res: Res) => {
try {
const db = getDb();
const plans = db
.prepare(
`
const db = getDb();
const plans = db
.prepare(
`
SELECT * FROM snowball_plans WHERE user_id = ? ORDER BY created_at DESC
`,
)
.all(req.user.id);
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'));
}
)
.all(req.user.id);
res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) });
});
// GET /api/snowball/plans/active — return the active or paused plan (or null)
router.get('/plans/active', (req: Req, res: Res) => {
try {
const db = getDb();
const plan = db
.prepare(
`
const db = getDb();
const plan = db
.prepare(
`
SELECT * FROM snowball_plans
WHERE user_id = ? AND status IN ('active', 'paused')
ORDER BY created_at DESC LIMIT 1
`,
)
.get(req.user.id);
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'));
}
)
.get(req.user.id);
res.json(plan ? enrichPlanWithProgress(db, plan) : null);
});
// PATCH /api/snowball/plans/:id — update name or notes
router.patch('/plans/:id', (req: Req, res: Res) => {
try {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0)
return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id'));
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND'));
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid plan id', 'id');
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) throw NotFoundError('Plan not found');
const { name, notes } = req.body;
const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name;
const newNotes = notes !== undefined ? notes || null : plan.notes;
const { name, notes } = req.body;
const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name;
const newNotes = notes !== undefined ? notes || null : plan.notes;
db.prepare(
`
db.prepare(
`
UPDATE snowball_plans SET name = ?, notes = ?, updated_at = datetime('now') WHERE id = ?
`,
).run(newName, newNotes, id);
).run(newName, newNotes, id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
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'));
}
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
});
// Shared status-transition handler for pause / resume / complete / abandon.
// `setSql` is a fixed constant per route (never user input).
function transitionPlan(req, res, { allowedFrom, setSql, action, past }) {
try {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id'));
}
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND'));
if (!allowedFrom.includes(plan.status)) {
return res
.status(400)
.json(
standardizeError(
`Only ${allowedFrom.join(' or ')} plans can be ${past}`,
'INVALID_PLAN_STATE',
),
);
}
db.prepare(
`UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`,
).run(id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
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'));
function transitionPlan(req, res, { allowedFrom, setSql, past }) {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
throw ValidationError('Invalid plan id', 'id');
}
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) throw NotFoundError('Plan not found');
if (!allowedFrom.includes(plan.status)) {
throw ValidationError(
`Only ${allowedFrom.join(' or ')} plans can be ${past}`,
undefined,
'INVALID_PLAN_STATE',
);
}
db.prepare(`UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`).run(
id,
);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
}
// 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');
const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router();
const { getDb } = require('../db/database.cts');
const { ValidationError } = require('../utils/apiError.cts');
const {
getSpendingSummary,
getSpendingTransactions,
@ -18,31 +18,27 @@ const {
getIncomeTransactions,
} = 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) {
const now = new Date();
const year = parseInt(source.year || now.getFullYear(), 10);
const month = parseInt(source.month || now.getMonth() + 1, 10);
if (isNaN(year) || year < 2000 || year > 2100) return { error: 'Invalid year' };
if (isNaN(month) || month < 1 || month > 12) return { error: 'Invalid month' };
if (isNaN(year) || year < 2000 || year > 2100) throw ValidationError('Invalid year', 'year');
if (isNaN(month) || month < 1 || month > 12) throw ValidationError('Invalid month', 'month');
return { year, month };
}
// GET /api/spending/summary?year=&month=
router.get('/summary', (req: Req, res: Res) => {
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));
} catch (err) {
log.error('[spending/summary]', err.message);
res.status(500).json({ error: 'Failed to load spending summary' });
}
res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month));
});
// GET /api/spending/transactions?year=&month=&category_id=&page=&limit=
router.get('/transactions', (req: Req, res: Res) => {
const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
const { category_id, page, limit } = req.query;
const categoryId =
@ -52,56 +48,38 @@ router.get('/transactions', (req: Req, res: Res) => {
? parseInt(category_id, 10)
: undefined;
try {
res.json(
getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, {
categoryId,
uncategorizedOnly: category_id === 'null',
page: parseInt(page || '1', 10),
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' });
}
res.json(
getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, {
categoryId,
uncategorizedOnly: category_id === 'null',
page: parseInt(page || '1', 10),
limit: Math.min(parseInt(limit || '50', 10), 200),
}),
);
});
// PATCH /api/spending/transactions/:id/category
router.patch('/transactions/:id/category', (req: Req, res: Res) => {
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 categoryId =
category_id === null || category_id === undefined ? null : parseInt(category_id, 10);
try {
categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule);
res.json({ ok: true });
} catch (err) {
res
.status(err.status || 500)
.json({ error: err.message || 'Failed to categorize transaction' });
}
categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule);
res.json({ ok: true });
});
// GET /api/spending/budgets?year=&month=
router.get('/budgets', (req: Req, res: Res) => {
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) });
} catch (err) {
log.error('[spending/budgets GET]', err.message);
res.status(500).json({ error: 'Failed to load budgets' });
}
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) });
});
// POST /api/spending/budgets/copy — copy all budgets from previous month into target month
router.post('/budgets/copy', (req: Req, res: Res) => {
const ym = parseYM(req.body || {});
if (ym.error) return res.status(400).json({ error: ym.error });
// Previous month
let prevYear = ym.year,
@ -111,25 +89,24 @@ router.post('/budgets/copy', (req: Req, res: Res) => {
prevYear--;
}
try {
const db = getDb();
const prev = db
.prepare(
`
const db = getDb();
const prev = db
.prepare(
`
SELECT category_id, amount FROM spending_budgets
WHERE user_id = ? AND year = ? AND month = ?
`,
)
.all(req.user.id, prevYear, prevMonth);
)
.all(req.user.id, prevYear, prevMonth);
if (prev.length === 0) {
return res.json({
copied: 0,
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
});
}
if (prev.length === 0) {
return res.json({
copied: 0,
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
});
}
const upsert = db.prepare(`
const upsert = db.prepare(`
INSERT INTO spending_budgets (user_id, category_id, year, month, amount, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
@ -137,95 +114,66 @@ router.post('/budgets/copy', (req: Req, res: Res) => {
updated_at = datetime('now')
`);
db.transaction(() => {
for (const row of prev)
upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
})();
db.transaction(() => {
for (const row of prev) upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
})();
res.json({
copied: prev.length,
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' });
}
res.json({
copied: prev.length,
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
});
});
// PUT /api/spending/budgets — { category_id, year, month, amount }
router.put('/budgets', (req: Req, res: Res) => {
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 });
if (ym.error) return res.status(400).json({ error: ym.error });
try {
setSpendingBudget(
getDb(),
req.user.id,
parseInt(category_id, 10),
ym.year,
ym.month,
amount ?? null,
);
res.json({ ok: true });
} catch (err) {
log.error('[spending/budgets PUT]', err.message);
res.status(500).json({ error: 'Failed to save budget' });
}
setSpendingBudget(
getDb(),
req.user.id,
parseInt(category_id, 10),
ym.year,
ym.month,
amount ?? null,
);
res.json({ ok: true });
});
// GET /api/spending/category-rules
router.get('/category-rules', (req: Req, res: Res) => {
try {
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' });
}
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
});
// POST /api/spending/category-rules — { category_id, merchant }
router.post('/category-rules', (req: Req, res: Res) => {
const { category_id, merchant } = req.body || {};
if (!category_id || !merchant)
return res.status(400).json({ error: 'category_id and merchant required' });
try {
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
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' });
}
throw ValidationError(
'category_id and merchant required',
!category_id ? 'category_id' : 'merchant',
);
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
res.json({ ok: true });
});
// DELETE /api/spending/category-rules/:id
router.delete('/category-rules/:id', (req: Req, res: Res) => {
try {
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
res.json({ ok: true });
} catch (err) {
log.error('[spending/category-rules DELETE]', err.message);
res.status(500).json({ error: 'Failed to delete rule' });
}
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
res.json({ ok: true });
});
// GET /api/spending/income?year=&month=&page=
router.get('/income', (req: Req, res: Res) => {
const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
try {
res.json(
getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
page: parseInt(req.query.page || '1', 10),
limit: Math.min(parseInt(req.query.limit || '50', 10), 200),
includeIgnored: req.query.include_ignored === 'true',
}),
);
} catch (err) {
log.error('[spending/income]', err.message);
res.status(500).json({ error: 'Failed to load income transactions' });
}
res.json(
getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
page: parseInt(req.query.page || '1', 10),
limit: Math.min(parseInt(req.query.limit || '50', 10), 200),
includeIgnored: req.query.include_ignored === 'true',
}),
);
});
module.exports = router;

View File

@ -3,7 +3,12 @@ import type { Req, Res, Next } from '../types/http';
const express = require('express');
const router = express.Router();
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 {
createSubscriptionFromRecommendation,
@ -36,45 +41,26 @@ router.get('/recommendations', (req: Req, res: Res) => {
});
router.get('/transaction-matches', (req: Req, res: Res) => {
try {
res.json({
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',
),
);
}
res.json({
transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query),
});
});
router.post('/recommendations/decline', (req: Req, res: Res) => {
const { decline_key } = req.body || {};
if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) {
return res
.status(400)
.json(standardizeError('decline_key is required', 'VALIDATION_ERROR', 'decline_key'));
}
try {
const db = getDb();
declineRecommendation(db, req.user.id, decline_key);
recordSubscriptionFeedback(db, req.user.id, {
action: 'decline',
catalog_id: req.body?.catalog_id,
merchant: req.body?.merchant,
confidence: req.body?.confidence,
metadata: { decline_key },
});
res.json({ ok: true });
} catch (err) {
res
.status(500)
.json(standardizeError(err.message || 'Failed to decline recommendation', 'DECLINE_ERROR'));
throw ValidationError('decline_key is required', 'decline_key');
}
const db = getDb();
declineRecommendation(db, req.user.id, decline_key);
recordSubscriptionFeedback(db, req.user.id, {
action: 'decline',
catalog_id: req.body?.catalog_id,
merchant: req.body?.merchant,
confidence: req.body?.confidence,
metadata: { decline_key },
});
res.json({ ok: true });
});
// POST /api/subscriptions/recommendations/match-bill
@ -88,20 +74,10 @@ router.post('/recommendations/match-bill', (req: Req, res: Res) => {
.slice(0, 50);
if (!Number.isInteger(billId) || billId < 1) {
return res
.status(400)
.json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id'));
throw ValidationError('bill_id is required', 'bill_id');
}
if (txIds.length === 0) {
return res
.status(400)
.json(
standardizeError(
'transaction_ids must be a non-empty array',
'VALIDATION_ERROR',
'transaction_ids',
),
);
throw ValidationError('transaction_ids must be a non-empty array', 'transaction_ids');
}
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',
)
.get(billId, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
const catalogId = parseInt(req.body?.catalog_id, 10);
const catalogEntry =
Number.isInteger(catalogId) && catalogId > 0
@ -187,15 +162,7 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
.get(categoryId, req.user.id)
: null;
if (!category) {
return res
.status(400)
.json(
standardizeError(
'category_id is invalid for this user',
'VALIDATION_ERROR',
'category_id',
),
);
throw ValidationError('category_id is invalid for this user', 'category_id');
}
}
try {
@ -212,15 +179,14 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
});
res.status(201).json(created);
} catch (err) {
res
.status(err.status || 400)
.json(
standardizeError(
err.message || 'Could not create subscription',
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
err.field || null,
),
);
// Service validation errors are user-facing at 4xx (parity with the old
// behavior: no-status service throws surfaced at 400, never as masked 5xx).
throw new ApiError(
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
err.message || 'Could not create subscription',
err.status || 400,
{ field: err.field || null },
);
}
});
@ -228,24 +194,23 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
router.get('/catalog', (req: Req, res: Res) => {
const db = getDb();
try {
const catalogEntries = db
.prepare(
`
const catalogEntries = db
.prepare(
`
SELECT id, rank, name, category, subcategory, subscription_type,
website, starting_monthly_usd, starting_annual_usd
FROM subscription_catalog
ORDER BY rank ASC
`,
)
.all();
)
.all();
if (!catalogEntries.length) return res.json({ catalog: [] });
if (!catalogEntries.length) return res.json({ catalog: [] });
// User's subscription bills that are linked to a catalog entry
const matchedBills = db
.prepare(
`
// User's subscription bills that are linked to a catalog entry
const matchedBills = db
.prepare(
`
SELECT b.id, b.name, b.expected_amount, b.active, b.catalog_id,
b.cycle_type, b.billing_cycle
FROM bills b
@ -254,56 +219,49 @@ router.get('/catalog', (req: Req, res: Res) => {
AND b.deleted_at IS NULL
AND b.catalog_id IS NOT NULL
`,
)
)
.all(req.user.id);
const billByCatalogId = new Map(matchedBills.map((b) => [b.catalog_id, b]));
// User's custom descriptors
let userDescriptors = [];
try {
userDescriptors = db
.prepare('SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?')
.all(req.user.id);
const billByCatalogId = new Map(matchedBills.map((b) => [b.catalog_id, b]));
// User's custom descriptors
let userDescriptors = [];
try {
userDescriptors = db
.prepare(
'SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?',
)
.all(req.user.id);
} catch {
/* pre-v0.96 */
}
const userDescsByCatalogId = new Map();
for (const d of userDescriptors) {
if (!userDescsByCatalogId.has(d.catalog_id)) userDescsByCatalogId.set(d.catalog_id, []);
userDescsByCatalogId.get(d.catalog_id).push({ id: d.id, descriptor: d.descriptor });
}
const catalog = catalogEntries.map((entry) => {
const bill = billByCatalogId.get(entry.id) ?? null;
return {
...entry,
matched_bill: bill
? {
id: bill.id,
name: bill.name,
expected_amount: fromCents(bill.expected_amount),
active: !!bill.active,
monthly_equivalent: monthlyEquivalent(
fromCents(bill.expected_amount),
bill.cycle_type,
bill.billing_cycle,
),
}
: null,
user_descriptors: userDescsByCatalogId.get(entry.id) ?? [],
};
});
res.json({ catalog });
} catch (err) {
res
.status(500)
.json(standardizeError(err.message || 'Failed to load catalog', 'CATALOG_ERROR'));
} catch {
/* pre-v0.96 */
}
const userDescsByCatalogId = new Map();
for (const d of userDescriptors) {
if (!userDescsByCatalogId.has(d.catalog_id)) userDescsByCatalogId.set(d.catalog_id, []);
userDescsByCatalogId.get(d.catalog_id).push({ id: d.id, descriptor: d.descriptor });
}
const catalog = catalogEntries.map((entry) => {
const bill = billByCatalogId.get(entry.id) ?? null;
return {
...entry,
matched_bill: bill
? {
id: bill.id,
name: bill.name,
expected_amount: fromCents(bill.expected_amount),
active: !!bill.active,
monthly_equivalent: monthlyEquivalent(
fromCents(bill.expected_amount),
bill.cycle_type,
bill.billing_cycle,
),
}
: null,
user_descriptors: userDescsByCatalogId.get(entry.id) ?? [],
};
});
res.json({ catalog });
});
// 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 billId = parseInt(req.params.id, 10);
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
@ -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',
)
.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;
@ -338,23 +296,12 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
const catalogId = parseInt(rawCatalogId, 10);
if (!Number.isInteger(catalogId) || catalogId < 1) {
return res
.status(400)
.json(
standardizeError(
'catalog_id must be a positive integer or null',
'VALIDATION_ERROR',
'catalog_id',
),
);
throw ValidationError('catalog_id must be a positive integer or null', 'catalog_id');
}
const catalogEntry = db
.prepare('SELECT id FROM subscription_catalog WHERE id = ?')
.get(catalogId);
if (!catalogEntry)
return res
.status(404)
.json(standardizeError('Catalog entry not found', 'NOT_FOUND', 'catalog_id'));
if (!catalogEntry) throw NotFoundError('Catalog entry not found', 'catalog_id');
db.prepare(
"UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
@ -374,70 +321,49 @@ router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => {
const db = getDb();
const catalogId = parseInt(req.params.catalogId, 10);
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
.prepare('SELECT id FROM subscription_catalog WHERE id = ?')
.get(catalogId);
if (!catalogEntry)
return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND'));
if (!catalogEntry) throw NotFoundError('Catalog entry not found');
const descriptor = String(req.body?.descriptor ?? '').trim();
if (!descriptor) {
return res
.status(400)
.json(standardizeError('descriptor is required', 'VALIDATION_ERROR', 'descriptor'));
throw ValidationError('descriptor is required', 'descriptor');
}
if (descriptor.length > 100) {
return res
.status(400)
.json(
standardizeError(
'descriptor must be 100 characters or less',
'VALIDATION_ERROR',
'descriptor',
),
);
throw ValidationError('descriptor must be 100 characters or less', 'descriptor');
}
try {
// Check for case-insensitive duplicate
const exists = db
.prepare(
'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)',
)
.get(req.user.id, catalogId, descriptor);
if (exists) {
return res
.status(409)
.json(
standardizeError(
'Descriptor already exists for this service',
'DUPLICATE_ERROR',
'descriptor',
),
);
}
const result = db
.prepare(
'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)',
)
.run(req.user.id, catalogId, descriptor);
recordSubscriptionFeedback(db, req.user.id, {
action: 'add_descriptor',
catalog_id: catalogId,
merchant: descriptor,
descriptor,
});
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'));
// Check for case-insensitive duplicate
const exists = db
.prepare(
'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)',
)
.get(req.user.id, catalogId, descriptor);
if (exists) {
throw ConflictError(
'Descriptor already exists for this service',
'descriptor',
'DUPLICATE_ERROR',
);
}
const result = db
.prepare(
'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)',
)
.run(req.user.id, catalogId, descriptor);
recordSubscriptionFeedback(db, req.user.id, {
action: 'add_descriptor',
catalog_id: catalogId,
merchant: descriptor,
descriptor,
});
res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId });
});
// Delete a user-added catalog descriptor
@ -445,53 +371,42 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
const db = getDb();
const descriptorId = parseInt(req.params.id, 10);
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
.prepare(
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?',
)
.get(descriptorId, req.user.id);
const result = db
.prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?')
.run(descriptorId, req.user.id);
if (result.changes === 0) {
return res.status(404).json(standardizeError('Descriptor not found', 'NOT_FOUND'));
}
if (existing) {
recordSubscriptionFeedback(db, req.user.id, {
action: 'delete_descriptor',
catalog_id: existing.catalog_id,
merchant: existing.descriptor,
descriptor: existing.descriptor,
});
}
res.json({ ok: true });
} catch (err) {
res
.status(500)
.json(
standardizeError(err.message || 'Failed to delete descriptor', 'DESCRIPTOR_DELETE_ERROR'),
);
const existing = db
.prepare(
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?',
)
.get(descriptorId, req.user.id);
const result = db
.prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?')
.run(descriptorId, req.user.id);
if (result.changes === 0) {
throw NotFoundError('Descriptor not found');
}
if (existing) {
recordSubscriptionFeedback(db, req.user.id, {
action: 'delete_descriptor',
catalog_id: existing.catalog_id,
merchant: existing.descriptor,
descriptor: existing.descriptor,
});
}
res.json({ ok: true });
});
router.patch('/:id', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) {
return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
throw ValidationError('bill_id must be an integer', 'bill_id');
}
const existing = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id);
if (!existing)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!existing) throw NotFoundError('Bill not found', 'bill_id');
const allowedTypes = new Set([
'streaming',
@ -530,15 +445,7 @@ router.patch('/:id', (req: Req, res: Res) => {
next.reminder_days_before < 0 ||
next.reminder_days_before > 30
) {
return res
.status(400)
.json(
standardizeError(
'reminder_days_before must be between 0 and 30',
'VALIDATION_ERROR',
'reminder_days_before',
),
);
throw ValidationError('reminder_days_before must be between 0 and 30', 'reminder_days_before');
}
db.prepare(

View File

@ -8,6 +8,7 @@ const { getCycleRange, resolveDueDate } = require('../services/statusService.cts
const { getUserSettings } = require('../services/userSettings.cts');
const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { toCents, fromCents } = require('../utils/money.mts');
const { ValidationError } = require('../utils/apiError.cts');
const DEFAULT_INCOME_LABEL = 'Salary';
const DEFAULT_PENDING_DAYS = 3;
@ -127,10 +128,10 @@ function parseYearMonth(source) {
const month = parseInt(source.month || now.getMonth() + 1, 10);
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) {
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 };
@ -447,7 +448,6 @@ function buildSummary(db, userId, year, month) {
router.get('/', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.query);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const db = getDb();
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) => {
const parsed = parseYearMonth(req.body || {});
if (parsed.error) return res.status(400).json({ error: parsed.error });
const amount = Number(req.body?.amount);
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 =

View File

@ -4,6 +4,16 @@ const router = require('express').Router();
const { log } = require('../utils/logger.cts');
const { getDb } = require('../db/database.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 {
decorateTransaction,
ensureManualDataSource,
@ -356,14 +366,15 @@ function rejectDirectMatchState(body = {}) {
);
}
function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') {
if (err.status) {
return res
.status(err.status)
.json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field));
// 4xx service errors keep their message/code on the wire; anything else is
// rethrown raw so the terminal handler masks + logs it as a 5xx.
function toTransactionServiceError(err) {
if (err.status && err.status < 500) {
return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, {
field: err.field,
});
}
log.error('[transactions] service error:', err.stack || err.message);
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
return err;
}
function emptyBankLedger(enabled, hasConnections = false) {
@ -460,7 +471,7 @@ router.get('/', (req: Req, res: Res) => {
ensureManualDataSource(db, req.user.id);
const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error);
if (page.error) throwStandardized(page.error);
const SORT_COLUMNS = {
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']) {
if (req.query[field] !== undefined) {
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} = ?`);
params.push(parsed.value);
}
@ -542,13 +553,13 @@ router.get('/', (req: Req, res: Res) => {
if (req.query.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)) >= ?');
params.push(parsed.value);
}
if (req.query.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)) <= ?');
params.push(parsed.value);
}
@ -607,7 +618,7 @@ router.get('/', (req: Req, res: Res) => {
router.get('/bank-ledger', (req: Req, res: Res) => {
const config = getBankSyncConfig();
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 sources = db
@ -653,7 +664,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
.all(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 = {
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) => {
const db = getDb();
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);
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 source = ensureManualDataSource(db, req.user.id);
const state = resolveTransactionState(tx);
if (state.error) return res.status(400).json(state.error);
if (state.error) throwStandardized(state.error);
Object.assign(tx, state);
const result = db
@ -803,20 +814,19 @@ router.post('/manual', (req: Req, res: Res) => {
router.put('/:id', (req: Req, res: Res) => {
const db = getDb();
const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState);
if (directMatchState) throwStandardized(directMatchState);
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);
if (!existing)
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
if (!existing) throw NotFoundError('Transaction not found', 'id');
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 state = resolveTransactionState(tx, existing);
if (state.error) return res.status(400).json(state.error);
if (state.error) throwStandardized(state.error);
Object.assign(tx, state);
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) => {
const db = getDb();
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);
if (!existing)
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
if (!existing) throw NotFoundError('Transaction not found', 'id');
db.transaction(() => {
unmatchTransaction(req.user.id, id.value);
@ -905,7 +914,7 @@ router.post('/:id/match', (req: Req, res: Res) => {
);
res.json(result);
} 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);
res.json(result);
} 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) => {
const matches = req.body?.matches;
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) {
return res
@ -996,13 +1005,19 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
results.push({ transaction_id: txId, ok: true });
}
} 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);
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 });
}
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);
res.json(result.transaction);
} 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);
res.json(result.transaction);
} 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 {
const db = getDb();
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);
if (!existing)
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
if (!existing) throw NotFoundError('Transaction not found', 'id');
const match = findMerchantMatch(
db,
@ -1045,7 +1059,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
);
res.json({ match });
} 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 {
const db = getDb();
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);
if (!existing)
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
if (!existing) throw NotFoundError('Transaction not found', 'id');
const match = findMerchantMatch(
db,
@ -1075,7 +1088,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
display_name: match.display_name,
});
} 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);
} 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');
const history = fs.readFileSync(HISTORY_PATH, 'utf8');
let updatedAt = null;
let updatedAt;
try {
updatedAt = fs.statSync(HISTORY_PATH).mtime.toISOString();
} catch {

View File

@ -273,7 +273,8 @@ const { getCsrfToken } = require('./middleware/csrf.cts');
app.get('/{*splat}', (req, res) => {
// Set CSRF cookie if not present (needed for SPA to read token)
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 ──────────────────────────────────────────────────────

View File

@ -89,9 +89,17 @@ async function login(username: any, password: any) {
createAuthenticationChallenge,
createLoginChallenge,
} = require('./webauthnService.cts');
const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id);
const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options };
try {
const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id);
const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
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

View File

@ -50,7 +50,7 @@ async function claimSetupToken(setupToken: unknown): Promise<string> {
const token = setupToken.trim();
// Base64-decode to get the claim URL; handle both raw base64 and data-URLs
let claimUrl = token;
let claimUrl;
try {
const decoded = Buffer.from(token, 'base64').toString('utf8').trim();
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');
}
let accessUrl = '';
let accessUrl;
try {
const res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(30000) });
if (res.status === 403) {

View File

@ -18,11 +18,24 @@ function getRpId(): string {
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;
if (o) return o;
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 ──────────────────────────────────────────────────────────────
@ -80,6 +93,7 @@ async function verifyRegistration(
challengeId: string,
response: any,
credentialName: any,
requestOrigin?: string,
) {
const row = db
.prepare(
@ -92,7 +106,7 @@ async function verifyRegistration(
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: row.challenge,
expectedOrigin: getOrigin(),
expectedOrigin: getOrigin(requestOrigin),
expectedRPID: getRpId(),
});
@ -162,7 +176,13 @@ async function createAuthenticationChallenge(db: Db, userId: number) {
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
.prepare(
"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({
response,
expectedChallenge: row.challenge,
expectedOrigin: getOrigin(),
expectedOrigin: getOrigin(requestOrigin),
expectedRPID: getRpId(),
credential: {
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;
const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const { getTracker } = require('../services/trackerService.cts');
function createUser(db, suffix) {
@ -60,7 +61,13 @@ function callBillsRoute(routePath, method, { userId, params = {}, query = {}, bo
try {
handler(req, res);
} catch (err) {
reject(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);
}
}
});
}

View File

@ -15,6 +15,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-payments-${process.pid}.sqli
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const router = require('../routes/payments.cts');
function handler(method, routePath) {
@ -36,7 +37,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
resolve({ status: this.statusCode, data: d });
},
};
h(req, res);
// 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);
} catch (err) {
resolve({ status: err.status || 500, data: formatError(err) });
}
});
}
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;
const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const router = require('../routes/snowball.cts');
function handler(method, routePath) {
@ -34,7 +35,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
resolve({ status: this.statusCode, data: d });
},
};
h(req, res);
// 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);
} 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;
const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const { ensureManualDataSource } = require('../services/transactionService.cts');
const { getTracker } = require('../services/trackerService.cts');
const {
@ -131,7 +132,13 @@ function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
try {
handler(req, res);
} catch (err) {
reject(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);
}
}
});
}
@ -164,7 +171,13 @@ function callPaymentsRoute(routePath, method, { userId, params = {}, query = {},
try {
handler(req, res);
} catch (err) {
reject(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);
}
}
});
}

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.',
);
}
// 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 };

View File

@ -103,18 +103,15 @@ export default defineConfig({
output: {
// 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.
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-radix': [
'@radix-ui/react-dialog',
'@radix-ui/react-select',
'@radix-ui/react-dropdown-menu',
'@radix-ui/react-tabs',
'@radix-ui/react-tooltip',
'@radix-ui/react-alert-dialog',
],
'vendor-motion': ['framer-motion'],
'vendor-query': ['@tanstack/react-query', '@tanstack/react-virtual'],
// Function form: vite 8 (rolldown core) dropped the object form.
manualChunks(id) {
if (!id.includes('node_modules')) return undefined;
if (/node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/.test(id))
return 'vendor-react';
if (/node_modules[\\/]@radix-ui[\\/]/.test(id)) return 'vendor-radix';
if (/node_modules[\\/]framer-motion[\\/]/.test(id)) return 'vendor-motion';
if (/node_modules[\\/]@tanstack[\\/]/.test(id)) return 'vendor-query';
return undefined;
},
},
},