@openparachute/hub 0.7.7-rc.8 → 0.7.7

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 (80) hide show
  1. package/README.md +7 -7
  2. package/package.json +4 -12
  3. package/src/__tests__/account-api.test.ts +29 -2
  4. package/src/__tests__/account-session.test.ts +9 -1
  5. package/src/__tests__/account-token.test.ts +26 -2
  6. package/src/__tests__/admin-connections-credentials.test.ts +41 -0
  7. package/src/__tests__/admin-lock.test.ts +3 -14
  8. package/src/__tests__/admin-module-token.test.ts +10 -30
  9. package/src/__tests__/admin-surfaces.test.ts +21 -0
  10. package/src/__tests__/api-hub-upgrade.test.ts +11 -0
  11. package/src/__tests__/api-mint-token.test.ts +25 -0
  12. package/src/__tests__/api-modules-ops.test.ts +34 -29
  13. package/src/__tests__/api-modules.test.ts +50 -58
  14. package/src/__tests__/api-revoke-token.test.ts +23 -0
  15. package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
  16. package/src/__tests__/api-settings-root-redirect.test.ts +159 -4
  17. package/src/__tests__/api-tokens.test.ts +44 -0
  18. package/src/__tests__/audience-gate.test.ts +24 -0
  19. package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
  20. package/src/__tests__/chrome-strip.test.ts +18 -1
  21. package/src/__tests__/doctor.test.ts +10 -17
  22. package/src/__tests__/hub-command.test.ts +70 -2
  23. package/src/__tests__/hub-server.test.ts +322 -1
  24. package/src/__tests__/hub-settings.test.ts +110 -6
  25. package/src/__tests__/hub.test.ts +29 -0
  26. package/src/__tests__/install.test.ts +359 -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__/root-serve.test.ts +139 -0
  33. package/src/__tests__/scope-explanations.test.ts +0 -2
  34. package/src/__tests__/serve-boot.test.ts +25 -36
  35. package/src/__tests__/service-spec-discovery.test.ts +30 -35
  36. package/src/__tests__/services-manifest.test.ts +372 -132
  37. package/src/__tests__/setup-wizard.test.ts +301 -22
  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/__tests__/wizard-transcription.test.ts +35 -0
  42. package/src/__tests__/wizard.test.ts +79 -0
  43. package/src/account-api.ts +6 -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-mint-token.ts +2 -1
  50. package/src/api-modules-ops.ts +2 -1
  51. package/src/api-modules.ts +10 -8
  52. package/src/api-revoke-token.ts +2 -1
  53. package/src/api-settings-hub-origin.ts +2 -1
  54. package/src/api-settings-root-redirect.ts +97 -20
  55. package/src/api-tokens.ts +2 -1
  56. package/src/audience-gate.ts +2 -1
  57. package/src/chrome-strip.ts +16 -4
  58. package/src/commands/hub.ts +104 -0
  59. package/src/commands/install.ts +97 -8
  60. package/src/commands/migrate.ts +5 -1
  61. package/src/commands/setup.ts +9 -6
  62. package/src/commands/wizard-transcription.ts +24 -0
  63. package/src/commands/wizard.ts +34 -1
  64. package/src/help.ts +6 -6
  65. package/src/hub-server.ts +111 -54
  66. package/src/hub-settings.ts +147 -0
  67. package/src/hub.ts +64 -31
  68. package/src/module-ops-client.ts +2 -1
  69. package/src/notes-serve.ts +73 -31
  70. package/src/oauth-handlers.ts +1 -11
  71. package/src/operator-token.ts +0 -1
  72. package/src/origin-check.ts +2 -2
  73. package/src/root-serve.ts +156 -0
  74. package/src/scope-explanations.ts +2 -5
  75. package/src/service-spec.ts +129 -74
  76. package/src/services-manifest.ts +112 -52
  77. package/src/setup-wizard.ts +144 -31
  78. package/src/surface-notes-alias.ts +126 -0
  79. package/src/__tests__/admin-agent-token.test.ts +0 -173
  80. package/src/admin-agent-token.ts +0 -147
package/README.md CHANGED
@@ -342,23 +342,23 @@ Parachute services reserve a block of loopback ports in the canonical range **19
342
342
  | ---- | ------------------ |
343
343
  | 1939 | parachute-hub (internal proxy + static) |
344
344
  | 1940 | parachute-vault |
345
- | 1941 | parachute-agent *(renamed from parachute-channel 2026-06-17)* |
346
- | 1942 | *parachute-notes (archived 2026-05-24 — notes-daemon retired; Notes now bundled in parachute-surface. Slot reclaimable; see [`parachute-notes/DEPRECATED.md`](https://github.com/ParachuteComputer/parachute-notes/blob/main/DEPRECATED.md))* |
345
+ | 1941 | *unassigned (released when parachute-agent retired)* |
346
+ | 1942 | parachute-notes *(archived, reservation retained)* |
347
347
  | 1943 | parachute-scribe |
348
- | 1944 | *parachute-agent-legacy (the Claude-in-containers module retired 2026-05-20; slot held — NOT the 1941 agent module, which is the renamed channel)* |
349
- | 1945 | parachute-runner *(shipped; exploration-tier, not committed-core)* |
348
+ | 1944 | parachute-app |
349
+ | 1945 | *unassigned (CLI fallback)* |
350
350
  | 1946 | parachute-surface *(committed core; UI host — bundles Notes, hosts custom surfaces)* |
351
351
  | 1947–1949 | *unassigned (CLI fallback range)* |
352
352
 
353
353
  The hub pins 1939 — no fallback. If something else is on 1939 when you run `parachute expose`, the command fails with a pointer to `lsof -iTCP:1939` rather than walking up into another service's slot.
354
354
 
355
- **The CLI is the port authority.** `parachute install <svc>` picks the port at install time and writes `PORT=<port>` into `~/.parachute/<svc>/.env`; lifecycle.start merges that .env into the spawn env so the next daemon boot binds the port the CLI assigned. The algorithm:
355
+ **The CLI is the port authority.** `parachute install <svc>` selects the service port and persists it in `~/.parachute/services.json`. On supervised boot, that manifest entry is authoritative: the hub injects its port into the child environment and ignores a stale per-module `.env` `PORT`. The algorithm:
356
356
 
357
357
  1. Prefer the canonical slot (e.g. vault → 1940).
358
- 2. On collision, walk the unassigned range (1947–1949).
358
+ 2. On collision, walk the unassigned canonical range starting at 1941.
359
359
  3. Range exhausted: assign past 1949 with a warning.
360
360
 
361
- Idempotent: an existing `PORT=` in `~/.parachute/<svc>/.env` wins, so re-installs and operator-edited ports survive across upgrades. Services keep their compiled-in fallbacks (vault → 1940 etc.) so a stand-alone `bun run` still works without a CLI-managed .env.
361
+ The resulting `services.json` entry is authoritative for supervised starts. Services retain compiled-in defaults for stand-alone execution without a Hub-managed manifest.
362
362
 
363
363
  `parachute expose` probes every service's port at bringup. A service that isn't responding still gets exposed, but you get a `⚠ parachute-<svc> (port …) is not responding` line so proxied requests never silently 502 without explanation.
364
364
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.7-rc.8",
3
+ "version": "0.7.7",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -11,16 +11,8 @@
11
11
  "bin": {
12
12
  "parachute": "src/cli.ts"
13
13
  },
14
- "workspaces": [
15
- "packages/*"
16
- ],
17
- "files": [
18
- "src",
19
- "web/ui/dist",
20
- "scripts/git-credential-parachute",
21
- "README.md",
22
- "LICENSE"
23
- ],
14
+ "workspaces": ["packages/*"],
15
+ "files": ["src", "web/ui/dist", "scripts/git-credential-parachute", "README.md", "LICENSE"],
24
16
  "repository": {
25
17
  "type": "git",
26
18
  "url": "https://github.com/ParachuteComputer/parachute-hub.git"
@@ -42,7 +34,6 @@
42
34
  },
43
35
  "devDependencies": {
44
36
  "@biomejs/biome": "^1.9.4",
45
- "@openparachute/door-contract": "workspace:*",
46
37
  "@types/bun": "latest",
47
38
  "@types/qrcode": "^1.5.6"
48
39
  },
@@ -52,6 +43,7 @@
52
43
  "dependencies": {
53
44
  "@node-rs/argon2": "^2.0.2",
54
45
  "@openparachute/depcheck": "0.1.1",
46
+ "@openparachute/door-contract": "^0.6.0",
55
47
  "jose": "^6.2.2",
56
48
  "otpauth": "^9.5.0",
57
49
  "qrcode": "^1.5.4"
@@ -3,7 +3,12 @@ import { describe, expect, test } from "bun:test";
3
3
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { checkAccountDescriptor, validateVaultScopes } from "@openparachute/door-contract";
6
+ import {
7
+ ACCOUNT_ERROR_CODES,
8
+ checkAccountDescriptor,
9
+ checkVaultTokenMintResponse,
10
+ validateVaultScopes,
11
+ } from "@openparachute/door-contract";
7
12
  import {
8
13
  handleAccountCapabilities,
9
14
  handleAccountCreateVault,
@@ -376,6 +381,13 @@ describe("account API — auth gates", () => {
376
381
  try {
377
382
  const res = await handleAccountListVaults(withBearer("/account/vaults", null), deps(h));
378
383
  expect(res.status).toBe(401);
384
+ // H1.3 — pin the CURRENT shape: auth-gate failures on /account/* emit
385
+ // {error, error_description} (door-contract account-contract.ts:244-246),
386
+ // via the shared `adminAuthErrorResponse` translator.
387
+ const body = (await res.json()) as { error: string; error_description: string };
388
+ expect(body.error).toBe("invalid_token");
389
+ expect(typeof body.error_description).toBe("string");
390
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
379
391
  } finally {
380
392
  h.cleanup();
381
393
  }
@@ -387,6 +399,10 @@ describe("account API — auth gates", () => {
387
399
  const token = await bearer(h, ["vault:beta:read"]);
388
400
  const res = await handleAccountListVaults(withBearer("/account/vaults", token), deps(h));
389
401
  expect(res.status).toBe(403);
402
+ const body = (await res.json()) as { error: string; error_description: string };
403
+ expect(body.error).toBe("insufficient_scope");
404
+ expect(typeof body.error_description).toBe("string");
405
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
390
406
  } finally {
391
407
  h.cleanup();
392
408
  }
@@ -594,6 +610,10 @@ describe("handleAccountMintVaultToken", () => {
594
610
  };
595
611
  expect(body.vault_token.length).toBeGreaterThan(0);
596
612
  expect(body.services["vault:field-notes"]?.url).toBe(`${ISSUER}/vault/field-notes`);
613
+ // H1.2 — door-contract conformance against the live
614
+ // `POST /account/vaults/<name>/token` success body (V1.4/C1.4 twin
615
+ // coverage, hub half).
616
+ expect(checkVaultTokenMintResponse(body, "field-notes")).toEqual([]);
597
617
  const validated = await validateAccessToken(h.db, body.vault_token, ISSUER);
598
618
  expect(validated.payload.aud).toBe("vault.field-notes");
599
619
  expect(validated.payload.scope).toBe("vault:field-notes:read vault:field-notes:write");
@@ -632,7 +652,14 @@ describe("handleAccountMintVaultToken", () => {
632
652
  deps(h),
633
653
  );
634
654
  expect(res.status).toBe(404);
635
- expect(((await res.json()) as { error: string }).error).toBe("vault_not_found");
655
+ const body = (await res.json()) as { error: string; message: string };
656
+ expect(body.error).toBe("vault_not_found");
657
+ // H1.3 — pin the CURRENT shape: resource-level failures on /account/*
658
+ // emit {error, message} (door-contract account-contract.ts:244-246) —
659
+ // distinct from the auth-gate's {error, error_description} above.
660
+ expect(typeof body.message).toBe("string");
661
+ expect(body.message.length).toBeGreaterThan(0);
662
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
636
663
  } finally {
637
664
  h.cleanup();
638
665
  }
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { checkAccountSessionResponse } from "@openparachute/door-contract";
6
+ import { ACCOUNT_ERROR_CODES, checkAccountSessionResponse } from "@openparachute/door-contract";
7
7
  import { handleAccountSession } from "../account-session.ts";
8
8
  import { CSRF_COOKIE_NAME, buildCsrfCookie, generateCsrfToken, parseCsrfCookie } from "../csrf.ts";
9
9
  import { hubDbPath, openHubDb } from "../hub-db.ts";
@@ -227,6 +227,14 @@ describe("handleAccountSession — method + headers", () => {
227
227
  const res = handleAccountSession(getReq(undefined, "POST"), { db: harness.db });
228
228
  expect(res.status).toBe(405);
229
229
  expect(res.headers.get("allow")).toBe("GET");
230
+ // H1.3 — pin the CURRENT shape: resource-level failures on /account/*
231
+ // emit {error, message} (door-contract account-contract.ts:244-246). This
232
+ // is account-session.ts's own `methodNotAllowed`, distinct from
233
+ // account-token.ts's {error, error_description} 405 (see H1.1/H1.3 PR
234
+ // notes — a decisions-needed inconsistency, not fixed here).
235
+ const body = (await res.json()) as { error: string; message: string };
236
+ expect(body).toEqual({ error: "method_not_allowed", message: "use GET" });
237
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
230
238
  });
231
239
 
232
240
  test("cache-control: no-store on the 200 path", async () => {
@@ -19,6 +19,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
19
19
  import { mkdtempSync, rmSync } from "node:fs";
20
20
  import { tmpdir } from "node:os";
21
21
  import { join } from "node:path";
22
+ import { ACCOUNT_ERROR_CODES, checkAccountTokenMintResponse } from "@openparachute/door-contract";
22
23
  // The `/account/*` validator (H2 / cloud) hand-rolls its scope check via
23
24
  // scope-guard's hasScope — hub never imports scope-guard at runtime (the
24
25
  // issuer/validator boundary), but the test asserts the RS-side inheritance
@@ -118,6 +119,14 @@ describe("handleAccountToken", () => {
118
119
  const req = new Request(`${ISSUER}/account/token`, { headers: { cookie } });
119
120
  const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
120
121
  expect(res.status).toBe(405);
122
+ // H1.3 — pin the CURRENT shape: account-token.ts emits {error,
123
+ // error_description} uniformly (its own local `jsonError`), including for
124
+ // this 405 — DIFFERENT from account-session.ts's 405, which emits {error,
125
+ // message} (see that file's test). A decisions-needed inconsistency
126
+ // across /account/*, not fixed here.
127
+ const body = (await res.json()) as { error: string; error_description: string };
128
+ expect(body).toEqual({ error: "method_not_allowed", error_description: "use POST" });
129
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
121
130
  });
122
131
 
123
132
  test("401 when no session cookie is present (unauthenticated → refused)", async () => {
@@ -126,8 +135,13 @@ describe("handleAccountToken", () => {
126
135
  const req = postReq("", { [CSRF_FIELD_NAME]: "anything" });
127
136
  const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
128
137
  expect(res.status).toBe(401);
129
- const body = (await res.json()) as { error: string };
138
+ const body = (await res.json()) as { error: string; error_description: string };
130
139
  expect(body.error).toBe("unauthenticated");
140
+ // H1.3 — pin the CURRENT shape: auth-gate failures on /account/* emit
141
+ // {error, error_description} (door-contract account-contract.ts:244-246).
142
+ expect(typeof body.error_description).toBe("string");
143
+ expect(body.error_description.length).toBeGreaterThan(0);
144
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
131
145
  });
132
146
 
133
147
  test("401 when the cookie names a deleted session", async () => {
@@ -146,8 +160,11 @@ describe("handleAccountToken", () => {
146
160
  rotateSigningKey(harness.db);
147
161
  const res = await handleAccountToken(postReq(cookie, {}), { db: harness.db, issuer: ISSUER });
148
162
  expect(res.status).toBe(403);
149
- const body = (await res.json()) as { error: string };
163
+ const body = (await res.json()) as { error: string; error_description: string };
150
164
  expect(body.error).toBe("csrf_failed");
165
+ // H1.3 — pin the {error, error_description} shape + ACCOUNT_ERROR_CODES membership.
166
+ expect(typeof body.error_description).toBe("string");
167
+ expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
151
168
  });
152
169
 
153
170
  test("403 csrf_failed when the body token doesn't match the cookie token", async () => {
@@ -176,6 +193,10 @@ describe("handleAccountToken", () => {
176
193
  const body = (await res.json()) as { error: string; error_description: string };
177
194
  expect(body.error).toBe("not_admin");
178
195
  expect(body.error_description).toContain("/account/");
196
+ // H1.3 — DECISIONS-NEEDED (cataloged, not fixed here): "not_admin" is a
197
+ // real emitted /account/* error code but is NOT a member of door-contract's
198
+ // ACCOUNT_ERROR_CODES union — a gap in the shared vocabulary. See the PR
199
+ // body's decisions-needed section rather than asserting membership here.
179
200
  });
180
201
 
181
202
  test("200 mints the account superset with aud=account, validating against the hub keys", async () => {
@@ -201,6 +222,9 @@ describe("handleAccountToken", () => {
201
222
  "parachute:host:auth",
202
223
  ]);
203
224
  expect(body.aud).toBe(ACCOUNT_TOKEN_AUDIENCE);
225
+ // H1.2 — door-contract conformance against the live `POST /account/token`
226
+ // success body (V1.4/C1.4 twin coverage, hub half).
227
+ expect(checkAccountTokenMintResponse(body)).toEqual([]);
204
228
 
205
229
  // TTL is roughly the 10-min window.
206
230
  const skew = new Date(body.expires_at).getTime() - Date.now();
@@ -556,6 +556,26 @@ describe("credential connection — renewal (proof of possession)", () => {
556
556
  expect(rec.provisioned.mintedJtis).toEqual([out.credential.jti!]);
557
557
  });
558
558
 
559
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
560
+ test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
561
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
562
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
563
+ const cred = await provision(deps, calls);
564
+
565
+ const res = await handleConnections(
566
+ new Request(`http://127.0.0.1/admin/connections/${cred.connection_id}/renew`, {
567
+ method: "POST",
568
+ headers: { authorization: `bearer ${cred.token}` },
569
+ }),
570
+ `/${cred.connection_id}/renew`,
571
+ deps,
572
+ );
573
+ expect(res.status).toBe(200);
574
+ const out = (await res.json()) as { ok: boolean; credential: DeliveredCredential };
575
+ expect(out.ok).toBe(true);
576
+ expect(out.credential.op).toBe("renewed");
577
+ });
578
+
559
579
  test("renewed credential can renew again (the chain extends)", async () => {
560
580
  const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
561
581
  const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
@@ -941,6 +961,27 @@ describe("credential connection — claim/reconcile (surface#113)", () => {
941
961
  ]);
942
962
  });
943
963
 
964
+ // H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
965
+ test("claim: mixed-case bearer scheme (BeArEr) authenticates identically to canonical Bearer", async () => {
966
+ const { fetchImpl } = mockFetch({});
967
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
968
+ const direct = await mintDirectDelivered({ vault: "default", verb: "read", tags: ["boulder"] });
969
+
970
+ const claim = await handleConnections(
971
+ new Request(`http://127.0.0.1/admin/connections/${CLAIM_ID}/claim`, {
972
+ method: "POST",
973
+ headers: { "content-type": "application/json", authorization: `BeArEr ${direct.token}` },
974
+ body: JSON.stringify(SURFACE_CLAIM),
975
+ }),
976
+ `/${CLAIM_ID}/claim`,
977
+ deps,
978
+ );
979
+ expect(claim.status).toBe(202);
980
+ const claimBody = (await claim.json()) as { ok: boolean; status: string };
981
+ expect(claimBody.ok).toBe(true);
982
+ expect(claimBody.status).toBe("pending");
983
+ });
984
+
944
985
  test("the live docs shape: an UNTAGGED write credential is claimable verbatim (create's write-requires-tags guards NEW grants; the operator's approve sanctions the existing shape)", async () => {
945
986
  const { fetchImpl } = mockFetch({});
946
987
  const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
@@ -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
  });