@openparachute/hub 0.5.14-rc.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-revoke-token.test.ts +384 -0
  9. package/src/__tests__/api-users.test.ts +7 -2
  10. package/src/__tests__/auth.test.ts +157 -30
  11. package/src/__tests__/cli.test.ts +44 -5
  12. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  13. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  14. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  15. package/src/__tests__/expose.test.ts +52 -2
  16. package/src/__tests__/hub-server.test.ts +97 -0
  17. package/src/__tests__/hub.test.ts +85 -6
  18. package/src/__tests__/init.test.ts +102 -1
  19. package/src/__tests__/lifecycle.test.ts +464 -2
  20. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  21. package/src/__tests__/oauth-ui.test.ts +12 -1
  22. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  23. package/src/__tests__/resource-binding.test.ts +97 -0
  24. package/src/__tests__/scope-explanations.test.ts +77 -12
  25. package/src/__tests__/services-manifest.test.ts +122 -4
  26. package/src/__tests__/setup-wizard.test.ts +335 -15
  27. package/src/__tests__/status.test.ts +36 -0
  28. package/src/__tests__/two-factor-flow.test.ts +602 -0
  29. package/src/__tests__/two-factor.test.ts +183 -0
  30. package/src/__tests__/upgrade.test.ts +78 -1
  31. package/src/__tests__/users.test.ts +68 -0
  32. package/src/__tests__/vault-auth-status.test.ts +47 -6
  33. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  34. package/src/account-home-ui.ts +488 -38
  35. package/src/account-vault-token.ts +282 -0
  36. package/src/admin-handlers.ts +159 -4
  37. package/src/admin-login-ui.ts +49 -5
  38. package/src/admin-vaults.ts +48 -15
  39. package/src/api-account.ts +14 -0
  40. package/src/api-mint-token.ts +132 -24
  41. package/src/api-modules-ops.ts +49 -11
  42. package/src/api-revoke-token.ts +107 -21
  43. package/src/api-users.ts +29 -3
  44. package/src/cli.ts +26 -21
  45. package/src/clients.ts +18 -6
  46. package/src/cloudflare/config.ts +10 -4
  47. package/src/cloudflare/detect.ts +39 -44
  48. package/src/commands/auth.ts +165 -24
  49. package/src/commands/expose-2fa-warning.ts +34 -32
  50. package/src/commands/expose-auth-preflight.ts +89 -78
  51. package/src/commands/expose-cloudflare.ts +370 -12
  52. package/src/commands/expose.ts +8 -0
  53. package/src/commands/init.ts +33 -2
  54. package/src/commands/lifecycle.ts +386 -17
  55. package/src/commands/status.ts +22 -0
  56. package/src/commands/upgrade.ts +55 -11
  57. package/src/commands/wizard.ts +8 -4
  58. package/src/env-file.ts +10 -0
  59. package/src/help.ts +3 -1
  60. package/src/hub-db.ts +39 -1
  61. package/src/hub-server.ts +52 -0
  62. package/src/hub.ts +82 -14
  63. package/src/oauth-handlers.ts +298 -21
  64. package/src/oauth-ui.ts +10 -0
  65. package/src/operator-token.ts +151 -0
  66. package/src/pending-login.ts +116 -0
  67. package/src/rate-limit.ts +51 -0
  68. package/src/resource-binding.ts +134 -0
  69. package/src/scope-attenuation.ts +85 -0
  70. package/src/scope-explanations.ts +131 -14
  71. package/src/services-manifest.ts +112 -0
  72. package/src/setup-wizard.ts +77 -7
  73. package/src/tailscale/run.ts +28 -11
  74. package/src/totp.ts +201 -0
  75. package/src/two-factor-handlers.ts +287 -0
  76. package/src/two-factor-store.ts +181 -0
  77. package/src/two-factor-ui.ts +462 -0
  78. package/src/users.ts +58 -0
  79. package/src/vault/auth-status.ts +71 -19
  80. package/src/vault-hub-origin-env.ts +163 -0
  81. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  82. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  83. package/web/ui/dist/index.html +2 -2
  84. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  85. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  86. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  87. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,287 @@
1
+ /**
2
+ * `/account/2fa` — user self-service TOTP 2FA enroll / disenroll (hub#473).
3
+ *
4
+ * GET /account/2fa — render current state (enrolled → status+disenroll;
5
+ * not enrolled → "set up" CTA).
6
+ * POST /account/2fa — dispatch on the `action` field:
7
+ * start → generate a secret + render QR/confirm
8
+ * confirm → verify code vs the in-flight secret,
9
+ * persist enrollment, show backup codes
10
+ * disable → verify current password, clear 2FA
11
+ *
12
+ * Auth posture: every endpoint requires an active session (the signed-in user
13
+ * acts on their OWN account). Same session-or-302-to-/login shape as
14
+ * `/account/change-password`.
15
+ *
16
+ * Enrollment is browser-first (right for headless servers): the secret is
17
+ * generated server-side, shown as a QR + manual base32 key, and only PERSISTED
18
+ * after the user confirms a live code (proving the authenticator was set up).
19
+ * The in-flight secret rides a hidden form field between `start` and `confirm`
20
+ * — it isn't stored until confirmation, so an abandoned setup leaves no state.
21
+ *
22
+ * No JSON layer — server-rendered HTML forms, same as the rest of `/account/*`,
23
+ * so 2FA setup works without JS.
24
+ */
25
+ import type { Database } from "bun:sqlite";
26
+ import QRCode from "qrcode";
27
+ import { renderAdminError } from "./admin-login-ui.ts";
28
+ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
29
+ import { isHttpsRequest } from "./request-protocol.ts";
30
+ import { findActiveSession } from "./sessions.ts";
31
+ import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
32
+ import {
33
+ backupCodesRemaining,
34
+ clearEnrollment,
35
+ getTotpState,
36
+ isTotpEnrolled,
37
+ persistEnrollment,
38
+ } from "./two-factor-store.ts";
39
+ import {
40
+ renderTwoFactorBackupCodes,
41
+ renderTwoFactorEnrolled,
42
+ renderTwoFactorEnrolling,
43
+ renderTwoFactorNotEnrolled,
44
+ } from "./two-factor-ui.ts";
45
+ import { PASSWORD_MAX_LEN, type User, getUserById, verifyPassword } from "./users.ts";
46
+
47
+ export interface TwoFactorDeps {
48
+ db: Database;
49
+ /** Test seam — defaults to real clock. */
50
+ now?: () => Date;
51
+ }
52
+
53
+ function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
54
+ return new Response(body, {
55
+ status,
56
+ headers: { "content-type": "text/html; charset=utf-8", ...extra },
57
+ });
58
+ }
59
+
60
+ function redirect(location: string, extra: Record<string, string> = {}): Response {
61
+ return new Response(null, { status: 302, headers: { location, ...extra } });
62
+ }
63
+
64
+ /** Resolve the signed-in user, or a Response to return (302 / 401). */
65
+ function requireUser(deps: TwoFactorDeps, req: Request): { user: User } | { response: Response } {
66
+ const session = findActiveSession(deps.db, req);
67
+ if (!session) {
68
+ return { response: redirect(`/login?next=${encodeURIComponent("/account/2fa")}`) };
69
+ }
70
+ const user = getUserById(deps.db, session.userId);
71
+ if (!user) {
72
+ return { response: redirect("/login") };
73
+ }
74
+ return { user };
75
+ }
76
+
77
+ /** Render the QR SVG for a secret + the enrolling page. */
78
+ async function renderEnrolling(
79
+ csrfToken: string,
80
+ secret: string,
81
+ label: string,
82
+ errorMessage?: string,
83
+ ): Promise<Response> {
84
+ const otpauthUrl = otpauthUrlFor(secret, label);
85
+ // Inline SVG — self-contained, no external fetch (privacy posture).
86
+ const qrSvg = await QRCode.toString(otpauthUrl, {
87
+ type: "svg",
88
+ margin: 0,
89
+ errorCorrectionLevel: "M",
90
+ });
91
+ return htmlResponse(
92
+ renderTwoFactorEnrolling({
93
+ csrfToken,
94
+ qrSvg,
95
+ secret,
96
+ ...(errorMessage ? { errorMessage } : {}),
97
+ }),
98
+ );
99
+ }
100
+
101
+ /**
102
+ * GET /account/2fa — render the current 2FA state for the signed-in user.
103
+ */
104
+ export function handleTwoFactorGet(req: Request, deps: TwoFactorDeps): Response {
105
+ const got = requireUser(deps, req);
106
+ if ("response" in got) return got.response;
107
+ const user = got.user;
108
+
109
+ const csrf = ensureCsrfToken(req);
110
+ const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
111
+
112
+ if (isTotpEnrolled(deps.db, user.id)) {
113
+ const state = getTotpState(deps.db, user.id);
114
+ return htmlResponse(
115
+ renderTwoFactorEnrolled({
116
+ csrfToken: csrf.token,
117
+ enrolledAt: state.enrolledAt,
118
+ backupCodesRemaining: state.backupCodes.length,
119
+ }),
120
+ 200,
121
+ extra,
122
+ );
123
+ }
124
+ // `?disabled=1` — the disenroll POST 302s here so a refresh doesn't re-POST;
125
+ // surface a one-line confirmation on the not-enrolled page.
126
+ const disabled = new URL(req.url).searchParams.get("disabled") === "1";
127
+ return htmlResponse(
128
+ renderTwoFactorNotEnrolled({
129
+ csrfToken: csrf.token,
130
+ ...(disabled ? { notice: "Two-factor authentication has been turned off." } : {}),
131
+ }),
132
+ 200,
133
+ extra,
134
+ );
135
+ }
136
+
137
+ /**
138
+ * POST /account/2fa — dispatch on `action`.
139
+ */
140
+ export async function handleTwoFactorPost(req: Request, deps: TwoFactorDeps): Promise<Response> {
141
+ const got = requireUser(deps, req);
142
+ if ("response" in got) {
143
+ // For a POST without a session, a 302 to /login is fine (browser will
144
+ // follow with the form lost, which is acceptable — re-auth, then retry).
145
+ return got.response;
146
+ }
147
+ const user = got.user;
148
+
149
+ const form = await req.formData();
150
+ const formCsrf = form.get(CSRF_FIELD_NAME);
151
+ const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
152
+ if (!verifyCsrfToken(req, csrfToken || null)) {
153
+ return htmlResponse(
154
+ renderAdminError({
155
+ title: "Invalid form submission",
156
+ message: "The form's CSRF token did not match. Reload the page and try again.",
157
+ }),
158
+ 400,
159
+ );
160
+ }
161
+
162
+ const action = String(form.get("action") ?? "");
163
+
164
+ // --- start: generate a secret, render QR + confirm form ---------------
165
+ if (action === "start") {
166
+ // Refuse to start a fresh enrollment if already enrolled — disenroll
167
+ // first. Prevents accidentally clobbering a working setup.
168
+ if (isTotpEnrolled(deps.db, user.id)) {
169
+ return htmlResponse(
170
+ renderTwoFactorEnrolled({
171
+ csrfToken,
172
+ enrolledAt: getTotpState(deps.db, user.id).enrolledAt,
173
+ backupCodesRemaining: backupCodesRemaining(deps.db, user.id),
174
+ errorMessage: "Two-factor is already enabled. Turn it off first to re-enroll.",
175
+ }),
176
+ 409,
177
+ );
178
+ }
179
+ const { secret } = generateTotpSecret(user.username);
180
+ return renderEnrolling(csrfToken, secret, user.username);
181
+ }
182
+
183
+ // --- confirm: verify code vs the in-flight secret, persist ------------
184
+ if (action === "confirm") {
185
+ const secret = String(form.get("secret") ?? "");
186
+ const code = String(form.get("code") ?? "");
187
+ // Validate the in-flight secret format before it reaches verifyTotpCode /
188
+ // persistEnrollment (N1 — cheap defense in depth). The secret is a
189
+ // server-minted base32 string round-tripped through a hidden form field;
190
+ // it's session-gated + CSRF'd, but a malformed value (truncated paste,
191
+ // hand-crafted POST) should be rejected explicitly rather than stored.
192
+ // base32 alphabet (A–Z, 2–7) + optional `=` padding, ≥16 chars (our secret
193
+ // is 20 bytes → 32 base32 chars; 16 is a conservative floor).
194
+ if (!secret || !/^[A-Z2-7]+=*$/i.test(secret) || secret.length < 16) {
195
+ // Lost / malformed in-flight secret (stale form, bad paste) — restart.
196
+ return htmlResponse(
197
+ renderTwoFactorNotEnrolled({
198
+ csrfToken,
199
+ errorMessage: "Setup expired. Please start again.",
200
+ }),
201
+ 400,
202
+ );
203
+ }
204
+ // Defensive: someone POSTing confirm for an already-enrolled account.
205
+ if (isTotpEnrolled(deps.db, user.id)) {
206
+ return htmlResponse(
207
+ renderTwoFactorEnrolled({
208
+ csrfToken,
209
+ enrolledAt: getTotpState(deps.db, user.id).enrolledAt,
210
+ backupCodesRemaining: backupCodesRemaining(deps.db, user.id),
211
+ errorMessage: "Two-factor is already enabled.",
212
+ }),
213
+ 409,
214
+ );
215
+ }
216
+ if (!verifyTotpCode(secret, code)) {
217
+ return renderEnrolling(
218
+ csrfToken,
219
+ secret,
220
+ user.username,
221
+ "That code didn't match. Make sure your device clock is correct and try the current code.",
222
+ );
223
+ }
224
+ const result = await persistEnrollment(
225
+ deps.db,
226
+ user.id,
227
+ secret,
228
+ deps.now ?? (() => new Date()),
229
+ );
230
+ // Show the backup codes ONCE. Setting no-store so the browser doesn't
231
+ // cache the page with the plaintext codes in it.
232
+ return htmlResponse(renderTwoFactorBackupCodes({ codes: result.backupCodes }), 200, {
233
+ "cache-control": "no-store",
234
+ });
235
+ }
236
+
237
+ // --- disable: verify current password, clear 2FA ----------------------
238
+ if (action === "disable") {
239
+ if (!isTotpEnrolled(deps.db, user.id)) {
240
+ // Already off — render the not-enrolled page (idempotent).
241
+ return htmlResponse(
242
+ renderTwoFactorNotEnrolled({
243
+ csrfToken,
244
+ notice: "Two-factor authentication is already off.",
245
+ }),
246
+ );
247
+ }
248
+ const password = String(form.get("password") ?? "");
249
+ const state = getTotpState(deps.db, user.id);
250
+ const renderEnrolledError = (msg: string, status: number): Response =>
251
+ htmlResponse(
252
+ renderTwoFactorEnrolled({
253
+ csrfToken,
254
+ enrolledAt: state.enrolledAt,
255
+ backupCodesRemaining: state.backupCodes.length,
256
+ errorMessage: msg,
257
+ }),
258
+ status,
259
+ );
260
+ if (!password) {
261
+ return renderEnrolledError("Enter your current password to turn off two-factor.", 400);
262
+ }
263
+ // Cap before argon2id verify (CPU-DoS guard — same posture as /login).
264
+ if (password.length > PASSWORD_MAX_LEN) {
265
+ return renderEnrolledError(`Password must be ≤ ${PASSWORD_MAX_LEN} characters.`, 413);
266
+ }
267
+ const ok = await verifyPassword(user, password);
268
+ if (!ok) {
269
+ return renderEnrolledError("That password is incorrect.", 401);
270
+ }
271
+ clearEnrollment(deps.db, user.id, deps.now ?? (() => new Date()));
272
+ // Redirect to the GET so a refresh doesn't re-POST. The not-enrolled
273
+ // page renders the success notice via a query flag.
274
+ return redirect("/account/2fa?disabled=1", {
275
+ "cache-control": "no-store",
276
+ "x-secure-context": isHttpsRequest(req) ? "https" : "http",
277
+ });
278
+ }
279
+
280
+ return htmlResponse(
281
+ renderAdminError({
282
+ title: "Unknown action",
283
+ message: "That two-factor action isn't recognized. Reload the page and try again.",
284
+ }),
285
+ 400,
286
+ );
287
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Persistence for hub-login TOTP 2FA (hub#473). Reads/writes the three
3
+ * `users` columns added in migration v11: `totp_secret`, `totp_backup_codes`
4
+ * (JSON array of argon2id hashes), `totp_enrolled_at`.
5
+ *
6
+ * The pure crypto lives in `totp.ts`; this is the storage seam. The login +
7
+ * enroll handlers compose both. Backup-code consumption is done here inside a
8
+ * DB transaction so verify-then-remove is atomic against concurrent login
9
+ * POSTs (two requests can't both consume the same code).
10
+ */
11
+ import type { Database } from "bun:sqlite";
12
+ import { findBackupCodeIndex, generateBackupCodes, verifyTotpCode } from "./totp.ts";
13
+
14
+ export interface TotpState {
15
+ /** Base32 secret, or null if 2FA isn't enrolled for this user. */
16
+ secret: string | null;
17
+ /** argon2id hashes of the remaining single-use backup codes. */
18
+ backupCodes: string[];
19
+ /** ISO-8601 enrollment timestamp, or null. */
20
+ enrolledAt: string | null;
21
+ }
22
+
23
+ interface TotpRow {
24
+ totp_secret: string | null;
25
+ totp_backup_codes: string | null;
26
+ totp_enrolled_at: string | null;
27
+ }
28
+
29
+ function parseBackupCodes(raw: string | null): string[] {
30
+ if (!raw) return [];
31
+ try {
32
+ const parsed = JSON.parse(raw) as unknown;
33
+ if (!Array.isArray(parsed)) return [];
34
+ return parsed.filter((c): c is string => typeof c === "string");
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Read a user's TOTP state. Returns `secret: null` (not enrolled) when the
42
+ * row is missing — callers treat "no row" identically to "not enrolled."
43
+ */
44
+ export function getTotpState(db: Database, userId: string): TotpState {
45
+ const row = db
46
+ .query<TotpRow, [string]>(
47
+ "SELECT totp_secret, totp_backup_codes, totp_enrolled_at FROM users WHERE id = ?",
48
+ )
49
+ .get(userId);
50
+ if (!row) return { secret: null, backupCodes: [], enrolledAt: null };
51
+ return {
52
+ secret: row.totp_secret && row.totp_secret.length > 0 ? row.totp_secret : null,
53
+ backupCodes: parseBackupCodes(row.totp_backup_codes),
54
+ enrolledAt: row.totp_enrolled_at,
55
+ };
56
+ }
57
+
58
+ /** Cheap "is 2FA on for this user?" check — true iff a non-empty secret is stored. */
59
+ export function isTotpEnrolled(db: Database, userId: string): boolean {
60
+ return getTotpState(db, userId).secret !== null;
61
+ }
62
+
63
+ /** Number of unused backup codes remaining for a user. */
64
+ export function backupCodesRemaining(db: Database, userId: string): number {
65
+ return getTotpState(db, userId).backupCodes.length;
66
+ }
67
+
68
+ export interface EnrollResult {
69
+ /** Plaintext backup codes — show ONCE; never retrievable after. */
70
+ backupCodes: string[];
71
+ /** Enrollment timestamp persisted on the row. */
72
+ enrolledAt: string;
73
+ }
74
+
75
+ /**
76
+ * Persist a confirmed enrollment: store the (already-verified) secret, mint +
77
+ * store a fresh set of backup-code hashes, stamp `totp_enrolled_at`. Returns
78
+ * the plaintext backup codes for one-time display. Overwrites any prior
79
+ * enrollment (re-enroll rotates the secret + codes).
80
+ *
81
+ * The caller MUST have verified a live code against `secret` before calling
82
+ * this (proves the authenticator was provisioned correctly). The async hash
83
+ * happens before the write (single statement, no transaction needed).
84
+ */
85
+ export async function persistEnrollment(
86
+ db: Database,
87
+ userId: string,
88
+ secret: string,
89
+ now: () => Date = () => new Date(),
90
+ ): Promise<EnrollResult> {
91
+ const { codes, hashes } = await generateBackupCodes();
92
+ const enrolledAt = now().toISOString();
93
+ const result = db
94
+ .prepare(
95
+ `UPDATE users
96
+ SET totp_secret = ?, totp_backup_codes = ?, totp_enrolled_at = ?, updated_at = ?
97
+ WHERE id = ?`,
98
+ )
99
+ .run(secret, JSON.stringify(hashes), enrolledAt, enrolledAt, userId);
100
+ if (result.changes === 0) {
101
+ throw new Error(`persistEnrollment: no user row for id ${userId}`);
102
+ }
103
+ return { backupCodes: codes, enrolledAt };
104
+ }
105
+
106
+ /** Clear all TOTP state for a user (disenroll). Idempotent. */
107
+ export function clearEnrollment(
108
+ db: Database,
109
+ userId: string,
110
+ now: () => Date = () => new Date(),
111
+ ): void {
112
+ const stamp = now().toISOString();
113
+ db.prepare(
114
+ `UPDATE users
115
+ SET totp_secret = NULL, totp_backup_codes = NULL, totp_enrolled_at = NULL, updated_at = ?
116
+ WHERE id = ?`,
117
+ ).run(stamp, userId);
118
+ }
119
+
120
+ export type SecondFactorResult =
121
+ | { ok: true; via: "totp" }
122
+ | { ok: true; via: "backup_code" }
123
+ | { ok: false };
124
+
125
+ /**
126
+ * Verify a submitted second factor for a user during login. Tries the TOTP
127
+ * code first; if that fails, tries each stored backup code. On a backup-code
128
+ * match the code is **consumed** — removed from the stored list inside a
129
+ * transaction so a concurrent login can't reuse it.
130
+ *
131
+ * Returns which factor succeeded (for logging / "X backup codes left"
132
+ * messaging) or `{ ok: false }`. A user who isn't enrolled returns `ok:false`
133
+ * — but the login handler only reaches this when `totp_secret` is set, so that
134
+ * path is defensive.
135
+ *
136
+ * `markUsed` is forwarded to {@link verifyTotpCode}'s replay cache (tests set
137
+ * false to reuse a code; production leaves it true).
138
+ */
139
+ export async function verifySecondFactor(
140
+ db: Database,
141
+ userId: string,
142
+ submitted: string,
143
+ markUsed = true,
144
+ ): Promise<SecondFactorResult> {
145
+ const state = getTotpState(db, userId);
146
+ if (!state.secret) return { ok: false };
147
+
148
+ const code = submitted.trim();
149
+ if (!code) return { ok: false };
150
+
151
+ // TOTP path: a 6-digit numeric is almost certainly a TOTP attempt. Try it
152
+ // first (cheap, no DB write on success beyond the in-memory replay cache).
153
+ if (verifyTotpCode(state.secret, code, markUsed)) {
154
+ return { ok: true, via: "totp" };
155
+ }
156
+
157
+ // Backup-code path. Find a matching hash, then consume it transactionally.
158
+ if (state.backupCodes.length === 0) return { ok: false };
159
+ const idx = await findBackupCodeIndex(code, state.backupCodes);
160
+ if (idx < 0) return { ok: false };
161
+
162
+ // Consume inside a transaction: re-read the stored list, confirm the code
163
+ // we matched is still present (defends against a concurrent login that
164
+ // consumed it between our read and this write), splice it out, write back.
165
+ let consumed = false;
166
+ db.transaction(() => {
167
+ const fresh = getTotpState(db, userId);
168
+ // The matched hash (by value) must still be in the current list.
169
+ const matchedHash = state.backupCodes[idx]!;
170
+ const freshIdx = fresh.backupCodes.indexOf(matchedHash);
171
+ if (freshIdx < 0) return; // already consumed by a racing request
172
+ const remaining = fresh.backupCodes.filter((_, j) => j !== freshIdx);
173
+ db.prepare("UPDATE users SET totp_backup_codes = ? WHERE id = ?").run(
174
+ JSON.stringify(remaining),
175
+ userId,
176
+ );
177
+ consumed = true;
178
+ })();
179
+
180
+ return consumed ? { ok: true, via: "backup_code" } : { ok: false };
181
+ }