@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.
- package/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- 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/
|
|
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 {
|
|
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 {
|
|
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. `/
|
|
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
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
1449
|
-
* resolve the peer address for `layerOf
|
|
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
|
-
//
|
|
2766
|
+
// Agent's row carries its MANIFEST name — resolve via
|
|
2227
2767
|
// findServiceByShort (see the /admin/connections note below).
|
|
2228
|
-
const
|
|
2229
|
-
const
|
|
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
|
-
|
|
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
|
|
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/
|
|
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.
|
|
2304
|
-
// drives `/admin/connections` +
|
|
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
|
|
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
|
-
//
|
|
2322
|
-
// (`parachute-
|
|
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 === "
|
|
2325
|
-
// "
|
|
2326
|
-
|
|
2327
|
-
const
|
|
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
|
-
|
|
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 —
|
|
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/
|
|
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)
|
|
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
|