@openparachute/hub 0.7.4-rc.8 → 0.7.4

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 (71) hide show
  1. package/package.json +1 -1
  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-handlers.test.ts +28 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +58 -1
  6. package/src/__tests__/admin-lock.test.ts +33 -1
  7. package/src/__tests__/admin-vaults.test.ts +52 -9
  8. package/src/__tests__/api-account-2fa.test.ts +453 -0
  9. package/src/__tests__/api-mint-token.test.ts +75 -0
  10. package/src/__tests__/api-modules.test.ts +143 -0
  11. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  12. package/src/__tests__/auth.test.ts +336 -0
  13. package/src/__tests__/clients.test.ts +298 -0
  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-settings.test.ts +188 -0
  18. package/src/__tests__/jwt-sign.test.ts +27 -0
  19. package/src/__tests__/oauth-handlers.test.ts +276 -21
  20. package/src/__tests__/oauth-ui.test.ts +52 -0
  21. package/src/__tests__/scope-explanations.test.ts +20 -9
  22. package/src/__tests__/sessions.test.ts +80 -0
  23. package/src/__tests__/setup-gate.test.ts +111 -3
  24. package/src/__tests__/vault-remove.test.ts +40 -19
  25. package/src/__tests__/well-known.test.ts +37 -2
  26. package/src/account-setup.ts +2 -0
  27. package/src/admin-agent-grants.ts +16 -1
  28. package/src/admin-auth.ts +13 -4
  29. package/src/admin-clients.ts +66 -5
  30. package/src/admin-grants.ts +11 -2
  31. package/src/admin-handlers.ts +2 -0
  32. package/src/admin-host-admin-token.ts +24 -1
  33. package/src/admin-lock.ts +16 -0
  34. package/src/admin-vaults.ts +70 -15
  35. package/src/api-account-2fa.ts +395 -0
  36. package/src/api-admin-lock.ts +7 -0
  37. package/src/api-hub-upgrade.ts +14 -1
  38. package/src/api-hub.ts +10 -1
  39. package/src/api-invites.ts +18 -3
  40. package/src/api-me.ts +11 -2
  41. package/src/api-mint-token.ts +16 -1
  42. package/src/api-modules.ts +119 -1
  43. package/src/api-revoke-token.ts +14 -1
  44. package/src/api-settings-hub-origin.ts +14 -1
  45. package/src/api-settings-root-redirect.ts +201 -0
  46. package/src/api-tokens.ts +14 -1
  47. package/src/api-users.ts +15 -6
  48. package/src/api-vault-caps.ts +11 -2
  49. package/src/cli.ts +29 -0
  50. package/src/clients.ts +164 -0
  51. package/src/commands/auth.ts +263 -1
  52. package/src/commands/doctor.ts +1250 -0
  53. package/src/commands/hub.ts +102 -1
  54. package/src/commands/vault-remove.ts +16 -24
  55. package/src/cors.ts +7 -3
  56. package/src/help.ts +53 -0
  57. package/src/hub-db.ts +14 -0
  58. package/src/hub-server.ts +123 -19
  59. package/src/hub-settings.ts +163 -1
  60. package/src/jwt-sign.ts +25 -6
  61. package/src/oauth-handlers.ts +25 -5
  62. package/src/oauth-ui.ts +51 -0
  63. package/src/rate-limit.ts +28 -0
  64. package/src/scope-explanations.ts +23 -9
  65. package/src/sessions.ts +43 -2
  66. package/src/setup-wizard.ts +2 -0
  67. package/src/well-known.ts +10 -1
  68. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  69. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  70. package/web/ui/dist/index.html +2 -2
  71. 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
+ });
@@ -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 {
@@ -6,8 +6,10 @@ import {
6
6
  API_MODULES_CHANNEL_REQUIRED_SCOPE,
7
7
  API_MODULES_REQUIRED_SCOPE,
8
8
  _clearLatestVersionCacheForTests,
9
+ defaultReadInstalledVersion,
9
10
  handleApiModules,
10
11
  handleApiModulesChannel,
12
+ isUpgradeAvailable,
11
13
  } from "../api-modules.ts";
12
14
  import { hubDbPath, openHubDb } from "../hub-db.ts";
13
15
  import { getSetting, setModuleInstallChannel } from "../hub-settings.ts";
@@ -491,6 +493,147 @@ describe("GET /api/modules", () => {
491
493
  expect(scribe?.installed_version).toBeNull();
492
494
  });
493
495
 
496
+ // ── hub#243: upgrade-offer must be semver-aware + installed-version must be live ──
497
+
498
+ type UpgradeWire = {
499
+ short: string;
500
+ installed_version: string | null;
501
+ latest_version: string | null;
502
+ upgrade_available: boolean;
503
+ };
504
+
505
+ async function modulesWith(opts: {
506
+ installedVersion: string;
507
+ latest: string | null;
508
+ readInstalledVersion?: (installDir: string) => string | null;
509
+ }): Promise<UpgradeWire[]> {
510
+ writeManifest(h.manifestPath, [
511
+ {
512
+ name: "parachute-vault",
513
+ port: 1940,
514
+ paths: ["/vault/default"],
515
+ health: "/vault/default/health",
516
+ version: opts.installedVersion,
517
+ installDir: "/install/dir/vault",
518
+ },
519
+ ]);
520
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
521
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
522
+ db: h.db,
523
+ issuer: ISSUER,
524
+ manifestPath: h.manifestPath,
525
+ fetchLatestVersion: async () => opts.latest,
526
+ // Default: no live read (synthetic install dir has no package.json), so
527
+ // the services.json cache is used — matching the prior behavior.
528
+ readInstalledVersion: opts.readInstalledVersion ?? (() => null),
529
+ });
530
+ const body = (await res.json()) as { modules: UpgradeWire[] };
531
+ return body.modules;
532
+ }
533
+
534
+ test("does NOT offer an upgrade when the channel target is OLDER than installed (the live downgrade bug)", async () => {
535
+ // The exact live shape: rc operator installed 0.6.4-rc.15; channel resolved
536
+ // latest_version to the OLDER @latest 0.6.3. Strings differ, but it's a
537
+ // downgrade — upgrade_available MUST be false.
538
+ const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.3" });
539
+ const vault = mods.find((m) => m.short === "vault");
540
+ expect(vault?.installed_version).toBe("0.6.4-rc.15");
541
+ expect(vault?.latest_version).toBe("0.6.3");
542
+ expect(vault?.upgrade_available).toBe(false);
543
+ });
544
+
545
+ test("offers an upgrade for a real rc → newer-rc move", async () => {
546
+ const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.4-rc.16" });
547
+ const vault = mods.find((m) => m.short === "vault");
548
+ expect(vault?.upgrade_available).toBe(true);
549
+ });
550
+
551
+ test("offers an upgrade for rc → its own stable (stable > its rc per semver)", async () => {
552
+ const mods = await modulesWith({ installedVersion: "0.6.4-rc.15", latest: "0.6.4" });
553
+ const vault = mods.find((m) => m.short === "vault");
554
+ expect(vault?.upgrade_available).toBe(true);
555
+ });
556
+
557
+ test("offers an upgrade for a plain stable → newer stable", async () => {
558
+ const mods = await modulesWith({ installedVersion: "0.4.5", latest: "0.5.0" });
559
+ const vault = mods.find((m) => m.short === "vault");
560
+ expect(vault?.upgrade_available).toBe(true);
561
+ });
562
+
563
+ test("no upgrade when installed === latest", async () => {
564
+ const mods = await modulesWith({ installedVersion: "0.5.0", latest: "0.5.0" });
565
+ const vault = mods.find((m) => m.short === "vault");
566
+ expect(vault?.upgrade_available).toBe(false);
567
+ });
568
+
569
+ test("no upgrade when the npm probe failed (latest_version null)", async () => {
570
+ const mods = await modulesWith({ installedVersion: "0.5.0", latest: null });
571
+ const vault = mods.find((m) => m.short === "vault");
572
+ expect(vault?.latest_version).toBeNull();
573
+ expect(vault?.upgrade_available).toBe(false);
574
+ });
575
+
576
+ test("installed_version reflects the LIVE on-disk version, not a stale services.json cache (hub#243)", async () => {
577
+ // services.json cache lags the bun-linked checkout: cache says 0.5.4-rc.15
578
+ // (the live symptom) while package.json on disk is already 0.6.4-rc.15.
579
+ // The admin view must show what's actually installed.
580
+ const mods = await modulesWith({
581
+ installedVersion: "0.5.4-rc.15",
582
+ latest: "0.6.3",
583
+ readInstalledVersion: (dir) => (dir === "/install/dir/vault" ? "0.6.4-rc.15" : null),
584
+ });
585
+ const vault = mods.find((m) => m.short === "vault");
586
+ expect(vault?.installed_version).toBe("0.6.4-rc.15");
587
+ // And with the corrected current, @latest 0.6.3 is still a downgrade → no offer.
588
+ expect(vault?.upgrade_available).toBe(false);
589
+ });
590
+
591
+ test("falls back to the services.json version when the live read returns null", async () => {
592
+ const mods = await modulesWith({
593
+ installedVersion: "0.6.4-rc.15",
594
+ latest: "0.6.4-rc.16",
595
+ readInstalledVersion: () => null,
596
+ });
597
+ const vault = mods.find((m) => m.short === "vault");
598
+ expect(vault?.installed_version).toBe("0.6.4-rc.15");
599
+ expect(vault?.upgrade_available).toBe(true);
600
+ });
601
+
602
+ test("isUpgradeAvailable: semver-aware, fail-closed on unparseable + nulls", () => {
603
+ // strictly-newer → true
604
+ expect(isUpgradeAvailable("0.4.5", "0.5.0")).toBe(true);
605
+ expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.4-rc.16")).toBe(true);
606
+ expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.4")).toBe(true); // stable > its rc
607
+ // same / older → false
608
+ expect(isUpgradeAvailable("0.5.0", "0.5.0")).toBe(false);
609
+ expect(isUpgradeAvailable("0.6.4-rc.15", "0.6.3")).toBe(false); // the live downgrade
610
+ expect(isUpgradeAvailable("0.6.4", "0.6.4-rc.15")).toBe(false); // stable → its rc
611
+ // nulls → false (not installed / probe failed)
612
+ expect(isUpgradeAvailable(null, "0.5.0")).toBe(false);
613
+ expect(isUpgradeAvailable("0.5.0", null)).toBe(false);
614
+ // unparseable → false (fail-closed: never offer a move we can't verify)
615
+ expect(isUpgradeAvailable("not-a-version", "0.5.0")).toBe(false);
616
+ expect(isUpgradeAvailable("0.5.0", "garbage")).toBe(false);
617
+ });
618
+
619
+ test("defaultReadInstalledVersion reads package.json version + tolerates missing/bad files", () => {
620
+ const tmp = mkdtempSync(join(tmpdir(), "phub-live-ver-"));
621
+ try {
622
+ writeFileSync(join(tmp, "package.json"), JSON.stringify({ version: "0.6.4-rc.15" }));
623
+ expect(defaultReadInstalledVersion(tmp)).toBe("0.6.4-rc.15");
624
+ // Missing dir / no package.json → null.
625
+ expect(defaultReadInstalledVersion(join(tmp, "does-not-exist"))).toBeNull();
626
+ // Malformed JSON → null (no throw).
627
+ writeFileSync(join(tmp, "package.json"), "{ not json");
628
+ expect(defaultReadInstalledVersion(tmp)).toBeNull();
629
+ // No version field → null.
630
+ writeFileSync(join(tmp, "package.json"), JSON.stringify({ name: "x" }));
631
+ expect(defaultReadInstalledVersion(tmp)).toBeNull();
632
+ } finally {
633
+ rmSync(tmp, { recursive: true, force: true });
634
+ }
635
+ });
636
+
494
637
  test("includes supervisor status + pid when a supervisor is injected", async () => {
495
638
  writeManifest(h.manifestPath, [
496
639
  {