@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
package/src/hub-server.ts CHANGED
@@ -15,7 +15,8 @@
15
15
  *
16
16
  * # Pre-rename 301 back-compat (hub#231 — first so they preempt any
17
17
  * # remaining handlers under /vault or /hub).
18
- * /vault, /vault/, /vault/new → 301 → /admin/vaults[/new]
18
+ * /vault, /vault/, /vault/new → 301 → /vault/admin/ (B5:
19
+ * vault's daemon-level admin)
19
20
  * /hub/vaults* → 301 → /admin/vaults*
20
21
  * /hub/permissions → 301 → /admin/permissions
21
22
  * /hub/tokens → 301 → /admin/tokens
@@ -43,8 +44,25 @@
43
44
  *
44
45
  * # Admin API + bearer-mint surfaces (must precede /admin/* SPA mount).
45
46
  * /vaults (POST) → create vault
47
+ * /vaults/<name> (DELETE) → destroy vault + identity cascade
48
+ * (B1: confirm body, host:admin,
49
+ * tokens/grants/user_vaults/invites/
50
+ * connections sweep, CLI remove,
51
+ * supervisor restart)
46
52
  * /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
47
53
  * /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
54
+ * /admin/channel-token (GET) → channel UI bearer mint (cookie-gated)
55
+ * /admin/module-token/<short> (GET) → generic module config-UI bearer mint <short>:admin (cookie-gated)
56
+ * /api/connections/catalog (GET) → events/actions across installed modules (cookie-gated)
57
+ * /admin/connections (POST/GET) → connection provision/list (cookie-gated; POST CSRF-belted)
58
+ * /admin/connections/<id> (DELETE) → connection teardown (cookie-gated; CSRF-belted)
59
+ * /admin/connections/<id>/renew (POST) → credential renewal (H4; Bearer = the credential itself, proof of possession)
60
+ * /admin/connections/<id>/claim (POST) → claim/reconcile a directly-delivered credential → pending record (surface#113; Bearer = the credential itself)
61
+ * /admin/connections/<id>/approve (POST) → operator approval of a pending claim (cookie-gated; CSRF-belted)
62
+ *
63
+ * # "CSRF-belted" = strict same-origin Origin check on cookie-authed
64
+ * # mutations (hub#632, boundary C1) — origin-check.ts
65
+ * # `assertSameOriginForCookieMutation` carries the canonical enumeration.
48
66
  * /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
49
67
  * /api/hub (GET) → hub version + uptime + install-source (host:admin)
50
68
  * /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
@@ -94,6 +112,11 @@
94
112
  * /admin/config* → 301 → /admin/vaults (legacy
95
113
  * portal retired post-SPA-rework)
96
114
  *
115
+ * # Vault MODULE daemon-level admin surface (B-route, 2026-06-09
116
+ * # hub-module-boundary). Runs BEFORE the per-vault proxy; "admin" is a
117
+ * # reserved vault name so no instance can claim the mount.
118
+ * /vault/admin, /vault/admin/* → proxy to the vault module's daemon
119
+ *
97
120
  * # Per-vault content proxy (user-facing vault data: Notes PWA, MCP, etc.).
98
121
  * /vault/<name>/* → proxy to the vault backend
99
122
  *
@@ -130,7 +153,13 @@ import pkg from "../package.json" with { type: "json" };
130
153
  import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
131
154
  import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
132
155
  import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
156
+ import { handleChannelToken } from "./admin-channel-token.ts";
133
157
  import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
158
+ import {
159
+ type ConnectionsDeps,
160
+ handleConnections,
161
+ handleConnectionsCatalog,
162
+ } from "./admin-connections.ts";
134
163
  import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
135
164
  import {
136
165
  handleAdminLoginGet,
@@ -139,8 +168,9 @@ import {
139
168
  handleAdminLogoutPost,
140
169
  } from "./admin-handlers.ts";
141
170
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
171
+ import { handleModuleToken } from "./admin-module-token.ts";
142
172
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
143
- import { handleCreateVault } from "./admin-vaults.ts";
173
+ import { handleCreateVault, handleDeleteVault } from "./admin-vaults.ts";
144
174
  import {
145
175
  handleAccountChangePasswordGet,
146
176
  handleAccountChangePasswordPost,
@@ -151,7 +181,6 @@ import { handleApiHub } from "./api-hub.ts";
151
181
  import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "./api-invites.ts";
152
182
  import { handleApiMe } from "./api-me.ts";
153
183
  import { handleApiMintToken } from "./api-mint-token.ts";
154
- import { handleApiModulesConfig, parseModulesConfigPath } from "./api-modules-config.ts";
155
184
  import {
156
185
  getDefaultOperationsRegistry,
157
186
  handleInstall,
@@ -178,7 +207,12 @@ import {
178
207
  handleResetUserPassword,
179
208
  handleUpdateUserVaults,
180
209
  } from "./api-users.ts";
181
- import { buildChromeForRequest, injectChromeIntoResponse } from "./chrome-strip.ts";
210
+ import { gateUiAudience, resolveUiMount } from "./audience-gate.ts";
211
+ import {
212
+ CHROME_OPT_OUT_PREFIXES,
213
+ buildChromeForRequest,
214
+ injectChromeIntoResponse,
215
+ } from "./chrome-strip.ts";
182
216
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
183
217
  import { applyCorsHeaders, corsPreflightResponse, isCorsAllowedRoute } from "./cors.ts";
184
218
  import { ensureCsrfToken } from "./csrf.ts";
@@ -211,7 +245,7 @@ import {
211
245
  protectedResourceMetadata,
212
246
  } from "./oauth-handlers.ts";
213
247
  import { renderNotFoundPage } from "./oauth-ui.ts";
214
- import { buildHubBoundOrigins } from "./origin-check.ts";
248
+ import { assertSameOriginForCookieMutation, buildHubBoundOrigins } from "./origin-check.ts";
215
249
  import { clearPid, writePid } from "./process-state.ts";
216
250
  import { toResponse as proxyErrorToResponse, renderProxyError } from "./proxy-error-ui.ts";
217
251
  import { classifyUpstream } from "./proxy-state.ts";
@@ -220,6 +254,7 @@ import {
220
254
  FIRST_PARTY_FALLBACKS,
221
255
  KNOWN_MODULES,
222
256
  effectivePublicExposure,
257
+ findServiceByShort,
223
258
  shortNameForManifest,
224
259
  } from "./service-spec.ts";
225
260
  import { type ServiceEntry, readManifest, readManifestLenient } from "./services-manifest.ts";
@@ -243,6 +278,8 @@ import {
243
278
  isVaultEntry,
244
279
  vaultInstanceNameFor,
245
280
  } from "./well-known.ts";
281
+ import { type WsBridgeData, createWsBridgeHandlers } from "./ws-bridge.ts";
282
+ import { type WsConnectionTracker, defaultWsConnectionTracker } from "./ws-connection-caps.ts";
246
283
 
247
284
  interface Args {
248
285
  port: number;
@@ -412,6 +449,62 @@ function hasVaultInstalled(manifestPath: string): boolean {
412
449
  }
413
450
  }
414
451
 
452
+ /**
453
+ * Snapshot of every installed module's `.parachute/module.json` + its
454
+ * user-facing mount path, for the Connections engine (2026-06-09 modular-UI
455
+ * architecture, P5). Read at request time so a freshly-installed module's
456
+ * declared events/actions surface without a hub restart. A malformed manifest
457
+ * on one module is skipped (logged), never 500s the whole catalog — same
458
+ * posture as `/api/modules`.
459
+ *
460
+ * `mount` is the first non-`.parachute` services.json path (the proxied
461
+ * user-facing prefix, e.g. `/channel`), which the engine joins with a sink
462
+ * action's `endpoint` to build the hub-proxied webhook.
463
+ */
464
+ async function collectInstalledModules(
465
+ manifestPath: string,
466
+ readManifestFn: (installDir: string) => Promise<ModuleManifest | null>,
467
+ ): Promise<import("./admin-connections.ts").InstalledModuleInfo[]> {
468
+ // Lenient — see hub#406.
469
+ const services = readManifestLenient(manifestPath).services;
470
+ const out: import("./admin-connections.ts").InstalledModuleInfo[] = [];
471
+ await Promise.all(
472
+ services.map(async (entry) => {
473
+ if (!entry.installDir) return;
474
+ const short = shortNameForManifest(entry.name) ?? entry.name;
475
+ const userPath = (entry.paths ?? []).find(
476
+ (p) => p !== "/.parachute" && !p.startsWith("/.parachute/"),
477
+ );
478
+ try {
479
+ const manifest = await readManifestFn(entry.installDir);
480
+ if (!manifest) return;
481
+ out.push({ short, manifest, mount: userPath ?? null });
482
+ } catch (err) {
483
+ const msg = err instanceof Error ? err.message : String(err);
484
+ console.warn(`connections: skipping module ${short}: ${msg}`);
485
+ }
486
+ }),
487
+ );
488
+ return out;
489
+ }
490
+
491
+ /**
492
+ * Resolve a module's loopback origin by SHORT name from services.json — the
493
+ * H4 credential-delivery seam (the Connections engine POSTs minted
494
+ * credentials + removal payloads direct to the daemon, not through the hub
495
+ * proxy). Short derivation mirrors `collectInstalledModules`:
496
+ * `shortNameForManifest(name) ?? name`, so third-party modules (whose row
497
+ * name IS their short) resolve too. Read per-request — a module installed
498
+ * seconds ago is deliverable without a hub restart.
499
+ */
500
+ function makeResolveModuleOrigin(manifestPath: string): (short: string) => string | null {
501
+ return (short) => {
502
+ const services = readManifestLenient(manifestPath).services;
503
+ const entry = services.find((s) => (shortNameForManifest(s.name) ?? s.name) === short);
504
+ return entry ? `http://127.0.0.1:${entry.port}` : null;
505
+ };
506
+ }
507
+
415
508
  /**
416
509
  * The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
417
510
  * every request reaches it via one of three trusted forwarders (or directly
@@ -498,6 +591,125 @@ function isLoopbackPeer(peerAddr: string | null | undefined): boolean {
498
591
  );
499
592
  }
500
593
 
594
+ /**
595
+ * The two substrate trust headers the hub stamps on every forwarded request
596
+ * (H2, surface-runtime-primitives design §10):
597
+ *
598
+ * X-Parachute-Layer — the `layerOf` classification ("loopback" |
599
+ * "tailnet" | "public"), fail-closed to "public"
600
+ * when the peer address is unknown.
601
+ * X-Parachute-Client-IP — the resolved client IP (CF-Connecting-IP →
602
+ * X-Forwarded-For first hop → peer address; same
603
+ * precedence as rate-limit.ts `clientIpFromRequest`,
604
+ * with the peer address as the direct-caller floor).
605
+ *
606
+ * Backends (surface-host's `ctx.layer` / `ctx.clientIp`, any module reading
607
+ * trust signals) consume THESE, never raw forwarder headers — the hub is the
608
+ * only component that can see the actual peer socket, so it's the only place
609
+ * the classification can be made fail-closed.
610
+ */
611
+ export const PARACHUTE_LAYER_HEADER = "x-parachute-layer";
612
+ export const PARACHUTE_CLIENT_IP_HEADER = "x-parachute-client-ip";
613
+
614
+ /**
615
+ * Resolve the client IP for the X-Parachute-Client-IP stamp. Precedence:
616
+ *
617
+ * 1. `CF-Connecting-IP` — cloudflared stamps the actual client IP on every
618
+ * forwarded request (authoritative on cloudflare-fronted hubs).
619
+ * 2. `X-Forwarded-For` first hop — tailscale serve/funnel and generic
620
+ * reverse proxies set it; the leftmost entry is the original client.
621
+ * 3. The peer address itself — the direct caller (loopback CLI, or a
622
+ * direct network peer on a 0.0.0.0 bind).
623
+ *
624
+ * Returns null when nothing resolves (no forwarder headers AND no peer
625
+ * address — e.g. a unit test calling the fetch fn without a Server). The
626
+ * caller omits the header in that case; backends treat absence as null.
627
+ *
628
+ * Known limitation (same as the rate-limiter's keying): a DIRECT caller can
629
+ * spoof the forwarded-IP headers and misattribute its own address. It cannot
630
+ * spoof the LAYER (layerOf classifies direct non-loopback peers as "public"
631
+ * regardless of injected headers), so the trust signal stays sound — only
632
+ * the attribution string is best-effort for direct callers.
633
+ */
634
+ export function resolveClientIp(req: Request, peerAddr: string | null): string | null {
635
+ const cf = req.headers.get("cf-connecting-ip")?.trim();
636
+ if (cf) return cf;
637
+ const xff = req.headers.get("x-forwarded-for");
638
+ if (xff) {
639
+ const first = xff.split(",")[0]?.trim();
640
+ if (first) return first;
641
+ }
642
+ const peer = peerAddr?.trim();
643
+ return peer ? peer : null;
644
+ }
645
+
646
+ /**
647
+ * Strip any inbound occurrences of the substrate trust headers, then stamp
648
+ * the hub's own classification. The strip is load-bearing: a public client
649
+ * sending `X-Parachute-Layer: loopback` (or a forged client IP) must never
650
+ * ride that injection past the proxy into a module that keys trust off it.
651
+ * Mutates `headers` in place (the proxy's outgoing header bag).
652
+ */
653
+ export function stampSubstrateTrustHeaders(
654
+ headers: Headers,
655
+ req: Request,
656
+ peerAddr: string | null,
657
+ ): void {
658
+ headers.delete(PARACHUTE_LAYER_HEADER);
659
+ headers.delete(PARACHUTE_CLIENT_IP_HEADER);
660
+ headers.set(PARACHUTE_LAYER_HEADER, layerOf(req, peerAddr));
661
+ const clientIp = resolveClientIp(req, peerAddr);
662
+ if (clientIp) headers.set(PARACHUTE_CLIENT_IP_HEADER, clientIp);
663
+ }
664
+
665
+ /**
666
+ * Shared bucket for connections whose client IP cannot be derived at all
667
+ * (no forwarder headers AND no peer address). Fail-closed: they all contend
668
+ * for one per-IP allotment rather than each minting a fresh bucket — the
669
+ * same posture as rate-limit.ts's UNKNOWN_IP_SENTINEL.
670
+ */
671
+ export const WS_CAP_SHARED_BUCKET = "unknown";
672
+
673
+ /**
674
+ * Derive the connection-cap bucket key for a WS upgrade (hub#649).
675
+ *
676
+ * STRICTER than {@link resolveClientIp} on purpose. The H2 attribution stamp
677
+ * tolerates a direct caller misattributing itself (documented limitation —
678
+ * the LAYER stays truthful, only the attribution string is best-effort). A
679
+ * cap key cannot afford that tolerance: if a direct peer's forged
680
+ * X-Forwarded-For were believed, rotating the header would mint a fresh
681
+ * bucket per connection and the per-IP cap would never trip. So forwarded
682
+ * IP headers are believed ONLY when the peer is loopback — the hub's actual
683
+ * forwarder topology (cloudflared, tailscale serve/funnel) runs on-box and
684
+ * dials 127.0.0.1; nothing else legitimately presents those headers from
685
+ * loopback, and a remote attacker can't BE loopback.
686
+ *
687
+ * - loopback peer: CF-Connecting-IP → X-Forwarded-For first hop → the
688
+ * loopback address itself (direct local callers share one bucket —
689
+ * owner-operated, and the cap is configurable).
690
+ * - non-loopback peer: the peer address, regardless of injected headers
691
+ * (spoofed XFF on an untrusted layer lands in the spoofer's own bucket).
692
+ * - no peer derivable: {@link WS_CAP_SHARED_BUCKET} (fail closed).
693
+ *
694
+ * Known limitation, container deploys (Render / Fly): the platform edge
695
+ * dials from a private non-loopback address, so all public clients share
696
+ * the edge peer's bucket there — the per-IP cap degrades to a coarse shared
697
+ * cap and the global cap is the operative bound. Raise
698
+ * PARACHUTE_WS_MAX_PER_IP on such deploys; a trusted-proxy allowlist can
699
+ * refine this when a cloud WS surface actually ships.
700
+ */
701
+ export function wsCapBucketKey(req: Request, peerAddr: string | null): string {
702
+ const peer = peerAddr?.trim() || null;
703
+ if (peer && isLoopbackPeer(peer)) {
704
+ const cf = req.headers.get("cf-connecting-ip")?.trim();
705
+ if (cf) return cf;
706
+ const xff = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
707
+ if (xff) return xff;
708
+ return peer;
709
+ }
710
+ return peer ?? WS_CAP_SHARED_BUCKET;
711
+ }
712
+
501
713
  /**
502
714
  * Forward a request to a loopback service on `127.0.0.1:<port>`. By default
503
715
  * the incoming pathname + query are preserved verbatim; pass `targetPath` to
@@ -529,10 +741,17 @@ function isLoopbackPeer(peerAddr: string | null | undefined): boolean {
529
741
  * `short` is the canonical short (`vault`/`scribe`/`notes`) — used as
530
742
  * the supervisor map key + pidfile directory key for classification.
531
743
  *
532
- * Hop-by-hop notes: WebSocket upgrades and HTTP/2 trailers don't traverse
533
- * fetch-based proxies cleanly. No on-box service uses either today; if one
534
- * eventually needs them, switch to a Node http.IncomingMessage / http.request
535
- * pair.
744
+ * `peerAddr` is the resolved peer address (`server.requestIP`), threaded so
745
+ * the substrate trust headers (below) classify the layer the same way the
746
+ * `publicExposure` cloak does fail-closed to `public` when unknown.
747
+ *
748
+ * Hop-by-hop notes: HTTP/2 trailers don't traverse fetch-based proxies
749
+ * cleanly; no on-box service uses them today. WebSocket upgrades CANNOT
750
+ * traverse this fetch-based path either — they're handled BEFORE dispatch by
751
+ * the Bun-native upgrade bridge (H1: `maybeUpgradeWebSocket` +
752
+ * `src/ws-bridge.ts`) for modules that declare the capability; an upgrade
753
+ * request reaching this function belongs to a non-declaring mount and the
754
+ * upstream sees a plain GET.
536
755
  */
537
756
  async function proxyRequest(
538
757
  req: Request,
@@ -540,6 +759,7 @@ async function proxyRequest(
540
759
  serviceLabel: string,
541
760
  short: string,
542
761
  supervisor: Supervisor | undefined,
762
+ peerAddr: string | null,
543
763
  targetPath?: string,
544
764
  ): Promise<Response> {
545
765
  const url = new URL(req.url);
@@ -585,6 +805,11 @@ async function proxyRequest(
585
805
  if (!headers.has("x-forwarded-proto")) {
586
806
  headers.set("x-forwarded-proto", isHttpsRequest(req) ? "https" : "http");
587
807
  }
808
+ // Substrate trust headers (H2, surface-runtime design §10): stamped on
809
+ // EVERY forwarded request so module backends read trust signals from the
810
+ // substrate instead of re-deriving them from raw forwarder headers (the
811
+ // "header-absence = local trust" anti-pattern the design rejects).
812
+ stampSubstrateTrustHeaders(headers, req, peerAddr);
588
813
 
589
814
  const init: RequestInit & { duplex?: "half" } = {
590
815
  method: req.method,
@@ -695,7 +920,51 @@ async function proxyToVault(
695
920
  // vault instances share the same supervisor key under hub's current
696
921
  // single-vault-per-hub model; if multi-vault-per-hub ever ships, the
697
922
  // classifier will need a per-instance key.
698
- return proxyRequest(req, match.port, "vault", "vault", supervisor, targetPath);
923
+ return proxyRequest(req, match.port, "vault", "vault", supervisor, peerAddr, targetPath);
924
+ }
925
+
926
+ /**
927
+ * Reverse-proxy `/vault/admin` + `/vault/admin/*` to the vault MODULE's
928
+ * daemon — the daemon-level multi-vault admin surface (B-route, 2026-06-09
929
+ * hub-module-boundary migration), NOT a per-instance path.
930
+ *
931
+ * Resolution is via `findServiceByShort(services, "vault")` (the canonical
932
+ * self-registered `parachute-vault` row — same shape as the channelEntry
933
+ * lookup in the Connections deps), deliberately NOT `findVaultUpstream`:
934
+ * vault must NOT self-register `/vault/admin` in `paths[]`, because every
935
+ * consumer that derives instance names from paths (`vaultInstanceNameFor`,
936
+ * the well-known vaults[] fan-out, `findExistingVault`, the mint
937
+ * allowlists, the users vault-picker) would fabricate a phantom vault named
938
+ * "admin". The mount is hub-owned and gated on the B2h `admin` name
939
+ * reservation, so no real instance can ever claim it.
940
+ *
941
+ * Applies the SAME `publicExposure: "loopback"` 404-cloak as `proxyToVault`
942
+ * (the per-vault proxy's only layer-gate; "allowed"/"auth-required" pass
943
+ * through and the daemon self-gates). Vault's row declares no `stripPrefix`
944
+ * — the FULL path forwards, so the daemon's own `/vault/admin` routing
945
+ * branch (vault wave, B3) serves it.
946
+ *
947
+ * Returns `undefined` when no vault module is installed (caller 404s).
948
+ * NOTE: legacy per-instance rows named `parachute-vault-<name>` don't
949
+ * resolve through `findServiceByShort` (it only knows the canonical
950
+ * manifest name) — on such an install this surface 404s until vault's boot
951
+ * selfRegister rewrites the canonical row, which is the documented
952
+ * old-install degradation, not a routing hole.
953
+ */
954
+ async function proxyToVaultAdmin(
955
+ req: Request,
956
+ manifestPath: string,
957
+ supervisor: Supervisor | undefined,
958
+ peerAddr: string | null,
959
+ ): Promise<Response | undefined> {
960
+ // Lenient — see hub#406 (same posture as proxyToVault).
961
+ const services = readManifestLenient(manifestPath).services;
962
+ const entry = findServiceByShort(services, "vault");
963
+ if (!entry) return undefined;
964
+ if (effectivePublicExposure(entry) === "loopback" && layerOf(req, peerAddr) !== "loopback") {
965
+ return new Response("not found", { status: 404 });
966
+ }
967
+ return proxyRequest(req, entry.port, "vault", "vault", supervisor, peerAddr);
699
968
  }
700
969
 
701
970
  /**
@@ -784,15 +1053,15 @@ async function proxyToService(
784
1053
  ) {
785
1054
  return new Response("not found", { status: 404 });
786
1055
  }
787
- // Consult FIRST_PARTY_FALLBACKS as a fallback for `stripPrefix` (#196).
788
- // Pre-hub#310, scribe's `stripPrefix: true` lived only in hub's vendored
789
- // fallback; post-#310 scribe self-registers with `stripPrefix: true` on
790
- // its row, so the entry-based path is authoritative for scribe. The
791
- // fallback consultation now matters only for notes / channel
792
- // (FIRST_PARTY_FALLBACKS shorts that haven't yet self-registered with
793
- // the canonical declaration). Explicit-on-entry still wins; absent →
794
- // fallback → false (preserving the keep-prefix default for unknown
795
- // services).
1056
+ // Consult FIRST_PARTY_FALLBACKS / KNOWN_MODULES as a fallback for
1057
+ // `stripPrefix` (#196). Pre-hub#310, scribe's `stripPrefix: true` lived
1058
+ // only in hub's vendored fallback; post-#310 scribe (and post-D3 channel)
1059
+ // self-register with `stripPrefix: true` on their rows, so the entry-based
1060
+ // path is authoritative. The registry consultation now matters only for
1061
+ // notes (the remaining FALLBACK short) and legacy rows written before the
1062
+ // module emitted the field (KNOWN_MODULES canonicalStripPrefix).
1063
+ // Explicit-on-entry still wins; absent → fallback → false (preserving the
1064
+ // keep-prefix default for unknown services).
796
1065
  const stripPrefix = stripPrefixFor(match.entry);
797
1066
  const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
798
1067
  // Resolve canonical short for classification — falls back to the
@@ -801,20 +1070,21 @@ async function proxyToService(
801
1070
  // will land in "persistent" by default which is the safer choice for
802
1071
  // unknown lifecycle).
803
1072
  const short = shortNameForManifest(match.entry.name) ?? match.entry.name;
804
- return proxyRequest(req, match.port, match.entry.name, short, supervisor, targetPath);
1073
+ return proxyRequest(req, match.port, match.entry.name, short, supervisor, peerAddr, targetPath);
805
1074
  }
806
1075
 
807
1076
  /**
808
1077
  * Resolve effective `stripPrefix` for a service entry. Explicit on-entry
809
1078
  * wins; otherwise consult `FIRST_PARTY_FALLBACKS` keyed by short name (for
810
- * notes / channel — vault/scribe/runner retired their FALLBACK entries in
811
- * hub#310 and self-register with the canonical `stripPrefix` declaration on
812
- * their services.json row). `KNOWN_MODULES[short]?.canonicalStripPrefix`
813
- * is the next fallback — covers the edge case where a self-registering
814
- * module wrote its row before the `stripPrefix` field was being emitted
815
- * (e.g. pre-scribe#50 services.json rows). Defaults to `false` — keep the
816
- * prefix matching the pre-#196 dispatch behavior for unknown / third-
817
- * party services.
1079
+ * notes — vault/scribe/runner retired their FALLBACK entries in hub#310,
1080
+ * channel in boundary D3; all self-register with the canonical `stripPrefix`
1081
+ * declaration on their services.json row).
1082
+ * `KNOWN_MODULES[short]?.canonicalStripPrefix` is the next fallback — covers
1083
+ * the edge case where a self-registering module wrote its row before the
1084
+ * `stripPrefix` field was being emitted (e.g. pre-scribe#50 or pre-D3
1085
+ * channel services.json rows). Defaults to `false` keep the prefix
1086
+ * matching the pre-#196 dispatch behavior for unknown / third-party
1087
+ * services.
818
1088
  *
819
1089
  * For a self-registering KNOWN_MODULES short whose row is missing entirely
820
1090
  * (uninstalled, never booted), the request never reaches this code path —
@@ -873,6 +1143,11 @@ export interface HubFetchDeps {
873
1143
  * the doc reflect `parachute vault create` etc. without re-running expose.
874
1144
  */
875
1145
  manifestPath?: string;
1146
+ /**
1147
+ * Path to `connections.json` (the Connections store, P5). Tests point this
1148
+ * at a tmpdir; production defaults to `<CONFIG_DIR>/connections.json`.
1149
+ */
1150
+ connectionsStorePath?: string;
876
1151
  /**
877
1152
  * Directory containing the built SPA bundle (`index.html` + `assets/`). When
878
1153
  * absent, the hub auto-resolves to `<repo>/web/ui/dist/` — handy for the
@@ -922,6 +1197,15 @@ export interface HubFetchDeps {
922
1197
  * CLI commands directly.
923
1198
  */
924
1199
  supervisor?: Supervisor;
1200
+ /**
1201
+ * WebSocket connection-cap accounting (hub#649). Production uses the
1202
+ * process-wide {@link defaultWsConnectionTracker} (caps from env at boot);
1203
+ * tests inject their own tracker so they neither consume nor depend on the
1204
+ * shared counters. Release pairing is structural — the acquire site stashes
1205
+ * the release closure on the upgraded socket's `data`, so a mismatched
1206
+ * tracker between fetch fn and bridge handlers is impossible.
1207
+ */
1208
+ wsConnectionTracker?: WsConnectionTracker;
925
1209
  }
926
1210
 
927
1211
  /**
@@ -1024,8 +1308,9 @@ function defaultSpaDistDir(): string {
1024
1308
  * The admin SPA serves at a single mount: `/admin/*` (since hub#231).
1025
1309
  *
1026
1310
  * Routes:
1027
- * - `/admin/vaults` vault list (the SPA's home)
1028
- * - `/admin/vaults/new` → vault create form
1311
+ * - `/admin/` Home (the admin-shell overview)
1312
+ * - `/admin/vaults` legacy vault list; feature-detects a new-manifest
1313
+ * vault and forwards to `/vault/admin/` (B5)
1029
1314
  * - `/admin/permissions` → OAuth consent grant management
1030
1315
  * - `/admin/tokens` → token registry: mint / list / revoke
1031
1316
  *
@@ -1328,15 +1613,230 @@ export function resolveIssuerSource(
1328
1613
 
1329
1614
  /**
1330
1615
  * Minimal structural type for the Bun `Server` handle the fetch callback
1331
- * receives as its 2nd argument. We only need `requestIP` (item E / #526) to
1332
- * resolve the peer address for `layerOf`. Typed structurally (rather than
1616
+ * receives as its 2nd argument. We need `requestIP` (item E / #526) to
1617
+ * resolve the peer address for `layerOf`, and `upgrade` (H1) to hand a
1618
+ * gated WebSocket upgrade to the bridge. Typed structurally (rather than
1333
1619
  * importing Bun's full `Server`) so tests can pass a tiny fake and so the
1334
1620
  * signature stays robust to Bun type-shape churn. Optional in the callback
1335
1621
  * because a direct unit call to the returned fetch fn may omit it — in which
1336
- * case `peerAddr` is null and `layerOf` fails closed to `public`.
1622
+ * case `peerAddr` is null and `layerOf` fails closed to `public`, and a
1623
+ * WebSocket upgrade is refused (503 — no server to upgrade on).
1337
1624
  */
1338
1625
  interface PeerIpResolver {
1339
1626
  requestIP(req: Request): { address: string } | null;
1627
+ /**
1628
+ * Bun `Server.upgrade` — present on the real server, optional on fakes.
1629
+ * Typed with the bridge's data payload (Bun's own signature takes
1630
+ * `data: unknown`; method bivariance keeps the real Server assignable).
1631
+ */
1632
+ upgrade?(req: Request, options: { data: WsBridgeData }): boolean;
1633
+ }
1634
+
1635
+ /**
1636
+ * True when the request is a WebSocket upgrade. The `Upgrade` header is the
1637
+ * discriminator (RFC 6455 §4.1 requires it; Bun's `server.upgrade` re-checks
1638
+ * the full handshake — key, version, Connection token — so this only needs
1639
+ * to be a cheap router predicate, not a validator).
1640
+ */
1641
+ export function isWebSocketUpgrade(req: Request): boolean {
1642
+ return (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
1643
+ }
1644
+
1645
+ /**
1646
+ * Hop-by-hop + WS-handshake headers never forwarded on the upstream connect:
1647
+ * the Bun WebSocket client re-mints its own handshake (key/version/
1648
+ * extensions), and forwarding the originals would corrupt it.
1649
+ */
1650
+ const WS_HOP_BY_HOP_HEADERS = [
1651
+ "host",
1652
+ "connection",
1653
+ "upgrade",
1654
+ "keep-alive",
1655
+ "proxy-authorization",
1656
+ "te",
1657
+ "trailer",
1658
+ "transfer-encoding",
1659
+ "sec-websocket-key",
1660
+ "sec-websocket-version",
1661
+ "sec-websocket-extensions",
1662
+ "sec-websocket-accept",
1663
+ // Subprotocol negotiation is NOT forwarded in v1 (see ws-bridge.ts header).
1664
+ "sec-websocket-protocol",
1665
+ ] as const;
1666
+
1667
+ /** The verdict of {@link maybeUpgradeWebSocket}. */
1668
+ type WsUpgradeVerdict =
1669
+ | { kind: "upgraded" }
1670
+ | { kind: "response"; response: Response }
1671
+ | { kind: "pass" };
1672
+
1673
+ /**
1674
+ * H1 — the WebSocket upgrade bridge's routing + gating half (the frame
1675
+ * piping lives in `src/ws-bridge.ts`).
1676
+ *
1677
+ * For an `Upgrade: websocket` request:
1678
+ *
1679
+ * 1. Resolve the service mount (generic longest-prefix, then vault mounts —
1680
+ * same resolution as the HTTP proxies). No mount → `pass` (normal
1681
+ * dispatch 404s / handles it).
1682
+ * 2. Gate BEFORE upgrading — same posture as the HTTP path:
1683
+ * `publicExposure: "loopback"` cloak (404, indistinguishable from
1684
+ * not-installed) and the per-UI audience gate (H3).
1685
+ * 3. Capability check, DENY BY DEFAULT: the module must declare
1686
+ * `websocket: true` on its services.json row OR its
1687
+ * `.parachute/module.json`. No declaration → 426 (the route exists but
1688
+ * doesn't speak WebSocket; the fetch-based proxy can't forward upgrades
1689
+ * and the daemon never sees the request).
1690
+ * 4. Connection caps (hub#649): per-client-IP + total concurrent caps,
1691
+ * checked-and-acquired in the same synchronous block as the upgrade
1692
+ * (no await between check and commit). Over-cap → generic 429 (no
1693
+ * count leakage; the hub log carries which cap + bucket), refused
1694
+ * BEFORE `server.upgrade()` commits a socket or the bridge dials the
1695
+ * upstream. Keying + trust model: {@link wsCapBucketKey}; defaults +
1696
+ * env overrides: `ws-connection-caps.ts`. Release rides the bridge's
1697
+ * close handler via `data.releaseCap`.
1698
+ * 5. `server.upgrade(req, { data })` with the upstream URL + headers
1699
+ * (client headers minus hop-by-hop/handshake, plus the H2 substrate
1700
+ * trust stamps). The ws-bridge handlers take over from there.
1701
+ */
1702
+ async function maybeUpgradeWebSocket(
1703
+ req: Request,
1704
+ server: PeerIpResolver | undefined,
1705
+ deps: {
1706
+ manifestPath: string;
1707
+ peerAddr: string | null;
1708
+ readModuleManifestFn: (installDir: string) => Promise<ModuleManifest | null>;
1709
+ /** H3 — gate the upgrade on the mount's audience BEFORE upgrading. */
1710
+ gateAudience?: (pathname: string) => Promise<Response | null>;
1711
+ /** hub#649 — per-IP + total connection-cap accounting. */
1712
+ wsConnectionTracker: WsConnectionTracker;
1713
+ },
1714
+ ): Promise<WsUpgradeVerdict> {
1715
+ const services = readManifestLenient(deps.manifestPath).services;
1716
+ const url = new URL(req.url);
1717
+ const match =
1718
+ findServiceUpstream(services, url.pathname) ?? findVaultUpstream(services, url.pathname);
1719
+ if (!match) return { kind: "pass" };
1720
+
1721
+ // Layer cloak first — a loopback-only module must look not-installed from
1722
+ // tailnet/public, for upgrades exactly as for HTTP.
1723
+ if (
1724
+ effectivePublicExposure(match.entry) === "loopback" &&
1725
+ layerOf(req, deps.peerAddr) !== "loopback"
1726
+ ) {
1727
+ return { kind: "response", response: new Response("not found", { status: 404 }) };
1728
+ }
1729
+
1730
+ // Audience gate (H3) — runs BEFORE the upgrade so an unauthorized client
1731
+ // never gets a socket. Threaded from dispatch (needs db + issuer).
1732
+ if (deps.gateAudience) {
1733
+ const gated = await deps.gateAudience(url.pathname);
1734
+ if (gated) return { kind: "response", response: gated };
1735
+ }
1736
+
1737
+ // Capability — deny by default. services.json row wins; module.json is the
1738
+ // canonical declaration source for modules that haven't re-registered yet.
1739
+ let declared = match.entry.websocket === true;
1740
+ if (!declared && match.entry.installDir) {
1741
+ try {
1742
+ const manifest = await deps.readModuleManifestFn(match.entry.installDir);
1743
+ declared = manifest?.websocket === true;
1744
+ } catch {
1745
+ declared = false; // malformed manifest → deny (fail closed)
1746
+ }
1747
+ }
1748
+ if (!declared) {
1749
+ return {
1750
+ kind: "response",
1751
+ response: new Response(
1752
+ JSON.stringify({
1753
+ error: "websocket_not_supported",
1754
+ error_description: `module "${match.entry.name}" does not declare WebSocket support`,
1755
+ }),
1756
+ { status: 426, headers: { "content-type": "application/json", upgrade: "websocket" } },
1757
+ ),
1758
+ };
1759
+ }
1760
+
1761
+ if (!server?.upgrade) {
1762
+ return {
1763
+ kind: "response",
1764
+ response: new Response(
1765
+ JSON.stringify({
1766
+ error: "service_unavailable",
1767
+ error_description: "websocket upgrade unavailable on this server",
1768
+ }),
1769
+ { status: 503, headers: { "content-type": "application/json" } },
1770
+ ),
1771
+ };
1772
+ }
1773
+
1774
+ // Upstream URL — same path semantics as the HTTP proxy (stripPrefix honored).
1775
+ const stripPrefix = stripPrefixFor(match.entry);
1776
+ const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : url.pathname;
1777
+ const upstreamUrl = `ws://127.0.0.1:${match.port}${targetPath}${url.search}`;
1778
+
1779
+ // Upstream headers: the client's own (cookie / authorization ride through
1780
+ // so the daemon authenticates the connection) minus hop-by-hop + handshake
1781
+ // headers, plus the H2 substrate trust stamps.
1782
+ const headers = new Headers(req.headers);
1783
+ for (const h of WS_HOP_BY_HOP_HEADERS) headers.delete(h);
1784
+ stampSubstrateTrustHeaders(headers, req, deps.peerAddr);
1785
+ const upstreamHeaders: Record<string, string> = {};
1786
+ headers.forEach((value, key) => {
1787
+ upstreamHeaders[key] = value;
1788
+ });
1789
+
1790
+ // Connection caps (hub#649) — the LAST gate, synchronous with the upgrade
1791
+ // itself (everything between here and `server.upgrade` must stay
1792
+ // await-free so the check can't race the commit). Last on purpose: the
1793
+ // earlier refusals keep their precise statuses (the 404 cloak stays
1794
+ // indistinguishable from not-installed even under cap pressure), and the
1795
+ // counters only ever hold slots for connections that would actually
1796
+ // bridge.
1797
+ const capKey = wsCapBucketKey(req, deps.peerAddr);
1798
+ const acquired = deps.wsConnectionTracker.tryAcquire(capKey);
1799
+ if (!acquired.ok) {
1800
+ // Operator-facing pressure signal: which cap, which bucket, how full.
1801
+ // None of this reaches the client — the 429 body is deliberately
1802
+ // generic (no counts, no cap identity).
1803
+ console.warn(
1804
+ `[ws-caps] refused upgrade for ${url.pathname}: ${
1805
+ acquired.reason === "per_ip_cap" ? `per-IP cap (ip=${capKey})` : `total cap (ip=${capKey})`
1806
+ }; total=${deps.wsConnectionTracker.totalCount} ip_count=${deps.wsConnectionTracker.countFor(
1807
+ capKey,
1808
+ )}`,
1809
+ );
1810
+ return {
1811
+ kind: "response",
1812
+ response: new Response(
1813
+ JSON.stringify({
1814
+ error: "too_many_connections",
1815
+ error_description: "WebSocket connection limit reached; try again later",
1816
+ }),
1817
+ { status: 429, headers: { "content-type": "application/json" } },
1818
+ ),
1819
+ };
1820
+ }
1821
+
1822
+ const upgraded = server.upgrade(req, {
1823
+ data: { upstreamUrl, upstreamHeaders, releaseCap: acquired.release },
1824
+ });
1825
+ if (upgraded) return { kind: "upgraded" };
1826
+ // No socket was created, so the bridge's close handler will never fire —
1827
+ // release the slot inline (the closure latches, so this can't double-count
1828
+ // against a later close).
1829
+ acquired.release();
1830
+ return {
1831
+ kind: "response",
1832
+ response: new Response(
1833
+ JSON.stringify({
1834
+ error: "upgrade_failed",
1835
+ error_description: "WebSocket handshake was malformed or could not be completed",
1836
+ }),
1837
+ { status: 400, headers: { "content-type": "application/json" } },
1838
+ ),
1839
+ };
1340
1840
  }
1341
1841
 
1342
1842
  /**
@@ -1467,6 +1967,49 @@ export function hubFetch(
1467
1967
  // error detail"). A transient SQLITE_BUSY is classified non-fatal and just
1468
1968
  // surfaces a 503 the next request clears — it never kills the hub.
1469
1969
  try {
1970
+ // H1 — WebSocket upgrade bridge. Runs before normal dispatch: an
1971
+ // `Upgrade: websocket` request targeting a declared service mount is
1972
+ // gated (publicExposure cloak + audience gate) and, if it passes,
1973
+ // upgraded into the Bun-native bridge (src/ws-bridge.ts) instead of
1974
+ // the fetch-based proxy (which cannot forward upgrades). Upgrade
1975
+ // requests that match no service mount fall through to normal dispatch
1976
+ // unchanged — no hub-owned route speaks WebSocket.
1977
+ if (isWebSocketUpgrade(req)) {
1978
+ const verdict = await maybeUpgradeWebSocket(req, server, {
1979
+ manifestPath,
1980
+ peerAddr,
1981
+ readModuleManifestFn: deps?.readModuleManifest ?? defaultReadModuleManifest,
1982
+ wsConnectionTracker: deps?.wsConnectionTracker ?? defaultWsConnectionTracker,
1983
+ // H3 — the audience gate runs BEFORE the upgrade, same posture as
1984
+ // the HTTP dispatch below: a WS endpoint under a hub-users surface
1985
+ // never hands a socket to an anonymous caller, while `surface`
1986
+ // audiences pass through (the backed surface authenticates the
1987
+ // socket itself — e.g. the docs editor's collab WS rides this).
1988
+ // (The publicExposure cloak already ran inside
1989
+ // maybeUpgradeWebSocket before this hook.)
1990
+ gateAudience: async (wsPathname) => {
1991
+ const wsUiMatch = resolveUiMount(
1992
+ readManifestLenient(manifestPath).services,
1993
+ wsPathname,
1994
+ );
1995
+ if (!wsUiMatch) return null;
1996
+ return gateUiAudience(req, wsUiMatch.audience, wsUiMatch.ui, {
1997
+ db: getDb?.(),
1998
+ knownIssuers: () => oauthDeps(req).hubBoundOrigins(),
1999
+ });
2000
+ },
2001
+ });
2002
+ if (verdict.kind === "upgraded") {
2003
+ // Bun's contract after a successful `server.upgrade()` is to
2004
+ // return undefined from fetch — the socket now belongs to the
2005
+ // websocket handlers. The public signature stays Response-typed
2006
+ // for the many direct (non-WS) call sites; this cast is the one
2007
+ // deliberate exception, observed only by Bun's runtime.
2008
+ return undefined as unknown as Response;
2009
+ }
2010
+ if (verdict.kind === "response") return verdict.response;
2011
+ // kind === "pass" — fall through to normal dispatch.
2012
+ }
1470
2013
  return await dispatch();
1471
2014
  } catch (err) {
1472
2015
  const klass = classifyDbError(err);
@@ -1497,11 +2040,17 @@ export function hubFetch(
1497
2040
  async function dispatch(): Promise<Response> {
1498
2041
  // 301 back-compat for the pre-hub#231 admin-SPA mounts:
1499
2042
  //
1500
- // `/vault` → `/admin/vaults`
1501
- // `/vault/new` → `/admin/vaults/new`
2043
+ // `/vault`, `/vault/new` → `/vault/admin/` (vault's own daemon-level
2044
+ // admin surface — B5, 2026-06-09 hub-module-
2045
+ // boundary migration. These pointed at
2046
+ // `/admin/vaults[/new]` until B5; with the
2047
+ // list+create UX module-owned, pointing DIRECTLY
2048
+ // at the target avoids a redirect → SPA-load →
2049
+ // client-side-forward chain)
1502
2050
  // `/hub/vaults*` → `/admin/vaults*` (this redirect predates #231;
1503
- // it now retargets at the new admin mount instead
1504
- // of the interim `/vault` mount)
2051
+ // /admin/vaults survives as the feature-detected
2052
+ // legacy list, so the target stays valid on both
2053
+ // old-vault and new-vault boxes)
1505
2054
  // `/hub/permissions` → `/admin/permissions`
1506
2055
  // `/hub/tokens` → `/admin/tokens`
1507
2056
  // `/hub` (bare) → `/admin/vaults`
@@ -1513,12 +2062,13 @@ export function hubFetch(
1513
2062
  // POST endpoint to protect.
1514
2063
  //
1515
2064
  // `/vault/<name>/*` is INTENTIONALLY excluded — that's the per-vault
1516
- // content proxy (Notes PWA, etc.), not the admin SPA. Stays where it is.
2065
+ // content proxy (Notes PWA, etc.), not the admin SPA. The exact-match
2066
+ // condition here also never touches `/vault/admin*` — the daemon-level
2067
+ // mount dispatched further down.
1517
2068
  if (pathname === "/vault" || pathname === "/vault/" || pathname === "/vault/new") {
1518
- const sub = pathname === "/vault/new" ? "/new" : "";
1519
2069
  return new Response("", {
1520
2070
  status: 301,
1521
- headers: { location: `/admin/vaults${sub}${url.search}` },
2071
+ headers: { location: `/vault/admin/${url.search}` },
1522
2072
  });
1523
2073
  }
1524
2074
  if (pathname === "/hub/vaults" || pathname.startsWith("/hub/vaults/")) {
@@ -1795,7 +2345,35 @@ export function hubFetch(
1795
2345
  );
1796
2346
  }
1797
2347
 
1798
- if (pathname === "/" || pathname === "/hub.html") {
2348
+ // Bare `/` `/admin` (admin-shell IA, R1). The home page and the admin
2349
+ // SPA used to be two disconnected surfaces; `/` now funnels straight into
2350
+ // the single coherent admin shell, whose Home/Overview carries the
2351
+ // discovery content (hub-native sections, modules, user surfaces) that
2352
+ // used to live here.
2353
+ //
2354
+ // Ordering matters: this sits AFTER the fresh-hub wizard funnel above
2355
+ // (so a brand-new operator still lands on `/admin/setup`, not a 404 inside
2356
+ // the shell) and AFTER the pre-admin lockout (so an admin-less hub still
2357
+ // 503s API callers correctly). 302 (not 301) — `/` is reclaimed for
2358
+ // future use, but a permanent redirect would get cached and we may want
2359
+ // `/` back later.
2360
+ //
2361
+ // The signed-out path is preserved: a signed-out visitor lands on
2362
+ // `/admin`, where the SPA's AuthIndicator shows a "Sign in" link that
2363
+ // round-trips through `/login?next=/admin/...` and back. We don't pin the
2364
+ // redirect on session state — the shell handles both auth states itself.
2365
+ //
2366
+ // `/hub.html` is INTENTIONALLY excluded: it still renders the discovery
2367
+ // page (used by the static `parachute expose --set-path=/` disk file and
2368
+ // any bookmark to the explicit `.html`). Only the bare `/` redirects.
2369
+ if (pathname === "/") {
2370
+ return new Response(null, {
2371
+ status: 302,
2372
+ headers: { location: "/admin" },
2373
+ });
2374
+ }
2375
+
2376
+ if (pathname === "/hub.html") {
1799
2377
  // When a DB is configured, render the discovery page dynamically so
1800
2378
  // the header carries a "Signed in as <name>" affordance for the
1801
2379
  // active session. Without a DB, fall back to the static disk file
@@ -2063,6 +2641,47 @@ export function hubFetch(
2063
2641
  });
2064
2642
  }
2065
2643
 
2644
+ // DELETE /vaults/<name> — destroy a vault with the full identity
2645
+ // cascade (B1, 2026-06-09 hub-module-boundary: lifecycle symmetry).
2646
+ // Bearer parachute:host:admin + {"confirm": "<name>"} body. See
2647
+ // admin-vaults.handleDeleteVault for the enumerated cascade.
2648
+ if (pathname.startsWith("/vaults/")) {
2649
+ if (!getDb) return dbNotConfigured();
2650
+ const name = decodeURIComponent(pathname.slice("/vaults/".length));
2651
+ const services = readManifestLenient(manifestPath).services;
2652
+ // Channel's row carries its MANIFEST name — resolve via
2653
+ // findServiceByShort (see the /admin/connections note below).
2654
+ const channelEntry = findServiceByShort(services, "channel");
2655
+ const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
2656
+ const resolveVaultOrigin = (vaultName: string): string | null => {
2657
+ const match = findVaultUpstream(
2658
+ readManifestLenient(manifestPath).services,
2659
+ `/vault/${vaultName}`,
2660
+ );
2661
+ return match ? `http://127.0.0.1:${match.port}` : null;
2662
+ };
2663
+ const supervisor = deps?.supervisor;
2664
+ return handleDeleteVault(req, name, {
2665
+ db: getDb(),
2666
+ issuer: oauthDeps(req).issuer,
2667
+ manifestPath,
2668
+ connectionsStorePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
2669
+ channelOrigin,
2670
+ resolveVaultOrigin,
2671
+ resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
2672
+ // Daemon eviction — the same in-process supervisor the lifecycle
2673
+ // verbs drive (module-ops API); restarting vault evicts the open
2674
+ // store handle + re-runs selfRegister (services.json path rebuild).
2675
+ ...(supervisor
2676
+ ? {
2677
+ restartVaultModule: async () => {
2678
+ await supervisor.restart("vault");
2679
+ },
2680
+ }
2681
+ : {}),
2682
+ });
2683
+ }
2684
+
2066
2685
  // Note: the old `/hub/*` SPA mount has been retired. Known prefixes
2067
2686
  // (`/hub`, `/hub/vaults*`, `/hub/permissions`, `/hub/tokens`) are
2068
2687
  // 301-redirected at the top of dispatch. Any other `/hub/*` path falls
@@ -2076,6 +2695,98 @@ export function hubFetch(
2076
2695
  });
2077
2696
  }
2078
2697
 
2698
+ if (pathname === "/admin/channel-token") {
2699
+ if (!getDb) return dbNotConfigured();
2700
+ return handleChannelToken(req, {
2701
+ db: getDb(),
2702
+ issuer: oauthDeps(req).issuer,
2703
+ });
2704
+ }
2705
+
2706
+ // Generic per-module config-UI bearer mint (2026-06-09 modular-UI
2707
+ // architecture, P3). `<short>:admin` for any single-audience module —
2708
+ // the admin scope each module-owned config UI needs to call its own
2709
+ // endpoints. Cookie-gated to the first-admin operator, exactly like
2710
+ // /admin/channel-token + /admin/vault-admin-token. Gated on
2711
+ // self-registration (services.json row + readable module.json) with the
2712
+ // bootstrap registries as a fallback (boundary C5) — a genuinely
2713
+ // third-party module mints here with zero hub code changes. Vault is
2714
+ // per-instance and routed to /admin/vault-admin-token/<name> instead.
2715
+ if (pathname.startsWith("/admin/module-token/")) {
2716
+ if (!getDb) return dbNotConfigured();
2717
+ const short = decodeURIComponent(pathname.slice("/admin/module-token/".length));
2718
+ return handleModuleToken(req, short, {
2719
+ db: getDb(),
2720
+ issuer: oauthDeps(req).issuer,
2721
+ // Lenient + per-request — a module that self-registered since hub
2722
+ // boot is mintable without a restart (see hub#406 for lenient).
2723
+ readServices: () => readManifestLenient(manifestPath).services,
2724
+ ...(deps?.readModuleManifest ? { readModuleManifest: deps.readModuleManifest } : {}),
2725
+ });
2726
+ }
2727
+
2728
+ // Note: the legacy `/admin/channels` bespoke vault-channel orchestration
2729
+ // endpoint (pre-Connections, hub#624 era) was retired in boundary D1 —
2730
+ // superseded by the general engine below. Channel's own admin page
2731
+ // drives `/admin/connections` + `/admin/channel-token`.
2732
+
2733
+ // Connections — the GENERAL module event→action engine (2026-06-09
2734
+ // modular-UI architecture, P5). `/api/connections/catalog` (GET) returns
2735
+ // the available events/actions read from each installed module's
2736
+ // `module.json`; `/admin/connections` (GET/POST) lists + provisions;
2737
+ // `/admin/connections/:id` (DELETE) tears down. Cookie-gated to the
2738
+ // first-admin operator. The provisioning engine derives the vault
2739
+ // trigger's webhook + scope from the SINK action's declaration —
2740
+ // nothing is channel-hardcoded.
2741
+ if (
2742
+ pathname === "/api/connections/catalog" ||
2743
+ pathname === "/admin/connections" ||
2744
+ pathname.startsWith("/admin/connections/")
2745
+ ) {
2746
+ if (!getDb) return dbNotConfigured();
2747
+ const services = readManifestLenient(manifestPath).services;
2748
+ // Channel's services.json row carries its MANIFEST name
2749
+ // (`parachute-channel`), not the bare short `channel` — resolve via
2750
+ // findServiceByShort so the lookup matches the on-disk row. (A bare
2751
+ // `s.name === "channel"` never matched, leaving channelOrigin null →
2752
+ // "channel not installed".)
2753
+ const channelEntry = findServiceByShort(services, "channel");
2754
+ const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
2755
+ const resolveVaultOrigin = (vaultName: string): string | null => {
2756
+ const match = findVaultUpstream(
2757
+ readManifestLenient(manifestPath).services,
2758
+ `/vault/${vaultName}`,
2759
+ );
2760
+ return match ? `http://127.0.0.1:${match.port}` : null;
2761
+ };
2762
+ const readManifestFn = deps?.readModuleManifest ?? defaultReadModuleManifest;
2763
+ const modules = await collectInstalledModules(manifestPath, readManifestFn);
2764
+ const connectionsDeps: ConnectionsDeps = {
2765
+ db: getDb(),
2766
+ hubOrigin: oauthDeps(req).issuer,
2767
+ modules,
2768
+ resolveVaultOrigin,
2769
+ resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
2770
+ channelOrigin,
2771
+ storePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
2772
+ };
2773
+ if (pathname === "/api/connections/catalog") {
2774
+ return handleConnectionsCatalog(req, connectionsDeps);
2775
+ }
2776
+ // CSRF belt (hub#632, boundary C1): cookie-authed POST/DELETE must
2777
+ // carry a matching Origin. The seam's canonical consumer — channel's
2778
+ // admin page POSTing link-vault with `credentials: "include"` — is a
2779
+ // same-origin fetch() and passes; see origin-check.ts
2780
+ // `assertSameOriginForCookieMutation` for the belted-endpoint
2781
+ // enumeration.
2782
+ {
2783
+ const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
2784
+ if (rejected) return rejected;
2785
+ }
2786
+ const subPath = pathname.slice("/admin/connections".length);
2787
+ return handleConnections(req, subPath, connectionsDeps);
2788
+ }
2789
+
2079
2790
  if (pathname.startsWith("/admin/vault-admin-token/")) {
2080
2791
  if (!getDb) return dbNotConfigured();
2081
2792
  const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
@@ -2218,28 +2929,12 @@ export function hubFetch(
2218
2929
  });
2219
2930
  }
2220
2931
 
2221
- // Per-module config surface (hub#260) schema + values GET, values PUT.
2222
- // Sits ahead of the install/restart/upgrade/uninstall switch below so
2223
- // `/api/modules/<short>/config[/schema]` doesn't fall into the default-
2224
- // branch 404 (`parseModulesPath` only matches the action-suffix shape,
2225
- // not the `config` / `config/schema` shape).
2226
- //
2227
- // Diverges from the action endpoints in two ways: doesn't require a
2228
- // supervisor (we just proxy to the running module's HTTP surface, not
2229
- // spawn a child), and mints a `<short>:admin` token at proxy time so
2230
- // the upstream auth gate is satisfied without forcing the SPA bearer
2231
- // to carry per-module scopes.
2232
- {
2233
- const configMatch = parseModulesConfigPath(pathname);
2234
- if (configMatch) {
2235
- if (!getDb) return dbNotConfigured();
2236
- return handleApiModulesConfig(req, configMatch, {
2237
- db: getDb(),
2238
- issuer: oauthDeps(req).issuer,
2239
- manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
2240
- });
2241
- }
2242
- }
2932
+ // NOTE: the hub-hosted generic per-module config proxy
2933
+ // (`/api/modules/<short>/config[/schema]`) + its SPA form were RETIRED in
2934
+ // the 2026-06-09 modular-UI architecture P3. Config is module-owned +
2935
+ // hub-framed now: the Modules page "Configure" action opens the module's
2936
+ // OWN config UI (`configUiUrl`), which mints its admin Bearer from the
2937
+ // cookie-gated `/admin/module-token/<short>` (or `/admin/channel-token`).
2243
2938
 
2244
2939
  // Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
2245
2940
  if (pathname.startsWith("/api/modules/")) {
@@ -2669,8 +3364,10 @@ export function hubFetch(
2669
3364
  }
2670
3365
 
2671
3366
  // Legacy `/admin/config` (server-rendered module-config portal, #46)
2672
- // retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
2673
- // post-login redirect lands somewhere useful. The route stays here in
3367
+ // retired post-SPA-rework. 301 → /admin/vaults so any bookmark or stale
3368
+ // post-login redirect lands somewhere useful (post-B5 that's the
3369
+ // feature-detected vaults surface — legacy list on an old-vault box,
3370
+ // forward to /vault/admin/ on a new one). The route stays here in
2674
3371
  // dispatch order (above the /admin/* SPA catch-all) so the redirect
2675
3372
  // wins over a SPA shell render.
2676
3373
  if (pathname === "/admin/config" || pathname.startsWith("/admin/config/")) {
@@ -2680,10 +3377,31 @@ export function hubFetch(
2680
3377
  });
2681
3378
  }
2682
3379
 
3380
+ // /vault/admin + /vault/admin/* — the vault MODULE's daemon-level
3381
+ // admin surface (B-route, 2026-06-09 hub-module-boundary). MUST run
3382
+ // BEFORE the per-vault proxy dispatch below: this is a module-level
3383
+ // mount, not an instance path. "admin" is a reserved vault name (B2h)
3384
+ // so no instance can claim it, and `findVaultUpstream` never sees it —
3385
+ // no consumer fabricates a phantom vault named "admin". Exact-segment
3386
+ // match only: a vault instance named e.g. "adminx" still routes
3387
+ // per-instance through the branch below.
3388
+ if (pathname === "/vault/admin" || pathname.startsWith("/vault/admin/")) {
3389
+ // Same per-request force-change-password gate as the per-vault proxy
3390
+ // (P0-1 / hub#469) — a pre-rotation signed-in user can't reach the
3391
+ // multi-vault admin surface on an un-rotated temp password either.
3392
+ if (getDb) {
3393
+ const gate = forceChangePasswordGate(getDb(), req);
3394
+ if (gate) return gate;
3395
+ }
3396
+ const proxied = await proxyToVaultAdmin(req, manifestPath, deps?.supervisor, peerAddr);
3397
+ if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
3398
+ return new Response("not found", { status: 404 });
3399
+ }
3400
+
2683
3401
  // /vault/<name>/* — per-vault content proxy. Stays as user-facing
2684
3402
  // surface (the Notes PWA loads through here, etc.). The bare `/vault`
2685
3403
  // and `/vault/new` paths were SPA routes pre-#231; they 301-redirect at
2686
- // the top of dispatch now. Multi-segment requests like
3404
+ // the top of dispatch now (to `/vault/admin/` since B5). Multi-segment requests like
2687
3405
  // `/vault/<unknown>/health` are vault-API shapes targeting a
2688
3406
  // non-existent vault and 404 directly — there's no SPA-shell fallback
2689
3407
  // here anymore (the SPA moved to /admin), so we can't accidentally
@@ -2713,9 +3431,9 @@ export function hubFetch(
2713
3431
  // SPA's own router renders the page and handles 404 client-side for
2714
3432
  // unknown sub-paths.
2715
3433
  if (pathname === "/admin" || pathname === "/admin/") {
2716
- // Unprefixed /admin → SPA shell pointed at the vault list (its home).
2717
- // The SPA's basename is /admin, so the router will land on / and
2718
- // render VaultsList.
3434
+ // Unprefixed /admin → SPA shell at its index route. The SPA's
3435
+ // basename is /admin, so the router lands on / and renders Home
3436
+ // (the admin-shell overview).
2719
3437
  if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2720
3438
  return serveSpa(spaDistDir, pathname, "/admin");
2721
3439
  }
@@ -2728,8 +3446,54 @@ export function hubFetch(
2728
3446
  // here only after every hub-owned prefix above has had its turn — so
2729
3447
  // `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
2730
3448
  // `/api/*` are excluded by ordering, not by an explicit denylist (#182).
3449
+ //
3450
+ // H3 — per-UI audience gate. When the path falls under a declared UI
3451
+ // sub-unit (a `uis{}` entry on the matched service row — surface-hosted
3452
+ // UI mounts like /surface/<name>/*), the sub-unit's audience is
3453
+ // enforced BEFORE forwarding: 'public' passes, 'surface' passes (the
3454
+ // backed surface authenticates every request itself), 'hub-users'
3455
+ // requires a session or a scope-satisfying Bearer, 'operator' requires
3456
+ // the first admin. Module API paths outside any uis entry are NOT
3457
+ // gated here — modules keep their own auth. Ordering nuance: when the
3458
+ // row's publicExposure cloak would fire (loopback-only, non-loopback
3459
+ // layer), the gate is SKIPPED so the 404 cloak stays indistinguishable
3460
+ // from not-installed (a 401 here would leak the route's existence) —
3461
+ // which also means a 'surface'/'public' mount on a loopback-only row
3462
+ // stays unreachable from tailnet/funnel: exposure is orthogonal to
3463
+ // audience.
3464
+ const uiMatch = resolveUiMount(readManifestLenient(manifestPath).services, pathname);
3465
+ if (uiMatch) {
3466
+ const cloaked =
3467
+ effectivePublicExposure(uiMatch.entry) === "loopback" &&
3468
+ layerOf(req, peerAddr) !== "loopback";
3469
+ if (!cloaked) {
3470
+ const denied = await gateUiAudience(req, uiMatch.audience, uiMatch.ui, {
3471
+ db: getDb?.(),
3472
+ knownIssuers: () => oauthDeps(req).hubBoundOrigins(),
3473
+ });
3474
+ if (denied) return denied;
3475
+ }
3476
+ }
2731
3477
  const proxied = await proxyToService(req, manifestPath, deps?.supervisor, peerAddr);
2732
- if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
3478
+ if (proxied) {
3479
+ // H5 — chrome-strip rides the gate: where the audience resolved
3480
+ // `public`, the identity chrome is disabled for that mount (public
3481
+ // readers aren't hub users). `surface` follows the same precedent —
3482
+ // a backed surface's visitors are mostly capability-link invitees,
3483
+ // NOT hub users, so the "Signed in as…" chrome would be wrong for
3484
+ // them (and the surface owns its whole page anyway). Reuses the
3485
+ // per-path opt-out mechanism the /surface/notes/ precedent
3486
+ // established, generalized to the declared audience.
3487
+ return decorateWithChrome(
3488
+ proxied,
3489
+ req,
3490
+ pathname,
3491
+ getDb,
3492
+ uiMatch !== undefined && (uiMatch.audience === "public" || uiMatch.audience === "surface")
3493
+ ? [uiMatch.mount]
3494
+ : undefined,
3495
+ );
3496
+ }
2733
3497
 
2734
3498
  // Branded fall-through 404 (closes hub#392) — the operator who mistyped
2735
3499
  // a URL sees a clear "not found" page with a path back home, not the
@@ -2757,6 +3521,14 @@ export function hubFetch(
2757
3521
  * wrapper threads in the session-aware chrome HTML and a `set-cookie`
2758
3522
  * append when a fresh CSRF cookie was minted.
2759
3523
  *
3524
+ * `extraOptOutPrefixes` (H5) generalizes the static opt-out list: the
3525
+ * dispatch passes the matched UI mount when the audience gate resolved
3526
+ * `public` or `surface` — public readers (and a backed surface's
3527
+ * capability-link invitees) aren't hub users, so the identity chrome
3528
+ * ("Signed in as…", Sign in link) must not ride their pages. Same
3529
+ * mechanism as the hardcoded `/surface/notes/` precedent, now driven by
3530
+ * the sub-unit's declared audience instead of a hub-side path list.
3531
+ *
2760
3532
  * When `getDb` isn't wired (hubFetch instantiated without state — tests,
2761
3533
  * cold-start hub minus DB), we still inject — the signed-out variant.
2762
3534
  */
@@ -2765,6 +3537,7 @@ async function decorateWithChrome(
2765
3537
  req: Request,
2766
3538
  pathname: string,
2767
3539
  getDb: HubFetchDeps["getDb"],
3540
+ extraOptOutPrefixes?: readonly string[],
2768
3541
  ): Promise<Response> {
2769
3542
  // Build chrome HTML lazily — `buildChromeForRequest` already opens the DB
2770
3543
  // for the session lookup; calling it on a response that won't be rewritten
@@ -2785,6 +3558,9 @@ async function decorateWithChrome(
2785
3558
  const out = await injectChromeIntoResponse(res, {
2786
3559
  chromeHtml,
2787
3560
  pathname,
3561
+ ...(extraOptOutPrefixes !== undefined && extraOptOutPrefixes.length > 0
3562
+ ? { optOutPrefixes: [...CHROME_OPT_OUT_PREFIXES, ...extraOptOutPrefixes] }
3563
+ : {}),
2788
3564
  });
2789
3565
  // Append set-cookie if a CSRF was minted AND the chrome was actually
2790
3566
  // injected (we know that by checking out !== res — pass-through preserves
@@ -2862,6 +3638,9 @@ if (import.meta.main) {
2862
3638
  issuer,
2863
3639
  loopbackPort: port,
2864
3640
  }),
3641
+ // H1 — the WebSocket upgrade bridge's frame-piping handlers. Connections
3642
+ // land here only after `maybeUpgradeWebSocket` gated + upgraded them.
3643
+ websocket: createWsBridgeHandlers(),
2865
3644
  });
2866
3645
  // Register PID + port from the running hub itself so any startup path
2867
3646
  // (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from