@openparachute/hub 0.7.1 → 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 (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
@@ -735,7 +735,7 @@ import { findTokenRowByJti, listActiveRevocations, recordTokenMint } from "../jw
735
735
  import { createUser, setUserVaults } from "../users.ts";
736
736
 
737
737
  const VAULT_ORIGIN = "http://127.0.0.1:19400";
738
- const CHANNEL_ORIGIN = "http://127.0.0.1:19410";
738
+ const AGENT_ORIGIN = "http://127.0.0.1:19410";
739
739
 
740
740
  /** Successful no-op runner — records the commands it was asked to run. */
741
741
  function stubRun(
@@ -788,7 +788,7 @@ interface DeleteCallOpts {
788
788
  connectionsStorePath: string;
789
789
  runCommand?: (cmd: readonly string[]) => Promise<RunResult>;
790
790
  restartVaultModule?: () => Promise<void>;
791
- channelOrigin?: string | null;
791
+ agentOrigin?: string | null;
792
792
  fetchImpl?: typeof fetch;
793
793
  resolveVaultOrigin?: (v: string) => string | null;
794
794
  }
@@ -810,7 +810,7 @@ async function callDelete(opts: DeleteCallOpts): Promise<Response> {
810
810
  issuer: ISSUER,
811
811
  manifestPath: opts.manifestPath,
812
812
  connectionsStorePath: opts.connectionsStorePath,
813
- channelOrigin: opts.channelOrigin ?? null,
813
+ agentOrigin: opts.agentOrigin ?? null,
814
814
  resolveVaultOrigin: opts.resolveVaultOrigin ?? (() => VAULT_ORIGIN),
815
815
  runCommand: opts.runCommand ?? stubRun().run,
816
816
  ...(opts.restartVaultModule ? { restartVaultModule: opts.restartVaultModule } : {}),
@@ -1002,7 +1002,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1002
1002
  // 1. Registry rows: two naming "work" (one standalone + the
1003
1003
  // connection's registered mint below), one naming "default".
1004
1004
  registryRow(db, "jti-work-1", ["vault:work:write"]);
1005
- registryRow(db, "jti-conn-1", ["channel:send"]); // connection webhook bearer (non-vault scope)
1005
+ registryRow(db, "jti-conn-1", ["agent:send"]); // connection webhook bearer (non-vault scope)
1006
1006
  registryRow(db, "jti-default-1", ["vault:default:read"]);
1007
1007
 
1008
1008
  // 2. Grants: one spanning both vaults (rewrite), one work-only (drop).
@@ -1036,7 +1036,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1036
1036
  putConnection(store, {
1037
1037
  id: "conn-work",
1038
1038
  source: { module: "vault", vault: "work", event: "note.created" },
1039
- sink: { module: "channel", action: "message.deliver", params: { channel: "eng" } },
1039
+ sink: { module: "agent", action: "message.deliver", params: { channel: "eng" } },
1040
1040
  provisioned: {
1041
1041
  type: "vault-trigger",
1042
1042
  vault: "work",
@@ -1048,7 +1048,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1048
1048
  putConnection(store, {
1049
1049
  id: "conn-default",
1050
1050
  source: { module: "vault", vault: "default", event: "note.created" },
1051
- sink: { module: "channel", action: "message.deliver", params: { channel: "ops" } },
1051
+ sink: { module: "agent", action: "message.deliver", params: { channel: "ops" } },
1052
1052
  provisioned: { type: "vault-trigger", vault: "default", triggerName: "conn_d" },
1053
1053
  createdAt: new Date().toISOString(),
1054
1054
  });
@@ -1078,7 +1078,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1078
1078
  restartVaultModule: async () => {
1079
1079
  restarted = true;
1080
1080
  },
1081
- channelOrigin: CHANNEL_ORIGIN,
1081
+ agentOrigin: AGENT_ORIGIN,
1082
1082
  fetchImpl,
1083
1083
  resolveVaultOrigin: (v) => (v === "work" ? VAULT_ORIGIN : null),
1084
1084
  });
@@ -1102,7 +1102,7 @@ describe("DELETE /vaults/<name> — the identity cascade", () => {
1102
1102
  expect(out.name).toBe("work");
1103
1103
 
1104
1104
  // 1. Registry sweep: the work-scoped row revoked; default untouched.
1105
- // The connection's webhook-bearer row (channel:send — names no
1105
+ // The connection's webhook-bearer row (agent:send — names no
1106
1106
  // vault) is revoked by the CONNECTION teardown, not the sweep.
1107
1107
  expect(out.cascade.tokens_revoked).toBe(1);
1108
1108
  expect(findTokenRowByJti(db, "jti-work-1")?.revokedAt).not.toBeNull();
@@ -208,13 +208,14 @@ describe("parseModulesPath", () => {
208
208
  });
209
209
  });
210
210
 
211
- test("accepts any KNOWN module short — channel install now resolves (was the bug)", () => {
211
+ test("accepts any KNOWN module short — agent install now resolves (was the bug)", () => {
212
212
  // Post-2026-06-09 (modular-UI architecture, P2) the install-path gate is
213
213
  // `isKnownModuleShort` (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old
214
- // CURATED_MODULES whitelist. channel is in FIRST_PARTY_FALLBACKS, so its
215
- // install path now resolves — fixing the running-but-uninstallable bug.
216
- expect(parseModulesPath("/api/modules/channel/install")).toEqual({
217
- short: "channel",
214
+ // CURATED_MODULES whitelist. agent (renamed from channel 2026-06-17) is in
215
+ // KNOWN_MODULES, so its install path now resolves — fixing the
216
+ // running-but-uninstallable bug.
217
+ expect(parseModulesPath("/api/modules/agent/install")).toEqual({
218
+ short: "agent",
218
219
  rest: "install",
219
220
  });
220
221
  // Other known modules (runner / surface) resolve too.
@@ -1014,15 +1015,15 @@ describe("POST /api/modules/:short/start", () => {
1014
1015
  });
1015
1016
 
1016
1017
  test("channel#41: start reconciles a drifted services.json port back to canonical (API path)", async () => {
1017
- // The live signature: channel's row carried 19415 instead of canonical 1941.
1018
- // The API start path (admin SPA / `parachute start channel`) must apply the
1019
- // SAME reconcile the boot path does — otherwise an operator-triggered start
1020
- // re-strands the module on the dead port.
1018
+ // The live signature: the agent (then channel) row carried 19415 instead of
1019
+ // canonical 1941. The API start path (admin SPA / `parachute start agent`)
1020
+ // must apply the SAME reconcile the boot path does — otherwise an
1021
+ // operator-triggered start re-strands the module on the dead port.
1021
1022
  writeManifest(h.manifestPath, [
1022
1023
  {
1023
- name: "parachute-channel",
1024
+ name: "parachute-agent",
1024
1025
  port: 19415,
1025
- paths: ["/channel"],
1026
+ paths: ["/agent"],
1026
1027
  health: "/health",
1027
1028
  version: "0.0.0-linked",
1028
1029
  },
@@ -1032,8 +1033,8 @@ describe("POST /api/modules/:short/start", () => {
1032
1033
  const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1033
1034
 
1034
1035
  const res = await handleStart(
1035
- postReq("/api/modules/channel/start", { authorization: `Bearer ${bearer}` }),
1036
- "channel",
1036
+ postReq("/api/modules/agent/start", { authorization: `Bearer ${bearer}` }),
1037
+ "agent",
1037
1038
  {
1038
1039
  db: h.db,
1039
1040
  issuer: ISSUER,
@@ -1048,14 +1049,14 @@ describe("POST /api/modules/:short/start", () => {
1048
1049
  // The supervisor child gets PORT=1941 (canonical), not the drifted 19415 —
1049
1050
  // so it binds + the readiness probe checks the right port.
1050
1051
  expect(spawns.length).toBe(1);
1051
- expect(spawns[0]?.short).toBe("channel");
1052
+ expect(spawns[0]?.short).toBe("agent");
1052
1053
  expect(spawns[0]?.env?.PORT).toBe("1941");
1053
1054
  // services.json row is rewritten to 1941 → the reverse-proxy (which reads
1054
- // services.json) routes /channel/* to the live port.
1055
+ // services.json) routes /agent/* to the live port.
1055
1056
  const onDisk = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
1056
1057
  services: { name: string; port: number }[];
1057
1058
  };
1058
- expect(onDisk.services.find((s) => s.name === "parachute-channel")?.port).toBe(1941);
1059
+ expect(onDisk.services.find((s) => s.name === "parachute-agent")?.port).toBe(1941);
1059
1060
  // The reconcile event logged on the API path too (deps.log wired — #41 review).
1060
1061
  expect(
1061
1062
  logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1941")),
@@ -220,7 +220,7 @@ describe("GET /api/modules", () => {
220
220
  // by the UNION of the bootstrap registries (KNOWN_MODULES ∪
221
221
  // FIRST_PARTY_FALLBACKS), NOT a curated whitelist. Every known module
222
222
  // surfaces — core (vault/scribe/surface) in the headline tier, the rest
223
- // (channel/runner/notes) as `experimental` — so the channel-not-installed
223
+ // (agent/runner/notes) as `experimental` — so the agent-not-installed
224
224
  // class (running but invisible) can't recur.
225
225
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
226
226
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
@@ -244,11 +244,11 @@ describe("GET /api/modules", () => {
244
244
  // The core tier leads, in the recommended install order (vault → scribe),
245
245
  // ahead of every experimental module.
246
246
  expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
247
- expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("channel"));
247
+ expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("agent"));
248
248
  expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("runner"));
249
249
  // Every known module is discoverable — vault/scribe/surface (core) +
250
- // channel/runner/notes (experimental).
251
- for (const s of ["vault", "scribe", "surface", "channel", "runner", "notes"]) {
250
+ // agent/runner/notes (experimental).
251
+ for (const s of ["vault", "scribe", "surface", "agent", "runner", "notes"]) {
252
252
  expect(shorts).toContain(s);
253
253
  }
254
254
  // Focus tier resolves from the default map.
@@ -256,7 +256,7 @@ describe("GET /api/modules", () => {
256
256
  expect(byShort.get("vault")?.focus).toBe("core");
257
257
  expect(byShort.get("scribe")?.focus).toBe("core");
258
258
  expect(byShort.get("surface")?.focus).toBe("core");
259
- expect(byShort.get("channel")?.focus).toBe("experimental");
259
+ expect(byShort.get("agent")?.focus).toBe("experimental");
260
260
  expect(byShort.get("runner")?.focus).toBe("experimental");
261
261
  expect(byShort.get("notes")?.focus).toBe("experimental");
262
262
  expect(body.modules.every((m) => m.available)).toBe(true);
@@ -304,24 +304,24 @@ describe("GET /api/modules", () => {
304
304
  expect(scribe?.available).toBe(true);
305
305
  });
306
306
 
307
- test("channel (running + self-registered) appears as installed + experimental — regression for the channel-not-installed bug", async () => {
308
- // THE bug this PR fixes (2026-06-09 modular-UI architecture, P2): channel
307
+ test("agent (running + self-registered) appears as installed + experimental — regression for the channel-not-installed bug", async () => {
308
+ // THE bug this PR fixes (2026-06-09 modular-UI architecture, P2): agent (renamed from channel 2026-06-17)
309
309
  // was running, proxied, supervised, and self-registered in services.json
310
310
  // yet invisible on the Modules screen — because the old CURATED_MODULES =
311
311
  // ["vault","scribe"] whitelist gated discovery. Now discovery is driven by
312
- // self-registration ∪ the known registries, so a self-registered channel
312
+ // self-registration ∪ the known registries, so a self-registered agent
313
313
  // row surfaces as installed, in the experimental tier, with its run-state.
314
314
  writeManifest(h.manifestPath, [
315
315
  {
316
- name: "parachute-channel",
316
+ name: "parachute-agent",
317
317
  port: 1941,
318
- paths: ["/channel"],
319
- health: "/channel/health",
318
+ paths: ["/agent"],
319
+ health: "/agent/health",
320
320
  version: "0.3.1",
321
321
  },
322
322
  ]);
323
323
  const { supervisor } = makeIdleSupervisor();
324
- await supervisor.start({ short: "channel", cmd: ["parachute-channel", "daemon"] });
324
+ await supervisor.start({ short: "agent", cmd: ["parachute-agent"] });
325
325
 
326
326
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
327
327
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
@@ -340,18 +340,17 @@ describe("GET /api/modules", () => {
340
340
  supervisor_status: string | null;
341
341
  }>;
342
342
  };
343
- const channel = body.modules.find((m) => m.short === "channel");
344
- expect(channel).toBeDefined();
345
- expect(channel?.installed).toBe(true);
346
- expect(channel?.installed_version).toBe("0.3.1");
347
- expect(channel?.focus).toBe("experimental");
348
- expect(channel?.supervisor_status).toBe("running");
343
+ const agent = body.modules.find((m) => m.short === "agent");
344
+ expect(agent).toBeDefined();
345
+ expect(agent?.installed).toBe(true);
346
+ expect(agent?.installed_version).toBe("0.3.1");
347
+ expect(agent?.focus).toBe("experimental");
348
+ expect(agent?.supervisor_status).toBe("running");
349
349
  });
350
350
 
351
351
  test("every self-registered + known module appears in `modules` — no running-but-invisible class", async () => {
352
352
  // The two-registry-disagreement (services.json says installed, the curated
353
- // whitelist says invisible) is gone: a self-registered surface row + a
354
- // supervised channel both surface in `modules` (2026-06-09 modular-UI
353
+ // whitelist says invisible) is gone: a self-registered surface row + a // supervised agent both surface in `modules` (2026-06-09 modular-UI
355
354
  // architecture). `supervised` still mirrors the run-state for every
356
355
  // tracked module (hub#539) — consumers dedupe by short.
357
356
  writeManifest(h.manifestPath, [
@@ -630,19 +629,19 @@ describe("GET /api/modules", () => {
630
629
  });
631
630
 
632
631
  test("populates config_ui_url from a module's configUiUrl (2026-06-09 modular-UI P3)", async () => {
633
- // Channel declares `configUiUrl: "/channel/admin"` (a single-instance,
634
- // origin-absolute path) + `uiUrl: "/channel/ui"`. The hub surfaces
632
+ // Agent declares `configUiUrl: "/agent/admin"` (a single-instance,
633
+ // origin-absolute path) + `uiUrl: "/agent/ui"`. The hub surfaces
635
634
  // both: `config_ui_url` drives the Modules page Configure action,
636
635
  // `management_url` drives Open. configUiUrl resolves identically to
637
636
  // managementUrl (same B4 unified semantics).
638
637
  writeManifest(h.manifestPath, [
639
638
  {
640
- name: "channel",
639
+ name: "agent",
641
640
  port: 1941,
642
- paths: ["/channel"],
641
+ paths: ["/agent"],
643
642
  health: "/health",
644
643
  version: "0.1.0",
645
- installDir: "/install/dir/channel",
644
+ installDir: "/install/dir/agent",
646
645
  },
647
646
  ]);
648
647
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
@@ -652,17 +651,17 @@ describe("GET /api/modules", () => {
652
651
  manifestPath: h.manifestPath,
653
652
  fetchLatestVersion: async () => null,
654
653
  readModuleManifest: async (installDir) => {
655
- if (installDir === "/install/dir/channel") {
654
+ if (installDir === "/install/dir/agent") {
656
655
  return {
657
- name: "channel",
658
- manifestName: "parachute-channel",
659
- displayName: "Channel",
656
+ name: "agent",
657
+ manifestName: "parachute-agent",
658
+ displayName: "Agent",
660
659
  tagline: "",
661
660
  port: 1941,
662
- paths: ["/channel"],
661
+ paths: ["/agent"],
663
662
  health: "/health",
664
- uiUrl: "/channel/ui",
665
- configUiUrl: "/channel/admin",
663
+ uiUrl: "/agent/ui",
664
+ configUiUrl: "/agent/admin",
666
665
  } as unknown as Awaited<
667
666
  ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
668
667
  >;
@@ -678,11 +677,11 @@ describe("GET /api/modules", () => {
678
677
  management_url: string | null;
679
678
  }>;
680
679
  };
681
- const channel = body.modules.find((m) => m.short === "channel");
682
- // Origin-absolute — verbatim, never double-prepends `/channel`.
683
- expect(channel?.config_ui_url).toBe("/channel/admin");
680
+ const agent = body.modules.find((m) => m.short === "agent");
681
+ // Origin-absolute — verbatim, never double-prepends `/agent`.
682
+ expect(agent?.config_ui_url).toBe("/agent/admin");
684
683
  // uiUrl (no managementUrl) drives the Open action's management_url.
685
- expect(channel?.management_url).toBe("/channel/ui");
684
+ expect(agent?.management_url).toBe("/agent/ui");
686
685
  });
687
686
 
688
687
  test("config_ui_url is null when the module declares no configUiUrl", async () => {
@@ -92,7 +92,7 @@ describe("handleApiReady — supervisor mode", () => {
92
92
  moduleState({ short: "vault", status: "starting" }),
93
93
  moduleState({ short: "scribe", status: "restarting" }),
94
94
  moduleState({ short: "notes", status: "crashed" }),
95
- moduleState({ short: "channel", status: "stopped" }),
95
+ moduleState({ short: "agent", status: "stopped" }),
96
96
  moduleState({
97
97
  short: "runner",
98
98
  status: "running",
@@ -109,7 +109,7 @@ describe("handleApiReady — supervisor mode", () => {
109
109
  expect(body.ready).toBe(false);
110
110
  expect(body.ready_modules).toEqual(["runner"]);
111
111
  expect(body.transient_modules.sort()).toEqual(["scribe", "vault"]);
112
- expect(body.persistent_modules.sort()).toEqual(["channel", "notes"]);
112
+ expect(body.persistent_modules.sort()).toEqual(["agent", "notes"]);
113
113
  });
114
114
 
115
115
  test("only crashed/stopped + nothing transient → ready=false (still failing)", async () => {
@@ -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
+ });