@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
@@ -0,0 +1,110 @@
1
+ /**
2
+ * H1.1 (contracts-brief H1) — Bearer scheme-casing unification.
3
+ *
4
+ * RFC 7235 §2.1: the auth-scheme token ("Bearer") is case-insensitive; the
5
+ * credential (the token itself) is opaque and must be passed VERBATIM — never
6
+ * case-folded, never trimmed beyond surrounding whitespace. This mirrors
7
+ * parachute-vault's V1.4 (`BEARER_PREFIX`, case-insensitive) and
8
+ * parachute-cloud's C1.3 (`workers/vault/src/auth.ts:130-143`) — the other two
9
+ * door surfaces already converged on this shape; this file (plus the per-site
10
+ * tests below) closes the hub's half.
11
+ *
12
+ * Every converted call site (`src/api-hub-upgrade.ts`, `src/api-tokens.ts`,
13
+ * `src/api-revoke-token.ts`, `src/api-settings-root-redirect.ts`,
14
+ * `src/api-modules.ts` ×2, `src/api-modules-ops.ts`, `src/api-mint-token.ts`,
15
+ * `src/api-settings-hub-origin.ts`, `src/admin-surfaces.ts`,
16
+ * `src/admin-connections.ts` ×2, `src/audience-gate.ts`) uses the SAME
17
+ * two-step shape: a case-insensitive `/^Bearer\s+/i` scheme test, followed by
18
+ * the PRE-EXISTING `header.slice("Bearer ".length).trim()` extraction
19
+ * (unchanged — "Bearer ".length === "bearer ".length === 7, so the fixed-
20
+ * offset slice is casing-agnostic and the token substring is untouched).
21
+ * `src/admin-auth.ts`'s `extractBearerToken` (the documented shared helper,
22
+ * used by `requireScope` + several `/admin/*` and `/api/*` routes already)
23
+ * uses an equivalent `/^Bearer\s+(.+)$/i` regex. This file pins that shared
24
+ * pattern directly, independent of any one handler's DB/deps scaffolding.
25
+ *
26
+ * Live end-to-end coverage (a lowercase/mixed-case Authorization header
27
+ * reaching a real handler and authenticating) lives alongside each site's
28
+ * existing tests, NOT here:
29
+ * - src/__tests__/api-hub-upgrade.test.ts
30
+ * - src/__tests__/api-tokens.test.ts
31
+ * - src/__tests__/api-revoke-token.test.ts
32
+ * - src/__tests__/api-settings-root-redirect.test.ts
33
+ * - src/__tests__/api-modules.test.ts (both handleApiModules + handleApiModulesChannel)
34
+ * - src/__tests__/api-modules-ops.test.ts
35
+ * - src/__tests__/api-mint-token.test.ts
36
+ * - src/__tests__/api-settings-hub-origin.test.ts
37
+ * - src/__tests__/admin-surfaces.test.ts
38
+ * - src/__tests__/admin-connections-credentials.test.ts (renew + claim)
39
+ * - src/__tests__/audience-gate.test.ts
40
+ */
41
+ import { describe, expect, test } from "bun:test";
42
+ import { extractBearerToken } from "../admin-auth.ts";
43
+
44
+ /** The exact regex + slice shape used at all 12 converted call sites. */
45
+ function siteShapeExtract(header: string | null): string | null {
46
+ if (!header || !/^Bearer\s+/i.test(header)) return null;
47
+ return header.slice("Bearer ".length).trim();
48
+ }
49
+
50
+ // A JWT-shaped token: base64url alphabet includes uppercase letters, so any
51
+ // real access/operator token already exercises "verbatim, case preserved."
52
+ // Use one explicitly so the pin doesn't depend on a real signer.
53
+ const MIXED_CASE_TOKEN = "eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJBQkNkZWYxMjMifQ.SIGNATURE-Xyz";
54
+
55
+ describe("H1.1 — site-shape extraction (regex test + fixed-offset slice)", () => {
56
+ test.each([
57
+ ["Bearer", `Bearer ${MIXED_CASE_TOKEN}`],
58
+ ["bearer", `bearer ${MIXED_CASE_TOKEN}`],
59
+ ["BEARER", `BEARER ${MIXED_CASE_TOKEN}`],
60
+ ["BeArEr", `BeArEr ${MIXED_CASE_TOKEN}`],
61
+ ["bEARER", `bEARER ${MIXED_CASE_TOKEN}`],
62
+ ])("scheme %s → token extracted verbatim, case preserved", (_label, header) => {
63
+ expect(siteShapeExtract(header)).toBe(MIXED_CASE_TOKEN);
64
+ });
65
+
66
+ test("wrong scheme keyword (not Bearer) → rejected regardless of case", () => {
67
+ expect(siteShapeExtract(`Basic ${MIXED_CASE_TOKEN}`)).toBeNull();
68
+ expect(siteShapeExtract(`Digest ${MIXED_CASE_TOKEN}`)).toBeNull();
69
+ });
70
+
71
+ test("missing header → rejected", () => {
72
+ expect(siteShapeExtract(null)).toBeNull();
73
+ });
74
+
75
+ test("scheme with no token → empty string (site's own 'empty bearer token' check catches this)", () => {
76
+ expect(siteShapeExtract("Bearer ")).toBe("");
77
+ expect(siteShapeExtract("bearer ")).toBe("");
78
+ });
79
+
80
+ test("extra internal whitespace after the scheme is tolerated (trim absorbs it), casing still ignored", () => {
81
+ expect(siteShapeExtract(`bearer ${MIXED_CASE_TOKEN}`)).toBe(MIXED_CASE_TOKEN);
82
+ expect(siteShapeExtract(`Bearer\t${MIXED_CASE_TOKEN}`)).toBe(MIXED_CASE_TOKEN);
83
+ });
84
+ });
85
+
86
+ describe("H1.1 — admin-auth.ts extractBearerToken (the documented shared helper)", () => {
87
+ function reqWith(header: string | undefined): Request {
88
+ return new Request("http://127.0.0.1/admin/probe", {
89
+ headers: header !== undefined ? { authorization: header } : {},
90
+ });
91
+ }
92
+
93
+ test.each([
94
+ ["Bearer", `Bearer ${MIXED_CASE_TOKEN}`],
95
+ ["bearer", `bearer ${MIXED_CASE_TOKEN}`],
96
+ ["BeArEr", `BeArEr ${MIXED_CASE_TOKEN}`],
97
+ ])("scheme %s → extractBearerToken returns the token verbatim", (_label, header) => {
98
+ expect(extractBearerToken(reqWith(header))).toBe(MIXED_CASE_TOKEN);
99
+ });
100
+
101
+ test("missing Authorization header → AdminAuthError(401)", () => {
102
+ expect(() => extractBearerToken(reqWith(undefined))).toThrow(/missing Authorization header/);
103
+ });
104
+
105
+ test("non-Bearer scheme → AdminAuthError(401)", () => {
106
+ expect(() => extractBearerToken(reqWith(`Basic ${MIXED_CASE_TOKEN}`))).toThrow(
107
+ /Authorization header must be 'Bearer <token>'/,
108
+ );
109
+ });
110
+ });
@@ -99,6 +99,20 @@ describe("shouldInjectChrome", () => {
99
99
  expect(shouldInjectChrome("/surface/notes-archive/")).toBe(true);
100
100
  });
101
101
 
102
+ test("default: opts out the app's surface mount at /surface/parachute/* (W2-12)", () => {
103
+ expect(shouldInjectChrome("/surface/parachute")).toBe(false);
104
+ expect(shouldInjectChrome("/surface/parachute/")).toBe(false);
105
+ expect(shouldInjectChrome("/surface/parachute/index.html")).toBe(false);
106
+ expect(shouldInjectChrome("/surface/parachute/assets/index-XXX.js")).toBe(false);
107
+ });
108
+
109
+ test("the /surface/parachute/ opt-out does not over-match sibling paths", () => {
110
+ // `/surface/parachutes` must NOT match `/surface/parachute/` — the slash
111
+ // boundary check applies to the new entry the same as the legacy one.
112
+ expect(shouldInjectChrome("/surface/parachutes")).toBe(true);
113
+ expect(shouldInjectChrome("/surface/parachute-club/")).toBe(true);
114
+ });
115
+
102
116
  test("custom opt-out list is honored", () => {
103
117
  expect(shouldInjectChrome("/foo/bar", ["/foo/"])).toBe(false);
104
118
  expect(shouldInjectChrome("/baz", ["/foo/"])).toBe(true);
@@ -111,8 +125,11 @@ describe("shouldInjectChrome", () => {
111
125
  expect(shouldInjectChrome("/foo/bar", ["/foo"])).toBe(false);
112
126
  });
113
127
 
114
- test("the canonical opt-out list contains /surface/notes/", () => {
128
+ test("the canonical opt-out list contains BOTH /surface/notes/ and /surface/parachute/", () => {
129
+ // W2-12 is additive: the legacy notes-ui mount keeps its opt-out for
130
+ // existing installs; the renamed app-surface mount gets its own.
115
131
  expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/notes/");
132
+ expect(CHROME_OPT_OUT_PREFIXES).toContain("/surface/parachute/");
116
133
  });
117
134
  });
118
135
 
@@ -502,7 +502,7 @@ describe("doctor — canonical-port-drift detection (read-only)", () => {
502
502
  test("a non-canonical port + a duplicate-port pair → port-drift WARNs naming the services", async () => {
503
503
  const h = makeHarness();
504
504
  try {
505
- // scribe drifted off 1943 onto 1944; agent also squats 1944 (a collision).
505
+ // Scribe drifted off 1943 onto Surface's canonical 1946 (a collision).
506
506
  writeManifestRows(h.manifestPath, [
507
507
  {
508
508
  name: "parachute-vault",
@@ -511,21 +511,17 @@ describe("doctor — canonical-port-drift detection (read-only)", () => {
511
511
  health: "/h",
512
512
  version: "1",
513
513
  },
514
- { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
515
- { name: "parachute-agent", port: 1944, paths: ["/agent"], health: "/h", version: "1" },
514
+ { name: "parachute-scribe", port: 1946, paths: ["/scribe"], health: "/h", version: "1" },
515
+ { name: "parachute-surface", port: 1946, paths: ["/surface"], health: "/h", version: "1" },
516
516
  ]);
517
517
  seedOperatorToken(h.configDir);
518
518
  const { code, checks } = await runDoctor(h, healthyDeps());
519
519
  const pd = byName(checks, "port-drift");
520
520
  expect(pd?.status).toBe("warn");
521
- // Names the drifted service AND the colliding pair.
522
521
  expect(pd?.detail).toContain("scribe");
523
- expect(pd?.detail).toContain("1944");
524
- expect(pd?.detail).toContain("parachute-scribe + parachute-agent");
522
+ expect(pd?.detail).toContain("1946");
523
+ expect(pd?.detail).toContain("parachute-scribe + parachute-surface");
525
524
  expect(pd?.fix).toBe("parachute doctor --fix");
526
- // Drift is advisory — exit stays 0 (a WARN, not a FAIL). The duplicate
527
- // rows also trip modules-alive (both can't bind 1944) but that's expected
528
- // for this fixture; we only assert on port-drift here.
529
525
  expect([0, 1]).toContain(code);
530
526
  } finally {
531
527
  h.cleanup();
@@ -732,22 +728,19 @@ describe("doctor --fix — canonical-port repair (confirm-gated, idempotent, non
732
728
  test("--fix reports a duplicate-port collision but does not auto-resolve it", async () => {
733
729
  const h = makeHarness();
734
730
  try {
735
- // Two services collide on 1944; neither is on its canonical slot. The
736
- // diff fixes the canonical drift; the collision is reported, not guessed.
731
+ // Two supported services collide on unassigned 1945; both are off canonical.
737
732
  writeManifestRows(h.manifestPath, [
738
- { name: "parachute-scribe", port: 1944, paths: ["/scribe"], health: "/h", version: "1" },
739
- { name: "parachute-agent", port: 1944, paths: ["/agent"], health: "/h", version: "1" },
733
+ { name: "parachute-scribe", port: 1945, paths: ["/scribe"], health: "/h", version: "1" },
734
+ { name: "parachute-surface", port: 1945, paths: ["/surface"], health: "/h", version: "1" },
740
735
  ]);
741
736
  const { code, lines } = await runFix(h, {}, { yes: true });
742
737
  const text = lines.join("\n");
743
738
  expect(text.toLowerCase()).toContain("shared by");
744
- expect(text).toContain("parachute-scribe + parachute-agent");
745
- // scribe → 1943 and agent → 1941 are both off 1944, so after the rewrite
746
- // they no longer collide; fix applied, exit 0.
739
+ expect(text).toContain("parachute-scribe + parachute-surface");
747
740
  expect(code).toBe(0);
748
741
  const rows = readRows(h.manifestPath);
749
742
  expect(rows.find((r) => r.name === "parachute-scribe")?.port).toBe(1943);
750
- expect(rows.find((r) => r.name === "parachute-agent")?.port).toBe(1941);
743
+ expect(rows.find((r) => r.name === "parachute-surface")?.port).toBe(1946);
751
744
  } finally {
752
745
  h.cleanup();
753
746
  }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ACCESS_TOKEN_TTL_SECONDS as CONTRACT_ACCESS_TTL,
4
+ ACCOUNT_SELF_ADMIN_SCOPE as CONTRACT_ACCOUNT_ADMIN,
5
+ ACCOUNT_SELF_READ_SCOPE as CONTRACT_ACCOUNT_READ,
6
+ REFRESH_GRACE_MS as CONTRACT_REFRESH_GRACE,
7
+ REFRESH_TOKEN_TTL_MS as CONTRACT_REFRESH_TTL,
8
+ hasAccountScope,
9
+ } from "@openparachute/door-contract";
10
+ import { ACCESS_TOKEN_TTL_SECONDS, REFRESH_GRACE_MS, REFRESH_TOKEN_TTL_MS } from "../jwt-sign.ts";
11
+ import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "../scope-explanations.ts";
12
+
13
+ /**
14
+ * Drift detector for the shared door contract (Cloud+Hub shared-core campaign,
15
+ * parachute-cloud#116, Phase A). The hub's issuer constants + account-scope
16
+ * strings are duplicated in `@openparachute/door-contract` (the hosted cloud
17
+ * door duplicates them too). These assertions bind the hub's LIVE runtime values
18
+ * to the shared canon — no runtime code path is changed by this test, but any
19
+ * future divergence between the hub's issuer and the shared contract fails here,
20
+ * forcing the change through the shared package (and thus the cloud twin).
21
+ *
22
+ * When the hub adopts the contract at runtime (Phase B — `jwt-sign.ts` imports
23
+ * these constants instead of re-declaring them), these become identity checks;
24
+ * until then they are the guardrail.
25
+ */
26
+ describe("door-contract parity — token constants", () => {
27
+ test("the hub's issuer TTL/grace equal the shared contract", () => {
28
+ expect(ACCESS_TOKEN_TTL_SECONDS).toBe(CONTRACT_ACCESS_TTL);
29
+ expect(REFRESH_TOKEN_TTL_MS).toBe(CONTRACT_REFRESH_TTL);
30
+ expect(REFRESH_GRACE_MS).toBe(CONTRACT_REFRESH_GRACE);
31
+ });
32
+ });
33
+
34
+ describe("door-contract parity — account scopes", () => {
35
+ test("the hub's account scope strings equal the shared contract", () => {
36
+ expect(ACCOUNT_SELF_ADMIN_SCOPE).toBe(CONTRACT_ACCOUNT_ADMIN);
37
+ expect(ACCOUNT_SELF_READ_SCOPE).toBe(CONTRACT_ACCOUNT_READ);
38
+ });
39
+
40
+ test("the shared checker agrees with the hub's admin⊇read intent", () => {
41
+ // The hub's `/account/*` gate treats `account:self:admin` as satisfying a
42
+ // read requirement (scope-explanations.ts). The shared checker must too.
43
+ expect(hasAccountScope([ACCOUNT_SELF_ADMIN_SCOPE], "self", "read")).toBe(true);
44
+ expect(hasAccountScope([ACCOUNT_SELF_READ_SCOPE], "self", "admin")).toBe(false);
45
+ });
46
+ });
@@ -993,6 +993,29 @@ describe("hubFetch routing", () => {
993
993
  }
994
994
  });
995
995
 
996
+ test("retired Agent token endpoints neither mint nor redirect", async () => {
997
+ const h = makeHarness();
998
+ try {
999
+ const dist = join(h.dir, "dist");
1000
+ mkdirIfMissing(dist);
1001
+ writeFileSync(join(dist, "index.html"), "<!doctype html><title>admin shell</title>");
1002
+ const fetcher = hubFetch(h.dir, { spaDistDir: dist });
1003
+
1004
+ for (const path of ["/admin/agent-token", "/admin/channel-token"]) {
1005
+ const post = await fetcher(req(path, { method: "POST" }));
1006
+ expect(post.status).toBe(405);
1007
+ expect(post.headers.get("location")).toBeNull();
1008
+
1009
+ const get = await fetcher(req(path));
1010
+ expect(get.status).toBe(200);
1011
+ expect(get.headers.get("location")).toBeNull();
1012
+ expect(await get.text()).toContain("admin shell");
1013
+ }
1014
+ } finally {
1015
+ h.cleanup();
1016
+ }
1017
+ });
1018
+
996
1019
  // 301 back-compat redirects (closes hub#231): pre-rename SPA URLs
997
1020
  // 301-redirect to the new /admin/* mount. Tests cover every entry in the
998
1021
  // dispatch — operator bookmarks landing on any of these still work.
@@ -1249,6 +1272,20 @@ describe("hubFetch routing", () => {
1249
1272
  }
1250
1273
  });
1251
1274
 
1275
+ test("retired /channel/* paths no longer redirect to /agent/*", async () => {
1276
+ const h = makeHarness();
1277
+ try {
1278
+ writeManifest({ services: [] }, h.manifestPath);
1279
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
1280
+ req("/channel/mcp/default?q=1"),
1281
+ );
1282
+ expect(res.status).toBe(404);
1283
+ expect(res.headers.get("location")).toBeNull();
1284
+ } finally {
1285
+ h.cleanup();
1286
+ }
1287
+ });
1288
+
1252
1289
  test("/hub/<unknown> (no SPA mount anymore) → 404", async () => {
1253
1290
  const h = makeHarness();
1254
1291
  try {
@@ -239,6 +239,35 @@ describe("renderHub — signed-out slimming (operator feedback)", () => {
239
239
  });
240
240
  });
241
241
 
242
+ describe("renderHub — Get started tiles are uis[]-driven (W2-12)", () => {
243
+ // The tile logic runs client-side inside the discovery IIFE; these pins
244
+ // assert the emitted script's SHAPE (the JS-parses check above guards its
245
+ // validity). The section is uis[]-driven — one tile per active uis[]
246
+ // sub-unit, whichever mount identity (/surface/notes or
247
+ // /surface/parachute) an install serves — with the pre-W2-12 hardcoded
248
+ // "Open Notes" tile demoted to a fallback for surface-host rows that
249
+ // predate uis{} self-registration.
250
+ const html = renderHub({
251
+ session: { displayName: "operator", csrfToken: "csrf-w212" },
252
+ });
253
+
254
+ test("the discovery script iterates uis[] sub-units", () => {
255
+ expect(html).toContain("svc.uis");
256
+ // Inactive sub-units are skipped; absent status counts as active.
257
+ expect(html).toContain("ui.status");
258
+ });
259
+
260
+ test("the legacy 'Open Notes' hardcode survives only as the no-uis fallback", () => {
261
+ // Still present (pre-uis surface-host installs keep their CTA) …
262
+ expect(html).toContain("Open Notes");
263
+ expect(html).toContain("'/surface/notes/'");
264
+ // … but no longer keyed on the bare presence of parachute-surface as
265
+ // the PRIMARY path: the uis[] loop renders first, the hasSurface check
266
+ // only backstops an empty tile list.
267
+ expect(html).toContain("Legacy fallback");
268
+ });
269
+ });
270
+
242
271
  describe("renderHub — signed-in indicator (rc.13)", () => {
243
272
  test("session user → 'Signed in as <name>' + inline POST form with CSRF", () => {
244
273
  const html = renderHub({
@@ -1,8 +1,14 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
6
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
7
+ import {
8
+ PARACHUTE_HUB_ROOT_REDIRECT_ENV,
9
+ getRootRedirect,
10
+ setRootRedirect,
11
+ } from "../hub-settings.ts";
6
12
  import { findService, upsertService } from "../services-manifest.ts";
7
13
 
8
14
  function makeTempPath(): { path: string; configDir: string; cleanup: () => void } {
@@ -62,6 +68,71 @@ describe("install", () => {
62
68
  }
63
69
  });
64
70
 
71
+ test("refuses `install agent` without invoking bun or the deprecated binary", async () => {
72
+ const { path, cleanup } = makeTempPath();
73
+ try {
74
+ const calls: string[][] = [];
75
+ const logs: string[] = [];
76
+ const code = await install("agent", {
77
+ runner: async (cmd) => {
78
+ calls.push([...cmd]);
79
+ return 0;
80
+ },
81
+ manifestPath: path,
82
+ startService: async () => 0,
83
+ isLinked: () => false,
84
+ portProbe: async () => false,
85
+ log: (line) => logs.push(line),
86
+ });
87
+ expect(code).toBe(1);
88
+ expect(calls).toEqual([]);
89
+ expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
90
+ expect(logs.join("\n")).toMatch(/Vault and Surface/);
91
+ } finally {
92
+ cleanup();
93
+ }
94
+ });
95
+
96
+ test("refuses explicit retired Agent npm package names before invoking bun", async () => {
97
+ for (const packageName of [
98
+ "@openparachute/agent",
99
+ "@openparachute/agent@0.9.0",
100
+ "@openparachute/channel",
101
+ ]) {
102
+ const calls: string[][] = [];
103
+ const logs: string[] = [];
104
+ const code = await install(packageName, {
105
+ runner: async (cmd) => {
106
+ calls.push([...cmd]);
107
+ return 0;
108
+ },
109
+ log: (line) => logs.push(line),
110
+ });
111
+ expect(code).toBe(1);
112
+ expect(calls).toEqual([]);
113
+ expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
114
+ }
115
+ });
116
+
117
+ test("refuses a local checkout whose package metadata is a retired Agent package", async () => {
118
+ const pkgDir = mkdtempSync(join(tmpdir(), "pcli-retired-agent-"));
119
+ try {
120
+ const calls: string[][] = [];
121
+ const code = await install(pkgDir, {
122
+ runner: async (cmd) => {
123
+ calls.push([...cmd]);
124
+ return 0;
125
+ },
126
+ readPackageName: () => "@openparachute/agent",
127
+ log: () => {},
128
+ });
129
+ expect(code).toBe(1);
130
+ expect(calls).toEqual([]);
131
+ } finally {
132
+ rmSync(pkgDir, { recursive: true, force: true });
133
+ }
134
+ });
135
+
65
136
  test("runs bun add -g then init; seeds manifest when service didn't write one", async () => {
66
137
  const { path, cleanup } = makeTempPath();
67
138
  try {
@@ -408,7 +479,7 @@ describe("install", () => {
408
479
  test("ADOPT-KILLS an attributable same-module orphan on the canonical port + reclaims it (#609)", async () => {
409
480
  // Wipe-recovery: `rm -rf ~/.parachute` + re-`init` leaves the supervised
410
481
  // vault child running on :1940. The fresh install must reclaim the canonical
411
- // port (adopt-kill the attributable orphan) rather than port-walk to 1944.
482
+ // port (adopt-kill the attributable orphan) rather than port-walk to 1945.
412
483
  const { path, configDir, cleanup } = makeTempPath();
413
484
  try {
414
485
  const logs: string[] = [];
@@ -1636,9 +1707,10 @@ describe("install", () => {
1636
1707
  log: (l) => logs.push(l),
1637
1708
  });
1638
1709
  expect(code).toBe(0);
1639
- // First reservation slot is 1944.
1710
+ // Agent retirement released 1941, so it is now the first unassigned
1711
+ // canonical slot available to the fallback walker.
1640
1712
  const entry = findService("parachute-vault", path);
1641
- expect(entry?.port).toBe(1944);
1713
+ expect(entry?.port).toBe(1941);
1642
1714
  expect(logs.join("\n")).toMatch(/canonical port 1940 is in use/);
1643
1715
  // .env is not touched.
1644
1716
  const envPath = join(configDir, "vault", ".env");
@@ -1865,6 +1937,33 @@ describe("install", () => {
1865
1937
  }
1866
1938
  });
1867
1939
 
1940
+ test("refuses third-party metadata that declares a retired Agent manifest identity", async () => {
1941
+ const { path, cleanup } = makeTempPath();
1942
+ try {
1943
+ const logs: string[] = [];
1944
+ const code = await install("@vendor/agent-wrapper", {
1945
+ runner: async () => 0,
1946
+ manifestPath: path,
1947
+ isLinked: () => false,
1948
+ log: (line) => logs.push(line),
1949
+ readManifest: async () => ({
1950
+ name: "claw",
1951
+ manifestName: "@vendor/agent-wrapper",
1952
+ port: 1952,
1953
+ paths: ["/claw"],
1954
+ health: "/claw/health",
1955
+ }),
1956
+ findGlobalInstall: () =>
1957
+ "/fake/bun-globals/node_modules/@vendor/agent-wrapper/package.json",
1958
+ });
1959
+ expect(code).toBe(1);
1960
+ expect(findService("claw", path)).toBeUndefined();
1961
+ expect(logs.join("\n")).toMatch(/parachute-agent was retired/);
1962
+ } finally {
1963
+ cleanup();
1964
+ }
1965
+ });
1966
+
1868
1967
  test("npm-installed third-party module persists installDir from bun globals", async () => {
1869
1968
  // hub#83: for `parachute install <npm-pkg>`, installDir is dirname of
1870
1969
  // the package.json that findGlobalInstall returns. Without this,
@@ -1940,7 +2039,8 @@ describe("install", () => {
1940
2039
  expect(startCalls).toEqual(["someapp"]);
1941
2040
  // Log lines speak in the canonical short name too. Port comes from
1942
2041
  // assignServicePort (third-party gets the first unassigned canonical
1943
- // slot, currently 1944), not the manifest's port hint.
2042
+ // slot, currently 1945 — 1944 is parachute-app's canonical assigned
2043
+ // slot as of hub-parity P5), not the manifest's port hint.
1944
2044
  const joined = logs.join("\n");
1945
2045
  expect(joined).toMatch(/Seeded services\.json entry for someapp/);
1946
2046
  expect(joined).toMatch(/someapp registered on port \d+/);
@@ -2316,3 +2416,177 @@ describe("hub#573 — install auto-start converges on supervised detection", ()
2316
2416
  expect(opts.log).toBe(log);
2317
2417
  });
2318
2418
  });
2419
+
2420
+ // hub-parity P5 (2026-07-11): `parachute install app` defaults the hub's
2421
+ // bare `/` redirect to the app's front door — SET-IF-UNSET ONLY. Every test
2422
+ // here injects `rootRedirectDb` (a hub.db opened in the same disposable
2423
+ // tempdir `makeTempPath()` already isolates) so no real
2424
+ // `~/.parachute/hub.db` is ever touched — never drive this against the
2425
+ // operator's live install.
2426
+ describe("app-only root-redirect set-if-unset (hub-parity P5)", () => {
2427
+ // The write gates on `resolveRootRedirectDetailed(db).source === "default"`,
2428
+ // which consults `PARACHUTE_HUB_ROOT_REDIRECT`. Neutralize any ambient value
2429
+ // (Aaron's box, CI) so the default-tier tests are deterministic; the
2430
+ // env-set test below manages its own value inside its own body.
2431
+ let savedEnv: string | undefined;
2432
+ beforeEach(() => {
2433
+ savedEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2434
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2435
+ });
2436
+ afterEach(() => {
2437
+ if (savedEnv === undefined) {
2438
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2439
+ } else {
2440
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = savedEnv;
2441
+ }
2442
+ });
2443
+
2444
+ test("fresh install (no prior root_redirect) sets it to /app/", async () => {
2445
+ const { path, configDir, cleanup } = makeTempPath();
2446
+ try {
2447
+ const db = openHubDb(hubDbPath(configDir));
2448
+ const logs: string[] = [];
2449
+ try {
2450
+ const code = await install("app", {
2451
+ runner: async () => 0,
2452
+ manifestPath: path,
2453
+ configDir,
2454
+ startService: async () => 0,
2455
+ isLinked: () => false,
2456
+ portProbe: async () => false,
2457
+ rootRedirectDb: db,
2458
+ log: (l) => logs.push(l),
2459
+ });
2460
+ expect(code).toBe(0);
2461
+ expect(getRootRedirect(db)).toBe("/app/");
2462
+ expect(logs.join("\n")).toMatch(/front page.*now opens the app at \/app\//);
2463
+ } finally {
2464
+ db.close();
2465
+ }
2466
+ } finally {
2467
+ cleanup();
2468
+ }
2469
+ });
2470
+
2471
+ test("a pre-set root_redirect is left alone — never clobbered", async () => {
2472
+ const { path, configDir, cleanup } = makeTempPath();
2473
+ try {
2474
+ const db = openHubDb(hubDbPath(configDir));
2475
+ const logs: string[] = [];
2476
+ try {
2477
+ setRootRedirect(db, "/surface/reading-room");
2478
+ const code = await install("app", {
2479
+ runner: async () => 0,
2480
+ manifestPath: path,
2481
+ configDir,
2482
+ startService: async () => 0,
2483
+ isLinked: () => false,
2484
+ portProbe: async () => false,
2485
+ rootRedirectDb: db,
2486
+ log: (l) => logs.push(l),
2487
+ });
2488
+ expect(code).toBe(0);
2489
+ // The operator's prior choice survives byte-for-byte.
2490
+ expect(getRootRedirect(db)).toBe("/surface/reading-room");
2491
+ // The set-if-unset write's OWN log line (distinct from the static
2492
+ // postInstallFooter boilerplate, which always prints "now opens the
2493
+ // app too, unless you've already..." regardless of whether the write
2494
+ // fired) must NOT appear — the write itself was skipped.
2495
+ expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
2496
+ } finally {
2497
+ db.close();
2498
+ }
2499
+ } finally {
2500
+ cleanup();
2501
+ }
2502
+ });
2503
+
2504
+ test("installing a different module never touches root_redirect", async () => {
2505
+ const { path, configDir, cleanup } = makeTempPath();
2506
+ try {
2507
+ const db = openHubDb(hubDbPath(configDir));
2508
+ try {
2509
+ const code = await install("notes", {
2510
+ runner: async () => 0,
2511
+ manifestPath: path,
2512
+ configDir,
2513
+ startService: async () => 0,
2514
+ isLinked: () => false,
2515
+ portProbe: async () => false,
2516
+ rootRedirectDb: db,
2517
+ log: () => {},
2518
+ });
2519
+ expect(code).toBe(0);
2520
+ expect(getRootRedirect(db)).toBeNull();
2521
+ } finally {
2522
+ db.close();
2523
+ }
2524
+ } finally {
2525
+ cleanup();
2526
+ }
2527
+ });
2528
+
2529
+ test("an ENV-configured redirect (no DB row) is left alone — never clobbered (N1)", async () => {
2530
+ // Container deploys pin the landing page via PARACHUTE_HUB_ROOT_REDIRECT,
2531
+ // not a DB row. Gating on `getRootRedirect(db) === null` (DB only) would
2532
+ // miss this and write `/app/`, which — since the DB row wins over env on
2533
+ // read — silently overrides their configured landing. The fix gates on
2534
+ // `resolveRootRedirectDetailed(db).source === "default"`, which sees the
2535
+ // env tier. So: env set → no DB row written.
2536
+ const { path, configDir, cleanup } = makeTempPath();
2537
+ const prevEnv = process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2538
+ try {
2539
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = "/surface/team-room";
2540
+ const db = openHubDb(hubDbPath(configDir));
2541
+ const logs: string[] = [];
2542
+ try {
2543
+ const code = await install("app", {
2544
+ runner: async () => 0,
2545
+ manifestPath: path,
2546
+ configDir,
2547
+ startService: async () => 0,
2548
+ isLinked: () => false,
2549
+ portProbe: async () => false,
2550
+ rootRedirectDb: db,
2551
+ log: (l) => logs.push(l),
2552
+ });
2553
+ expect(code).toBe(0);
2554
+ // No DB row written — the env-configured landing survives.
2555
+ expect(getRootRedirect(db)).toBeNull();
2556
+ expect(logs.join("\n")).not.toMatch(/now opens the app at \/app\//);
2557
+ } finally {
2558
+ db.close();
2559
+ }
2560
+ } finally {
2561
+ if (prevEnv === undefined) {
2562
+ delete process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
2563
+ } else {
2564
+ process.env[PARACHUTE_HUB_ROOT_REDIRECT_ENV] = prevEnv;
2565
+ }
2566
+ cleanup();
2567
+ }
2568
+ });
2569
+
2570
+ test("production gate: without rootRedirectDb + a tempdir manifestPath, the write is skipped (not attempted)", async () => {
2571
+ // Mirrors the existing `guidanceProbeAllowed` discriminant elsewhere in
2572
+ // install.ts: a test with a tempdir manifestPath and no explicit opt-in
2573
+ // must never open the real ~/.parachute/hub.db. There's nothing to open
2574
+ // here at all — the assertion is simply that install still completes
2575
+ // cleanly with the DB-touching branch skipped.
2576
+ const { path, configDir, cleanup } = makeTempPath();
2577
+ try {
2578
+ const code = await install("app", {
2579
+ runner: async () => 0,
2580
+ manifestPath: path,
2581
+ configDir,
2582
+ startService: async () => 0,
2583
+ isLinked: () => false,
2584
+ portProbe: async () => false,
2585
+ log: () => {},
2586
+ });
2587
+ expect(code).toBe(0);
2588
+ } finally {
2589
+ cleanup();
2590
+ }
2591
+ });
2592
+ });