@openparachute/hub 0.7.0 → 0.7.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -38,6 +38,7 @@ import {
38
38
  type OAuthClient,
39
39
  type RegisteredClient,
40
40
  approveClient,
41
+ expandRedirectUrisForHubOrigins,
41
42
  getClient,
42
43
  isValidRedirectUri,
43
44
  registerClient,
@@ -71,6 +72,7 @@ import { isHttpsRequest } from "./request-protocol.ts";
71
72
  import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
72
73
  import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
73
74
  import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
75
+ import { shortNameForManifest } from "./service-spec.ts";
74
76
  import {
75
77
  type ServicesManifest,
76
78
  // Hot-path OAuth flows use the lenient reader so a single malformed
@@ -391,13 +393,20 @@ function oauthErrorRedirect(
391
393
 
392
394
  /**
393
395
  * Optional first-party modules whose scopes `FIRST_PARTY_SCOPES` carries
394
- * statically (it's `Object.keys(SCOPE_EXPLANATIONS)`), paired with the
395
- * services.json entry that means "installed." Vault + hub are core and always
396
+ * statically (it's `Object.keys(SCOPE_EXPLANATIONS)`), paired with the module
397
+ * SHORT name that means "installed." Vault + hub are core and always
396
398
  * advertised; these are the modules a hub may not have.
399
+ *
400
+ * Keyed on the SHORT name (resolved from each services.json row via
401
+ * `shortNameForManifest`) rather than the raw manifest name so a row written
402
+ * under a legacy manifest name still counts as installed — e.g. an un-upgraded
403
+ * `parachute-channel` row aliases to short `agent` (channel→agent rename
404
+ * 2026-06-17), so `agent:send` is still correctly advertised until the daemon
405
+ * re-registers under `parachute-agent`.
397
406
  */
398
- const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, service: string]> = [
399
- ["scribe:", "parachute-scribe"],
400
- ["channel:", "parachute-channel"],
407
+ const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, short: string]> = [
408
+ ["scribe:", "scribe"],
409
+ ["agent:", "agent"],
401
410
  ];
402
411
 
403
412
  /**
@@ -406,20 +415,22 @@ const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, service: s
406
415
  * module isn't installed.
407
416
  *
408
417
  * Why: `FIRST_PARTY_SCOPES` is static, so a vault-only hub still advertised
409
- * `scribe:*` + `channel:send`. Discovery clients list the advertised catalog
418
+ * `scribe:*` + `agent:send`. Discovery clients list the advertised catalog
410
419
  * verbatim — claude.ai's connector UI showed a friend connecting ONE vault a
411
- * request for Scribe + Channel access the hub can't even honor. So advertise an
420
+ * request for Scribe + Agent access the hub can't even honor. So advertise an
412
421
  * optional module's scopes only when its service is present in services.json.
413
422
  * (Trims the ADVERTISEMENT only; issuance/validation still use the full
414
423
  * `loadDeclaredScopes` set, and the per-vault PRM stays vault-narrowed.)
415
424
  */
416
425
  function advertisedScopes(declared: ReadonlySet<string>, manifest: ServicesManifest): string[] {
417
- const installed = new Set(manifest.services.map((s) => s.name));
426
+ const installedShorts = new Set(
427
+ manifest.services.map((s) => shortNameForManifest(s.name) ?? s.name),
428
+ );
418
429
  return Array.from(declared)
419
430
  .filter(isRequestableScope)
420
431
  .filter((scope) => {
421
- for (const [prefix, service] of OPTIONAL_MODULE_SCOPES) {
422
- if (scope.startsWith(prefix) && !installed.has(service)) return false;
432
+ for (const [prefix, short] of OPTIONAL_MODULE_SCOPES) {
433
+ if (scope.startsWith(prefix) && !installedShorts.has(short)) return false;
423
434
  }
424
435
  return true;
425
436
  });
@@ -888,11 +899,11 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
888
899
  // 1. The consent screen — and the session-less "App not yet approved"
889
900
  // page (`pendingClientResponse`) — shows ONLY that vault's scopes
890
901
  // instead of the whole-hub catalog. Narrowing DROPS non-vault scopes
891
- // (`scribe:*`, `channel:send`, `hub:admin`) outright: the token this
902
+ // (`scribe:*`, `agent:send`, `hub:admin`) outright: the token this
892
903
  // flow mints is stamped `aud=vault.<name>`, so they're unusable inside
893
904
  // it and only inflate the consent surface — the exact "scary consent"
894
905
  // a friend hit connecting Claude to ONE vault (scribe isn't even
895
- // installed; `channel:send` is meaningless to them).
906
+ // installed; `agent:send` is meaningless to them).
896
907
  // 2. The minted token carries the named scope, so `inferAudience` stamps
897
908
  // `aud=vault.<name>` and a current-line vault accepts it (an unnamed
898
909
  // `vault:read` token is rejected by `findBroadVaultScopes`).
@@ -2546,10 +2557,26 @@ export async function handleRegister(
2546
2557
  }
2547
2558
  const confidential = body.token_endpoint_auth_method === "client_secret_post";
2548
2559
  const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
2560
+ // Cross-hub-origin expansion (surface#118). A hub-served module (surface,
2561
+ // notes) registers at install time knowing only the loopback hub origin;
2562
+ // once exposed, the browser computes its redirect_uri from the PUBLIC hub
2563
+ // origin, which strict authorize-time matching would reject. We expand each
2564
+ // submitted URI rooted at one of THIS hub's known origins onto every other
2565
+ // known hub origin, so a loopback-registered hub-served client is valid on
2566
+ // the public origin too. FOREIGN-origin redirect_uris (a separate-origin
2567
+ // surface on its own domain) are stored verbatim — never expanded onto hub
2568
+ // origins, never dropped — preserving the strict anti-open-redirect
2569
+ // invariant. Authorize-time matching stays exact-match
2570
+ // (`requireRegisteredRedirectUri`); this only ever ADDS correct hub-origin
2571
+ // URIs the operator already controls.
2572
+ const expandedRedirectUris = expandRedirectUrisForHubOrigins(
2573
+ redirectUris,
2574
+ resolveBoundOrigins(deps),
2575
+ );
2549
2576
  let registered: RegisteredClient;
2550
2577
  try {
2551
2578
  registered = registerClient(db, {
2552
- redirectUris,
2579
+ redirectUris: expandedRedirectUris,
2553
2580
  scopes,
2554
2581
  clientName: body.client_name,
2555
2582
  confidential,
@@ -104,7 +104,7 @@ export const OPERATOR_TOKEN_SCOPE_SETS: Readonly<Record<OperatorScopeSet, readon
104
104
  "parachute:host:vault",
105
105
  "vault:admin",
106
106
  "scribe:admin",
107
- "channel:send",
107
+ "agent:send",
108
108
  ],
109
109
  };
110
110
 
@@ -180,17 +180,25 @@ const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
180
180
  * mutation under `/admin/*`; keep this list in sync with the dispatch in
181
181
  * `hub-server.ts`):
182
182
  *
183
- * - `POST /admin/connections` + `DELETE /admin/connections/<id>`
184
- * (connection provision/teardown — the seam's canonical consumers are
185
- * channel's admin page and the hub SPA, both same-origin `fetch()` with
186
- * `credentials: "include"`)
183
+ * - `POST /admin/connections` + `DELETE /admin/connections/<id>` +
184
+ * `POST /admin/connections/<id>/approve`
185
+ * (connection provision/teardown/claim-approval the seam's canonical
186
+ * consumers are the agent module's admin page and the hub SPA, both same-origin
187
+ * `fetch()` with `credentials: "include"`. The Bearer-authed
188
+ * `/<id>/renew` + `/<id>/claim` siblings pass the belt via the
189
+ * Authorization carve-out below.)
190
+ * - `POST /admin/grants/<id>/approve` + `POST /admin/grants/<id>/revoke`
191
+ * (agent-connector grant approval/revoke — operator cookie-authed in the
192
+ * hub SPA, 4b-1. The `PUT /admin/grants` + `GET /admin/grants[/...]`
193
+ * siblings are host-admin-Bearer-authed and pass via the Authorization
194
+ * carve-out below.)
187
195
  *
188
196
  * (The legacy `POST/DELETE /admin/channels` pair was belted here until
189
197
  * boundary D1 retired the endpoint — superseded by `/admin/connections`.)
190
198
  *
191
199
  * NOT belted, and why:
192
200
  * - GET/HEAD/OPTIONS — read-shaped; the mint GETs
193
- * (`/admin/host-admin-token`, `/admin/channel-token`,
201
+ * (`/admin/host-admin-token`, `/admin/agent-token`,
194
202
  * `/admin/module-token/<short>`, `/admin/vault-admin-token/<name>`)
195
203
  * enforce GET-only with a 405 and their response bodies are unreadable
196
204
  * cross-origin (no CORS on these routes).
@@ -117,15 +117,15 @@ function decodeVaultName(segment: string): string | null {
117
117
  * - already-named `vault:<other>:<verb>` is LEFT UNTOUCHED — a client that
118
118
  * explicitly named a different vault is not silently re-pointed; the
119
119
  * downstream picker / assignment defenses decide whether that's allowed.
120
- * - non-vault scopes (`scribe:*`, `channel:send`, `hub:admin`, …) are
120
+ * - non-vault scopes (`scribe:*`, `agent:send`, `hub:admin`, …) are
121
121
  * DROPPED. This flow mints a token stamped `aud=vault.<name>` (RFC 8707),
122
- * so a scribe/channel/hub scope inside it is unusable — keeping it only
122
+ * so a scribe/agent/hub scope inside it is unusable — keeping it only
123
123
  * inflates the consent surface a friend sees when connecting ONE vault.
124
124
  * That "scary consent" is the failure mode this module exists to kill
125
125
  * (see the header docstring): the verb-narrowing alone left the foreign
126
126
  * scopes riding through, so a client that over-requests the whole-hub
127
127
  * catalog (claude.ai reads it from the AS-metadata `scopes_supported`)
128
- * still surfaced `scribe:admin` + `channel:send` on the consent screen.
128
+ * still surfaced `scribe:admin` + `agent:send` on the consent screen.
129
129
  * A client that genuinely wants a scribe token runs a separate flow
130
130
  * naming the scribe resource.
131
131
  *
@@ -137,7 +137,7 @@ export function narrowResourceVaultScopes(scopes: readonly string[], vaultName:
137
137
  const out: string[] = [];
138
138
  for (const s of scopes) {
139
139
  const parts = s.split(":");
140
- if (parts[0] !== "vault") continue; // drop scribe:/channel:/hub:/… — foreign to a vault-audience token
140
+ if (parts[0] !== "vault") continue; // drop scribe:/agent:/hub:/… — foreign to a vault-audience token
141
141
  const verb = parts[1];
142
142
  if (parts.length === 2 && verb && VAULT_VERBS.has(verb)) {
143
143
  out.push(`vault:${vaultName}:${verb}`);
@@ -45,7 +45,7 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
45
45
  label: "Full vault access plus configuration changes (rotate tokens, change settings).",
46
46
  level: "admin",
47
47
  },
48
- // Optional-module scopes (scribe / channel). These are in FIRST_PARTY_SCOPES
48
+ // Optional-module scopes (scribe / agent). These are in FIRST_PARTY_SCOPES
49
49
  // (= Object.keys(this map)) but the modules may not be installed — so they're
50
50
  // GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts) and only advertised in
51
51
  // `scopes_supported` when the service is in services.json. If you add scopes
@@ -59,8 +59,8 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
59
59
  label: "Manage Scribe configuration (provider keys, models, quotas).",
60
60
  level: "admin",
61
61
  },
62
- "channel:send": {
63
- label: "Post messages to your Channel.",
62
+ "agent:send": {
63
+ label: "Post messages to your Agent.",
64
64
  level: "send",
65
65
  },
66
66
  "hub:admin": {
@@ -8,7 +8,7 @@ import type { ServiceEntry } from "./services-manifest.ts";
8
8
  *
9
9
  * 1939 parachute-hub internal static + proxy, CLI-managed
10
10
  * 1940 parachute-vault committed core
11
- * 1941 parachute-channel exploration (may retire)
11
+ * 1941 parachute-agent exploration (renamed from parachute-channel)
12
12
  * 1942 parachute-notes committed core (PWA bundle)
13
13
  * 1943 parachute-scribe committed core
14
14
  * 1944–1949 unassigned
@@ -64,7 +64,7 @@ export interface PortReservation {
64
64
  export const PORT_RESERVATIONS: readonly PortReservation[] = [
65
65
  { port: 1939, name: "parachute-hub", status: "assigned" },
66
66
  { port: 1940, name: "parachute-vault", status: "assigned" },
67
- { port: 1941, name: "parachute-channel", status: "assigned" },
67
+ { port: 1941, name: "parachute-agent", status: "assigned" },
68
68
  { port: 1942, name: "parachute-notes", status: "assigned" },
69
69
  { port: 1943, name: "parachute-scribe", status: "assigned" },
70
70
  { port: 1944, name: "unassigned", status: "reserved" },
@@ -110,7 +110,7 @@ export interface FirstPartyExtras {
110
110
  /**
111
111
  * Does the service gate its endpoints behind auth today? Drives
112
112
  * `effectivePublicExposure`'s default for api/tool services. True for
113
- * vault/channel; conservatively false for scribe until its auth-gate ships.
113
+ * vault/agent; conservatively false for scribe until its auth-gate ships.
114
114
  */
115
115
  readonly hasAuth?: boolean;
116
116
  /**
@@ -260,9 +260,10 @@ export function composeServiceSpec(opts: {
260
260
  //
261
261
  // As of 2026-05-21 (hub#310), vault / scribe / runner have all retired their
262
262
  // FALLBACK entries: each ships `module.json` AND self-registers its
263
- // services.json row at boot (vault#356, scribe#50, runner#3). Channel
264
- // followed in the 2026-06-09 hub-module-boundary migration (D3) — it ships
265
- // `.parachute/module.json` and self-registers (channel#34 era), so its
263
+ // services.json row at boot (vault#356, scribe#50, runner#3). Agent
264
+ // (renamed from channel 2026-06-17) followed in the 2026-06-09 hub-module-
265
+ // boundary migration (D3) — it ships `.parachute/module.json` and
266
+ // self-registers (channel#34 era, pre-rename), so its
266
267
  // vendored manifest retired to a KNOWN_MODULES row. Hub reads the
267
268
  // canonical fields from services.json (operator-authoritative) and falls
268
269
  // through to `<installDir>/.parachute/module.json` when a lifecycle command
@@ -321,7 +322,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
321
322
  * Indexed by short name (the `parachute install <X>` token).
322
323
  *
323
324
  * Only notes remains — see the block comment above for the rationale
324
- * (vault/scribe/runner/channel now self-register and ship their own
325
+ * (vault/scribe/runner/agent now self-register and ship their own
325
326
  * module.json). Other code paths consult both this table AND `KNOWN_MODULES`
326
327
  * (which carries the post-self-register-retirement entries) via the helpers
327
328
  * in this file (`shortNameForManifest`, `knownServices`, …).
@@ -332,7 +333,7 @@ export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
332
333
 
333
334
  /**
334
335
  * Minimal install-time registry for first-party modules whose FALLBACK has
335
- * retired (vault / scribe / runner as of hub#310; channel as of the
336
+ * retired (vault / scribe / runner as of hub#310; agent as of the
336
337
  * 2026-06-09 hub-module-boundary migration, D3). Hub uses this for:
337
338
  *
338
339
  * 1. **Install bootstrap**: mapping `parachute install <short>` to the npm
@@ -457,28 +458,26 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
457
458
  hasAuth: true,
458
459
  },
459
460
  },
460
- channel: {
461
- short: "channel",
462
- package: "@openparachute/channel",
463
- manifestName: "parachute-channel",
461
+ agent: {
462
+ short: "agent",
463
+ package: "@openparachute/agent",
464
+ manifestName: "parachute-agent",
464
465
  canonicalPort: 1941,
465
- displayName: "Channel",
466
- // Mirrors channel's own module.json (the canonical fields below do too —
467
- // keep in sync if channel's declaration changes). The retired FALLBACK's
468
- // copy ("Notification fan-out across modules.", health "/channel/health",
469
- // startCmd ["parachute-channel", "daemon"]) predated channel's
470
- // module.json + self-registration and had drifted on all three.
466
+ displayName: "Agent",
467
+ // Mirrors agent's own module.json (the canonical fields below do too —
468
+ // keep in sync if agent's declaration changes). Renamed from `channel`
469
+ // 2026-06-17 (parachute-channel → parachute-agent); the module is the
470
+ // same webhook-fan-out + MCP bridge daemon, on the same port 1941.
471
471
  tagline: "Chat with your Claude Code sessions — a channel per session.",
472
- canonicalPaths: ["/channel"],
472
+ canonicalPaths: ["/agent"],
473
473
  canonicalHealth: "/health",
474
474
  canonicalStripPrefix: true,
475
475
  extras: {
476
476
  // Backward-compat startCmd for rows without installDir — same rationale
477
- // as scribe / vault / runner. The bare binary IS the daemon (channel's
478
- // package.json bin maps `parachute-channel` → src/daemon.ts); the old
479
- // vendored `["parachute-channel", "daemon"]` subcommand is stale.
480
- startCmd: () => ["parachute-channel"],
481
- // Channel gates its endpoints behind hub-issued JWTs (channel:* scopes).
477
+ // as scribe / vault / runner. The bare binary IS the daemon (agent's
478
+ // package.json bin maps `parachute-agent` → src/daemon.ts).
479
+ startCmd: () => ["parachute-agent"],
480
+ // Agent gates its endpoints behind hub-issued JWTs (agent:* scopes).
482
481
  hasAuth: true,
483
482
  },
484
483
  },
@@ -535,15 +534,20 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
535
534
  * reappear silently on legacy installs.
536
535
  */
537
536
  export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
538
- agent: {
539
- retiredAt: "2026-05-20",
540
- replacement: "parachute-surface or parachute-runner (depending on use case)",
541
- },
542
- // 2026-05-20 retirement caught both forms of legacy rows.
543
- "parachute-agent": {
544
- retiredAt: "2026-05-20",
545
- replacement: "parachute-surface or parachute-runner (depending on use case)",
546
- },
537
+ // NOTE (2026-06-17, channel→agent rename): the `agent` / `parachute-agent`
538
+ // names were retired here on 2026-05-20 (the Claude-in-containers module).
539
+ // They are NOW RE-ASSIGNED to the renamed channel module (parachute-channel
540
+ // → parachute-agent). Keeping these as RETIRED entries would make
541
+ // `dropRetiredModuleRows` drop the NEW agent module's services.json row on
542
+ // load — breaking the live module. The names are no longer retired; they
543
+ // belong to the active agent module. The retired containers code now lives
544
+ // under `parachute-agent-legacy` (GitHub) and is not installable, so there
545
+ // is no live `agent` daemon to GC. (The historical `claw → agent`
546
+ // migration in services-manifest.ts still rewrites stale paraclaw rows to
547
+ // `name: "agent"` — they now resolve to the LIVE agent module's mount,
548
+ // which is harmless / arguably correct since paraclaw was the original
549
+ // "agent".)
550
+ //
547
551
  // The `parachute-app` row name retires 2026-05-27 along with the
548
552
  // app → surface rename (patterns#102). Operators upgrading from
549
553
  // 0.5.13-stable will have a `parachute-app` row in services.json
@@ -637,7 +641,7 @@ export function knownServices(): string[] {
637
641
  * The hub PREFERS a module's manifest-declared `focus`; this map is the
638
642
  * fallback when `module.json` omits it (and the bootstrap value before a
639
643
  * module is installed). vault / scribe / hub / surface are `core`; everything
640
- * else (channel / runner / notes / unknown third-party) defaults to
644
+ * else (agent / runner / notes / unknown third-party) defaults to
641
645
  * `experimental`. **Show all; never hide** — `focus` only groups + labels.
642
646
  */
643
647
  const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
@@ -645,7 +649,7 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
645
649
  scribe: "core",
646
650
  hub: "core",
647
651
  surface: "core",
648
- channel: "experimental",
652
+ agent: "experimental",
649
653
  runner: "experimental",
650
654
  notes: "experimental",
651
655
  };
@@ -668,7 +672,7 @@ export function focusForShort(short: string, declared?: ModuleFocus): ModuleFocu
668
672
  * `CURATED_MODULES` whitelist (2026-06-09 modular-UI architecture, P2): every
669
673
  * module the hub can resolve a package/manifest for is discoverable + installable,
670
674
  * regardless of `focus` tier. Deduped, with FIRST_PARTY_FALLBACKS shorts first
671
- * (notes) then KNOWN_MODULES (vault / scribe / runner / channel / surface).
675
+ * (notes) then KNOWN_MODULES (vault / scribe / runner / agent / surface).
672
676
  *
673
677
  * `notes` is intentionally included — it's still resolvable (vendored fallback)
674
678
  * for legacy installs; it surfaces as `experimental` and isn't pushed as a
@@ -727,7 +731,7 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
727
731
  * spec with embedded manifest + extras — the vendored manifest is the
728
732
  * source of truth pre-install and the install path preserves it through.
729
733
  *
730
- * KNOWN_MODULES shorts (vault / scribe / runner / channel / surface — post
734
+ * KNOWN_MODULES shorts (vault / scribe / runner / agent / surface — post
731
735
  * FALLBACK retirement) return a **minimal** spec carrying `package`, `manifestName`,
732
736
  * and the imperative `extras` fields
733
737
  * (`init`, `hasAuth`, `urlForEntry`, `postInstallFooter`). They do NOT carry
@@ -821,14 +825,23 @@ export async function getSpecFromInstallDir(
821
825
  * their install — without this alias, `parachute start/stop/logs/status`
822
826
  * silently skip those rows. Remove after launch, alongside the `lens →
823
827
  * notes` install alias.
828
+ *
829
+ * `parachute-channel` → `agent` (2026-06-17): the channel module was renamed
830
+ * to agent (parachute-channel → parachute-agent). Operators with an
831
+ * un-upgraded `services.json` carry a `name: "parachute-channel"` row; this
832
+ * alias keeps `shortNameForManifest("parachute-channel") === "agent"` so the
833
+ * legacy row still resolves to the live agent module for routing + lifecycle
834
+ * until the daemon re-registers under `parachute-agent`. Remove after one
835
+ * release cycle, alongside the `channel → agent` install alias.
824
836
  */
825
837
  const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
826
838
  "parachute-lens": "notes",
839
+ "parachute-channel": "agent",
827
840
  };
828
841
 
829
842
  /** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
830
843
  * Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
831
- * (vault / scribe / runner / channel / surface — post-FALLBACK-retirement).
844
+ * (vault / scribe / runner / agent / surface — post-FALLBACK-retirement).
832
845
  * Returns undefined for unknown manifests. */
833
846
  export function shortNameForManifest(manifestName: string): string | undefined {
834
847
  for (const [short, fb] of Object.entries(FIRST_PARTY_FALLBACKS)) {
@@ -841,13 +854,13 @@ export function shortNameForManifest(manifestName: string): string | undefined {
841
854
  }
842
855
 
843
856
  /**
844
- * Find a services.json row by its SHORT name (e.g. `"channel"`), resolving each
845
- * row's manifest name (`parachute-channel`) back through `shortNameForManifest`.
857
+ * Find a services.json row by its SHORT name (e.g. `"agent"`), resolving each
858
+ * row's manifest name (`parachute-agent`) back through `shortNameForManifest`.
846
859
  *
847
860
  * services.json rows carry the MANIFEST name, not the bare short — so a direct
848
861
  * `s.name === short` comparison silently misses every first-party module. That
849
- * exact mismatch (`s.name === "channel"` vs a `parachute-channel` row) is what
850
- * surfaced a spurious "channel module is not installed" when wiring the channel
862
+ * exact mismatch (`s.name === "agent"` vs a `parachute-agent` row) is what
863
+ * surfaced a spurious "agent module is not installed" when wiring the agent
851
864
  * connection sink. Resolve through the short↔manifest map instead. Returns the
852
865
  * first match, or undefined when no installed row maps to `short`.
853
866
  *
@@ -855,7 +868,7 @@ export function shortNameForManifest(manifestName: string): string | undefined {
855
868
  * here — `shortNameForManifest` only knows the canonical `parachute-vault`, so
856
869
  * `findServiceByShort(services, "vault")` returns undefined even when a vault is
857
870
  * installed. Vault rows are resolved by mount path via `findVaultUpstream`; this
858
- * helper is for single-instance modules (channel / scribe / runner / surface).
871
+ * helper is for single-instance modules (agent / scribe / runner / surface).
859
872
  */
860
873
  export function findServiceByShort<T extends { name: string }>(
861
874
  services: readonly T[],
@@ -71,6 +71,38 @@ function normalizeUiSubUnitStatus(value: string): UiSubUnitStatus | undefined {
71
71
  return UI_SUB_UNIT_STATUS_ALIASES[value];
72
72
  }
73
73
 
74
+ /**
75
+ * Who may load a UI sub-unit through the hub proxy (H3, surface-runtime
76
+ * design §12 — fixes parachute-surface#88, where `public` was declared but
77
+ * never enforced):
78
+ *
79
+ * "public" — anyone; no hub identity required (chrome strip off — H5).
80
+ * "hub-users" — a valid hub session cookie OR a hub-issued Bearer whose
81
+ * scopes satisfy the surface's `scopes_required` (the OR
82
+ * keeps installed PWAs working). THE DEFAULT when absent.
83
+ * "operator" — the first-admin session only.
84
+ * "surface" — the surface backend owns admission END-TO-END; the hub
85
+ * proxy passes every request through (backed surfaces —
86
+ * kit-authenticated via @openparachute/surface-server: hub
87
+ * JWTs / capability links / anon, deny-by-default). Chrome
88
+ * strip off like `public` — capability-link invitees are
89
+ * NOT hub users.
90
+ *
91
+ * Enforced at the hub proxy BEFORE forwarding (`src/audience-gate.ts`).
92
+ * Legacy boolean `public` on the wire is accepted one alias window:
93
+ * `public: true` → `"public"`, `public: false` → default. Fail-closed: an
94
+ * unrecognized `audience` value rejects the row (the lenient manifest read
95
+ * then drops it — unroutable beats accidentally public). Version-skew
96
+ * corollary: a surface declaring `audience: "surface"` registered against a
97
+ * hub OLDER than this value's introduction gets its row rejected by that
98
+ * same fail-closed rule — the mount 404s until the hub is upgraded. The
99
+ * surface-side meta-schema ships the value with a hub-version requirement
100
+ * note for exactly this reason.
101
+ */
102
+ export type UiAudience = "public" | "hub-users" | "operator" | "surface";
103
+
104
+ const UI_AUDIENCE_VALUES: readonly UiAudience[] = ["public", "hub-users", "operator", "surface"];
105
+
74
106
  /**
75
107
  * A sub-unit beneath a module — used today by parachute-app to surface each
76
108
  * hosted UI as its own discoverable row under the App module, and the shape
@@ -129,6 +161,20 @@ export interface UiSubUnit {
129
161
  oauthClientId?: string;
130
162
  /** UI sub-unit lifecycle state. Absent → discovery treats as "active". */
131
163
  status?: UiSubUnitStatus;
164
+ /**
165
+ * Audience exposure (H3). Absent → "hub-users". The legacy boolean
166
+ * `public` field is normalized into this on read (true → "public") for
167
+ * one alias window. See {@link UiAudience}.
168
+ */
169
+ audience?: UiAudience;
170
+ /**
171
+ * OAuth scopes the UI declares as required (surface-host stamps these from
172
+ * the surface's meta.json `scopes_required`, e.g. `["vault:*:read"]` —
173
+ * `*` is a single-segment wildcard). The audience gate's Bearer branch
174
+ * checks a presented token's scopes against these. Snake_case to match
175
+ * the wire shape surface already writes.
176
+ */
177
+ scopes_required?: string[];
132
178
  }
133
179
 
134
180
  export interface ServiceEntry {
@@ -170,6 +216,16 @@ export interface ServiceEntry {
170
216
  * bridges the gap. Tracked in parachute-scribe (separate issue).
171
217
  */
172
218
  stripPrefix?: boolean;
219
+ /**
220
+ * When `true`, the module's daemon accepts WebSocket upgrades and the hub's
221
+ * Bun-native upgrade bridge (H1, surface-runtime design) will forward
222
+ * `Upgrade: websocket` requests on this module's mounts to it. DENY BY
223
+ * DEFAULT: absent/false means an upgrade request to this mount is refused
224
+ * (426) without ever reaching the daemon. Modules declare this on their
225
+ * `.parachute/module.json` (the install-time contract) and carry it onto
226
+ * their self-registered services.json row; the hub honors either source.
227
+ */
228
+ websocket?: boolean;
173
229
  /**
174
230
  * Sub-units hosted under this module — parachute-app's bag of UIs, and
175
231
  * the shape vault is expected to adopt for per-instance metadata in a
@@ -282,6 +338,10 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
282
338
  if (stripPrefix !== undefined && typeof stripPrefix !== "boolean") {
283
339
  throw new ServicesManifestError(`${where}: "stripPrefix" must be a boolean if present`);
284
340
  }
341
+ const websocket = e.websocket;
342
+ if (websocket !== undefined && typeof websocket !== "boolean") {
343
+ throw new ServicesManifestError(`${where}: "websocket" must be a boolean if present`);
344
+ }
285
345
  const uis = e.uis;
286
346
  const validatedUis = validateUis(uis, where);
287
347
  const validatedStartError = validateStartError(e.lastStartError, where);
@@ -291,6 +351,7 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
291
351
  if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
292
352
  if (installDir !== undefined) entry.installDir = installDir;
293
353
  if (stripPrefix !== undefined) entry.stripPrefix = stripPrefix;
354
+ if (websocket !== undefined) entry.websocket = websocket;
294
355
  if (validatedUis !== undefined) entry.uis = validatedUis;
295
356
  if (validatedStartError !== undefined) entry.lastStartError = validatedStartError;
296
357
  return entry;
@@ -405,12 +466,48 @@ function validateUiSubUnit(raw: unknown, where: string): UiSubUnit {
405
466
  }
406
467
  normalizedStatus = norm;
407
468
  }
469
+ // Audience (H3). Explicit `audience` wins; the legacy boolean `public` is
470
+ // accepted as an alias for one release window (true → "public"; false →
471
+ // the default, i.e. no field). FAIL-CLOSED on malformed values: throwing
472
+ // here makes the lenient manifest read drop the whole row — an unroutable
473
+ // surface beats one that silently falls open to the wrong audience.
474
+ let audience: UiAudience | undefined;
475
+ if (u.audience !== undefined) {
476
+ if (
477
+ typeof u.audience !== "string" ||
478
+ !(UI_AUDIENCE_VALUES as readonly string[]).includes(u.audience)
479
+ ) {
480
+ throw new ServicesManifestError(
481
+ `${where}: "audience" must be "public" | "hub-users" | "operator" | "surface" if present (got ${JSON.stringify(u.audience)})`,
482
+ );
483
+ }
484
+ audience = u.audience as UiAudience;
485
+ } else if (u.public !== undefined) {
486
+ if (typeof u.public !== "boolean") {
487
+ throw new ServicesManifestError(`${where}: legacy "public" must be a boolean if present`);
488
+ }
489
+ if (u.public) audience = "public";
490
+ }
491
+ let scopesRequired: string[] | undefined;
492
+ if (u.scopes_required !== undefined) {
493
+ if (
494
+ !Array.isArray(u.scopes_required) ||
495
+ u.scopes_required.some((s) => typeof s !== "string" || s.length === 0)
496
+ ) {
497
+ throw new ServicesManifestError(
498
+ `${where}: "scopes_required" must be an array of non-empty strings if present`,
499
+ );
500
+ }
501
+ scopesRequired = u.scopes_required as string[];
502
+ }
408
503
  const out: UiSubUnit = { displayName, path };
409
504
  if (tagline !== undefined) out.tagline = tagline;
410
505
  if (iconUrl !== undefined) out.iconUrl = iconUrl;
411
506
  if (version !== undefined) out.version = version;
412
507
  if (oauthClientId !== undefined) out.oauthClientId = oauthClientId;
413
508
  if (normalizedStatus !== undefined) out.status = normalizedStatus;
509
+ if (audience !== undefined) out.audience = audience;
510
+ if (scopesRequired !== undefined) out.scopes_required = scopesRequired;
414
511
  return out;
415
512
  }
416
513