@openparachute/hub 0.6.5-rc.8 → 0.7.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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -29,6 +29,7 @@
29
29
  * case. CSRF + session gates upstream are the real auth defense — this is
30
30
  * a belt for browser flows where the legitimate Origin/Referer got dropped.
31
31
  */
32
+ import { parseSessionCookie } from "./sessions.ts";
32
33
 
33
34
  /**
34
35
  * Build the bound-origin set from the hub's configuration. Returns
@@ -161,3 +162,111 @@ export function isSameOriginRequest(req: Request, boundOrigins: readonly string[
161
162
  }
162
163
  return false;
163
164
  }
165
+
166
+ // ===========================================================================
167
+ // CSRF belt for cookie-gated /admin/* JSON mutation endpoints (hub#632,
168
+ // 2026-06-09 hub-module-boundary Phase C1)
169
+ // ===========================================================================
170
+
171
+ /** Methods the belt gates. GET/HEAD/OPTIONS are read-shaped and pass. */
172
+ const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
173
+
174
+ /**
175
+ * Strict same-origin belt for cookie-authenticated JSON mutations — the
176
+ * defense-in-depth layer over `SameSite=Lax` on the hub's cookie-gated JSON
177
+ * mutation endpoints (hub#632; hub-module-boundary charter, trust statement).
178
+ *
179
+ * BELTED ENDPOINTS (the explicit enumeration — every cookie-gated JSON
180
+ * mutation under `/admin/*`; keep this list in sync with the dispatch in
181
+ * `hub-server.ts`):
182
+ *
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 channel'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
+ *
191
+ * (The legacy `POST/DELETE /admin/channels` pair was belted here until
192
+ * boundary D1 retired the endpoint — superseded by `/admin/connections`.)
193
+ *
194
+ * NOT belted, and why:
195
+ * - GET/HEAD/OPTIONS — read-shaped; the mint GETs
196
+ * (`/admin/host-admin-token`, `/admin/channel-token`,
197
+ * `/admin/module-token/<short>`, `/admin/vault-admin-token/<name>`)
198
+ * enforce GET-only with a 405 and their response bodies are unreadable
199
+ * cross-origin (no CORS on these routes).
200
+ * - Bearer-authed requests — a cross-site page cannot attach an
201
+ * `Authorization` header without a CORS preflight these routes never
202
+ * approve, so a request carrying one is not a browser CSRF. The
203
+ * downstream auth gate still validates the credential; this belt sits
204
+ * only on the cookie path.
205
+ * - Server-rendered form posts (`/login`, `/logout`, `/admin/setup/*`,
206
+ * `/oauth/authorize` approve) — already carry the double-submit CSRF
207
+ * token (`csrf.ts`) and/or `isSameOriginRequest`; not double-gated here.
208
+ * - `/vaults` (POST) + `/vaults/<name>` (DELETE) — Bearer
209
+ * `parachute:host:admin` gated, CSRF-immune.
210
+ * - `/api/*` — Bearer-gated. `/oauth/*` — spec-shaped, own protections.
211
+ *
212
+ * Stricter than `isSameOriginRequest` ON PURPOSE: no Referer fallback and —
213
+ * critically — no Host-header fallback. The Host fallback exists for
214
+ * server-rendered form flows where the double-submit token is the real
215
+ * defense and headers got proxy-stripped (#245). These JSON endpoints carry
216
+ * NO token, so a Host fallback would be a genuine bypass: an attacker form
217
+ * post under `referrer-policy: no-referrer` arrives with `Origin: null` and
218
+ * no Referer, and the Host header always names the target. Browsers send a
219
+ * real `Origin` on every non-GET `fetch()` (same-origin included — default
220
+ * `mode: "cors"` is exempt from referrer-policy Origin masking), so every
221
+ * legitimate consumer of these endpoints passes tier 1. `Origin: null` and
222
+ * malformed values are affirmative mismatches; a missing header on a
223
+ * cookie-authed mutation is rejected with its own error code
224
+ * (`csrf_origin_required`) naming the fix (send Origin, or use a Bearer).
225
+ *
226
+ * Returns `null` when the request may proceed, or a 403 JSON `Response`
227
+ * when the belt rejects. Rejections are logged (method, path, origin — no
228
+ * cookies, no tokens).
229
+ */
230
+ export function assertSameOriginForCookieMutation(
231
+ req: Request,
232
+ boundOrigins: readonly string[],
233
+ ): Response | null {
234
+ if (!MUTATION_METHODS.has(req.method.toUpperCase())) return null;
235
+ // Authorization present → not a browser CSRF (custom headers require a
236
+ // CORS preflight no /admin/* route approves). The endpoint's own gate
237
+ // validates the credential — API clients with Bearers never see the belt.
238
+ if (req.headers.get("authorization")) return null;
239
+ // No session cookie → no ambient credential to ride; the endpoint's own
240
+ // gate returns its usual 401. Presence is enough here — a forged/stale
241
+ // session id fails downstream regardless of what the belt decides.
242
+ if (!parseSessionCookie(req.headers.get("cookie"))) return null;
243
+
244
+ const pathname = new URL(req.url).pathname;
245
+ const origin = req.headers.get("origin");
246
+ if (!origin) {
247
+ console.warn(`csrf belt: rejected cookie-authed ${req.method} ${pathname} — no Origin header`);
248
+ return csrfBeltError(
249
+ "csrf_origin_required",
250
+ "cookie-authenticated mutations require an Origin header matching the hub origin; browser fetch() sends it automatically — non-browser clients should authenticate with a Bearer token instead",
251
+ );
252
+ }
253
+ if (origin !== "null") {
254
+ try {
255
+ if (new Set(boundOrigins).has(new URL(origin).origin)) return null;
256
+ } catch {
257
+ // Malformed Origin — fall through to the mismatch rejection.
258
+ }
259
+ }
260
+ console.warn(`csrf belt: rejected cookie-authed ${req.method} ${pathname} — origin=${origin}`);
261
+ return csrfBeltError(
262
+ "csrf_origin_mismatch",
263
+ "request Origin does not match this hub's origin — cross-site mutations are not allowed on cookie-authenticated endpoints",
264
+ );
265
+ }
266
+
267
+ function csrfBeltError(code: string, description: string): Response {
268
+ return new Response(JSON.stringify({ error: code, error_description: description }), {
269
+ status: 403,
270
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
271
+ });
272
+ }
@@ -47,7 +47,7 @@ export const TRANSIENT_MAX_ATTEMPTS = 5;
47
47
  export const ADMIN_MODULES_URL = "/admin/modules";
48
48
 
49
49
  /** JSON error vocabulary. Matches the snake_case shape used elsewhere
50
- * in the hub's API (`api-modules-config.ts` etc.) for consistency. */
50
+ * in the hub's API (`api-modules.ts` etc.) for consistency. */
51
51
  export const ERROR_TYPE_TRANSIENT = "upstream_starting";
52
52
  export const ERROR_TYPE_PERSISTENT = "upstream_unreachable";
53
53
 
@@ -1,5 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
- import { type ModuleManifest, readModuleManifest } from "./module-manifest.ts";
2
+ import { type ModuleFocus, type ModuleManifest, readModuleManifest } from "./module-manifest.ts";
3
3
  import type { ServiceEntry } from "./services-manifest.ts";
4
4
 
5
5
  /**
@@ -126,8 +126,8 @@ export interface FirstPartyExtras {
126
126
  * The CLI prefers the installed module's own `.parachute/module.json` when
127
127
  * present and falls back to this embedded manifest otherwise. The plan is
128
128
  * to delete each fallback as its upstream module starts shipping the real
129
- * file — see the `// FALLBACK: Delete when ...` markers below for the
130
- * specific upstream reference per entry.
129
+ * file — see the `// FALLBACK: Delete when ...` marker below for the
130
+ * specific upstream reference.
131
131
  *
132
132
  * Third-party modules never have a fallback; they ship `module.json` or
133
133
  * the install hard-errors.
@@ -260,7 +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). Hub reads the
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
266
+ // vendored manifest retired to a KNOWN_MODULES row. Hub reads the
264
267
  // canonical fields from services.json (operator-authoritative) and falls
265
268
  // through to `<installDir>/.parachute/module.json` when a lifecycle command
266
269
  // needs a static manifest. The `KNOWN_MODULES` registry below carries just
@@ -272,11 +275,10 @@ export function composeServiceSpec(opts: {
272
275
  // What remains in FIRST_PARTY_FALLBACKS:
273
276
  // - notes: still a frontend with a hub-side static-serve shim (`notes-serve.ts`)
274
277
  // — its startCmd is composed from the services.json entry's port + mount,
275
- // which is hub-side logic, not something notes itself runs.
276
- // - channel: exploration tier; may retire before it ever ships module.json,
277
- // so the vendored fallback is fine.
278
+ // which is hub-side logic, not something notes itself runs. (The archive
279
+ // isn't done notes-daemon Phase 3 retirement hasn't landed.)
278
280
  //
279
- // Both remaining entries keep their "FALLBACK: Delete when …" markers so the
281
+ // The remaining entry keeps its "FALLBACK: Delete when …" marker so the
280
282
  // next cleanup pass is a one-grep operation.
281
283
  // ---------------------------------------------------------------------------
282
284
 
@@ -314,44 +316,24 @@ const NOTES_FALLBACK: FirstPartyFallback = {
314
316
  },
315
317
  };
316
318
 
317
- // FALLBACK: Delete when @openparachute/channel ships .parachute/module.json
318
- // (parachute-channel repo: file follow-up after parachute-hub#56 lands;
319
- // channel is exploration tier — may be retired before module.json ships).
320
- const CHANNEL_FALLBACK: FirstPartyFallback = {
321
- package: "@openparachute/channel",
322
- manifest: {
323
- name: "channel",
324
- manifestName: "parachute-channel",
325
- displayName: "Channel",
326
- tagline: "Notification fan-out across modules.",
327
- port: 1941,
328
- paths: ["/channel"],
329
- health: "/channel/health",
330
- startCmd: ["parachute-channel", "daemon"],
331
- },
332
- extras: {
333
- hasAuth: true,
334
- },
335
- };
336
-
337
319
  /**
338
320
  * Vendored manifests + extras for first-party modules that still need them.
339
321
  * Indexed by short name (the `parachute install <X>` token).
340
322
  *
341
- * Only notes + channel remain — see the block comment above for the rationale
342
- * (vault/scribe/runner now self-register and ship their own module.json).
343
- * Other code paths consult both this table AND `KNOWN_MODULES` (which carries
344
- * the post-self-register-retirement entries) via the helpers in this file
345
- * (`shortNameForManifest`, `knownServices`, …).
323
+ * Only notes remains — see the block comment above for the rationale
324
+ * (vault/scribe/runner/channel now self-register and ship their own
325
+ * module.json). Other code paths consult both this table AND `KNOWN_MODULES`
326
+ * (which carries the post-self-register-retirement entries) via the helpers
327
+ * in this file (`shortNameForManifest`, `knownServices`, …).
346
328
  */
347
329
  export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
348
330
  notes: NOTES_FALLBACK,
349
- channel: CHANNEL_FALLBACK,
350
331
  };
351
332
 
352
333
  /**
353
334
  * Minimal install-time registry for first-party modules whose FALLBACK has
354
- * retired (vault / scribe / runner as of hub#310). Hub uses this for:
335
+ * retired (vault / scribe / runner as of hub#310; channel as of the
336
+ * 2026-06-09 hub-module-boundary migration, D3). Hub uses this for:
355
337
  *
356
338
  * 1. **Install bootstrap**: mapping `parachute install <short>` to the npm
357
339
  * package to `bun add -g`. Pre-install there's no module.json on disk
@@ -475,6 +457,31 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
475
457
  hasAuth: true,
476
458
  },
477
459
  },
460
+ channel: {
461
+ short: "channel",
462
+ package: "@openparachute/channel",
463
+ manifestName: "parachute-channel",
464
+ 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.
471
+ tagline: "Chat with your Claude Code sessions — a channel per session.",
472
+ canonicalPaths: ["/channel"],
473
+ canonicalHealth: "/health",
474
+ canonicalStripPrefix: true,
475
+ extras: {
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).
482
+ hasAuth: true,
483
+ },
484
+ },
478
485
  surface: {
479
486
  short: "surface",
480
487
  package: "@openparachute/surface",
@@ -625,6 +632,66 @@ export function knownServices(): string[] {
625
632
  return [...Object.keys(FIRST_PARTY_FALLBACKS), ...Object.keys(KNOWN_MODULES)];
626
633
  }
627
634
 
635
+ /**
636
+ * Default discovery tier per short name (2026-06-09 modular-UI architecture).
637
+ * The hub PREFERS a module's manifest-declared `focus`; this map is the
638
+ * fallback when `module.json` omits it (and the bootstrap value before a
639
+ * module is installed). vault / scribe / hub / surface are `core`; everything
640
+ * else (channel / runner / notes / unknown third-party) defaults to
641
+ * `experimental`. **Show all; never hide** — `focus` only groups + labels.
642
+ */
643
+ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
644
+ vault: "core",
645
+ scribe: "core",
646
+ hub: "core",
647
+ surface: "core",
648
+ channel: "experimental",
649
+ runner: "experimental",
650
+ notes: "experimental",
651
+ };
652
+
653
+ /**
654
+ * Resolve a short name's discovery tier. When `declared` (the module's
655
+ * `module.json` `focus`) is present it wins; otherwise fall back to
656
+ * `FOCUS_DEFAULTS`, defaulting any unlisted short to `experimental`. Never
657
+ * returns undefined — the Modules screen always has a tier to group by.
658
+ */
659
+ export function focusForShort(short: string, declared?: ModuleFocus): ModuleFocus {
660
+ if (declared !== undefined) return declared;
661
+ return FOCUS_DEFAULTS[short] ?? "experimental";
662
+ }
663
+
664
+ /**
665
+ * The set of short names the hub knows how to discover, display, and install
666
+ * from its bootstrap registries — `KNOWN_MODULES` ∪ `FIRST_PARTY_FALLBACKS`.
667
+ * This is the SELF-REGISTRATION-driven discovery surface that replaces the old
668
+ * `CURATED_MODULES` whitelist (2026-06-09 modular-UI architecture, P2): every
669
+ * module the hub can resolve a package/manifest for is discoverable + installable,
670
+ * regardless of `focus` tier. Deduped, with FIRST_PARTY_FALLBACKS shorts first
671
+ * (notes) then KNOWN_MODULES (vault / scribe / runner / channel / surface).
672
+ *
673
+ * `notes` is intentionally included — it's still resolvable (vendored fallback)
674
+ * for legacy installs; it surfaces as `experimental` and isn't pushed as a
675
+ * fresh install. Callers that want only the "recommended fresh-install" subset
676
+ * can filter by `focus === "core"`.
677
+ */
678
+ export function discoverableShorts(): string[] {
679
+ const seen = new Set<string>();
680
+ const out: string[] = [];
681
+ for (const short of [...Object.keys(FIRST_PARTY_FALLBACKS), ...Object.keys(KNOWN_MODULES)]) {
682
+ if (seen.has(short)) continue;
683
+ seen.add(short);
684
+ out.push(short);
685
+ }
686
+ return out;
687
+ }
688
+
689
+ /** True iff `short` is a module the hub can resolve a package/manifest for
690
+ * (the install-path gate, replacing the `CURATED_MODULES` whitelist). */
691
+ export function isKnownModuleShort(short: string): boolean {
692
+ return short in FIRST_PARTY_FALLBACKS || short in KNOWN_MODULES;
693
+ }
694
+
628
695
  /**
629
696
  * Canonical port assignment for a known short name, or `undefined` for
630
697
  * third-party services we don't have a fallback for. Drives the
@@ -656,12 +723,12 @@ export function canonicalPortForManifest(manifestName: string): number | undefin
656
723
  /**
657
724
  * Resolve the runtime spec for a known short name.
658
725
  *
659
- * FIRST_PARTY_FALLBACKS shorts (notes / channel) return a fully-composed
726
+ * FIRST_PARTY_FALLBACKS shorts (notes) return a fully-composed
660
727
  * spec with embedded manifest + extras — the vendored manifest is the
661
728
  * source of truth pre-install and the install path preserves it through.
662
729
  *
663
- * KNOWN_MODULES shorts (vault / scribe / runner — post hub#310 FALLBACK
664
- * retirement) return a **minimal** spec carrying `package`, `manifestName`,
730
+ * KNOWN_MODULES shorts (vault / scribe / runner / channel / surface — post
731
+ * FALLBACK retirement) return a **minimal** spec carrying `package`, `manifestName`,
665
732
  * and the imperative `extras` fields
666
733
  * (`init`, `hasAuth`, `urlForEntry`, `postInstallFooter`). They do NOT carry
667
734
  * `startCmd` or `seedEntry` — those come from `<installDir>/.parachute/module.json`
@@ -760,9 +827,9 @@ const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
760
827
  };
761
828
 
762
829
  /** Short name for a given manifest name, e.g. `parachute-vault` → `vault`.
763
- * Consults both FIRST_PARTY_FALLBACKS (notes / channel) and KNOWN_MODULES
764
- * (vault / scribe / runner — post-FALLBACK-retirement). Returns undefined
765
- * for unknown manifests. */
830
+ * Consults both FIRST_PARTY_FALLBACKS (notes) and KNOWN_MODULES
831
+ * (vault / scribe / runner / channel / surface — post-FALLBACK-retirement).
832
+ * Returns undefined for unknown manifests. */
766
833
  export function shortNameForManifest(manifestName: string): string | undefined {
767
834
  for (const [short, fb] of Object.entries(FIRST_PARTY_FALLBACKS)) {
768
835
  if (fb.manifest.manifestName === manifestName) return short;
@@ -773,6 +840,30 @@ export function shortNameForManifest(manifestName: string): string | undefined {
773
840
  return LEGACY_MANIFEST_ALIASES[manifestName];
774
841
  }
775
842
 
843
+ /**
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`.
846
+ *
847
+ * services.json rows carry the MANIFEST name, not the bare short — so a direct
848
+ * `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
851
+ * connection sink. Resolve through the short↔manifest map instead. Returns the
852
+ * first match, or undefined when no installed row maps to `short`.
853
+ *
854
+ * NOTE: multi-instance vault rows (`parachute-vault-<name>`) are NOT resolvable
855
+ * here — `shortNameForManifest` only knows the canonical `parachute-vault`, so
856
+ * `findServiceByShort(services, "vault")` returns undefined even when a vault is
857
+ * installed. Vault rows are resolved by mount path via `findVaultUpstream`; this
858
+ * helper is for single-instance modules (channel / scribe / runner / surface).
859
+ */
860
+ export function findServiceByShort<T extends { name: string }>(
861
+ services: readonly T[],
862
+ short: string,
863
+ ): T | undefined {
864
+ return services.find((s) => shortNameForManifest(s.name) === short);
865
+ }
866
+
776
867
  /**
777
868
  * Compose a `ServiceSpec` from a `KNOWN_MODULES` entry plus the static
778
869
  * manifest data the caller has on hand (typically read from
@@ -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
 
@@ -199,6 +199,16 @@ export interface DerivedWizardState {
199
199
  hasAdmin: boolean;
200
200
  /** Whether the first vault (curated) has been provisioned in services.json. */
201
201
  hasVault: boolean;
202
+ /**
203
+ * Whether a REAL vault instance exists (a non-placeholder services.json
204
+ * row) — `hasVault` minus the `setup_vault_skipped` marker. The
205
+ * re-enterable vault step (B5, 2026-06-09 hub-module-boundary) keys on
206
+ * this: an operator who skipped the wizard's vault step has
207
+ * `hasVault === true` but `hasRealVault === false`, and the
208
+ * `/admin/setup?step=vault` deep-link (Home's "create your first vault"
209
+ * card) must still reach the create/import form.
210
+ */
211
+ hasRealVault: boolean;
202
212
  /**
203
213
  * Whether the operator has answered the "how will this hub be reached?"
204
214
  * question (the expose step, hub#268 Item 2). When admin + vault both
@@ -290,6 +300,14 @@ export function deriveWizardState(deps: {
290
300
  // phantom `vaults[]` row at SEED_VERSION); both surfaces must agree that a
291
301
  // placeholder is not a real vault.
292
302
  const vaultIsPlaceholder = vaultEntry !== undefined && vaultEntry.version === SEED_VERSION;
303
+ // INVARIANT (B5 re-enterable vault step): hasRealVault means "a real
304
+ // instance row exists" — placeholder excluded here, skip-marker excluded
305
+ // below (skip flips hasVault, never hasRealVault). THREE sites key on this
306
+ // same placeholder logic and must move together: this derivation,
307
+ // handleSetupGet's `?step=vault` re-entry gate, and handleSetupVaultPost's
308
+ // already-provisioned short-circuit. Changing one without the others
309
+ // either re-opens a provisioning form over a real vault or dead-ends the
310
+ // post-skip re-entry path.
293
311
  const hasRealVault = vaultEntry !== undefined && !vaultIsPlaceholder;
294
312
  // hub#168 Cut 2: `setup_vault_skipped === "true"` advances the wizard
295
313
  // past the vault step even when no vault row exists. The operator
@@ -339,7 +357,7 @@ export function deriveWizardState(deps: {
339
357
  else if (!hasVault) step = "vault";
340
358
  else if (!hasExposeMode) step = "expose";
341
359
  else step = "done";
342
- return { step, hasAdmin, hasVault, hasExposeMode };
360
+ return { step, hasAdmin, hasVault, hasRealVault, hasExposeMode };
343
361
  }
344
362
 
345
363
  // --- handler types -------------------------------------------------------
@@ -724,7 +742,7 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
724
742
  <h2>What's next</h2>
725
743
  <p>You'll land on a success screen with copy-paste MCP install
726
744
  instructions for Claude Code and a link to the admin UI, where
727
- you can rename or add additional vaults.</p>
745
+ you can add more vaults.</p>
728
746
  </section>
729
747
  <section class="preview">
730
748
  <p class="preview-label">About to create</p>
@@ -735,8 +753,8 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
735
753
  </div>
736
754
  <p class="preview-fine">
737
755
  The name shows up in the MCP URL (<code>/vault/&lt;name&gt;/mcp</code>)
738
- and on the admin UI. You can rename or add vaults later from
739
- <code>/admin/vaults</code>.
756
+ and on the admin UI. You can add or manage vaults later from the
757
+ vault module's own admin at <code>/vault/admin/</code>.
740
758
  </p>
741
759
  </section>
742
760
  ${error}
@@ -1696,6 +1714,41 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1696
1714
  };
1697
1715
  if (csrf.setCookie) extraHeaders["set-cookie"] = csrf.setCookie;
1698
1716
 
1717
+ // Re-enterable vault step (B5, 2026-06-09 hub-module-boundary migration).
1718
+ // A wizard-skip leaves the vault MODULE installed with no instances and no
1719
+ // daemon (hub#607's zero-instances state) — and `setup_vault_skipped`
1720
+ // makes `hasVault` true, so a plain GET resumes at expose/done and the
1721
+ // create form is unreachable. The hub-side "create your first vault"
1722
+ // affordance (Home's vault card + the legacy /admin/vaults empty state)
1723
+ // deep-links `?step=vault`, which re-enters the create/import form as long
1724
+ // as no REAL vault instance exists. Session-gated: post-account the box
1725
+ // has an admin, and re-opening a provisioning form to a drive-by GET
1726
+ // would leak setup state (the POST is session+CSRF-gated either way).
1727
+ // With a real vault present the param is ignored and the normal flow
1728
+ // (expose step / 301 → /login) runs.
1729
+ //
1730
+ // INVARIANT: this gate keys on `hasRealVault` (deriveWizardState) — the
1731
+ // same placeholder logic handleSetupVaultPost's short-circuit uses. The
1732
+ // three sites must move together; see the derivation comment in
1733
+ // deriveWizardState.
1734
+ if (url.searchParams.get("step") === "vault" && state.hasAdmin && !state.hasRealVault) {
1735
+ const session = findActiveSession(deps.db, req);
1736
+ if (!session) {
1737
+ // Preserve the CSRF set-cookie across the bounce — same shape as the
1738
+ // `?just_finished=1` session gate below.
1739
+ const redirectHeaders: Record<string, string> = {
1740
+ location: `/login?next=${encodeURIComponent("/admin/setup?step=vault")}`,
1741
+ };
1742
+ if (csrf.setCookie) redirectHeaders["set-cookie"] = csrf.setCookie;
1743
+ return new Response(null, { status: 302, headers: redirectHeaders });
1744
+ }
1745
+ const cloudHost = detectAutoExposeMode(deps.env ?? process.env) === "public";
1746
+ return new Response(renderVaultStep({ csrfToken: csrf.token, cloudHost }), {
1747
+ status: 200,
1748
+ headers: extraHeaders,
1749
+ });
1750
+ }
1751
+
1699
1752
  // Setup fully complete (including expose-mode choice) — redirect to
1700
1753
  // /login unless we're rendering the success page once. The success
1701
1754
  // page sets `?just_finished=1` and the session cookie is on the
@@ -2168,9 +2221,18 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
2168
2221
  "Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
2169
2222
  );
2170
2223
  }
2171
- // Already done — short-circuit to the done step.
2224
+ // Already done — short-circuit to the done step. Keyed on hasRealVault
2225
+ // (NOT hasVault): the `setup_vault_skipped` marker satisfies hasVault, but
2226
+ // a skipped box has no instance and the re-entered vault step (B5,
2227
+ // `?step=vault`) must be able to POST create/import — `mode=create` below
2228
+ // clears the skip marker; `mode=skip` just re-sets it (idempotent).
2229
+ //
2230
+ // INVARIANT: same placeholder logic as deriveWizardState's hasRealVault
2231
+ // derivation and handleSetupGet's `?step=vault` re-entry gate — the three
2232
+ // sites must move together; see the derivation comment in
2233
+ // deriveWizardState.
2172
2234
  const state = deriveWizardState(deps);
2173
- if (state.hasVault) {
2235
+ if (state.hasRealVault) {
2174
2236
  if (form.isJson) {
2175
2237
  return jsonOkResponse({ step: "expose", message: "vault already provisioned" });
2176
2238
  }
package/src/users.ts CHANGED
@@ -466,6 +466,17 @@ export function setUserVaults(
466
466
  return true;
467
467
  }
468
468
 
469
+ /**
470
+ * Vault-delete cascade step (B1, 2026-06-09 hub-module-boundary): drop every
471
+ * `user_vaults` assignment row for the deleted vault, across all users.
472
+ * Exact `=` comparison on `vault_name` — no pattern matching. Returns the
473
+ * number of rows deleted.
474
+ */
475
+ export function removeVaultAssignments(db: Database, vaultName: string): number {
476
+ const res = db.prepare("DELETE FROM user_vaults WHERE vault_name = ?").run(vaultName);
477
+ return Number(res.changes);
478
+ }
479
+
469
480
  /**
470
481
  * Updates the password for an existing user. Throws `UserNotFoundError` if
471
482
  * the id has no row. Single-user-mode flows look up by username first and