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

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 (75) hide show
  1. package/package.json +4 -11
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-lock.test.ts +7 -1
  5. package/src/__tests__/admin-vaults.test.ts +216 -10
  6. package/src/__tests__/api-account-2fa.test.ts +453 -0
  7. package/src/__tests__/api-hub-upgrade.test.ts +59 -3
  8. package/src/__tests__/api-mint-token.test.ts +75 -0
  9. package/src/__tests__/api-modules.test.ts +143 -0
  10. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  11. package/src/__tests__/auth.test.ts +336 -0
  12. package/src/__tests__/clients.test.ts +326 -8
  13. package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-server.test.ts +127 -5
  18. package/src/__tests__/hub-settings.test.ts +188 -0
  19. package/src/__tests__/init.test.ts +153 -0
  20. package/src/__tests__/jwt-sign.test.ts +27 -0
  21. package/src/__tests__/managed-unit.test.ts +62 -0
  22. package/src/__tests__/oauth-handlers.test.ts +626 -0
  23. package/src/__tests__/oauth-ui.test.ts +107 -1
  24. package/src/__tests__/scope-explanations.test.ts +19 -0
  25. package/src/__tests__/setup-gate.test.ts +111 -3
  26. package/src/__tests__/setup-wizard.test.ts +124 -7
  27. package/src/__tests__/supervisor.test.ts +25 -0
  28. package/src/__tests__/vault-names.test.ts +32 -3
  29. package/src/__tests__/vault-remove.test.ts +40 -19
  30. package/src/__tests__/well-known.test.ts +37 -2
  31. package/src/admin-agent-grants.ts +16 -1
  32. package/src/admin-auth.ts +13 -4
  33. package/src/admin-clients.ts +66 -5
  34. package/src/admin-grants.ts +11 -2
  35. package/src/admin-vaults.ts +77 -27
  36. package/src/api-account-2fa.ts +395 -0
  37. package/src/api-admin-lock.ts +7 -0
  38. package/src/api-hub-upgrade.ts +52 -4
  39. package/src/api-hub.ts +10 -1
  40. package/src/api-invites.ts +18 -3
  41. package/src/api-me.ts +11 -2
  42. package/src/api-mint-token.ts +16 -1
  43. package/src/api-modules.ts +119 -1
  44. package/src/api-revoke-token.ts +14 -1
  45. package/src/api-settings-hub-origin.ts +14 -1
  46. package/src/api-settings-root-redirect.ts +201 -0
  47. package/src/api-tokens.ts +14 -1
  48. package/src/api-users.ts +15 -6
  49. package/src/api-vault-caps.ts +11 -2
  50. package/src/cli.ts +56 -5
  51. package/src/clients.ts +178 -0
  52. package/src/commands/auth.ts +263 -1
  53. package/src/commands/doctor.ts +1250 -0
  54. package/src/commands/hub.ts +102 -1
  55. package/src/commands/init.ts +108 -0
  56. package/src/commands/vault-remove.ts +16 -24
  57. package/src/cors.ts +7 -3
  58. package/src/help.ts +65 -1
  59. package/src/hub-db.ts +14 -0
  60. package/src/hub-server.ts +173 -25
  61. package/src/hub-settings.ts +163 -1
  62. package/src/jwt-sign.ts +25 -6
  63. package/src/managed-unit.ts +30 -1
  64. package/src/oauth-handlers.ts +110 -7
  65. package/src/oauth-ui.ts +174 -0
  66. package/src/rate-limit.ts +28 -0
  67. package/src/scope-explanations.ts +2 -1
  68. package/src/setup-wizard.ts +40 -21
  69. package/src/supervisor.ts +46 -2
  70. package/src/vault-names.ts +15 -4
  71. package/src/well-known.ts +10 -1
  72. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  73. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  74. package/web/ui/dist/index.html +2 -2
  75. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -0,0 +1,453 @@
1
+ /**
2
+ * `/api/account/*` JSON self-service endpoints (hub#85): password change +
3
+ * 2FA start/confirm/disable. Plus `/api/me`'s `two_factor_enabled` field.
4
+ *
5
+ * Coverage:
6
+ * - auth: no session → 401; wrong CSRF → 403; self-only (keyed off session)
7
+ * - password: happy path (hash rotated + tokens revoked); wrong current →
8
+ * 401; too short → 400; mismatch handled client-side (not here); new ===
9
+ * current → 400; too long → 413
10
+ * - 2fa start → secret + qr; already-enrolled → 409
11
+ * - 2fa confirm: round-trip with a live code persists + returns backup codes;
12
+ * bad code → 400; malformed secret → 400
13
+ * - 2fa disable: password-gated (wrong → 401), clears enrollment; idempotent
14
+ * - /api/me reflects two_factor_enabled
15
+ */
16
+ import type { Database } from "bun:sqlite";
17
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
18
+ import { mkdtempSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import * as OTPAuth from "otpauth";
22
+ import { handleApiAccount } from "../api-account-2fa.ts";
23
+ import { handleApiMe } from "../api-me.ts";
24
+ import { CSRF_COOKIE_NAME } from "../csrf.ts";
25
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
26
+ import { recordTokenMint } from "../jwt-sign.ts";
27
+ import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
28
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
29
+ import { _resetTotpReplayCache, generateTotpSecret } from "../totp.ts";
30
+ import { isTotpEnrolled, persistEnrollment } from "../two-factor-store.ts";
31
+ import { createUser, verifyPassword } from "../users.ts";
32
+
33
+ const TEST_CSRF = "csrf-account-2fa-token";
34
+ const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
35
+
36
+ interface Harness {
37
+ db: Database;
38
+ configDir: string;
39
+ cleanup: () => void;
40
+ }
41
+
42
+ function makeHarness(): Harness {
43
+ const configDir = mkdtempSync(join(tmpdir(), "phub-api-account-2fa-"));
44
+ const db = openHubDb(hubDbPath(configDir));
45
+ return {
46
+ db,
47
+ configDir,
48
+ cleanup: () => {
49
+ db.close();
50
+ rmSync(configDir, { recursive: true, force: true });
51
+ },
52
+ };
53
+ }
54
+
55
+ async function userWithSession(
56
+ db: Database,
57
+ username: string,
58
+ password: string,
59
+ ): Promise<{ userId: string; cookie: string }> {
60
+ const user = await createUser(db, username, password, { passwordChanged: true });
61
+ const session = createSession(db, { userId: user.id });
62
+ const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`;
63
+ return { userId: user.id, cookie };
64
+ }
65
+
66
+ function post(
67
+ subpath: string,
68
+ cookie: string | null,
69
+ body: Record<string, unknown>,
70
+ ): Promise<Response> {
71
+ const headers: Record<string, string> = { "content-type": "application/json" };
72
+ if (cookie) headers.cookie = cookie;
73
+ return handleApiAccount(
74
+ new Request(`http://hub.test/api/account${subpath}`, {
75
+ method: "POST",
76
+ headers,
77
+ body: JSON.stringify(body),
78
+ }),
79
+ subpath,
80
+ { db: harness.db },
81
+ );
82
+ }
83
+
84
+ function liveCode(secretBase32: string, label = "owner"): string {
85
+ return new OTPAuth.TOTP({
86
+ issuer: "Parachute Hub",
87
+ label,
88
+ algorithm: "SHA1",
89
+ digits: 6,
90
+ period: 30,
91
+ secret: OTPAuth.Secret.fromBase32(secretBase32),
92
+ }).generate();
93
+ }
94
+
95
+ let harness: Harness;
96
+ beforeEach(() => {
97
+ harness = makeHarness();
98
+ resetRateLimit();
99
+ _resetTotpReplayCache();
100
+ });
101
+ afterEach(() => {
102
+ harness.cleanup();
103
+ });
104
+
105
+ describe("/api/account/* — auth posture", () => {
106
+ test("no session → 401", async () => {
107
+ const res = await post("/password", null, {
108
+ __csrf: TEST_CSRF,
109
+ current_password: "x",
110
+ new_password: "y",
111
+ });
112
+ expect(res.status).toBe(401);
113
+ });
114
+
115
+ test("wrong CSRF → 403", async () => {
116
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
117
+ const res = await post("/password", cookie, {
118
+ __csrf: "not-the-token",
119
+ current_password: "owner-password-123",
120
+ new_password: "brand-new-passphrase",
121
+ });
122
+ expect(res.status).toBe(403);
123
+ });
124
+
125
+ test("unknown subpath → 404", async () => {
126
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
127
+ const res = await post("/bogus", cookie, { __csrf: TEST_CSRF });
128
+ expect(res.status).toBe(404);
129
+ });
130
+
131
+ test("GET → 405", async () => {
132
+ const res = await handleApiAccount(
133
+ new Request("http://hub.test/api/account/password", { method: "GET" }),
134
+ "/password",
135
+ { db: harness.db },
136
+ );
137
+ expect(res.status).toBe(405);
138
+ });
139
+ });
140
+
141
+ describe("/api/account/password", () => {
142
+ test("happy path rotates the hash + revokes active tokens", async () => {
143
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
144
+ // Seed an active token for this user — it should be revoked.
145
+ recordTokenMint(harness.db, {
146
+ jti: "tok-1",
147
+ userId,
148
+ subject: userId,
149
+ clientId: "cli",
150
+ scopes: ["vault:default:read"],
151
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
152
+ createdVia: "cli_mint",
153
+ });
154
+
155
+ const res = await post("/password", cookie, {
156
+ __csrf: TEST_CSRF,
157
+ current_password: "owner-password-123",
158
+ new_password: "brand-new-passphrase",
159
+ });
160
+ expect(res.status).toBe(200);
161
+
162
+ // New password verifies; old does not.
163
+ const row = harness.db
164
+ .query<{ password_hash: string }, [string]>("SELECT password_hash FROM users WHERE id = ?")
165
+ .get(userId);
166
+ expect(row).not.toBeNull();
167
+ const fakeUser = { passwordHash: row!.password_hash } as Parameters<typeof verifyPassword>[0];
168
+ expect(await verifyPassword(fakeUser, "brand-new-passphrase")).toBe(true);
169
+ expect(await verifyPassword(fakeUser, "owner-password-123")).toBe(false);
170
+
171
+ // Token revoked.
172
+ const tok = harness.db
173
+ .query<{ revoked_at: string | null }, [string]>("SELECT revoked_at FROM tokens WHERE jti = ?")
174
+ .get("tok-1");
175
+ expect(tok?.revoked_at).not.toBeNull();
176
+ });
177
+
178
+ test("wrong current password → 401 invalid_credentials", async () => {
179
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
180
+ const res = await post("/password", cookie, {
181
+ __csrf: TEST_CSRF,
182
+ current_password: "WRONG",
183
+ new_password: "brand-new-passphrase",
184
+ });
185
+ expect(res.status).toBe(401);
186
+ const body = (await res.json()) as { error: string };
187
+ expect(body.error).toBe("invalid_credentials");
188
+ });
189
+
190
+ test("new password too short → 400 invalid_password", async () => {
191
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
192
+ const res = await post("/password", cookie, {
193
+ __csrf: TEST_CSRF,
194
+ current_password: "owner-password-123",
195
+ new_password: "short",
196
+ });
197
+ expect(res.status).toBe(400);
198
+ const body = (await res.json()) as { error: string };
199
+ expect(body.error).toBe("invalid_password");
200
+ });
201
+
202
+ test("new === current → 400 password_unchanged", async () => {
203
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
204
+ const res = await post("/password", cookie, {
205
+ __csrf: TEST_CSRF,
206
+ current_password: "owner-password-123",
207
+ new_password: "owner-password-123",
208
+ });
209
+ expect(res.status).toBe(400);
210
+ const body = (await res.json()) as { error: string };
211
+ expect(body.error).toBe("password_unchanged");
212
+ });
213
+
214
+ test("missing fields → 400", async () => {
215
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
216
+ const res = await post("/password", cookie, { __csrf: TEST_CSRF });
217
+ expect(res.status).toBe(400);
218
+ });
219
+
220
+ test("new password over PASSWORD_MAX_LEN → 413 (before argon2id hash)", async () => {
221
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
222
+ const res = await post("/password", cookie, {
223
+ __csrf: TEST_CSRF,
224
+ current_password: "owner-password-123",
225
+ new_password: "x".repeat(257),
226
+ });
227
+ expect(res.status).toBe(413);
228
+ });
229
+
230
+ test("rate-limited after repeated wrong-current attempts → 429", async () => {
231
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
232
+ // Bucket is 3 attempts / 5 min (CHANGE_PASSWORD_*). Burn 3 wrong-current
233
+ // attempts (each 401), then the 4th is rejected at 429 BEFORE the verify.
234
+ for (let i = 0; i < 3; i++) {
235
+ const r = await post("/password", cookie, {
236
+ __csrf: TEST_CSRF,
237
+ current_password: "WRONG",
238
+ new_password: "brand-new-passphrase",
239
+ });
240
+ expect(r.status).toBe(401);
241
+ }
242
+ const limited = await post("/password", cookie, {
243
+ __csrf: TEST_CSRF,
244
+ current_password: "WRONG",
245
+ new_password: "brand-new-passphrase",
246
+ });
247
+ expect(limited.status).toBe(429);
248
+ expect(limited.headers.get("retry-after")).not.toBeNull();
249
+ });
250
+ });
251
+
252
+ describe("/api/account/2fa start + confirm", () => {
253
+ test("start returns a secret + otpauth_url + qr_data_url", async () => {
254
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
255
+ const res = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
256
+ expect(res.status).toBe(200);
257
+ const body = (await res.json()) as {
258
+ secret: string;
259
+ otpauth_url: string;
260
+ qr_data_url: string;
261
+ };
262
+ expect(body.secret).toMatch(/^[A-Z2-7]+$/);
263
+ expect(body.otpauth_url.startsWith("otpauth://totp/")).toBe(true);
264
+ expect(body.qr_data_url.startsWith("data:image/png;base64,")).toBe(true);
265
+ });
266
+
267
+ test("start refuses (409) when already enrolled", async () => {
268
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
269
+ await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
270
+ const res = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
271
+ expect(res.status).toBe(409);
272
+ });
273
+
274
+ test("confirm with a live code persists enrollment + returns backup codes", async () => {
275
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
276
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
277
+ const { secret } = (await startRes.json()) as { secret: string };
278
+
279
+ const confirmRes = await post("/2fa/confirm", cookie, {
280
+ __csrf: TEST_CSRF,
281
+ secret,
282
+ code: liveCode(secret),
283
+ });
284
+ expect(confirmRes.status).toBe(200);
285
+ const body = (await confirmRes.json()) as { enrolled: boolean; backup_codes: string[] };
286
+ expect(body.enrolled).toBe(true);
287
+ expect(body.backup_codes.length).toBe(10);
288
+ expect(isTotpEnrolled(harness.db, userId)).toBe(true);
289
+ });
290
+
291
+ test("confirm with a wrong code → 400 invalid_code (not persisted)", async () => {
292
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
293
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
294
+ const { secret } = (await startRes.json()) as { secret: string };
295
+ const res = await post("/2fa/confirm", cookie, {
296
+ __csrf: TEST_CSRF,
297
+ secret,
298
+ code: "000000",
299
+ });
300
+ expect(res.status).toBe(400);
301
+ expect(isTotpEnrolled(harness.db, userId)).toBe(false);
302
+ });
303
+
304
+ test("confirm with a malformed secret → 400 setup_expired", async () => {
305
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
306
+ const res = await post("/2fa/confirm", cookie, {
307
+ __csrf: TEST_CSRF,
308
+ secret: "not-base32!!",
309
+ code: "123456",
310
+ });
311
+ expect(res.status).toBe(400);
312
+ const body = (await res.json()) as { error: string };
313
+ expect(body.error).toBe("setup_expired");
314
+ });
315
+
316
+ test("confirm is rate-limited after 10 attempts → 429 (lenient, #712)", async () => {
317
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
318
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
319
+ const { secret } = (await startRes.json()) as { secret: string };
320
+ // 10 honest mistypes are admitted (each 400 invalid_code) — the lenient
321
+ // bucket doesn't punish a fumbling enroller.
322
+ for (let i = 0; i < 10; i++) {
323
+ const r = await post("/2fa/confirm", cookie, {
324
+ __csrf: TEST_CSRF,
325
+ secret,
326
+ code: "000000",
327
+ });
328
+ expect(r.status).toBe(400);
329
+ }
330
+ // 11th is denied by the limiter BEFORE the code is checked.
331
+ const denied = await post("/2fa/confirm", cookie, {
332
+ __csrf: TEST_CSRF,
333
+ secret,
334
+ code: "000000",
335
+ });
336
+ expect(denied.status).toBe(429);
337
+ const body = (await denied.json()) as { error: string };
338
+ expect(body.error).toBe("too_many_attempts");
339
+ expect(denied.headers.get("retry-after")).toBeTruthy();
340
+ // The grind never touched enrollment.
341
+ expect(isTotpEnrolled(harness.db, userId)).toBe(false);
342
+ });
343
+
344
+ test("malformed-secret POSTs don't burn the confirm budget (#712)", async () => {
345
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
346
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
347
+ const { secret } = (await startRes.json()) as { secret: string };
348
+ // 10 junk POSTs are rejected by the format guard BEFORE the limiter runs.
349
+ for (let i = 0; i < 10; i++) {
350
+ const r = await post("/2fa/confirm", cookie, {
351
+ __csrf: TEST_CSRF,
352
+ secret: "not-base32!!",
353
+ code: "000000",
354
+ });
355
+ expect(r.status).toBe(400);
356
+ }
357
+ // Budget untouched — the legit live code still enrolls on the next attempt.
358
+ const ok = await post("/2fa/confirm", cookie, {
359
+ __csrf: TEST_CSRF,
360
+ secret,
361
+ code: liveCode(secret),
362
+ });
363
+ expect(ok.status).toBe(200);
364
+ expect(isTotpEnrolled(harness.db, userId)).toBe(true);
365
+ });
366
+
367
+ test("a few mistypes then the live code within budget still enrolls (lenient)", async () => {
368
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
369
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
370
+ const { secret } = (await startRes.json()) as { secret: string };
371
+ for (let i = 0; i < 3; i++) {
372
+ const r = await post("/2fa/confirm", cookie, {
373
+ __csrf: TEST_CSRF,
374
+ secret,
375
+ code: "000000",
376
+ });
377
+ expect(r.status).toBe(400);
378
+ }
379
+ const ok = await post("/2fa/confirm", cookie, {
380
+ __csrf: TEST_CSRF,
381
+ secret,
382
+ code: liveCode(secret),
383
+ });
384
+ expect(ok.status).toBe(200);
385
+ expect(isTotpEnrolled(harness.db, userId)).toBe(true);
386
+ });
387
+ });
388
+
389
+ describe("/api/account/2fa/disable", () => {
390
+ test("password-gated: wrong password → 401, enrollment intact", async () => {
391
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
392
+ await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
393
+ const res = await post("/2fa/disable", cookie, {
394
+ __csrf: TEST_CSRF,
395
+ password: "WRONG",
396
+ });
397
+ expect(res.status).toBe(401);
398
+ expect(isTotpEnrolled(harness.db, userId)).toBe(true);
399
+ });
400
+
401
+ test("correct password clears enrollment", async () => {
402
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
403
+ await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
404
+ const res = await post("/2fa/disable", cookie, {
405
+ __csrf: TEST_CSRF,
406
+ password: "owner-password-123",
407
+ });
408
+ expect(res.status).toBe(200);
409
+ expect(isTotpEnrolled(harness.db, userId)).toBe(false);
410
+ });
411
+
412
+ test("idempotent when already off", async () => {
413
+ const { cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
414
+ const res = await post("/2fa/disable", cookie, {
415
+ __csrf: TEST_CSRF,
416
+ password: "owner-password-123",
417
+ });
418
+ expect(res.status).toBe(200);
419
+ });
420
+
421
+ test("missing password → 400", async () => {
422
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
423
+ await persistEnrollment(harness.db, userId, generateTotpSecret("owner").secret);
424
+ const res = await post("/2fa/disable", cookie, { __csrf: TEST_CSRF });
425
+ expect(res.status).toBe(400);
426
+ });
427
+ });
428
+
429
+ describe("/api/me — two_factor_enabled", () => {
430
+ test("false when not enrolled, true after enrollment", async () => {
431
+ const user = await createUser(harness.db, "owner", "owner-password-123", {
432
+ passwordChanged: true,
433
+ });
434
+ const session = createSession(harness.db, { userId: user.id });
435
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
436
+
437
+ const before = await handleApiMe(
438
+ new Request("http://hub.test/api/me", { headers: { cookie } }),
439
+ { db: harness.db },
440
+ );
441
+ const beforeBody = (await before.json()) as { two_factor_enabled?: boolean };
442
+ expect(beforeBody.two_factor_enabled).toBe(false);
443
+
444
+ await persistEnrollment(harness.db, user.id, generateTotpSecret("owner").secret);
445
+
446
+ const after = await handleApiMe(
447
+ new Request("http://hub.test/api/me", { headers: { cookie } }),
448
+ { db: harness.db },
449
+ );
450
+ const afterBody = (await after.json()) as { two_factor_enabled?: boolean };
451
+ expect(afterBody.two_factor_enabled).toBe(true);
452
+ });
453
+ });
@@ -323,8 +323,15 @@ describe("POST /api/hub/upgrade — redeploy-required short-circuit (§5.3)", ()
323
323
  });
324
324
 
325
325
  describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", () => {
326
- /** Seed the status file with a prior op in the given phase. */
327
- function seedStatus(dir: string, phase: HubUpgradeStatus["phase"], opId = "prior-op"): void {
326
+ /** Seed the status file with a prior op in the given phase. `startedAt`
327
+ * defaults to now (a FRESH in-flight slot); pass an old ISO string to seed a
328
+ * stale / abandoned slot for the #506 TTL tests. */
329
+ function seedStatus(
330
+ dir: string,
331
+ phase: HubUpgradeStatus["phase"],
332
+ opId = "prior-op",
333
+ startedAt: string = new Date().toISOString(),
334
+ ): void {
328
335
  writeHubUpgradeStatus(dir, {
329
336
  operation_id: opId,
330
337
  phase,
@@ -333,7 +340,7 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
333
340
  target_version: "0.6.3-rc.2",
334
341
  channel: "rc",
335
342
  log: [],
336
- started_at: new Date().toISOString(),
343
+ started_at: startedAt,
337
344
  });
338
345
  }
339
346
 
@@ -385,6 +392,55 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
385
392
  expect(res.status).toBe(202);
386
393
  expect(spawned.length).toBe(1);
387
394
  });
395
+
396
+ // #506: a crashed helper leaves an in-flight slot stuck forever — without a
397
+ // TTL it 409-deadlocks every future upgrade. A STALE in-flight slot must be
398
+ // treated as abandoned so the new request proceeds.
399
+ for (const phase of ["pending", "running", "restarting"] as const) {
400
+ test(`#506: STALE in-flight slot (phase=${phase}, started 30m ago) → proceeds, not 409`, async () => {
401
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
402
+ const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
403
+ seedStatus(harness.dir, phase, "crashed-op", thirtyMinAgo);
404
+ const { deps, spawned } = baseDeps(harness);
405
+ const res = await handleHubUpgrade(
406
+ postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
407
+ deps,
408
+ );
409
+ // Abandoned slot freed: a fresh op took over + spawned its helper.
410
+ expect(res.status).toBe(202);
411
+ expect(spawned.length).toBe(1);
412
+ const status = readHubUpgradeStatus(harness.dir);
413
+ expect(status?.operation_id).not.toBe("crashed-op");
414
+ expect(spawned[0]?.operationId).toBe(status?.operation_id);
415
+ });
416
+ }
417
+
418
+ test("#506: FRESH in-flight slot (started just now) → still 409", async () => {
419
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
420
+ // Just-started (well within the 15m TTL) → a real, live upgrade → 409.
421
+ seedStatus(harness.dir, "running", "live-op", new Date().toISOString());
422
+ const { deps, spawned } = baseDeps(harness);
423
+ const res = await handleHubUpgrade(
424
+ postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
425
+ deps,
426
+ );
427
+ expect(res.status).toBe(409);
428
+ expect(spawned.length).toBe(0);
429
+ expect(readHubUpgradeStatus(harness.dir)?.operation_id).toBe("live-op");
430
+ });
431
+
432
+ test("#506: in-flight slot with a malformed started_at → treated as stale, proceeds", async () => {
433
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
434
+ seedStatus(harness.dir, "running", "garbage-op", "not-a-date");
435
+ const { deps, spawned } = baseDeps(harness);
436
+ const res = await handleHubUpgrade(
437
+ postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
438
+ deps,
439
+ );
440
+ // An unparseable timestamp must not deadlock — treat as abandoned.
441
+ expect(res.status).toBe(202);
442
+ expect(spawned.length).toBe(1);
443
+ });
388
444
  });
389
445
 
390
446
  describe("appendHubUpgradeStatus — operation_id guard (stale-helper isolation)", () => {
@@ -172,6 +172,81 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
172
172
  }
173
173
  });
174
174
 
175
+ // hub#516 parity — the live "mint refused" after `parachute hub set-origin`.
176
+ // An operator/agent credential minted under a PRIOR origin (still a member of
177
+ // the hub's bound-origin set) must keep minting after the canonical issuer
178
+ // switches; the minted token still carries the new canonical issuer.
179
+ describe("multi-origin issuer set (set-origin parity)", () => {
180
+ const TUNNEL = "https://brain.gitcoin.co";
181
+
182
+ test("mints when the bearer's iss is in knownIssuers but ≠ the canonical issuer", async () => {
183
+ const h = makeHarness();
184
+ try {
185
+ const { db, userId } = await bootstrap(h.dir);
186
+ try {
187
+ // Operator token minted under the TUNNEL origin (pre-`set-origin`).
188
+ const op = await mintOperatorToken(db, userId, { issuer: TUNNEL });
189
+ const resp = await handleApiMintToken(
190
+ jsonRequest(
191
+ { scope: "scribe:transcribe", expires_in: 3600 },
192
+ { authorization: `Bearer ${op.token}` },
193
+ ),
194
+ // Canonical issuer is now ISSUER (loopback), but the bound set still
195
+ // includes TUNNEL — the still-valid prior origin.
196
+ { db, issuer: ISSUER, knownIssuers: [ISSUER, TUNNEL] },
197
+ );
198
+ expect(resp.status).toBe(200);
199
+ const body = (await resp.json()) as { token: string };
200
+ // The MINTED token carries the canonical issuer, not the bearer's.
201
+ const validated = await validateAccessToken(db, body.token, ISSUER);
202
+ expect(validated.payload.iss).toBe(ISSUER);
203
+ } finally {
204
+ db.close();
205
+ }
206
+ } finally {
207
+ h.cleanup();
208
+ }
209
+ });
210
+
211
+ test("rejects 401 when the bearer's iss is OUTSIDE knownIssuers", async () => {
212
+ const h = makeHarness();
213
+ try {
214
+ const { db, userId } = await bootstrap(h.dir);
215
+ try {
216
+ const op = await mintOperatorToken(db, userId, { issuer: "https://evil.example.com" });
217
+ const resp = await handleApiMintToken(
218
+ jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
219
+ { db, issuer: ISSUER, knownIssuers: [ISSUER, TUNNEL] },
220
+ );
221
+ expect(resp.status).toBe(401);
222
+ } finally {
223
+ db.close();
224
+ }
225
+ } finally {
226
+ h.cleanup();
227
+ }
228
+ });
229
+
230
+ test("back-compat: without knownIssuers, a non-canonical iss is still rejected", async () => {
231
+ const h = makeHarness();
232
+ try {
233
+ const { db, userId } = await bootstrap(h.dir);
234
+ try {
235
+ const op = await mintOperatorToken(db, userId, { issuer: TUNNEL });
236
+ const resp = await handleApiMintToken(
237
+ jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
238
+ { db, issuer: ISSUER }, // no knownIssuers → falls back to [ISSUER]
239
+ );
240
+ expect(resp.status).toBe(401);
241
+ } finally {
242
+ db.close();
243
+ }
244
+ } finally {
245
+ h.cleanup();
246
+ }
247
+ });
248
+ });
249
+
175
250
  test("happy path: --scope-set=auth narrow operator token also passes the scope gate", async () => {
176
251
  const h = makeHarness();
177
252
  try {