@openparachute/hub 0.6.5-rc.8 → 0.7.1

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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  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-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -13,6 +13,7 @@ import {
13
13
  hubFetch,
14
14
  layerOf,
15
15
  parseArgs,
16
+ resolveClientIp,
16
17
  } from "../hub-server.ts";
17
18
  import { setNotesRedirectDisabled } from "../hub-settings.ts";
18
19
  import { clearNotesRedirectLogState } from "../notes-redirect.ts";
@@ -85,20 +86,26 @@ function vaultEntry(name: string): ServiceEntry {
85
86
  }
86
87
 
87
88
  describe("hubFetch routing", () => {
88
- test("/ serves hub.html with text/html content-type", async () => {
89
+ // Admin-shell IA (R1): the bare `/` page redirects into the single coherent
90
+ // admin shell at `/admin`. The old discovery-page content moved into the
91
+ // shell's Home overview. Only the bare `/` redirects — `/hub.html` still
92
+ // serves the discovery page (static expose file + explicit-`.html` bookmarks).
93
+ test("/ redirects (302) to /admin (admin-shell IA)", async () => {
89
94
  const h = makeHarness();
90
95
  try {
96
+ // No DB → exercises the redirect on the static-fallback path. The
97
+ // redirect sits above the static hub.html serve, so it fires regardless
98
+ // of whether a disk file exists.
91
99
  writeFileSync(join(h.dir, "hub.html"), "<html><body>hi</body></html>");
92
100
  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>");
101
+ expect(res.status).toBe(302);
102
+ expect(res.headers.get("location")).toBe("/admin");
96
103
  } finally {
97
104
  h.cleanup();
98
105
  }
99
106
  });
100
107
 
101
- test("/hub.html serves the same file as / (no DB → static fallback)", async () => {
108
+ test("/hub.html still serves the discovery page (no DB → static fallback)", async () => {
102
109
  const h = makeHarness();
103
110
  try {
104
111
  writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
@@ -173,11 +180,14 @@ describe("hubFetch routing", () => {
173
180
  }
174
181
  });
175
182
 
176
- test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
183
+ test("/hub.html renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
177
184
  // The dynamic path takes over from the static disk file the moment a
178
185
  // DB is configured. With no session cookie, we still render — just
179
186
  // with the "Sign in" affordance.
180
187
  //
188
+ // Targets `/hub.html` (not `/`): bare `/` now 302-redirects to the admin
189
+ // shell (R1). `/hub.html` still serves the dynamic discovery page.
190
+ //
181
191
  // hub#259 rc.6: requires an admin row to bypass the fresh-hub
182
192
  // funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
183
193
  // test continues to exercise the signed-out-but-setup-done branch.
@@ -198,7 +208,7 @@ describe("hubFetch routing", () => {
198
208
  const res = await hubFetch(h.dir, {
199
209
  getDb: () => db,
200
210
  manifestPath: h.manifestPath,
201
- })(req("/"));
211
+ })(req("/hub.html"));
202
212
  expect(res.status).toBe(200);
203
213
  expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
204
214
  const body = await res.text();
@@ -213,10 +223,11 @@ describe("hubFetch routing", () => {
213
223
  }
214
224
  });
215
225
 
216
- test("/ renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
226
+ test("/hub.html renders 'Signed in as <name>' + sign-out form when session cookie is active (rc.13)", async () => {
217
227
  // Same wizard-funnel bypass as the signed-out test above — seed a
218
228
  // vault row and pass an explicit manifestPath so CI doesn't fall back
219
- // to ~/.parachute/services.json.
229
+ // to ~/.parachute/services.json. Targets `/hub.html` (bare `/` now
230
+ // redirects to the admin shell, R1).
220
231
  const h = makeHarness();
221
232
  try {
222
233
  writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
@@ -232,7 +243,7 @@ describe("hubFetch routing", () => {
232
243
  const res = await hubFetch(h.dir, {
233
244
  getDb: () => db,
234
245
  manifestPath: h.manifestPath,
235
- })(req("/", { headers: { cookie } }));
246
+ })(req("/hub.html", { headers: { cookie } }));
236
247
  expect(res.status).toBe(200);
237
248
  const body = await res.text();
238
249
  expect(body).toContain("Signed in as");
@@ -249,6 +260,36 @@ describe("hubFetch routing", () => {
249
260
  }
250
261
  });
251
262
 
263
+ test("/ → /admin even with a DB + admin + vault (setup complete; not the wizard funnel)", async () => {
264
+ // Distinguishes the admin-shell redirect (302 → /admin) from the fresh-hub
265
+ // wizard funnel (302 → /admin/setup). With admin + vault seeded, the wizard
266
+ // funnel is bypassed and the bare `/` lands on the admin shell. Also proves
267
+ // the redirect fires on the DB-configured path, not just the static
268
+ // fallback. `/hub.html` under the same setup still renders the discovery
269
+ // page (asserted separately above) — only bare `/` redirects.
270
+ const h = makeHarness();
271
+ try {
272
+ writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
273
+ const db = openHubDb(hubDbPath(h.dir));
274
+ try {
275
+ const { createUser } = await import("../users.ts");
276
+ await createUser(db, "owner", "pw");
277
+ const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
278
+ const rootRes = await handler(req("/"));
279
+ expect(rootRes.status).toBe(302);
280
+ expect(rootRes.headers.get("location")).toBe("/admin");
281
+ // /hub.html under the same setup-complete state still serves discovery.
282
+ const hubHtmlRes = await handler(req("/hub.html"));
283
+ expect(hubHtmlRes.status).toBe(200);
284
+ expect(hubHtmlRes.headers.get("content-type")).toBe("text/html; charset=utf-8");
285
+ } finally {
286
+ db.close();
287
+ }
288
+ } finally {
289
+ h.cleanup();
290
+ }
291
+ });
292
+
252
293
  test("/.well-known/parachute.json builds the doc dynamically from services.json", async () => {
253
294
  const h = makeHarness();
254
295
  try {
@@ -648,8 +689,9 @@ describe("hubFetch routing", () => {
648
689
  test("missing hub.html returns 404 rather than crashing", async () => {
649
690
  const h = makeHarness();
650
691
  try {
651
- // dir exists but no files in it
652
- const res = await hubFetch(h.dir)(req("/"));
692
+ // dir exists but no files in it. Targets `/hub.html` directly — bare `/`
693
+ // now 302-redirects to the admin shell before reaching the static serve.
694
+ const res = await hubFetch(h.dir)(req("/hub.html"));
653
695
  expect(res.status).toBe(404);
654
696
  } finally {
655
697
  h.cleanup();
@@ -932,23 +974,28 @@ describe("hubFetch routing", () => {
932
974
  // 301-redirect to the new /admin/* mount. Tests cover every entry in the
933
975
  // dispatch — operator bookmarks landing on any of these still work.
934
976
 
935
- test("301: /vault /admin/vaults", async () => {
977
+ // B5 (2026-06-09 hub-module-boundary): the legacy `/vault[/new]` 301s point
978
+ // DIRECTLY at vault's daemon-level admin surface — not at /admin/vaults,
979
+ // whose SPA route is now just a feature-detected forwarder. Pointing at the
980
+ // final target avoids a redirect → SPA-load → client-side-forward chain.
981
+
982
+ test("301: /vault → /vault/admin/ (direct — no chain through /admin/vaults)", async () => {
936
983
  const h = makeHarness();
937
984
  try {
938
985
  const res = await hubFetch(h.dir)(req("/vault"));
939
986
  expect(res.status).toBe(301);
940
- expect(res.headers.get("location")).toBe("/admin/vaults");
987
+ expect(res.headers.get("location")).toBe("/vault/admin/");
941
988
  } finally {
942
989
  h.cleanup();
943
990
  }
944
991
  });
945
992
 
946
- test("301: /vault/new → /admin/vaults/new", async () => {
993
+ test("301: /vault/new → /vault/admin/ (create relocated into vault's surface)", async () => {
947
994
  const h = makeHarness();
948
995
  try {
949
996
  const res = await hubFetch(h.dir)(req("/vault/new"));
950
997
  expect(res.status).toBe(301);
951
- expect(res.headers.get("location")).toBe("/admin/vaults/new");
998
+ expect(res.headers.get("location")).toBe("/vault/admin/");
952
999
  } finally {
953
1000
  h.cleanup();
954
1001
  }
@@ -959,7 +1006,7 @@ describe("hubFetch routing", () => {
959
1006
  try {
960
1007
  const res = await hubFetch(h.dir)(req("/vault?next=foo"));
961
1008
  expect(res.status).toBe(301);
962
- expect(res.headers.get("location")).toBe("/admin/vaults?next=foo");
1009
+ expect(res.headers.get("location")).toBe("/vault/admin/?next=foo");
963
1010
  } finally {
964
1011
  h.cleanup();
965
1012
  }
@@ -1622,7 +1669,7 @@ describe("hubFetch routing", () => {
1622
1669
  }
1623
1670
  });
1624
1671
 
1625
- test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
1672
+ test("live Bun.serve round-trip: /, /hub.html and /.well-known resolve", async () => {
1626
1673
  const h = makeHarness();
1627
1674
  try {
1628
1675
  writeFileSync(join(h.dir, "hub.html"), "<html>live</html>");
@@ -1634,9 +1681,15 @@ describe("hubFetch routing", () => {
1634
1681
  });
1635
1682
  try {
1636
1683
  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>");
1684
+ // Bare `/` 302-redirects into the admin shell (R1). `redirect: "manual"`
1685
+ // so we observe the redirect itself rather than following it to /admin.
1686
+ const r1 = await fetch(`${base}/`, { redirect: "manual" });
1687
+ expect(r1.status).toBe(302);
1688
+ expect(r1.headers.get("location")).toBe("/admin");
1689
+ // The discovery page still serves at /hub.html.
1690
+ const rHub = await fetch(`${base}/hub.html`);
1691
+ expect(rHub.status).toBe(200);
1692
+ expect(await rHub.text()).toBe("<html>live</html>");
1640
1693
  const r2 = await fetch(`${base}/.well-known/parachute.json`);
1641
1694
  expect(r2.headers.get("content-type")).toBe("application/json");
1642
1695
  expect(await r2.json()).toEqual({ vaults: [], services: [] });
@@ -4051,6 +4104,208 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
4051
4104
  });
4052
4105
  });
4053
4106
 
4107
+ describe("hubFetch /vault/admin daemon-level mount (B-route, hub-module-boundary)", () => {
4108
+ // /vault/admin + /vault/admin/* route to the vault MODULE's daemon
4109
+ // (resolved via findServiceByShort, not findVaultUpstream) — the
4110
+ // daemon-level multi-vault admin surface, not an instance path. "admin"
4111
+ // is a reserved vault name (B2h) so no instance can ever claim the mount.
4112
+
4113
+ /** Upstream that ECHOES the request path, so the tests can pin both which
4114
+ * daemon got the request and that the FULL path was forwarded (vault's
4115
+ * row declares no stripPrefix). */
4116
+ function startEchoUpstream(tag: string): { port: number; stop: () => void } {
4117
+ const server = Bun.serve({
4118
+ port: 0,
4119
+ hostname: "127.0.0.1",
4120
+ fetch: (r) =>
4121
+ new Response(JSON.stringify({ tag, path: new URL(r.url).pathname }), {
4122
+ status: 200,
4123
+ headers: { "content-type": "application/json" },
4124
+ }),
4125
+ });
4126
+ return { port: server.port as number, stop: () => server.stop(true) };
4127
+ }
4128
+
4129
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
4130
+
4131
+ test("/vault/admin and /vault/admin/* route to the vault module's daemon port with the FULL path", async () => {
4132
+ const h = makeHarness();
4133
+ const upstream = startEchoUpstream("vault-daemon");
4134
+ try {
4135
+ writeManifest(
4136
+ {
4137
+ services: [
4138
+ {
4139
+ // The canonical self-registered row name — findServiceByShort
4140
+ // resolves `parachute-vault` → short "vault".
4141
+ name: "parachute-vault",
4142
+ port: upstream.port,
4143
+ paths: ["/vault/default"],
4144
+ health: "/vault/default/health",
4145
+ version: "0.5.0",
4146
+ },
4147
+ ],
4148
+ },
4149
+ h.manifestPath,
4150
+ );
4151
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4152
+ for (const path of ["/vault/admin", "/vault/admin/", "/vault/admin/assets/app.js"]) {
4153
+ const res = await fetcher(req(path));
4154
+ expect(res.status).toBe(200);
4155
+ const body = (await res.json()) as { tag: string; path: string };
4156
+ expect(body.tag).toBe("vault-daemon");
4157
+ // Full path forwarded — no prefix strip (vault routes /vault/admin itself).
4158
+ expect(body.path).toBe(path);
4159
+ }
4160
+ } finally {
4161
+ upstream.stop();
4162
+ h.cleanup();
4163
+ }
4164
+ });
4165
+
4166
+ test("/vault/admin is NOT captured by a per-instance mount — daemon-level route wins", async () => {
4167
+ // A pathological vault row mounted at the bare `/vault` would prefix-match
4168
+ // `/vault/admin` in findVaultUpstream; the daemon-level branch runs FIRST
4169
+ // so the per-vault proxy never sees the path (and no consumer can
4170
+ // fabricate a phantom instance named "admin"). With distinct upstream
4171
+ // ports we can tell exactly which one served the request.
4172
+ const h = makeHarness();
4173
+ const daemonUpstream = startEchoUpstream("vault-daemon");
4174
+ const instanceUpstream = startEchoUpstream("vault-instance");
4175
+ try {
4176
+ writeManifest(
4177
+ {
4178
+ services: [
4179
+ {
4180
+ name: "parachute-vault",
4181
+ port: daemonUpstream.port,
4182
+ paths: ["/vault/default"],
4183
+ health: "/vault/default/health",
4184
+ version: "0.5.0",
4185
+ },
4186
+ {
4187
+ // Pathological-but-representable bare mount on a second row.
4188
+ name: "parachute-vault-bare",
4189
+ port: instanceUpstream.port,
4190
+ paths: ["/vault"],
4191
+ health: "/vault/health",
4192
+ version: "0.5.0",
4193
+ },
4194
+ ],
4195
+ },
4196
+ h.manifestPath,
4197
+ );
4198
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4199
+ const res = await fetcher(req("/vault/admin/"));
4200
+ expect(res.status).toBe(200);
4201
+ const body = (await res.json()) as { tag: string };
4202
+ expect(body.tag).toBe("vault-daemon");
4203
+ } finally {
4204
+ daemonUpstream.stop();
4205
+ instanceUpstream.stop();
4206
+ h.cleanup();
4207
+ }
4208
+ });
4209
+
4210
+ test('a vault instance named "adminx" still routes per-instance (exact-segment match only)', async () => {
4211
+ const h = makeHarness();
4212
+ const upstream = startEchoUpstream("vault-daemon");
4213
+ try {
4214
+ writeManifest(
4215
+ {
4216
+ services: [
4217
+ {
4218
+ name: "parachute-vault",
4219
+ port: upstream.port,
4220
+ paths: ["/vault/adminx"],
4221
+ health: "/vault/adminx/health",
4222
+ version: "0.5.0",
4223
+ },
4224
+ ],
4225
+ },
4226
+ h.manifestPath,
4227
+ );
4228
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4229
+ // Per-instance path — proxied through the per-vault dispatch (full
4230
+ // path forwarded; vault rows don't strip).
4231
+ const res = await fetcher(req("/vault/adminx/health"));
4232
+ expect(res.status).toBe(200);
4233
+ const body = (await res.json()) as { path: string };
4234
+ expect(body.path).toBe("/vault/adminx/health");
4235
+ } finally {
4236
+ upstream.stop();
4237
+ h.cleanup();
4238
+ }
4239
+ });
4240
+
4241
+ test("404 cleanly when no vault module is installed", async () => {
4242
+ const h = makeHarness();
4243
+ try {
4244
+ writeManifest(
4245
+ {
4246
+ services: [
4247
+ {
4248
+ // Some other module installed — must not capture /vault/admin.
4249
+ name: "parachute-scribe",
4250
+ port: 1942,
4251
+ paths: ["/scribe"],
4252
+ health: "/scribe/health",
4253
+ version: "0.1.0",
4254
+ },
4255
+ ],
4256
+ },
4257
+ h.manifestPath,
4258
+ );
4259
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4260
+ const res = await fetcher(req("/vault/admin/"));
4261
+ expect(res.status).toBe(404);
4262
+ } finally {
4263
+ h.cleanup();
4264
+ }
4265
+ });
4266
+
4267
+ test('publicExposure: "loopback" on the vault row cloaks /vault/admin from tailnet/public (same gate as proxyToVault)', async () => {
4268
+ const h = makeHarness();
4269
+ const upstream = startEchoUpstream("vault-daemon");
4270
+ try {
4271
+ writeManifest(
4272
+ {
4273
+ services: [
4274
+ {
4275
+ name: "parachute-vault",
4276
+ port: upstream.port,
4277
+ paths: ["/vault/default"],
4278
+ health: "/vault/default/health",
4279
+ version: "0.5.0",
4280
+ publicExposure: "loopback",
4281
+ },
4282
+ ],
4283
+ },
4284
+ h.manifestPath,
4285
+ );
4286
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
4287
+ // Tailnet caller → cloaked.
4288
+ const tailnet = await fetcher(
4289
+ req("/vault/admin/", { headers: { "Tailscale-User-Login": "alice@example.com" } }),
4290
+ );
4291
+ expect(tailnet.status).toBe(404);
4292
+ // Public caller → cloaked.
4293
+ const pub = await fetcher(req("/vault/admin/", { headers: { "CF-Ray": "abc123" } }));
4294
+ expect(pub.status).toBe(404);
4295
+ // Header-absent NON-loopback peer (0.0.0.0 bind) → cloaked (#526 posture).
4296
+ const direct = await fetcher(req("/vault/admin/"), fakeServer("198.51.100.4"));
4297
+ expect(direct.status).toBe(404);
4298
+ // Loopback peer → reaches the daemon.
4299
+ const loop = await fetcher(req("/vault/admin/"), fakeServer("127.0.0.1"));
4300
+ expect(loop.status).toBe(200);
4301
+ expect(((await loop.json()) as { tag: string }).tag).toBe("vault-daemon");
4302
+ } finally {
4303
+ upstream.stop();
4304
+ h.cleanup();
4305
+ }
4306
+ });
4307
+ });
4308
+
4054
4309
  /** Find a port that no one is listening on by binding briefly and releasing. */
4055
4310
  async function pickClosedPort(): Promise<number> {
4056
4311
  const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("x") });
@@ -5004,6 +5259,50 @@ describe("force-change-password per-request gate (#469)", () => {
5004
5259
  }
5005
5260
  });
5006
5261
 
5262
+ test("(a) pre-rotation browser GET /vault/admin/ is 302'd to change-password (B-route gate parity)", async () => {
5263
+ // The daemon-level /vault/admin mount applies the SAME per-request
5264
+ // force-change-password gate as the per-vault proxy — a pre-rotation
5265
+ // session can't reach the multi-vault admin surface on an un-rotated
5266
+ // temp password either.
5267
+ const h = makeHarness();
5268
+ try {
5269
+ writeManifest(
5270
+ {
5271
+ services: [
5272
+ {
5273
+ // Canonical row name so findServiceByShort would resolve it if
5274
+ // the request ever got past the gate.
5275
+ name: "parachute-vault",
5276
+ port: 1940,
5277
+ paths: ["/vault/work"],
5278
+ health: "/vault/work/health",
5279
+ version: "0.5.0",
5280
+ },
5281
+ ],
5282
+ },
5283
+ h.manifestPath,
5284
+ );
5285
+ const db = openHubDb(hubDbPath(h.dir));
5286
+ try {
5287
+ const { cookie } = await seedUser(h, db, {
5288
+ passwordChanged: false,
5289
+ assignedVaults: ["work"],
5290
+ });
5291
+ const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
5292
+ req("/vault/admin/", { headers: { cookie, accept: "text/html" } }),
5293
+ );
5294
+ // Gated BEFORE proxyToVaultAdmin ever runs — the gate's 302, not a
5295
+ // proxy 404/502.
5296
+ expect(res.status).toBe(302);
5297
+ expect(res.headers.get("location")).toBe("/account/change-password");
5298
+ } finally {
5299
+ db.close();
5300
+ }
5301
+ } finally {
5302
+ h.cleanup();
5303
+ }
5304
+ });
5305
+
5007
5306
  test("(b) pre-rotation user CAN still reach /account/change-password (GET)", async () => {
5008
5307
  const h = makeHarness();
5009
5308
  try {
@@ -5282,3 +5581,268 @@ describe("force-change-password per-request gate (#469)", () => {
5282
5581
  }
5283
5582
  });
5284
5583
  });
5584
+
5585
+ describe("substrate trust headers — X-Parachute-Layer / X-Parachute-Client-IP (H2)", () => {
5586
+ // The hub stamps the layerOf classification + resolved client IP on every
5587
+ // request it forwards to a module upstream, STRIPPING any inbound
5588
+ // occurrences first (a public client must not be able to inject a forged
5589
+ // "loopback" trust signal past the proxy). Surface-runtime design §10.
5590
+
5591
+ function startEchoUpstream(): { port: number; stop: () => void } {
5592
+ const server = Bun.serve({
5593
+ port: 0,
5594
+ hostname: "127.0.0.1",
5595
+ fetch: (req) =>
5596
+ new Response(
5597
+ JSON.stringify({
5598
+ layer: req.headers.get("x-parachute-layer"),
5599
+ clientIp: req.headers.get("x-parachute-client-ip"),
5600
+ }),
5601
+ { status: 200, headers: { "content-type": "application/json" } },
5602
+ ),
5603
+ });
5604
+ return { port: server.port as number, stop: () => server.stop(true) };
5605
+ }
5606
+
5607
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
5608
+
5609
+ function writeEchoService(h: Harness, port: number): void {
5610
+ writeManifest(
5611
+ {
5612
+ services: [
5613
+ {
5614
+ name: "echo-svc",
5615
+ port,
5616
+ paths: ["/echo-svc"],
5617
+ health: "/echo-svc/health",
5618
+ version: "0.1.0",
5619
+ },
5620
+ ],
5621
+ },
5622
+ h.manifestPath,
5623
+ );
5624
+ }
5625
+
5626
+ test("loopback direct-to-hub → stamped loopback + peer IP (generic proxy)", async () => {
5627
+ const h = makeHarness();
5628
+ const upstream = startEchoUpstream();
5629
+ try {
5630
+ writeEchoService(h, upstream.port);
5631
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5632
+ const res = await fetcher(req("/echo-svc/x"), fakeServer("127.0.0.1"));
5633
+ expect(res.status).toBe(200);
5634
+ const body = (await res.json()) as { layer: string; clientIp: string };
5635
+ expect(body.layer).toBe("loopback");
5636
+ expect(body.clientIp).toBe("127.0.0.1");
5637
+ } finally {
5638
+ upstream.stop();
5639
+ h.cleanup();
5640
+ }
5641
+ });
5642
+
5643
+ test("tailnet request → stamped tailnet + X-Forwarded-For client IP", async () => {
5644
+ const h = makeHarness();
5645
+ const upstream = startEchoUpstream();
5646
+ try {
5647
+ writeEchoService(h, upstream.port);
5648
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5649
+ const res = await fetcher(
5650
+ req("/echo-svc/x", {
5651
+ headers: {
5652
+ "tailscale-user-login": "aaron@example.com",
5653
+ "x-forwarded-for": "100.64.0.7",
5654
+ },
5655
+ }),
5656
+ fakeServer("127.0.0.1"),
5657
+ );
5658
+ const body = (await res.json()) as { layer: string; clientIp: string };
5659
+ expect(body.layer).toBe("tailnet");
5660
+ expect(body.clientIp).toBe("100.64.0.7");
5661
+ } finally {
5662
+ upstream.stop();
5663
+ h.cleanup();
5664
+ }
5665
+ });
5666
+
5667
+ test("public (cloudflared) request → stamped public + CF-Connecting-IP", async () => {
5668
+ const h = makeHarness();
5669
+ const upstream = startEchoUpstream();
5670
+ try {
5671
+ writeEchoService(h, upstream.port);
5672
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5673
+ const res = await fetcher(
5674
+ req("/echo-svc/x", {
5675
+ headers: { "cf-ray": "8abc123", "cf-connecting-ip": "203.0.113.9" },
5676
+ }),
5677
+ fakeServer("127.0.0.1"),
5678
+ );
5679
+ const body = (await res.json()) as { layer: string; clientIp: string };
5680
+ expect(body.layer).toBe("public");
5681
+ expect(body.clientIp).toBe("203.0.113.9");
5682
+ } finally {
5683
+ upstream.stop();
5684
+ h.cleanup();
5685
+ }
5686
+ });
5687
+
5688
+ test("inbound spoof is STRIPPED — public peer injecting loopback headers gets re-stamped", async () => {
5689
+ // The attack H2's strip exists for: a direct network peer (0.0.0.0 bind)
5690
+ // sends X-Parachute-Layer: loopback + a forged client IP. The hub must
5691
+ // strip both and stamp its own fail-closed classification.
5692
+ const h = makeHarness();
5693
+ const upstream = startEchoUpstream();
5694
+ try {
5695
+ writeEchoService(h, upstream.port);
5696
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5697
+ const res = await fetcher(
5698
+ req("/echo-svc/x", {
5699
+ headers: {
5700
+ "x-parachute-layer": "loopback",
5701
+ "x-parachute-client-ip": "127.0.0.1",
5702
+ },
5703
+ }),
5704
+ fakeServer("198.51.100.20"),
5705
+ );
5706
+ const body = (await res.json()) as { layer: string; clientIp: string };
5707
+ expect(body.layer).toBe("public"); // header-absent non-loopback peer → public
5708
+ expect(body.clientIp).toBe("198.51.100.20"); // peer address, not the forgery
5709
+ } finally {
5710
+ upstream.stop();
5711
+ h.cleanup();
5712
+ }
5713
+ });
5714
+
5715
+ test("direct caller injecting CF-Connecting-IP: layer stamp stays sound (public), client IP is best-effort attribution", async () => {
5716
+ // Pins the documented property (resolveClientIp's "Known limitation"):
5717
+ // a DIRECT caller can spoof the forwarded-IP headers and misattribute
5718
+ // its own address — X-Parachute-Client-IP reflects the injected value —
5719
+ // but it CANNOT spoof the LAYER: the CF header's presence classifies the
5720
+ // request "public" regardless of the peer (here even a loopback one),
5721
+ // so spoofing only ever moves the trust signal DOWN. Layer is the
5722
+ // security signal; client IP is attribution.
5723
+ const h = makeHarness();
5724
+ const upstream = startEchoUpstream();
5725
+ try {
5726
+ writeEchoService(h, upstream.port);
5727
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5728
+ const res = await fetcher(
5729
+ req("/echo-svc/x", {
5730
+ headers: { "cf-connecting-ip": "203.0.113.50" },
5731
+ }),
5732
+ fakeServer("127.0.0.1"),
5733
+ );
5734
+ const body = (await res.json()) as { layer: string; clientIp: string };
5735
+ expect(body.layer).toBe("public"); // CF header presence → public, not loopback
5736
+ expect(body.clientIp).toBe("203.0.113.50"); // injected value — best-effort by design
5737
+ } finally {
5738
+ upstream.stop();
5739
+ h.cleanup();
5740
+ }
5741
+ });
5742
+
5743
+ test("unknown peer (no Server threaded) → fail-closed public, header for client IP omitted", async () => {
5744
+ const h = makeHarness();
5745
+ const upstream = startEchoUpstream();
5746
+ try {
5747
+ writeEchoService(h, upstream.port);
5748
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5749
+ // No second arg — peerAddr resolves null; layer fails closed to public.
5750
+ const res = await fetcher(req("/echo-svc/x"));
5751
+ const body = (await res.json()) as { layer: string; clientIp: string | null };
5752
+ expect(body.layer).toBe("public");
5753
+ expect(body.clientIp).toBeNull();
5754
+ } finally {
5755
+ upstream.stop();
5756
+ h.cleanup();
5757
+ }
5758
+ });
5759
+
5760
+ test("per-vault proxy stamps the same headers (shared proxyRequest path)", async () => {
5761
+ const h = makeHarness();
5762
+ const upstream = startEchoUpstream();
5763
+ try {
5764
+ writeManifest(
5765
+ {
5766
+ services: [
5767
+ {
5768
+ name: "parachute-vault-default",
5769
+ port: upstream.port,
5770
+ paths: ["/vault/default"],
5771
+ health: "/health",
5772
+ version: "0.4.0",
5773
+ },
5774
+ ],
5775
+ },
5776
+ h.manifestPath,
5777
+ );
5778
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5779
+ const res = await fetcher(req("/vault/default/api/notes"), fakeServer("127.0.0.1"));
5780
+ const body = (await res.json()) as { layer: string; clientIp: string };
5781
+ expect(body.layer).toBe("loopback");
5782
+ expect(body.clientIp).toBe("127.0.0.1");
5783
+ } finally {
5784
+ upstream.stop();
5785
+ h.cleanup();
5786
+ }
5787
+ });
5788
+
5789
+ test("/vault/admin route stamps the same headers (proxyToVaultAdmin path)", async () => {
5790
+ const h = makeHarness();
5791
+ const upstream = startEchoUpstream();
5792
+ try {
5793
+ writeManifest(
5794
+ {
5795
+ services: [
5796
+ {
5797
+ name: "parachute-vault",
5798
+ port: upstream.port,
5799
+ paths: ["/vault/default"],
5800
+ health: "/health",
5801
+ version: "0.5.0",
5802
+ },
5803
+ ],
5804
+ },
5805
+ h.manifestPath,
5806
+ );
5807
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5808
+ const res = await fetcher(
5809
+ req("/vault/admin/", {
5810
+ headers: { "tailscale-user-login": "aaron@example.com" },
5811
+ }),
5812
+ fakeServer("127.0.0.1"),
5813
+ );
5814
+ const body = (await res.json()) as { layer: string };
5815
+ expect(body.layer).toBe("tailnet");
5816
+ } finally {
5817
+ upstream.stop();
5818
+ h.cleanup();
5819
+ }
5820
+ });
5821
+ });
5822
+
5823
+ describe("resolveClientIp (H2)", () => {
5824
+ test("CF-Connecting-IP wins over X-Forwarded-For and peer", () => {
5825
+ const r = req("/", {
5826
+ headers: { "cf-connecting-ip": "203.0.113.1", "x-forwarded-for": "10.0.0.1" },
5827
+ });
5828
+ expect(resolveClientIp(r, "127.0.0.1")).toBe("203.0.113.1");
5829
+ });
5830
+
5831
+ test("X-Forwarded-For first hop wins over peer", () => {
5832
+ const r = req("/", { headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2" } });
5833
+ expect(resolveClientIp(r, "127.0.0.1")).toBe("10.0.0.1");
5834
+ });
5835
+
5836
+ test("falls back to the peer address", () => {
5837
+ expect(resolveClientIp(req("/"), "198.51.100.7")).toBe("198.51.100.7");
5838
+ });
5839
+
5840
+ test("null when nothing resolves", () => {
5841
+ expect(resolveClientIp(req("/"), null)).toBeNull();
5842
+ });
5843
+
5844
+ test("whitespace-only header values are treated as absent", () => {
5845
+ const r = req("/", { headers: { "x-forwarded-for": " " } });
5846
+ expect(resolveClientIp(r, null)).toBeNull();
5847
+ });
5848
+ });