@openparachute/hub 0.6.5-rc.7 → 0.7.0

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +34 -0
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections.test.ts +1154 -0
  6. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  7. package/src/__tests__/admin-module-token.test.ts +311 -0
  8. package/src/__tests__/admin-vaults.test.ts +590 -0
  9. package/src/__tests__/api-modules-ops.test.ts +70 -5
  10. package/src/__tests__/api-modules.test.ts +262 -79
  11. package/src/__tests__/hub-db-liveness.test.ts +12 -7
  12. package/src/__tests__/hub-server.test.ts +319 -21
  13. package/src/__tests__/invites.test.ts +27 -0
  14. package/src/__tests__/module-manifest.test.ts +305 -8
  15. package/src/__tests__/serve-boot.test.ts +133 -2
  16. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  17. package/src/__tests__/setup-gate.test.ts +13 -7
  18. package/src/__tests__/setup-wizard.test.ts +228 -1
  19. package/src/__tests__/vault-name.test.ts +20 -5
  20. package/src/__tests__/well-known.test.ts +44 -8
  21. package/src/account-vault-admin-token.ts +43 -14
  22. package/src/admin-channel-token.ts +135 -0
  23. package/src/admin-connections.ts +980 -0
  24. package/src/admin-module-token.ts +197 -0
  25. package/src/admin-vaults.ts +390 -12
  26. package/src/api-hub-upgrade.ts +4 -3
  27. package/src/api-modules-ops.ts +41 -16
  28. package/src/api-modules.ts +238 -116
  29. package/src/api-tokens.ts +8 -5
  30. package/src/commands/serve-boot.ts +80 -3
  31. package/src/commands/setup.ts +4 -4
  32. package/src/connections-store.ts +161 -0
  33. package/src/grants.ts +50 -0
  34. package/src/hub-db-liveness.ts +33 -17
  35. package/src/hub-server.ts +354 -61
  36. package/src/invites.ts +22 -0
  37. package/src/jwt-sign.ts +41 -1
  38. package/src/module-manifest.ts +429 -23
  39. package/src/origin-check.ts +106 -0
  40. package/src/proxy-error-ui.ts +1 -1
  41. package/src/service-spec.ts +132 -41
  42. package/src/setup-wizard.ts +68 -6
  43. package/src/users.ts +11 -0
  44. package/src/vault-name.ts +27 -7
  45. package/src/well-known.ts +41 -33
  46. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  47. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  48. package/web/ui/dist/index.html +2 -2
  49. package/src/__tests__/api-modules-config.test.ts +0 -882
  50. package/src/api-modules-config.ts +0 -421
  51. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  52. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -85,20 +85,26 @@ function vaultEntry(name: string): ServiceEntry {
85
85
  }
86
86
 
87
87
  describe("hubFetch routing", () => {
88
- test("/ serves hub.html with text/html content-type", async () => {
88
+ // Admin-shell IA (R1): the bare `/` page redirects into the single coherent
89
+ // admin shell at `/admin`. The old discovery-page content moved into the
90
+ // shell's Home overview. Only the bare `/` redirects — `/hub.html` still
91
+ // serves the discovery page (static expose file + explicit-`.html` bookmarks).
92
+ test("/ redirects (302) to /admin (admin-shell IA)", async () => {
89
93
  const h = makeHarness();
90
94
  try {
95
+ // No DB → exercises the redirect on the static-fallback path. The
96
+ // redirect sits above the static hub.html serve, so it fires regardless
97
+ // of whether a disk file exists.
91
98
  writeFileSync(join(h.dir, "hub.html"), "<html><body>hi</body></html>");
92
99
  const res = await hubFetch(h.dir)(req("/"));
93
- expect(res.status).toBe(200);
94
- expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
95
- expect(await res.text()).toContain("<html>");
100
+ expect(res.status).toBe(302);
101
+ expect(res.headers.get("location")).toBe("/admin");
96
102
  } finally {
97
103
  h.cleanup();
98
104
  }
99
105
  });
100
106
 
101
- test("/hub.html serves the same file as / (no DB → static fallback)", async () => {
107
+ test("/hub.html still serves the discovery page (no DB → static fallback)", async () => {
102
108
  const h = makeHarness();
103
109
  try {
104
110
  writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
@@ -173,11 +179,14 @@ describe("hubFetch routing", () => {
173
179
  }
174
180
  });
175
181
 
176
- test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
182
+ test("/hub.html renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
177
183
  // The dynamic path takes over from the static disk file the moment a
178
184
  // DB is configured. With no session cookie, we still render — just
179
185
  // with the "Sign in" affordance.
180
186
  //
187
+ // Targets `/hub.html` (not `/`): bare `/` now 302-redirects to the admin
188
+ // shell (R1). `/hub.html` still serves the dynamic discovery page.
189
+ //
181
190
  // hub#259 rc.6: requires an admin row to bypass the fresh-hub
182
191
  // funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
183
192
  // test continues to exercise the signed-out-but-setup-done branch.
@@ -198,7 +207,7 @@ describe("hubFetch routing", () => {
198
207
  const res = await hubFetch(h.dir, {
199
208
  getDb: () => db,
200
209
  manifestPath: h.manifestPath,
201
- })(req("/"));
210
+ })(req("/hub.html"));
202
211
  expect(res.status).toBe(200);
203
212
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
204
213
  const body = await res.text();
@@ -213,10 +222,11 @@ describe("hubFetch routing", () => {
213
222
  }
214
223
  });
215
224
 
216
- test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
225
+ test("/hub.html renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
217
226
  // Same wizard-funnel bypass as the signed-out test above — seed a
218
227
  // vault row and pass an explicit manifestPath so CI doesn't fall back
219
- // to ~/.parachute/services.json.
228
+ // to ~/.parachute/services.json. Targets `/hub.html` (bare `/` now
229
+ // redirects to the admin shell, R1).
220
230
  const h = makeHarness();
221
231
  try {
222
232
  writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
@@ -232,7 +242,7 @@ describe("hubFetch routing", () => {
232
242
  const res = await hubFetch(h.dir, {
233
243
  getDb: () => db,
234
244
  manifestPath: h.manifestPath,
235
- })(req("/", { headers: { cookie } }));
245
+ })(req("/hub.html", { headers: { cookie } }));
236
246
  expect(res.status).toBe(200);
237
247
  const body = await res.text();
238
248
  expect(body).toContain("Signed in as");
@@ -249,6 +259,36 @@ describe("hubFetch routing", () => {
249
259
  }
250
260
  });
251
261
 
262
+ test("/ → /admin even with a DB + admin + vault (setup complete; not the wizard funnel)", async () => {
263
+ // Distinguishes the admin-shell redirect (302 → /admin) from the fresh-hub
264
+ // wizard funnel (302 → /admin/setup). With admin + vault seeded, the wizard
265
+ // funnel is bypassed and the bare `/` lands on the admin shell. Also proves
266
+ // the redirect fires on the DB-configured path, not just the static
267
+ // fallback. `/hub.html` under the same setup still renders the discovery
268
+ // page (asserted separately above) — only bare `/` redirects.
269
+ const h = makeHarness();
270
+ try {
271
+ writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
272
+ const db = openHubDb(hubDbPath(h.dir));
273
+ try {
274
+ const { createUser } = await import("../users.ts");
275
+ await createUser(db, "owner", "pw");
276
+ const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
277
+ const rootRes = await handler(req("/"));
278
+ expect(rootRes.status).toBe(302);
279
+ expect(rootRes.headers.get("location")).toBe("/admin");
280
+ // /hub.html under the same setup-complete state still serves discovery.
281
+ const hubHtmlRes = await handler(req("/hub.html"));
282
+ expect(hubHtmlRes.status).toBe(200);
283
+ expect(hubHtmlRes.headers.get("content-type")).toBe("text/html; charset=utf-8");
284
+ } finally {
285
+ db.close();
286
+ }
287
+ } finally {
288
+ h.cleanup();
289
+ }
290
+ });
291
+
252
292
  test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
253
293
  const h = makeHarness();
254
294
  try {
@@ -648,8 +688,9 @@ describe("hubFetch routing", () => {
648
688
  test("missing hub.html returns 404 rather than crashing", async () => {
649
689
  const h = makeHarness();
650
690
  try {
651
- // dir exists but no files in it
652
- const res = await hubFetch(h.dir)(req("/"));
691
+ // dir exists but no files in it. Targets `/hub.html` directly — bare `/`
692
+ // now 302-redirects to the admin shell before reaching the static serve.
693
+ const res = await hubFetch(h.dir)(req("/hub.html"));
653
694
  expect(res.status).toBe(404);
654
695
  } finally {
655
696
  h.cleanup();
@@ -932,23 +973,28 @@ describe("hubFetch routing", () => {
932
973
  // 301-redirect to the new /admin/* mount. Tests cover every entry in the
933
974
  // dispatch — operator bookmarks landing on any of these still work.
934
975
 
935
- test("301: /vault /admin/vaults", async () => {
976
+ // B5 (2026-06-09 hub-module-boundary): the legacy `/vault[/new]` 301s point
977
+ // DIRECTLY at vault's daemon-level admin surface — not at /admin/vaults,
978
+ // whose SPA route is now just a feature-detected forwarder. Pointing at the
979
+ // final target avoids a redirect → SPA-load → client-side-forward chain.
980
+
981
+ test("301: /vault → /vault/admin/ (direct — no chain through /admin/vaults)", async () => {
936
982
  const h = makeHarness();
937
983
  try {
938
984
  const res = await hubFetch(h.dir)(req("/vault"));
939
985
  expect(res.status).toBe(301);
940
- expect(res.headers.get("location")).toBe("/admin/vaults");
986
+ expect(res.headers.get("location")).toBe("/vault/admin/");
941
987
  } finally {
942
988
  h.cleanup();
943
989
  }
944
990
  });
945
991
 
946
- test("301: /vault/new → /admin/vaults/new", async () => {
992
+ test("301: /vault/new → /vault/admin/ (create relocated into vault's surface)", async () => {
947
993
  const h = makeHarness();
948
994
  try {
949
995
  const res = await hubFetch(h.dir)(req("/vault/new"));
950
996
  expect(res.status).toBe(301);
951
- expect(res.headers.get("location")).toBe("/admin/vaults/new");
997
+ expect(res.headers.get("location")).toBe("/vault/admin/");
952
998
  } finally {
953
999
  h.cleanup();
954
1000
  }
@@ -959,7 +1005,7 @@ describe("hubFetch routing", () => {
959
1005
  try {
960
1006
  const res = await hubFetch(h.dir)(req("/vault?next=foo"));
961
1007
  expect(res.status).toBe(301);
962
- expect(res.headers.get("location")).toBe("/admin/vaults?next=foo");
1008
+ expect(res.headers.get("location")).toBe("/vault/admin/?next=foo");
963
1009
  } finally {
964
1010
  h.cleanup();
965
1011
  }
@@ -1622,7 +1668,7 @@ describe("hubFetch routing", () => {
1622
1668
  }
1623
1669
  });
1624
1670
 
1625
- test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
1671
+ test("live Bun.serve round-trip: /, /hub.html and /.well-known resolve", async () => {
1626
1672
  const h = makeHarness();
1627
1673
  try {
1628
1674
  writeFileSync(join(h.dir, "hub.html"), "<html>live</html>");
@@ -1634,9 +1680,15 @@ describe("hubFetch routing", () => {
1634
1680
  });
1635
1681
  try {
1636
1682
  const base = `http://127.0.0.1:${server.port}`;
1637
- const r1 = await fetch(`${base}/`);
1638
- expect(r1.status).toBe(200);
1639
- expect(await r1.text()).toBe("<html>live</html>");
1683
+ // Bare `/` 302-redirects into the admin shell (R1). `redirect: "manual"`
1684
+ // so we observe the redirect itself rather than following it to /admin.
1685
+ const r1 = await fetch(`${base}/`, { redirect: "manual" });
1686
+ expect(r1.status).toBe(302);
1687
+ expect(r1.headers.get("location")).toBe("/admin");
1688
+ // The discovery page still serves at /hub.html.
1689
+ const rHub = await fetch(`${base}/hub.html`);
1690
+ expect(rHub.status).toBe(200);
1691
+ expect(await rHub.text()).toBe("<html>live</html>");
1640
1692
  const r2 = await fetch(`${base}/.well-known/parachute.json`);
1641
1693
  expect(r2.headers.get("content-type")).toBe("application/json");
1642
1694
  expect(await r2.json()).toEqual({ vaults: [], services: [] });
@@ -4051,6 +4103,208 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
4051
4103
  });
4052
4104
  });
4053
4105
 
4106
+ describe("hubFetch /vault/admin daemon-level mount (B-route, hub-module-boundary)", () => {
4107
+ // /vault/admin + /vault/admin/* route to the vault MODULE's daemon
4108
+ // (resolved via findServiceByShort, not findVaultUpstream) — the
4109
+ // daemon-level multi-vault admin surface, not an instance path. "admin"
4110
+ // is a reserved vault name (B2h) so no instance can ever claim the mount.
4111
+
4112
+ /** Upstream that ECHOES the request path, so the tests can pin both which
4113
+ * daemon got the request and that the FULL path was forwarded (vault's
4114
+ * row declares no stripPrefix). */
4115
+ function startEchoUpstream(tag: string): { port: number; stop: () => void } {
4116
+ const server = Bun.serve({
4117
+ port: 0,
4118
+ hostname: "127.0.0.1",
4119
+ fetch: (r) =>
4120
+ new Response(JSON.stringify({ tag, path: new URL(r.url).pathname }), {
4121
+ status: 200,
4122
+ headers: { "content-type": "application/json" },
4123
+ }),
4124
+ });
4125
+ return { port: server.port as number, stop: () => server.stop(true) };
4126
+ }
4127
+
4128
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
4129
+
4130
+ test("/vault/admin and /vault/admin/* route to the vault module's daemon port with the FULL path", async () => {
4131
+ const h = makeHarness();
4132
+ const upstream = startEchoUpstream("vault-daemon");
4133
+ try {
4134
+ writeManifest(
4135
+ {
4136
+ services: [
4137
+ {
4138
+ // The canonical self-registered row name — findServiceByShort
4139
+ // resolves `parachute-vault` → short "vault".
4140
+ name: "parachute-vault",
4141
+ port: upstream.port,
4142
+ paths: ["/vault/default"],
4143
+ health: "/vault/default/health",
4144
+ version: "0.5.0",
4145
+ },
4146
+ ],
4147
+ },
4148
+ h.manifestPath,
4149
+ );
4150
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4151
+ for (const path of ["/vault/admin", "/vault/admin/", "/vault/admin/assets/app.js"]) {
4152
+ const res = await fetcher(req(path));
4153
+ expect(res.status).toBe(200);
4154
+ const body = (await res.json()) as { tag: string; path: string };
4155
+ expect(body.tag).toBe("vault-daemon");
4156
+ // Full path forwarded — no prefix strip (vault routes /vault/admin itself).
4157
+ expect(body.path).toBe(path);
4158
+ }
4159
+ } finally {
4160
+ upstream.stop();
4161
+ h.cleanup();
4162
+ }
4163
+ });
4164
+
4165
+ test("/vault/admin is NOT captured by a per-instance mount — daemon-level route wins", async () => {
4166
+ // A pathological vault row mounted at the bare `/vault` would prefix-match
4167
+ // `/vault/admin` in findVaultUpstream; the daemon-level branch runs FIRST
4168
+ // so the per-vault proxy never sees the path (and no consumer can
4169
+ // fabricate a phantom instance named "admin"). With distinct upstream
4170
+ // ports we can tell exactly which one served the request.
4171
+ const h = makeHarness();
4172
+ const daemonUpstream = startEchoUpstream("vault-daemon");
4173
+ const instanceUpstream = startEchoUpstream("vault-instance");
4174
+ try {
4175
+ writeManifest(
4176
+ {
4177
+ services: [
4178
+ {
4179
+ name: "parachute-vault",
4180
+ port: daemonUpstream.port,
4181
+ paths: ["/vault/default"],
4182
+ health: "/vault/default/health",
4183
+ version: "0.5.0",
4184
+ },
4185
+ {
4186
+ // Pathological-but-representable bare mount on a second row.
4187
+ name: "parachute-vault-bare",
4188
+ port: instanceUpstream.port,
4189
+ paths: ["/vault"],
4190
+ health: "/vault/health",
4191
+ version: "0.5.0",
4192
+ },
4193
+ ],
4194
+ },
4195
+ h.manifestPath,
4196
+ );
4197
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4198
+ const res = await fetcher(req("/vault/admin/"));
4199
+ expect(res.status).toBe(200);
4200
+ const body = (await res.json()) as { tag: string };
4201
+ expect(body.tag).toBe("vault-daemon");
4202
+ } finally {
4203
+ daemonUpstream.stop();
4204
+ instanceUpstream.stop();
4205
+ h.cleanup();
4206
+ }
4207
+ });
4208
+
4209
+ test('a vault instance named "adminx" still routes per-instance (exact-segment match only)', async () => {
4210
+ const h = makeHarness();
4211
+ const upstream = startEchoUpstream("vault-daemon");
4212
+ try {
4213
+ writeManifest(
4214
+ {
4215
+ services: [
4216
+ {
4217
+ name: "parachute-vault",
4218
+ port: upstream.port,
4219
+ paths: ["/vault/adminx"],
4220
+ health: "/vault/adminx/health",
4221
+ version: "0.5.0",
4222
+ },
4223
+ ],
4224
+ },
4225
+ h.manifestPath,
4226
+ );
4227
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4228
+ // Per-instance path — proxied through the per-vault dispatch (full
4229
+ // path forwarded; vault rows don't strip).
4230
+ const res = await fetcher(req("/vault/adminx/health"));
4231
+ expect(res.status).toBe(200);
4232
+ const body = (await res.json()) as { path: string };
4233
+ expect(body.path).toBe("/vault/adminx/health");
4234
+ } finally {
4235
+ upstream.stop();
4236
+ h.cleanup();
4237
+ }
4238
+ });
4239
+
4240
+ test("404 cleanly when no vault module is installed", async () => {
4241
+ const h = makeHarness();
4242
+ try {
4243
+ writeManifest(
4244
+ {
4245
+ services: [
4246
+ {
4247
+ // Some other module installed — must not capture /vault/admin.
4248
+ name: "parachute-scribe",
4249
+ port: 1942,
4250
+ paths: ["/scribe"],
4251
+ health: "/scribe/health",
4252
+ version: "0.1.0",
4253
+ },
4254
+ ],
4255
+ },
4256
+ h.manifestPath,
4257
+ );
4258
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4259
+ const res = await fetcher(req("/vault/admin/"));
4260
+ expect(res.status).toBe(404);
4261
+ } finally {
4262
+ h.cleanup();
4263
+ }
4264
+ });
4265
+
4266
+ test('publicExposure: "loopback" on the vault row cloaks /vault/admin from tailnet/public (same gate as proxyToVault)', async () => {
4267
+ const h = makeHarness();
4268
+ const upstream = startEchoUpstream("vault-daemon");
4269
+ try {
4270
+ writeManifest(
4271
+ {
4272
+ services: [
4273
+ {
4274
+ name: "parachute-vault",
4275
+ port: upstream.port,
4276
+ paths: ["/vault/default"],
4277
+ health: "/vault/default/health",
4278
+ version: "0.5.0",
4279
+ publicExposure: "loopback",
4280
+ },
4281
+ ],
4282
+ },
4283
+ h.manifestPath,
4284
+ );
4285
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4286
+ // Tailnet caller → cloaked.
4287
+ const tailnet = await fetcher(
4288
+ req("/vault/admin/", { headers: { "Tailscale-User-Login": "alice@example.com" } }),
4289
+ );
4290
+ expect(tailnet.status).toBe(404);
4291
+ // Public caller → cloaked.
4292
+ const pub = await fetcher(req("/vault/admin/", { headers: { "CF-Ray": "abc123" } }));
4293
+ expect(pub.status).toBe(404);
4294
+ // Header-absent NON-loopback peer (0.0.0.0 bind) → cloaked (#526 posture).
4295
+ const direct = await fetcher(req("/vault/admin/"), fakeServer("198.51.100.4"));
4296
+ expect(direct.status).toBe(404);
4297
+ // Loopback peer → reaches the daemon.
4298
+ const loop = await fetcher(req("/vault/admin/"), fakeServer("127.0.0.1"));
4299
+ expect(loop.status).toBe(200);
4300
+ expect(((await loop.json()) as { tag: string }).tag).toBe("vault-daemon");
4301
+ } finally {
4302
+ upstream.stop();
4303
+ h.cleanup();
4304
+ }
4305
+ });
4306
+ });
4307
+
4054
4308
  /** Find a port that no one is listening on by binding briefly and releasing. */
4055
4309
  async function pickClosedPort(): Promise<number> {
4056
4310
  const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
@@ -5004,6 +5258,50 @@ describe("force-change-password per-request gate (#469)", () => {
5004
5258
  }
5005
5259
  });
5006
5260
 
5261
+ test("(a) pre-rotation browser GET /vault/admin/ is 302'd to change-password (B-route gate parity)", async () => {
5262
+ // The daemon-level /vault/admin mount applies the SAME per-request
5263
+ // force-change-password gate as the per-vault proxy — a pre-rotation
5264
+ // session can't reach the multi-vault admin surface on an un-rotated
5265
+ // temp password either.
5266
+ const h = makeHarness();
5267
+ try {
5268
+ writeManifest(
5269
+ {
5270
+ services: [
5271
+ {
5272
+ // Canonical row name so findServiceByShort would resolve it if
5273
+ // the request ever got past the gate.
5274
+ name: "parachute-vault",
5275
+ port: 1940,
5276
+ paths: ["/vault/work"],
5277
+ health: "/vault/work/health",
5278
+ version: "0.5.0",
5279
+ },
5280
+ ],
5281
+ },
5282
+ h.manifestPath,
5283
+ );
5284
+ const db = openHubDb(hubDbPath(h.dir));
5285
+ try {
5286
+ const { cookie } = await seedUser(h, db, {
5287
+ passwordChanged: false,
5288
+ assignedVaults: ["work"],
5289
+ });
5290
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
5291
+ req("/vault/admin/", { headers: { cookie, accept: "text/html" } }),
5292
+ );
5293
+ // Gated BEFORE proxyToVaultAdmin ever runs — the gate's 302, not a
5294
+ // proxy 404/502.
5295
+ expect(res.status).toBe(302);
5296
+ expect(res.headers.get("location")).toBe("/account/change-password");
5297
+ } finally {
5298
+ db.close();
5299
+ }
5300
+ } finally {
5301
+ h.cleanup();
5302
+ }
5303
+ });
5304
+
5007
5305
  test("(b) pre-rotation user CAN still reach /account/change-password (GET)", async () => {
5008
5306
  const h = makeHarness();
5009
5307
  try {
@@ -27,6 +27,7 @@ import {
27
27
  issueInvite,
28
28
  listInvites,
29
29
  revokeInvite,
30
+ revokeInvitesForVault,
30
31
  } from "../invites.ts";
31
32
  import { createUser } from "../users.ts";
32
33
 
@@ -183,6 +184,32 @@ describe("revokeInvite", () => {
183
184
  });
184
185
  });
185
186
 
187
+ describe("revokeInvitesForVault (B1 cascade step)", () => {
188
+ test("a NULL-vault_name invite (redeemer-named flow) is NOT revoked by any vault's cascade", async () => {
189
+ // The cascade invalidates invites PINNED to the deleted vault — an
190
+ // unpinned invite (vault_name NULL: the redeemer names their own vault)
191
+ // can't resurrect a specific name, so it must survive every vault's
192
+ // delete. SQL `vault_name = ?` never matches NULL; pin that boundary.
193
+ const { db, adminId, cleanup } = await makeDb();
194
+ try {
195
+ const unpinned = issueInvite(db, { createdBy: adminId }); // vault_name NULL
196
+ const pinned = issueInvite(db, { createdBy: adminId, vaultName: "work" });
197
+
198
+ expect(revokeInvitesForVault(db, "work")).toBe(1);
199
+ // The pinned invite is revoked; the unpinned one rides on untouched.
200
+ expect(findInviteByRawToken(db, pinned.rawToken)?.revokedAt).not.toBeNull();
201
+ expect(findInviteByRawToken(db, unpinned.rawToken)?.revokedAt).toBeNull();
202
+
203
+ // A second sweep (or another vault's sweep) finds nothing more.
204
+ expect(revokeInvitesForVault(db, "work")).toBe(0);
205
+ expect(revokeInvitesForVault(db, "other")).toBe(0);
206
+ expect(findInviteByRawToken(db, unpinned.rawToken)?.revokedAt).toBeNull();
207
+ } finally {
208
+ cleanup();
209
+ }
210
+ });
211
+ });
212
+
186
213
  describe("inviteStatus / listInvites", () => {
187
214
  test("derives pending / redeemed / expired / revoked", async () => {
188
215
  const { db, adminId, cleanup } = await makeDb();