@openparachute/hub 0.6.3 → 0.6.4-rc.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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -15,6 +15,7 @@ import {
15
15
  } from "../hub-server.ts";
16
16
  import { setNotesRedirectDisabled } from "../hub-settings.ts";
17
17
  import { clearNotesRedirectLogState } from "../notes-redirect.ts";
18
+ import { mintOperatorToken } from "../operator-token.ts";
18
19
  import { pidPath } from "../process-state.ts";
19
20
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
20
21
  import { buildSessionCookie, createSession } from "../sessions.ts";
@@ -530,9 +531,13 @@ describe("hubFetch routing", () => {
530
531
  const h = makeHarness();
531
532
  try {
532
533
  writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
533
- const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
534
- new Request("http://127.0.0.1:1939/.well-known/parachute.json"),
535
- );
534
+ const res = await hubFetch(h.dir, {
535
+ manifestPath: h.manifestPath,
536
+ // No exposure recorded — isolate from the host's real expose-state.json
537
+ // so the request-origin fallback (not the #531 expose tier) is what
538
+ // this test exercises.
539
+ loadExposeHubOrigin: () => undefined,
540
+ })(new Request("http://127.0.0.1:1939/.well-known/parachute.json"));
536
541
  const body = (await res.json()) as { vaults: Array<{ url: string }> };
537
542
  expect(body.vaults[0]?.url).toBe("http://127.0.0.1:1939/vault/default");
538
543
  } finally {
@@ -1625,6 +1630,139 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
1625
1630
  }
1626
1631
  });
1627
1632
 
1633
+ // #525: bare `/vault/<name>` POST (no `/mcp` suffix) half-connects MCP
1634
+ // clients. The fix 308-redirects the bare-path POST to `<mount>/mcp` BEFORE
1635
+ // proxying, so a compliant client re-POSTs to the right endpoint and clients
1636
+ // that don't follow redirects at least get an actionable Location + JSON body
1637
+ // (vs the old opaque vault 405).
1638
+ test("POST to bare /vault/<name> 308-redirects to /vault/<name>/mcp with an actionable body (#525)", async () => {
1639
+ const h = makeHarness();
1640
+ try {
1641
+ writeManifest({ services: [vaultEntry("aaron")] }, h.manifestPath);
1642
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1643
+ const res = await fetcher(
1644
+ req("/vault/aaron", {
1645
+ method: "POST",
1646
+ headers: { "content-type": "application/json" },
1647
+ body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1 }),
1648
+ }),
1649
+ );
1650
+ expect(res.status).toBe(308);
1651
+ expect(res.headers.get("location")).toBe("/vault/aaron/mcp");
1652
+ // 308 is permanently cacheable by default — no-store prevents a cached
1653
+ // redirect outliving a remount.
1654
+ expect(res.headers.get("cache-control")).toBe("no-store");
1655
+ const body = (await res.json()) as { error: string; mcp_url: string; message: string };
1656
+ expect(body.error).toBe("missing_mcp_suffix");
1657
+ expect(body.mcp_url).toBe("/vault/aaron/mcp");
1658
+ expect(body.message).toContain("/vault/aaron/mcp");
1659
+ } finally {
1660
+ h.cleanup();
1661
+ }
1662
+ });
1663
+
1664
+ test("bare-path 308 honors a trailing-slash mount: POST /vault/default → /vault/default/mcp (#525)", async () => {
1665
+ // Mounts in services.json sometimes carry a trailing slash (#197). The
1666
+ // redirect target must be built from the *normalized* mount so it stays
1667
+ // `/vault/default/mcp`, never `/vault/default//mcp`.
1668
+ const h = makeHarness();
1669
+ try {
1670
+ writeManifest(
1671
+ {
1672
+ services: [
1673
+ {
1674
+ name: "parachute-vault-default",
1675
+ port: 1940,
1676
+ paths: ["/vault/default/"],
1677
+ health: "/health",
1678
+ version: "0.4.0",
1679
+ },
1680
+ ],
1681
+ },
1682
+ h.manifestPath,
1683
+ );
1684
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1685
+ const res = await fetcher(req("/vault/default", { method: "POST" }));
1686
+ expect(res.status).toBe(308);
1687
+ expect(res.headers.get("location")).toBe("/vault/default/mcp");
1688
+ } finally {
1689
+ h.cleanup();
1690
+ }
1691
+ });
1692
+
1693
+ test("GET to bare /vault/<name> is NOT redirected — proxies through untouched (#525)", async () => {
1694
+ // Only POST (the MCP transport verb) is caught. A browser GET to the bare
1695
+ // path keeps its existing proxy behavior so we don't break any bare-path
1696
+ // GET surface.
1697
+ const h = makeHarness();
1698
+ const upstream = startUpstream("bare-get");
1699
+ try {
1700
+ writeManifest(
1701
+ {
1702
+ services: [
1703
+ {
1704
+ name: "parachute-vault-aaron",
1705
+ port: upstream.port,
1706
+ paths: ["/vault/aaron"],
1707
+ health: "/health",
1708
+ version: "0.4.0",
1709
+ },
1710
+ ],
1711
+ },
1712
+ h.manifestPath,
1713
+ );
1714
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1715
+ const res = await fetcher(req("/vault/aaron", { method: "GET" }));
1716
+ expect(res.status).toBe(200);
1717
+ const body = (await res.json()) as { tag: string; method: string; pathname: string };
1718
+ expect(body.tag).toBe("bare-get");
1719
+ expect(body.method).toBe("GET");
1720
+ expect(body.pathname).toBe("/vault/aaron");
1721
+ } finally {
1722
+ upstream.stop();
1723
+ h.cleanup();
1724
+ }
1725
+ });
1726
+
1727
+ test("POST to the canonical /vault/<name>/mcp sub-path is NOT redirected — proxies through (#525)", async () => {
1728
+ // The real MCP endpoint must keep working: a POST that already carries the
1729
+ // `/mcp` suffix is a sub-path (not the exact bare mount) and proxies
1730
+ // straight to the vault backend.
1731
+ const h = makeHarness();
1732
+ const upstream = startUpstream("mcp-post");
1733
+ try {
1734
+ writeManifest(
1735
+ {
1736
+ services: [
1737
+ {
1738
+ name: "parachute-vault-aaron",
1739
+ port: upstream.port,
1740
+ paths: ["/vault/aaron"],
1741
+ health: "/health",
1742
+ version: "0.4.0",
1743
+ },
1744
+ ],
1745
+ },
1746
+ h.manifestPath,
1747
+ );
1748
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1749
+ const res = await fetcher(
1750
+ req("/vault/aaron/mcp", {
1751
+ method: "POST",
1752
+ body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 2 }),
1753
+ }),
1754
+ );
1755
+ expect(res.status).toBe(200);
1756
+ const body = (await res.json()) as { tag: string; method: string; pathname: string };
1757
+ expect(body.tag).toBe("mcp-post");
1758
+ expect(body.method).toBe("POST");
1759
+ expect(body.pathname).toBe("/vault/aaron/mcp");
1760
+ } finally {
1761
+ upstream.stop();
1762
+ h.cleanup();
1763
+ }
1764
+ });
1765
+
1628
1766
  test("synthesizes X-Forwarded-Proto when edge didn't set it (direct HTTPS to hub)", async () => {
1629
1767
  // Non-Render shape: hub bound directly to https (e.g. local TLS or a
1630
1768
  // proxy that doesn't set X-Forwarded-Proto). isHttpsRequest sees the
@@ -3195,13 +3333,46 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
3195
3333
  });
3196
3334
  });
3197
3335
 
3198
- describe("layerOf — classify trust layer from proxy headers", () => {
3199
- // Hub binds 127.0.0.1:1939; only trusted forwarders (cloudflared,
3200
- // tailscaled-serve, tailscaled-funnel) reach the listener. Spoofing isn't
3201
- // a concern. layerOf inspects the headers each forwarder injects.
3336
+ describe("layerOf — classify trust layer from proxy headers + peer (item E / #526)", () => {
3337
+ // Proxy headers (cloudflared, tailscale serve/funnel) take precedence. When
3338
+ // absent, the PEER ADDRESS is the loopback discriminator — header-absence is
3339
+ // no longer a loopback signal (#526). The peer-address is the 2nd arg.
3202
3340
 
3203
- test("no proxy headers → loopback (direct localhost call)", () => {
3204
- expect(layerOf(req("/"))).toBe("loopback");
3341
+ test("no proxy headers + loopback peer (127.0.0.1) → loopback (on-box CLI)", () => {
3342
+ expect(layerOf(req("/"), "127.0.0.1")).toBe("loopback");
3343
+ });
3344
+
3345
+ test("no proxy headers + IPv6 loopback peer (::1) → loopback", () => {
3346
+ expect(layerOf(req("/"), "::1")).toBe("loopback");
3347
+ });
3348
+
3349
+ test("no proxy headers + IPv4-mapped IPv6 loopback (::ffff:127.0.0.1) → loopback", () => {
3350
+ expect(layerOf(req("/"), "::ffff:127.0.0.1")).toBe("loopback");
3351
+ });
3352
+
3353
+ // THE FIX: a header-absent NON-loopback peer (the 0.0.0.0-bind direct-network
3354
+ // case) must NOT be classified loopback — it would bypass the
3355
+ // publicExposure:loopback 404-cloak. Fail to public (least-trusted).
3356
+ test("no proxy headers + non-loopback peer → public (NOT loopback) [#526]", () => {
3357
+ expect(layerOf(req("/"), "203.0.113.7")).toBe("public");
3358
+ expect(layerOf(req("/"), "10.0.0.5")).toBe("public");
3359
+ expect(layerOf(req("/"), "fd00::1234")).toBe("public");
3360
+ });
3361
+
3362
+ // Fail-closed: an unknown peer (no Server threaded — null/undefined) is NOT
3363
+ // loopback. A direct unit call to the fetch fn with no server lands here.
3364
+ test("no proxy headers + unknown peer (null/undefined) → public (fail closed)", () => {
3365
+ expect(layerOf(req("/"), null)).toBe("public");
3366
+ expect(layerOf(req("/"), undefined)).toBe("public");
3367
+ expect(layerOf(req("/"))).toBe("public");
3368
+ });
3369
+
3370
+ // Headers still win over peer address — a tailnet/public forwarder sets the
3371
+ // header and the peer (the local tailscaled/cloudflared) is loopback, but the
3372
+ // header is authoritative.
3373
+ test("Tailscale-User-Login → tailnet even from a loopback peer", () => {
3374
+ const r = req("/", { headers: { "Tailscale-User-Login": "alice@example.com" } });
3375
+ expect(layerOf(r, "127.0.0.1")).toBe("tailnet");
3205
3376
  });
3206
3377
 
3207
3378
  test("Tailscale-User-Login → tailnet (authed via tailscale serve)", () => {
@@ -3266,6 +3437,11 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
3266
3437
  return { port: server.port as number, stop: () => server.stop(true) };
3267
3438
  }
3268
3439
 
3440
+ // Fake Bun Server handle exposing only `requestIP` (item E / #526) so the
3441
+ // fetch fn can resolve the peer address. The on-box CLI caller connects from
3442
+ // 127.0.0.1; a network peer on a 0.0.0.0 bind connects from its real IP.
3443
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
3444
+
3269
3445
  test("publicExposure: loopback + tailnet header → 404 (gate hides the route)", async () => {
3270
3446
  const h = makeHarness();
3271
3447
  const upstream = startUpstream("loopback-only");
@@ -3326,7 +3502,7 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
3326
3502
  }
3327
3503
  });
3328
3504
 
3329
- test("publicExposure: loopback + no headers → reaches upstream (loopback layer)", async () => {
3505
+ test("publicExposure: loopback + no headers + loopback peer → reaches upstream (loopback layer)", async () => {
3330
3506
  const h = makeHarness();
3331
3507
  const upstream = startUpstream("loopback-only");
3332
3508
  try {
@@ -3346,7 +3522,8 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
3346
3522
  h.manifestPath,
3347
3523
  );
3348
3524
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3349
- const res = await fetcher(req("/loopback-only/health"));
3525
+ // On-box CLI caller: 127.0.0.1 peer, no proxy headers → loopback layer.
3526
+ const res = await fetcher(req("/loopback-only/health"), fakeServer("127.0.0.1"));
3350
3527
  expect(res.status).toBe(200);
3351
3528
  const body = (await res.json()) as { tag: string };
3352
3529
  expect(body.tag).toBe("loopback-only");
@@ -3356,6 +3533,37 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
3356
3533
  }
3357
3534
  });
3358
3535
 
3536
+ // Item E / #526 — the core fix. On a 0.0.0.0 bind a network peer reaches the
3537
+ // listener with NO proxy headers; it must NOT be treated as loopback, so the
3538
+ // loopback-exposure cloak still fires (404) rather than leaking the route.
3539
+ test("publicExposure: loopback + no headers + NON-loopback peer → 404 (cloak fires) [#526]", async () => {
3540
+ const h = makeHarness();
3541
+ const upstream = startUpstream("loopback-only");
3542
+ try {
3543
+ writeManifest(
3544
+ {
3545
+ services: [
3546
+ {
3547
+ name: "loopback-only",
3548
+ port: upstream.port,
3549
+ paths: ["/loopback-only"],
3550
+ health: "/loopback-only/health",
3551
+ version: "0.1.0",
3552
+ publicExposure: "loopback",
3553
+ },
3554
+ ],
3555
+ },
3556
+ h.manifestPath,
3557
+ );
3558
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3559
+ const res = await fetcher(req("/loopback-only/health"), fakeServer("203.0.113.9"));
3560
+ expect(res.status).toBe(404);
3561
+ } finally {
3562
+ upstream.stop();
3563
+ h.cleanup();
3564
+ }
3565
+ });
3566
+
3359
3567
  test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
3360
3568
  const h = makeHarness();
3361
3569
  const upstream = startUpstream("allowed");
@@ -3514,6 +3722,10 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
3514
3722
  return { port: server.port as number, stop: () => server.stop(true) };
3515
3723
  }
3516
3724
 
3725
+ // Item E / #526 — fake Bun Server handle exposing `requestIP` for the peer-
3726
+ // address discriminator (see the proxyToService block for the rationale).
3727
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
3728
+
3517
3729
  test("vault publicExposure: loopback + tailnet header → 404", async () => {
3518
3730
  const h = makeHarness();
3519
3731
  const upstream = startVaultUpstream("vault-private");
@@ -3545,7 +3757,7 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
3545
3757
  }
3546
3758
  });
3547
3759
 
3548
- test("vault publicExposure: loopback + no headers → reaches vault backend", async () => {
3760
+ test("vault publicExposure: loopback + no headers + loopback peer → reaches vault backend", async () => {
3549
3761
  const h = makeHarness();
3550
3762
  const upstream = startVaultUpstream("vault-private");
3551
3763
  try {
@@ -3565,7 +3777,7 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
3565
3777
  h.manifestPath,
3566
3778
  );
3567
3779
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3568
- const res = await fetcher(req("/vault/private/health"));
3780
+ const res = await fetcher(req("/vault/private/health"), fakeServer("127.0.0.1"));
3569
3781
  expect(res.status).toBe(200);
3570
3782
  const body = (await res.json()) as { tag: string };
3571
3783
  expect(body.tag).toBe("vault-private");
@@ -3575,6 +3787,36 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
3575
3787
  }
3576
3788
  });
3577
3789
 
3790
+ // Item E / #526 — vault-path symmetry: a header-absent NON-loopback peer on a
3791
+ // 0.0.0.0 bind must NOT reach a loopback-exposed vault (cloak fires).
3792
+ test("vault publicExposure: loopback + no headers + NON-loopback peer → 404 [#526]", async () => {
3793
+ const h = makeHarness();
3794
+ const upstream = startVaultUpstream("vault-private");
3795
+ try {
3796
+ writeManifest(
3797
+ {
3798
+ services: [
3799
+ {
3800
+ name: "parachute-vault-private",
3801
+ port: upstream.port,
3802
+ paths: ["/vault/private"],
3803
+ health: "/vault/private/health",
3804
+ version: "0.4.0",
3805
+ publicExposure: "loopback",
3806
+ },
3807
+ ],
3808
+ },
3809
+ h.manifestPath,
3810
+ );
3811
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3812
+ const res = await fetcher(req("/vault/private/health"), fakeServer("198.51.100.4"));
3813
+ expect(res.status).toBe(404);
3814
+ } finally {
3815
+ upstream.stop();
3816
+ h.cleanup();
3817
+ }
3818
+ });
3819
+
3578
3820
  test("vault publicExposure: allowed + tailnet header → reaches backend", async () => {
3579
3821
  const h = makeHarness();
3580
3822
  const upstream = startVaultUpstream("vault-public");
@@ -4185,6 +4427,9 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
4185
4427
  const friend = await createUser(db, "friend", "friend-password-123", {
4186
4428
  assignedVaults,
4187
4429
  allowMulti: true,
4430
+ // Item F (#469): the friend mints a token only after rotating the temp
4431
+ // password; an unrotated friend is force-redirected before any mint.
4432
+ passwordChanged: true,
4188
4433
  });
4189
4434
  const session = createSession(db, { userId: friend.id });
4190
4435
  const csrf = generateCsrfToken();
@@ -4227,6 +4472,63 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
4227
4472
  }
4228
4473
  });
4229
4474
 
4475
+ // Item F (#469) routed e2e — an assigned but unrotated friend is force-
4476
+ // redirected to the change-password rail before any mint, through the real
4477
+ // dispatch.
4478
+ test("unrotated friend is force-change-gated, routed through hubFetch (item F / #469)", async () => {
4479
+ // As of hub#469 the broad per-request gate (forceChangePasswordGate) is the
4480
+ // choke point and intercepts BEFORE the per-route mint handler. A browser
4481
+ // request (Accept: text/html) is 302'd to the change-password rail; an
4482
+ // API-style POST without that header is 403 force_change_password. Both
4483
+ // prove the unrotated friend can't parlay the temp password into a mint.
4484
+ const h = makeHarness();
4485
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4486
+ const db = openHubDb(hubDbPath(h.dir));
4487
+ rotateSigningKey(db);
4488
+ await createUser(db, "operator", "operator-password-123");
4489
+ const friend = await createUser(db, "friend", "friend-password-123", {
4490
+ assignedVaults: ["work"],
4491
+ allowMulti: true,
4492
+ passwordChanged: false, // not yet rotated
4493
+ });
4494
+ const session = createSession(db, { userId: friend.id });
4495
+ const csrf = generateCsrfToken();
4496
+ const cookie = `${buildSessionCookie(session.id, 3600, { secure: false })}; ${
4497
+ buildCsrfCookie(csrf, { secure: false }).split(";")[0]
4498
+ }`;
4499
+ const fetcher = hubFetch(h.dir, {
4500
+ getDb: () => db,
4501
+ manifestPath: h.manifestPath,
4502
+ issuer: "https://hub.test",
4503
+ });
4504
+ try {
4505
+ // The mint is a POST (non-GET) → the gate rejects with 403
4506
+ // force_change_password regardless of Accept, per the spec ("redirect
4507
+ // browser GETs, reject non-GET / API-style requests with 403"). A 302 on
4508
+ // a POST wouldn't usefully re-issue the mint anyway.
4509
+ const apiRes = await fetcher(
4510
+ req("/account/vault-token/work", {
4511
+ method: "POST",
4512
+ headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
4513
+ body: postBody(csrf, "read"),
4514
+ }),
4515
+ );
4516
+ expect(apiRes.status).toBe(403);
4517
+ expect(((await apiRes.json()) as { error: string }).error).toBe("force_change_password");
4518
+
4519
+ // The friend's browser GET of the account home IS bounced to the
4520
+ // change-password rail — the surface they'd navigate to is gated.
4521
+ const browserRes = await fetcher(
4522
+ req("/account/", { headers: { cookie, accept: "text/html" } }),
4523
+ );
4524
+ expect(browserRes.status).toBe(302);
4525
+ expect(browserRes.headers.get("location")).toBe("/account/change-password");
4526
+ } finally {
4527
+ db.close();
4528
+ h.cleanup();
4529
+ }
4530
+ });
4531
+
4230
4532
  test("unassigned vault → 403, no token, routed through hubFetch", async () => {
4231
4533
  const h = makeHarness();
4232
4534
  writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
@@ -4269,6 +4571,73 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
4269
4571
  });
4270
4572
  });
4271
4573
 
4574
+ // Item D (#450) routed e2e — exercise the knownVaultNames threading from
4575
+ // services.json → hubFetch → handleApiMintToken (hub-server.ts dispatch),
4576
+ // which the unit-level handler tests can't cover. A `vault:<typo>:admin` mint
4577
+ // for an unregistered vault → 400 through the full stack; a known vault → 200.
4578
+ describe("POST /api/auth/mint-token — vault-existence threading (routed end-to-end, item D)", () => {
4579
+ const ISSUER = "https://hub.test";
4580
+
4581
+ async function seedMint(
4582
+ h: Harness,
4583
+ vaultNames: string[],
4584
+ ): Promise<{ db: ReturnType<typeof openHubDb>; bearer: string }> {
4585
+ const db = openHubDb(hubDbPath(h.dir));
4586
+ rotateSigningKey(db); // mint needs an active signing key
4587
+ const owner = await createUser(db, "owner", "owner-password-123");
4588
+ // The default operator scope-set carries parachute:host:admin, which mints
4589
+ // vault:<name>:admin (canGrant rule 2).
4590
+ const op = await mintOperatorToken(db, owner.id, { issuer: ISSUER });
4591
+ writeManifest({ services: vaultNames.map((n) => vaultEntry(n)) }, h.manifestPath);
4592
+ return { db, bearer: op.token };
4593
+ }
4594
+
4595
+ function mintReq(scope: string, bearer: string): Request {
4596
+ return req("/api/auth/mint-token", {
4597
+ method: "POST",
4598
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
4599
+ body: JSON.stringify({ scope }),
4600
+ });
4601
+ }
4602
+
4603
+ test("vault:<typo>:admin for an unregistered vault → 400 (knownVaultNames from services.json)", async () => {
4604
+ const h = makeHarness();
4605
+ const { db, bearer } = await seedMint(h, ["work", "default"]);
4606
+ try {
4607
+ const res = await hubFetch(h.dir, {
4608
+ getDb: () => db,
4609
+ manifestPath: h.manifestPath,
4610
+ issuer: ISSUER,
4611
+ })(mintReq("vault:typo:admin", bearer));
4612
+ expect(res.status).toBe(400);
4613
+ const body = (await res.json()) as { error: string; error_description: string };
4614
+ expect(body.error).toBe("invalid_scope");
4615
+ expect(body.error_description).toContain("typo");
4616
+ } finally {
4617
+ db.close();
4618
+ h.cleanup();
4619
+ }
4620
+ });
4621
+
4622
+ test("vault:<name>:admin for a REGISTERED vault → 200 (proves the gate isn't over-blocking)", async () => {
4623
+ const h = makeHarness();
4624
+ const { db, bearer } = await seedMint(h, ["work", "default"]);
4625
+ try {
4626
+ const res = await hubFetch(h.dir, {
4627
+ getDb: () => db,
4628
+ manifestPath: h.manifestPath,
4629
+ issuer: ISSUER,
4630
+ })(mintReq("vault:work:admin", bearer));
4631
+ expect(res.status).toBe(200);
4632
+ const body = (await res.json()) as { scope: string };
4633
+ expect(body.scope).toBe("vault:work:admin");
4634
+ } finally {
4635
+ db.close();
4636
+ h.cleanup();
4637
+ }
4638
+ });
4639
+ });
4640
+
4272
4641
  describe("hubFetch routing — /api/hub/upgrade (D4 SPA hub-upgrade)", () => {
4273
4642
  test("POST /api/hub/upgrade dispatches to the handler (401 without bearer, NOT 404)", async () => {
4274
4643
  const h = makeHarness();
@@ -4332,3 +4701,382 @@ describe("hubFetch routing — /api/hub/upgrade (D4 SPA hub-upgrade)", () => {
4332
4701
  }
4333
4702
  });
4334
4703
  });
4704
+
4705
+ // Per-request force-change-password enforcement (P0-1 / hub#469). The redirect
4706
+ // at /login was never enough: a signed-in user holding an admin-set temp
4707
+ // password (`password_changed=false`) could navigate DIRECTLY to /account/* or
4708
+ // a per-vault proxy URL and operate indefinitely on the un-rotated secret.
4709
+ // These tests pin the broad per-request gate: pre-rotation users are bounced
4710
+ // off every /account/* surface AND the per-vault proxy, EXCEPT the rotation/exit
4711
+ // path (/account/change-password + /logout); after rotation all surfaces open.
4712
+ describe("force-change-password per-request gate (#469)", () => {
4713
+ // Seed a signed-in user with a chosen password_changed flag and return a
4714
+ // ready-to-use session cookie. Mirrors the seedFriend helpers in the
4715
+ // account-vault-{token,admin-token} suites.
4716
+ async function seedUser(
4717
+ h: Harness,
4718
+ db: ReturnType<typeof openHubDb>,
4719
+ opts: { passwordChanged: boolean; assignedVaults?: string[] },
4720
+ ): Promise<{ cookie: string }> {
4721
+ const { SESSION_TTL_MS } = await import("../sessions.ts");
4722
+ const user = await createUser(db, "friend", "temp-pw", {
4723
+ allowMulti: true,
4724
+ passwordChanged: opts.passwordChanged,
4725
+ assignedVaults: opts.assignedVaults ?? [],
4726
+ });
4727
+ const session = createSession(db, { userId: user.id });
4728
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
4729
+ return { cookie };
4730
+ }
4731
+
4732
+ test("(a) pre-rotation browser GET /account/ is 302'd to change-password", async () => {
4733
+ const h = makeHarness();
4734
+ try {
4735
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4736
+ const db = openHubDb(hubDbPath(h.dir));
4737
+ try {
4738
+ const { cookie } = await seedUser(h, db, {
4739
+ passwordChanged: false,
4740
+ assignedVaults: ["work"],
4741
+ });
4742
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4743
+ req("/account/", { headers: { cookie, accept: "text/html" } }),
4744
+ );
4745
+ expect(res.status).toBe(302);
4746
+ expect(res.headers.get("location")).toBe("/account/change-password");
4747
+ } finally {
4748
+ db.close();
4749
+ }
4750
+ } finally {
4751
+ h.cleanup();
4752
+ }
4753
+ });
4754
+
4755
+ test("(a) pre-rotation API POST /account/vault-token/<name> is 403 force_change_password", async () => {
4756
+ const h = makeHarness();
4757
+ try {
4758
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4759
+ const db = openHubDb(hubDbPath(h.dir));
4760
+ try {
4761
+ const { cookie } = await seedUser(h, db, {
4762
+ passwordChanged: false,
4763
+ assignedVaults: ["work"],
4764
+ });
4765
+ // No Accept: text/html → treated as an API client → 403 JSON, not a 302.
4766
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4767
+ req("/account/vault-token/work", { method: "POST", headers: { cookie } }),
4768
+ );
4769
+ expect(res.status).toBe(403);
4770
+ const body = (await res.json()) as { error: string };
4771
+ expect(body.error).toBe("force_change_password");
4772
+ } finally {
4773
+ db.close();
4774
+ }
4775
+ } finally {
4776
+ h.cleanup();
4777
+ }
4778
+ });
4779
+
4780
+ test("(a) pre-rotation browser GET of a per-vault proxy URL is 302'd to change-password", async () => {
4781
+ const h = makeHarness();
4782
+ try {
4783
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4784
+ const db = openHubDb(hubDbPath(h.dir));
4785
+ try {
4786
+ const { cookie } = await seedUser(h, db, {
4787
+ passwordChanged: false,
4788
+ assignedVaults: ["work"],
4789
+ });
4790
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4791
+ req("/vault/work/notes/abc", { headers: { cookie, accept: "text/html" } }),
4792
+ );
4793
+ // Gated BEFORE the proxy ever runs — so this is the gate's 302, not a
4794
+ // proxy 404/502.
4795
+ expect(res.status).toBe(302);
4796
+ expect(res.headers.get("location")).toBe("/account/change-password");
4797
+ } finally {
4798
+ db.close();
4799
+ }
4800
+ } finally {
4801
+ h.cleanup();
4802
+ }
4803
+ });
4804
+
4805
+ test("(b) pre-rotation user CAN still reach /account/change-password (GET)", async () => {
4806
+ const h = makeHarness();
4807
+ try {
4808
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4809
+ const db = openHubDb(hubDbPath(h.dir));
4810
+ try {
4811
+ const { cookie } = await seedUser(h, db, {
4812
+ passwordChanged: false,
4813
+ assignedVaults: ["work"],
4814
+ });
4815
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4816
+ req("/account/change-password", { headers: { cookie, accept: "text/html" } }),
4817
+ );
4818
+ // Reaches the real handler (200 form render) — NOT bounced to itself.
4819
+ expect(res.status).toBe(200);
4820
+ const body = await res.text();
4821
+ expect(body.toLowerCase()).toContain("password");
4822
+ } finally {
4823
+ db.close();
4824
+ }
4825
+ } finally {
4826
+ h.cleanup();
4827
+ }
4828
+ });
4829
+
4830
+ test("(b) pre-rotation user CAN still reach /logout (POST)", async () => {
4831
+ const h = makeHarness();
4832
+ try {
4833
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4834
+ const db = openHubDb(hubDbPath(h.dir));
4835
+ try {
4836
+ const { cookie } = await seedUser(h, db, {
4837
+ passwordChanged: false,
4838
+ assignedVaults: ["work"],
4839
+ });
4840
+ // CSRF cookie + matching field so the logout POST passes its own gate;
4841
+ // the point is the force-change gate does NOT intercept /logout.
4842
+ const csrf = generateCsrfToken();
4843
+ const form = new URLSearchParams();
4844
+ form.set("__csrf", csrf);
4845
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4846
+ req("/logout", {
4847
+ method: "POST",
4848
+ headers: {
4849
+ cookie: `${cookie}; ${buildCsrfCookie(csrf)}`,
4850
+ "content-type": "application/x-www-form-urlencoded",
4851
+ },
4852
+ body: form.toString(),
4853
+ }),
4854
+ );
4855
+ // Logout succeeds (302 to /) — it is NOT the force-change 302 to
4856
+ // /account/change-password and NOT a 403.
4857
+ expect(res.status).toBe(302);
4858
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4859
+ expect(res.status).not.toBe(403);
4860
+ } finally {
4861
+ db.close();
4862
+ }
4863
+ } finally {
4864
+ h.cleanup();
4865
+ }
4866
+ });
4867
+
4868
+ test("(c) after rotation, /account/ is reachable (not gated)", async () => {
4869
+ const h = makeHarness();
4870
+ try {
4871
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4872
+ const db = openHubDb(hubDbPath(h.dir));
4873
+ try {
4874
+ const { cookie } = await seedUser(h, db, {
4875
+ passwordChanged: true,
4876
+ assignedVaults: ["work"],
4877
+ });
4878
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4879
+ req("/account/", { headers: { cookie, accept: "text/html" } }),
4880
+ );
4881
+ // Account home renders — not bounced.
4882
+ expect(res.status).toBe(200);
4883
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4884
+ } finally {
4885
+ db.close();
4886
+ }
4887
+ } finally {
4888
+ h.cleanup();
4889
+ }
4890
+ });
4891
+
4892
+ test("(c) after rotation, a per-vault proxy URL is NOT gated (reaches the proxy)", async () => {
4893
+ const h = makeHarness();
4894
+ try {
4895
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4896
+ const db = openHubDb(hubDbPath(h.dir));
4897
+ try {
4898
+ const { cookie } = await seedUser(h, db, {
4899
+ passwordChanged: true,
4900
+ assignedVaults: ["work"],
4901
+ });
4902
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4903
+ req("/vault/work/notes/abc", { headers: { cookie, accept: "text/html" } }),
4904
+ );
4905
+ // No upstream listening → the proxy returns a 404/502, NOT the gate's
4906
+ // 302→change-password / 403. The point is the gate let it through.
4907
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4908
+ const body = res.status === 403 ? ((await res.json()) as { error?: string }) : null;
4909
+ expect(body?.error).not.toBe("force_change_password");
4910
+ } finally {
4911
+ db.close();
4912
+ }
4913
+ } finally {
4914
+ h.cleanup();
4915
+ }
4916
+ });
4917
+
4918
+ test("an UNAUTHENTICATED per-vault proxy request is NOT gated (no hub session)", async () => {
4919
+ const h = makeHarness();
4920
+ try {
4921
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4922
+ const db = openHubDb(hubDbPath(h.dir));
4923
+ try {
4924
+ // No cookie at all — the common Notes/MCP case carrying its own bearer.
4925
+ // forceChangePasswordGate returns null (no session) → proxy handles it.
4926
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4927
+ req("/vault/work/notes/abc", { headers: { accept: "text/html" } }),
4928
+ );
4929
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4930
+ expect(res.status).not.toBe(403);
4931
+ } finally {
4932
+ db.close();
4933
+ }
4934
+ } finally {
4935
+ h.cleanup();
4936
+ }
4937
+ });
4938
+
4939
+ test("(b) pre-rotation user CAN POST /account/change-password (the rotation action itself)", async () => {
4940
+ // The exempt POST: a pre-rotation user submitting their new password must
4941
+ // NOT be intercepted by the gate — it's the only way out of force-change.
4942
+ // `/account/change-password` is dispatched ABOVE the gate, so it never
4943
+ // reaches the choke point. We assert the POST reaches its own handler (it
4944
+ // fails CSRF here → its OWN 400/403, NOT the gate's 302→change-password).
4945
+ const h = makeHarness();
4946
+ try {
4947
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4948
+ const db = openHubDb(hubDbPath(h.dir));
4949
+ try {
4950
+ const { cookie } = await seedUser(h, db, {
4951
+ passwordChanged: false,
4952
+ assignedVaults: ["work"],
4953
+ });
4954
+ const csrf = generateCsrfToken();
4955
+ const form = new URLSearchParams();
4956
+ form.set("__csrf", csrf);
4957
+ form.set("current_password", "temp-pw");
4958
+ form.set("new_password", "rotated-password-123");
4959
+ form.set("new_password_confirm", "rotated-password-123");
4960
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
4961
+ req("/account/change-password", {
4962
+ method: "POST",
4963
+ headers: {
4964
+ cookie: `${cookie}; ${buildCsrfCookie(csrf)}`,
4965
+ "content-type": "application/x-www-form-urlencoded",
4966
+ accept: "text/html",
4967
+ },
4968
+ body: form.toString(),
4969
+ }),
4970
+ );
4971
+ // Reaches its own handler (303 back to /account/ on success, or its own
4972
+ // form re-render). The point: it is NOT the gate's 302 to
4973
+ // change-password and NOT the gate's 403 force_change_password.
4974
+ expect(res.status).not.toBe(403);
4975
+ if (res.status === 302 || res.status === 303) {
4976
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
4977
+ }
4978
+ // And the rotation actually took: the user's flag flipped to true.
4979
+ const { getUserById } = await import("../users.ts");
4980
+ const friend = getUserById(
4981
+ db,
4982
+ db.query<{ id: string }, []>("SELECT id FROM users WHERE username = 'friend'").get()
4983
+ ?.id ?? "",
4984
+ );
4985
+ expect(friend?.passwordChanged).toBe(true);
4986
+ } finally {
4987
+ db.close();
4988
+ }
4989
+ } finally {
4990
+ h.cleanup();
4991
+ }
4992
+ });
4993
+
4994
+ test("(a) bare /account (no trailing slash) is gated on the FIRST hop for a pre-rotation user", async () => {
4995
+ // The bare `/account` 301s to `/account/`; without an explicit match it
4996
+ // would slip past `startsWith("/account/")` and only be gated on the second
4997
+ // hop. The gate must intercept the first request.
4998
+ const h = makeHarness();
4999
+ try {
5000
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
5001
+ const db = openHubDb(hubDbPath(h.dir));
5002
+ try {
5003
+ const { cookie } = await seedUser(h, db, {
5004
+ passwordChanged: false,
5005
+ assignedVaults: ["work"],
5006
+ });
5007
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
5008
+ req("/account", { headers: { cookie, accept: "text/html" } }),
5009
+ );
5010
+ // Gated → 302 to change-password, NOT the 301 → /account/.
5011
+ expect(res.status).toBe(302);
5012
+ expect(res.headers.get("location")).toBe("/account/change-password");
5013
+ } finally {
5014
+ db.close();
5015
+ }
5016
+ } finally {
5017
+ h.cleanup();
5018
+ }
5019
+ });
5020
+
5021
+ test("(a) pre-rotation session at /oauth/authorize is 302'd to change-password (no auth code issued)", async () => {
5022
+ // The important one: a signed-in pre-rotation user must not ride the
5023
+ // consent flow to an auth code → /oauth/token exchange for a vault token
5024
+ // WITHOUT rotating. The gate intercepts /oauth/authorize before any client
5025
+ // validation or code issuance, so no `code=` redirect can be produced.
5026
+ const h = makeHarness();
5027
+ try {
5028
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
5029
+ const db = openHubDb(hubDbPath(h.dir));
5030
+ try {
5031
+ const { cookie } = await seedUser(h, db, {
5032
+ passwordChanged: false,
5033
+ assignedVaults: ["work"],
5034
+ });
5035
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
5036
+ req(
5037
+ "/oauth/authorize?client_id=x&response_type=code&redirect_uri=https://app.example/cb&scope=vault:work:read",
5038
+ { headers: { cookie, accept: "text/html" } },
5039
+ ),
5040
+ );
5041
+ expect(res.status).toBe(302);
5042
+ const location = res.headers.get("location") ?? "";
5043
+ expect(location).toBe("/account/change-password");
5044
+ // No auth code was issued — the redirect is to the rotation rail, not a
5045
+ // client callback carrying a `code=`.
5046
+ expect(location).not.toContain("code=");
5047
+ expect(location).not.toContain("app.example");
5048
+ } finally {
5049
+ db.close();
5050
+ }
5051
+ } finally {
5052
+ h.cleanup();
5053
+ }
5054
+ });
5055
+
5056
+ test("(c) after rotation, /oauth/authorize is NOT gated (reaches the oauth handler)", async () => {
5057
+ const h = makeHarness();
5058
+ try {
5059
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
5060
+ const db = openHubDb(hubDbPath(h.dir));
5061
+ try {
5062
+ const { cookie } = await seedUser(h, db, {
5063
+ passwordChanged: true,
5064
+ assignedVaults: ["work"],
5065
+ });
5066
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
5067
+ req(
5068
+ "/oauth/authorize?client_id=x&response_type=code&redirect_uri=https://app.example/cb&scope=vault:work:read",
5069
+ { headers: { cookie, accept: "text/html" } },
5070
+ ),
5071
+ );
5072
+ // Reaches the real authorize handler (login/consent/error) — NOT bounced
5073
+ // to the change-password rail by the gate.
5074
+ expect(res.headers.get("location")).not.toBe("/account/change-password");
5075
+ } finally {
5076
+ db.close();
5077
+ }
5078
+ } finally {
5079
+ h.cleanup();
5080
+ }
5081
+ });
5082
+ });