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