@openparachute/hub 0.7.1 → 0.7.2

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 (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tests for the in-flight OAuth consent-flow store (`oauth-flows-store.ts`, 4b-2).
3
+ *
4
+ * put / get-by-state / delete-on-use / round-trip / TTL prune / 0600 perms / the
5
+ * lenient-read posture. The file holds PKCE verifiers (secrets) → 0600.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import {
12
+ FLOW_TTL_MS,
13
+ type PendingFlow,
14
+ deleteFlow,
15
+ getFlowByState,
16
+ pruneExpiredFlows,
17
+ putFlow,
18
+ } from "../oauth-flows-store.ts";
19
+
20
+ let dir: string;
21
+ let storePath: string;
22
+ beforeEach(() => {
23
+ dir = mkdtempSync(join(tmpdir(), "phub-oauth-flows-"));
24
+ storePath = join(dir, "agent-oauth-flows.json");
25
+ });
26
+ afterEach(() => {
27
+ rmSync(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ function flow(over: Partial<PendingFlow> = {}): PendingFlow {
31
+ return {
32
+ state: over.state ?? "state-1",
33
+ grantId: over.grantId ?? "grant-1",
34
+ issuer: over.issuer ?? "https://issuer.test",
35
+ clientId: over.clientId ?? "cid-1",
36
+ tokenEndpoint: over.tokenEndpoint ?? "https://issuer.test/oauth/token",
37
+ verifier: over.verifier ?? "pkce-verifier-secret",
38
+ mcpUrl: over.mcpUrl ?? "https://remote.test/mcp",
39
+ redirectUri: over.redirectUri ?? "https://hub.test/oauth/agent-grant/callback",
40
+ createdAt: over.createdAt ?? new Date().toISOString(),
41
+ ...(over.revocationEndpoint ? { revocationEndpoint: over.revocationEndpoint } : {}),
42
+ ...(over.scope ? { scope: over.scope } : {}),
43
+ };
44
+ }
45
+
46
+ describe("round-trip", () => {
47
+ test("a missing file returns null for any state", () => {
48
+ expect(getFlowByState(storePath, "anything")).toBeNull();
49
+ });
50
+
51
+ test("put → get by state", () => {
52
+ const f = flow();
53
+ putFlow(storePath, f);
54
+ const back = getFlowByState(storePath, "state-1");
55
+ expect(back).toEqual(f);
56
+ });
57
+
58
+ test("put upserts by state (no duplicate)", () => {
59
+ putFlow(storePath, flow({ clientId: "old" }));
60
+ putFlow(storePath, flow({ clientId: "new" }));
61
+ expect(getFlowByState(storePath, "state-1")?.clientId).toBe("new");
62
+ });
63
+
64
+ test("delete-on-use removes the flow (single-use)", () => {
65
+ putFlow(storePath, flow());
66
+ const removed = deleteFlow(storePath, "state-1");
67
+ expect(removed?.state).toBe("state-1");
68
+ expect(getFlowByState(storePath, "state-1")).toBeNull();
69
+ // second delete is a no-op
70
+ expect(deleteFlow(storePath, "state-1")).toBeNull();
71
+ });
72
+
73
+ test("distinct states coexist", () => {
74
+ putFlow(storePath, flow({ state: "a" }));
75
+ putFlow(storePath, flow({ state: "b", grantId: "grant-2" }));
76
+ expect(getFlowByState(storePath, "a")?.grantId).toBe("grant-1");
77
+ expect(getFlowByState(storePath, "b")?.grantId).toBe("grant-2");
78
+ });
79
+ });
80
+
81
+ describe("TTL prune", () => {
82
+ test("an expired flow is pruned on read (returns null)", () => {
83
+ const old = new Date(Date.now() - (FLOW_TTL_MS + 60_000)).toISOString();
84
+ putFlow(storePath, flow({ createdAt: old }));
85
+ // The default-now read prunes it.
86
+ expect(getFlowByState(storePath, "state-1")).toBeNull();
87
+ });
88
+
89
+ test("pruneExpiredFlows drops the expired and keeps the live", () => {
90
+ const now = Date.now();
91
+ putFlow(storePath, flow({ state: "fresh", createdAt: new Date(now).toISOString() }), now);
92
+ putFlow(
93
+ storePath,
94
+ flow({ state: "stale", createdAt: new Date(now - FLOW_TTL_MS - 1).toISOString() }),
95
+ now,
96
+ );
97
+ const live = pruneExpiredFlows(storePath, now);
98
+ expect(live.map((f) => f.state).sort()).toEqual(["fresh"]);
99
+ });
100
+
101
+ test("put prunes expired flows before persisting", () => {
102
+ const now = Date.now();
103
+ // Seed an expired flow directly.
104
+ writeFileSync(
105
+ storePath,
106
+ JSON.stringify({
107
+ flows: [flow({ state: "stale", createdAt: new Date(now - FLOW_TTL_MS - 1).toISOString() })],
108
+ }),
109
+ );
110
+ putFlow(storePath, flow({ state: "fresh", createdAt: new Date(now).toISOString() }), now);
111
+ expect(getFlowByState(storePath, "stale", now)).toBeNull();
112
+ expect(getFlowByState(storePath, "fresh", now)).not.toBeNull();
113
+ });
114
+ });
115
+
116
+ describe("0600 perms (holds PKCE verifiers)", () => {
117
+ test("the store file is created mode 0600", () => {
118
+ putFlow(storePath, flow());
119
+ const mode = statSync(storePath).mode & 0o777;
120
+ expect(mode).toBe(0o600);
121
+ });
122
+ });
123
+
124
+ describe("lenient read", () => {
125
+ test("garbage JSON reads as empty", () => {
126
+ writeFileSync(storePath, "not json {{{");
127
+ expect(getFlowByState(storePath, "x")).toBeNull();
128
+ });
129
+
130
+ test("a malformed row is dropped, valid rows survive", () => {
131
+ const valid = flow({ state: "ok" });
132
+ writeFileSync(
133
+ storePath,
134
+ JSON.stringify({
135
+ flows: [valid, { state: "bad" /* missing required fields */ }],
136
+ }),
137
+ );
138
+ expect(getFlowByState(storePath, "ok")).not.toBeNull();
139
+ expect(getFlowByState(storePath, "bad")).toBeNull();
140
+ });
141
+ });
@@ -115,9 +115,9 @@ describe("authorizationServerMetadata", () => {
115
115
  expect(scopesSupported).toContain("vault:admin");
116
116
  expect(scopesSupported).toContain("scribe:transcribe"); // scribe is in the fixture manifest
117
117
  expect(scopesSupported).toContain("hub:admin");
118
- // channel isn't in the fixture manifest → its scopes aren't advertised
118
+ // agent isn't in the fixture manifest → its scopes aren't advertised
119
119
  // (hub#…: optional-module scopes only surface when the module is installed).
120
- expect(scopesSupported).not.toContain("channel:send");
120
+ expect(scopesSupported).not.toContain("agent:send");
121
121
  });
122
122
 
123
123
  test("does NOT advertise non-requestable operator-only scopes", async () => {
@@ -145,8 +145,8 @@ describe("authorizationServerMetadata", () => {
145
145
  "vault:admin",
146
146
  "hub:admin",
147
147
  "parachute:host:admin", // declared but operator-only — must still be filtered
148
- "agent:read",
149
- "agent:write",
148
+ "widget:read",
149
+ "widget:write",
150
150
  "mymodule:do-thing",
151
151
  ]);
152
152
  const res = authorizationServerMetadata({
@@ -156,9 +156,10 @@ describe("authorizationServerMetadata", () => {
156
156
  });
157
157
  const body = (await res.json()) as Record<string, unknown>;
158
158
  const scopesSupported = body.scopes_supported as string[];
159
- // Third-party scopes show up
160
- expect(scopesSupported).toContain("agent:read");
161
- expect(scopesSupported).toContain("agent:write");
159
+ // Third-party scopes show up (`widget:*` / `mymodule:*` aren't gated
160
+ // optional-module prefixes — only scribe:/agent: are, see OPTIONAL_MODULE_SCOPES).
161
+ expect(scopesSupported).toContain("widget:read");
162
+ expect(scopesSupported).toContain("widget:write");
162
163
  expect(scopesSupported).toContain("mymodule:do-thing");
163
164
  // First-party still advertised — no regression
164
165
  expect(scopesSupported).toContain("vault:read");
@@ -169,10 +170,10 @@ describe("authorizationServerMetadata", () => {
169
170
  });
170
171
 
171
172
  test("advertises an optional module's scopes only when it's installed", async () => {
172
- // FIRST_PARTY_SCOPES carries scribe:* + channel:send statically. On a
173
+ // FIRST_PARTY_SCOPES carries scribe:* + agent:send statically. On a
173
174
  // vault-only hub they must NOT be advertised — a discovery client (e.g.
174
175
  // claude.ai's connector UI) lists the catalog verbatim, so a friend
175
- // connecting one vault was shown Scribe + Channel access the hub can't
176
+ // connecting one vault was shown Scribe + Agent access the hub can't
176
177
  // honor. Vault + hub are core and always advertised.
177
178
  const declared = new Set<string>([
178
179
  "vault:read",
@@ -180,7 +181,7 @@ describe("authorizationServerMetadata", () => {
180
181
  "vault:admin",
181
182
  "scribe:transcribe",
182
183
  "scribe:admin",
183
- "channel:send",
184
+ "agent:send",
184
185
  "hub:admin",
185
186
  ]);
186
187
  const vaultOnly = {
@@ -207,7 +208,7 @@ describe("authorizationServerMetadata", () => {
207
208
  // uninstalled optional-module scopes are dropped
208
209
  expect(scopes).not.toContain("scribe:transcribe");
209
210
  expect(scopes).not.toContain("scribe:admin");
210
- expect(scopes).not.toContain("channel:send");
211
+ expect(scopes).not.toContain("agent:send");
211
212
 
212
213
  // ...but once scribe is installed, its scopes ARE advertised again.
213
214
  const withScribe = {
@@ -235,7 +236,7 @@ describe("authorizationServerMetadata", () => {
235
236
  });
236
237
  const scopes2 = ((await res2.json()) as Record<string, unknown>).scopes_supported as string[];
237
238
  expect(scopes2).toContain("scribe:transcribe");
238
- expect(scopes2).not.toContain("channel:send"); // channel still not installed
239
+ expect(scopes2).not.toContain("agent:send"); // agent still not installed
239
240
  });
240
241
  });
241
242
 
@@ -263,7 +264,7 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
263
264
  "vault:admin",
264
265
  "hub:admin",
265
266
  "parachute:host:admin",
266
- "agent:read",
267
+ "widget:read",
267
268
  ]);
268
269
  const res = protectedResourceMetadata({
269
270
  issuer: ISSUER,
@@ -273,7 +274,7 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
273
274
  const body = (await res.json()) as Record<string, unknown>;
274
275
  const scopes = body.scopes_supported as string[];
275
276
  expect(scopes).toContain("vault:read");
276
- expect(scopes).toContain("agent:read");
277
+ expect(scopes).toContain("widget:read");
277
278
  expect(scopes).not.toContain("parachute:host:admin");
278
279
  });
279
280
  });
@@ -988,13 +989,13 @@ describe("handleAuthorizeGet — vault picker", () => {
988
989
  describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes (scary-consent fix)", () => {
989
990
  // claude.ai connecting to ONE vault reads the hub's whole-hub AS-metadata
990
991
  // `scopes_supported` and over-requests the full catalog. Bound to the vault
991
- // resource (`aud=vault.<name>`), scribe/channel/hub scopes are unusable, so
992
+ // resource (`aud=vault.<name>`), scribe/agent/hub scopes are unusable, so
992
993
  // they must be DROPPED before consent — Aaron hit them as "a fuck ton of
993
994
  // privileges that don't make sense" (scribe isn't even installed here).
994
995
  const FOREIGN_AND_VAULT =
995
- "vault:read vault:write scribe:transcribe scribe:admin channel:send hub:admin";
996
+ "vault:read vault:write scribe:transcribe scribe:admin agent:send hub:admin";
996
997
 
997
- test("session consent for a vault MCP resource drops scribe/channel/hub scopes", async () => {
998
+ test("session consent for a vault MCP resource drops scribe/agent/hub scopes", async () => {
998
999
  const { db, cleanup } = await makeDb();
999
1000
  try {
1000
1001
  const user = await createUser(db, "owner", "pw");
@@ -1035,7 +1036,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
1035
1036
  // The foreign scopes are gone.
1036
1037
  expect(html).not.toContain("Send audio to Scribe for transcription."); // scribe:transcribe
1037
1038
  expect(html).not.toContain("Manage Scribe configuration"); // scribe:admin
1038
- expect(html).not.toContain("Post messages to your Channel."); // channel:send
1039
+ expect(html).not.toContain("Post messages to your Agent."); // agent:send
1039
1040
  expect(html).not.toContain("Manage hub identity"); // hub:admin
1040
1041
  } finally {
1041
1042
  cleanup();
@@ -1058,7 +1059,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
1058
1059
  response_type: "code",
1059
1060
  code_challenge: challenge,
1060
1061
  code_challenge_method: "S256",
1061
- scope: "vault:read scribe:transcribe channel:send",
1062
+ scope: "vault:read scribe:transcribe agent:send",
1062
1063
  resource: `${ISSUER}/vault/default/mcp`,
1063
1064
  }),
1064
1065
  // No session cookie → the unauth pending page renders.
@@ -1072,13 +1073,13 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
1072
1073
  expect(html).toContain("App not yet approved");
1073
1074
  // Foreign scopes absent from the rendered rows...
1074
1075
  expect(html).not.toContain("Send audio to Scribe for transcription.");
1075
- expect(html).not.toContain("Post messages to your Channel.");
1076
+ expect(html).not.toContain("Post messages to your Agent.");
1076
1077
  // ...and from the login round-trip URL embedded in the page (the
1077
1078
  // narrowed scope was written back onto `url` before this render).
1078
1079
  expect(html).not.toContain("scribe:transcribe");
1079
- expect(html).not.toContain("channel:send");
1080
+ expect(html).not.toContain("agent:send");
1080
1081
  expect(html).not.toContain("scribe%3Atranscribe");
1081
- expect(html).not.toContain("channel%3Asend");
1082
+ expect(html).not.toContain("agent%3Asend");
1082
1083
  } finally {
1083
1084
  cleanup();
1084
1085
  }
@@ -1105,7 +1106,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
1105
1106
  response_type: "code",
1106
1107
  code_challenge: challenge,
1107
1108
  code_challenge_method: "S256",
1108
- scope: "scribe:transcribe channel:send",
1109
+ scope: "scribe:transcribe agent:send",
1109
1110
  resource: `${ISSUER}/vault/default/mcp`,
1110
1111
  }),
1111
1112
  {
@@ -1121,7 +1122,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
1121
1122
  expect(res.status).toBe(200);
1122
1123
  const html = await res.text();
1123
1124
  expect(html).not.toContain("Send audio to Scribe for transcription.");
1124
- expect(html).not.toContain("Post messages to your Channel.");
1125
+ expect(html).not.toContain("Post messages to your Agent.");
1125
1126
  } finally {
1126
1127
  cleanup();
1127
1128
  }
@@ -1130,7 +1131,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
1130
1131
  test("trust-by-client_name no longer re-prompts when the request over-asks the whole-hub catalog", async () => {
1131
1132
  // Before the narrowing was moved ahead of the status branch, the
1132
1133
  // trust-by-client_name coverage check compared the RAW request
1133
- // (`vault:read scribe:transcribe channel:send`) against a vault-only prior
1134
+ // (`vault:read scribe:transcribe agent:send`) against a vault-only prior
1134
1135
  // grant — never matched — re-prompting consent every session for a client
1135
1136
  // the operator had already approved. Narrowing first makes the comparison
1136
1137
  // vault-only-vs-vault-only, so the silent re-link fires.
@@ -1160,7 +1161,7 @@ describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes
1160
1161
  response_type: "code",
1161
1162
  code_challenge: challenge,
1162
1163
  code_challenge_method: "S256",
1163
- scope: "vault:read scribe:transcribe channel:send",
1164
+ scope: "vault:read scribe:transcribe agent:send",
1164
1165
  resource: `${ISSUER}/vault/default/mcp`,
1165
1166
  }),
1166
1167
  {
@@ -3279,6 +3280,103 @@ describe("handleRegister — RFC 7591 DCR", () => {
3279
3280
  });
3280
3281
  });
3281
3282
 
3283
+ // surface#118 — a hub-served module (surface, notes) registers at install time
3284
+ // knowing only the loopback hub origin; once exposed, the browser computes its
3285
+ // redirect_uri from the PUBLIC hub origin, which strict authorize-time matching
3286
+ // would reject. handleRegister expands hub-origin-rooted redirect_uris onto
3287
+ // every known hub origin (via deps.hubBoundOrigins). Foreign-origin URIs stay
3288
+ // verbatim — never expanded onto hub origins, never dropped (open-redirect
3289
+ // guard). Authorize-time matching is unchanged (strict exact-match).
3290
+ describe("handleRegister — cross-hub-origin redirect_uri expansion (surface#118)", () => {
3291
+ const PUBLIC = "https://box.taildf9ce2.ts.net";
3292
+ const LOOPBACK = "http://127.0.0.1:1939";
3293
+ const boundOrigins = () => [ISSUER, LOOPBACK, "http://localhost:1939", PUBLIC];
3294
+
3295
+ test("loopback-rooted URI is stored WITH the public-origin variant", async () => {
3296
+ const { db, cleanup } = await makeDb();
3297
+ try {
3298
+ const req = new Request(`${ISSUER}/oauth/register`, {
3299
+ method: "POST",
3300
+ body: JSON.stringify({
3301
+ redirect_uris: [`${LOOPBACK}/surface/notes/oauth/callback`],
3302
+ scope: "vault:default:read",
3303
+ client_name: "Notes",
3304
+ }),
3305
+ headers: { "content-type": "application/json" },
3306
+ });
3307
+ const res = await handleRegister(db, req, {
3308
+ issuer: ISSUER,
3309
+ hubBoundOrigins: boundOrigins,
3310
+ });
3311
+ expect(res.status).toBe(201);
3312
+ const body = (await res.json()) as Record<string, unknown>;
3313
+ const stored = body.redirect_uris as string[];
3314
+ // The submitted loopback URI is preserved...
3315
+ expect(stored).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
3316
+ // ...and the public-origin variant is now registered — the fix.
3317
+ expect(stored).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
3318
+
3319
+ // The stored set drives authorize-time matching; confirm the public
3320
+ // variant now matches via the real client record.
3321
+ const stored2 = getClient(db, body.client_id as string);
3322
+ expect(stored2?.redirectUris).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
3323
+ } finally {
3324
+ cleanup();
3325
+ }
3326
+ });
3327
+
3328
+ test("INVARIANT: a foreign-origin redirect_uri is stored verbatim, NOT expanded onto hub origins", async () => {
3329
+ const { db, cleanup } = await makeDb();
3330
+ try {
3331
+ const foreign = "https://my-vault-ui.example/oauth/callback";
3332
+ const req = new Request(`${ISSUER}/oauth/register`, {
3333
+ method: "POST",
3334
+ body: JSON.stringify({
3335
+ redirect_uris: [foreign],
3336
+ scope: "vault:default:read",
3337
+ client_name: "Off-origin surface",
3338
+ }),
3339
+ headers: { "content-type": "application/json" },
3340
+ });
3341
+ const res = await handleRegister(db, req, {
3342
+ issuer: ISSUER,
3343
+ hubBoundOrigins: boundOrigins,
3344
+ });
3345
+ expect(res.status).toBe(201);
3346
+ const body = (await res.json()) as Record<string, unknown>;
3347
+ const stored = body.redirect_uris as string[];
3348
+ // Stored exactly as submitted — never dropped.
3349
+ expect(stored).toEqual([foreign]);
3350
+ // No hub-origin variant was minted from the foreign URI (open-redirect guard).
3351
+ for (const o of [ISSUER, LOOPBACK, "http://localhost:1939", PUBLIC]) {
3352
+ expect(stored).not.toContain(`${o}/oauth/callback`);
3353
+ }
3354
+ } finally {
3355
+ cleanup();
3356
+ }
3357
+ });
3358
+
3359
+ test("no expansion when only one hub origin is known (single-origin hub unaffected)", async () => {
3360
+ const { db, cleanup } = await makeDb();
3361
+ try {
3362
+ const req = new Request(`${ISSUER}/oauth/register`, {
3363
+ method: "POST",
3364
+ body: JSON.stringify({
3365
+ redirect_uris: [`${ISSUER}/surface/notes/oauth/callback`],
3366
+ }),
3367
+ headers: { "content-type": "application/json" },
3368
+ });
3369
+ // hubBoundOrigins absent → resolveBoundOrigins falls back to [issuer].
3370
+ const res = await handleRegister(db, req, { issuer: ISSUER });
3371
+ expect(res.status).toBe(201);
3372
+ const body = (await res.json()) as Record<string, unknown>;
3373
+ expect(body.redirect_uris).toEqual([`${ISSUER}/surface/notes/oauth/callback`]);
3374
+ } finally {
3375
+ cleanup();
3376
+ }
3377
+ });
3378
+ });
3379
+
3282
3380
  // closes #74 — DCR is now operator-gated. Self-served registrations land as
3283
3381
  // pending and cannot OAuth; operator-bearer (hub:admin) registrations land
3284
3382
  // as approved and can OAuth immediately. This block covers all four exposed
@@ -66,7 +66,7 @@ describe("mintOperatorToken", () => {
66
66
  }
67
67
  });
68
68
 
69
- test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe/channel admins (#213)", () => {
69
+ test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe/agent admins (#213)", () => {
70
70
  // OPERATOR_TOKEN_SCOPES === OPERATOR_TOKEN_SCOPE_SETS.admin (back-compat
71
71
  // alias). The pre-#213 set was 5 scopes; #213 added the fine-grained
72
72
  // parachute:host:install/start/expose/auth/vault scopes to the admin
@@ -81,7 +81,7 @@ describe("mintOperatorToken", () => {
81
81
  "parachute:host:vault",
82
82
  "vault:admin",
83
83
  "scribe:admin",
84
- "channel:send",
84
+ "agent:send",
85
85
  ]);
86
86
  });
87
87
  });
@@ -19,7 +19,7 @@ describe("SCOPE_EXPLANATIONS", () => {
19
19
  "vault:admin",
20
20
  "scribe:transcribe",
21
21
  "scribe:admin",
22
- "channel:send",
22
+ "agent:send",
23
23
  "hub:admin",
24
24
  "parachute:host:admin",
25
25
  ];
@@ -96,7 +96,7 @@ describe("scopeIsAdmin", () => {
96
96
 
97
97
  test("false for non-admin and unknown scopes", () => {
98
98
  expect(scopeIsAdmin("vault:read")).toBe(false);
99
- expect(scopeIsAdmin("channel:send")).toBe(false);
99
+ expect(scopeIsAdmin("agent:send")).toBe(false);
100
100
  expect(scopeIsAdmin("unknown:anything")).toBe(false);
101
101
  });
102
102
 
@@ -133,7 +133,7 @@ describe("isRequestableScope", () => {
133
133
  expect(isRequestableScope("hub:admin")).toBe(true);
134
134
  expect(isRequestableScope("vault:read")).toBe(true);
135
135
  expect(isRequestableScope("vault:admin")).toBe(true);
136
- expect(isRequestableScope("channel:send")).toBe(true);
136
+ expect(isRequestableScope("agent:send")).toBe(true);
137
137
  });
138
138
 
139
139
  test("true for unknown scopes (third-party module scopes pass through)", () => {
@@ -290,24 +290,24 @@ describe("bootSupervisedModules", () => {
290
290
  });
291
291
  });
292
292
 
293
- // channel#41 — a transiently-wrong (drifted) services.json port for a
293
+ // agent (then channel)#41 — a transiently-wrong (drifted) services.json port for a
294
294
  // fixed-port first-party module self-perpetuates: the supervisor injects PORT /
295
295
  // probes / proxies from that row, so the wrong port strands the module forever.
296
296
  // The boot path snaps it back to canonical before spawn AND persists the fix so
297
297
  // the reverse-proxy (which reads services.json) routes correctly.
298
- describe("reconcilePortToCanonical (channel#41)", () => {
298
+ describe("reconcilePortToCanonical (channel#41 — module now agent)", () => {
299
299
  let h: Harness;
300
300
  beforeEach(() => {
301
301
  h = makeHarness();
302
302
  });
303
303
  afterEach(() => h.cleanup());
304
304
 
305
- // The live-observed signature: channel's row carried 19415 instead of its
305
+ // The live-observed signature: the agent (then channel) row carried 19415 instead of its
306
306
  // canonical 1941.
307
307
  const DRIFTED_CHANNEL: ServiceEntry = {
308
- name: "parachute-channel",
308
+ name: "parachute-agent",
309
309
  port: 19415,
310
- paths: ["/channel"],
310
+ paths: ["/agent"],
311
311
  health: "/health",
312
312
  version: "0.1.0",
313
313
  };
@@ -320,12 +320,12 @@ describe("reconcilePortToCanonical (channel#41)", () => {
320
320
  logs.push(l),
321
321
  );
322
322
 
323
- // Returned entry carries canonical (channel → 1941).
323
+ // Returned entry carries canonical (agent → 1941).
324
324
  expect(reconciled.port).toBe(1941);
325
325
  // And it's PERSISTED — the proxy reads services.json, so the row itself must
326
- // now point at 1941 or `/channel/*` keeps routing to the dead 19415.
326
+ // now point at 1941 or `/agent/*` keeps routing to the dead 19415.
327
327
  const onDisk = readManifestLenient(h.manifestPath).services.find(
328
- (s) => s.name === "parachute-channel",
328
+ (s) => s.name === "parachute-agent",
329
329
  );
330
330
  expect(onDisk?.port).toBe(1941);
331
331
  expect(
@@ -356,7 +356,7 @@ describe("reconcilePortToCanonical (channel#41)", () => {
356
356
 
357
357
  test("does NOT steal the canonical port when another row already holds it", () => {
358
358
  // Another row legitimately occupies 1941 — reconciling would trip the
359
- // write-side duplicate-port guard and isn't channel's to take. Leave the
359
+ // write-side duplicate-port guard and isn't the agent module's to take. Leave the
360
360
  // drift; the supervisor's squatter detection surfaces it.
361
361
  const squatter: ServiceEntry = {
362
362
  name: "parachute-vault",
@@ -372,13 +372,13 @@ describe("reconcilePortToCanonical (channel#41)", () => {
372
372
 
373
373
  expect(out.port).toBe(19415); // unchanged
374
374
  const onDisk = readManifestLenient(h.manifestPath).services.find(
375
- (s) => s.name === "parachute-channel",
375
+ (s) => s.name === "parachute-agent",
376
376
  );
377
377
  expect(onDisk?.port).toBe(19415); // not rewritten
378
378
  expect(logs.some((l) => l.includes("held by another row"))).toBe(true);
379
379
  });
380
380
 
381
- test("boot path injects PORT=canonical + persists the fix for a drifted channel row", async () => {
381
+ test("boot path injects PORT=canonical + persists the fix for a drifted agent row", async () => {
382
382
  writeManifest({ services: [DRIFTED_CHANNEL] }, h.manifestPath);
383
383
  const recorder = makeRecorder();
384
384
  const sup = new Supervisor({ spawnFn: recorder.spawn });
@@ -390,11 +390,11 @@ describe("reconcilePortToCanonical (channel#41)", () => {
390
390
 
391
391
  // The supervisor child gets PORT=1941 (so it binds + the readiness probe
392
392
  // checks the right port), not the drifted 19415.
393
- expect(recorder.calls[0]?.short).toBe("channel");
393
+ expect(recorder.calls[0]?.short).toBe("agent");
394
394
  expect(recorder.calls[0]?.env?.PORT).toBe("1941");
395
- // services.json row is reconciled → proxy routes /channel/* to 1941.
395
+ // services.json row is reconciled → proxy routes /agent/* to 1941.
396
396
  const onDisk = readManifestLenient(h.manifestPath).services.find(
397
- (s) => s.name === "parachute-channel",
397
+ (s) => s.name === "parachute-agent",
398
398
  );
399
399
  expect(onDisk?.port).toBe(1941);
400
400
  });
@@ -8,12 +8,38 @@ import {
8
8
  formatBootstrapTokenBanner,
9
9
  formatListeningBanner,
10
10
  hubPortConflictMessage,
11
+ hubServeOptions,
11
12
  resolveStartupIssuer,
12
13
  seedInitialAdminIfNeeded,
13
14
  } from "../commands/serve.ts";
14
15
  import { openHubDb } from "../hub-db.ts";
15
16
  import { getUserByUsername, userCount } from "../users.ts";
16
17
 
18
+ describe("hubServeOptions — the production listener wires the WS bridge", () => {
19
+ // Regression guard: the WS bridge handler was wired into hub-server.ts's
20
+ // Bun.serve but NOT this production `parachute serve` path, so module WS
21
+ // upgrades (the channel in-page terminal) 500'd through the hub
22
+ // ("set the websocket object in Bun.serve({})"). The listener MUST declare a
23
+ // websocket handler or `server.upgrade()` throws.
24
+ const fakeFetch = (() => new Response("ok")) as unknown as Parameters<typeof hubServeOptions>[0]["fetch"];
25
+
26
+ test("declares a websocket handler set (open/message/close)", () => {
27
+ const o = hubServeOptions({ port: 0, hostname: "127.0.0.1", fetch: fakeFetch });
28
+ expect(o.websocket).toBeDefined();
29
+ expect(typeof o.websocket.open).toBe("function");
30
+ expect(typeof o.websocket.message).toBe("function");
31
+ expect(typeof o.websocket.close).toBe("function");
32
+ });
33
+
34
+ test("preserves the port/hostname/idleTimeout + the passed fetch", () => {
35
+ const o = hubServeOptions({ port: 1939, hostname: "0.0.0.0", fetch: fakeFetch });
36
+ expect(o.port).toBe(1939);
37
+ expect(o.hostname).toBe("0.0.0.0");
38
+ expect(o.idleTimeout).toBe(255);
39
+ expect(o.fetch).toBe(fakeFetch);
40
+ });
41
+ });
42
+
17
43
  describe("hubPortConflictMessage (hub#536)", () => {
18
44
  test("maps a port-in-use error to a clear duplicate-supervisor message", () => {
19
45
  // Bun surfaces a port conflict as "...Is port 1939 in use?"; node-style is