@openparachute/hub 0.7.0 → 0.7.2-rc.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 (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import {
6
6
  InvalidRedirectUriError,
7
7
  approveClient,
8
+ expandRedirectUrisForHubOrigins,
8
9
  getClient,
9
10
  isValidRedirectUri,
10
11
  listClientsByStatus,
@@ -144,6 +145,96 @@ describe("requireRegisteredRedirectUri", () => {
144
145
  });
145
146
  });
146
147
 
148
+ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expansion)", () => {
149
+ const LOOPBACK = "http://127.0.0.1:1939";
150
+ const PUBLIC = "https://box.taildf9ce2.ts.net";
151
+ const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
152
+
153
+ test("expands a loopback-rooted URI onto every other hub origin", () => {
154
+ const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/oauth/callback`], hubOrigins);
155
+ // Original is preserved + the public + localhost variants are added.
156
+ expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
157
+ expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
158
+ expect(out).toContain("http://localhost:1939/surface/notes/oauth/callback");
159
+ // The submitted URI comes first (order-preserving).
160
+ expect(out[0]).toBe(`${LOOPBACK}/surface/notes/oauth/callback`);
161
+ });
162
+
163
+ test("INVARIANT: a foreign-origin URI is stored verbatim — not expanded, not dropped", () => {
164
+ const foreign = "https://my-vault-ui.example/oauth/callback";
165
+ const out = expandRedirectUrisForHubOrigins([foreign], hubOrigins);
166
+ // Stored exactly as submitted...
167
+ expect(out).toContain(foreign);
168
+ // ...and NOTHING was minted on any hub origin from it (no open redirect).
169
+ expect(out).toEqual([foreign]);
170
+ for (const o of hubOrigins) {
171
+ expect(out).not.toContain(`${o}/oauth/callback`);
172
+ }
173
+ });
174
+
175
+ test("mixed submit: hub-origin URI expands, foreign URI rides verbatim alongside", () => {
176
+ const foreign = "https://evil.example/cb";
177
+ const out = expandRedirectUrisForHubOrigins(
178
+ [`${LOOPBACK}/surface/notes/`, foreign],
179
+ hubOrigins,
180
+ );
181
+ // Hub-origin URI fanned out to the public origin.
182
+ expect(out).toContain(`${PUBLIC}/surface/notes/`);
183
+ // Foreign URI present, but no hub-origin variant of it exists.
184
+ expect(out).toContain(foreign);
185
+ for (const o of hubOrigins) {
186
+ expect(out).not.toContain(`${o}/cb`);
187
+ }
188
+ });
189
+
190
+ test("single known hub origin → no expansion (submitted set returned as-is)", () => {
191
+ const out = expandRedirectUrisForHubOrigins(
192
+ [`${LOOPBACK}/surface/notes/`],
193
+ [LOOPBACK],
194
+ );
195
+ expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
196
+ });
197
+
198
+ test("dedupes — already-public + loopback submit doesn't double-register", () => {
199
+ const out = expandRedirectUrisForHubOrigins(
200
+ [`${LOOPBACK}/surface/notes/`, `${PUBLIC}/surface/notes/`],
201
+ hubOrigins,
202
+ );
203
+ const publicCount = out.filter((u) => u === `${PUBLIC}/surface/notes/`).length;
204
+ expect(publicCount).toBe(1);
205
+ });
206
+
207
+ test("path + query + hash are preserved across origins", () => {
208
+ const out = expandRedirectUrisForHubOrigins(
209
+ [`${LOOPBACK}/surface/notes/oauth-callback?foo=bar`],
210
+ hubOrigins,
211
+ );
212
+ expect(out).toContain(`${PUBLIC}/surface/notes/oauth-callback?foo=bar`);
213
+ });
214
+
215
+ test("expanded URIs survive registerClient → requireRegisteredRedirectUri strict match", () => {
216
+ const { db, cleanup } = makeDb();
217
+ try {
218
+ const expanded = expandRedirectUrisForHubOrigins(
219
+ [`${LOOPBACK}/surface/notes/oauth/callback`],
220
+ hubOrigins,
221
+ );
222
+ const r = registerClient(db, { redirectUris: expanded });
223
+ // The public-origin variant now matches exactly at authorize time — the
224
+ // off-localhost sign-in that surface#118 broke.
225
+ expect(
226
+ requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`),
227
+ ).toBe(`${PUBLIC}/surface/notes/oauth/callback`);
228
+ // A truly-unregistered URI is still rejected — strict match unchanged.
229
+ expect(() =>
230
+ requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
231
+ ).toThrow(InvalidRedirectUriError);
232
+ } finally {
233
+ cleanup();
234
+ }
235
+ });
236
+ });
237
+
147
238
  describe("verifyClientSecret", () => {
148
239
  test("matches the issued secret, rejects others", () => {
149
240
  const { db, cleanup } = makeDb();
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Tests for the agent-connector grant store (`grants-store.ts`, 4b-1).
3
+ *
4
+ * Distinct from `grants.test.ts` (the OAuth consent skip-list in SQLite). This
5
+ * is the on-disk JSON store for approval-gated agent grants: round-trip, 0600
6
+ * perms (the file holds the granted secrets), idempotency-key derivation, and
7
+ * the lenient-read posture.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ type ConnectionSpec,
15
+ type GrantRecord,
16
+ connectionKey,
17
+ getGrant,
18
+ grantId,
19
+ listGrantsForAgent,
20
+ putGrant,
21
+ readGrants,
22
+ removeGrant,
23
+ } from "../grants-store.ts";
24
+
25
+ let dir: string;
26
+ let storePath: string;
27
+ beforeEach(() => {
28
+ dir = mkdtempSync(join(tmpdir(), "phub-grants-store-"));
29
+ storePath = join(dir, "agent-grants.json");
30
+ });
31
+ afterEach(() => {
32
+ rmSync(dir, { recursive: true, force: true });
33
+ });
34
+
35
+ function vaultSpec(over: Partial<ConnectionSpec> = {}): ConnectionSpec {
36
+ return { kind: "vault", target: "research", access: "read", ...over };
37
+ }
38
+
39
+ function rec(over: Partial<GrantRecord> = {}): GrantRecord {
40
+ const connection = over.connection ?? vaultSpec();
41
+ return {
42
+ id: over.id ?? grantId("agent1", connection),
43
+ agent: over.agent ?? "agent1",
44
+ connection,
45
+ status: over.status ?? "pending",
46
+ createdAt: over.createdAt ?? "2026-06-17T00:00:00.000Z",
47
+ ...(over.reason ? { reason: over.reason } : {}),
48
+ ...(over.material ? { material: over.material } : {}),
49
+ ...(over.approvedAt ? { approvedAt: over.approvedAt } : {}),
50
+ };
51
+ }
52
+
53
+ describe("round-trip", () => {
54
+ test("a missing file reads as empty", () => {
55
+ expect(readGrants(storePath)).toEqual([]);
56
+ });
57
+
58
+ test("put → read returns the record", () => {
59
+ const r = rec();
60
+ putGrant(storePath, r);
61
+ const back = readGrants(storePath);
62
+ expect(back).toHaveLength(1);
63
+ expect(back[0]).toEqual(r);
64
+ });
65
+
66
+ test("put upserts by id (no duplicate row)", () => {
67
+ const r = rec();
68
+ putGrant(storePath, r);
69
+ putGrant(storePath, { ...r, status: "approved", approvedAt: "2026-06-18T00:00:00.000Z" });
70
+ const back = readGrants(storePath);
71
+ expect(back).toHaveLength(1);
72
+ expect(back[0]?.status).toBe("approved");
73
+ });
74
+
75
+ test("getGrant + removeGrant", () => {
76
+ const r = rec();
77
+ putGrant(storePath, r);
78
+ expect(getGrant(storePath, r.id)?.id).toBe(r.id);
79
+ const removed = removeGrant(storePath, r.id);
80
+ expect(removed?.id).toBe(r.id);
81
+ expect(getGrant(storePath, r.id)).toBeNull();
82
+ });
83
+
84
+ test("listGrantsForAgent filters by agent (case-insensitive)", () => {
85
+ putGrant(storePath, rec({ id: "a", agent: "AgentOne" }));
86
+ putGrant(
87
+ storePath,
88
+ rec({ id: "b", agent: "agenttwo", connection: vaultSpec({ target: "x" }) }),
89
+ );
90
+ expect(listGrantsForAgent(storePath, "agentone")).toHaveLength(1);
91
+ expect(listGrantsForAgent(storePath, "AGENTTWO")).toHaveLength(1);
92
+ expect(listGrantsForAgent(storePath, "nope")).toHaveLength(0);
93
+ });
94
+ });
95
+
96
+ describe("0600 perms (the file holds secrets)", () => {
97
+ test("the store file is created mode 0600", () => {
98
+ putGrant(
99
+ storePath,
100
+ rec({
101
+ status: "approved",
102
+ material: { kind: "service", token: "ghp_secret" },
103
+ connection: { kind: "service", target: "github" },
104
+ }),
105
+ );
106
+ const mode = statSync(storePath).mode & 0o777;
107
+ expect(mode).toBe(0o600);
108
+ });
109
+ });
110
+
111
+ describe("lenient read", () => {
112
+ test("garbage JSON reads as empty", () => {
113
+ writeFileSync(storePath, "not json {{{");
114
+ expect(readGrants(storePath)).toEqual([]);
115
+ });
116
+
117
+ test("a top-level array (wrong shape) reads as empty", () => {
118
+ writeFileSync(storePath, JSON.stringify([{ id: "x" }]));
119
+ expect(readGrants(storePath)).toEqual([]);
120
+ });
121
+
122
+ test("a malformed row is dropped, valid rows survive", () => {
123
+ const valid = rec();
124
+ writeFileSync(
125
+ storePath,
126
+ JSON.stringify({
127
+ grants: [
128
+ valid,
129
+ { id: "missing-agent" }, // no agent/connection/status → dropped
130
+ { id: "bad-status", agent: "a", connection: vaultSpec(), status: "weird" }, // bad status → dropped
131
+ ],
132
+ }),
133
+ );
134
+ const back = readGrants(storePath);
135
+ expect(back).toHaveLength(1);
136
+ expect(back[0]?.id).toBe(valid.id);
137
+ });
138
+ });
139
+
140
+ describe("4b-2 mcp material + needs_consent (regression)", () => {
141
+ test("mcp material round-trips (OAuth shape)", () => {
142
+ const r = rec({
143
+ status: "approved",
144
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
145
+ material: {
146
+ kind: "mcp",
147
+ access_token: "at-1",
148
+ refresh_token: "rt-1",
149
+ expiresAt: "2026-06-18T13:00:00.000Z",
150
+ issuer: "https://issuer.test",
151
+ clientId: "cid-1",
152
+ tokenEndpoint: "https://issuer.test/oauth/token",
153
+ revocationEndpoint: "https://issuer.test/oauth/revoke",
154
+ mcpUrl: "https://remote.test/mcp",
155
+ },
156
+ });
157
+ putGrant(storePath, r);
158
+ const back = readGrants(storePath);
159
+ expect(back).toHaveLength(1);
160
+ expect(back[0]).toEqual(r);
161
+ });
162
+
163
+ test("mcp static-bearer material round-trips (no refresh)", () => {
164
+ const r = rec({
165
+ status: "approved",
166
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
167
+ material: { kind: "mcp", access_token: "static-paste", mcpUrl: "https://remote.test/mcp" },
168
+ });
169
+ putGrant(storePath, r);
170
+ expect(readGrants(storePath)[0]).toEqual(r);
171
+ });
172
+
173
+ test("a needs_consent row SURVIVES readGrants (the 4b-2 blocker regression)", () => {
174
+ const r = rec({
175
+ status: "needs_consent",
176
+ reason: "refresh failed: invalid_grant",
177
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
178
+ });
179
+ putGrant(storePath, r);
180
+ const back = readGrants(storePath);
181
+ expect(back).toHaveLength(1);
182
+ expect(back[0]?.status).toBe("needs_consent");
183
+ expect(getGrant(storePath, r.id)?.status).toBe("needs_consent");
184
+ });
185
+ });
186
+
187
+ describe("connectionKey / grantId derivation (idempotency)", () => {
188
+ test("vault key includes access + sorted tags", () => {
189
+ expect(connectionKey(vaultSpec())).toBe("vault:research:read");
190
+ expect(connectionKey(vaultSpec({ access: "write" }))).toBe("vault:research:write");
191
+ // tag order doesn't change the key
192
+ const a = connectionKey(vaultSpec({ tags: ["#b", "#a"] }));
193
+ const b = connectionKey(vaultSpec({ tags: ["#a", "#b"] }));
194
+ expect(a).toBe(b);
195
+ expect(a).toBe("vault:research:read##a,#b");
196
+ });
197
+
198
+ test("service key ignores inject (re-declare with new inject → same key)", () => {
199
+ const k1 = connectionKey({ kind: "service", target: "github", inject: ["env"] });
200
+ const k2 = connectionKey({ kind: "service", target: "github", inject: ["env", "mcp"] });
201
+ expect(k1).toBe(k2);
202
+ expect(k1).toBe("service:github");
203
+ });
204
+
205
+ test("mcp key is the url target", () => {
206
+ expect(connectionKey({ kind: "mcp", target: "https://x.test/mcp" })).toBe(
207
+ "mcp:https://x.test/mcp",
208
+ );
209
+ });
210
+
211
+ test("grantId is a stable URL-safe slug from agent + key", () => {
212
+ const id = grantId("agent1", vaultSpec());
213
+ expect(id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
214
+ // deterministic
215
+ expect(grantId("agent1", vaultSpec())).toBe(id);
216
+ // different connection → different id
217
+ expect(grantId("agent1", vaultSpec({ access: "write" }))).not.toBe(id);
218
+ });
219
+ });
@@ -380,6 +380,42 @@ describe("openHubDb + migrate", () => {
380
380
  }
381
381
  });
382
382
 
383
+ test("v13 adds invites.username; pre-existing invite rows keep NULL (no backfill)", () => {
384
+ const h = makeHarness();
385
+ try {
386
+ const db = openHubDb(h.dbPath);
387
+ try {
388
+ // Simulate a real v12 install: strip the v13 column, mark v13
389
+ // unapplied, and seed an invite row that pre-dates pre-naming.
390
+ db.exec(`
391
+ ALTER TABLE invites DROP COLUMN username;
392
+ INSERT INTO invites (token, created_by, vault_name, role, provision_vault,
393
+ default_mirror, expires_at, created_at)
394
+ VALUES ('hash-v12', NULL, 'maya', 'write', 1, NULL,
395
+ '2099-01-01T00:00:00.000Z', '2026-06-01T00:00:00.000Z');
396
+ `);
397
+ db.exec("DELETE FROM schema_version WHERE version = 13");
398
+ migrate(db);
399
+ const cols = db
400
+ .query<{ name: string }, []>("SELECT name FROM pragma_table_info('invites')")
401
+ .all()
402
+ .map((c) => c.name);
403
+ expect(cols).toContain("username");
404
+ // Grandfathered row → NULL (redeemer picks their own name).
405
+ const row = db
406
+ .query<{ username: string | null }, [string]>(
407
+ "SELECT username FROM invites WHERE token = ?",
408
+ )
409
+ .get("hash-v12");
410
+ expect(row?.username).toBeNull();
411
+ } finally {
412
+ db.close();
413
+ }
414
+ } finally {
415
+ h.cleanup();
416
+ }
417
+ });
418
+
383
419
  test("v10 FK cascade: deleting a user drops their user_vaults rows", () => {
384
420
  const h = makeHarness();
385
421
  try {
@@ -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";
@@ -1575,8 +1576,10 @@ describe("hubFetch routing", () => {
1575
1576
  });
1576
1577
 
1577
1578
  // Pre-admin lockout (hub#258). When no admin row exists, operator-
1578
- // facing surfaces (admin/api/login) 503 with a JSON body pointing at
1579
- // /admin/setup. Public surfaces (health, well-known, /, oauth, vault,
1579
+ // facing API surfaces (admin/api) 503 with a JSON body pointing at
1580
+ // /admin/setup. The browser-facing `/login` GET 302s to the wizard
1581
+ // instead (hub#644) — a JSON 503 there would render as raw text in the
1582
+ // visitor's tab. Public surfaces (health, well-known, /, oauth, vault,
1580
1583
  // /admin/setup itself) stay open so the container is reachable and
1581
1584
  // OAuth third parties aren't held hostage by admin onboarding.
1582
1585
  test("pre-admin lockout: /admin/vaults returns 503 setup_required", async () => {
@@ -1614,7 +1617,7 @@ describe("hubFetch routing", () => {
1614
1617
  }
1615
1618
  });
1616
1619
 
1617
- test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
1620
+ test("pre-admin lockout: /login GET funnels to /admin/setup, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
1618
1621
  const h = makeHarness();
1619
1622
  try {
1620
1623
  writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
@@ -1622,9 +1625,11 @@ describe("hubFetch routing", () => {
1622
1625
  const db = openHubDb(hubDbPath(h.dir));
1623
1626
  try {
1624
1627
  const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
1625
- // /login gated
1628
+ // /login GET funnels to the wizard (hub#644) — the browser-facing
1629
+ // sign-in surface 302s to /admin/setup instead of a raw JSON 503.
1626
1630
  const loginRes = await handler(req("/login"));
1627
- expect(loginRes.status).toBe(503);
1631
+ expect(loginRes.status).toBe(302);
1632
+ expect(loginRes.headers.get("location")).toBe("/admin/setup");
1628
1633
  // /admin/setup open
1629
1634
  const setupRes = await handler(req("/admin/setup"));
1630
1635
  expect(setupRes.status).toBe(200);
@@ -5580,3 +5585,268 @@ describe("force-change-password per-request gate (#469)", () => {
5580
5585
  }
5581
5586
  });
5582
5587
  });
5588
+
5589
+ describe("substrate trust headers — X-Parachute-Layer / X-Parachute-Client-IP (H2)", () => {
5590
+ // The hub stamps the layerOf classification + resolved client IP on every
5591
+ // request it forwards to a module upstream, STRIPPING any inbound
5592
+ // occurrences first (a public client must not be able to inject a forged
5593
+ // "loopback" trust signal past the proxy). Surface-runtime design §10.
5594
+
5595
+ function startEchoUpstream(): { port: number; stop: () => void } {
5596
+ const server = Bun.serve({
5597
+ port: 0,
5598
+ hostname: "127.0.0.1",
5599
+ fetch: (req) =>
5600
+ new Response(
5601
+ JSON.stringify({
5602
+ layer: req.headers.get("x-parachute-layer"),
5603
+ clientIp: req.headers.get("x-parachute-client-ip"),
5604
+ }),
5605
+ { status: 200, headers: { "content-type": "application/json" } },
5606
+ ),
5607
+ });
5608
+ return { port: server.port as number, stop: () => server.stop(true) };
5609
+ }
5610
+
5611
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
5612
+
5613
+ function writeEchoService(h: Harness, port: number): void {
5614
+ writeManifest(
5615
+ {
5616
+ services: [
5617
+ {
5618
+ name: "echo-svc",
5619
+ port,
5620
+ paths: ["/echo-svc"],
5621
+ health: "/echo-svc/health",
5622
+ version: "0.1.0",
5623
+ },
5624
+ ],
5625
+ },
5626
+ h.manifestPath,
5627
+ );
5628
+ }
5629
+
5630
+ test("loopback direct-to-hub → stamped loopback + peer IP (generic proxy)", async () => {
5631
+ const h = makeHarness();
5632
+ const upstream = startEchoUpstream();
5633
+ try {
5634
+ writeEchoService(h, upstream.port);
5635
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5636
+ const res = await fetcher(req("/echo-svc/x"), fakeServer("127.0.0.1"));
5637
+ expect(res.status).toBe(200);
5638
+ const body = (await res.json()) as { layer: string; clientIp: string };
5639
+ expect(body.layer).toBe("loopback");
5640
+ expect(body.clientIp).toBe("127.0.0.1");
5641
+ } finally {
5642
+ upstream.stop();
5643
+ h.cleanup();
5644
+ }
5645
+ });
5646
+
5647
+ test("tailnet request → stamped tailnet + X-Forwarded-For client IP", async () => {
5648
+ const h = makeHarness();
5649
+ const upstream = startEchoUpstream();
5650
+ try {
5651
+ writeEchoService(h, upstream.port);
5652
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5653
+ const res = await fetcher(
5654
+ req("/echo-svc/x", {
5655
+ headers: {
5656
+ "tailscale-user-login": "aaron@example.com",
5657
+ "x-forwarded-for": "100.64.0.7",
5658
+ },
5659
+ }),
5660
+ fakeServer("127.0.0.1"),
5661
+ );
5662
+ const body = (await res.json()) as { layer: string; clientIp: string };
5663
+ expect(body.layer).toBe("tailnet");
5664
+ expect(body.clientIp).toBe("100.64.0.7");
5665
+ } finally {
5666
+ upstream.stop();
5667
+ h.cleanup();
5668
+ }
5669
+ });
5670
+
5671
+ test("public (cloudflared) request → stamped public + CF-Connecting-IP", async () => {
5672
+ const h = makeHarness();
5673
+ const upstream = startEchoUpstream();
5674
+ try {
5675
+ writeEchoService(h, upstream.port);
5676
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5677
+ const res = await fetcher(
5678
+ req("/echo-svc/x", {
5679
+ headers: { "cf-ray": "8abc123", "cf-connecting-ip": "203.0.113.9" },
5680
+ }),
5681
+ fakeServer("127.0.0.1"),
5682
+ );
5683
+ const body = (await res.json()) as { layer: string; clientIp: string };
5684
+ expect(body.layer).toBe("public");
5685
+ expect(body.clientIp).toBe("203.0.113.9");
5686
+ } finally {
5687
+ upstream.stop();
5688
+ h.cleanup();
5689
+ }
5690
+ });
5691
+
5692
+ test("inbound spoof is STRIPPED — public peer injecting loopback headers gets re-stamped", async () => {
5693
+ // The attack H2's strip exists for: a direct network peer (0.0.0.0 bind)
5694
+ // sends X-Parachute-Layer: loopback + a forged client IP. The hub must
5695
+ // strip both and stamp its own fail-closed classification.
5696
+ const h = makeHarness();
5697
+ const upstream = startEchoUpstream();
5698
+ try {
5699
+ writeEchoService(h, upstream.port);
5700
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5701
+ const res = await fetcher(
5702
+ req("/echo-svc/x", {
5703
+ headers: {
5704
+ "x-parachute-layer": "loopback",
5705
+ "x-parachute-client-ip": "127.0.0.1",
5706
+ },
5707
+ }),
5708
+ fakeServer("198.51.100.20"),
5709
+ );
5710
+ const body = (await res.json()) as { layer: string; clientIp: string };
5711
+ expect(body.layer).toBe("public"); // header-absent non-loopback peer → public
5712
+ expect(body.clientIp).toBe("198.51.100.20"); // peer address, not the forgery
5713
+ } finally {
5714
+ upstream.stop();
5715
+ h.cleanup();
5716
+ }
5717
+ });
5718
+
5719
+ test("direct caller injecting CF-Connecting-IP: layer stamp stays sound (public), client IP is best-effort attribution", async () => {
5720
+ // Pins the documented property (resolveClientIp's "Known limitation"):
5721
+ // a DIRECT caller can spoof the forwarded-IP headers and misattribute
5722
+ // its own address — X-Parachute-Client-IP reflects the injected value —
5723
+ // but it CANNOT spoof the LAYER: the CF header's presence classifies the
5724
+ // request "public" regardless of the peer (here even a loopback one),
5725
+ // so spoofing only ever moves the trust signal DOWN. Layer is the
5726
+ // security signal; client IP is attribution.
5727
+ const h = makeHarness();
5728
+ const upstream = startEchoUpstream();
5729
+ try {
5730
+ writeEchoService(h, upstream.port);
5731
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5732
+ const res = await fetcher(
5733
+ req("/echo-svc/x", {
5734
+ headers: { "cf-connecting-ip": "203.0.113.50" },
5735
+ }),
5736
+ fakeServer("127.0.0.1"),
5737
+ );
5738
+ const body = (await res.json()) as { layer: string; clientIp: string };
5739
+ expect(body.layer).toBe("public"); // CF header presence → public, not loopback
5740
+ expect(body.clientIp).toBe("203.0.113.50"); // injected value — best-effort by design
5741
+ } finally {
5742
+ upstream.stop();
5743
+ h.cleanup();
5744
+ }
5745
+ });
5746
+
5747
+ test("unknown peer (no Server threaded) → fail-closed public, header for client IP omitted", async () => {
5748
+ const h = makeHarness();
5749
+ const upstream = startEchoUpstream();
5750
+ try {
5751
+ writeEchoService(h, upstream.port);
5752
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5753
+ // No second arg — peerAddr resolves null; layer fails closed to public.
5754
+ const res = await fetcher(req("/echo-svc/x"));
5755
+ const body = (await res.json()) as { layer: string; clientIp: string | null };
5756
+ expect(body.layer).toBe("public");
5757
+ expect(body.clientIp).toBeNull();
5758
+ } finally {
5759
+ upstream.stop();
5760
+ h.cleanup();
5761
+ }
5762
+ });
5763
+
5764
+ test("per-vault proxy stamps the same headers (shared proxyRequest path)", async () => {
5765
+ const h = makeHarness();
5766
+ const upstream = startEchoUpstream();
5767
+ try {
5768
+ writeManifest(
5769
+ {
5770
+ services: [
5771
+ {
5772
+ name: "parachute-vault-default",
5773
+ port: upstream.port,
5774
+ paths: ["/vault/default"],
5775
+ health: "/health",
5776
+ version: "0.4.0",
5777
+ },
5778
+ ],
5779
+ },
5780
+ h.manifestPath,
5781
+ );
5782
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5783
+ const res = await fetcher(req("/vault/default/api/notes"), fakeServer("127.0.0.1"));
5784
+ const body = (await res.json()) as { layer: string; clientIp: string };
5785
+ expect(body.layer).toBe("loopback");
5786
+ expect(body.clientIp).toBe("127.0.0.1");
5787
+ } finally {
5788
+ upstream.stop();
5789
+ h.cleanup();
5790
+ }
5791
+ });
5792
+
5793
+ test("/vault/admin route stamps the same headers (proxyToVaultAdmin path)", async () => {
5794
+ const h = makeHarness();
5795
+ const upstream = startEchoUpstream();
5796
+ try {
5797
+ writeManifest(
5798
+ {
5799
+ services: [
5800
+ {
5801
+ name: "parachute-vault",
5802
+ port: upstream.port,
5803
+ paths: ["/vault/default"],
5804
+ health: "/health",
5805
+ version: "0.5.0",
5806
+ },
5807
+ ],
5808
+ },
5809
+ h.manifestPath,
5810
+ );
5811
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
5812
+ const res = await fetcher(
5813
+ req("/vault/admin/", {
5814
+ headers: { "tailscale-user-login": "aaron@example.com" },
5815
+ }),
5816
+ fakeServer("127.0.0.1"),
5817
+ );
5818
+ const body = (await res.json()) as { layer: string };
5819
+ expect(body.layer).toBe("tailnet");
5820
+ } finally {
5821
+ upstream.stop();
5822
+ h.cleanup();
5823
+ }
5824
+ });
5825
+ });
5826
+
5827
+ describe("resolveClientIp (H2)", () => {
5828
+ test("CF-Connecting-IP wins over X-Forwarded-For and peer", () => {
5829
+ const r = req("/", {
5830
+ headers: { "cf-connecting-ip": "203.0.113.1", "x-forwarded-for": "10.0.0.1" },
5831
+ });
5832
+ expect(resolveClientIp(r, "127.0.0.1")).toBe("203.0.113.1");
5833
+ });
5834
+
5835
+ test("X-Forwarded-For first hop wins over peer", () => {
5836
+ const r = req("/", { headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2" } });
5837
+ expect(resolveClientIp(r, "127.0.0.1")).toBe("10.0.0.1");
5838
+ });
5839
+
5840
+ test("falls back to the peer address", () => {
5841
+ expect(resolveClientIp(req("/"), "198.51.100.7")).toBe("198.51.100.7");
5842
+ });
5843
+
5844
+ test("null when nothing resolves", () => {
5845
+ expect(resolveClientIp(req("/"), null)).toBeNull();
5846
+ });
5847
+
5848
+ test("whitespace-only header values are treated as absent", () => {
5849
+ const r = req("/", { headers: { "x-forwarded-for": " " } });
5850
+ expect(resolveClientIp(r, null)).toBeNull();
5851
+ });
5852
+ });