@openparachute/hub 0.7.6-rc.4 → 0.7.7-rc.12

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 (78) hide show
  1. package/README.md +7 -7
  2. package/package.json +8 -13
  3. package/src/__tests__/account-api.test.ts +798 -0
  4. package/src/__tests__/account-session.test.ts +252 -0
  5. package/src/__tests__/account-token.test.ts +316 -0
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-handlers.test.ts +25 -0
  8. package/src/__tests__/admin-lock.test.ts +3 -14
  9. package/src/__tests__/admin-module-token.test.ts +10 -30
  10. package/src/__tests__/admin-surfaces.test.ts +21 -0
  11. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  12. package/src/__tests__/api-mint-token.test.ts +25 -0
  13. package/src/__tests__/api-modules-ops.test.ts +34 -29
  14. package/src/__tests__/api-modules.test.ts +50 -58
  15. package/src/__tests__/api-revoke-token.test.ts +23 -0
  16. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  17. package/src/__tests__/api-settings-root-redirect.test.ts +12 -0
  18. package/src/__tests__/api-tokens.test.ts +44 -0
  19. package/src/__tests__/audience-gate.test.ts +24 -0
  20. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  21. package/src/__tests__/chrome-strip.test.ts +18 -1
  22. package/src/__tests__/doctor.test.ts +10 -17
  23. package/src/__tests__/door-contract-parity.test.ts +46 -0
  24. package/src/__tests__/hub-server.test.ts +37 -0
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +279 -5
  27. package/src/__tests__/migrate.test.ts +3 -1
  28. package/src/__tests__/notes-serve.test.ts +216 -0
  29. package/src/__tests__/oauth-handlers.test.ts +19 -8
  30. package/src/__tests__/operator-token.test.ts +1 -2
  31. package/src/__tests__/port-assign.test.ts +37 -17
  32. package/src/__tests__/scope-explanations.test.ts +22 -2
  33. package/src/__tests__/serve-boot.test.ts +25 -36
  34. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  35. package/src/__tests__/services-manifest.test.ts +372 -132
  36. package/src/__tests__/sessions.test.ts +75 -28
  37. package/src/__tests__/setup-wizard.test.ts +7 -10
  38. package/src/__tests__/setup.test.ts +13 -14
  39. package/src/__tests__/status.test.ts +0 -5
  40. package/src/__tests__/surface-notes-alias.test.ts +296 -0
  41. package/src/account-api.ts +677 -0
  42. package/src/account-session.ts +132 -0
  43. package/src/account-token.ts +200 -0
  44. package/src/admin-connections.ts +4 -2
  45. package/src/admin-lock.ts +1 -2
  46. package/src/admin-module-token.ts +13 -8
  47. package/src/admin-surfaces.ts +2 -1
  48. package/src/api-hub-upgrade.ts +2 -1
  49. package/src/api-invites.ts +19 -0
  50. package/src/api-mint-token.ts +2 -1
  51. package/src/api-modules-ops.ts +2 -1
  52. package/src/api-modules.ts +10 -8
  53. package/src/api-revoke-token.ts +2 -1
  54. package/src/api-settings-hub-origin.ts +2 -1
  55. package/src/api-settings-root-redirect.ts +2 -1
  56. package/src/api-tokens.ts +2 -1
  57. package/src/audience-gate.ts +2 -1
  58. package/src/chrome-strip.ts +16 -4
  59. package/src/commands/install.ts +86 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/help.ts +6 -6
  63. package/src/hub-server.ts +247 -52
  64. package/src/hub-settings.ts +25 -1
  65. package/src/hub.ts +64 -31
  66. package/src/invites.ts +42 -0
  67. package/src/module-ops-client.ts +2 -1
  68. package/src/notes-serve.ts +73 -31
  69. package/src/oauth-handlers.ts +1 -11
  70. package/src/operator-token.ts +0 -1
  71. package/src/origin-check.ts +2 -2
  72. package/src/scope-explanations.ts +35 -5
  73. package/src/service-spec.ts +128 -74
  74. package/src/services-manifest.ts +112 -52
  75. package/src/sessions.ts +66 -30
  76. package/src/surface-notes-alias.ts +126 -0
  77. package/src/__tests__/admin-agent-token.test.ts +0 -173
  78. package/src/admin-agent-token.ts +0 -147
@@ -21,7 +21,7 @@ import { createHash, randomBytes } from "node:crypto";
21
21
  import { mkdtempSync, rmSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
- import { handleAgentToken } from "../admin-agent-token.ts";
24
+
25
25
  import { handleHostAdminToken } from "../admin-host-admin-token.ts";
26
26
  import {
27
27
  DEFAULT_ADMIN_LOCK_IDLE_SECONDS,
@@ -239,10 +239,10 @@ describe("recordLoginUnlock", () => {
239
239
  });
240
240
 
241
241
  // ---------------------------------------------------------------------------
242
- // The cascade: a locked session refuses ALL four admin-token mints.
242
+ // The cascade: a locked session refuses every admin-token mint.
243
243
  // ---------------------------------------------------------------------------
244
244
 
245
- describe("lock cascade through the four admin-token mints", () => {
245
+ describe("lock cascade through the admin-token mints", () => {
246
246
  test("host-admin-token: 200 when no PIN, 423 when locked, 200 after unlock", async () => {
247
247
  const { cookie, sessionId } = await withAdmin();
248
248
  rotateSigningKey(harness.db);
@@ -262,17 +262,6 @@ describe("lock cascade through the four admin-token mints", () => {
262
262
  expect((await handleHostAdminToken(mk(), { db: harness.db, issuer: ISSUER })).status).toBe(200);
263
263
  });
264
264
 
265
- test("agent-token cascades (423 when locked)", async () => {
266
- const { cookie } = await withAdmin();
267
- rotateSigningKey(harness.db);
268
- await setPin(harness.db, "4827");
269
- const res = await handleAgentToken(
270
- new Request(`${ISSUER}/admin/agent-token`, { headers: { cookie } }),
271
- { db: harness.db, issuer: ISSUER },
272
- );
273
- expect(res.status).toBe(423);
274
- });
275
-
276
265
  test("vault-admin-token cascades (423 when locked)", async () => {
277
266
  const { cookie } = await withAdmin();
278
267
  rotateSigningKey(harness.db);
@@ -7,7 +7,7 @@
7
7
  * - 401 when the cookie names a deleted session.
8
8
  * - 405 on POST.
9
9
  * - 200 + JWT carrying `aud: "<short>"` and `<short>:admin` for known modules
10
- * (scribe / surface / agent).
10
+ * (scribe / surface).
11
11
  * - 400 for `vault` (per-instance — points at /admin/vault-admin-token/<name>).
12
12
  * - 404 for an unknown short.
13
13
  * - First-admin gate: 403 for a signed-in non-first-admin (friend).
@@ -114,7 +114,7 @@ describe("handleModuleToken", () => {
114
114
  // `<short>:admin` with `aud: <short>`. (runner left this set with its
115
115
  // 2026-07-01 registry removal — a LEGACY install still mints via the
116
116
  // self-registration gate, pinned below.)
117
- for (const short of ["scribe", "surface", "agent"]) {
117
+ for (const short of ["scribe", "surface"]) {
118
118
  test(`200 mints a JWT carrying aud:${short} + ${short}:admin`, async () => {
119
119
  const { cookie, userId } = await withSession();
120
120
  rotateSigningKey(harness.db);
@@ -321,7 +321,7 @@ describe("handleModuleToken", () => {
321
321
  expect(body.error).toBe("use_vault_admin_token");
322
322
  });
323
323
 
324
- test("first-party row resolves through the manifest-name map (parachute-agent agent)", async () => {
324
+ test("retired Agent identifiers do not mint even when stale rows are supplied", async () => {
325
325
  const { cookie } = await withSession();
326
326
  rotateSigningKey(harness.db);
327
327
  const installDir = writeManifestDir("agent");
@@ -335,40 +335,20 @@ describe("handleModuleToken", () => {
335
335
  version: "0.1.0",
336
336
  installDir,
337
337
  },
338
- ];
339
- const req = new Request(urlFor("agent"), { headers: { cookie } });
340
- const res = await handleModuleToken(req, "agent", depsWith(services));
341
- expect(res.status).toBe(200);
342
- const body = (await res.json()) as { scopes: string[] };
343
- expect(body.scopes).toEqual(["agent:admin"]);
344
- } finally {
345
- rmSync(installDir, { recursive: true, force: true });
346
- }
347
- });
348
-
349
- test("a legacy parachute-channel row still resolves to short `agent` (rename back-compat)", async () => {
350
- // Un-upgraded operators carry a `parachute-channel` services.json row;
351
- // LEGACY_MANIFEST_ALIASES maps it to short `agent` so the agent config UI
352
- // can still mint its admin Bearer until the daemon re-registers.
353
- const { cookie } = await withSession();
354
- rotateSigningKey(harness.db);
355
- const installDir = writeManifestDir("agent");
356
- try {
357
- const services: ServiceEntry[] = [
358
338
  {
359
339
  name: "parachute-channel",
360
- port: 1941,
361
- paths: ["/agent"],
340
+ port: 1942,
341
+ paths: ["/channel"],
362
342
  health: "/health",
363
343
  version: "0.1.0",
364
344
  installDir,
365
345
  },
366
346
  ];
367
- const req = new Request(urlFor("agent"), { headers: { cookie } });
368
- const res = await handleModuleToken(req, "agent", depsWith(services));
369
- expect(res.status).toBe(200);
370
- const body = (await res.json()) as { scopes: string[] };
371
- expect(body.scopes).toEqual(["agent:admin"]);
347
+ for (const short of ["agent", "parachute-agent", "parachute-channel"]) {
348
+ const req = new Request(urlFor(short), { headers: { cookie } });
349
+ const res = await handleModuleToken(req, short, depsWith(services));
350
+ expect(res.status).toBe(404);
351
+ }
372
352
  } finally {
373
353
  rmSync(installDir, { recursive: true, force: true });
374
354
  }
@@ -151,6 +151,27 @@ describe("routeAdminSurfaces — register + list", () => {
151
151
  }
152
152
  });
153
153
 
154
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
155
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
156
+ const h = await makeHarness();
157
+ try {
158
+ const token = await mint(h, ["parachute:host:admin"]);
159
+ const res = await routeAdminSurfaces(
160
+ req(
161
+ "POST",
162
+ { authorization: `bearer ${token}`, "content-type": "application/json" },
163
+ { name: "brain", mount: "/surface/brain", mode: "prod" },
164
+ ),
165
+ deps(h),
166
+ );
167
+ expect(res?.status).toBe(200);
168
+ const body = (await res?.json()) as { ok: boolean };
169
+ expect(body.ok).toBe(true);
170
+ } finally {
171
+ h.cleanup();
172
+ }
173
+ });
174
+
154
175
  test("POST with a missing name → 400", async () => {
155
176
  const h = await makeHarness();
156
177
  try {
@@ -510,6 +510,17 @@ describe("GET /api/hub/upgrade/status", () => {
510
510
  const res = await handleHubUpgradeStatus(getStatusReq({}), deps);
511
511
  expect(res.status).toBe(401);
512
512
  });
513
+
514
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
515
+ test("lowercase bearer scheme authenticates identically to canonical Bearer (404, not 401)", async () => {
516
+ const bearer = await mintBearer(harness, ["parachute:host:admin"]);
517
+ const { deps } = baseDeps(harness);
518
+ const res = await handleHubUpgradeStatus(
519
+ getStatusReq({ authorization: `bearer ${bearer}` }),
520
+ deps,
521
+ );
522
+ expect(res.status).toBe(404);
523
+ });
513
524
  });
514
525
 
515
526
  describe("detectHubUpgradeMode — §5.3 heuristic", () => {
@@ -172,6 +172,31 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
172
172
  }
173
173
  });
174
174
 
175
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
176
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
177
+ const h = makeHarness();
178
+ try {
179
+ const { db, userId } = await bootstrap(h.dir);
180
+ try {
181
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
182
+ const resp = await handleApiMintToken(
183
+ jsonRequest(
184
+ { scope: "scribe:transcribe", expires_in: 3600 },
185
+ { authorization: `bearer ${op.token}` },
186
+ ),
187
+ { db, issuer: ISSUER },
188
+ );
189
+ expect(resp.status).toBe(200);
190
+ const body = (await resp.json()) as { scope: string };
191
+ expect(body.scope).toBe("scribe:transcribe");
192
+ } finally {
193
+ db.close();
194
+ }
195
+ } finally {
196
+ h.cleanup();
197
+ }
198
+ });
199
+
175
200
  // hub#516 parity — the live "mint refused" after `parachute hub set-origin`.
176
201
  // An operator/agent credential minted under a PRIOR origin (still a member of
177
202
  // the hub's bound-origin set) must keep minting after the canonical issuer
@@ -208,21 +208,12 @@ describe("parseModulesPath", () => {
208
208
  });
209
209
  });
210
210
 
211
- test("accepts any KNOWN module short agent install now resolves (was the bug)", () => {
212
- // Post-2026-06-09 (modular-UI architecture, P2) the install-path gate is
213
- // `isKnownModuleShort` (KNOWN_MODULES ∪ FIRST_PARTY_FALLBACKS), NOT the old
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",
219
- rest: "install",
220
- });
221
- // Other known modules (surface) resolve too.
211
+ test("accepts supported module shorts and rejects retired Agent", () => {
222
212
  expect(parseModulesPath("/api/modules/surface/install")).toEqual({
223
213
  short: "surface",
224
214
  rest: "install",
225
215
  });
216
+ expect(parseModulesPath("/api/modules/agent/install")).toBeUndefined();
226
217
  });
227
218
 
228
219
  test("rejects unknown shorts (the hub itself + genuinely third-party rows)", () => {
@@ -309,6 +300,29 @@ describe("POST /api/modules/:short/install", () => {
309
300
  expect(body.error_description).toContain("parachute:host:admin");
310
301
  });
311
302
 
303
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
304
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
305
+ const { supervisor } = makeIdleSupervisor();
306
+ const { run } = alwaysOkRun();
307
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
308
+ const res = await handleInstall(
309
+ postReq("/api/modules/vault/install", { authorization: `bearer ${bearer}` }),
310
+ "vault",
311
+ {
312
+ db: h.db,
313
+ issuer: ISSUER,
314
+ manifestPath: h.manifestPath,
315
+ configDir: h.dir,
316
+ supervisor,
317
+ run,
318
+ isLinked: TEST_DEFAULT_NOT_LINKED,
319
+ },
320
+ );
321
+ expect(res.status).toBe(202);
322
+ const body = (await res.json()) as { operation_id: string };
323
+ expect(body.operation_id).toBeDefined();
324
+ });
325
+
312
326
  test("202 + operation_id, runs bun add + seeds services.json + spawns", async () => {
313
327
  const { supervisor, spawns } = makeIdleSupervisor();
314
328
  const { run, calls } = alwaysOkRun();
@@ -1019,16 +1033,12 @@ describe("POST /api/modules/:short/start", () => {
1019
1033
  expect(calls).toEqual([]);
1020
1034
  });
1021
1035
 
1022
- test("channel#41: start reconciles a drifted services.json port back to canonical (API path)", async () => {
1023
- // The live signature: the agent (then channel) row carried 19415 instead of
1024
- // canonical 1941. The API start path (admin SPA / `parachute start agent`)
1025
- // must apply the SAME reconcile the boot path does — otherwise an
1026
- // operator-triggered start re-strands the module on the dead port.
1036
+ test("start reconciles a supported module's drifted port back to canonical", async () => {
1027
1037
  writeManifest(h.manifestPath, [
1028
1038
  {
1029
- name: "parachute-agent",
1039
+ name: "parachute-scribe",
1030
1040
  port: 19415,
1031
- paths: ["/agent"],
1041
+ paths: ["/scribe"],
1032
1042
  health: "/health",
1033
1043
  version: "0.0.0-linked",
1034
1044
  },
@@ -1038,8 +1048,8 @@ describe("POST /api/modules/:short/start", () => {
1038
1048
  const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1039
1049
 
1040
1050
  const res = await handleStart(
1041
- postReq("/api/modules/agent/start", { authorization: `Bearer ${bearer}` }),
1042
- "agent",
1051
+ postReq("/api/modules/scribe/start", { authorization: `Bearer ${bearer}` }),
1052
+ "scribe",
1043
1053
  {
1044
1054
  db: h.db,
1045
1055
  issuer: ISSUER,
@@ -1051,20 +1061,15 @@ describe("POST /api/modules/:short/start", () => {
1051
1061
  );
1052
1062
 
1053
1063
  expect(res.status).toBe(200);
1054
- // The supervisor child gets PORT=1941 (canonical), not the drifted 19415 —
1055
- // so it binds + the readiness probe checks the right port.
1056
1064
  expect(spawns.length).toBe(1);
1057
- expect(spawns[0]?.short).toBe("agent");
1058
- expect(spawns[0]?.env?.PORT).toBe("1941");
1059
- // services.json row is rewritten to 1941 → the reverse-proxy (which reads
1060
- // services.json) routes /agent/* to the live port.
1065
+ expect(spawns[0]?.short).toBe("scribe");
1066
+ expect(spawns[0]?.env?.PORT).toBe("1943");
1061
1067
  const onDisk = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
1062
1068
  services: { name: string; port: number }[];
1063
1069
  };
1064
- expect(onDisk.services.find((s) => s.name === "parachute-agent")?.port).toBe(1941);
1065
- // The reconcile event logged on the API path too (deps.log wired — #41 review).
1070
+ expect(onDisk.services.find((s) => s.name === "parachute-scribe")?.port).toBe(1943);
1066
1071
  expect(
1067
- logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1941")),
1072
+ logs.some((l) => l.includes("reconciled") && l.includes("19415") && l.includes("1943")),
1068
1073
  ).toBe(true);
1069
1074
  });
1070
1075
  });
@@ -175,6 +175,18 @@ describe("GET /api/modules", () => {
175
175
  expect(body.error).toBe("insufficient_scope");
176
176
  });
177
177
 
178
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
179
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
180
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
181
+ const res = await handleApiModules(getReq({ authorization: `bearer ${bearer}` }), {
182
+ db: h.db,
183
+ issuer: ISSUER,
184
+ manifestPath: h.manifestPath,
185
+ fetchLatestVersion: async () => null,
186
+ });
187
+ expect(res.status).toBe(200);
188
+ });
189
+
178
190
  // hub#516: `parachute status` reads /api/modules on loopback presenting the
179
191
  // operator token, whose `iss` is the PUBLIC origin after `expose`. The
180
192
  // host-admin bearer's iss is validated against `knownIssuers` (loopback ∪
@@ -250,15 +262,11 @@ describe("GET /api/modules", () => {
250
262
  // The core tier leads, in the recommended install order (vault → scribe),
251
263
  // ahead of every experimental module, which lead the deprecated ones.
252
264
  expect(shorts.indexOf("vault")).toBeLessThan(shorts.indexOf("scribe"));
253
- expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("agent"));
254
- // agent (experimental) sorts ahead of notes (deprecated).
255
- expect(shorts.indexOf("agent")).toBeLessThan(shorts.indexOf("notes"));
256
- // Every known module is discoverable — vault/scribe/surface (core),
257
- // agent (experimental), notes (deprecated). runner is gone (2026-07-01
258
- // registry removal) — a fresh box never sees it.
259
- for (const s of ["vault", "scribe", "surface", "agent", "notes"]) {
265
+ expect(shorts.indexOf("scribe")).toBeLessThan(shorts.indexOf("notes"));
266
+ for (const s of ["vault", "scribe", "surface", "app", "notes"]) {
260
267
  expect(shorts).toContain(s);
261
268
  }
269
+ expect(shorts).not.toContain("agent");
262
270
  expect(shorts).not.toContain("runner");
263
271
  expect(shorts).not.toContain("parachute-runner");
264
272
  // Focus tier resolves from the default map.
@@ -266,7 +274,8 @@ describe("GET /api/modules", () => {
266
274
  expect(byShort.get("vault")?.focus).toBe("core");
267
275
  expect(byShort.get("scribe")?.focus).toBe("core");
268
276
  expect(byShort.get("surface")?.focus).toBe("core");
269
- expect(byShort.get("agent")?.focus).toBe("experimental");
277
+ expect(byShort.get("app")?.focus).toBe("core");
278
+ expect(byShort.get("agent")).toBeUndefined();
270
279
  expect(byShort.get("notes")?.focus).toBe("deprecated");
271
280
  // `available` stays true for every known module (re-installable), but the
272
281
  // fresh-install OFFER (`available_to_install`) drops the deprecated tier —
@@ -275,7 +284,7 @@ describe("GET /api/modules", () => {
275
284
  expect(byShort.get("vault")?.available_to_install).toBe(true);
276
285
  expect(byShort.get("scribe")?.available_to_install).toBe(true);
277
286
  expect(byShort.get("surface")?.available_to_install).toBe(true);
278
- expect(byShort.get("agent")?.available_to_install).toBe(true);
287
+ expect(byShort.get("app")?.available_to_install).toBe(true);
279
288
  expect(byShort.get("notes")?.available_to_install).toBe(false);
280
289
  expect(body.modules.every((m) => !m.installed)).toBe(true);
281
290
  expect(body.modules.every((m) => m.latest_version === "0.9.9")).toBe(true);
@@ -369,13 +378,7 @@ describe("GET /api/modules", () => {
369
378
  expect(scribe?.available).toBe(true);
370
379
  });
371
380
 
372
- test("agent (running + self-registered) appears as installed + experimental — regression for the channel-not-installed bug", async () => {
373
- // THE bug this PR fixes (2026-06-09 modular-UI architecture, P2): agent (renamed from channel 2026-06-17)
374
- // was running, proxied, supervised, and self-registered in services.json
375
- // yet invisible on the Modules screen — because the old CURATED_MODULES =
376
- // ["vault","scribe"] whitelist gated discovery. Now discovery is driven by
377
- // self-registration ∪ the known registries, so a self-registered agent
378
- // row surfaces as installed, in the experimental tier, with its run-state.
381
+ test("retired Agent rows do not surface in the module catalog", async () => {
379
382
  writeManifest(h.manifestPath, [
380
383
  {
381
384
  name: "parachute-agent",
@@ -385,32 +388,16 @@ describe("GET /api/modules", () => {
385
388
  version: "0.3.1",
386
389
  },
387
390
  ]);
388
- const { supervisor } = makeIdleSupervisor();
389
- await supervisor.start({ short: "agent", cmd: ["parachute-agent"] });
390
-
391
391
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
392
392
  const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
393
393
  db: h.db,
394
394
  issuer: ISSUER,
395
395
  manifestPath: h.manifestPath,
396
- supervisor,
397
396
  fetchLatestVersion: async () => null,
398
397
  });
399
- const body = (await res.json()) as {
400
- modules: Array<{
401
- short: string;
402
- focus: "core" | "experimental";
403
- installed: boolean;
404
- installed_version: string | null;
405
- supervisor_status: string | null;
406
- }>;
407
- };
408
- const agent = body.modules.find((m) => m.short === "agent");
409
- expect(agent).toBeDefined();
410
- expect(agent?.installed).toBe(true);
411
- expect(agent?.installed_version).toBe("0.3.1");
412
- expect(agent?.focus).toBe("experimental");
413
- expect(agent?.supervisor_status).toBe("running");
398
+ const body = (await res.json()) as { modules: Array<{ short: string }> };
399
+ expect(body.modules.find((m) => m.short === "agent")).toBeUndefined();
400
+ expect(body.modules.find((m) => m.short === "parachute-agent")).toBeUndefined();
414
401
  });
415
402
 
416
403
  test("every self-registered + known module appears in `modules` — no running-but-invisible class", async () => {
@@ -834,20 +821,15 @@ describe("GET /api/modules", () => {
834
821
  expect(vault?.management_url).toBe("/vault/default/admin");
835
822
  });
836
823
 
837
- test("populates config_ui_url from a module's configUiUrl (2026-06-09 modular-UI P3)", async () => {
838
- // Agent declares `configUiUrl: "/agent/admin"` (a single-instance,
839
- // origin-absolute path) + `uiUrl: "/agent/ui"`. The hub surfaces
840
- // both: `config_ui_url` drives the Modules page Configure action,
841
- // `management_url` drives Open. configUiUrl resolves identically to
842
- // managementUrl (same B4 unified semantics).
824
+ test("populates config_ui_url from a third-party module manifest", async () => {
843
825
  writeManifest(h.manifestPath, [
844
826
  {
845
- name: "agent",
846
- port: 1941,
847
- paths: ["/agent"],
827
+ name: "widgets",
828
+ port: 1947,
829
+ paths: ["/widgets"],
848
830
  health: "/health",
849
831
  version: "0.1.0",
850
- installDir: "/install/dir/agent",
832
+ installDir: "/install/dir/widgets",
851
833
  },
852
834
  ]);
853
835
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
@@ -857,17 +839,17 @@ describe("GET /api/modules", () => {
857
839
  manifestPath: h.manifestPath,
858
840
  fetchLatestVersion: async () => null,
859
841
  readModuleManifest: async (installDir) => {
860
- if (installDir === "/install/dir/agent") {
842
+ if (installDir === "/install/dir/widgets") {
861
843
  return {
862
- name: "agent",
863
- manifestName: "parachute-agent",
864
- displayName: "Agent",
844
+ name: "widgets",
845
+ manifestName: "widgets",
846
+ displayName: "Widgets",
865
847
  tagline: "",
866
- port: 1941,
867
- paths: ["/agent"],
848
+ port: 1947,
849
+ paths: ["/widgets"],
868
850
  health: "/health",
869
- uiUrl: "/agent/ui",
870
- configUiUrl: "/agent/admin",
851
+ uiUrl: "/widgets/ui",
852
+ configUiUrl: "/widgets/admin",
871
853
  } as unknown as Awaited<
872
854
  ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
873
855
  >;
@@ -883,11 +865,9 @@ describe("GET /api/modules", () => {
883
865
  management_url: string | null;
884
866
  }>;
885
867
  };
886
- const agent = body.modules.find((m) => m.short === "agent");
887
- // Origin-absolute — verbatim, never double-prepends `/agent`.
888
- expect(agent?.config_ui_url).toBe("/agent/admin");
889
- // uiUrl (no managementUrl) drives the Open action's management_url.
890
- expect(agent?.management_url).toBe("/agent/ui");
868
+ const widgets = body.modules.find((m) => m.short === "widgets");
869
+ expect(widgets?.config_ui_url).toBe("/widgets/admin");
870
+ expect(widgets?.management_url).toBe("/widgets/ui");
891
871
  });
892
872
 
893
873
  test("config_ui_url is null when the module declares no configUiUrl", async () => {
@@ -1498,6 +1478,18 @@ describe("PUT /api/modules/channel — hub#275 channel toggle", () => {
1498
1478
  expect(res.status).toBe(401);
1499
1479
  });
1500
1480
 
1481
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
1482
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
1483
+ const bearer = await mintBearer(h, [API_MODULES_CHANNEL_REQUIRED_SCOPE]);
1484
+ const res = await handleApiModulesChannel(
1485
+ putReq({ channel: "rc" }, { authorization: `bearer ${bearer}` }),
1486
+ { db: h.db, issuer: ISSUER },
1487
+ );
1488
+ expect(res.status).toBe(200);
1489
+ const body = (await res.json()) as { channel: string };
1490
+ expect(body.channel).toBe("rc");
1491
+ });
1492
+
1501
1493
  test("403 on bearer without parachute:host:admin", async () => {
1502
1494
  // `:host:auth` reads the GET catalog — it must NOT be allowed to
1503
1495
  // flip the install channel. Boundary matches install/upgrade/uninstall.
@@ -263,6 +263,29 @@ describe("POST /api/auth/revoke-token (closes hub#220)", () => {
263
263
  }
264
264
  });
265
265
 
266
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
267
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
268
+ const h = makeHarness();
269
+ try {
270
+ const { db, userId } = await bootstrap(h.dir);
271
+ try {
272
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
273
+ const jti = await seedToken(db, userId);
274
+ const resp = await handleApiRevokeToken(
275
+ jsonRequest({ jti }, { authorization: `bearer ${op.token}` }),
276
+ { db, issuer: ISSUER },
277
+ );
278
+ expect(resp.status).toBe(200);
279
+ const body = (await resp.json()) as { jti: string };
280
+ expect(body.jti).toBe(jti);
281
+ } finally {
282
+ db.close();
283
+ }
284
+ } finally {
285
+ h.cleanup();
286
+ }
287
+ });
288
+
266
289
  test("idempotent: re-revoking returns 200 with the original revoked_at", async () => {
267
290
  const h = makeHarness();
268
291
  try {
@@ -258,6 +258,18 @@ describe("GET /api/settings/hub-origin", () => {
258
258
  expect(body.resolved_issuer).toBe("http://localhost"); // request origin from getReq()
259
259
  });
260
260
 
261
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
262
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
263
+ const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
264
+ const res = await handleApiSettingsHubOrigin(
265
+ getReq({ authorization: `bearer ${bearer}` }),
266
+ deps(h),
267
+ );
268
+ expect(res.status).toBe(200);
269
+ const body = (await res.json()) as { hub_origin: string | null };
270
+ expect(body.hub_origin).toBeNull();
271
+ });
272
+
261
273
  test("returns env source + env-resolved issuer when configuredIssuer is set", async () => {
262
274
  const bearer = await mintBearer(h, [API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE]);
263
275
  const res = await handleApiSettingsHubOrigin(getReq({ authorization: `Bearer ${bearer}` }), {
@@ -195,6 +195,18 @@ describe("GET /api/settings/root-redirect", () => {
195
195
  expect(body).toEqual({ root_redirect: null, resolved: "/admin", source: "default" });
196
196
  });
197
197
 
198
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
199
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
200
+ const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
201
+ const res = await handleApiSettingsRootRedirect(
202
+ getReq({ authorization: `bearer ${bearer}` }),
203
+ deps(h),
204
+ );
205
+ expect(res.status).toBe(200);
206
+ const body = (await res.json()) as Record<string, unknown>;
207
+ expect(body).toEqual({ root_redirect: null, resolved: "/admin", source: "default" });
208
+ });
209
+
198
210
  test("reflects a stored value with source=db", async () => {
199
211
  setRootRedirect(h.db, "/surface/reading-room");
200
212
  const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
@@ -139,6 +139,50 @@ describe("GET /api/auth/tokens (admin token list — Phase 2 backend)", () => {
139
139
  }
140
140
  });
141
141
 
142
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
143
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
144
+ const h = makeHarness();
145
+ try {
146
+ const { db, userId } = await bootstrap(h.dir);
147
+ try {
148
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
149
+ const resp = await handleApiTokens(
150
+ getRequest("", { authorization: `bearer ${op.token}` }),
151
+ {
152
+ db,
153
+ issuer: ISSUER,
154
+ },
155
+ );
156
+ expect(resp.status).toBe(200);
157
+ const body = (await resp.json()) as { tokens: unknown[] };
158
+ expect(body.tokens).toHaveLength(1);
159
+ } finally {
160
+ db.close();
161
+ }
162
+ } finally {
163
+ h.cleanup();
164
+ }
165
+ });
166
+
167
+ test("mixed-case bearer scheme (BeArEr) authenticates identically to canonical Bearer", async () => {
168
+ const h = makeHarness();
169
+ try {
170
+ const { db, userId } = await bootstrap(h.dir);
171
+ try {
172
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
173
+ const resp = await handleApiTokens(
174
+ getRequest("", { authorization: `BeArEr ${op.token}` }),
175
+ { db, issuer: ISSUER },
176
+ );
177
+ expect(resp.status).toBe(200);
178
+ } finally {
179
+ db.close();
180
+ }
181
+ } finally {
182
+ h.cleanup();
183
+ }
184
+ });
185
+
142
186
  test("happy path: empty registry returns empty array", async () => {
143
187
  const h = makeHarness();
144
188
  try {
@@ -217,6 +217,30 @@ describe("audience gate matrix (H3)", () => {
217
217
  }
218
218
  });
219
219
 
220
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
221
+ test("audience: hub-users — lowercase/mixed-case bearer scheme authenticates identically to canonical Bearer", async () => {
222
+ const upstream = startEchoUpstream();
223
+ try {
224
+ writeServices(surfaceEntry(upstream.port, { audience: "hub-users" }));
225
+ const f = fetcher();
226
+ const bearer = await mintBearer(["vault:default:read", "vault:default:write"]);
227
+
228
+ const lower = await f(
229
+ req("/surface/notes/x", { headers: { authorization: `bearer ${bearer}` } }),
230
+ fakeServer("127.0.0.1"),
231
+ );
232
+ expect(lower?.status).toBe(200);
233
+
234
+ const mixed = await f(
235
+ req("/surface/notes/x", { headers: { authorization: `BeArEr ${bearer}` } }),
236
+ fakeServer("127.0.0.1"),
237
+ );
238
+ expect(mixed?.status).toBe(200);
239
+ } finally {
240
+ upstream.stop();
241
+ }
242
+ });
243
+
220
244
  test("audience: hub-users — Bearer with NON-satisfying scopes → 403; garbage Bearer → 401", async () => {
221
245
  const upstream = startEchoUpstream();
222
246
  try {