@openparachute/hub 0.7.0 → 0.7.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
package/src/hub-server.ts CHANGED
@@ -51,16 +51,27 @@
51
51
  * supervisor restart)
52
52
  * /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
53
53
  * /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
54
- * /admin/channel-token (GET) → channel UI bearer mint (cookie-gated)
54
+ * /admin/agent-token (GET) → agent UI bearer mint (cookie-gated)
55
+ * /admin/channel-token (GET) → 301 → /admin/agent-token (legacy; channel→agent rename 2026-06-17)
55
56
  * /admin/module-token/<short> (GET) → generic module config-UI bearer mint <short>:admin (cookie-gated)
56
57
  * /api/connections/catalog (GET) → events/actions across installed modules (cookie-gated)
57
58
  * /admin/connections (POST/GET) → connection provision/list (cookie-gated; POST CSRF-belted)
58
59
  * /admin/connections/<id> (DELETE) → connection teardown (cookie-gated; CSRF-belted)
60
+ * /admin/connections/<id>/renew (POST) → credential renewal (H4; Bearer = the credential itself, proof of possession)
61
+ * /admin/connections/<id>/claim (POST) → claim/reconcile a directly-delivered credential → pending record (surface#113; Bearer = the credential itself)
62
+ * /admin/connections/<id>/approve (POST) → operator approval of a pending claim (cookie-gated; CSRF-belted)
63
+ * /admin/grants (PUT/GET) → agent-connector grant upsert/list (4b-1; host-admin Bearer)
64
+ * /admin/grants/<id>/material (GET) → injectable secret for an APPROVED grant (4b-1; host-admin Bearer)
65
+ * /admin/grants/<id>/approve (POST) → operator approves a grant — mint (vault) / store (service) / static-bearer-or-start-OAuth (mcp, 4b-2) (cookie-gated; CSRF-belted)
66
+ * /admin/grants/<id>/revoke (POST) → operator revokes a grant — drop the stored secret + best-effort issuer revoke (mcp, 4b-2) (cookie-gated; CSRF-belted)
67
+ * /oauth/agent-grant/callback (GET) → OAuth-client redirect target for an mcp grant consent (4b-2; single-use state, no Bearer, NOT same-origin-belted — cross-site redirect in)
59
68
  *
60
69
  * # "CSRF-belted" = strict same-origin Origin check on cookie-authed
61
70
  * # mutations (hub#632, boundary C1) — origin-check.ts
62
71
  * # `assertSameOriginForCookieMutation` carries the canonical enumeration.
63
72
  * /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
73
+ * /api/admin-lock (GET) → screen-lock status (cookie-gated; first-admin)
74
+ * /api/admin-lock/{set,change,remove,unlock,lock,heartbeat} (POST) → manage the optional admin idle PIN lock (cookie-gated; CSRF)
64
75
  * /api/hub (GET) → hub version + uptime + install-source (host:admin)
65
76
  * /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
66
77
  * /api/hub/upgrade/status (GET) → poll the on-disk hub-upgrade status (host:admin)
@@ -150,7 +161,12 @@ import pkg from "../package.json" with { type: "json" };
150
161
  import { handleAccountSetupGet, handleAccountSetupPost } from "./account-setup.ts";
151
162
  import { handleAccountVaultAdminTokenPost } from "./account-vault-admin-token.ts";
152
163
  import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
153
- import { handleChannelToken } from "./admin-channel-token.ts";
164
+ import {
165
+ type AgentGrantsDeps,
166
+ handleAgentGrants,
167
+ handleOAuthGrantCallback,
168
+ } from "./admin-agent-grants.ts";
169
+ import { handleAgentToken } from "./admin-agent-token.ts";
154
170
  import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
155
171
  import {
156
172
  type ConnectionsDeps,
@@ -173,6 +189,7 @@ import {
173
189
  handleAccountChangePasswordPost,
174
190
  handleAccountHomeGet,
175
191
  } from "./api-account.ts";
192
+ import { handleAdminLock } from "./api-admin-lock.ts";
176
193
  import { handleHubUpgrade, handleHubUpgradeStatus } from "./api-hub-upgrade.ts";
177
194
  import { handleApiHub } from "./api-hub.ts";
178
195
  import { handleCreateInvite, handleListInvites, handleRevokeInvite } from "./api-invites.ts";
@@ -204,7 +221,12 @@ import {
204
221
  handleResetUserPassword,
205
222
  handleUpdateUserVaults,
206
223
  } from "./api-users.ts";
207
- import { buildChromeForRequest, injectChromeIntoResponse } from "./chrome-strip.ts";
224
+ import { gateUiAudience, resolveUiMount } from "./audience-gate.ts";
225
+ import {
226
+ CHROME_OPT_OUT_PREFIXES,
227
+ buildChromeForRequest,
228
+ injectChromeIntoResponse,
229
+ } from "./chrome-strip.ts";
208
230
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
209
231
  import { applyCorsHeaders, corsPreflightResponse, isCorsAllowedRoute } from "./cors.ts";
210
232
  import { ensureCsrfToken } from "./csrf.ts";
@@ -270,6 +292,8 @@ import {
270
292
  isVaultEntry,
271
293
  vaultInstanceNameFor,
272
294
  } from "./well-known.ts";
295
+ import { type WsBridgeData, createWsBridgeHandlers } from "./ws-bridge.ts";
296
+ import { type WsConnectionTracker, defaultWsConnectionTracker } from "./ws-connection-caps.ts";
273
297
 
274
298
  interface Args {
275
299
  port: number;
@@ -448,7 +472,7 @@ function hasVaultInstalled(manifestPath: string): boolean {
448
472
  * posture as `/api/modules`.
449
473
  *
450
474
  * `mount` is the first non-`.parachute` services.json path (the proxied
451
- * user-facing prefix, e.g. `/channel`), which the engine joins with a sink
475
+ * user-facing prefix, e.g. `/agent`), which the engine joins with a sink
452
476
  * action's `endpoint` to build the hub-proxied webhook.
453
477
  */
454
478
  async function collectInstalledModules(
@@ -478,6 +502,23 @@ async function collectInstalledModules(
478
502
  return out;
479
503
  }
480
504
 
505
+ /**
506
+ * Resolve a module's loopback origin by SHORT name from services.json — the
507
+ * H4 credential-delivery seam (the Connections engine POSTs minted
508
+ * credentials + removal payloads direct to the daemon, not through the hub
509
+ * proxy). Short derivation mirrors `collectInstalledModules`:
510
+ * `shortNameForManifest(name) ?? name`, so third-party modules (whose row
511
+ * name IS their short) resolve too. Read per-request — a module installed
512
+ * seconds ago is deliverable without a hub restart.
513
+ */
514
+ function makeResolveModuleOrigin(manifestPath: string): (short: string) => string | null {
515
+ return (short) => {
516
+ const services = readManifestLenient(manifestPath).services;
517
+ const entry = services.find((s) => (shortNameForManifest(s.name) ?? s.name) === short);
518
+ return entry ? `http://127.0.0.1:${entry.port}` : null;
519
+ };
520
+ }
521
+
481
522
  /**
482
523
  * The trust layer a request arrived through. Hub binds `127.0.0.1:1939`, so
483
524
  * every request reaches it via one of three trusted forwarders (or directly
@@ -564,6 +605,125 @@ function isLoopbackPeer(peerAddr: string | null | undefined): boolean {
564
605
  );
565
606
  }
566
607
 
608
+ /**
609
+ * The two substrate trust headers the hub stamps on every forwarded request
610
+ * (H2, surface-runtime-primitives design §10):
611
+ *
612
+ * X-Parachute-Layer — the `layerOf` classification ("loopback" |
613
+ * "tailnet" | "public"), fail-closed to "public"
614
+ * when the peer address is unknown.
615
+ * X-Parachute-Client-IP — the resolved client IP (CF-Connecting-IP →
616
+ * X-Forwarded-For first hop → peer address; same
617
+ * precedence as rate-limit.ts `clientIpFromRequest`,
618
+ * with the peer address as the direct-caller floor).
619
+ *
620
+ * Backends (surface-host's `ctx.layer` / `ctx.clientIp`, any module reading
621
+ * trust signals) consume THESE, never raw forwarder headers — the hub is the
622
+ * only component that can see the actual peer socket, so it's the only place
623
+ * the classification can be made fail-closed.
624
+ */
625
+ export const PARACHUTE_LAYER_HEADER = "x-parachute-layer";
626
+ export const PARACHUTE_CLIENT_IP_HEADER = "x-parachute-client-ip";
627
+
628
+ /**
629
+ * Resolve the client IP for the X-Parachute-Client-IP stamp. Precedence:
630
+ *
631
+ * 1. `CF-Connecting-IP` — cloudflared stamps the actual client IP on every
632
+ * forwarded request (authoritative on cloudflare-fronted hubs).
633
+ * 2. `X-Forwarded-For` first hop — tailscale serve/funnel and generic
634
+ * reverse proxies set it; the leftmost entry is the original client.
635
+ * 3. The peer address itself — the direct caller (loopback CLI, or a
636
+ * direct network peer on a 0.0.0.0 bind).
637
+ *
638
+ * Returns null when nothing resolves (no forwarder headers AND no peer
639
+ * address — e.g. a unit test calling the fetch fn without a Server). The
640
+ * caller omits the header in that case; backends treat absence as null.
641
+ *
642
+ * Known limitation (same as the rate-limiter's keying): a DIRECT caller can
643
+ * spoof the forwarded-IP headers and misattribute its own address. It cannot
644
+ * spoof the LAYER (layerOf classifies direct non-loopback peers as "public"
645
+ * regardless of injected headers), so the trust signal stays sound — only
646
+ * the attribution string is best-effort for direct callers.
647
+ */
648
+ export function resolveClientIp(req: Request, peerAddr: string | null): string | null {
649
+ const cf = req.headers.get("cf-connecting-ip")?.trim();
650
+ if (cf) return cf;
651
+ const xff = req.headers.get("x-forwarded-for");
652
+ if (xff) {
653
+ const first = xff.split(",")[0]?.trim();
654
+ if (first) return first;
655
+ }
656
+ const peer = peerAddr?.trim();
657
+ return peer ? peer : null;
658
+ }
659
+
660
+ /**
661
+ * Strip any inbound occurrences of the substrate trust headers, then stamp
662
+ * the hub's own classification. The strip is load-bearing: a public client
663
+ * sending `X-Parachute-Layer: loopback` (or a forged client IP) must never
664
+ * ride that injection past the proxy into a module that keys trust off it.
665
+ * Mutates `headers` in place (the proxy's outgoing header bag).
666
+ */
667
+ export function stampSubstrateTrustHeaders(
668
+ headers: Headers,
669
+ req: Request,
670
+ peerAddr: string | null,
671
+ ): void {
672
+ headers.delete(PARACHUTE_LAYER_HEADER);
673
+ headers.delete(PARACHUTE_CLIENT_IP_HEADER);
674
+ headers.set(PARACHUTE_LAYER_HEADER, layerOf(req, peerAddr));
675
+ const clientIp = resolveClientIp(req, peerAddr);
676
+ if (clientIp) headers.set(PARACHUTE_CLIENT_IP_HEADER, clientIp);
677
+ }
678
+
679
+ /**
680
+ * Shared bucket for connections whose client IP cannot be derived at all
681
+ * (no forwarder headers AND no peer address). Fail-closed: they all contend
682
+ * for one per-IP allotment rather than each minting a fresh bucket — the
683
+ * same posture as rate-limit.ts's UNKNOWN_IP_SENTINEL.
684
+ */
685
+ export const WS_CAP_SHARED_BUCKET = "unknown";
686
+
687
+ /**
688
+ * Derive the connection-cap bucket key for a WS upgrade (hub#649).
689
+ *
690
+ * STRICTER than {@link resolveClientIp} on purpose. The H2 attribution stamp
691
+ * tolerates a direct caller misattributing itself (documented limitation —
692
+ * the LAYER stays truthful, only the attribution string is best-effort). A
693
+ * cap key cannot afford that tolerance: if a direct peer's forged
694
+ * X-Forwarded-For were believed, rotating the header would mint a fresh
695
+ * bucket per connection and the per-IP cap would never trip. So forwarded
696
+ * IP headers are believed ONLY when the peer is loopback — the hub's actual
697
+ * forwarder topology (cloudflared, tailscale serve/funnel) runs on-box and
698
+ * dials 127.0.0.1; nothing else legitimately presents those headers from
699
+ * loopback, and a remote attacker can't BE loopback.
700
+ *
701
+ * - loopback peer: CF-Connecting-IP → X-Forwarded-For first hop → the
702
+ * loopback address itself (direct local callers share one bucket —
703
+ * owner-operated, and the cap is configurable).
704
+ * - non-loopback peer: the peer address, regardless of injected headers
705
+ * (spoofed XFF on an untrusted layer lands in the spoofer's own bucket).
706
+ * - no peer derivable: {@link WS_CAP_SHARED_BUCKET} (fail closed).
707
+ *
708
+ * Known limitation, container deploys (Render / Fly): the platform edge
709
+ * dials from a private non-loopback address, so all public clients share
710
+ * the edge peer's bucket there — the per-IP cap degrades to a coarse shared
711
+ * cap and the global cap is the operative bound. Raise
712
+ * PARACHUTE_WS_MAX_PER_IP on such deploys; a trusted-proxy allowlist can
713
+ * refine this when a cloud WS surface actually ships.
714
+ */
715
+ export function wsCapBucketKey(req: Request, peerAddr: string | null): string {
716
+ const peer = peerAddr?.trim() || null;
717
+ if (peer && isLoopbackPeer(peer)) {
718
+ const cf = req.headers.get("cf-connecting-ip")?.trim();
719
+ if (cf) return cf;
720
+ const xff = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
721
+ if (xff) return xff;
722
+ return peer;
723
+ }
724
+ return peer ?? WS_CAP_SHARED_BUCKET;
725
+ }
726
+
567
727
  /**
568
728
  * Forward a request to a loopback service on `127.0.0.1:<port>`. By default
569
729
  * the incoming pathname + query are preserved verbatim; pass `targetPath` to
@@ -595,10 +755,17 @@ function isLoopbackPeer(peerAddr: string | null | undefined): boolean {
595
755
  * `short` is the canonical short (`vault`/`scribe`/`notes`) — used as
596
756
  * the supervisor map key + pidfile directory key for classification.
597
757
  *
598
- * Hop-by-hop notes: WebSocket upgrades and HTTP/2 trailers don't traverse
599
- * fetch-based proxies cleanly. No on-box service uses either today; if one
600
- * eventually needs them, switch to a Node http.IncomingMessage / http.request
601
- * pair.
758
+ * `peerAddr` is the resolved peer address (`server.requestIP`), threaded so
759
+ * the substrate trust headers (below) classify the layer the same way the
760
+ * `publicExposure` cloak does fail-closed to `public` when unknown.
761
+ *
762
+ * Hop-by-hop notes: HTTP/2 trailers don't traverse fetch-based proxies
763
+ * cleanly; no on-box service uses them today. WebSocket upgrades CANNOT
764
+ * traverse this fetch-based path either — they're handled BEFORE dispatch by
765
+ * the Bun-native upgrade bridge (H1: `maybeUpgradeWebSocket` +
766
+ * `src/ws-bridge.ts`) for modules that declare the capability; an upgrade
767
+ * request reaching this function belongs to a non-declaring mount and the
768
+ * upstream sees a plain GET.
602
769
  */
603
770
  async function proxyRequest(
604
771
  req: Request,
@@ -606,6 +773,7 @@ async function proxyRequest(
606
773
  serviceLabel: string,
607
774
  short: string,
608
775
  supervisor: Supervisor | undefined,
776
+ peerAddr: string | null,
609
777
  targetPath?: string,
610
778
  ): Promise<Response> {
611
779
  const url = new URL(req.url);
@@ -651,6 +819,11 @@ async function proxyRequest(
651
819
  if (!headers.has("x-forwarded-proto")) {
652
820
  headers.set("x-forwarded-proto", isHttpsRequest(req) ? "https" : "http");
653
821
  }
822
+ // Substrate trust headers (H2, surface-runtime design §10): stamped on
823
+ // EVERY forwarded request so module backends read trust signals from the
824
+ // substrate instead of re-deriving them from raw forwarder headers (the
825
+ // "header-absence = local trust" anti-pattern the design rejects).
826
+ stampSubstrateTrustHeaders(headers, req, peerAddr);
654
827
 
655
828
  const init: RequestInit & { duplex?: "half" } = {
656
829
  method: req.method,
@@ -761,7 +934,7 @@ async function proxyToVault(
761
934
  // vault instances share the same supervisor key under hub's current
762
935
  // single-vault-per-hub model; if multi-vault-per-hub ever ships, the
763
936
  // classifier will need a per-instance key.
764
- return proxyRequest(req, match.port, "vault", "vault", supervisor, targetPath);
937
+ return proxyRequest(req, match.port, "vault", "vault", supervisor, peerAddr, targetPath);
765
938
  }
766
939
 
767
940
  /**
@@ -770,7 +943,7 @@ async function proxyToVault(
770
943
  * hub-module-boundary migration), NOT a per-instance path.
771
944
  *
772
945
  * Resolution is via `findServiceByShort(services, "vault")` (the canonical
773
- * self-registered `parachute-vault` row — same shape as the channelEntry
946
+ * self-registered `parachute-vault` row — same shape as the agentEntry
774
947
  * lookup in the Connections deps), deliberately NOT `findVaultUpstream`:
775
948
  * vault must NOT self-register `/vault/admin` in `paths[]`, because every
776
949
  * consumer that derives instance names from paths (`vaultInstanceNameFor`,
@@ -805,7 +978,7 @@ async function proxyToVaultAdmin(
805
978
  if (effectivePublicExposure(entry) === "loopback" && layerOf(req, peerAddr) !== "loopback") {
806
979
  return new Response("not found", { status: 404 });
807
980
  }
808
- return proxyRequest(req, entry.port, "vault", "vault", supervisor);
981
+ return proxyRequest(req, entry.port, "vault", "vault", supervisor, peerAddr);
809
982
  }
810
983
 
811
984
  /**
@@ -896,7 +1069,8 @@ async function proxyToService(
896
1069
  }
897
1070
  // Consult FIRST_PARTY_FALLBACKS / KNOWN_MODULES as a fallback for
898
1071
  // `stripPrefix` (#196). Pre-hub#310, scribe's `stripPrefix: true` lived
899
- // only in hub's vendored fallback; post-#310 scribe (and post-D3 channel)
1072
+ // only in hub's vendored fallback; post-#310 scribe (and post-D3 agent,
1073
+ // renamed from channel 2026-06-17)
900
1074
  // self-register with `stripPrefix: true` on their rows, so the entry-based
901
1075
  // path is authoritative. The registry consultation now matters only for
902
1076
  // notes (the remaining FALLBACK short) and legacy rows written before the
@@ -911,19 +1085,19 @@ async function proxyToService(
911
1085
  // will land in "persistent" by default which is the safer choice for
912
1086
  // unknown lifecycle).
913
1087
  const short = shortNameForManifest(match.entry.name) ?? match.entry.name;
914
- return proxyRequest(req, match.port, match.entry.name, short, supervisor, targetPath);
1088
+ return proxyRequest(req, match.port, match.entry.name, short, supervisor, peerAddr, targetPath);
915
1089
  }
916
1090
 
917
1091
  /**
918
1092
  * Resolve effective `stripPrefix` for a service entry. Explicit on-entry
919
1093
  * wins; otherwise consult `FIRST_PARTY_FALLBACKS` keyed by short name (for
920
1094
  * notes — vault/scribe/runner retired their FALLBACK entries in hub#310,
921
- * channel in boundary D3; all self-register with the canonical `stripPrefix`
922
- * declaration on their services.json row).
1095
+ * agent (renamed from channel 2026-06-17) in boundary D3; all self-register
1096
+ * with the canonical `stripPrefix` declaration on their services.json row).
923
1097
  * `KNOWN_MODULES[short]?.canonicalStripPrefix` is the next fallback — covers
924
1098
  * the edge case where a self-registering module wrote its row before the
925
1099
  * `stripPrefix` field was being emitted (e.g. pre-scribe#50 or pre-D3
926
- * channel services.json rows). Defaults to `false` — keep the prefix —
1100
+ * agent services.json rows). Defaults to `false` — keep the prefix —
927
1101
  * matching the pre-#196 dispatch behavior for unknown / third-party
928
1102
  * services.
929
1103
  *
@@ -989,6 +1163,17 @@ export interface HubFetchDeps {
989
1163
  * at a tmpdir; production defaults to `<CONFIG_DIR>/connections.json`.
990
1164
  */
991
1165
  connectionsStorePath?: string;
1166
+ /**
1167
+ * Path to `agent-grants.json` (the agent-connector grant store, 4b-1). Tests
1168
+ * point this at a tmpdir; production defaults to `<CONFIG_DIR>/agent-grants.json`.
1169
+ */
1170
+ agentGrantsStorePath?: string;
1171
+ /**
1172
+ * Path to `agent-oauth-flows.json` (the in-flight agent-grant OAuth consents,
1173
+ * 4b-2). Tests point this at a tmpdir; production defaults to
1174
+ * `<CONFIG_DIR>/agent-oauth-flows.json`.
1175
+ */
1176
+ agentOAuthFlowsStorePath?: string;
992
1177
  /**
993
1178
  * Directory containing the built SPA bundle (`index.html` + `assets/`). When
994
1179
  * absent, the hub auto-resolves to `<repo>/web/ui/dist/` — handy for the
@@ -1038,6 +1223,15 @@ export interface HubFetchDeps {
1038
1223
  * CLI commands directly.
1039
1224
  */
1040
1225
  supervisor?: Supervisor;
1226
+ /**
1227
+ * WebSocket connection-cap accounting (hub#649). Production uses the
1228
+ * process-wide {@link defaultWsConnectionTracker} (caps from env at boot);
1229
+ * tests inject their own tracker so they neither consume nor depend on the
1230
+ * shared counters. Release pairing is structural — the acquire site stashes
1231
+ * the release closure on the upgraded socket's `data`, so a mismatched
1232
+ * tracker between fetch fn and bridge handlers is impossible.
1233
+ */
1234
+ wsConnectionTracker?: WsConnectionTracker;
1041
1235
  }
1042
1236
 
1043
1237
  /**
@@ -1445,15 +1639,230 @@ export function resolveIssuerSource(
1445
1639
 
1446
1640
  /**
1447
1641
  * Minimal structural type for the Bun `Server` handle the fetch callback
1448
- * receives as its 2nd argument. We only need `requestIP` (item E / #526) to
1449
- * resolve the peer address for `layerOf`. Typed structurally (rather than
1642
+ * receives as its 2nd argument. We need `requestIP` (item E / #526) to
1643
+ * resolve the peer address for `layerOf`, and `upgrade` (H1) to hand a
1644
+ * gated WebSocket upgrade to the bridge. Typed structurally (rather than
1450
1645
  * importing Bun's full `Server`) so tests can pass a tiny fake and so the
1451
1646
  * signature stays robust to Bun type-shape churn. Optional in the callback
1452
1647
  * because a direct unit call to the returned fetch fn may omit it — in which
1453
- * case `peerAddr` is null and `layerOf` fails closed to `public`.
1648
+ * case `peerAddr` is null and `layerOf` fails closed to `public`, and a
1649
+ * WebSocket upgrade is refused (503 — no server to upgrade on).
1454
1650
  */
1455
1651
  interface PeerIpResolver {
1456
1652
  requestIP(req: Request): { address: string } | null;
1653
+ /**
1654
+ * Bun `Server.upgrade` — present on the real server, optional on fakes.
1655
+ * Typed with the bridge's data payload (Bun's own signature takes
1656
+ * `data: unknown`; method bivariance keeps the real Server assignable).
1657
+ */
1658
+ upgrade?(req: Request, options: { data: WsBridgeData }): boolean;
1659
+ }
1660
+
1661
+ /**
1662
+ * True when the request is a WebSocket upgrade. The `Upgrade` header is the
1663
+ * discriminator (RFC 6455 §4.1 requires it; Bun's `server.upgrade` re-checks
1664
+ * the full handshake — key, version, Connection token — so this only needs
1665
+ * to be a cheap router predicate, not a validator).
1666
+ */
1667
+ export function isWebSocketUpgrade(req: Request): boolean {
1668
+ return (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
1669
+ }
1670
+
1671
+ /**
1672
+ * Hop-by-hop + WS-handshake headers never forwarded on the upstream connect:
1673
+ * the Bun WebSocket client re-mints its own handshake (key/version/
1674
+ * extensions), and forwarding the originals would corrupt it.
1675
+ */
1676
+ const WS_HOP_BY_HOP_HEADERS = [
1677
+ "host",
1678
+ "connection",
1679
+ "upgrade",
1680
+ "keep-alive",
1681
+ "proxy-authorization",
1682
+ "te",
1683
+ "trailer",
1684
+ "transfer-encoding",
1685
+ "sec-websocket-key",
1686
+ "sec-websocket-version",
1687
+ "sec-websocket-extensions",
1688
+ "sec-websocket-accept",
1689
+ // Subprotocol negotiation is NOT forwarded in v1 (see ws-bridge.ts header).
1690
+ "sec-websocket-protocol",
1691
+ ] as const;
1692
+
1693
+ /** The verdict of {@link maybeUpgradeWebSocket}. */
1694
+ type WsUpgradeVerdict =
1695
+ | { kind: "upgraded" }
1696
+ | { kind: "response"; response: Response }
1697
+ | { kind: "pass" };
1698
+
1699
+ /**
1700
+ * H1 — the WebSocket upgrade bridge's routing + gating half (the frame
1701
+ * piping lives in `src/ws-bridge.ts`).
1702
+ *
1703
+ * For an `Upgrade: websocket` request:
1704
+ *
1705
+ * 1. Resolve the service mount (generic longest-prefix, then vault mounts —
1706
+ * same resolution as the HTTP proxies). No mount → `pass` (normal
1707
+ * dispatch 404s / handles it).
1708
+ * 2. Gate BEFORE upgrading — same posture as the HTTP path:
1709
+ * `publicExposure: "loopback"` cloak (404, indistinguishable from
1710
+ * not-installed) and the per-UI audience gate (H3).
1711
+ * 3. Capability check, DENY BY DEFAULT: the module must declare
1712
+ * `websocket: true` on its services.json row OR its
1713
+ * `.parachute/module.json`. No declaration → 426 (the route exists but
1714
+ * doesn't speak WebSocket; the fetch-based proxy can't forward upgrades
1715
+ * and the daemon never sees the request).
1716
+ * 4. Connection caps (hub#649): per-client-IP + total concurrent caps,
1717
+ * checked-and-acquired in the same synchronous block as the upgrade
1718
+ * (no await between check and commit). Over-cap → generic 429 (no
1719
+ * count leakage; the hub log carries which cap + bucket), refused
1720
+ * BEFORE `server.upgrade()` commits a socket or the bridge dials the
1721
+ * upstream. Keying + trust model: {@link wsCapBucketKey}; defaults +
1722
+ * env overrides: `ws-connection-caps.ts`. Release rides the bridge's
1723
+ * close handler via `data.releaseCap`.
1724
+ * 5. `server.upgrade(req, { data })` with the upstream URL + headers
1725
+ * (client headers minus hop-by-hop/handshake, plus the H2 substrate
1726
+ * trust stamps). The ws-bridge handlers take over from there.
1727
+ */
1728
+ async function maybeUpgradeWebSocket(
1729
+ req: Request,
1730
+ server: PeerIpResolver | undefined,
1731
+ deps: {
1732
+ manifestPath: string;
1733
+ peerAddr: string | null;
1734
+ readModuleManifestFn: (installDir: string) => Promise<ModuleManifest | null>;
1735
+ /** H3 — gate the upgrade on the mount's audience BEFORE upgrading. */
1736
+ gateAudience?: (pathname: string) => Promise<Response | null>;
1737
+ /** hub#649 — per-IP + total connection-cap accounting. */
1738
+ wsConnectionTracker: WsConnectionTracker;
1739
+ },
1740
+ ): Promise<WsUpgradeVerdict> {
1741
+ const services = readManifestLenient(deps.manifestPath).services;
1742
+ const url = new URL(req.url);
1743
+ const match =
1744
+ findServiceUpstream(services, url.pathname) ?? findVaultUpstream(services, url.pathname);
1745
+ if (!match) return { kind: "pass" };
1746
+
1747
+ // Layer cloak first — a loopback-only module must look not-installed from
1748
+ // tailnet/public, for upgrades exactly as for HTTP.
1749
+ if (
1750
+ effectivePublicExposure(match.entry) === "loopback" &&
1751
+ layerOf(req, deps.peerAddr) !== "loopback"
1752
+ ) {
1753
+ return { kind: "response", response: new Response("not found", { status: 404 }) };
1754
+ }
1755
+
1756
+ // Audience gate (H3) — runs BEFORE the upgrade so an unauthorized client
1757
+ // never gets a socket. Threaded from dispatch (needs db + issuer).
1758
+ if (deps.gateAudience) {
1759
+ const gated = await deps.gateAudience(url.pathname);
1760
+ if (gated) return { kind: "response", response: gated };
1761
+ }
1762
+
1763
+ // Capability — deny by default. services.json row wins; module.json is the
1764
+ // canonical declaration source for modules that haven't re-registered yet.
1765
+ let declared = match.entry.websocket === true;
1766
+ if (!declared && match.entry.installDir) {
1767
+ try {
1768
+ const manifest = await deps.readModuleManifestFn(match.entry.installDir);
1769
+ declared = manifest?.websocket === true;
1770
+ } catch {
1771
+ declared = false; // malformed manifest → deny (fail closed)
1772
+ }
1773
+ }
1774
+ if (!declared) {
1775
+ return {
1776
+ kind: "response",
1777
+ response: new Response(
1778
+ JSON.stringify({
1779
+ error: "websocket_not_supported",
1780
+ error_description: `module "${match.entry.name}" does not declare WebSocket support`,
1781
+ }),
1782
+ { status: 426, headers: { "content-type": "application/json", upgrade: "websocket" } },
1783
+ ),
1784
+ };
1785
+ }
1786
+
1787
+ if (!server?.upgrade) {
1788
+ return {
1789
+ kind: "response",
1790
+ response: new Response(
1791
+ JSON.stringify({
1792
+ error: "service_unavailable",
1793
+ error_description: "websocket upgrade unavailable on this server",
1794
+ }),
1795
+ { status: 503, headers: { "content-type": "application/json" } },
1796
+ ),
1797
+ };
1798
+ }
1799
+
1800
+ // Upstream URL — same path semantics as the HTTP proxy (stripPrefix honored).
1801
+ const stripPrefix = stripPrefixFor(match.entry);
1802
+ const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : url.pathname;
1803
+ const upstreamUrl = `ws://127.0.0.1:${match.port}${targetPath}${url.search}`;
1804
+
1805
+ // Upstream headers: the client's own (cookie / authorization ride through
1806
+ // so the daemon authenticates the connection) minus hop-by-hop + handshake
1807
+ // headers, plus the H2 substrate trust stamps.
1808
+ const headers = new Headers(req.headers);
1809
+ for (const h of WS_HOP_BY_HOP_HEADERS) headers.delete(h);
1810
+ stampSubstrateTrustHeaders(headers, req, deps.peerAddr);
1811
+ const upstreamHeaders: Record<string, string> = {};
1812
+ headers.forEach((value, key) => {
1813
+ upstreamHeaders[key] = value;
1814
+ });
1815
+
1816
+ // Connection caps (hub#649) — the LAST gate, synchronous with the upgrade
1817
+ // itself (everything between here and `server.upgrade` must stay
1818
+ // await-free so the check can't race the commit). Last on purpose: the
1819
+ // earlier refusals keep their precise statuses (the 404 cloak stays
1820
+ // indistinguishable from not-installed even under cap pressure), and the
1821
+ // counters only ever hold slots for connections that would actually
1822
+ // bridge.
1823
+ const capKey = wsCapBucketKey(req, deps.peerAddr);
1824
+ const acquired = deps.wsConnectionTracker.tryAcquire(capKey);
1825
+ if (!acquired.ok) {
1826
+ // Operator-facing pressure signal: which cap, which bucket, how full.
1827
+ // None of this reaches the client — the 429 body is deliberately
1828
+ // generic (no counts, no cap identity).
1829
+ console.warn(
1830
+ `[ws-caps] refused upgrade for ${url.pathname}: ${
1831
+ acquired.reason === "per_ip_cap" ? `per-IP cap (ip=${capKey})` : `total cap (ip=${capKey})`
1832
+ }; total=${deps.wsConnectionTracker.totalCount} ip_count=${deps.wsConnectionTracker.countFor(
1833
+ capKey,
1834
+ )}`,
1835
+ );
1836
+ return {
1837
+ kind: "response",
1838
+ response: new Response(
1839
+ JSON.stringify({
1840
+ error: "too_many_connections",
1841
+ error_description: "WebSocket connection limit reached; try again later",
1842
+ }),
1843
+ { status: 429, headers: { "content-type": "application/json" } },
1844
+ ),
1845
+ };
1846
+ }
1847
+
1848
+ const upgraded = server.upgrade(req, {
1849
+ data: { upstreamUrl, upstreamHeaders, releaseCap: acquired.release },
1850
+ });
1851
+ if (upgraded) return { kind: "upgraded" };
1852
+ // No socket was created, so the bridge's close handler will never fire —
1853
+ // release the slot inline (the closure latches, so this can't double-count
1854
+ // against a later close).
1855
+ acquired.release();
1856
+ return {
1857
+ kind: "response",
1858
+ response: new Response(
1859
+ JSON.stringify({
1860
+ error: "upgrade_failed",
1861
+ error_description: "WebSocket handshake was malformed or could not be completed",
1862
+ }),
1863
+ { status: 400, headers: { "content-type": "application/json" } },
1864
+ ),
1865
+ };
1457
1866
  }
1458
1867
 
1459
1868
  /**
@@ -1584,6 +1993,49 @@ export function hubFetch(
1584
1993
  // error detail"). A transient SQLITE_BUSY is classified non-fatal and just
1585
1994
  // surfaces a 503 the next request clears — it never kills the hub.
1586
1995
  try {
1996
+ // H1 — WebSocket upgrade bridge. Runs before normal dispatch: an
1997
+ // `Upgrade: websocket` request targeting a declared service mount is
1998
+ // gated (publicExposure cloak + audience gate) and, if it passes,
1999
+ // upgraded into the Bun-native bridge (src/ws-bridge.ts) instead of
2000
+ // the fetch-based proxy (which cannot forward upgrades). Upgrade
2001
+ // requests that match no service mount fall through to normal dispatch
2002
+ // unchanged — no hub-owned route speaks WebSocket.
2003
+ if (isWebSocketUpgrade(req)) {
2004
+ const verdict = await maybeUpgradeWebSocket(req, server, {
2005
+ manifestPath,
2006
+ peerAddr,
2007
+ readModuleManifestFn: deps?.readModuleManifest ?? defaultReadModuleManifest,
2008
+ wsConnectionTracker: deps?.wsConnectionTracker ?? defaultWsConnectionTracker,
2009
+ // H3 — the audience gate runs BEFORE the upgrade, same posture as
2010
+ // the HTTP dispatch below: a WS endpoint under a hub-users surface
2011
+ // never hands a socket to an anonymous caller, while `surface`
2012
+ // audiences pass through (the backed surface authenticates the
2013
+ // socket itself — e.g. the docs editor's collab WS rides this).
2014
+ // (The publicExposure cloak already ran inside
2015
+ // maybeUpgradeWebSocket before this hook.)
2016
+ gateAudience: async (wsPathname) => {
2017
+ const wsUiMatch = resolveUiMount(
2018
+ readManifestLenient(manifestPath).services,
2019
+ wsPathname,
2020
+ );
2021
+ if (!wsUiMatch) return null;
2022
+ return gateUiAudience(req, wsUiMatch.audience, wsUiMatch.ui, {
2023
+ db: getDb?.(),
2024
+ knownIssuers: () => oauthDeps(req).hubBoundOrigins(),
2025
+ });
2026
+ },
2027
+ });
2028
+ if (verdict.kind === "upgraded") {
2029
+ // Bun's contract after a successful `server.upgrade()` is to
2030
+ // return undefined from fetch — the socket now belongs to the
2031
+ // websocket handlers. The public signature stays Response-typed
2032
+ // for the many direct (non-WS) call sites; this cast is the one
2033
+ // deliberate exception, observed only by Bun's runtime.
2034
+ return undefined as unknown as Response;
2035
+ }
2036
+ if (verdict.kind === "response") return verdict.response;
2037
+ // kind === "pass" — fall through to normal dispatch.
2038
+ }
1587
2039
  return await dispatch();
1588
2040
  } catch (err) {
1589
2041
  const klass = classifyDbError(err);
@@ -1717,6 +2169,27 @@ export function hubFetch(
1717
2169
  }
1718
2170
  }
1719
2171
 
2172
+ // `/channel/*` 301-redirects to `/agent/*` — back-compat for the
2173
+ // 2026-06-17 channel→agent module rename. Operator bookmarks, an
2174
+ // un-upgraded chat/config UI's deep links, and any externally-shared
2175
+ // `/channel/mcp/<name>` URL keep resolving for one release cycle while
2176
+ // the module's canonical mount moves to `/agent`. Method-agnostic, same
2177
+ // shape as the `/notes/*` redirect above (the agent's read-write surface
2178
+ // is its own daemon API, not the hub mount, so a re-issued GET is fine).
2179
+ // Matches `/channel` exactly and any `/channel/...` subpath, but NOT a
2180
+ // longer-prefix module like a hypothetical `/channelthing` — the guard is
2181
+ // exact-or-slash-delimited. Query string is preserved. (The generic
2182
+ // services-proxy fallthrough below would otherwise 404 a `/channel/*`
2183
+ // request once the module self-registers under `/agent`.)
2184
+ if (pathname === "/channel" || pathname.startsWith("/channel/")) {
2185
+ const dest = new URL(req.url);
2186
+ dest.pathname = `/agent${pathname.slice("/channel".length)}`;
2187
+ return new Response("", {
2188
+ status: 301,
2189
+ headers: { location: dest.pathname + dest.search },
2190
+ });
2191
+ }
2192
+
1720
2193
  // CORS preflight for the public OAuth + discovery surface. Browsers
1721
2194
  // issue OPTIONS before any non-simple cross-origin request — third-party
1722
2195
  // SPAs hitting `/oauth/register` (RFC 7591 DCR), `/oauth/token`,
@@ -1888,6 +2361,41 @@ export function hubFetch(
1888
2361
  }
1889
2362
  }
1890
2363
 
2364
+ // Fresh-hub `/login` funnel (hub#644). `/login` is a browser-facing,
2365
+ // server-rendered HTML surface (the sign-in form) — but on a no-admin
2366
+ // box there is no account to sign in as, so the JSON-503 gate below
2367
+ // would render as raw `{"error":"setup_required",...}` text in the
2368
+ // visitor's tab. This is exactly the path a visitor takes when they
2369
+ // load an open module surface (e.g. /vault/admin/) and click its
2370
+ // "Sign in" banner, which links to `/login?next=<surface>`. Funnel the
2371
+ // GET to the wizard instead, mirroring the `/` + `/hub.html` redirect
2372
+ // above (same shape, same justification: never emit a JSON body on an
2373
+ // HTML surface). 302 (not 301) so it disappears the moment setup
2374
+ // completes and the real sign-in form takes over.
2375
+ //
2376
+ // Scoped to `userCount === 0` (the true no-admin state), NOT the
2377
+ // broader `needsWizard`: once an admin exists, that operator must be
2378
+ // able to reach the sign-in form even if no vault is installed yet
2379
+ // (env-seed deploys). Only GET is funneled — a POST to `/login`
2380
+ // pre-admin has no account to authenticate and falls through to the
2381
+ // JSON-503 gate, the right shape for a stray non-browser caller.
2382
+ //
2383
+ // The `?next=<surface>` param is intentionally dropped: there's no
2384
+ // account yet to return the visitor to, and the open surface they came
2385
+ // from likely can't function until setup completes. They land on the
2386
+ // wizard, not back on that surface.
2387
+ //
2388
+ // `cache-control: no-store` (the `/` + `/hub.html` funnel above omits
2389
+ // it) — this 302 reflects transient pre-setup state that flips the
2390
+ // moment an admin is created, so it must never be cached by a CDN or
2391
+ // bfcache and serve a stale "go finish setup" to a now-set-up hub.
2392
+ if (getDb && pathname === "/login" && req.method === "GET" && userCount(getDb()) === 0) {
2393
+ return new Response(null, {
2394
+ status: 302,
2395
+ headers: { location: "/admin/setup", "cache-control": "no-store" },
2396
+ });
2397
+ }
2398
+
1891
2399
  // Pre-admin lockout. When the hub has booted with no admin row (the
1892
2400
  // fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
1893
2401
  // /admin/setup is walked), every operator-facing surface that requires
@@ -2207,6 +2715,38 @@ export function hubFetch(
2207
2715
  return applyCorsHeaders(req, await handleRevoke(getDb(), req, oauthDeps(req)));
2208
2716
  }
2209
2717
 
2718
+ // Agent-connector OAuth-client callback (Phase 4b-2). The operator's
2719
+ // browser is redirected here by a REMOTE issuer after consenting to a
2720
+ // `kind:mcp` grant. Standalone server-rendered route — NOT under /admin/*,
2721
+ // so the SPA catch-all never swallows it. GET, no Bearer: the single-use
2722
+ // `state` it carries is the CSRF defense (this is a cross-site redirect IN
2723
+ // from the remote issuer, so the same-origin belt does NOT apply). The
2724
+ // handler looks up the pending flow by `state`, exchanges the code at the
2725
+ // remote token endpoint, stores the grant material, and renders a tiny
2726
+ // HTML "connected" / error page (never a token).
2727
+ if (pathname === "/oauth/agent-grant/callback") {
2728
+ if (!getDb) return dbNotConfigured();
2729
+ if (req.method !== "GET") {
2730
+ return new Response("method not allowed", { status: 405 });
2731
+ }
2732
+ const resolveVaultOrigin = (vaultName: string): string | null => {
2733
+ const match = findVaultUpstream(
2734
+ readManifestLenient(manifestPath).services,
2735
+ `/vault/${vaultName}`,
2736
+ );
2737
+ return match ? `http://127.0.0.1:${match.port}` : null;
2738
+ };
2739
+ const agentGrantsDeps: AgentGrantsDeps = {
2740
+ db: getDb(),
2741
+ hubOrigin: oauthDeps(req).issuer,
2742
+ storePath: deps?.agentGrantsStorePath ?? join(CONFIG_DIR, "agent-grants.json"),
2743
+ flowsStorePath:
2744
+ deps?.agentOAuthFlowsStorePath ?? join(CONFIG_DIR, "agent-oauth-flows.json"),
2745
+ resolveVaultOrigin,
2746
+ };
2747
+ return handleOAuthGrantCallback(req, agentGrantsDeps);
2748
+ }
2749
+
2210
2750
  if (pathname === "/vaults") {
2211
2751
  if (!getDb) return dbNotConfigured();
2212
2752
  return handleCreateVault(req, {
@@ -2223,10 +2763,10 @@ export function hubFetch(
2223
2763
  if (!getDb) return dbNotConfigured();
2224
2764
  const name = decodeURIComponent(pathname.slice("/vaults/".length));
2225
2765
  const services = readManifestLenient(manifestPath).services;
2226
- // Channel's row carries its MANIFEST name — resolve via
2766
+ // Agent's row carries its MANIFEST name — resolve via
2227
2767
  // findServiceByShort (see the /admin/connections note below).
2228
- const channelEntry = findServiceByShort(services, "channel");
2229
- const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
2768
+ const agentEntry = findServiceByShort(services, "agent");
2769
+ const agentOrigin = agentEntry ? `http://127.0.0.1:${agentEntry.port}` : null;
2230
2770
  const resolveVaultOrigin = (vaultName: string): string | null => {
2231
2771
  const match = findVaultUpstream(
2232
2772
  readManifestLenient(manifestPath).services,
@@ -2240,8 +2780,9 @@ export function hubFetch(
2240
2780
  issuer: oauthDeps(req).issuer,
2241
2781
  manifestPath,
2242
2782
  connectionsStorePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
2243
- channelOrigin,
2783
+ agentOrigin,
2244
2784
  resolveVaultOrigin,
2785
+ resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
2245
2786
  // Daemon eviction — the same in-process supervisor the lifecycle
2246
2787
  // verbs drive (module-ops API); restarting vault evicts the open
2247
2788
  // store handle + re-runs selfRegister (services.json path rebuild).
@@ -2268,9 +2809,19 @@ export function hubFetch(
2268
2809
  });
2269
2810
  }
2270
2811
 
2812
+ // Back-compat: the agent module's admin-token mint moved from
2813
+ // `/admin/channel-token` to `/admin/agent-token` in the 2026-06-17
2814
+ // channel→agent rename. 301-redirect the old path so operator bookmarks
2815
+ // + any un-upgraded UI fallback keep working for one release cycle.
2271
2816
  if (pathname === "/admin/channel-token") {
2817
+ const dest = new URL(req.url);
2818
+ dest.pathname = "/admin/agent-token";
2819
+ return Response.redirect(dest.toString(), 301);
2820
+ }
2821
+
2822
+ if (pathname === "/admin/agent-token") {
2272
2823
  if (!getDb) return dbNotConfigured();
2273
- return handleChannelToken(req, {
2824
+ return handleAgentToken(req, {
2274
2825
  db: getDb(),
2275
2826
  issuer: oauthDeps(req).issuer,
2276
2827
  });
@@ -2280,7 +2831,7 @@ export function hubFetch(
2280
2831
  // architecture, P3). `<short>:admin` for any single-audience module —
2281
2832
  // the admin scope each module-owned config UI needs to call its own
2282
2833
  // endpoints. Cookie-gated to the first-admin operator, exactly like
2283
- // /admin/channel-token + /admin/vault-admin-token. Gated on
2834
+ // /admin/agent-token + /admin/vault-admin-token. Gated on
2284
2835
  // self-registration (services.json row + readable module.json) with the
2285
2836
  // bootstrap registries as a fallback (boundary C5) — a genuinely
2286
2837
  // third-party module mints here with zero hub code changes. Vault is
@@ -2300,8 +2851,9 @@ export function hubFetch(
2300
2851
 
2301
2852
  // Note: the legacy `/admin/channels` bespoke vault-channel orchestration
2302
2853
  // endpoint (pre-Connections, hub#624 era) was retired in boundary D1 —
2303
- // superseded by the general engine below. Channel's own admin page
2304
- // drives `/admin/connections` + `/admin/channel-token`.
2854
+ // superseded by the general engine below. The agent module's own admin
2855
+ // page (renamed from channel 2026-06-17) drives `/admin/connections` +
2856
+ // `/admin/agent-token`.
2305
2857
 
2306
2858
  // Connections — the GENERAL module event→action engine (2026-06-09
2307
2859
  // modular-UI architecture, P5). `/api/connections/catalog` (GET) returns
@@ -2310,7 +2862,7 @@ export function hubFetch(
2310
2862
  // `/admin/connections/:id` (DELETE) tears down. Cookie-gated to the
2311
2863
  // first-admin operator. The provisioning engine derives the vault
2312
2864
  // trigger's webhook + scope from the SINK action's declaration —
2313
- // nothing is channel-hardcoded.
2865
+ // nothing is agent-hardcoded.
2314
2866
  if (
2315
2867
  pathname === "/api/connections/catalog" ||
2316
2868
  pathname === "/admin/connections" ||
@@ -2318,13 +2870,14 @@ export function hubFetch(
2318
2870
  ) {
2319
2871
  if (!getDb) return dbNotConfigured();
2320
2872
  const services = readManifestLenient(manifestPath).services;
2321
- // Channel's services.json row carries its MANIFEST name
2322
- // (`parachute-channel`), not the bare short `channel` — resolve via
2873
+ // Agent's services.json row carries its MANIFEST name
2874
+ // (`parachute-agent`), not the bare short `agent` — resolve via
2323
2875
  // findServiceByShort so the lookup matches the on-disk row. (A bare
2324
- // `s.name === "channel"` never matched, leaving channelOrigin null →
2325
- // "channel not installed".)
2326
- const channelEntry = findServiceByShort(services, "channel");
2327
- const channelOrigin = channelEntry ? `http://127.0.0.1:${channelEntry.port}` : null;
2876
+ // `s.name === "agent"` never matched, leaving agentOrigin null →
2877
+ // "agent not installed".) A legacy un-upgraded `parachute-channel` row
2878
+ // still resolves here via the LEGACY_MANIFEST_ALIASES fallback.
2879
+ const agentEntry = findServiceByShort(services, "agent");
2880
+ const agentOrigin = agentEntry ? `http://127.0.0.1:${agentEntry.port}` : null;
2328
2881
  const resolveVaultOrigin = (vaultName: string): string | null => {
2329
2882
  const match = findVaultUpstream(
2330
2883
  readManifestLenient(manifestPath).services,
@@ -2339,15 +2892,16 @@ export function hubFetch(
2339
2892
  hubOrigin: oauthDeps(req).issuer,
2340
2893
  modules,
2341
2894
  resolveVaultOrigin,
2342
- channelOrigin,
2895
+ resolveModuleOrigin: makeResolveModuleOrigin(manifestPath),
2896
+ agentOrigin,
2343
2897
  storePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
2344
2898
  };
2345
2899
  if (pathname === "/api/connections/catalog") {
2346
2900
  return handleConnectionsCatalog(req, connectionsDeps);
2347
2901
  }
2348
2902
  // CSRF belt (hub#632, boundary C1): cookie-authed POST/DELETE must
2349
- // carry a matching Origin. The seam's canonical consumer — channel's
2350
- // admin page POSTing link-vault with `credentials: "include"` — is a
2903
+ // carry a matching Origin. The seam's canonical consumer — the agent
2904
+ // module's admin page POSTing link-vault with `credentials: "include"` — is a
2351
2905
  // same-origin fetch() and passes; see origin-check.ts
2352
2906
  // `assertSameOriginForCookieMutation` for the belted-endpoint
2353
2907
  // enumeration.
@@ -2359,6 +2913,43 @@ export function hubFetch(
2359
2913
  return handleConnections(req, subPath, connectionsDeps);
2360
2914
  }
2361
2915
 
2916
+ // Agent-connector GRANTS — the approval-gated resource-grant subsystem
2917
+ // (Phase 4b-1, agent-connectors design 2026-06-17). Generalizes the
2918
+ // Connections engine from "event→action triggers" to "approval-gated
2919
+ // resource grants": an agent declares connections it WANTS beyond its
2920
+ // def-vault; the agent module registers each as a pending grant (PUT,
2921
+ // host-admin Bearer); the operator approves per-connection (POST
2922
+ // /approve, first-admin cookie); the hub mints (vault) / stores (service)
2923
+ // the secret; the agent module fetches it at spawn (GET /material,
2924
+ // host-admin Bearer). Two auth classes split by route inside the handler.
2925
+ if (pathname === "/admin/grants" || pathname.startsWith("/admin/grants/")) {
2926
+ if (!getDb) return dbNotConfigured();
2927
+ const resolveVaultOrigin = (vaultName: string): string | null => {
2928
+ const match = findVaultUpstream(
2929
+ readManifestLenient(manifestPath).services,
2930
+ `/vault/${vaultName}`,
2931
+ );
2932
+ return match ? `http://127.0.0.1:${match.port}` : null;
2933
+ };
2934
+ const agentGrantsDeps: AgentGrantsDeps = {
2935
+ db: getDb(),
2936
+ hubOrigin: oauthDeps(req).issuer,
2937
+ storePath: deps?.agentGrantsStorePath ?? join(CONFIG_DIR, "agent-grants.json"),
2938
+ flowsStorePath:
2939
+ deps?.agentOAuthFlowsStorePath ?? join(CONFIG_DIR, "agent-oauth-flows.json"),
2940
+ resolveVaultOrigin,
2941
+ };
2942
+ // CSRF belt (same posture as /admin/connections, hub#632): a no-op for
2943
+ // the host-admin-Bearer PUT/GET (Bearer → not a browser CSRF), and the
2944
+ // real gate on the cookie-authed POST /approve + /revoke.
2945
+ {
2946
+ const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
2947
+ if (rejected) return rejected;
2948
+ }
2949
+ const subPath = pathname.slice("/admin/grants".length);
2950
+ return handleAgentGrants(req, subPath, agentGrantsDeps);
2951
+ }
2952
+
2362
2953
  if (pathname.startsWith("/admin/vault-admin-token/")) {
2363
2954
  if (!getDb) return dbNotConfigured();
2364
2955
  const vaultName = decodeURIComponent(pathname.slice("/admin/vault-admin-token/".length));
@@ -2385,6 +2976,25 @@ export function hubFetch(
2385
2976
  return handleApiMe(req, { db: getDb() });
2386
2977
  }
2387
2978
 
2979
+ // Admin screen-lock management (optional idle PIN lock for the admin
2980
+ // UI). Session-cookie-gated to the first admin; these manage the lock
2981
+ // itself so they are NOT behind the lock gate. The lock GATE lives in
2982
+ // the four `/admin/*-token` mint handlers (admin-lock.ts:requireUnlocked)
2983
+ // — it does NOT touch `/oauth/*`, so the OAuth issuer is unaffected.
2984
+ if (pathname === "/api/admin-lock" || pathname.startsWith("/api/admin-lock/")) {
2985
+ if (!getDb) return dbNotConfigured();
2986
+ // CSRF belt (same posture as /admin/connections): these are
2987
+ // cookie-authed JSON mutations. The handler ALSO checks a double-
2988
+ // submit `__csrf` token; the Origin belt is defense-in-depth on the
2989
+ // POST paths (GET status is read-shaped and skips it).
2990
+ {
2991
+ const rejected = assertSameOriginForCookieMutation(req, oauthDeps(req).hubBoundOrigins());
2992
+ if (rejected) return rejected;
2993
+ }
2994
+ const subpath = pathname.slice("/api/admin-lock".length);
2995
+ return handleAdminLock(req, subpath, { db: getDb() });
2996
+ }
2997
+
2388
2998
  // SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4). Dedicated
2389
2999
  // endpoint — the hub is NOT a supervised module (no /api/modules/hub/*),
2390
3000
  // so it gets its own route. Checked BEFORE the `/api/hub` exact match
@@ -2506,7 +3116,7 @@ export function hubFetch(
2506
3116
  // the 2026-06-09 modular-UI architecture P3. Config is module-owned +
2507
3117
  // hub-framed now: the Modules page "Configure" action opens the module's
2508
3118
  // OWN config UI (`configUiUrl`), which mints its admin Bearer from the
2509
- // cookie-gated `/admin/module-token/<short>` (or `/admin/channel-token`).
3119
+ // cookie-gated `/admin/module-token/<short>` (or `/admin/agent-token`).
2510
3120
 
2511
3121
  // Per-module action endpoints: /api/modules/:short/{install,restart,upgrade,uninstall}.
2512
3122
  if (pathname.startsWith("/api/modules/")) {
@@ -3018,8 +3628,54 @@ export function hubFetch(
3018
3628
  // here only after every hub-owned prefix above has had its turn — so
3019
3629
  // `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
3020
3630
  // `/api/*` are excluded by ordering, not by an explicit denylist (#182).
3631
+ //
3632
+ // H3 — per-UI audience gate. When the path falls under a declared UI
3633
+ // sub-unit (a `uis{}` entry on the matched service row — surface-hosted
3634
+ // UI mounts like /surface/<name>/*), the sub-unit's audience is
3635
+ // enforced BEFORE forwarding: 'public' passes, 'surface' passes (the
3636
+ // backed surface authenticates every request itself), 'hub-users'
3637
+ // requires a session or a scope-satisfying Bearer, 'operator' requires
3638
+ // the first admin. Module API paths outside any uis entry are NOT
3639
+ // gated here — modules keep their own auth. Ordering nuance: when the
3640
+ // row's publicExposure cloak would fire (loopback-only, non-loopback
3641
+ // layer), the gate is SKIPPED so the 404 cloak stays indistinguishable
3642
+ // from not-installed (a 401 here would leak the route's existence) —
3643
+ // which also means a 'surface'/'public' mount on a loopback-only row
3644
+ // stays unreachable from tailnet/funnel: exposure is orthogonal to
3645
+ // audience.
3646
+ const uiMatch = resolveUiMount(readManifestLenient(manifestPath).services, pathname);
3647
+ if (uiMatch) {
3648
+ const cloaked =
3649
+ effectivePublicExposure(uiMatch.entry) === "loopback" &&
3650
+ layerOf(req, peerAddr) !== "loopback";
3651
+ if (!cloaked) {
3652
+ const denied = await gateUiAudience(req, uiMatch.audience, uiMatch.ui, {
3653
+ db: getDb?.(),
3654
+ knownIssuers: () => oauthDeps(req).hubBoundOrigins(),
3655
+ });
3656
+ if (denied) return denied;
3657
+ }
3658
+ }
3021
3659
  const proxied = await proxyToService(req, manifestPath, deps?.supervisor, peerAddr);
3022
- if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
3660
+ if (proxied) {
3661
+ // H5 — chrome-strip rides the gate: where the audience resolved
3662
+ // `public`, the identity chrome is disabled for that mount (public
3663
+ // readers aren't hub users). `surface` follows the same precedent —
3664
+ // a backed surface's visitors are mostly capability-link invitees,
3665
+ // NOT hub users, so the "Signed in as…" chrome would be wrong for
3666
+ // them (and the surface owns its whole page anyway). Reuses the
3667
+ // per-path opt-out mechanism the /surface/notes/ precedent
3668
+ // established, generalized to the declared audience.
3669
+ return decorateWithChrome(
3670
+ proxied,
3671
+ req,
3672
+ pathname,
3673
+ getDb,
3674
+ uiMatch !== undefined && (uiMatch.audience === "public" || uiMatch.audience === "surface")
3675
+ ? [uiMatch.mount]
3676
+ : undefined,
3677
+ );
3678
+ }
3023
3679
 
3024
3680
  // Branded fall-through 404 (closes hub#392) — the operator who mistyped
3025
3681
  // a URL sees a clear "not found" page with a path back home, not the
@@ -3047,6 +3703,14 @@ export function hubFetch(
3047
3703
  * wrapper threads in the session-aware chrome HTML and a `set-cookie`
3048
3704
  * append when a fresh CSRF cookie was minted.
3049
3705
  *
3706
+ * `extraOptOutPrefixes` (H5) generalizes the static opt-out list: the
3707
+ * dispatch passes the matched UI mount when the audience gate resolved
3708
+ * `public` or `surface` — public readers (and a backed surface's
3709
+ * capability-link invitees) aren't hub users, so the identity chrome
3710
+ * ("Signed in as…", Sign in link) must not ride their pages. Same
3711
+ * mechanism as the hardcoded `/surface/notes/` precedent, now driven by
3712
+ * the sub-unit's declared audience instead of a hub-side path list.
3713
+ *
3050
3714
  * When `getDb` isn't wired (hubFetch instantiated without state — tests,
3051
3715
  * cold-start hub minus DB), we still inject — the signed-out variant.
3052
3716
  */
@@ -3055,6 +3719,7 @@ async function decorateWithChrome(
3055
3719
  req: Request,
3056
3720
  pathname: string,
3057
3721
  getDb: HubFetchDeps["getDb"],
3722
+ extraOptOutPrefixes?: readonly string[],
3058
3723
  ): Promise<Response> {
3059
3724
  // Build chrome HTML lazily — `buildChromeForRequest` already opens the DB
3060
3725
  // for the session lookup; calling it on a response that won't be rewritten
@@ -3075,6 +3740,9 @@ async function decorateWithChrome(
3075
3740
  const out = await injectChromeIntoResponse(res, {
3076
3741
  chromeHtml,
3077
3742
  pathname,
3743
+ ...(extraOptOutPrefixes !== undefined && extraOptOutPrefixes.length > 0
3744
+ ? { optOutPrefixes: [...CHROME_OPT_OUT_PREFIXES, ...extraOptOutPrefixes] }
3745
+ : {}),
3078
3746
  });
3079
3747
  // Append set-cookie if a CSRF was minted AND the chrome was actually
3080
3748
  // injected (we know that by checking out !== res — pass-through preserves
@@ -3152,6 +3820,9 @@ if (import.meta.main) {
3152
3820
  issuer,
3153
3821
  loopbackPort: port,
3154
3822
  }),
3823
+ // H1 — the WebSocket upgrade bridge's frame-piping handlers. Connections
3824
+ // land here only after `maybeUpgradeWebSocket` gated + upgraded them.
3825
+ websocket: createWsBridgeHandlers(),
3155
3826
  });
3156
3827
  // Register PID + port from the running hub itself so any startup path
3157
3828
  // (spawn-via-`ensureHubRunning` or a direct `bun src/hub-server.ts` from