@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.20

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 (60) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-clients.test.ts +103 -1
  3. package/src/__tests__/admin-lock.test.ts +7 -1
  4. package/src/__tests__/admin-vaults.test.ts +216 -10
  5. package/src/__tests__/api-account-2fa.test.ts +453 -0
  6. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  7. package/src/__tests__/api-modules.test.ts +143 -0
  8. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  9. package/src/__tests__/auth.test.ts +336 -0
  10. package/src/__tests__/clients.test.ts +326 -8
  11. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  12. package/src/__tests__/cors.test.ts +138 -1
  13. package/src/__tests__/doctor.test.ts +755 -0
  14. package/src/__tests__/hub-command.test.ts +69 -2
  15. package/src/__tests__/hub-server.test.ts +127 -5
  16. package/src/__tests__/hub-settings.test.ts +188 -0
  17. package/src/__tests__/init.test.ts +153 -0
  18. package/src/__tests__/managed-unit.test.ts +62 -0
  19. package/src/__tests__/oauth-handlers.test.ts +626 -0
  20. package/src/__tests__/oauth-ui.test.ts +107 -1
  21. package/src/__tests__/scope-explanations.test.ts +19 -0
  22. package/src/__tests__/setup-gate.test.ts +111 -3
  23. package/src/__tests__/setup-wizard.test.ts +124 -7
  24. package/src/__tests__/supervisor.test.ts +25 -0
  25. package/src/__tests__/vault-names.test.ts +32 -3
  26. package/src/__tests__/vault-remove.test.ts +40 -19
  27. package/src/__tests__/well-known.test.ts +37 -2
  28. package/src/admin-clients.ts +55 -3
  29. package/src/admin-vaults.ts +52 -25
  30. package/src/api-account-2fa.ts +395 -0
  31. package/src/api-admin-lock.ts +7 -0
  32. package/src/api-hub-upgrade.ts +38 -3
  33. package/src/api-me.ts +11 -2
  34. package/src/api-modules.ts +105 -0
  35. package/src/api-settings-root-redirect.ts +188 -0
  36. package/src/cli.ts +56 -5
  37. package/src/clients.ts +178 -0
  38. package/src/commands/auth.ts +263 -1
  39. package/src/commands/doctor.ts +1250 -0
  40. package/src/commands/hub.ts +102 -1
  41. package/src/commands/init.ts +108 -0
  42. package/src/commands/vault-remove.ts +16 -24
  43. package/src/cors.ts +7 -3
  44. package/src/help.ts +65 -1
  45. package/src/hub-db.ts +14 -0
  46. package/src/hub-server.ts +139 -24
  47. package/src/hub-settings.ts +163 -1
  48. package/src/managed-unit.ts +30 -1
  49. package/src/oauth-handlers.ts +103 -6
  50. package/src/oauth-ui.ts +174 -0
  51. package/src/rate-limit.ts +28 -0
  52. package/src/scope-explanations.ts +2 -1
  53. package/src/setup-wizard.ts +40 -21
  54. package/src/supervisor.ts +46 -2
  55. package/src/vault-names.ts +15 -4
  56. package/src/well-known.ts +10 -1
  57. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  58. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  59. package/web/ui/dist/index.html +2 -2
  60. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -4,8 +4,11 @@
4
4
  * without round-tripping through the `/oauth/authorize` flow (whose
5
5
  * `POST /oauth/authorize/approve` requires a `return_to` authorize URL).
6
6
  *
7
- * GET /api/oauth/clients/<client_id> client details
8
- * POST /api/oauth/clients/<client_id>/approve flip status to approved
7
+ * GET /api/oauth/clients/<client_id> client details
8
+ * POST /api/oauth/clients/<client_id>/approve flip status to approved
9
+ * DELETE /oauth/clients/<client_id> deregister (RFC 7592) — note
10
+ * the TOP-LEVEL prefix, see
11
+ * handleDeleteClient
9
12
  *
10
13
  * Both gated by `parachute:host:admin` Bearer (same shape as /api/grants,
11
14
  * /api/auth/tokens, etc.). The SPA mints one via the session cookie at
@@ -54,7 +57,7 @@ import {
54
57
  requireScope,
55
58
  } from "./admin-auth.ts";
56
59
  import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
57
- import { approveClient, getClient } from "./clients.ts";
60
+ import { approveClient, deleteClient, getClient } from "./clients.ts";
58
61
  import { isSafeAuthorizeReturnTo } from "./oauth-handlers.ts";
59
62
 
60
63
  export interface AdminClientsDeps {
@@ -177,6 +180,55 @@ export async function handleApproveClient(
177
180
  });
178
181
  }
179
182
 
183
+ /**
184
+ * RFC 7592 Dynamic Client Registration *deletion* (deregistration).
185
+ *
186
+ * DELETE /oauth/clients/<client_id> remove the client + its cascade
187
+ *
188
+ * Mounted at the TOP-LEVEL `/oauth/clients/` prefix (NOT under `/api/...`)
189
+ * because that's the path parachute-surface's remove-flow actually calls
190
+ * (`packages/surface-host/src/dcr.ts` → `DELETE <hub>/oauth/clients/<id>`),
191
+ * carrying the operator token as a Bearer. Before this route existed the
192
+ * hub 404'd every such DELETE, so every Notes/Claude reconnect orphaned a
193
+ * `clients` row in the operator's DB (closes hub#640, 4/5 boxes — the GC
194
+ * reaper for legacy orphans is a separate follow-up).
195
+ *
196
+ * Auth mirrors `handleGetClient`: `parachute:host:admin` Bearer via
197
+ * `requireScope`. Returns 204 (no content) on a successful delete, 404 when
198
+ * the client isn't registered — the same shape the surface already tolerates
199
+ * (`hubDeleteStatus: "ok"` on 200/204, `"not_found"` on a JSON 404).
200
+ *
201
+ * Audit: emits a `client deleted: ...` line in the same `key=value` shape as
202
+ * the `client approved: ...` line, so cross-machine "who removed this client"
203
+ * is greppable in hub.log.
204
+ */
205
+ export async function handleDeleteClient(
206
+ req: Request,
207
+ clientId: string,
208
+ deps: AdminClientsDeps,
209
+ ): Promise<Response> {
210
+ if (req.method !== "DELETE") {
211
+ return jsonError(405, "method_not_allowed", "use DELETE");
212
+ }
213
+ let ctx: AdminAuthContext;
214
+ try {
215
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
216
+ } catch (err) {
217
+ return adminAuthErrorResponse(err as AdminAuthError);
218
+ }
219
+ // Capture the name BEFORE deleting so the audit line can carry it.
220
+ const before = getClient(deps.db, clientId);
221
+ const removed = deleteClient(deps.db, clientId);
222
+ if (!removed) {
223
+ return jsonError(404, "not_found", `no client registered with id ${clientId}`);
224
+ }
225
+ console.log(
226
+ `client deleted: client_id=${clientId} client_name=${before?.clientName ?? ""} remover_sub=${ctx.sub}`,
227
+ );
228
+ // 204 No Content — RFC 7592 §2.3 prescribes 204 for a successful delete.
229
+ return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
230
+ }
231
+
180
232
  interface ApproveClientResponse {
181
233
  client_id: string;
182
234
  status: "approved";
@@ -207,15 +207,11 @@ function findExistingVault(
207
207
  } catch {
208
208
  return null;
209
209
  }
210
- const target = `/vault/${name}`;
211
210
  for (const svc of manifest.services) {
212
211
  if (!isVaultEntry(svc)) continue;
213
- if (svc.paths.length === 0) {
214
- if (vaultInstanceNameFor(svc.name, undefined) === name) {
215
- return { url: target, version: svc.version, path: target };
216
- }
217
- continue;
218
- }
212
+ // #478: an empty-paths vault row means "installed but no servable vault
213
+ // instance" skip it entirely so it never resolves to a phantom "default".
214
+ if (svc.paths.length === 0) continue;
219
215
  for (const path of svc.paths) {
220
216
  if (vaultInstanceNameFor(svc.name, path) === name) {
221
217
  return { url: path, version: svc.version, path };
@@ -607,7 +603,7 @@ function emptyCascadeSummary(): CascadeSummary {
607
603
  }
608
604
 
609
605
  /** Every vault instance name currently registered in services.json. */
610
- function listVaultInstanceNames(manifestPath: string): Set<string> {
606
+ export function listVaultInstanceNames(manifestPath: string): Set<string> {
611
607
  const names = new Set<string>();
612
608
  let manifest: ReturnType<typeof readManifest>;
613
609
  try {
@@ -617,10 +613,9 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
617
613
  }
618
614
  for (const svc of manifest.services) {
619
615
  if (!isVaultEntry(svc)) continue;
620
- if (svc.paths.length === 0) {
621
- names.add(vaultInstanceNameFor(svc.name, undefined));
622
- continue;
623
- }
616
+ // #478: an empty-paths vault row means "installed but no servable vault
617
+ // instance" — skip it so no phantom "default" is synthesized.
618
+ if (svc.paths.length === 0) continue;
624
619
  for (const path of svc.paths) names.add(vaultInstanceNameFor(svc.name, path));
625
620
  }
626
621
  return names;
@@ -635,15 +630,22 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
635
630
  *
636
631
  * Refusals:
637
632
  * - unknown vault → 404;
638
- * - LAST remaining vault → 409. Vault's boot auto-creates `default` at
639
- * zero vaults, so deleting the last one would silently resurrect a fresh
640
- * `default` (with a fresh global API key) — refusing sidesteps the
641
- * resurrection class entirely. The CLI (`parachute-vault remove`) is the
642
- * escape hatch for an operator who really means it.
643
633
  * - RESERVED names are deliberately ALLOWED (no reserved-name gate): a
644
634
  * squatted `admin`/`new`/`assets` vault created before the B2h
645
635
  * reservation must be removable through this endpoint.
646
636
  *
637
+ * Last-vault handling (#678): deleting the LAST remaining vault runs the SAME
638
+ * cascade-then-delete as any other vault — it is NOT refused. The old 409 that
639
+ * steered the operator to the raw `parachute-vault remove` CLI was a
640
+ * correctness defect: that escape hatch SKIPS this cascade, orphaning tokens +
641
+ * grants that named the last vault. The resurrection risk the refusal once
642
+ * guarded (vault boot auto-creating a fresh-credentialed first vault at zero
643
+ * vaults) is handled downstream instead: the vault CLI writes an
644
+ * `auto_create: false` marker on last-vault removal and the vault boot gate
645
+ * honors it, so the server won't silently resurrect. Detection stays
646
+ * count-based + name-agnostic (no `name === "default"` special case); the last
647
+ * vault just adds a `last_vault` warning to the 200 response.
648
+ *
647
649
  * Cascade, in order (identity first, mechanics last — revocation is the safe
648
650
  * direction if a later step fails):
649
651
  * 1. tokens-registry sweep (exact scope-segment match — never SQL LIKE);
@@ -666,6 +668,15 @@ function listVaultInstanceNames(manifestPath: string): Set<string> {
666
668
  * Response: 200 with a structured per-step summary (counts +
667
669
  * `orphaned_channels` + warnings). A mechanics failure responds 500 with
668
670
  * the partial summary — the identity artifacts already revoked stay revoked.
671
+ *
672
+ * Bounded residual: the cascade revokes every *registered* token row naming
673
+ * the vault, but an UNREGISTERED interactive-mint (a host-admin browser
674
+ * session minting a short-lived vault token at ≤10-min TTL — see
675
+ * REGISTERED_MINT_TTL_THRESHOLD_SECONDS in admin-connections.ts) that was
676
+ * issued just before the delete leaves no registry row to sweep. Such a token
677
+ * stays valid for at most its remaining ≤10-min TTL, against a vault whose
678
+ * daemon is evicted in step 7 anyway. Same bound the auth-codes note below
679
+ * relies on; not eliminated, just bounded.
669
680
  */
670
681
  export async function handleDeleteVault(
671
682
  req: Request,
@@ -719,16 +730,20 @@ export async function handleDeleteVault(
719
730
  return jsonError(404, "not_found", `no vault named "${name}" on this hub`);
720
731
  }
721
732
 
722
- // Last-vault refusal (resurrection guard).
733
+ // Last-vault detection (count-based, name-agnostic). Deleting the LAST
734
+ // vault used to refuse with 409 and steer the operator to the raw
735
+ // `parachute-vault remove` CLI — but that path SKIPS this whole identity
736
+ // cascade, orphaning tokens + grants that named the vault. The resurrection
737
+ // risk the refusal guarded against is already handled downstream: the vault
738
+ // CLI writes an `auto_create: false` marker when it removes the last vault
739
+ // (vault `cli.ts` cmdRemove) and the vault boot gate honors it
740
+ // (`bootAutoCreateAllowed` in vault `config.ts`), so the server won't
741
+ // silently resurrect a fresh-credentialed first vault. We therefore run the
742
+ // cascade-then-delete for the last vault exactly as for any other — the
743
+ // count is informational only (no refusal).
723
744
  const instanceNames = listVaultInstanceNames(manifestPath);
724
745
  instanceNames.delete(name);
725
- if (instanceNames.size === 0) {
726
- return jsonError(
727
- 409,
728
- "last_vault",
729
- `"${name}" is the last vault on this hub. Vault's boot auto-creates "default" at zero vaults, so deleting the last one would silently resurrect it with fresh credentials. Create another vault first, or use the CLI (parachute-vault remove ${name} --yes) if you really mean to empty the hub.`,
730
- );
731
- }
746
+ const isLastVault = instanceNames.size === 0;
732
747
 
733
748
  const summary = emptyCascadeSummary();
734
749
  const warnings: { step: string; detail: string }[] = [];
@@ -870,6 +885,18 @@ export async function handleDeleteVault(
870
885
  );
871
886
  }
872
887
 
888
+ // Last-vault heads-up. The vault CLI's remove wrote `auto_create: false`, so
889
+ // the next vault boot won't resurrect a fresh-credentialed first vault — the
890
+ // hub is now deliberately empty. Surface that so the operator knows to create
891
+ // one when they want the hub serving again.
892
+ if (isLastVault) {
893
+ warnings.push({
894
+ step: "last_vault",
895
+ detail:
896
+ "the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
897
+ });
898
+ }
899
+
873
900
  // --- 7. Daemon eviction: supervisor-restart the vault module. -------------
874
901
  if (deps.restartVaultModule) {
875
902
  try {
@@ -0,0 +1,395 @@
1
+ /**
2
+ * `/api/account/*` — JSON self-service account surfaces for the admin SPA
3
+ * (hub#85). The server-rendered `/account/2fa` + `/account/change-password`
4
+ * pages stay (they work without JS, the friend-facing path); these are the
5
+ * JSON twins the in-`/admin` SPA "My account" page drives.
6
+ *
7
+ * POST /api/account/2fa/start → mint a fresh secret + QR + otpauth URL
8
+ * (NOT persisted — confirm seals it)
9
+ * POST /api/account/2fa/confirm → verify a live code vs the in-flight
10
+ * secret, persist enrollment, return the
11
+ * backup codes ONCE
12
+ * POST /api/account/2fa/disable → verify current password, clear 2FA
13
+ * POST /api/account/password → verify current, set new (+ revoke the
14
+ * user's still-active tokens)
15
+ *
16
+ * Auth posture: every endpoint is **self-service** — it acts on the
17
+ * SIGNED-IN user's OWN account (`session.userId`), never a client-supplied
18
+ * user id. ANY authenticated user reaches them (the owner / first-admin is
19
+ * NOT special — same path, no privilege bypass). This is deliberately the
20
+ * `/api/admin-lock` cookie+CSRF posture, NOT the host-admin Bearer posture:
21
+ * a user managing their own credentials shouldn't need (or have) the
22
+ * `parachute:host:admin` scope. Order on every POST:
23
+ *
24
+ * 1. Session cookie (else 401).
25
+ * 2. CSRF double-submit `__csrf` in the JSON body (else 403). Same-origin
26
+ * belt is applied by the hub-server dispatcher before this runs.
27
+ * 3. Per-action validation.
28
+ *
29
+ * The crypto + persistence is REUSED, never duplicated: secret generation +
30
+ * code verification live in `totp.ts`; enrollment storage lives in
31
+ * `two-factor-store.ts`; password validation + hashing live in `users.ts`.
32
+ * This file is the JSON wire layer only.
33
+ *
34
+ * In-flight-secret model (mirrors the server-rendered flow): `start` returns
35
+ * the secret, the SPA holds it client-side, and `confirm` sends it back with
36
+ * the live code. Nothing is persisted until `confirm` verifies — an abandoned
37
+ * setup leaves zero state.
38
+ */
39
+ import type { Database } from "bun:sqlite";
40
+ import { hash as argonHash } from "@node-rs/argon2";
41
+ import QRCode from "qrcode";
42
+ import { verifyCsrfToken } from "./csrf.ts";
43
+ import { changePasswordRateLimiter, totpEnrollConfirmRateLimiter } from "./rate-limit.ts";
44
+ import { findActiveSession } from "./sessions.ts";
45
+ import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
46
+ import {
47
+ clearEnrollment,
48
+ getTotpState,
49
+ isTotpEnrolled,
50
+ persistEnrollment,
51
+ } from "./two-factor-store.ts";
52
+ import {
53
+ PASSWORD_MAX_LEN,
54
+ type User,
55
+ UserNotFoundError,
56
+ getUserById,
57
+ validatePassword,
58
+ verifyPassword,
59
+ } from "./users.ts";
60
+
61
+ export interface ApiAccount2faDeps {
62
+ db: Database;
63
+ /** Test seam — defaults to the real clock. */
64
+ now?: () => Date;
65
+ }
66
+
67
+ function json(status: number, body: unknown, extra: Record<string, string> = {}): Response {
68
+ return new Response(JSON.stringify(body), {
69
+ status,
70
+ headers: { "content-type": "application/json", "cache-control": "no-store", ...extra },
71
+ });
72
+ }
73
+
74
+ function jsonError(status: number, error: string, description: string): Response {
75
+ return json(status, { error, error_description: description });
76
+ }
77
+
78
+ /** Resolve the signed-in user, or an error Response (401). Self-only — no id from the client. */
79
+ function requireUser(
80
+ db: Database,
81
+ req: Request,
82
+ ): { ok: true; user: User } | { ok: false; res: Response } {
83
+ const session = findActiveSession(db, req);
84
+ if (!session) {
85
+ return {
86
+ ok: false,
87
+ res: jsonError(401, "unauthenticated", "no session — sign in at /login first"),
88
+ };
89
+ }
90
+ const user = getUserById(db, session.userId);
91
+ if (!user) {
92
+ return {
93
+ ok: false,
94
+ res: jsonError(401, "unauthenticated", "signed-in account no longer exists"),
95
+ };
96
+ }
97
+ return { ok: true, user };
98
+ }
99
+
100
+ async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
101
+ try {
102
+ const body = (await req.json()) as unknown;
103
+ return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
104
+ } catch {
105
+ return {};
106
+ }
107
+ }
108
+
109
+ function checkCsrf(req: Request, body: Record<string, unknown>): boolean {
110
+ const token = typeof body.__csrf === "string" ? body.__csrf : null;
111
+ return verifyCsrfToken(req, token);
112
+ }
113
+
114
+ /**
115
+ * Gate the password-verifying endpoints (`/password`, `/2fa/disable`) before the
116
+ * argon2id `verifyPassword` call — a session-hijack attacker shouldn't get an
117
+ * unbounded grind window against the hash. Keyed by `user.id` (identity is
118
+ * already established by the session) and shares the `changePasswordRateLimiter`
119
+ * bucket (3 attempts / 5 min) with the server-rendered change-password POST, so
120
+ * a single user's argon2id budget is uniform across both surfaces. Returns a 429
121
+ * Response when the bucket is exhausted, else null. Fires AFTER CSRF so a junk
122
+ * cross-site POST can't burn the victim's bucket slot.
123
+ */
124
+ function passwordRateLimit(userId: string, now: () => Date): Response | null {
125
+ const gate = changePasswordRateLimiter.checkAndRecord(userId, now());
126
+ if (gate.allowed) return null;
127
+ const retryAfter = gate.retryAfterSeconds ?? 1;
128
+ return json(
129
+ 429,
130
+ {
131
+ error: "too_many_attempts",
132
+ error_description: `Too many attempts. Try again in ${retryAfter} seconds.`,
133
+ },
134
+ { "retry-after": String(retryAfter) },
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Router for `/api/account/*`. `subpath` is the path AFTER `/api/account`
140
+ * (e.g. "/2fa/start", "/password"). The hub-server dispatcher slices it.
141
+ *
142
+ * Every route here is a POST (state-changing); the read-side 2FA status the
143
+ * SPA renders comes from `/api/me`'s `two_factor_enabled` field, so there's
144
+ * no GET on this surface.
145
+ */
146
+ export async function handleApiAccount(
147
+ req: Request,
148
+ subpath: string,
149
+ deps: ApiAccount2faDeps,
150
+ ): Promise<Response> {
151
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
152
+
153
+ const gate = requireUser(deps.db, req);
154
+ if (!gate.ok) return gate.res;
155
+ const user = gate.user;
156
+
157
+ const body = await readJsonBody(req);
158
+ if (!checkCsrf(req, body)) {
159
+ return jsonError(403, "csrf_failed", "missing or invalid CSRF token");
160
+ }
161
+
162
+ switch (subpath) {
163
+ case "/2fa/start":
164
+ return handleStart(deps.db, user);
165
+ case "/2fa/confirm":
166
+ return handleConfirm(deps, user, body);
167
+ case "/2fa/disable":
168
+ return handleDisable(deps, user, body);
169
+ case "/password":
170
+ return handlePassword(deps, user, body);
171
+ default:
172
+ return jsonError(404, "not_found", `no account route at /api/account${subpath}`);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * POST /api/account/2fa/start — mint a fresh secret + provisioning artifacts.
178
+ * Refuses if already enrolled (disable first to re-enroll) — same guard as
179
+ * the server-rendered `start`. The secret is NOT persisted; the SPA holds it
180
+ * and round-trips it back on confirm.
181
+ */
182
+ async function handleStart(db: Database, user: User): Promise<Response> {
183
+ if (isTotpEnrolled(db, user.id)) {
184
+ return jsonError(
185
+ 409,
186
+ "already_enrolled",
187
+ "Two-factor is already enabled. Turn it off first to re-enroll.",
188
+ );
189
+ }
190
+ const { secret, otpauthUrl } = generateTotpSecret(user.username);
191
+ // PNG data-URL QR (margin:1 for scanner-friendly quiet zone). The repo
192
+ // already depends on `qrcode`; returning a data-URL lets the SPA render a
193
+ // plain <img> with no new client dependency, and the otpauth URL is
194
+ // returned alongside for manual-entry / copy affordances.
195
+ const qrDataUrl = await QRCode.toDataURL(otpauthUrl, { margin: 1, errorCorrectionLevel: "M" });
196
+ return json(200, { secret, otpauth_url: otpauthUrl, qr_data_url: qrDataUrl });
197
+ }
198
+
199
+ /** base32 alphabet (A–Z, 2–7) + optional `=` padding, ≥16 chars. Same N1 guard as the HTML flow. */
200
+ function isPlausibleBase32Secret(secret: string): boolean {
201
+ return /^[A-Z2-7]+=*$/i.test(secret) && secret.length >= 16;
202
+ }
203
+
204
+ /**
205
+ * POST /api/account/2fa/confirm {secret, code} — verify the live code vs the
206
+ * in-flight secret, persist enrollment, return the backup codes ONCE.
207
+ */
208
+ async function handleConfirm(
209
+ deps: ApiAccount2faDeps,
210
+ user: User,
211
+ body: Record<string, unknown>,
212
+ ): Promise<Response> {
213
+ const secret = typeof body.secret === "string" ? body.secret : "";
214
+ const code = typeof body.code === "string" ? body.code : "";
215
+
216
+ if (!secret || !isPlausibleBase32Secret(secret)) {
217
+ return jsonError(400, "setup_expired", "Setup expired or malformed. Start again.");
218
+ }
219
+ // Defensive — a confirm POST against an already-enrolled account.
220
+ if (isTotpEnrolled(deps.db, user.id)) {
221
+ return jsonError(409, "already_enrolled", "Two-factor is already enabled.");
222
+ }
223
+ // Bound a hijacked session grinding the in-flight (client-held) secret. Keyed
224
+ // by user.id, lenient (10/15min) so honest enroll mistypes aren't punished —
225
+ // defense-in-depth (#712). Fires AFTER the format + already-enrolled guards so
226
+ // junk/no-op POSTs don't burn the legit enroller's budget, and BEFORE the
227
+ // code verify so the grind window is actually bounded. A SUCCESSFUL confirm
228
+ // also consumes one slot (checkAndRecord counts every attempt) — harmless,
229
+ // since an enrolled account 409s on any further confirm anyway.
230
+ const confirmLimited = totpEnrollConfirmRateLimiter.checkAndRecord(
231
+ user.id,
232
+ deps.now ? deps.now() : new Date(),
233
+ );
234
+ if (!confirmLimited.allowed) {
235
+ const retryAfter = confirmLimited.retryAfterSeconds ?? 1;
236
+ return json(
237
+ 429,
238
+ {
239
+ error: "too_many_attempts",
240
+ error_description: `Too many attempts. Try again in ${retryAfter} seconds.`,
241
+ },
242
+ { "retry-after": String(retryAfter) },
243
+ );
244
+ }
245
+ if (!verifyTotpCode(secret, code)) {
246
+ return jsonError(
247
+ 400,
248
+ "invalid_code",
249
+ "That code didn't match. Check your device clock and try the current code.",
250
+ );
251
+ }
252
+ const result = await persistEnrollment(deps.db, user.id, secret, deps.now ?? (() => new Date()));
253
+ // Backup codes are shown ONCE — no-store so the response is never cached.
254
+ return json(200, {
255
+ enrolled: true,
256
+ enrolled_at: result.enrolledAt,
257
+ backup_codes: result.backupCodes,
258
+ });
259
+ }
260
+
261
+ /**
262
+ * POST /api/account/2fa/disable {password} — verify the current password,
263
+ * clear 2FA. Password-gated (same safety as the HTML flow): disabling a
264
+ * second factor with only a session cookie would let a hijacked session
265
+ * strip the very protection that defends the account.
266
+ */
267
+ async function handleDisable(
268
+ deps: ApiAccount2faDeps,
269
+ user: User,
270
+ body: Record<string, unknown>,
271
+ ): Promise<Response> {
272
+ const db = deps.db;
273
+ if (!isTotpEnrolled(db, user.id)) {
274
+ // Idempotent — already off.
275
+ return json(200, { enrolled: false });
276
+ }
277
+ const password = typeof body.password === "string" ? body.password : "";
278
+ if (!password) {
279
+ return jsonError(
280
+ 400,
281
+ "password_required",
282
+ "Enter your current password to turn off two-factor.",
283
+ );
284
+ }
285
+ // Cap before argon2id verify (CPU-DoS guard — same posture as /login).
286
+ if (password.length > PASSWORD_MAX_LEN) {
287
+ return jsonError(
288
+ 413,
289
+ "password_too_long",
290
+ `Password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
291
+ );
292
+ }
293
+ // Rate-limit before the argon2id verify (a stolen session shouldn't grind).
294
+ const limited = passwordRateLimit(user.id, deps.now ?? (() => new Date()));
295
+ if (limited) return limited;
296
+ const ok = await verifyPassword(user, password);
297
+ if (!ok) {
298
+ return jsonError(401, "invalid_credentials", "That password is incorrect.");
299
+ }
300
+ clearEnrollment(db, user.id);
301
+ return json(200, { enrolled: false });
302
+ }
303
+
304
+ /**
305
+ * POST /api/account/password {current_password, new_password} — JSON twin of
306
+ * the server-rendered `/account/change-password` POST. Same validation +
307
+ * atomic hash-write-and-revoke-tokens as `api-account.ts`, reusing the same
308
+ * `users.ts` validators. Self-only (the signed-in user's own hash).
309
+ *
310
+ * Check order mirrors the HTML handler:
311
+ * 1. fields present (400)
312
+ * 2. current too long → 413 (before argon2id verify)
313
+ * 3. new too long → 413 (before argon2id hash)
314
+ * 4. validatePassword(new) → 400
315
+ * 5. rate-limit (429, before the argon2id verify — same as the HTML twin)
316
+ * 6. verifyPassword(current) → 401
317
+ * 7. new === current → 400 (after verify — see api-account.ts rationale)
318
+ * 8. hash new + UPDATE + revoke tokens (one tx)
319
+ */
320
+ async function handlePassword(
321
+ deps: ApiAccount2faDeps,
322
+ user: User,
323
+ body: Record<string, unknown>,
324
+ ): Promise<Response> {
325
+ const currentPassword = typeof body.current_password === "string" ? body.current_password : "";
326
+ const newPassword = typeof body.new_password === "string" ? body.new_password : "";
327
+
328
+ if (!currentPassword || !newPassword) {
329
+ return jsonError(400, "missing_fields", "current_password and new_password are required.");
330
+ }
331
+ if (currentPassword.length > PASSWORD_MAX_LEN) {
332
+ return jsonError(
333
+ 413,
334
+ "password_too_long",
335
+ `Current password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
336
+ );
337
+ }
338
+ if (newPassword.length > PASSWORD_MAX_LEN) {
339
+ return jsonError(
340
+ 413,
341
+ "password_too_long",
342
+ `New password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
343
+ );
344
+ }
345
+ if (!validatePassword(newPassword).valid) {
346
+ return jsonError(
347
+ 400,
348
+ "invalid_password",
349
+ "New password must be at least 12 characters (a passphrase is fine).",
350
+ );
351
+ }
352
+ // Rate-limit before the argon2id verify (a stolen session shouldn't grind
353
+ // the current-password check). Shares the bucket with the HTML twin + the
354
+ // disable endpoint — uniform per-user argon2id budget.
355
+ const limited = passwordRateLimit(user.id, deps.now ?? (() => new Date()));
356
+ if (limited) return limited;
357
+ const currentOk = await verifyPassword(user, currentPassword);
358
+ if (!currentOk) {
359
+ return jsonError(401, "invalid_credentials", "Current password is incorrect.");
360
+ }
361
+ if (newPassword === currentPassword) {
362
+ return jsonError(
363
+ 400,
364
+ "password_unchanged",
365
+ "New password must differ from your current password.",
366
+ );
367
+ }
368
+
369
+ // Hash OUTSIDE the transaction — argon2id is async and bun:sqlite's
370
+ // `db.transaction()` is sync; an async closure silently breaks atomicity
371
+ // (same constraint api-account.ts documents). Then write the hash, flip
372
+ // `password_changed`, and revoke the user's still-active tokens in one tx.
373
+ const now = deps.now ?? (() => new Date());
374
+ const passwordHash = await argonHash(newPassword);
375
+ const stamp = now().toISOString();
376
+ try {
377
+ deps.db.transaction(() => {
378
+ const result = deps.db
379
+ .prepare(
380
+ "UPDATE users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE id = ?",
381
+ )
382
+ .run(passwordHash, stamp, user.id);
383
+ if (result.changes === 0) throw new UserNotFoundError(user.id);
384
+ deps.db
385
+ .prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
386
+ .run(stamp, user.id);
387
+ })();
388
+ } catch (err) {
389
+ if (err instanceof UserNotFoundError) {
390
+ return jsonError(401, "unauthenticated", "The signed-in account no longer exists.");
391
+ }
392
+ throw err;
393
+ }
394
+ return json(200, { ok: true });
395
+ }
@@ -159,8 +159,15 @@ export async function handleAdminLock(
159
159
  case "/heartbeat":
160
160
  // Slide the idle window forward if (and only if) currently unlocked.
161
161
  refreshActivity(gate.sessionId, getIdleSeconds(db), now().getTime());
162
+ // `idle_seconds` is part of the response so the heartbeat fulfills the
163
+ // same `AdminLockStatus` shape as GET status — the client re-anchors its
164
+ // local idle timer from it on every heartbeat, so it MUST be present.
165
+ // Omitting it poisoned the client timer with `undefined` (→ NaN → instant
166
+ // re-lock), the bug this fixes. It also lets a live session pick up an
167
+ // idle-window change the operator made in Settings mid-session.
162
168
  return json(200, {
163
169
  locked: isLockConfigured(db) && !isSessionUnlocked(gate.sessionId, now().getTime()),
170
+ idle_seconds: getIdleSeconds(db),
164
171
  unlock_seconds_remaining: unlockSecondsRemaining(gate.sessionId, now().getTime()),
165
172
  });
166
173
  default: