@openparachute/hub 0.6.5-rc.8 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +310 -6
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections-credentials.test.ts +1320 -0
  6. package/src/__tests__/admin-connections.test.ts +1154 -0
  7. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  8. package/src/__tests__/admin-module-token.test.ts +311 -0
  9. package/src/__tests__/admin-vaults.test.ts +590 -0
  10. package/src/__tests__/api-invites.test.ts +166 -6
  11. package/src/__tests__/api-modules-ops.test.ts +70 -5
  12. package/src/__tests__/api-modules.test.ts +262 -79
  13. package/src/__tests__/audience-gate.test.ts +752 -0
  14. package/src/__tests__/hub-db.test.ts +36 -0
  15. package/src/__tests__/hub-server.test.ts +585 -21
  16. package/src/__tests__/invites.test.ts +91 -1
  17. package/src/__tests__/lifecycle.test.ts +238 -3
  18. package/src/__tests__/module-manifest.test.ts +305 -8
  19. package/src/__tests__/serve-boot.test.ts +133 -2
  20. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  21. package/src/__tests__/setup-gate.test.ts +13 -7
  22. package/src/__tests__/setup-wizard.test.ts +228 -1
  23. package/src/__tests__/vault-name.test.ts +20 -5
  24. package/src/__tests__/well-known.test.ts +44 -8
  25. package/src/__tests__/ws-bridge.test.ts +573 -0
  26. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  27. package/src/account-setup.ts +94 -23
  28. package/src/account-vault-admin-token.ts +43 -14
  29. package/src/admin-channel-token.ts +135 -0
  30. package/src/admin-connections.ts +1882 -0
  31. package/src/admin-login-ui.ts +64 -15
  32. package/src/admin-module-token.ts +197 -0
  33. package/src/admin-vaults.ts +399 -12
  34. package/src/api-hub-upgrade.ts +4 -3
  35. package/src/api-invites.ts +92 -12
  36. package/src/api-modules-ops.ts +41 -16
  37. package/src/api-modules.ts +238 -116
  38. package/src/api-tokens.ts +8 -5
  39. package/src/audience-gate.ts +268 -0
  40. package/src/chrome-strip.ts +8 -1
  41. package/src/commands/lifecycle.ts +187 -47
  42. package/src/commands/serve-boot.ts +80 -3
  43. package/src/commands/setup.ts +4 -4
  44. package/src/connections-store.ts +191 -0
  45. package/src/grants.ts +50 -0
  46. package/src/help.ts +13 -6
  47. package/src/host-admin-token-validation.ts +6 -2
  48. package/src/hub-db.ts +26 -1
  49. package/src/hub-server.ts +849 -70
  50. package/src/invites.ts +91 -2
  51. package/src/jwt-sign.ts +47 -1
  52. package/src/module-manifest.ts +536 -23
  53. package/src/origin-check.ts +109 -0
  54. package/src/proxy-error-ui.ts +1 -1
  55. package/src/service-spec.ts +132 -41
  56. package/src/services-manifest.ts +97 -0
  57. package/src/setup-wizard.ts +68 -6
  58. package/src/users.ts +11 -0
  59. package/src/vault-name.ts +27 -7
  60. package/src/well-known.ts +41 -33
  61. package/src/ws-bridge.ts +256 -0
  62. package/src/ws-connection-caps.ts +170 -0
  63. package/web/ui/dist/assets/index-Cxtod68O.js +61 -0
  64. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/src/__tests__/api-modules-config.test.ts +0 -882
  67. package/src/api-modules-config.ts +0 -421
  68. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  69. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/vault-name.ts CHANGED
@@ -10,7 +10,9 @@
10
10
  *
11
11
  * * lowercase alphanumeric + hyphens or underscores
12
12
  * * 2–32 chars
13
- * * `list` is reserved
13
+ * * `list` / `new` / `assets` / `admin` are reserved (see
14
+ * `RESERVED_VAULT_NAMES` below — one consolidated set for every hub
15
+ * edge, per the 2026-06-09 hub-module-boundary migration B2)
14
16
  *
15
17
  * If vault's validator changes (e.g. additional reserved name, length
16
18
  * relaxation), the two must move in lockstep — hub passing the typed
@@ -41,12 +43,30 @@ const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
41
43
  const VAULT_NAME_MIN_LEN = 2;
42
44
  const VAULT_NAME_MAX_LEN = 32;
43
45
 
44
- const RESERVED_NAMES = new Set([
45
- // Mirrors vault's reservation. Collides with the legacy `/vaults/list`
46
- // discovery endpoint; the routes have moved under `/vault/<name>/` but
47
- // vault's `cmdCreate` still rejects "list" and cross-repo consistency
48
- // is cheap.
46
+ /**
47
+ * THE reserved vault-name set single source of truth for every hub edge
48
+ * that names a vault (B2h of the 2026-06-09 hub-module-boundary migration).
49
+ * Before the consolidation hub carried TWO drifted sets: this file held only
50
+ * `list` (gating the setup wizard + invite redemption via `validateVaultName`)
51
+ * while `admin-vaults.ts` held `{list, new, assets}` (gating POST /vaults
52
+ * only) — so a non-admin invite redeemer could squat names an admin couldn't.
53
+ *
54
+ * - `list` — mirrors vault's own `cmdCreate` reservation (legacy
55
+ * `/vaults/list` discovery endpoint; cross-repo consistency is cheap).
56
+ * - `new` — collides with `/vault/new`, the SPA's create-vault route.
57
+ * - `assets` — collides with `/vault/assets/*`, the SPA's static bundle.
58
+ * - `admin` — collides with `/vault/admin`, the daemon-level mount for
59
+ * vault's own multi-vault admin surface (B-route). A vault named `admin`
60
+ * would capture the mount.
61
+ *
62
+ * DELETE /vaults/<name> deliberately does NOT consult this set — a squatted
63
+ * reserved-name vault (created before the reservation) must be removable.
64
+ */
65
+ export const RESERVED_VAULT_NAMES: ReadonlySet<string> = new Set([
49
66
  "list",
67
+ "new",
68
+ "assets",
69
+ "admin",
50
70
  ]);
51
71
 
52
72
  export type VaultNameValidation = { ok: true; name: string } | { ok: false; error: string };
@@ -73,7 +93,7 @@ export function validateVaultName(raw: string): VaultNameValidation {
73
93
  error: "vault names must be lowercase alphanumeric with hyphens or underscores.",
74
94
  };
75
95
  }
76
- if (RESERVED_NAMES.has(name)) {
96
+ if (RESERVED_VAULT_NAMES.has(name)) {
77
97
  return { ok: false, error: `"${name}" is a reserved vault name.` };
78
98
  }
79
99
  return { ok: true, name };
package/src/well-known.ts CHANGED
@@ -169,12 +169,17 @@ export interface BuildWellKnownOpts {
169
169
  /**
170
170
  * Optional resolver mapping a `ServiceEntry` to its `module.json:uiUrl`,
171
171
  * if any. Same shape as `managementUrlFor`. Returning `undefined` means
172
- * "no user-facing UI" and discovery omits the Services tile. For vault
173
- * entries, the declared `uiUrl` is the per-instance path (e.g. "/admin/")
174
- * `buildWellKnown` prefixes it with the per-instance mount path on
175
- * emission, yielding one tile per vault instance pointing at
176
- * `<origin>/vault/<name>/admin/`. See patterns#96
177
- * `module-ui-declaration.md` §"Multi-instance services (vault)".
172
+ * "no user-facing UI" and discovery omits the Services tile.
173
+ *
174
+ * Resolution follows the B4 unified semantics (2026-06-09
175
+ * hub-module-boundary): http(s):// verbatim; leading-`/`
176
+ * ORIGIN-ABSOLUTE against the canonical origin (vault's daemon-level
177
+ * `/vault/admin/` emits as-is, once per row); RELATIVE (no leading slash,
178
+ * e.g. `"admin/"`) → the per-instance form, mount-joined per emitted path
179
+ * — for a multi-path vault entry that yields one tile per instance at
180
+ * `<origin>/vault/<name>/admin/`. The literal legacy `"/admin/"` on a
181
+ * vault entry rides the one-release compat shim (mount-join + deprecation
182
+ * log). See `buildWellKnown`'s uiUrl branch.
178
183
  */
179
184
  uiUrlFor?: (entry: ServiceEntry) => string | undefined;
180
185
  /**
@@ -227,6 +232,9 @@ function buildUisArray(uis: Record<string, UiSubUnit>, base: string): WellKnownU
227
232
  });
228
233
  }
229
234
 
235
+ /** One-time deprecation log for the legacy vault `"/admin/"` uiUrl (B4 compat shim). */
236
+ let warnedLegacyVaultUiUrl = false;
237
+
230
238
  export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
231
239
  const base = opts.canonicalOrigin.replace(/\/$/, "");
232
240
  const doc: WellKnownDocument = { vaults: [], services: [] };
@@ -257,43 +265,43 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
257
265
  // entry — no installDir round-trip needed since it's already
258
266
  // persisted server-side and reasonably stable across reboots.
259
267
  if (s.tagline !== undefined) entry.tagline = s.tagline;
260
- // Resolve uiUrl. Three forms (per patterns#96
261
- // `module-ui-declaration.md` §"Shape"):
268
+ // Resolve uiUrl. Unified URL-resolution semantics (B4 of the 2026-06-09
269
+ // hub-module-boundary migration — same doctrine as api-modules.ts
270
+ // `resolveModuleUrl` and the `resolveManagementUrl` pair):
262
271
  // - Absolute http(s) URL → verbatim.
263
- // - Path on a non-vault entry joined onto `base` directly.
264
- // - Path on a vault entry joined onto `base` AFTER prefixing
265
- // with the per-instance mount path. Vault is the only
266
- // multi-instance service today; its declared `uiUrl: "/admin/"`
267
- // resolves to `<base>/vault/<name>/admin/` (one tile per
268
- // instance). The mount path is whichever `path` we're iterating
269
- // this loop turn (vault's `pathsToEmit` is its `paths[]`,
270
- // fanning one row per instance).
272
+ // - Leading-`/` path ORIGIN-ABSOLUTE: resolved against `base`
273
+ // directly (`/scribe/admin``<base>/scribe/admin`; vault's
274
+ // daemon-level `/vault/admin/` `<base>/vault/admin/`, once,
275
+ // NOT per instance).
276
+ // - Relative path (no leading slash) → MOUNT-JOINED: the
277
+ // per-instance form. Vault is the only multi-instance service
278
+ // today; a declared `uiUrl: "admin/"` resolves to
279
+ // `<base>/vault/<name>/admin/` (one tile per instance). The mount
280
+ // is whichever `path` we're iterating this loop turn (vault's
281
+ // `pathsToEmit` is its `paths[]`, fanning one row per instance).
271
282
  //
272
- // Path concatenation: `path` is the canonical per-instance mount
273
- // ("/vault/default", no trailing slash from services.json). `uiUrlRaw`
274
- // starts with "/" per pattern rule. Direct concatenation yields the
275
- // correct join ("/vault/default" + "/admin/" "/vault/default/admin/").
283
+ // COMPAT SHIM (one release remove once vault's new manifest reaches
284
+ // @latest): the literal legacy `"/admin"`/`"/admin/"` on a VAULT entry
285
+ // is the OLD per-instance relative declaration that deployed vaults
286
+ // still ship; it mount-joins (the pre-B4 behavior) with a deprecation
287
+ // log instead of resolving origin-absolute.
276
288
  const uiUrlRaw = opts.uiUrlFor?.(s);
277
289
  if (uiUrlRaw !== undefined) {
290
+ const mount = path.replace(/\/+$/, "");
278
291
  if (/^https?:\/\//i.test(uiUrlRaw)) {
279
292
  entry.uiUrl = uiUrlRaw;
280
- } else if (isVault) {
281
- // Defensive guard: vault uiUrl MUST start with "/" per the
282
- // multi-instance pattern (see module-ui-declaration.md). A bare
283
- // "admin/" (no leading slash) would concatenate into
284
- // "/vault/defaultadmin/" — a silent malformed URL that 404s.
285
- // Warn loudly instead of emitting garbage; the entry just
286
- // omits its uiUrl rather than poisoning the well-known doc.
287
- if (!uiUrlRaw.startsWith("/")) {
293
+ } else if (isVault && (uiUrlRaw === "/admin" || uiUrlRaw === "/admin/")) {
294
+ if (!warnedLegacyVaultUiUrl) {
295
+ warnedLegacyVaultUiUrl = true;
288
296
  console.warn(
289
- `[well-known] vault entry "${s.name}" declares uiUrl=${JSON.stringify(uiUrlRaw)} without a leading slash; skipping uiUrl emission. Per module-ui-declaration.md, multi-instance uiUrl must be a path-form starting with "/".`,
297
+ `[well-known] vault entry "${s.name}" declares the legacy per-instance uiUrl ${JSON.stringify(uiUrlRaw)}; mount-joining for one release. New semantics: relative ("admin/") = per-instance mount-join, leading-"/" = origin-absolute. Upgrade the vault module to clear this.`,
290
298
  );
291
- } else {
292
- const mount = path.replace(/\/$/, "");
293
- entry.uiUrl = new URL(`${mount}${uiUrlRaw}`, `${base}/`).toString();
294
299
  }
295
- } else {
300
+ entry.uiUrl = new URL(`${mount}${uiUrlRaw}`, `${base}/`).toString();
301
+ } else if (uiUrlRaw.startsWith("/")) {
296
302
  entry.uiUrl = new URL(uiUrlRaw, `${base}/`).toString();
303
+ } else {
304
+ entry.uiUrl = new URL(`${mount}/${uiUrlRaw}`, `${base}/`).toString();
297
305
  }
298
306
  }
299
307
  // Hierarchical sub-units (hub#313 / parachute-app design doc §12). The
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Bun-native WebSocket upgrade bridge (H1, surface-runtime design §"Hub work
3
+ * items").
4
+ *
5
+ * The hub's HTTP proxy is fetch-based, and WebSocket upgrades don't traverse
6
+ * fetch — so until now the route table simply couldn't forward them (the
7
+ * hub-server docstring acknowledged it). This module is the transport half of
8
+ * the fix: when `hub-server.ts` accepts an upgrade for a service mount whose
9
+ * module DECLARES the capability (`websocket: true` on its services.json row
10
+ * or module.json — deny-by-default), it calls `server.upgrade(req, { data })`
11
+ * with a {@link WsBridgeData} payload, and these handlers pipe frames
12
+ * bidirectionally between the client socket and a Bun WebSocket client
13
+ * connected to the upstream daemon (same path, loopback).
14
+ *
15
+ * Scope discipline: this is TRANSPORT, not features —
16
+ *
17
+ * - The upstream connect carries the substrate trust headers (H2:
18
+ * X-Parachute-Layer / X-Parachute-Client-IP) plus the client's own
19
+ * headers (cookie, authorization) so the module authenticates the
20
+ * connection itself; the hub adds no WS-level auth of its own beyond the
21
+ * route gates that ran BEFORE the upgrade (publicExposure cloak,
22
+ * audience gate).
23
+ * - Subprotocol negotiation (`Sec-WebSocket-Protocol`) is NOT forwarded in
24
+ * v1 — none of the in-design consumers (y-websocket / Hocuspocus manual
25
+ * pumping) require it; forwarding it correctly needs a negotiation
26
+ * round-trip the first real consumer can motivate.
27
+ * - Backpressure is a blunt cap, not flow control: when either side's
28
+ * buffered amount exceeds {@link DEFAULT_MAX_BUFFERED_BYTES}, the bridge
29
+ * closes BOTH sides (1011). A slow consumer should reconnect rather than
30
+ * let the hub buffer unboundedly.
31
+ * - Admission control is upstream of this module (hub#649): the gate in
32
+ * `maybeUpgradeWebSocket` enforces per-client-IP + total connection caps
33
+ * (defaults 32 / 512, env-overridable via PARACHUTE_WS_MAX_PER_IP /
34
+ * PARACHUTE_WS_MAX_TOTAL — see `ws-connection-caps.ts`) BEFORE
35
+ * `server.upgrade()`. The bridge's part of the contract is release: the
36
+ * `close` handler below is the single funnel every accepted socket
37
+ * passes through, so it invokes {@link WsBridgeData.releaseCap} first
38
+ * thing — whatever the teardown reason — and the counters churn back to
39
+ * zero with the sockets.
40
+ *
41
+ * Lifecycle: either side closing tears down the other (close code + reason
42
+ * propagated where the RFC 6455 rules allow), and an upstream connect failure
43
+ * closes the client with 1011. The bridge holds no per-connection state
44
+ * outside `ws.data`, so a dropped socket leaks nothing.
45
+ */
46
+
47
+ import type { ServerWebSocket, WebSocketHandler } from "bun";
48
+
49
+ /**
50
+ * Default cap on either side's buffered (un-flushed) bytes before the bridge
51
+ * gives up and closes both sockets. 8 MiB comfortably covers CRDT sync bursts
52
+ * (a full Yjs document state vector is typically KBs) while bounding what a
53
+ * slow or stalled consumer can pin in hub memory per connection.
54
+ */
55
+ export const DEFAULT_MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
56
+
57
+ /**
58
+ * Per-connection payload attached at `server.upgrade(req, { data })` time by
59
+ * the hub's dispatch (which already ran the route gates). Everything the
60
+ * bridge needs to dial the upstream — it never re-derives routing or trust.
61
+ */
62
+ export interface WsBridgeData {
63
+ /** Absolute ws:// URL of the upstream daemon (same path + query, loopback). */
64
+ upstreamUrl: string;
65
+ /**
66
+ * Headers presented on the upstream connect: the client's own headers
67
+ * (minus hop-by-hop + WS handshake headers, which the Bun client re-mints)
68
+ * plus the H2 substrate trust stamps.
69
+ */
70
+ upstreamHeaders: Record<string, string>;
71
+ /**
72
+ * Releases this connection's slot in the connection-cap accounting
73
+ * (hub#649) — attached by the acquire site in `maybeUpgradeWebSocket`,
74
+ * invoked by the `close` handler. Self-disarming (safe to call more than
75
+ * once). Optional so handler-level unit tests that fake `ws.data` don't
76
+ * have to care about caps.
77
+ */
78
+ releaseCap?: () => void;
79
+ /** Internal bridge state — attached by `open()`, owned by this module. */
80
+ _bridge?: BridgeState;
81
+ }
82
+
83
+ interface BridgeState {
84
+ upstream: WebSocket;
85
+ /** Client frames received while the upstream is still CONNECTING. */
86
+ pending: (string | Uint8Array)[];
87
+ pendingBytes: number;
88
+ /** Set once either side initiated teardown — makes close idempotent. */
89
+ closed: boolean;
90
+ }
91
+
92
+ export interface WsBridgeOptions {
93
+ /** Override the buffered-bytes cap (tests use a tiny value). */
94
+ maxBufferedBytes?: number;
95
+ /** Test seam for the upstream WebSocket constructor. */
96
+ connectUpstream?: (url: string, headers: Record<string, string>) => WebSocket;
97
+ logger?: Pick<Console, "warn">;
98
+ }
99
+
100
+ /**
101
+ * RFC 6455: only codes 1000–4999 may be sent on the wire, and 1004/1005/1006/
102
+ * 1015 are reserved (never sent). A close event surfacing one of those (e.g.
103
+ * 1006 abnormal closure) is re-mapped to a no-code close, which the peer
104
+ * observes as 1005.
105
+ */
106
+ function sendableCloseCode(code: number | undefined): number | undefined {
107
+ if (code === undefined) return undefined;
108
+ if (code < 1000 || code > 4999) return undefined;
109
+ if (code === 1004 || code === 1005 || code === 1006 || code === 1015) return undefined;
110
+ return code;
111
+ }
112
+
113
+ /** Close reasons are capped at 123 bytes on the wire (RFC 6455 §5.5.1). */
114
+ function trimReason(reason: string | undefined): string {
115
+ if (!reason) return "";
116
+ // Trim by UTF-8 byte length, not string length.
117
+ let out = reason;
118
+ while (Buffer.byteLength(out, "utf8") > 123) out = out.slice(0, -1);
119
+ return out;
120
+ }
121
+
122
+ function closeQuietly(close: () => void): void {
123
+ try {
124
+ close();
125
+ } catch {
126
+ // Already closed / closing — teardown is best-effort by design.
127
+ }
128
+ }
129
+
130
+ /** Tear down both sides exactly once. */
131
+ function closeBoth(
132
+ ws: ServerWebSocket<WsBridgeData>,
133
+ state: BridgeState,
134
+ code: number,
135
+ reason: string,
136
+ ): void {
137
+ if (state.closed) return;
138
+ state.closed = true;
139
+ closeQuietly(() => state.upstream.close(sendableCloseCode(code), trimReason(reason)));
140
+ closeQuietly(() => ws.close(sendableCloseCode(code), trimReason(reason)));
141
+ }
142
+
143
+ function frameBytes(frame: string | Uint8Array | ArrayBuffer): number {
144
+ if (typeof frame === "string") return Buffer.byteLength(frame, "utf8");
145
+ return frame instanceof ArrayBuffer ? frame.byteLength : frame.byteLength;
146
+ }
147
+
148
+ /**
149
+ * Build the Bun.serve `websocket` handler set implementing the bridge. One
150
+ * handler object serves every bridged connection; per-connection state lives
151
+ * on `ws.data`.
152
+ */
153
+ export function createWsBridgeHandlers(opts: WsBridgeOptions = {}): WebSocketHandler<WsBridgeData> {
154
+ const cap = opts.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
155
+ const logger = opts.logger ?? console;
156
+ const connect =
157
+ opts.connectUpstream ??
158
+ ((url: string, headers: Record<string, string>) =>
159
+ // Bun's WebSocket client accepts custom headers (a Bun extension over
160
+ // the WHATWG constructor) — this is what carries the H2 trust stamps +
161
+ // the client's cookies/authorization to the upstream daemon.
162
+ new WebSocket(url, { headers } as unknown as string[]));
163
+
164
+ return {
165
+ open(ws) {
166
+ let upstream: WebSocket;
167
+ try {
168
+ upstream = connect(ws.data.upstreamUrl, ws.data.upstreamHeaders);
169
+ } catch (err) {
170
+ logger.warn(
171
+ `[ws-bridge] upstream connect threw for ${ws.data.upstreamUrl}: ${
172
+ err instanceof Error ? err.message : String(err)
173
+ }`,
174
+ );
175
+ closeQuietly(() => ws.close(1011, "upstream connect failed"));
176
+ return;
177
+ }
178
+ upstream.binaryType = "arraybuffer";
179
+ const state: BridgeState = { upstream, pending: [], pendingBytes: 0, closed: false };
180
+ ws.data._bridge = state;
181
+
182
+ upstream.addEventListener("open", () => {
183
+ if (state.closed) {
184
+ closeQuietly(() => upstream.close(1000, ""));
185
+ return;
186
+ }
187
+ for (const frame of state.pending) upstream.send(frame);
188
+ state.pending = [];
189
+ state.pendingBytes = 0;
190
+ });
191
+
192
+ upstream.addEventListener("message", (ev: MessageEvent) => {
193
+ if (state.closed) return;
194
+ const data = ev.data as string | ArrayBuffer;
195
+ ws.send(typeof data === "string" ? data : new Uint8Array(data));
196
+ // Backpressure: the client isn't draining what the upstream sends.
197
+ if (ws.getBufferedAmount() > cap) {
198
+ closeBoth(ws, state, 1011, "bridge backpressure cap exceeded");
199
+ }
200
+ });
201
+
202
+ upstream.addEventListener("close", (ev: CloseEvent) => {
203
+ if (state.closed) return;
204
+ state.closed = true;
205
+ closeQuietly(() => ws.close(sendableCloseCode(ev.code), trimReason(ev.reason)));
206
+ });
207
+
208
+ upstream.addEventListener("error", () => {
209
+ // A connect refusal (upstream not listening) surfaces here before
210
+ // any close event. Tear down the client; the close listener above is
211
+ // a no-op afterwards thanks to the `closed` latch.
212
+ closeBoth(ws, state, 1011, "upstream error");
213
+ });
214
+ },
215
+
216
+ message(ws, message) {
217
+ const state = ws.data._bridge;
218
+ if (!state || state.closed) return;
219
+ const frame: string | Uint8Array =
220
+ typeof message === "string" ? message : new Uint8Array(message);
221
+ const { upstream } = state;
222
+ if (upstream.readyState === WebSocket.OPEN) {
223
+ upstream.send(frame);
224
+ // Backpressure: the upstream isn't draining what the client sends.
225
+ if (upstream.bufferedAmount > cap) {
226
+ closeBoth(ws, state, 1011, "bridge backpressure cap exceeded");
227
+ }
228
+ } else if (upstream.readyState === WebSocket.CONNECTING) {
229
+ state.pending.push(frame);
230
+ state.pendingBytes += frameBytes(frame);
231
+ if (state.pendingBytes > cap) {
232
+ closeBoth(ws, state, 1011, "bridge backpressure cap exceeded");
233
+ }
234
+ }
235
+ // CLOSING / CLOSED: drop the frame — teardown is already in flight.
236
+ },
237
+
238
+ close(ws, code, reason) {
239
+ // Connection-cap release FIRST (hub#649) — before the bridge-state
240
+ // early-returns, because this callback fires even for sockets whose
241
+ // upstream connect threw in open() (no `_bridge` attached) and is the
242
+ // one teardown funnel every accepted socket passes through. The
243
+ // closure latches, so re-entry is harmless.
244
+ ws.data.releaseCap?.();
245
+ // Client → upstream close propagation. Note: Bun's server-side close
246
+ // callback delivers the client's close CODE but an empty `reason`
247
+ // (verified on Bun 1.3.13), so only the code propagates upstream in
248
+ // this direction. Upstream → client propagation (the close listener in
249
+ // open()) carries both.
250
+ const state = ws.data._bridge;
251
+ if (!state || state.closed) return;
252
+ state.closed = true;
253
+ closeQuietly(() => state.upstream.close(sendableCloseCode(code), trimReason(reason)));
254
+ },
255
+ };
256
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * WebSocket connection caps for the upgrade bridge (hub#649) — the gate
3
+ * before any backed surface goes public-facing.
4
+ *
5
+ * The H1 bridge (`ws-bridge.ts`) holds one client socket + one upstream
6
+ * socket per connection, and the only per-connection bound before this
7
+ * module was the 8 MiB buffered-byte backpressure cap — a public client
8
+ * could hold upgrade slots open indefinitely at near-zero send rate and
9
+ * exhaust daemon memory at ~KB/connection. With the `surface` audience tier
10
+ * (H3, hub#651) anonymous WS is REACHABLE BY DESIGN on surface-audience
11
+ * mounts, so admission control can't lean on auth: the hub needs a blunt
12
+ * concurrent-connection bound of its own.
13
+ *
14
+ * Enforcement lives in `maybeUpgradeWebSocket` (hub-server.ts), which calls
15
+ * {@link WsConnectionTracker.tryAcquire} synchronously right before
16
+ * `server.upgrade()` — over-cap upgrades are refused with a clean HTTP 429
17
+ * (the upgrade never happens, so a normal Response is the correct refusal
18
+ * shape in Bun) BEFORE the proxy commits any socket or upstream-dial
19
+ * resources. The refusal body is generic: it never reveals counts, caps, or
20
+ * which cap tripped (the hub log carries the specifics for the operator).
21
+ *
22
+ * Release rides the bridge's socket lifecycle: the acquire site threads a
23
+ * self-disarming release closure into `ws.data` ({@link
24
+ * WsBridgeData.releaseCap}), and the bridge's Bun-level `close` handler —
25
+ * the single funnel every accepted socket passes through, whatever the
26
+ * teardown reason (client close, upstream close, backpressure, connect
27
+ * failure) — invokes it first thing. A failed `server.upgrade()` releases
28
+ * inline (no socket ⇒ no close callback). The closure latches, so a stray
29
+ * double-close can't corrupt the counters.
30
+ *
31
+ * Defaults (overridable via env, the hub's config precedent —
32
+ * `PARACHUTE_HUB_ORIGIN` et al.):
33
+ *
34
+ * - {@link DEFAULT_WS_MAX_PER_IP} = 32 per client IP
35
+ * (`PARACHUTE_WS_MAX_PER_IP`). An owner-operated box realistically
36
+ * serves a handful of humans; a collab surface opens a socket or two
37
+ * per tab, so 32 covers a small team behind one NAT egress IP with
38
+ * headroom, while turning a single-source flood into a rotate-IPs
39
+ * problem.
40
+ * - {@link DEFAULT_WS_MAX_TOTAL} = 512 total (`PARACHUTE_WS_MAX_TOTAL`).
41
+ * Bounds worst-case hub memory under a distributed flood (512 bridged
42
+ * pairs ≈ low MBs idle) at a ceiling far above any realistic legitimate
43
+ * concurrent load for a single-operator hub.
44
+ *
45
+ * Keying: callers derive the bucket key with `wsCapBucketKey`
46
+ * (hub-server.ts), which follows the hub's substrate trust model — forwarded
47
+ * IP headers are only believed when the peer is an on-box (loopback)
48
+ * forwarder; direct network peers key by their socket address no matter
49
+ * what headers they inject; an underivable IP lands in one shared bucket
50
+ * (fail closed, same posture as rate-limit.ts's UNKNOWN_IP_SENTINEL).
51
+ */
52
+
53
+ /** Default cap on concurrent bridged WS connections per client-IP bucket. */
54
+ export const DEFAULT_WS_MAX_PER_IP = 32;
55
+ /** Default cap on concurrent bridged WS connections across all clients. */
56
+ export const DEFAULT_WS_MAX_TOTAL = 512;
57
+
58
+ /** Env var overriding {@link DEFAULT_WS_MAX_PER_IP}. */
59
+ export const WS_MAX_PER_IP_ENV = "PARACHUTE_WS_MAX_PER_IP";
60
+ /** Env var overriding {@link DEFAULT_WS_MAX_TOTAL}. */
61
+ export const WS_MAX_TOTAL_ENV = "PARACHUTE_WS_MAX_TOTAL";
62
+
63
+ export interface WsCaps {
64
+ maxPerIp: number;
65
+ maxTotal: number;
66
+ }
67
+
68
+ /**
69
+ * Parse the cap overrides from an env bag. Only positive integers are
70
+ * honored; absent / malformed / non-positive values fall back to the
71
+ * defaults (an operator typo must not silently disable the gate — fail to
72
+ * the safe defaults, never to "unlimited").
73
+ */
74
+ export function wsCapsFromEnv(env: NodeJS.ProcessEnv = process.env): WsCaps {
75
+ return {
76
+ maxPerIp: parseCap(env[WS_MAX_PER_IP_ENV], DEFAULT_WS_MAX_PER_IP),
77
+ maxTotal: parseCap(env[WS_MAX_TOTAL_ENV], DEFAULT_WS_MAX_TOTAL),
78
+ };
79
+ }
80
+
81
+ function parseCap(raw: string | undefined, fallback: number): number {
82
+ if (raw === undefined) return fallback;
83
+ const n = Number.parseInt(raw.trim(), 10);
84
+ if (!Number.isSafeInteger(n) || n <= 0 || String(n) !== raw.trim()) return fallback;
85
+ return n;
86
+ }
87
+
88
+ /** The verdict of {@link WsConnectionTracker.tryAcquire}. */
89
+ export type WsAcquireResult =
90
+ | { ok: true; release: () => void }
91
+ | { ok: false; reason: "per_ip_cap" | "total_cap" };
92
+
93
+ /**
94
+ * Concurrent-connection accounting: a per-key map + a global counter.
95
+ *
96
+ * Acquire and release are synchronous and O(1); `tryAcquire` is called in
97
+ * the same synchronous block as `server.upgrade()` (no await between check
98
+ * and commit), so the counters can't race the admission decision.
99
+ *
100
+ * The returned `release` closure is latched — calling it twice decrements
101
+ * once. That makes leaks structurally hard: the caller doesn't need to know
102
+ * which teardown paths can double-fire; any number of invocations after the
103
+ * first are no-ops, and a key's bucket entry is deleted at zero so an
104
+ * attacker cycling keys can't grow the map without also holding sockets.
105
+ */
106
+ export class WsConnectionTracker {
107
+ private readonly perKey = new Map<string, number>();
108
+ private total = 0;
109
+
110
+ constructor(
111
+ private readonly maxPerKey: number = DEFAULT_WS_MAX_PER_IP,
112
+ private readonly maxTotal: number = DEFAULT_WS_MAX_TOTAL,
113
+ ) {}
114
+
115
+ /**
116
+ * Admit-or-refuse a would-be connection for `key`. On admission the slot
117
+ * is counted immediately and the caller MUST either hand the returned
118
+ * `release` to the socket's close path or invoke it inline when the
119
+ * upgrade fails to complete.
120
+ */
121
+ tryAcquire(key: string): WsAcquireResult {
122
+ if (this.total >= this.maxTotal) return { ok: false, reason: "total_cap" };
123
+ const current = this.perKey.get(key) ?? 0;
124
+ if (current >= this.maxPerKey) return { ok: false, reason: "per_ip_cap" };
125
+ this.perKey.set(key, current + 1);
126
+ this.total += 1;
127
+
128
+ let released = false;
129
+ return {
130
+ ok: true,
131
+ release: () => {
132
+ if (released) return;
133
+ released = true;
134
+ const n = this.perKey.get(key) ?? 0;
135
+ if (n <= 1) this.perKey.delete(key);
136
+ else this.perKey.set(key, n - 1);
137
+ if (this.total > 0) this.total -= 1;
138
+ },
139
+ };
140
+ }
141
+
142
+ /** Current global connection count (observability + tests). */
143
+ get totalCount(): number {
144
+ return this.total;
145
+ }
146
+
147
+ /** Current count for one key (observability + tests). 0 when absent. */
148
+ countFor(key: string): number {
149
+ return this.perKey.get(key) ?? 0;
150
+ }
151
+
152
+ /** Number of distinct keys currently holding connections (leak probe). */
153
+ get keyCount(): number {
154
+ return this.perKey.size;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * The production tracker — one per hub process, shared by every upgrade.
160
+ * Caps come from the env at module load (the hub reads its config once at
161
+ * boot; changing the env requires a restart, same as every other
162
+ * `PARACHUTE_*` knob). Tests construct their own trackers and inject them
163
+ * via `HubFetchDeps.wsConnectionTracker` so they never consume (or depend
164
+ * on) the shared process-level counters.
165
+ */
166
+ const bootCaps = wsCapsFromEnv();
167
+ export const defaultWsConnectionTracker = new WsConnectionTracker(
168
+ bootCaps.maxPerIp,
169
+ bootCaps.maxTotal,
170
+ );