@openparachute/hub 0.5.10-rc.6 → 0.5.10

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +139 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-server.test.ts +29 -4
  10. package/src/__tests__/hub-settings.test.ts +377 -0
  11. package/src/__tests__/hub.test.ts +17 -0
  12. package/src/__tests__/jwt-sign.test.ts +59 -0
  13. package/src/__tests__/oauth-handlers.test.ts +1059 -10
  14. package/src/__tests__/oauth-ui.test.ts +210 -0
  15. package/src/__tests__/scope-explanations.test.ts +23 -0
  16. package/src/__tests__/serve.test.ts +8 -1
  17. package/src/__tests__/setup-wizard.test.ts +1500 -13
  18. package/src/__tests__/supervisor.test.ts +76 -2
  19. package/src/__tests__/users.test.ts +196 -0
  20. package/src/__tests__/vault-name.test.ts +79 -0
  21. package/src/__tests__/vault-names.test.ts +172 -0
  22. package/src/account-change-password-ui.ts +379 -0
  23. package/src/admin-handlers.ts +68 -2
  24. package/src/admin-host-admin-token.ts +5 -0
  25. package/src/admin-vault-admin-token.ts +7 -0
  26. package/src/api-account.ts +443 -0
  27. package/src/api-mint-token.ts +6 -0
  28. package/src/api-modules-ops.ts +30 -6
  29. package/src/api-modules.ts +101 -0
  30. package/src/api-users.ts +393 -0
  31. package/src/commands/auth.ts +10 -1
  32. package/src/commands/serve.ts +5 -1
  33. package/src/cors.ts +263 -0
  34. package/src/hub-db.ts +54 -0
  35. package/src/hub-server.ts +162 -18
  36. package/src/hub-settings.ts +259 -0
  37. package/src/hub.ts +34 -9
  38. package/src/jwt-sign.ts +17 -1
  39. package/src/oauth-handlers.ts +256 -29
  40. package/src/oauth-ui.ts +451 -38
  41. package/src/operator-token.ts +4 -0
  42. package/src/scope-explanations.ts +26 -1
  43. package/src/setup-wizard.ts +1100 -56
  44. package/src/supervisor.ts +66 -14
  45. package/src/users.ts +210 -3
  46. package/src/vault-name.ts +71 -0
  47. package/src/vault-names.ts +57 -0
  48. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  49. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  50. package/web/ui/dist/index.html +2 -2
  51. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
@@ -6,6 +6,12 @@ import { join } from "node:path";
6
6
  import { getClient, registerClient } from "../clients.ts";
7
7
  import { CSRF_COOKIE_NAME } from "../csrf.ts";
8
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
9
+ import {
10
+ FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
11
+ getSetting,
12
+ openFirstClientAutoApproveWindow,
13
+ setSetting,
14
+ } from "../hub-settings.ts";
9
15
  import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
10
16
  import {
11
17
  authorizationServerMetadata,
@@ -227,6 +233,142 @@ describe("handleAuthorizeGet", () => {
227
233
  );
228
234
  const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
229
235
  expect(res.status).toBe(400);
236
+ const body = await res.text();
237
+ expect(body).toContain("Unknown application");
238
+ // Cross-origin redirect_uri → no recovery affordance. The page must
239
+ // not include the inline JS reset block; we can't safely interact
240
+ // with a third-party SPA's storage from this page.
241
+ expect(body).not.toContain("unknown-client-reset");
242
+ expect(body).not.toContain("lens:dcr:");
243
+ } finally {
244
+ cleanup();
245
+ }
246
+ });
247
+
248
+ test("unknown client_id with self-origin redirect_uri renders recovery affordance (hub#fresh-machine-connect)", async () => {
249
+ // The canonical fresh-machine-stale-localStorage repro: notes' SPA
250
+ // is mounted at the hub's own origin, holds a cached client_id from
251
+ // a previous hub.db, and lands on /oauth/authorize with the dangling
252
+ // id. Hub recognizes the redirect_uri as one of its bound origins and
253
+ // surfaces a one-click recovery: the inline JS clears the SPA's DCR
254
+ // localStorage cache (any `lens:dcr:*` key) and navigates to the
255
+ // redirect_uri's pathname for a fresh DCR pass.
256
+ const { db, cleanup } = await makeDb();
257
+ try {
258
+ const { challenge } = makePkce();
259
+ const selfRedirect = `${ISSUER}/notes/oauth/callback`;
260
+ const req = new Request(
261
+ authorizeUrl({
262
+ client_id: "stale-dangling-id",
263
+ redirect_uri: selfRedirect,
264
+ response_type: "code",
265
+ code_challenge: challenge,
266
+ code_challenge_method: "S256",
267
+ }),
268
+ );
269
+ const res = handleAuthorizeGet(db, req, {
270
+ issuer: ISSUER,
271
+ hubBoundOrigins: () => [ISSUER],
272
+ });
273
+ expect(res.status).toBe(400);
274
+ const body = await res.text();
275
+ expect(body).toContain("Unknown application");
276
+ expect(body).toContain("stale-dangling-id");
277
+ // Recovery affordance is present.
278
+ expect(body).toContain("unknown-client-reset");
279
+ // The reset target is the redirect_uri's pathname only (not the
280
+ // full URL — we never surface a cross-origin redirect even when
281
+ // redirect_uri claims to be ours).
282
+ expect(body).toContain('data-target="/notes/oauth/callback"');
283
+ // The inline JS clears the SPA's known DCR cache prefix.
284
+ expect(body).toContain("lens:dcr:");
285
+ } finally {
286
+ cleanup();
287
+ }
288
+ });
289
+
290
+ test("unknown client_id with redirect_uri on unbound origin falls back to static error", async () => {
291
+ // hubBoundOrigins lists only the canonical hub origin; a redirect_uri
292
+ // pointing somewhere else (third-party SPA, attacker probe) MUST NOT
293
+ // surface the recovery JS — that JS only makes sense for SPAs we
294
+ // ourselves host.
295
+ const { db, cleanup } = await makeDb();
296
+ try {
297
+ const { challenge } = makePkce();
298
+ const req = new Request(
299
+ authorizeUrl({
300
+ client_id: "stale-id",
301
+ redirect_uri: "https://attacker.example/cb",
302
+ response_type: "code",
303
+ code_challenge: challenge,
304
+ code_challenge_method: "S256",
305
+ }),
306
+ );
307
+ const res = handleAuthorizeGet(db, req, {
308
+ issuer: ISSUER,
309
+ hubBoundOrigins: () => [ISSUER],
310
+ });
311
+ expect(res.status).toBe(400);
312
+ const body = await res.text();
313
+ expect(body).toContain("Unknown application");
314
+ expect(body).not.toContain("unknown-client-reset");
315
+ expect(body).not.toContain("lens:dcr:");
316
+ expect(body).not.toContain("attacker.example");
317
+ } finally {
318
+ cleanup();
319
+ }
320
+ });
321
+
322
+ test("unknown client_id with malformed redirect_uri falls back to static error", async () => {
323
+ const { db, cleanup } = await makeDb();
324
+ try {
325
+ const { challenge } = makePkce();
326
+ const req = new Request(
327
+ authorizeUrl({
328
+ client_id: "stale-id",
329
+ // Validated as non-empty by parseAuthorizeFormParams but not
330
+ // URL-parsed there; the unknown-client renderer must handle
331
+ // its own parsing safely.
332
+ redirect_uri: "not-a-valid-url",
333
+ response_type: "code",
334
+ code_challenge: challenge,
335
+ code_challenge_method: "S256",
336
+ }),
337
+ );
338
+ const res = handleAuthorizeGet(db, req, {
339
+ issuer: ISSUER,
340
+ hubBoundOrigins: () => [ISSUER],
341
+ });
342
+ expect(res.status).toBe(400);
343
+ const body = await res.text();
344
+ expect(body).toContain("Unknown application");
345
+ expect(body).not.toContain("unknown-client-reset");
346
+ } finally {
347
+ cleanup();
348
+ }
349
+ });
350
+
351
+ test("unknown client_id falls back to static error when hubBoundOrigins is unset", async () => {
352
+ // Pre-#245 callers don't thread hubBoundOrigins; the gate falls back
353
+ // to `[issuer]` so a single-origin hub still surfaces the recovery
354
+ // affordance for its own redirect_uris. Verify that path.
355
+ const { db, cleanup } = await makeDb();
356
+ try {
357
+ const { challenge } = makePkce();
358
+ const req = new Request(
359
+ authorizeUrl({
360
+ client_id: "stale-id",
361
+ redirect_uri: `${ISSUER}/notes/oauth/callback`,
362
+ response_type: "code",
363
+ code_challenge: challenge,
364
+ code_challenge_method: "S256",
365
+ }),
366
+ );
367
+ // No hubBoundOrigins → falls back to [issuer], which still matches.
368
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
369
+ expect(res.status).toBe(400);
370
+ const body = await res.text();
371
+ expect(body).toContain("unknown-client-reset");
230
372
  } finally {
231
373
  cleanup();
232
374
  }
@@ -880,6 +1022,58 @@ describe("handleAuthorizePost — consent submit", () => {
880
1022
  }
881
1023
  });
882
1024
 
1025
+ test("race-condition branch (client un-approved between GET and POST) — error points at web approval path, no CLI mention", async () => {
1026
+ // Defensive branch in handleConsentSubmit: consent only renders for
1027
+ // approved clients, but a row can flip back to pending between GET and
1028
+ // POST (operator revoke / hand-crafted POST). Pre-rc.19 follow-up the
1029
+ // error said "Run `parachute auth approve-client <id>` from a terminal";
1030
+ // rc.19 retired every browser-visible CLI mention, so this branch now
1031
+ // surfaces the same /admin/approve-client/<id> path the unauth GET-on-
1032
+ // pending page advertises.
1033
+ const { db, cleanup } = await makeDb();
1034
+ try {
1035
+ const user = await createUser(db, "owner", "pw");
1036
+ const session = createSession(db, { userId: user.id });
1037
+ const reg = registerClient(db, {
1038
+ redirectUris: ["https://app.example/cb"],
1039
+ status: "pending",
1040
+ });
1041
+ const { challenge } = makePkce();
1042
+ const form = new URLSearchParams({
1043
+ __action: "consent",
1044
+ __csrf: TEST_CSRF,
1045
+ approve: "yes",
1046
+ client_id: reg.client.clientId,
1047
+ redirect_uri: "https://app.example/cb",
1048
+ response_type: "code",
1049
+ scope: "vault:default:read",
1050
+ code_challenge: challenge,
1051
+ code_challenge_method: "S256",
1052
+ state: "race",
1053
+ });
1054
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
1055
+ method: "POST",
1056
+ body: form,
1057
+ headers: {
1058
+ "content-type": "application/x-www-form-urlencoded",
1059
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
1060
+ },
1061
+ });
1062
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
1063
+ expect(res.status).toBe(403);
1064
+ const html = await res.text();
1065
+ expect(html).toContain("App not yet approved");
1066
+ // Web path advertised, with the client_id rendered inline.
1067
+ expect(html).toContain(`/admin/approve-client/${reg.client.clientId}`);
1068
+ expect(html).toContain("Sign in as admin");
1069
+ // CLI mention retired from every browser-visible surface in rc.19.
1070
+ expect(html).not.toContain("parachute auth approve-client");
1071
+ expect(html).not.toContain("from a terminal");
1072
+ } finally {
1073
+ cleanup();
1074
+ }
1075
+ });
1076
+
883
1077
  test("rejects parachute:host:admin in form scope (defense-in-depth, #96)", async () => {
884
1078
  // GET-time gate already rejects, but a hand-crafted POST could carry
885
1079
  // an operator-only scope. Consent submit must independently reject.
@@ -955,6 +1149,57 @@ describe("handleAuthorizePost — consent submit", () => {
955
1149
  cleanup();
956
1150
  }
957
1151
  });
1152
+
1153
+ test("consent POST with unknown client_id + self-origin redirect_uri renders recovery affordance", async () => {
1154
+ // Symmetry with the GET-path coverage of the same hub#277 recovery
1155
+ // affordance. handleAuthorizePost's consent submit routes the
1156
+ // `getClient = null` branch through the same `unknownClientResponse`
1157
+ // helper as the GET path; pin it explicitly so a future refactor
1158
+ // can't silently drop the recovery path here. Reaching this branch
1159
+ // on the consent POST means the client_id was deleted between
1160
+ // render and submit (vanishingly rare in practice — exercised here
1161
+ // by registering nothing for the carried `client_id`).
1162
+ const { db, cleanup } = await makeDb();
1163
+ try {
1164
+ const user = await createUser(db, "owner", "pw");
1165
+ const session = createSession(db, { userId: user.id });
1166
+ const { challenge } = makePkce();
1167
+ const form = new URLSearchParams({
1168
+ __action: "consent",
1169
+ __csrf: TEST_CSRF,
1170
+ approve: "yes",
1171
+ client_id: "stale-dangling-id",
1172
+ redirect_uri: `${ISSUER}/notes/oauth/callback`,
1173
+ response_type: "code",
1174
+ scope: "vault:read",
1175
+ code_challenge: challenge,
1176
+ code_challenge_method: "S256",
1177
+ state: "abc",
1178
+ });
1179
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
1180
+ method: "POST",
1181
+ body: form,
1182
+ headers: {
1183
+ "content-type": "application/x-www-form-urlencoded",
1184
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
1185
+ },
1186
+ });
1187
+ const res = await handleAuthorizePost(db, req, {
1188
+ issuer: ISSUER,
1189
+ hubBoundOrigins: () => [ISSUER],
1190
+ });
1191
+ expect(res.status).toBe(400);
1192
+ const body = await res.text();
1193
+ expect(body).toContain("Unknown application");
1194
+ expect(body).toContain("stale-dangling-id");
1195
+ // Recovery affordance — same shape as the GET-path tests above.
1196
+ expect(body).toContain("unknown-client-reset");
1197
+ expect(body).toContain('data-target="/notes/oauth/callback"');
1198
+ expect(body).toContain("lens:dcr:");
1199
+ } finally {
1200
+ cleanup();
1201
+ }
1202
+ });
958
1203
  });
959
1204
 
960
1205
  describe("handleToken — full OAuth dance", () => {
@@ -2230,7 +2475,9 @@ describe("DCR approval gate (#74)", () => {
2230
2475
  expect(res.status).toBe(403);
2231
2476
  const html = await res.text();
2232
2477
  expect(html).toContain("App not yet approved");
2233
- expect(html).toContain("approve-client");
2478
+ // /admin/approve-client/<id> deep link is the canonical recovery now
2479
+ // (the pre-rc.19 CLI message was retired in favor of the web path).
2480
+ expect(html).toContain(`/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`);
2234
2481
  // No vault hint → no vault row in approve-meta. Single-vault hubs +
2235
2482
  // pre-vault-popover clients leave the section omitted (#244).
2236
2483
  expect(html).not.toContain('approve-meta-label">vault');
@@ -3723,10 +3970,13 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3723
3970
  });
3724
3971
  }
3725
3972
 
3726
- test("session absent → page renders WITHOUT approve form (CLI-only fallback)", async () => {
3727
- // Regression: pre-#208 behavior preserved when no session cookie is
3728
- // present. The CLI-fallback message must still be visible so an operator
3729
- // who arrived from a fresh browser knows what to do.
3973
+ test("session absent → page renders Sign-in CTA + shareable deep link (no CLI message)", async () => {
3974
+ // Approval-UX rc.19: the unauthenticated viewer no longer sees a
3975
+ // "Ask the operator to run `parachute auth approve-client <id>`"
3976
+ // message. The web approval path (#277) is the canonical recovery
3977
+ // now — render a primary Sign-in CTA wired to /login?next=/admin/...
3978
+ // and a shareable deep link the operator can send to whoever runs
3979
+ // the hub.
3730
3980
  const { db, cleanup } = await makeDb();
3731
3981
  try {
3732
3982
  const reg = registerClient(db, {
@@ -3739,9 +3989,23 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3739
3989
  expect(res.status).toBe(403);
3740
3990
  const html = await res.text();
3741
3991
  expect(html).toContain("App not yet approved");
3742
- // CLI-fallback message present the only way to recover without a session.
3743
- expect(html).toContain("approve-client");
3744
- // No form element pointing at the approve endpoint.
3992
+ // Primary CTA: Sign-in link wired to land the admin directly on
3993
+ // the approval page after authenticating.
3994
+ expect(html).toContain("Sign in as admin to approve");
3995
+ const expectedLoginHref = `/login?next=${encodeURIComponent(
3996
+ `/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
3997
+ )}`;
3998
+ expect(html).toContain(`href="${expectedLoginHref}"`);
3999
+ // Secondary CTA: shareable, fully-qualified deep link + Copy button.
4000
+ expect(html).toContain(
4001
+ `${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
4002
+ );
4003
+ expect(html).toContain('id="approve-share-copy"');
4004
+ expect(html).toContain("navigator.clipboard");
4005
+ // Retired CLI hint must not appear anywhere in the body.
4006
+ expect(html).not.toContain("parachute auth approve-client");
4007
+ expect(html).not.toContain("from a terminal");
4008
+ // No form element pointing at the approve endpoint (un-authed branch).
3745
4009
  expect(html).not.toContain('action="/oauth/authorize/approve"');
3746
4010
  } finally {
3747
4011
  cleanup();
@@ -3783,8 +4047,12 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3783
4047
  expect(html).toContain("MyApp");
3784
4048
  expect(html).toContain(reg.client.clientId);
3785
4049
  expect(html).toContain("https://app.example/cb");
3786
- // CLI fallback still visible.
3787
- expect(html).toContain("approve-client");
4050
+ // Authed branch shows only the one-click Approve form — the unauth
4051
+ // Sign-in CTA and shareable-link block do NOT render here.
4052
+ expect(html).not.toContain("Sign in as admin to approve");
4053
+ expect(html).not.toContain("Or send this link to your hub admin");
4054
+ // CLI hint also gone in this branch (approval-UX rc.19).
4055
+ expect(html).not.toContain("parachute auth approve-client");
3788
4056
  } finally {
3789
4057
  cleanup();
3790
4058
  }
@@ -4216,3 +4484,784 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
4216
4484
  }
4217
4485
  });
4218
4486
  });
4487
+
4488
+ // DCR first-client auto-approve window (hub#268 Item 3). The wizard's
4489
+ // expose-step POST opens a 60-minute window where the very next
4490
+ // `/oauth/register` registration is auto-approved + the window cleared.
4491
+ // Single-use: client #2 within the same window falls through to the
4492
+ // standard pending-approval flow.
4493
+ describe("DCR first-client auto-approve window (hub#268 Item 3)", () => {
4494
+ function registerRequest(): Request {
4495
+ return new Request(`${ISSUER}/oauth/register`, {
4496
+ method: "POST",
4497
+ body: JSON.stringify({
4498
+ redirect_uris: ["https://app.example/cb"],
4499
+ client_name: "first-client",
4500
+ }),
4501
+ headers: { "content-type": "application/json" },
4502
+ });
4503
+ }
4504
+
4505
+ test("client registered within the open window → status approved + window cleared", async () => {
4506
+ const { db, cleanup } = await makeDb();
4507
+ try {
4508
+ const t0 = new Date("2026-05-19T00:00:00.000Z");
4509
+ openFirstClientAutoApproveWindow(db, () => t0);
4510
+ const res = await handleRegister(db, registerRequest(), {
4511
+ issuer: ISSUER,
4512
+ now: () => t0,
4513
+ });
4514
+ expect(res.status).toBe(201);
4515
+ const body = (await res.json()) as Record<string, unknown>;
4516
+ expect(body.status).toBe("approved");
4517
+ // Persisted, not just response-shaped.
4518
+ const row = getClient(db, body.client_id as string);
4519
+ expect(row?.status).toBe("approved");
4520
+ // Window cleared on consume (single-use).
4521
+ expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
4522
+ } finally {
4523
+ cleanup();
4524
+ }
4525
+ });
4526
+
4527
+ test("client registered AFTER the window has expired → status pending", async () => {
4528
+ const { db, cleanup } = await makeDb();
4529
+ try {
4530
+ const t0 = new Date("2026-05-19T00:00:00.000Z");
4531
+ openFirstClientAutoApproveWindow(db, () => t0);
4532
+ const past = new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1);
4533
+ const res = await handleRegister(db, registerRequest(), {
4534
+ issuer: ISSUER,
4535
+ now: () => past,
4536
+ });
4537
+ expect(res.status).toBe(201);
4538
+ const body = (await res.json()) as Record<string, unknown>;
4539
+ expect(body.status).toBe("pending");
4540
+ } finally {
4541
+ cleanup();
4542
+ }
4543
+ });
4544
+
4545
+ test("second client within window after first auto-approved → status pending (single-use)", async () => {
4546
+ const { db, cleanup } = await makeDb();
4547
+ try {
4548
+ const t0 = new Date("2026-05-19T00:00:00.000Z");
4549
+ openFirstClientAutoApproveWindow(db, () => t0);
4550
+ // Client #1: approved.
4551
+ const res1 = await handleRegister(db, registerRequest(), {
4552
+ issuer: ISSUER,
4553
+ now: () => t0,
4554
+ });
4555
+ const body1 = (await res1.json()) as Record<string, unknown>;
4556
+ expect(body1.status).toBe("approved");
4557
+ // Client #2 within the (still-not-expired) window: pending.
4558
+ const stillWithinWindow = new Date(t0.getTime() + 30 * 60 * 1000);
4559
+ const res2 = await handleRegister(db, registerRequest(), {
4560
+ issuer: ISSUER,
4561
+ now: () => stillWithinWindow,
4562
+ });
4563
+ const body2 = (await res2.json()) as Record<string, unknown>;
4564
+ expect(body2.status).toBe("pending");
4565
+ } finally {
4566
+ cleanup();
4567
+ }
4568
+ });
4569
+
4570
+ test("no window set → status pending (default public-DCR flow)", async () => {
4571
+ const { db, cleanup } = await makeDb();
4572
+ try {
4573
+ const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
4574
+ const body = (await res.json()) as Record<string, unknown>;
4575
+ expect(body.status).toBe("pending");
4576
+ // Settings row untouched.
4577
+ expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
4578
+ } finally {
4579
+ cleanup();
4580
+ }
4581
+ });
4582
+
4583
+ test("operator-bearer auto-approve still takes precedence over the window (no double-consume)", async () => {
4584
+ // Bearer-authenticated registration approves directly; the
4585
+ // auto-approve window should NOT be consumed in that case — it's
4586
+ // still available for the first un-authenticated client.
4587
+ const { db, cleanup } = await makeDb();
4588
+ try {
4589
+ const t0 = new Date("2026-05-19T00:00:00.000Z");
4590
+ openFirstClientAutoApproveWindow(db, () => t0);
4591
+ // We can't easily mint an operator bearer in this test layer, so
4592
+ // simulate by using the session-cookie path (issuer-trusted) which
4593
+ // also auto-approves before falling through to the window check.
4594
+ const user = await createUser(db, "owner", "pw");
4595
+ const session = createSession(db, { userId: user.id });
4596
+ const req = new Request(`${ISSUER}/oauth/register`, {
4597
+ method: "POST",
4598
+ body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
4599
+ headers: {
4600
+ "content-type": "application/json",
4601
+ cookie: buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)),
4602
+ origin: ISSUER,
4603
+ },
4604
+ });
4605
+ const res = await handleRegister(db, req, { issuer: ISSUER, now: () => t0 });
4606
+ const body = (await res.json()) as Record<string, unknown>;
4607
+ expect(body.status).toBe("approved");
4608
+ // Window NOT consumed — still set, still open. The session-cookie
4609
+ // path approved first, never reaching the window-consume code.
4610
+ expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
4611
+ } finally {
4612
+ cleanup();
4613
+ }
4614
+ });
4615
+
4616
+ test("malformed timestamp in the setting → treated as no-window, status pending", async () => {
4617
+ const { db, cleanup } = await makeDb();
4618
+ try {
4619
+ setSetting(db, "pending_first_client_auto_approve_until", "not-a-real-iso-string");
4620
+ const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
4621
+ const body = (await res.json()) as Record<string, unknown>;
4622
+ expect(body.status).toBe("pending");
4623
+ } finally {
4624
+ cleanup();
4625
+ }
4626
+ });
4627
+ });
4628
+
4629
+ // Multi-user Phase 1, PR 4 (design 2026-05-20-multi-user-phase-1.md, hub#252):
4630
+ // non-admin users (with `assigned_vault` non-null) see the consent picker
4631
+ // locked, and the OAuth issuer mints tokens carrying `vault_scope: [<assigned>]`.
4632
+ // Server-side defense refuses any mint whose picked vault disagrees.
4633
+ describe("handleAuthorizeGet — multi-user assigned vault picker lock (PR 4)", () => {
4634
+ test("admin user (assigned_vault null) sees the free dropdown", async () => {
4635
+ const { db, cleanup } = await makeDb();
4636
+ try {
4637
+ const admin = await createUser(db, "admin-aaron", "pw");
4638
+ const session = createSession(db, { userId: admin.id });
4639
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4640
+ const { challenge } = makePkce();
4641
+ const req = new Request(
4642
+ authorizeUrl({
4643
+ client_id: reg.client.clientId,
4644
+ redirect_uri: "https://app.example/cb",
4645
+ response_type: "code",
4646
+ code_challenge: challenge,
4647
+ code_challenge_method: "S256",
4648
+ scope: "vault:read",
4649
+ }),
4650
+ {
4651
+ headers: {
4652
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
4653
+ },
4654
+ },
4655
+ );
4656
+ const res = handleAuthorizeGet(db, req, {
4657
+ issuer: ISSUER,
4658
+ loadServicesManifest: fixtureLoadServicesManifest,
4659
+ });
4660
+ expect(res.status).toBe(200);
4661
+ const html = await res.text();
4662
+ // Free dropdown for admin: radio inputs present, no "Assigned vault" lock.
4663
+ expect(html).toContain('name="vault_pick" value="default"');
4664
+ expect(html).not.toContain("Assigned vault");
4665
+ expect(html).not.toContain("admin-managed");
4666
+ } finally {
4667
+ cleanup();
4668
+ }
4669
+ });
4670
+
4671
+ test("non-admin user (assigned_vault set) sees the locked picker with admin-managed note", async () => {
4672
+ const { db, cleanup } = await makeDb();
4673
+ try {
4674
+ const admin = await createUser(db, "admin-aaron", "pw");
4675
+ const bob = await createUser(db, "bob", "pw", {
4676
+ allowMulti: true,
4677
+ assignedVault: "default",
4678
+ });
4679
+ void admin;
4680
+ const session = createSession(db, { userId: bob.id });
4681
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4682
+ const { challenge } = makePkce();
4683
+ const req = new Request(
4684
+ authorizeUrl({
4685
+ client_id: reg.client.clientId,
4686
+ redirect_uri: "https://app.example/cb",
4687
+ response_type: "code",
4688
+ code_challenge: challenge,
4689
+ code_challenge_method: "S256",
4690
+ scope: "vault:read",
4691
+ }),
4692
+ {
4693
+ headers: {
4694
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
4695
+ },
4696
+ },
4697
+ );
4698
+ const res = handleAuthorizeGet(db, req, {
4699
+ issuer: ISSUER,
4700
+ loadServicesManifest: fixtureLoadServicesManifest,
4701
+ });
4702
+ expect(res.status).toBe(200);
4703
+ const html = await res.text();
4704
+ expect(html).toContain("vault-picker-locked");
4705
+ expect(html).toContain("Assigned vault");
4706
+ expect(html).toContain("admin-managed");
4707
+ // Hidden input carries the assigned vault as the picker value.
4708
+ expect(html).toContain('<input type="hidden" name="vault_pick" value="default"');
4709
+ // No free-choice radio inputs.
4710
+ expect(html).not.toContain('type="radio" name="vault_pick"');
4711
+ } finally {
4712
+ cleanup();
4713
+ }
4714
+ });
4715
+ });
4716
+
4717
+ // Approval-UX rc.19 (Issue 2 in Aaron's bundle): the consent screen now
4718
+ // renders the *resolved* scope shape — `vault:<name>:<verb>` — instead of
4719
+ // the raw OAuth request `vault:<verb>`. The raw form was confusing because
4720
+ // it implied vault-wide unrestricted access, when hub actually narrows to
4721
+ // a specific vault at token-mint via the picker (or the user's
4722
+ // assigned_vault for multi-user setups).
4723
+ describe("handleAuthorizeGet — resolved scope display (approval-UX rc.19)", () => {
4724
+ test("non-admin user (assigned_vault set) sees vault:<assigned>:read on consent, not raw vault:read", async () => {
4725
+ const { db, cleanup } = await makeDb();
4726
+ try {
4727
+ const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
4728
+ const session = createSession(db, { userId: bob.id });
4729
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4730
+ const { challenge } = makePkce();
4731
+ const req = new Request(
4732
+ authorizeUrl({
4733
+ client_id: reg.client.clientId,
4734
+ redirect_uri: "https://app.example/cb",
4735
+ response_type: "code",
4736
+ code_challenge: challenge,
4737
+ code_challenge_method: "S256",
4738
+ scope: "vault:read",
4739
+ }),
4740
+ {
4741
+ headers: {
4742
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
4743
+ },
4744
+ },
4745
+ );
4746
+ const res = handleAuthorizeGet(db, req, {
4747
+ issuer: ISSUER,
4748
+ loadServicesManifest: fixtureLoadServicesManifest,
4749
+ });
4750
+ expect(res.status).toBe(200);
4751
+ const html = await res.text();
4752
+ // Resolved form rendered in the scope-row code block.
4753
+ expect(html).toContain('<code class="scope-name">vault:default:read</code>');
4754
+ // Raw unnamed form must NOT appear inside a scope row (it still
4755
+ // appears in the hidden form-roundtrip inputs as `name="scope" value="vault:read"`).
4756
+ expect(html).not.toContain('<code class="scope-name">vault:read</code>');
4757
+ } finally {
4758
+ cleanup();
4759
+ }
4760
+ });
4761
+
4762
+ test("admin user with picker — single-vault hub pre-checks and consent shows that vault", async () => {
4763
+ const { db, cleanup } = await makeDb();
4764
+ try {
4765
+ const admin = await createUser(db, "admin-aaron", "pw");
4766
+ const session = createSession(db, { userId: admin.id });
4767
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4768
+ const { challenge } = makePkce();
4769
+ const req = new Request(
4770
+ authorizeUrl({
4771
+ client_id: reg.client.clientId,
4772
+ redirect_uri: "https://app.example/cb",
4773
+ response_type: "code",
4774
+ code_challenge: challenge,
4775
+ code_challenge_method: "S256",
4776
+ scope: "vault:read",
4777
+ }),
4778
+ {
4779
+ headers: {
4780
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
4781
+ },
4782
+ },
4783
+ );
4784
+ const res = handleAuthorizeGet(db, req, {
4785
+ issuer: ISSUER,
4786
+ loadServicesManifest: fixtureLoadServicesManifest,
4787
+ });
4788
+ expect(res.status).toBe(200);
4789
+ const html = await res.text();
4790
+ // The fixture services manifest has a single vault named "default" — the
4791
+ // picker pre-checks it and the consent screen renders the resolved form.
4792
+ expect(html).toContain('<code class="scope-name">vault:default:read</code>');
4793
+ } finally {
4794
+ cleanup();
4795
+ }
4796
+ });
4797
+ });
4798
+
4799
+ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", () => {
4800
+ test("non-admin happy path: token carries vault_scope=[assigned] and narrowed scope", async () => {
4801
+ const { db, cleanup } = await makeDb();
4802
+ try {
4803
+ const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
4804
+ const session = createSession(db, { userId: bob.id });
4805
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4806
+ const { verifier, challenge } = makePkce();
4807
+ const consentForm = new URLSearchParams({
4808
+ __action: "consent",
4809
+ __csrf: TEST_CSRF,
4810
+ approve: "yes",
4811
+ client_id: reg.client.clientId,
4812
+ redirect_uri: "https://app.example/cb",
4813
+ response_type: "code",
4814
+ scope: "vault:read",
4815
+ code_challenge: challenge,
4816
+ code_challenge_method: "S256",
4817
+ vault_pick: "default",
4818
+ });
4819
+ const consentRes = await handleAuthorizePost(
4820
+ db,
4821
+ new Request(`${ISSUER}/oauth/authorize`, {
4822
+ method: "POST",
4823
+ body: consentForm,
4824
+ headers: {
4825
+ "content-type": "application/x-www-form-urlencoded",
4826
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
4827
+ },
4828
+ }),
4829
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
4830
+ );
4831
+ expect(consentRes.status).toBe(302);
4832
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
4833
+ const tokenRes = await handleToken(
4834
+ db,
4835
+ new Request(`${ISSUER}/oauth/token`, {
4836
+ method: "POST",
4837
+ body: new URLSearchParams({
4838
+ grant_type: "authorization_code",
4839
+ code: code ?? "",
4840
+ client_id: reg.client.clientId,
4841
+ redirect_uri: "https://app.example/cb",
4842
+ code_verifier: verifier,
4843
+ }),
4844
+ headers: { "content-type": "application/x-www-form-urlencoded" },
4845
+ }),
4846
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
4847
+ );
4848
+ expect(tokenRes.status).toBe(200);
4849
+ const body = (await tokenRes.json()) as { access_token: string; scope: string };
4850
+ expect(body.scope).toBe("vault:default:read");
4851
+ const { payload } = await validateAccessToken(db, body.access_token, ISSUER);
4852
+ expect(payload.scope).toBe("vault:default:read");
4853
+ expect(payload.vault_scope).toEqual(["default"]);
4854
+ } finally {
4855
+ cleanup();
4856
+ }
4857
+ });
4858
+
4859
+ test("admin user (assigned_vault null) mints with vault_scope=[]", async () => {
4860
+ const { db, cleanup } = await makeDb();
4861
+ try {
4862
+ const admin = await createUser(db, "admin-aaron", "pw");
4863
+ const session = createSession(db, { userId: admin.id });
4864
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4865
+ const { verifier, challenge } = makePkce();
4866
+ const consentForm = new URLSearchParams({
4867
+ __action: "consent",
4868
+ __csrf: TEST_CSRF,
4869
+ approve: "yes",
4870
+ client_id: reg.client.clientId,
4871
+ redirect_uri: "https://app.example/cb",
4872
+ response_type: "code",
4873
+ scope: "vault:read",
4874
+ code_challenge: challenge,
4875
+ code_challenge_method: "S256",
4876
+ vault_pick: "default",
4877
+ });
4878
+ const consentRes = await handleAuthorizePost(
4879
+ db,
4880
+ new Request(`${ISSUER}/oauth/authorize`, {
4881
+ method: "POST",
4882
+ body: consentForm,
4883
+ headers: {
4884
+ "content-type": "application/x-www-form-urlencoded",
4885
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
4886
+ },
4887
+ }),
4888
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
4889
+ );
4890
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
4891
+ const tokenRes = await handleToken(
4892
+ db,
4893
+ new Request(`${ISSUER}/oauth/token`, {
4894
+ method: "POST",
4895
+ body: new URLSearchParams({
4896
+ grant_type: "authorization_code",
4897
+ code: code ?? "",
4898
+ client_id: reg.client.clientId,
4899
+ redirect_uri: "https://app.example/cb",
4900
+ code_verifier: verifier,
4901
+ }),
4902
+ headers: { "content-type": "application/x-www-form-urlencoded" },
4903
+ }),
4904
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
4905
+ );
4906
+ const body = (await tokenRes.json()) as { access_token: string };
4907
+ const { payload } = await validateAccessToken(db, body.access_token, ISSUER);
4908
+ expect(payload.vault_scope).toEqual([]);
4909
+ } finally {
4910
+ cleanup();
4911
+ }
4912
+ });
4913
+
4914
+ test("non-admin with disagreeing vault_pick → 400 vault_scope_mismatch", async () => {
4915
+ const { db, cleanup } = await makeDb();
4916
+ try {
4917
+ const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
4918
+ const session = createSession(db, { userId: bob.id });
4919
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4920
+ const { challenge } = makePkce();
4921
+ // The fixture also has a vault "default"; build a manifest that has
4922
+ // two valid vault names so the mismatch isn't conflated with
4923
+ // "unknown vault."
4924
+ const twoVaultManifest: ServicesManifest = {
4925
+ services: [
4926
+ {
4927
+ name: "parachute-vault",
4928
+ port: 1940,
4929
+ paths: ["/vault/default", "/vault/other"],
4930
+ health: "/health",
4931
+ version: "0.3.0",
4932
+ },
4933
+ ],
4934
+ };
4935
+ const consentForm = new URLSearchParams({
4936
+ __action: "consent",
4937
+ __csrf: TEST_CSRF,
4938
+ approve: "yes",
4939
+ client_id: reg.client.clientId,
4940
+ redirect_uri: "https://app.example/cb",
4941
+ response_type: "code",
4942
+ scope: "vault:read",
4943
+ code_challenge: challenge,
4944
+ code_challenge_method: "S256",
4945
+ vault_pick: "other",
4946
+ });
4947
+ const res = await handleAuthorizePost(
4948
+ db,
4949
+ new Request(`${ISSUER}/oauth/authorize`, {
4950
+ method: "POST",
4951
+ body: consentForm,
4952
+ headers: {
4953
+ "content-type": "application/x-www-form-urlencoded",
4954
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
4955
+ },
4956
+ }),
4957
+ { issuer: ISSUER, loadServicesManifest: () => twoVaultManifest },
4958
+ );
4959
+ expect(res.status).toBe(400);
4960
+ const html = await res.text();
4961
+ expect(html).toContain("vault_scope_mismatch");
4962
+ // Echo back the picked-but-rejected vault (HTML-escaped), but DON'T
4963
+ // leak the assigned one (post-N1 nit-fold). "your vault assignment"
4964
+ // is the soft phrase replacing the prior `your assigned vault "..."`.
4965
+ expect(html).toContain("&quot;other&quot;");
4966
+ expect(html).toContain("your vault assignment");
4967
+ expect(html).not.toContain("&quot;default&quot;");
4968
+ } finally {
4969
+ cleanup();
4970
+ }
4971
+ });
4972
+
4973
+ test("non-admin requesting named scope for the wrong vault → 400 vault_scope_mismatch", async () => {
4974
+ const { db, cleanup } = await makeDb();
4975
+ try {
4976
+ const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
4977
+ const session = createSession(db, { userId: bob.id });
4978
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4979
+ const { challenge } = makePkce();
4980
+ const consentForm = new URLSearchParams({
4981
+ __action: "consent",
4982
+ __csrf: TEST_CSRF,
4983
+ approve: "yes",
4984
+ client_id: reg.client.clientId,
4985
+ redirect_uri: "https://app.example/cb",
4986
+ response_type: "code",
4987
+ // Explicit named scope targeting a vault other than bob's assigned one.
4988
+ scope: "vault:other:read",
4989
+ code_challenge: challenge,
4990
+ code_challenge_method: "S256",
4991
+ });
4992
+ const res = await handleAuthorizePost(
4993
+ db,
4994
+ new Request(`${ISSUER}/oauth/authorize`, {
4995
+ method: "POST",
4996
+ body: consentForm,
4997
+ headers: {
4998
+ "content-type": "application/x-www-form-urlencoded",
4999
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
5000
+ },
5001
+ }),
5002
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
5003
+ );
5004
+ expect(res.status).toBe(400);
5005
+ const html = await res.text();
5006
+ expect(html).toContain("vault_scope_mismatch");
5007
+ expect(html).toContain("vault:other:read");
5008
+ } finally {
5009
+ cleanup();
5010
+ }
5011
+ });
5012
+
5013
+ test("non-admin requesting named scope for the assigned vault → happy path", async () => {
5014
+ const { db, cleanup } = await makeDb();
5015
+ try {
5016
+ const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
5017
+ const session = createSession(db, { userId: bob.id });
5018
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5019
+ const { verifier, challenge } = makePkce();
5020
+ const consentForm = new URLSearchParams({
5021
+ __action: "consent",
5022
+ __csrf: TEST_CSRF,
5023
+ approve: "yes",
5024
+ client_id: reg.client.clientId,
5025
+ redirect_uri: "https://app.example/cb",
5026
+ response_type: "code",
5027
+ // Named scope matching bob's assigned vault — should pass.
5028
+ scope: "vault:default:read",
5029
+ code_challenge: challenge,
5030
+ code_challenge_method: "S256",
5031
+ });
5032
+ const consentRes = await handleAuthorizePost(
5033
+ db,
5034
+ new Request(`${ISSUER}/oauth/authorize`, {
5035
+ method: "POST",
5036
+ body: consentForm,
5037
+ headers: {
5038
+ "content-type": "application/x-www-form-urlencoded",
5039
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
5040
+ },
5041
+ }),
5042
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
5043
+ );
5044
+ expect(consentRes.status).toBe(302);
5045
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
5046
+ const tokenRes = await handleToken(
5047
+ db,
5048
+ new Request(`${ISSUER}/oauth/token`, {
5049
+ method: "POST",
5050
+ body: new URLSearchParams({
5051
+ grant_type: "authorization_code",
5052
+ code: code ?? "",
5053
+ client_id: reg.client.clientId,
5054
+ redirect_uri: "https://app.example/cb",
5055
+ code_verifier: verifier,
5056
+ }),
5057
+ headers: { "content-type": "application/x-www-form-urlencoded" },
5058
+ }),
5059
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
5060
+ );
5061
+ const body = (await tokenRes.json()) as { access_token: string };
5062
+ const { payload } = await validateAccessToken(db, body.access_token, ISSUER);
5063
+ expect(payload.scope).toBe("vault:default:read");
5064
+ expect(payload.vault_scope).toEqual(["default"]);
5065
+ } finally {
5066
+ cleanup();
5067
+ }
5068
+ });
5069
+
5070
+ test("refresh flow re-derives vault_scope from current assigned_vault", async () => {
5071
+ const { db, cleanup } = await makeDb();
5072
+ try {
5073
+ const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
5074
+ const session = createSession(db, { userId: bob.id });
5075
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5076
+ const { verifier, challenge } = makePkce();
5077
+
5078
+ // Step 1: complete the OAuth dance to obtain a refresh token.
5079
+ const consentForm = new URLSearchParams({
5080
+ __action: "consent",
5081
+ __csrf: TEST_CSRF,
5082
+ approve: "yes",
5083
+ client_id: reg.client.clientId,
5084
+ redirect_uri: "https://app.example/cb",
5085
+ response_type: "code",
5086
+ scope: "vault:read",
5087
+ code_challenge: challenge,
5088
+ code_challenge_method: "S256",
5089
+ vault_pick: "default",
5090
+ });
5091
+ const consentRes = await handleAuthorizePost(
5092
+ db,
5093
+ new Request(`${ISSUER}/oauth/authorize`, {
5094
+ method: "POST",
5095
+ body: consentForm,
5096
+ headers: {
5097
+ "content-type": "application/x-www-form-urlencoded",
5098
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
5099
+ },
5100
+ }),
5101
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
5102
+ );
5103
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
5104
+ const tokenRes = await handleToken(
5105
+ db,
5106
+ new Request(`${ISSUER}/oauth/token`, {
5107
+ method: "POST",
5108
+ body: new URLSearchParams({
5109
+ grant_type: "authorization_code",
5110
+ code: code ?? "",
5111
+ client_id: reg.client.clientId,
5112
+ redirect_uri: "https://app.example/cb",
5113
+ code_verifier: verifier,
5114
+ }),
5115
+ headers: { "content-type": "application/x-www-form-urlencoded" },
5116
+ }),
5117
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
5118
+ );
5119
+ const tokenBody = (await tokenRes.json()) as {
5120
+ access_token: string;
5121
+ refresh_token: string;
5122
+ };
5123
+ const firstValidated = await validateAccessToken(db, tokenBody.access_token, ISSUER);
5124
+ expect(firstValidated.payload.vault_scope).toEqual(["default"]);
5125
+
5126
+ // Step 2: refresh the token; vault_scope should still be ["default"].
5127
+ const refreshRes = await handleToken(
5128
+ db,
5129
+ new Request(`${ISSUER}/oauth/token`, {
5130
+ method: "POST",
5131
+ body: new URLSearchParams({
5132
+ grant_type: "refresh_token",
5133
+ refresh_token: tokenBody.refresh_token,
5134
+ client_id: reg.client.clientId,
5135
+ }),
5136
+ headers: { "content-type": "application/x-www-form-urlencoded" },
5137
+ }),
5138
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
5139
+ );
5140
+ const refreshBody = (await refreshRes.json()) as { access_token: string };
5141
+ const refreshedValidated = await validateAccessToken(db, refreshBody.access_token, ISSUER);
5142
+ expect(refreshedValidated.payload.vault_scope).toEqual(["default"]);
5143
+ } finally {
5144
+ cleanup();
5145
+ }
5146
+ });
5147
+
5148
+ // Reviewer nit N3 (PR #283): the previous test only verified that
5149
+ // `vault_scope` SURVIVES refresh — it didn't prove the claim is re-derived
5150
+ // mid-session if an admin changes the user's `assigned_vault`. This test
5151
+ // pins the actual "re-derived at refresh time" invariant by mutating the
5152
+ // assignment between mint and refresh, then asserting the new token
5153
+ // carries the post-mutation value. The `scope` claim itself stays
5154
+ // narrowed to the original vault (it was set at consent time and stored
5155
+ // on the refresh-token row); only the informational `vault_scope` claim
5156
+ // tracks the live row.
5157
+ test("refresh flow picks up a mid-session assigned_vault change", async () => {
5158
+ const { db, cleanup } = await makeDb();
5159
+ try {
5160
+ const bob = await createUser(db, "bob", "pw", { assignedVault: "vault-a" });
5161
+ const session = createSession(db, { userId: bob.id });
5162
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
5163
+ const { verifier, challenge } = makePkce();
5164
+
5165
+ // Manifest fixture: both vault-a (initial assignment) and vault-b
5166
+ // (post-admin-update assignment) are registered. PR 4 doesn't ship
5167
+ // a PATCH endpoint, so we use the same direct UPDATE the design
5168
+ // anticipates an admin path would call.
5169
+ const twoVaultManifest: ServicesManifest = {
5170
+ services: [
5171
+ {
5172
+ name: "parachute-vault",
5173
+ port: 1940,
5174
+ paths: ["/vault/vault-a", "/vault/vault-b"],
5175
+ health: "/health",
5176
+ version: "0.3.0",
5177
+ },
5178
+ ],
5179
+ };
5180
+
5181
+ // Step 1: initial OAuth dance + token mint. Asserts vault_scope=["vault-a"].
5182
+ const consentForm = new URLSearchParams({
5183
+ __action: "consent",
5184
+ __csrf: TEST_CSRF,
5185
+ approve: "yes",
5186
+ client_id: reg.client.clientId,
5187
+ redirect_uri: "https://app.example/cb",
5188
+ response_type: "code",
5189
+ scope: "vault:read",
5190
+ code_challenge: challenge,
5191
+ code_challenge_method: "S256",
5192
+ vault_pick: "vault-a",
5193
+ });
5194
+ const consentRes = await handleAuthorizePost(
5195
+ db,
5196
+ new Request(`${ISSUER}/oauth/authorize`, {
5197
+ method: "POST",
5198
+ body: consentForm,
5199
+ headers: {
5200
+ "content-type": "application/x-www-form-urlencoded",
5201
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
5202
+ },
5203
+ }),
5204
+ { issuer: ISSUER, loadServicesManifest: () => twoVaultManifest },
5205
+ );
5206
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
5207
+ const tokenRes = await handleToken(
5208
+ db,
5209
+ new Request(`${ISSUER}/oauth/token`, {
5210
+ method: "POST",
5211
+ body: new URLSearchParams({
5212
+ grant_type: "authorization_code",
5213
+ code: code ?? "",
5214
+ client_id: reg.client.clientId,
5215
+ redirect_uri: "https://app.example/cb",
5216
+ code_verifier: verifier,
5217
+ }),
5218
+ headers: { "content-type": "application/x-www-form-urlencoded" },
5219
+ }),
5220
+ { issuer: ISSUER, loadServicesManifest: () => twoVaultManifest },
5221
+ );
5222
+ const tokenBody = (await tokenRes.json()) as {
5223
+ access_token: string;
5224
+ refresh_token: string;
5225
+ };
5226
+ const initial = await validateAccessToken(db, tokenBody.access_token, ISSUER);
5227
+ expect(initial.payload.vault_scope).toEqual(["vault-a"]);
5228
+ expect(initial.payload.scope).toBe("vault:vault-a:read");
5229
+
5230
+ // Step 2: admin updates bob's assigned_vault to vault-b. Direct UPDATE
5231
+ // because Phase 1 has no PATCH endpoint; same effect a future admin
5232
+ // path would have. The refresh path reads the live row at mint time
5233
+ // (`vaultScopeForUser`), so the next refresh should pick up the new
5234
+ // value.
5235
+ db.prepare("UPDATE users SET assigned_vault = ? WHERE id = ?").run("vault-b", bob.id);
5236
+
5237
+ // Step 3: refresh the token. vault_scope should be ["vault-b"] (the
5238
+ // new live value); the `scope` claim stays narrowed to the original
5239
+ // vault (auth-code grant snapshotted it onto the refresh-token row).
5240
+ const refreshRes = await handleToken(
5241
+ db,
5242
+ new Request(`${ISSUER}/oauth/token`, {
5243
+ method: "POST",
5244
+ body: new URLSearchParams({
5245
+ grant_type: "refresh_token",
5246
+ refresh_token: tokenBody.refresh_token,
5247
+ client_id: reg.client.clientId,
5248
+ }),
5249
+ headers: { "content-type": "application/x-www-form-urlencoded" },
5250
+ }),
5251
+ { issuer: ISSUER, loadServicesManifest: () => twoVaultManifest },
5252
+ );
5253
+ expect(refreshRes.status).toBe(200);
5254
+ const refreshBody = (await refreshRes.json()) as { access_token: string };
5255
+ const refreshed = await validateAccessToken(db, refreshBody.access_token, ISSUER);
5256
+ expect(refreshed.payload.vault_scope).toEqual(["vault-b"]);
5257
+ // The `scope` claim is still bound to the original consent — the
5258
+ // refresh-token row carries `vault:vault-a:read` and the rotation
5259
+ // preserves it. PR 5 will be the side that enforces "your access
5260
+ // tokens for the old vault stop working when the assignment moves";
5261
+ // PR 4 just emits the informational claim correctly.
5262
+ expect(refreshed.payload.scope).toBe("vault:vault-a:read");
5263
+ } finally {
5264
+ cleanup();
5265
+ }
5266
+ });
5267
+ });