@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
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the H1 WebSocket upgrade bridge (surface-runtime design):
|
|
3
|
+
* routing + gating in `hub-server.ts` (`maybeUpgradeWebSocket` via the
|
|
4
|
+
* fetch fn) and frame piping in `src/ws-bridge.ts`.
|
|
5
|
+
*
|
|
6
|
+
* Two tiers:
|
|
7
|
+
* - INTEGRATION: a real `Bun.serve` hub (hubFetch + createWsBridgeHandlers)
|
|
8
|
+
* in front of a real Bun WS echo upstream — proves the upgrade is
|
|
9
|
+
* forwarded, frames flow both ways, closes propagate in both directions,
|
|
10
|
+
* and the upstream connect carries the H2 substrate trust headers.
|
|
11
|
+
* - UNIT: direct fetch-fn calls with a spy `upgrade` for the refusal /
|
|
12
|
+
* gating paths (no declaration → 426; loopback cloak → 404 before any
|
|
13
|
+
* upgrade; module.json fallback declaration), and handler-level fakes
|
|
14
|
+
* for backpressure + close-code sanitization.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { ServerWebSocket } from "bun";
|
|
21
|
+
import { hubFetch } from "../hub-server.ts";
|
|
22
|
+
import type { ModuleManifest } from "../module-manifest.ts";
|
|
23
|
+
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
24
|
+
import { type WsBridgeData, createWsBridgeHandlers } from "../ws-bridge.ts";
|
|
25
|
+
|
|
26
|
+
interface Harness {
|
|
27
|
+
dir: string;
|
|
28
|
+
manifestPath: string;
|
|
29
|
+
cleanup: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeHarness(): Harness {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-ws-bridge-"));
|
|
34
|
+
return {
|
|
35
|
+
dir,
|
|
36
|
+
manifestPath: join(dir, "services.json"),
|
|
37
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function wsEntry(port: number, extra: Partial<ServiceEntry> = {}): ServiceEntry {
|
|
42
|
+
return {
|
|
43
|
+
name: "wsmod",
|
|
44
|
+
port,
|
|
45
|
+
paths: ["/wsmod"],
|
|
46
|
+
health: "/wsmod/health",
|
|
47
|
+
version: "0.1.0",
|
|
48
|
+
...extra,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function upgradeReq(path: string, headers: Record<string, string> = {}): Request {
|
|
53
|
+
return new Request(`http://127.0.0.1${path}`, {
|
|
54
|
+
headers: { upgrade: "websocket", connection: "Upgrade", ...headers },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Bun WebSocket client with custom headers (Bun extension over WHATWG). */
|
|
59
|
+
function wsClient(url: string, headers?: Record<string, string>): WebSocket {
|
|
60
|
+
return new WebSocket(url, { headers } as unknown as string[]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function once<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
|
|
64
|
+
let resolve!: (v: T) => void;
|
|
65
|
+
const promise = new Promise<T>((r) => {
|
|
66
|
+
resolve = r;
|
|
67
|
+
});
|
|
68
|
+
return { promise, resolve };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ===========================================================================
|
|
72
|
+
// Integration — real hub Bun.serve + real upstream WS echo
|
|
73
|
+
// ===========================================================================
|
|
74
|
+
|
|
75
|
+
interface UpstreamRecorder {
|
|
76
|
+
port: number;
|
|
77
|
+
stop: () => void;
|
|
78
|
+
/** Resolves with (code, reason) when the upstream-side socket closes. */
|
|
79
|
+
closed: Promise<{ code: number; reason: string }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A WS echo upstream. On open it sends a JSON snapshot of the connect-time
|
|
84
|
+
* request (path + the substrate trust headers + a sampled client header), so
|
|
85
|
+
* the test can assert what the bridge presented. Echoes text as `echo:<msg>`
|
|
86
|
+
* and binary verbatim; the literal "close-me" makes the upstream close with
|
|
87
|
+
* 4001.
|
|
88
|
+
*/
|
|
89
|
+
function startWsEchoUpstream(): UpstreamRecorder {
|
|
90
|
+
const closeSignal = once<{ code: number; reason: string }>();
|
|
91
|
+
type Data = { snapshot: Record<string, string | null> };
|
|
92
|
+
const server = Bun.serve<Data>({
|
|
93
|
+
port: 0,
|
|
94
|
+
hostname: "127.0.0.1",
|
|
95
|
+
fetch(req, srv) {
|
|
96
|
+
const u = new URL(req.url);
|
|
97
|
+
const snapshot = {
|
|
98
|
+
path: u.pathname + u.search,
|
|
99
|
+
layer: req.headers.get("x-parachute-layer"),
|
|
100
|
+
clientIp: req.headers.get("x-parachute-client-ip"),
|
|
101
|
+
cookie: req.headers.get("cookie"),
|
|
102
|
+
secWebsocketKey: req.headers.get("sec-websocket-key"),
|
|
103
|
+
};
|
|
104
|
+
if (srv.upgrade(req, { data: { snapshot } })) return undefined as unknown as Response;
|
|
105
|
+
return new Response("expected websocket", { status: 400 });
|
|
106
|
+
},
|
|
107
|
+
websocket: {
|
|
108
|
+
open(ws) {
|
|
109
|
+
ws.send(JSON.stringify({ kind: "hello", snapshot: ws.data.snapshot }));
|
|
110
|
+
},
|
|
111
|
+
message(ws, msg) {
|
|
112
|
+
if (msg === "close-me") {
|
|
113
|
+
ws.close(4001, "upstream says bye");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (typeof msg === "string") ws.send(`echo:${msg}`);
|
|
117
|
+
else ws.send(msg);
|
|
118
|
+
},
|
|
119
|
+
close(_ws, code, reason) {
|
|
120
|
+
closeSignal.resolve({ code, reason });
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
port: server.port as number,
|
|
126
|
+
stop: () => server.stop(true),
|
|
127
|
+
closed: closeSignal.promise,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function startHub(h: Harness): { port: number; stop: () => void } {
|
|
132
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
133
|
+
const server = Bun.serve<WsBridgeData>({
|
|
134
|
+
port: 0,
|
|
135
|
+
hostname: "127.0.0.1",
|
|
136
|
+
fetch: (req, srv) => fetcher(req, srv),
|
|
137
|
+
websocket: createWsBridgeHandlers(),
|
|
138
|
+
});
|
|
139
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe("WS bridge integration — declaring module, real sockets (H1)", () => {
|
|
143
|
+
test("upgrade forwarded; H2 headers stamped on upstream connect; bidirectional frames; client-close propagates", async () => {
|
|
144
|
+
const h = makeHarness();
|
|
145
|
+
const upstream = startWsEchoUpstream();
|
|
146
|
+
let hub: { port: number; stop: () => void } | undefined;
|
|
147
|
+
try {
|
|
148
|
+
writeManifest({ services: [wsEntry(upstream.port, { websocket: true })] }, h.manifestPath);
|
|
149
|
+
hub = startHub(h);
|
|
150
|
+
|
|
151
|
+
const messages: (string | ArrayBuffer)[] = [];
|
|
152
|
+
const opened = once<void>();
|
|
153
|
+
const gotHello = once<string>();
|
|
154
|
+
const gotEcho = once<string>();
|
|
155
|
+
const gotBinary = once<Uint8Array>();
|
|
156
|
+
// The client tries to inject the substrate headers — the bridge must
|
|
157
|
+
// strip + re-stamp them (the peer is loopback, so the truthful stamp
|
|
158
|
+
// is "loopback", not the forged "tailnet"). A cookie rides through so
|
|
159
|
+
// the daemon can authenticate the connection.
|
|
160
|
+
const client = wsClient(`ws://127.0.0.1:${hub.port}/wsmod/ws?room=7`, {
|
|
161
|
+
"x-parachute-layer": "tailnet",
|
|
162
|
+
"x-parachute-client-ip": "203.0.113.99",
|
|
163
|
+
cookie: "parachute_session=abc",
|
|
164
|
+
});
|
|
165
|
+
client.binaryType = "arraybuffer";
|
|
166
|
+
client.addEventListener("open", () => opened.resolve());
|
|
167
|
+
client.addEventListener("message", (ev) => {
|
|
168
|
+
messages.push(ev.data as string | ArrayBuffer);
|
|
169
|
+
if (typeof ev.data === "string" && ev.data.includes('"hello"')) {
|
|
170
|
+
gotHello.resolve(ev.data);
|
|
171
|
+
} else if (typeof ev.data === "string" && ev.data.startsWith("echo:")) {
|
|
172
|
+
gotEcho.resolve(ev.data);
|
|
173
|
+
} else if (ev.data instanceof ArrayBuffer) {
|
|
174
|
+
gotBinary.resolve(new Uint8Array(ev.data));
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await opened.promise;
|
|
179
|
+
const hello = JSON.parse(await gotHello.promise) as {
|
|
180
|
+
snapshot: Record<string, string | null>;
|
|
181
|
+
};
|
|
182
|
+
// Path + query forwarded verbatim (no stripPrefix declared).
|
|
183
|
+
expect(hello.snapshot.path).toBe("/wsmod/ws?room=7");
|
|
184
|
+
// H2 stamps: truthful layer (loopback peer), not the client's forgery.
|
|
185
|
+
expect(hello.snapshot.layer).toBe("loopback");
|
|
186
|
+
expect(hello.snapshot.clientIp).toBe("127.0.0.1");
|
|
187
|
+
// The client's own credential headers ride through.
|
|
188
|
+
expect(hello.snapshot.cookie).toBe("parachute_session=abc");
|
|
189
|
+
// The Bun client re-mints the handshake; a key is present and is NOT
|
|
190
|
+
// the hub-side client's key (we can only assert presence here).
|
|
191
|
+
expect(hello.snapshot.secWebsocketKey).toBeTruthy();
|
|
192
|
+
|
|
193
|
+
// Client → upstream → client text round trip.
|
|
194
|
+
client.send("ping");
|
|
195
|
+
expect(await gotEcho.promise).toBe("echo:ping");
|
|
196
|
+
|
|
197
|
+
// Binary round trip.
|
|
198
|
+
client.send(new Uint8Array([1, 2, 3, 250]));
|
|
199
|
+
expect([...(await gotBinary.promise)]).toEqual([1, 2, 3, 250]);
|
|
200
|
+
|
|
201
|
+
// Client-initiated close propagates the CODE to the upstream. The
|
|
202
|
+
// reason string is not propagated in this direction: Bun's server-side
|
|
203
|
+
// websocket close callback delivers an empty reason (verified on Bun
|
|
204
|
+
// 1.3.13), so the bridge never receives it. Upstream→client reason
|
|
205
|
+
// propagation works (next test).
|
|
206
|
+
client.close(4002, "client done");
|
|
207
|
+
const upstreamClose = await upstream.closed;
|
|
208
|
+
expect(upstreamClose.code).toBe(4002);
|
|
209
|
+
expect(upstreamClose.reason).toBe("");
|
|
210
|
+
} finally {
|
|
211
|
+
hub?.stop();
|
|
212
|
+
upstream.stop();
|
|
213
|
+
h.cleanup();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("upstream-initiated close propagates code + reason to the client", async () => {
|
|
218
|
+
const h = makeHarness();
|
|
219
|
+
const upstream = startWsEchoUpstream();
|
|
220
|
+
let hub: { port: number; stop: () => void } | undefined;
|
|
221
|
+
try {
|
|
222
|
+
writeManifest({ services: [wsEntry(upstream.port, { websocket: true })] }, h.manifestPath);
|
|
223
|
+
hub = startHub(h);
|
|
224
|
+
|
|
225
|
+
const closed = once<{ code: number; reason: string }>();
|
|
226
|
+
const opened = once<void>();
|
|
227
|
+
const client = wsClient(`ws://127.0.0.1:${hub.port}/wsmod/ws`);
|
|
228
|
+
client.addEventListener("open", () => opened.resolve());
|
|
229
|
+
client.addEventListener("close", (ev) =>
|
|
230
|
+
closed.resolve({ code: ev.code, reason: ev.reason }),
|
|
231
|
+
);
|
|
232
|
+
await opened.promise;
|
|
233
|
+
client.send("close-me");
|
|
234
|
+
const ev = await closed.promise;
|
|
235
|
+
expect(ev.code).toBe(4001);
|
|
236
|
+
expect(ev.reason).toBe("upstream says bye");
|
|
237
|
+
} finally {
|
|
238
|
+
hub?.stop();
|
|
239
|
+
upstream.stop();
|
|
240
|
+
h.cleanup();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("unreachable upstream → client closed 1011 (no hang)", async () => {
|
|
245
|
+
const h = makeHarness();
|
|
246
|
+
// Bind a port + release it so the upstream connect gets ECONNREFUSED.
|
|
247
|
+
const probe = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") });
|
|
248
|
+
const deadPort = probe.port as number;
|
|
249
|
+
probe.stop(true);
|
|
250
|
+
let hub: { port: number; stop: () => void } | undefined;
|
|
251
|
+
try {
|
|
252
|
+
writeManifest({ services: [wsEntry(deadPort, { websocket: true })] }, h.manifestPath);
|
|
253
|
+
hub = startHub(h);
|
|
254
|
+
const closed = once<{ code: number }>();
|
|
255
|
+
const client = wsClient(`ws://127.0.0.1:${hub.port}/wsmod/ws`);
|
|
256
|
+
client.addEventListener("close", (ev) => closed.resolve({ code: ev.code }));
|
|
257
|
+
const ev = await closed.promise;
|
|
258
|
+
expect(ev.code).toBe(1011);
|
|
259
|
+
} finally {
|
|
260
|
+
hub?.stop();
|
|
261
|
+
h.cleanup();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ===========================================================================
|
|
267
|
+
// Unit — routing + gating verdicts via direct fetch-fn calls
|
|
268
|
+
// ===========================================================================
|
|
269
|
+
|
|
270
|
+
describe("WS upgrade routing + gating (H1, deny-by-default)", () => {
|
|
271
|
+
interface UpgradeSpy {
|
|
272
|
+
requestIP: () => { address: string };
|
|
273
|
+
upgrade: (req: Request, options: { data: WsBridgeData }) => boolean;
|
|
274
|
+
calls: { data: WsBridgeData }[];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function upgradeSpy(address: string, accept = true): UpgradeSpy {
|
|
278
|
+
const calls: { data: WsBridgeData }[] = [];
|
|
279
|
+
return {
|
|
280
|
+
requestIP: () => ({ address }),
|
|
281
|
+
upgrade: (_req, options) => {
|
|
282
|
+
calls.push(options);
|
|
283
|
+
return accept;
|
|
284
|
+
},
|
|
285
|
+
calls,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
test("non-declaring module → 426, upgrade never attempted, daemon never dialed", async () => {
|
|
290
|
+
const h = makeHarness();
|
|
291
|
+
try {
|
|
292
|
+
writeManifest({ services: [wsEntry(19999)] }, h.manifestPath); // no websocket flag
|
|
293
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
294
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
295
|
+
const res = await fetcher(upgradeReq("/wsmod/ws"), spy);
|
|
296
|
+
expect(res?.status).toBe(426);
|
|
297
|
+
const body = (await res?.json()) as { error: string };
|
|
298
|
+
expect(body.error).toBe("websocket_not_supported");
|
|
299
|
+
expect(spy.calls.length).toBe(0);
|
|
300
|
+
} finally {
|
|
301
|
+
h.cleanup();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("declared via services.json row → upgraded (fetch returns undefined, data carries upstream URL + stamped headers)", async () => {
|
|
306
|
+
const h = makeHarness();
|
|
307
|
+
try {
|
|
308
|
+
writeManifest({ services: [wsEntry(12345, { websocket: true })] }, h.manifestPath);
|
|
309
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
310
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
311
|
+
const res = await fetcher(
|
|
312
|
+
upgradeReq("/wsmod/collab?doc=a", {
|
|
313
|
+
// Spoof attempt — must be stripped + re-stamped.
|
|
314
|
+
"x-parachute-layer": "loopback",
|
|
315
|
+
"x-parachute-client-ip": "10.9.9.9",
|
|
316
|
+
cookie: "parachute_session=zzz",
|
|
317
|
+
"sec-websocket-key": "AAAA",
|
|
318
|
+
"sec-websocket-version": "13",
|
|
319
|
+
}),
|
|
320
|
+
spy,
|
|
321
|
+
);
|
|
322
|
+
expect(res).toBeUndefined(); // Bun contract: upgraded → undefined
|
|
323
|
+
expect(spy.calls.length).toBe(1);
|
|
324
|
+
const data = spy.calls[0]!.data;
|
|
325
|
+
expect(data.upstreamUrl).toBe("ws://127.0.0.1:12345/wsmod/collab?doc=a");
|
|
326
|
+
// H2 stamps (truthful loopback peer)…
|
|
327
|
+
expect(data.upstreamHeaders["x-parachute-layer"]).toBe("loopback");
|
|
328
|
+
expect(data.upstreamHeaders["x-parachute-client-ip"]).toBe("127.0.0.1");
|
|
329
|
+
// …credentials ride through…
|
|
330
|
+
expect(data.upstreamHeaders.cookie).toBe("parachute_session=zzz");
|
|
331
|
+
// …and the WS handshake + hop-by-hop headers are NOT forwarded (the
|
|
332
|
+
// Bun client re-mints its own handshake).
|
|
333
|
+
expect(data.upstreamHeaders["sec-websocket-key"]).toBeUndefined();
|
|
334
|
+
expect(data.upstreamHeaders["sec-websocket-version"]).toBeUndefined();
|
|
335
|
+
expect(data.upstreamHeaders.upgrade).toBeUndefined();
|
|
336
|
+
expect(data.upstreamHeaders.connection).toBeUndefined();
|
|
337
|
+
expect(data.upstreamHeaders.host).toBeUndefined();
|
|
338
|
+
} finally {
|
|
339
|
+
h.cleanup();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("declared via module.json fallback (row carries no flag) → upgraded", async () => {
|
|
344
|
+
const h = makeHarness();
|
|
345
|
+
try {
|
|
346
|
+
writeManifest(
|
|
347
|
+
{ services: [wsEntry(12346, { installDir: "/fake/install/dir" })] },
|
|
348
|
+
h.manifestPath,
|
|
349
|
+
);
|
|
350
|
+
const manifest: ModuleManifest = {
|
|
351
|
+
name: "wsmod",
|
|
352
|
+
manifestName: "wsmod",
|
|
353
|
+
port: 12346,
|
|
354
|
+
paths: ["/wsmod"],
|
|
355
|
+
health: "/wsmod/health",
|
|
356
|
+
websocket: true,
|
|
357
|
+
};
|
|
358
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
359
|
+
const fetcher = hubFetch(h.dir, {
|
|
360
|
+
manifestPath: h.manifestPath,
|
|
361
|
+
readModuleManifest: async () => manifest,
|
|
362
|
+
});
|
|
363
|
+
const res = await fetcher(upgradeReq("/wsmod/ws"), spy);
|
|
364
|
+
expect(res).toBeUndefined();
|
|
365
|
+
expect(spy.calls.length).toBe(1);
|
|
366
|
+
} finally {
|
|
367
|
+
h.cleanup();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("publicExposure: loopback + public layer → 404 cloak BEFORE upgrade (gate precedes capability)", async () => {
|
|
372
|
+
const h = makeHarness();
|
|
373
|
+
try {
|
|
374
|
+
writeManifest(
|
|
375
|
+
{
|
|
376
|
+
services: [wsEntry(12347, { websocket: true, publicExposure: "loopback" })],
|
|
377
|
+
},
|
|
378
|
+
h.manifestPath,
|
|
379
|
+
);
|
|
380
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
381
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
382
|
+
const res = await fetcher(upgradeReq("/wsmod/ws", { "cf-ray": "1" }), spy);
|
|
383
|
+
expect(res?.status).toBe(404); // indistinguishable from not-installed
|
|
384
|
+
expect(spy.calls.length).toBe(0);
|
|
385
|
+
} finally {
|
|
386
|
+
h.cleanup();
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("stripPrefix honored on the upstream URL", async () => {
|
|
391
|
+
const h = makeHarness();
|
|
392
|
+
try {
|
|
393
|
+
writeManifest(
|
|
394
|
+
{ services: [wsEntry(12348, { websocket: true, stripPrefix: true })] },
|
|
395
|
+
h.manifestPath,
|
|
396
|
+
);
|
|
397
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
398
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
399
|
+
const res = await fetcher(upgradeReq("/wsmod/ws"), spy);
|
|
400
|
+
expect(res).toBeUndefined();
|
|
401
|
+
expect(spy.calls[0]!.data.upstreamUrl).toBe("ws://127.0.0.1:12348/ws");
|
|
402
|
+
} finally {
|
|
403
|
+
h.cleanup();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("upgrade request matching NO service mount falls through to normal dispatch (404)", async () => {
|
|
408
|
+
const h = makeHarness();
|
|
409
|
+
try {
|
|
410
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
411
|
+
const spy = upgradeSpy("127.0.0.1");
|
|
412
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
413
|
+
const res = await fetcher(upgradeReq("/nothing-here"), spy);
|
|
414
|
+
expect(res?.status).toBe(404);
|
|
415
|
+
expect(spy.calls.length).toBe(0);
|
|
416
|
+
} finally {
|
|
417
|
+
h.cleanup();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("no Server threaded (no upgrade capability) → 503, not a crash", async () => {
|
|
422
|
+
const h = makeHarness();
|
|
423
|
+
try {
|
|
424
|
+
writeManifest({ services: [wsEntry(12349, { websocket: true })] }, h.manifestPath);
|
|
425
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
426
|
+
const res = await fetcher(upgradeReq("/wsmod/ws"));
|
|
427
|
+
expect(res?.status).toBe(503);
|
|
428
|
+
} finally {
|
|
429
|
+
h.cleanup();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// ===========================================================================
|
|
435
|
+
// Unit — bridge handler internals (backpressure, close-code sanitization)
|
|
436
|
+
// ===========================================================================
|
|
437
|
+
|
|
438
|
+
describe("createWsBridgeHandlers internals", () => {
|
|
439
|
+
type Listener = (ev: unknown) => void;
|
|
440
|
+
|
|
441
|
+
interface FakeUpstream {
|
|
442
|
+
readyState: number;
|
|
443
|
+
bufferedAmount: number;
|
|
444
|
+
binaryType: string;
|
|
445
|
+
sent: (string | Uint8Array)[];
|
|
446
|
+
closes: { code: number | undefined; reason: string | undefined }[];
|
|
447
|
+
listeners: Map<string, Listener[]>;
|
|
448
|
+
send(frame: string | Uint8Array): void;
|
|
449
|
+
close(code?: number, reason?: string): void;
|
|
450
|
+
addEventListener(name: string, fn: Listener): void;
|
|
451
|
+
fire(name: string, ev: unknown): void;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function fakeUpstream(readyState: number): FakeUpstream {
|
|
455
|
+
return {
|
|
456
|
+
readyState,
|
|
457
|
+
bufferedAmount: 0,
|
|
458
|
+
binaryType: "arraybuffer",
|
|
459
|
+
sent: [],
|
|
460
|
+
closes: [],
|
|
461
|
+
listeners: new Map(),
|
|
462
|
+
send(frame) {
|
|
463
|
+
this.sent.push(frame);
|
|
464
|
+
},
|
|
465
|
+
close(code, reason) {
|
|
466
|
+
this.closes.push({ code, reason });
|
|
467
|
+
},
|
|
468
|
+
addEventListener(name, fn) {
|
|
469
|
+
const arr = this.listeners.get(name) ?? [];
|
|
470
|
+
arr.push(fn);
|
|
471
|
+
this.listeners.set(name, arr);
|
|
472
|
+
},
|
|
473
|
+
fire(name, ev) {
|
|
474
|
+
for (const fn of this.listeners.get(name) ?? []) fn(ev);
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
interface FakeServerWs {
|
|
480
|
+
data: WsBridgeData;
|
|
481
|
+
sent: (string | Uint8Array)[];
|
|
482
|
+
closes: { code: number | undefined; reason: string | undefined }[];
|
|
483
|
+
buffered: number;
|
|
484
|
+
send(frame: string | Uint8Array): number;
|
|
485
|
+
close(code?: number, reason?: string): void;
|
|
486
|
+
getBufferedAmount(): number;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function fakeServerWs(): FakeServerWs {
|
|
490
|
+
return {
|
|
491
|
+
data: { upstreamUrl: "ws://127.0.0.1:1/x", upstreamHeaders: {} },
|
|
492
|
+
sent: [],
|
|
493
|
+
closes: [],
|
|
494
|
+
buffered: 0,
|
|
495
|
+
send(frame) {
|
|
496
|
+
this.sent.push(frame);
|
|
497
|
+
return typeof frame === "string" ? frame.length : frame.byteLength;
|
|
498
|
+
},
|
|
499
|
+
close(code, reason) {
|
|
500
|
+
this.closes.push({ code, reason });
|
|
501
|
+
},
|
|
502
|
+
getBufferedAmount() {
|
|
503
|
+
return this.buffered;
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function openBridge(opts: { cap?: number; upstreamState?: number } = {}) {
|
|
509
|
+
const upstream = fakeUpstream(opts.upstreamState ?? WebSocket.CONNECTING);
|
|
510
|
+
const handlers = createWsBridgeHandlers({
|
|
511
|
+
maxBufferedBytes: opts.cap ?? 64,
|
|
512
|
+
connectUpstream: () => upstream as unknown as WebSocket,
|
|
513
|
+
logger: { warn: () => {} },
|
|
514
|
+
});
|
|
515
|
+
const ws = fakeServerWs();
|
|
516
|
+
handlers.open?.(ws as unknown as ServerWebSocket<WsBridgeData>);
|
|
517
|
+
return { handlers, ws, upstream };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
test("client frames buffered while upstream CONNECTING, flushed on open", () => {
|
|
521
|
+
const { handlers, ws, upstream } = openBridge();
|
|
522
|
+
handlers.message?.(ws as unknown as ServerWebSocket<WsBridgeData>, "early");
|
|
523
|
+
expect(upstream.sent.length).toBe(0);
|
|
524
|
+
upstream.readyState = WebSocket.OPEN;
|
|
525
|
+
upstream.fire("open", {});
|
|
526
|
+
expect(upstream.sent).toEqual(["early"]);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("pending-buffer overflow while CONNECTING → closes both sides 1011", () => {
|
|
530
|
+
const { handlers, ws, upstream } = openBridge({ cap: 8 });
|
|
531
|
+
handlers.message?.(
|
|
532
|
+
ws as unknown as ServerWebSocket<WsBridgeData>,
|
|
533
|
+
"0123456789", // 10 bytes > 8-byte cap
|
|
534
|
+
);
|
|
535
|
+
expect(ws.closes).toEqual([{ code: 1011, reason: "bridge backpressure cap exceeded" }]);
|
|
536
|
+
expect(upstream.closes.length).toBe(1);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("upstream bufferedAmount over cap after a forward → closes both sides 1011", () => {
|
|
540
|
+
const { handlers, ws, upstream } = openBridge({ cap: 8, upstreamState: WebSocket.OPEN });
|
|
541
|
+
upstream.bufferedAmount = 100;
|
|
542
|
+
handlers.message?.(ws as unknown as ServerWebSocket<WsBridgeData>, "x");
|
|
543
|
+
expect(upstream.sent).toEqual(["x"]); // the frame was forwarded…
|
|
544
|
+
expect(ws.closes[0]?.code).toBe(1011); // …then the cap tripped
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("client-side bufferedAmount over cap on upstream→client delivery → closes both sides 1011", () => {
|
|
548
|
+
const { ws, upstream } = openBridge({ cap: 8, upstreamState: WebSocket.OPEN });
|
|
549
|
+
ws.buffered = 100;
|
|
550
|
+
upstream.fire("message", { data: "from-upstream" });
|
|
551
|
+
expect(ws.sent).toEqual(["from-upstream"]);
|
|
552
|
+
expect(ws.closes[0]?.code).toBe(1011);
|
|
553
|
+
expect(upstream.closes.length).toBe(1);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("reserved close codes (1006) are sanitized to a no-code close; reason trimmed to 123 bytes", () => {
|
|
557
|
+
const { ws, upstream } = openBridge({ upstreamState: WebSocket.OPEN });
|
|
558
|
+
upstream.fire("close", { code: 1006, reason: `R${"x".repeat(200)}` });
|
|
559
|
+
expect(ws.closes.length).toBe(1);
|
|
560
|
+
expect(ws.closes[0]?.code).toBeUndefined();
|
|
561
|
+
expect(Buffer.byteLength(ws.closes[0]?.reason ?? "", "utf8")).toBeLessThanOrEqual(123);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("client close propagates to upstream exactly once (idempotent latch)", () => {
|
|
565
|
+
const { handlers, ws, upstream } = openBridge({ upstreamState: WebSocket.OPEN });
|
|
566
|
+
handlers.close?.(ws as unknown as ServerWebSocket<WsBridgeData>, 4005, "done");
|
|
567
|
+
handlers.close?.(ws as unknown as ServerWebSocket<WsBridgeData>, 4005, "done");
|
|
568
|
+
expect(upstream.closes).toEqual([{ code: 4005, reason: "done" }]);
|
|
569
|
+
// After the latch, an upstream close event must not bounce back.
|
|
570
|
+
upstream.fire("close", { code: 1000, reason: "" });
|
|
571
|
+
expect(ws.closes.length).toBe(0);
|
|
572
|
+
});
|
|
573
|
+
});
|