@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
@@ -149,6 +149,124 @@ describe("notesFetch with empty mount (root deployment)", () => {
149
149
  });
150
150
  });
151
151
 
152
+ describe("notesFetch /health (2026-07-11, hub-parity P5)", () => {
153
+ test("GET /notes/health answers 2xx explicitly, not the SPA shell", async () => {
154
+ const h = makeHarness();
155
+ try {
156
+ const res = notesFetch(h.dir, "/notes")(req("/notes/health"));
157
+ expect(res.status).toBe(200);
158
+ // Explicit JSON, not the index.html SPA-shell fallback — proves the
159
+ // health path is a real handler, not an accident of the catch-all.
160
+ expect(res.headers.get("content-type")).toBe("application/json");
161
+ expect(await res.text()).not.toContain("notes spa");
162
+ } finally {
163
+ h.cleanup();
164
+ }
165
+ });
166
+
167
+ test("GET /health answers 2xx at the mount root too (empty mount)", async () => {
168
+ const h = makeHarness();
169
+ try {
170
+ const res = notesFetch(h.dir, "")(req("/health"));
171
+ expect(res.status).toBe(200);
172
+ expect(res.headers.get("content-type")).toBe("application/json");
173
+ } finally {
174
+ h.cleanup();
175
+ }
176
+ });
177
+
178
+ test("survives a missing dist/index.html — health doesn't depend on the SPA shell existing", async () => {
179
+ // A harness with NO index.html written — the SPA-shell fallback would
180
+ // throw/404 on this dist, but /health is answered before that code path
181
+ // is ever reached.
182
+ const dir = mkdtempSync(join(tmpdir(), "pcli-notes-serve-empty-"));
183
+ try {
184
+ const res = notesFetch(dir, "/app")(req("/app/health"));
185
+ expect(res.status).toBe(200);
186
+ } finally {
187
+ rmSync(dir, { recursive: true, force: true });
188
+ }
189
+ });
190
+ });
191
+
192
+ // hub-parity P5 (2026-07-11): the shim generalized beyond notes to serve
193
+ // @openparachute/parachute-app (mount `/app`, port 1944) via the same
194
+ // FIRST_PARTY_FALLBACKS startCmd shape (`--package @openparachute/parachute-app`).
195
+ // These tests re-run the load-bearing PWA regression (sw.js / manifest
196
+ // content-type, SPA fallback, mount-strip) for a NON-notes package/mount to
197
+ // prove the generalization didn't accidentally hardcode "notes" anywhere in
198
+ // the serving path (only `resolveNotesDistFrom`'s package resolution is
199
+ // notes-specific, and that's parameterized separately below).
200
+ describe("notesFetch generalized for a non-notes package (hub-parity P5 — the app mount)", () => {
201
+ function makeAppHarness(): Harness {
202
+ const dir = mkdtempSync(join(tmpdir(), "pcli-app-serve-"));
203
+ writeFileSync(join(dir, "index.html"), "<html><body>app spa</body></html>");
204
+ writeFileSync(join(dir, "sw.js"), "self.addEventListener('install', () => {});");
205
+ writeFileSync(join(dir, "manifest.webmanifest"), '{"name":"Parachute","start_url":"/app/"}');
206
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
207
+ }
208
+
209
+ test("GET /app/sw.js serves the SW with JS content-type, not text/html", async () => {
210
+ const h = makeAppHarness();
211
+ try {
212
+ const res = notesFetch(h.dir, "/app")(req("/app/sw.js"));
213
+ expect(res.status).toBe(200);
214
+ const ct = res.headers.get("content-type") ?? "";
215
+ expect(ct).not.toContain("text/html");
216
+ expect(ct).toMatch(/javascript/);
217
+ } finally {
218
+ h.cleanup();
219
+ }
220
+ });
221
+
222
+ test("GET /app/manifest.webmanifest serves application/manifest+json", async () => {
223
+ const h = makeAppHarness();
224
+ try {
225
+ const res = notesFetch(h.dir, "/app")(req("/app/manifest.webmanifest"));
226
+ expect(res.status).toBe(200);
227
+ expect(res.headers.get("content-type")).toBe("application/manifest+json");
228
+ expect(await res.text()).toContain('"name":"Parachute"');
229
+ } finally {
230
+ h.cleanup();
231
+ }
232
+ });
233
+
234
+ test("GET /app/ serves the SPA shell", async () => {
235
+ const h = makeAppHarness();
236
+ try {
237
+ const res = notesFetch(h.dir, "/app")(req("/app/"));
238
+ expect(res.status).toBe(200);
239
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
240
+ expect(await res.text()).toContain("app spa");
241
+ } finally {
242
+ h.cleanup();
243
+ }
244
+ });
245
+
246
+ test("GET /app/some/deep/route falls back to the SPA shell (client-side routing)", async () => {
247
+ const h = makeAppHarness();
248
+ try {
249
+ const res = notesFetch(h.dir, "/app")(req("/app/some/deep/route"));
250
+ expect(res.status).toBe(200);
251
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
252
+ expect(await res.text()).toContain("app spa");
253
+ } finally {
254
+ h.cleanup();
255
+ }
256
+ });
257
+
258
+ test("GET /appendix/foo (mount-prefix collision) is not stripped", async () => {
259
+ const h = makeAppHarness();
260
+ try {
261
+ const res = notesFetch(h.dir, "/app")(req("/appendix/foo"));
262
+ expect(res.status).toBe(200);
263
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
264
+ } finally {
265
+ h.cleanup();
266
+ }
267
+ });
268
+ });
269
+
152
270
  describe("notesDistCandidates", () => {
153
271
  test("returns cwd, then global node_modules, then global root", () => {
154
272
  const cands = notesDistCandidates("/some/cwd", "/home/user");
@@ -295,3 +413,101 @@ describe("resolveNotesDistFrom (hub#194)", () => {
295
413
  ).toThrow(/has no dist\/ directory/);
296
414
  });
297
415
  });
416
+
417
+ /**
418
+ * `--package` generalization (hub-parity P5, 2026-07-11). `resolveNotesDistFrom`
419
+ * defaults `pkg` to `@openparachute/notes` (every test above omits it and
420
+ * still resolves notes — back-compat), but a caller like the `app`
421
+ * FIRST_PARTY_FALLBACKS entry passes a different package name. These tests
422
+ * pin the resolver against a real on-disk fixture for a NON-notes package,
423
+ * proving the specifier passed to `Bun.resolveSync` (and every error message)
424
+ * is the caller's `pkg`, not a hardcoded "notes" string.
425
+ */
426
+ describe("resolveNotesDistFrom --package (hub-parity P5)", () => {
427
+ const APP_PKG = "@openparachute/parachute-app";
428
+
429
+ function makeAppFixture(): { home: string; cleanup: () => void; dist: string } {
430
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "pcli-app-resolve-")));
431
+ const home = join(root, "home");
432
+ const pkgRoot = join(home, ".bun/install/global/node_modules", APP_PKG);
433
+ mkdirSync(pkgRoot, { recursive: true });
434
+ const dist = join(pkgRoot, "dist");
435
+ mkdirSync(dist, { recursive: true });
436
+ writeFileSync(join(pkgRoot, "package.json"), JSON.stringify({ name: APP_PKG }));
437
+ return { home, dist, cleanup: () => rmSync(root, { recursive: true, force: true }) };
438
+ }
439
+
440
+ test("resolves a non-notes package's dist/ via the global node_modules fallback", () => {
441
+ const f = makeAppFixture();
442
+ try {
443
+ const out = resolveNotesDistFrom({
444
+ cwd: "/hub-repo-cwd-without-app",
445
+ home: f.home,
446
+ pkg: APP_PKG,
447
+ resolveSync: (specifier, base) => {
448
+ if (base === "/hub-repo-cwd-without-app") {
449
+ throw new Error(`Cannot find module '${specifier}' from '${base}'`);
450
+ }
451
+ return Bun.resolveSync(specifier, base);
452
+ },
453
+ });
454
+ expect(out).toBe(f.dist);
455
+ } finally {
456
+ f.cleanup();
457
+ }
458
+ });
459
+
460
+ test("default pkg (no --package) still resolves @openparachute/notes — back-compat", () => {
461
+ // Every earlier describe block already exercises this implicitly (none
462
+ // pass `pkg`); this test pins it explicitly against the specifier the
463
+ // resolver hands to `resolveSync`.
464
+ const specifiers: string[] = [];
465
+ expect(() =>
466
+ resolveNotesDistFrom({
467
+ cwd: "/cwd",
468
+ home: "/h",
469
+ resolveSync: (specifier) => {
470
+ specifiers.push(specifier);
471
+ throw new Error("not found");
472
+ },
473
+ }),
474
+ ).toThrow();
475
+ expect(specifiers).toEqual([
476
+ "@openparachute/notes/package.json",
477
+ "@openparachute/notes/package.json",
478
+ "@openparachute/notes/package.json",
479
+ ]);
480
+ });
481
+
482
+ test("error message names the caller's package, not a hardcoded 'notes'", () => {
483
+ let caught: unknown;
484
+ try {
485
+ resolveNotesDistFrom({
486
+ cwd: "/probe-cwd",
487
+ home: "/probe-home",
488
+ pkg: APP_PKG,
489
+ resolveSync: () => {
490
+ throw new Error("nope");
491
+ },
492
+ });
493
+ } catch (err) {
494
+ caught = err;
495
+ }
496
+ const msg = (caught as Error).message;
497
+ expect(msg).toContain(`Could not resolve ${APP_PKG}`);
498
+ expect(msg).toContain(`bun add -g ${APP_PKG}`);
499
+ expect(msg).not.toContain("@openparachute/notes");
500
+ });
501
+
502
+ test("no-dist/ hard error names the caller's package", () => {
503
+ expect(() =>
504
+ resolveNotesDistFrom({
505
+ cwd: "/cwd-with-app",
506
+ home: "/h",
507
+ pkg: APP_PKG,
508
+ resolveSync: () => "/cwd-with-app/node_modules/@openparachute/parachute-app/package.json",
509
+ existsSync: () => false,
510
+ }),
511
+ ).toThrow(new RegExp(`${APP_PKG.replace("/", "\\/")} resolved at .* has no dist/ directory`));
512
+ });
513
+ });
@@ -3,6 +3,11 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import {
7
+ checkAuthorizationServerMetadata,
8
+ checkProtectedResourceMetadata,
9
+ checkTokenResponseInvariants,
10
+ } from "@openparachute/door-contract";
6
11
  import { handleAdminLoginPost, handleAdminLoginTotpPost } from "../admin-handlers.ts";
7
12
  import { approveClient, getClient, registerClient } from "../clients.ts";
8
13
  import { CSRF_COOKIE_NAME } from "../csrf.ts";
@@ -122,9 +127,12 @@ describe("authorizationServerMetadata", () => {
122
127
  // hub:admin + scribe:admin are operator-only (non-requestable) — never advertised (2026-06-30)
123
128
  expect(scopesSupported).not.toContain("hub:admin");
124
129
  expect(scopesSupported).not.toContain("scribe:admin");
125
- // agent isn't in the fixture manifest its scopes aren't advertised
126
- // (hub#…: optional-module scopes only surface when the module is installed).
130
+ // Retired Agent scopes are no longer part of the first-party catalog.
127
131
  expect(scopesSupported).not.toContain("agent:send");
132
+ // H1.2 — door-contract conformance: the static RFC 8414 fields (endpoints,
133
+ // response/grant types, PKCE method, token-auth methods) match the shared
134
+ // contract exactly (V1.4/C1.4 twin coverage, hub half).
135
+ expect(checkAuthorizationServerMetadata(body, ISSUER, scopesSupported)).toEqual([]);
128
136
  });
129
137
 
130
138
  test("does NOT advertise non-requestable operator-only scopes", async () => {
@@ -164,7 +172,7 @@ describe("authorizationServerMetadata", () => {
164
172
  const body = (await res.json()) as Record<string, unknown>;
165
173
  const scopesSupported = body.scopes_supported as string[];
166
174
  // Third-party scopes show up (`widget:*` / `mymodule:*` aren't gated
167
- // optional-module prefixes — only scribe:/agent: are, see OPTIONAL_MODULE_SCOPES).
175
+ // optional-module prefixes — only scribe:/surface: are, see OPTIONAL_MODULE_SCOPES).
168
176
  expect(scopesSupported).toContain("widget:read");
169
177
  expect(scopesSupported).toContain("widget:write");
170
178
  expect(scopesSupported).toContain("mymodule:do-thing");
@@ -178,10 +186,10 @@ describe("authorizationServerMetadata", () => {
178
186
  });
179
187
 
180
188
  test("advertises an optional module's scopes only when it's installed", async () => {
181
- // FIRST_PARTY_SCOPES carries scribe:* + agent:send statically. On a
189
+ // FIRST_PARTY_SCOPES carries scribe:* statically. On a
182
190
  // vault-only hub they must NOT be advertised — a discovery client (e.g.
183
191
  // claude.ai's connector UI) lists the catalog verbatim, so a friend
184
- // connecting one vault was shown Scribe + Agent access the hub can't
192
+ // connecting one vault was shown Scribe access the hub can't
185
193
  // honor. Vault + hub are core and always advertised.
186
194
  const declared = new Set<string>([
187
195
  "vault:read",
@@ -189,7 +197,6 @@ describe("authorizationServerMetadata", () => {
189
197
  "vault:admin",
190
198
  "scribe:transcribe",
191
199
  "scribe:admin",
192
- "agent:send",
193
200
  "hub:admin",
194
201
  ]);
195
202
  const vaultOnly = {
@@ -217,7 +224,6 @@ describe("authorizationServerMetadata", () => {
217
224
  // uninstalled optional-module scopes are dropped
218
225
  expect(scopes).not.toContain("scribe:transcribe");
219
226
  expect(scopes).not.toContain("scribe:admin");
220
- expect(scopes).not.toContain("agent:send");
221
227
 
222
228
  // ...but once scribe is installed, its scopes ARE advertised again.
223
229
  const withScribe = {
@@ -249,7 +255,6 @@ describe("authorizationServerMetadata", () => {
249
255
  // requestability gate (non-requestable, 2026-06-30) doing the work here, not
250
256
  // the optional-module-not-installed gate that drops scribe:transcribe above.
251
257
  expect(scopes2).not.toContain("scribe:admin");
252
- expect(scopes2).not.toContain("agent:send"); // agent still not installed
253
258
  });
254
259
  });
255
260
 
@@ -265,6 +270,8 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
265
270
  expect(body.resource).toBe(ISSUER);
266
271
  expect(body.authorization_servers).toEqual([ISSUER]);
267
272
  expect(body.bearer_methods_supported).toEqual(["header"]);
273
+ // H1.2 — door-contract conformance (V1.4/C1.4 twin coverage, hub half).
274
+ expect(checkProtectedResourceMetadata(body, ISSUER)).toEqual([]);
268
275
  expect(Array.isArray(body.scopes_supported)).toBe(true);
269
276
  expect(body.resource_documentation).toMatch(/parachute\.computer/);
270
277
  });
@@ -2320,6 +2327,10 @@ describe("handleToken — full OAuth dance", () => {
2320
2327
  expect(tokenBody.token_type).toBe("Bearer");
2321
2328
  expect(tokenBody.scope).toBe("vault:default:read");
2322
2329
  expect(tokenBody.refresh_token.length).toBeGreaterThan(20);
2330
+ // H1.2 — door-contract conformance: token_type/expires_in/scope/access_token
2331
+ // invariants against a REAL `POST /oauth/token` success body (V1.4/C1.4 twin
2332
+ // coverage, hub half).
2333
+ expect(checkTokenResponseInvariants(tokenBody, "vault:default:read")).toEqual([]);
2323
2334
 
2324
2335
  // JWT must verify against the hub's signing keys, with the right sub +
2325
2336
  // aud (named `vault:default:read` → "vault.default" — RFC 8707-style
@@ -66,7 +66,7 @@ describe("mintOperatorToken", () => {
66
66
  }
67
67
  });
68
68
 
69
- test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe/agent admins (#213)", () => {
69
+ test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe admin scopes (#213)", () => {
70
70
  // OPERATOR_TOKEN_SCOPES === OPERATOR_TOKEN_SCOPE_SETS.admin (back-compat
71
71
  // alias). The pre-#213 set was 5 scopes; #213 added the fine-grained
72
72
  // parachute:host:install/start/expose/auth/vault scopes to the admin
@@ -81,7 +81,6 @@ describe("mintOperatorToken", () => {
81
81
  "parachute:host:vault",
82
82
  "vault:admin",
83
83
  "scribe:admin",
84
- "agent:send",
85
84
  ]);
86
85
  });
87
86
  });
@@ -3,7 +3,26 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { assignPort, assignServicePort } from "../port-assign.ts";
6
- import { CANONICAL_PORT_MAX, CANONICAL_PORT_MIN } from "../service-spec.ts";
6
+ import { CANONICAL_PORT_MAX, CANONICAL_PORT_MIN, PORT_RESERVATIONS } from "../service-spec.ts";
7
+
8
+ describe("PORT_RESERVATIONS (registry shape)", () => {
9
+ test("every port in the canonical range appears exactly once", () => {
10
+ const ports = PORT_RESERVATIONS.map((r) => r.port);
11
+ expect(new Set(ports).size).toBe(ports.length);
12
+ expect(ports.slice().sort((a, b) => a - b)).toEqual(ports);
13
+ for (const p of ports) {
14
+ expect(p).toBeGreaterThanOrEqual(CANONICAL_PORT_MIN);
15
+ expect(p).toBeLessThanOrEqual(CANONICAL_PORT_MAX);
16
+ }
17
+ });
18
+
19
+ test("1944 (parachute-app, hub-parity P5) doesn't collide with any other reservation", () => {
20
+ const owners = PORT_RESERVATIONS.filter((r) => r.port === 1944);
21
+ expect(owners).toHaveLength(1);
22
+ expect(owners[0]?.name).toBe("parachute-app");
23
+ expect(owners[0]?.status).toBe("assigned");
24
+ });
25
+ });
7
26
 
8
27
  function makeTempDir(): { dir: string; cleanup: () => void } {
9
28
  const dir = mkdtempSync(join(tmpdir(), "pcli-port-assign-"));
@@ -25,17 +44,18 @@ describe("assignPort (pure)", () => {
25
44
  });
26
45
 
27
46
  test("walks the unassigned reservation range when canonical is occupied", () => {
28
- // 1940 is taken; canonical reserved range starts at 1944 first hit.
47
+ // 1940 is taken. Agent retirement released 1941 as the first walkable slot.
29
48
  const result = assignPort(1940, [1940]);
30
- expect(result.port).toBe(1944);
49
+ expect(result.port).toBe(1941);
31
50
  expect(result.source).toBe("fallback-in-range");
32
51
  expect(result.warning).toMatch(/canonical port 1940 is in use/);
33
- expect(result.warning).toMatch(/1944/);
52
+ expect(result.warning).toMatch(/1941/);
34
53
  });
35
54
 
36
55
  test("skips reservations that are also occupied", () => {
37
- // Canonical 1940 + the first three reserved slots are all in use.
38
- const result = assignPort(1940, [1940, 1944, 1945, 1946]);
56
+ // Canonical 1940 and released slot 1941 are occupied. Assigned slots are
57
+ // skipped; 1945 is reserved-but-occupied; 1947 is reserved-and-free.
58
+ const result = assignPort(1940, [1940, 1941, 1945]);
39
59
  expect(result.port).toBe(1947);
40
60
  expect(result.source).toBe("fallback-in-range");
41
61
  });
@@ -60,21 +80,19 @@ describe("assignPort (pure)", () => {
60
80
  });
61
81
 
62
82
  test("third-party (no canonical slot) jumps straight to the reservation range", () => {
83
+ // Agent retirement released 1941 as the first walkable slot.
63
84
  const result = assignPort(undefined, []);
64
- expect(result.port).toBe(1944);
85
+ expect(result.port).toBe(1941);
65
86
  expect(result.source).toBe("fallback-in-range");
66
87
  expect(result.warning).toMatch(/no canonical slot/);
67
- expect(result.warning).toMatch(/1944/);
88
+ expect(result.warning).toMatch(/1941/);
68
89
  });
69
90
 
70
91
  test("third-party with reservations occupied walks further in the range", () => {
71
- // PORT_RESERVATIONS post-hub#323: 1944 reserved, 1945 reserved, 1946
72
- // assigned (parachute-app KNOWN_MODULES carries the canonical slot
73
- // so the fallback walker doesn't hand it to a third party), 1947
74
- // reserved. With 1944 + 1945 occupied, the walker skips the assigned
75
- // 1946 slot and lands on the next reserved-and-free port — 1947.
76
- const result = assignPort(undefined, [1944, 1945]);
77
- expect(result.port).toBe(1947);
92
+ // With released 1941 occupied, the walker skips assigned 1942–1944 and
93
+ // lands on the next reserved-and-free port, 1945.
94
+ const result = assignPort(undefined, [1941]);
95
+ expect(result.port).toBe(1945);
78
96
  expect(result.source).toBe("fallback-in-range");
79
97
  });
80
98
  });
@@ -145,7 +163,8 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
145
163
  canonical: 1940,
146
164
  occupied: [1940],
147
165
  });
148
- expect(result.port).toBe(1944);
166
+ // Agent retirement released 1941 as the first walkable slot.
167
+ expect(result.port).toBe(1941);
149
168
  expect(result.source).toBe("fallback-in-range");
150
169
  expect(result.warning).toMatch(/canonical port 1940 is in use/);
151
170
  // .env stays bit-for-bit identical.
@@ -162,7 +181,8 @@ describe("assignServicePort (hub#206 — services.json is authoritative)", () =>
162
181
  const result = assignServicePort({
163
182
  occupied: [],
164
183
  });
165
- expect(result.port).toBe(1944);
184
+ // Agent retirement released 1941 as the first walkable slot.
185
+ expect(result.port).toBe(1941);
166
186
  expect(result.source).toBe("fallback-in-range");
167
187
  expect(existsSync(envPath)).toBe(false);
168
188
  } finally {
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tests for `src/root-serve.ts` — serving the Parachute app AT the origin root
3
+ * (`root_mode = serve-app`).
4
+ *
5
+ * Covers `serveAppAtRoot` (the static file-or-SPA-shell decision, Accept-gated,
6
+ * reserved-prefix-guarded, traversal-guarded) and `makeAppDistResolver` (success
7
+ * memoization + failure NON-caching for dynamic recovery).
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { makeAppDistResolver, serveAppAtRoot } from "../root-serve.ts";
14
+
15
+ let dist: string;
16
+ let root: string;
17
+
18
+ beforeEach(() => {
19
+ root = mkdtempSync(join(tmpdir(), "phub-root-serve-"));
20
+ dist = join(root, "dist");
21
+ mkdirSync(join(dist, "assets"), { recursive: true });
22
+ writeFileSync(join(dist, "index.html"), "<!doctype html><title>App</title>");
23
+ writeFileSync(join(dist, "assets", "index-abc.js"), "console.log('app')");
24
+ writeFileSync(join(dist, "manifest.webmanifest"), '{"name":"Parachute"}');
25
+ writeFileSync(join(dist, "sw.js"), "self.addEventListener('install',()=>{})");
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(root, { recursive: true, force: true });
30
+ });
31
+
32
+ function get(pathname: string, accept?: string): Request {
33
+ return new Request(`http://localhost${pathname}`, {
34
+ method: "GET",
35
+ headers: accept ? { accept } : {},
36
+ });
37
+ }
38
+
39
+ describe("serveAppAtRoot", () => {
40
+ test("serves index.html at `/`", async () => {
41
+ const res = serveAppAtRoot(dist, get("/", "text/html"), "/");
42
+ expect(res).not.toBeNull();
43
+ expect(res?.headers.get("content-type")).toContain("text/html");
44
+ expect(await res?.text()).toContain("<title>App</title>");
45
+ });
46
+
47
+ test("serves an existing asset with an inferred content-type", async () => {
48
+ const res = serveAppAtRoot(dist, get("/assets/index-abc.js", "*/*"), "/assets/index-abc.js");
49
+ expect(res).not.toBeNull();
50
+ expect(res?.headers.get("content-type") ?? "").toMatch(/javascript/);
51
+ expect(await res?.text()).toContain("console.log");
52
+ });
53
+
54
+ test("serves the PWA manifest with the application/manifest+json override", () => {
55
+ const res = serveAppAtRoot(dist, get("/manifest.webmanifest", "*/*"), "/manifest.webmanifest");
56
+ expect(res).not.toBeNull();
57
+ expect(res?.headers.get("content-type")).toBe("application/manifest+json");
58
+ });
59
+
60
+ test("serves the service worker as a real file (not the SPA shell)", async () => {
61
+ const res = serveAppAtRoot(dist, get("/sw.js", "*/*"), "/sw.js");
62
+ expect(res).not.toBeNull();
63
+ expect(await res?.text()).toContain("addEventListener");
64
+ });
65
+
66
+ test("SPA fallback: an unclaimed HTML deep link gets index.html", async () => {
67
+ const res = serveAppAtRoot(dist, get("/some/app/route", "text/html"), "/some/app/route");
68
+ expect(res).not.toBeNull();
69
+ expect(await res?.text()).toContain("<title>App</title>");
70
+ });
71
+
72
+ test("non-HTML unclaimed request → null (branded 404 tail)", () => {
73
+ expect(serveAppAtRoot(dist, get("/nope.json", "application/json"), "/nope.json")).toBeNull();
74
+ // A missing asset fetched with Accept: */* is not an HTML navigation → null.
75
+ expect(serveAppAtRoot(dist, get("/assets/missing.js", "*/*"), "/assets/missing.js")).toBeNull();
76
+ });
77
+
78
+ test("non-GET → null (a non-GET unclaimed path keeps its default)", () => {
79
+ const post = new Request("http://localhost/", {
80
+ method: "POST",
81
+ headers: { accept: "text/html" },
82
+ });
83
+ expect(serveAppAtRoot(dist, post, "/")).toBeNull();
84
+ });
85
+
86
+ test("reserved hub/protocol prefixes keep the branded 404 even for HTML nav", () => {
87
+ for (const p of ["/api/bogus", "/oauth/typo", "/.well-known/nope"]) {
88
+ expect(serveAppAtRoot(dist, get(p, "text/html"), p)).toBeNull();
89
+ }
90
+ });
91
+
92
+ test("path traversal cannot escape dist", () => {
93
+ // A crafted encoded traversal joins outside dist → falls through (null).
94
+ const p = "/assets/..%2f..%2f..%2fetc%2fpasswd";
95
+ expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
96
+ });
97
+
98
+ test("a resolved dist whose index.html vanished → null (no broken shell)", () => {
99
+ rmSync(join(dist, "index.html"));
100
+ expect(serveAppAtRoot(dist, get("/", "text/html"), "/")).toBeNull();
101
+ });
102
+
103
+ test("malformed percent-encoding → null (no URIError thrown → branded 404, not 500)", () => {
104
+ // decodeURIComponent throws URIError on a bad escape; the guard must swallow
105
+ // it and fall through so the dispatch never 500s on a garbage asset URL.
106
+ for (const p of ["/assets/%ZZ", "/foo%", "/%E0%A4%A", "/bar%2"]) {
107
+ expect(() => serveAppAtRoot(dist, get(p, "*/*"), p)).not.toThrow();
108
+ expect(serveAppAtRoot(dist, get(p, "*/*"), p)).toBeNull();
109
+ }
110
+ // Even an HTML navigation with bad encoding falls through (redirect-mode
111
+ // parity) rather than shelling.
112
+ expect(serveAppAtRoot(dist, get("/foo%", "text/html"), "/foo%")).toBeNull();
113
+ });
114
+ });
115
+
116
+ describe("makeAppDistResolver", () => {
117
+ test("returns the resolved dist and memoizes a success", () => {
118
+ let calls = 0;
119
+ const resolve = makeAppDistResolver(() => {
120
+ calls++;
121
+ return dist;
122
+ });
123
+ expect(resolve()).toBe(dist);
124
+ expect(resolve()).toBe(dist);
125
+ expect(calls).toBe(1); // success cached — only resolved once
126
+ });
127
+
128
+ test("does NOT cache a failure — recovers once the app is installed", () => {
129
+ let installed = false;
130
+ const resolve = makeAppDistResolver(() => {
131
+ if (!installed) throw new Error("not installed");
132
+ return dist;
133
+ });
134
+ expect(resolve()).toBeNull(); // not installed yet
135
+ expect(resolve()).toBeNull(); // still probing, still null
136
+ installed = true;
137
+ expect(resolve()).toBe(dist); // picked up without a restart
138
+ });
139
+ });
@@ -19,7 +19,6 @@ describe("SCOPE_EXPLANATIONS", () => {
19
19
  "vault:admin",
20
20
  "scribe:transcribe",
21
21
  "scribe:admin",
22
- "agent:send",
23
22
  "hub:admin",
24
23
  "parachute:host:admin",
25
24
  // Account scopes (Parachute App campaign, Phase 2).
@@ -187,7 +186,6 @@ describe("isRequestableScope", () => {
187
186
  test("true for non-admin first-party scopes", () => {
188
187
  expect(isRequestableScope("vault:read")).toBe(true);
189
188
  expect(isRequestableScope("vault:admin")).toBe(true);
190
- expect(isRequestableScope("agent:send")).toBe(true);
191
189
  expect(isRequestableScope("scribe:transcribe")).toBe(true);
192
190
  });
193
191