@qilin-agent/client-connection 0.0.0-stage → 3.1.4
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/LICENSE +21 -0
- package/README.i18n.yaml +37 -0
- package/README.md +86 -2
- package/README.zh.md +87 -0
- package/lib/client.js +1459 -0
- package/lib/index.js +983 -0
- package/lib/types/api-path.d.ts +7 -0
- package/lib/types/api-request-trust.d.ts +45 -0
- package/lib/types/browser-auth.d.ts +68 -0
- package/lib/types/client/api.d.ts +14 -0
- package/lib/types/client/connection.d.ts +80 -0
- package/lib/types/client/index.d.ts +136 -0
- package/lib/types/client/random-uuid.d.ts +7 -0
- package/lib/types/client/rpc.d.ts +14 -0
- package/lib/types/http-bridge.d.ts +21 -0
- package/lib/types/index.d.ts +58 -0
- package/lib/types/loopback-hostname.d.ts +12 -0
- package/lib/types/operator-peer.d.ts +23 -0
- package/lib/types/recovery-config.d.ts +27 -0
- package/lib/types/rpc-host.d.ts +70 -0
- package/lib/types/rpc-schema.d.ts +30 -0
- package/lib/types/rpc.d.ts +262 -0
- package/lib/types/web-entry.d.ts +9 -0
- package/package.json +65 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
import z from "@qilin-agent/schemastery";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { credentialKey } from "@qilin-agent/credentials";
|
|
5
|
+
import { Service } from "@qilin-agent/kylin";
|
|
6
|
+
import { z as z$1 } from "zod";
|
|
7
|
+
import { createScope } from "@qilin-agent/scope";
|
|
8
|
+
//#region lib/types/api-path.js
|
|
9
|
+
/**
|
|
10
|
+
* The /api URL prefix — single source for both halves of the web transport.
|
|
11
|
+
* The node half registers this prefix on the web server.
|
|
12
|
+
*/
|
|
13
|
+
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
|
|
14
|
+
const API_PATH = "/api";
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region lib/types/http-bridge.js
|
|
17
|
+
/**
|
|
18
|
+
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
|
|
19
|
+
* web carrier; the fetch-shaped handler itself is transport-agnostic).
|
|
20
|
+
*/
|
|
21
|
+
/** Default carrier cap for all HTTP RPC bodies: sized for the default
|
|
22
|
+
* aggregate image limit (200 MiB) after base64 expansion plus envelope
|
|
23
|
+
* headroom (~267.7 MiB required), rounded up for slack. The bridge buffers
|
|
24
|
+
* each body in memory, so this cap is also the per-request resident bound. */
|
|
25
|
+
const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024;
|
|
26
|
+
/**
|
|
27
|
+
* Bridge one node:http request to the fetch-shaped handler (client close
|
|
28
|
+
* aborts; response bodies stream out chunk by chunk).
|
|
29
|
+
* @param req - incoming node:http request.
|
|
30
|
+
* @param res - node:http response the bridge writes and owns to completion.
|
|
31
|
+
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
|
32
|
+
* @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
|
|
33
|
+
*/
|
|
34
|
+
async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES) {
|
|
35
|
+
const abort = new AbortController();
|
|
36
|
+
res.on("close", () => {
|
|
37
|
+
if (!res.writableEnded) abort.abort();
|
|
38
|
+
});
|
|
39
|
+
/* v8 ignore next 2 -- node:http always sets url/method on server requests. */
|
|
40
|
+
const url = new URL(req.url ?? "/", "http://qilin.internal");
|
|
41
|
+
const method = req.method ?? "GET";
|
|
42
|
+
const headers = Object.fromEntries(Object.entries(req.headers).filter(([, value]) => typeof value === "string"));
|
|
43
|
+
const bodyMode = apiHandler.requestBodyMode({
|
|
44
|
+
method,
|
|
45
|
+
url
|
|
46
|
+
});
|
|
47
|
+
let request;
|
|
48
|
+
if (bodyMode === "buffered") {
|
|
49
|
+
const declaredLength = req.headers["content-length"];
|
|
50
|
+
if (declaredLength !== void 0 && Number(declaredLength) > maxRequestBodyBytes) {
|
|
51
|
+
res.writeHead(413, { connection: "close" });
|
|
52
|
+
res.end();
|
|
53
|
+
req.destroy();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let received = 0;
|
|
58
|
+
for await (const chunk of req) {
|
|
59
|
+
const buffer = chunk;
|
|
60
|
+
received += buffer.byteLength;
|
|
61
|
+
if (received > maxRequestBodyBytes) {
|
|
62
|
+
res.writeHead(413, { connection: "close" });
|
|
63
|
+
res.end();
|
|
64
|
+
req.destroy();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
chunks.push(buffer);
|
|
68
|
+
}
|
|
69
|
+
request = new Request(url, {
|
|
70
|
+
method,
|
|
71
|
+
headers,
|
|
72
|
+
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
|
|
73
|
+
signal: abort.signal
|
|
74
|
+
});
|
|
75
|
+
} else request = new Request(url, {
|
|
76
|
+
method,
|
|
77
|
+
headers,
|
|
78
|
+
body: Readable.toWeb(req),
|
|
79
|
+
signal: abort.signal,
|
|
80
|
+
duplex: "half"
|
|
81
|
+
});
|
|
82
|
+
const response = await apiHandler.fetch(request);
|
|
83
|
+
const requestUnread = bodyMode === "streaming" && !req.readableEnded;
|
|
84
|
+
const responseHeaders = Object.fromEntries(response.headers.entries());
|
|
85
|
+
res.writeHead(response.status, requestUnread ? {
|
|
86
|
+
...responseHeaders,
|
|
87
|
+
connection: "close"
|
|
88
|
+
} : responseHeaders);
|
|
89
|
+
if (response.body === null) {
|
|
90
|
+
res.end();
|
|
91
|
+
if (requestUnread) req.destroy();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for await (const chunk of response.body) if (!res.write(chunk)) await new Promise((resolve) => {
|
|
95
|
+
const done = () => {
|
|
96
|
+
res.off("drain", done);
|
|
97
|
+
res.off("close", done);
|
|
98
|
+
resolve();
|
|
99
|
+
};
|
|
100
|
+
res.once("drain", done);
|
|
101
|
+
res.once("close", done);
|
|
102
|
+
});
|
|
103
|
+
res.end();
|
|
104
|
+
if (requestUnread) req.destroy();
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region lib/types/loopback-hostname.js
|
|
108
|
+
/**
|
|
109
|
+
* Browser-safe, zero-dependency loopback classification shared by the `/api`
|
|
110
|
+
* Host fence and the package's `ctx.connection` state. The predicate stays
|
|
111
|
+
* package-internal; client plugins consume the derived state through Cordis.
|
|
112
|
+
*/
|
|
113
|
+
/**
|
|
114
|
+
* Whether a normalized URL hostname names the local loopback authority.
|
|
115
|
+
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
|
|
116
|
+
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
|
|
117
|
+
*/
|
|
118
|
+
function isLoopbackHostname(hostname) {
|
|
119
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
120
|
+
const parts = hostname.split(".");
|
|
121
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region lib/types/api-request-trust.js
|
|
125
|
+
/**
|
|
126
|
+
* Browser-trust fence for every /api request. Defends the two confused-deputy
|
|
127
|
+
* paths a browser opens against a local HTTP API — DNS rebinding (Host names
|
|
128
|
+
* the attacker's domain while the socket reaches this server) and cross-site
|
|
129
|
+
* requests fired from a malicious page. The Host fence binds every request,
|
|
130
|
+
* browser-looking or not: over plain HTTP a browser attaches neither Origin
|
|
131
|
+
* nor Fetch-Metadata to reads (images and navigations — those headers go only
|
|
132
|
+
* to trustworthy destinations), so an unmarked request may still be a rebound
|
|
133
|
+
* browser read and Host is the one header rebinding cannot forge. Non-browser
|
|
134
|
+
* and remote clients pass the same fence via loopback, the listener's bind IP
|
|
135
|
+
* literal, or a declared `trustedHosts` authority. Binding policy belongs to
|
|
136
|
+
* the webserver config; this fence never establishes identity.
|
|
137
|
+
*/
|
|
138
|
+
function header$1(headers, name) {
|
|
139
|
+
if (headers instanceof Headers) return headers.get(name) ?? void 0;
|
|
140
|
+
const value = headers[name];
|
|
141
|
+
return typeof value === "string" ? value : void 0;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Normalized URL of a bare authority (hostname lowercased, default port
|
|
145
|
+
* stripped, IPv6 bracketed), or undefined when unparsable.
|
|
146
|
+
* @param authority - `host` or `host:port` text.
|
|
147
|
+
* @param scheme - special scheme to parse under; its default port is the one stripped.
|
|
148
|
+
*/
|
|
149
|
+
function parseAuthority(authority, scheme = "http:") {
|
|
150
|
+
try {
|
|
151
|
+
return new URL(`${scheme}//${authority}`);
|
|
152
|
+
} catch {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
|
|
158
|
+
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged
|
|
159
|
+
* (case aside). Anything parsing would silently rewrite is refused as a typo
|
|
160
|
+
* that must fail the load loudly instead of being ignored until requests 403
|
|
161
|
+
* or quietly changing the grant: URL parts beyond the authority
|
|
162
|
+
* (`harness.internal/path`, `user@harness.internal` — which would authorize
|
|
163
|
+
* the embedded hostname), stripped whitespace, a dangling colon or
|
|
164
|
+
* zero-padded port (which would broaden an intended exact-port grant to every
|
|
165
|
+
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
|
|
166
|
+
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
|
|
167
|
+
* carries).
|
|
168
|
+
* @param entry - the configured value, verbatim.
|
|
169
|
+
*/
|
|
170
|
+
function assertTrustedAuthority(entry) {
|
|
171
|
+
const entryUrl = parseAuthority(entry);
|
|
172
|
+
if (entryUrl !== void 0 && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return;
|
|
173
|
+
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Canonical form of a parsed authority: `hostname` when no port was written,
|
|
177
|
+
* else `hostname:port`. The port is judged from URL parses under both special
|
|
178
|
+
* schemes (their default ports differ, so `:80` and `:443` still count as
|
|
179
|
+
* explicit), never from the raw string, where WHATWG trimming would misread
|
|
180
|
+
* shapes like `host:port ` as port-less.
|
|
181
|
+
*/
|
|
182
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
183
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`${entryUrl.protocol === "http:" ? "https:" : "http:"}//${entry}`).port;
|
|
184
|
+
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Whether the request authority matches a `trustedHosts` entry. An entry with
|
|
188
|
+
* an explicit port matches that exact authority; a port-less entry matches the
|
|
189
|
+
* hostname on any port. Both sides compare through WHATWG normalization,
|
|
190
|
+
* so case and a redundant scheme-default port never decide trust.
|
|
191
|
+
*/
|
|
192
|
+
function isTrustedAuthority(hostUrl, trustedHosts, protocol) {
|
|
193
|
+
return trustedHosts.some((entry) => {
|
|
194
|
+
const entryUrl = parseAuthority(entry, protocol);
|
|
195
|
+
if (entryUrl === void 0) return false;
|
|
196
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether a parsed authority names the deployment's own bind address. A browser
|
|
201
|
+
* dialing that literal sends it as Host, and accepting it grants nothing
|
|
202
|
+
* further: the literal is no rebinding target, the Origin and cross-site checks
|
|
203
|
+
* still apply, and it stays outside `trustedHosts`. Matching ignores the port,
|
|
204
|
+
* like a port-less entry, and any `%zone` id (an interface selector, not
|
|
205
|
+
* address text).
|
|
206
|
+
*/
|
|
207
|
+
function isBindAddressAuthority(hostUrl, bindHost) {
|
|
208
|
+
if (bindHost === void 0) return false;
|
|
209
|
+
const zoneAt = bindHost.indexOf("%");
|
|
210
|
+
const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt);
|
|
211
|
+
const bindUrl = parseAuthority(address.includes(":") ? `[${address}]` : address);
|
|
212
|
+
return bindUrl !== void 0 && bindUrl.hostname === hostUrl.hostname;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Decide whether one /api request may reach the RPC bridge.
|
|
216
|
+
* @param request - Node HTTP or Fetch request facts (headers).
|
|
217
|
+
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
|
218
|
+
* @param bindHost - the listener's own bind IP literal, accepted on any port independently of `trustedHosts`.
|
|
219
|
+
* @param protocol - listener protocol for default Host ports; HTTP when no Web carrier is mounted.
|
|
220
|
+
* @returns true when the Host is ours (loopback, the bind address, or trusted) and any attached browser markers are same-origin.
|
|
221
|
+
*/
|
|
222
|
+
function isTrustedApiRequest(request, trustedHosts, bindHost, protocol = "http:") {
|
|
223
|
+
const host = header$1(request.headers, "host");
|
|
224
|
+
if (host === void 0) return false;
|
|
225
|
+
const hostUrl = parseAuthority(host, protocol);
|
|
226
|
+
if (hostUrl === void 0) return false;
|
|
227
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts, protocol) && !isBindAddressAuthority(hostUrl, bindHost)) return false;
|
|
228
|
+
if (header$1(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
229
|
+
const origin = header$1(request.headers, "origin");
|
|
230
|
+
if (origin === void 0) return true;
|
|
231
|
+
return isSameAuthorityOrigin(origin, host);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Compare Host under the Origin's HTTP(S) scheme so default ports normalize
|
|
235
|
+
* consistently. The transport may remain HTTP behind an HTTPS proxy.
|
|
236
|
+
* @param origin - verbatim Origin header value.
|
|
237
|
+
* @param host - verbatim Host header value already accepted by the Host fence.
|
|
238
|
+
* @returns true only when the Origin is this listener's own `http(s)` authority.
|
|
239
|
+
*/
|
|
240
|
+
function isSameAuthorityOrigin(origin, host) {
|
|
241
|
+
let originUrl;
|
|
242
|
+
try {
|
|
243
|
+
originUrl = new URL(origin);
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const scheme = originUrl.protocol === "https:" ? "https:" : originUrl.protocol === "http:" ? "http:" : void 0;
|
|
248
|
+
if (scheme === void 0) return false;
|
|
249
|
+
const hostUrl = parseAuthority(host, scheme);
|
|
250
|
+
return hostUrl !== void 0 && originUrl.host === hostUrl.host;
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region lib/types/web-entry.js
|
|
254
|
+
/**
|
|
255
|
+
* The one request path that serves the harness application document. The site
|
|
256
|
+
* root keeps the public landing page and the sign-in screens have their own
|
|
257
|
+
* documents, so every entry URL, redirect, and deployment index path names
|
|
258
|
+
* this constant instead of repeating the literal.
|
|
259
|
+
*/
|
|
260
|
+
/** Request path serving the harness application document. */
|
|
261
|
+
const WEB_ENTRY_PATH = "/workspace";
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region lib/types/browser-auth.js
|
|
264
|
+
/** Browser-session authentication for the Host Connection carrier. */
|
|
265
|
+
const AUTH_RECORD_KEY = credentialKey("client-connection", "browser-session");
|
|
266
|
+
const DAY_MILLISECONDS = 1440 * 60 * 1e3;
|
|
267
|
+
const SECRET_BYTES = 32;
|
|
268
|
+
const TOKEN_QUERY = "token";
|
|
269
|
+
const DISPLAY_NAME_QUERY = "user";
|
|
270
|
+
const DISPLAY_NAME_MAX_CODE_UNITS = 64;
|
|
271
|
+
const COOKIE_PREFIX = "qilin-auth-";
|
|
272
|
+
const COOKIE_PAYLOAD_VERSION = 1;
|
|
273
|
+
const STORED_SECRET_VERSION = 1;
|
|
274
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
|
|
275
|
+
const PROCESS_LAUNCH_TOKENS = /* @__PURE__ */ new WeakMap();
|
|
276
|
+
function isRecord(value) {
|
|
277
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
278
|
+
}
|
|
279
|
+
function encodeBase64Url(value) {
|
|
280
|
+
return Buffer.from(value).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
281
|
+
}
|
|
282
|
+
function decodeBase64Url(value) {
|
|
283
|
+
if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return void 0;
|
|
284
|
+
const padding = "=".repeat((4 - value.length % 4) % 4);
|
|
285
|
+
const decoded = Buffer.from(value.replaceAll("-", "+").replaceAll("_", "/") + padding, "base64");
|
|
286
|
+
return encodeBase64Url(decoded) === value ? decoded : void 0;
|
|
287
|
+
}
|
|
288
|
+
function processLaunchToken(owner) {
|
|
289
|
+
const existing = PROCESS_LAUNCH_TOKENS.get(owner);
|
|
290
|
+
if (existing !== void 0) return existing;
|
|
291
|
+
const created = encodeBase64Url(randomBytes(SECRET_BYTES));
|
|
292
|
+
PROCESS_LAUNCH_TOKENS.set(owner, created);
|
|
293
|
+
return created;
|
|
294
|
+
}
|
|
295
|
+
function header(headers, name) {
|
|
296
|
+
if (headers instanceof Headers) return headers.get(name) ?? void 0;
|
|
297
|
+
const value = headers[name];
|
|
298
|
+
return typeof value === "string" ? value : void 0;
|
|
299
|
+
}
|
|
300
|
+
/** Canonical request authority used as the cookie name and signed audience. */
|
|
301
|
+
function requestAuthority(headers) {
|
|
302
|
+
const host = header(headers, "host");
|
|
303
|
+
if (host === void 0) return void 0;
|
|
304
|
+
try {
|
|
305
|
+
return new URL(`http://${host}`).host;
|
|
306
|
+
} catch {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function canonicalSecret(value) {
|
|
311
|
+
if (typeof value !== "string") return void 0;
|
|
312
|
+
const decoded = decodeBase64Url(value);
|
|
313
|
+
if (decoded === void 0 || decoded.byteLength !== SECRET_BYTES) return void 0;
|
|
314
|
+
return decoded;
|
|
315
|
+
}
|
|
316
|
+
function storedSecret(record) {
|
|
317
|
+
if (record === void 0) return void 0;
|
|
318
|
+
if (record.kind !== "grant" || !isRecord(record.payload) || record.payload.version !== STORED_SECRET_VERSION) throw new Error("client-connection: browser-session credential record has an unsupported format");
|
|
319
|
+
const secret = canonicalSecret(record.payload.secret);
|
|
320
|
+
if (secret === void 0) throw new Error("client-connection: browser-session credential record has an invalid secret");
|
|
321
|
+
return secret;
|
|
322
|
+
}
|
|
323
|
+
function tokenMatches(actual, expected) {
|
|
324
|
+
const actualBytes = Buffer.from(actual, "utf8");
|
|
325
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
326
|
+
return actualBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(actualBytes, expectedBytes);
|
|
327
|
+
}
|
|
328
|
+
function cookieName(authority) {
|
|
329
|
+
return COOKIE_PREFIX + encodeBase64Url(createHash("sha256").update(authority).digest());
|
|
330
|
+
}
|
|
331
|
+
/** Read the exact generated cookie without implementing general Cookie decoding. */
|
|
332
|
+
function cookieValue(headerValue, name) {
|
|
333
|
+
for (const segment of headerValue.split(";")) {
|
|
334
|
+
const at = segment.indexOf("=");
|
|
335
|
+
if (at === -1 || segment.slice(0, at).trim() !== name) continue;
|
|
336
|
+
return segment.slice(at + 1).trim();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */
|
|
340
|
+
function sessionCookie(name, value, expiresAt, maxAgeSeconds) {
|
|
341
|
+
return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`;
|
|
342
|
+
}
|
|
343
|
+
function signature(secret, body) {
|
|
344
|
+
return createHmac("sha256", secret).update(body).digest();
|
|
345
|
+
}
|
|
346
|
+
/** Request URL of one index request; a caller-supplied URL is optional because node:http always sets it. */
|
|
347
|
+
function requestUrl(req) {
|
|
348
|
+
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests */
|
|
349
|
+
return new URL(req.url ?? "/", "http://qilin.invalid");
|
|
350
|
+
}
|
|
351
|
+
/** Redirect one already-authenticated request to the clean entry path. */
|
|
352
|
+
function writeEntryRedirect(res) {
|
|
353
|
+
res.writeHead(303, {
|
|
354
|
+
"cache-control": "no-store",
|
|
355
|
+
"location": WEB_ENTRY_PATH,
|
|
356
|
+
"referrer-policy": "no-referrer"
|
|
357
|
+
});
|
|
358
|
+
res.end();
|
|
359
|
+
}
|
|
360
|
+
function encodeCookie(payload, secret) {
|
|
361
|
+
const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
|
|
362
|
+
return `v1.${body}.${encodeBase64Url(signature(secret, body))}`;
|
|
363
|
+
}
|
|
364
|
+
function decodeCookie(value, secret) {
|
|
365
|
+
const parts = value.split(".");
|
|
366
|
+
const [version, body, encodedSignature] = parts;
|
|
367
|
+
if (parts.length !== 3 || version !== "v1" || body === void 0 || encodedSignature === void 0) return;
|
|
368
|
+
const actualSignature = decodeBase64Url(encodedSignature);
|
|
369
|
+
if (actualSignature === void 0) return void 0;
|
|
370
|
+
const expectedSignature = signature(secret, body);
|
|
371
|
+
if (actualSignature.byteLength !== expectedSignature.byteLength || !timingSafeEqual(actualSignature, expectedSignature)) return void 0;
|
|
372
|
+
let decoded;
|
|
373
|
+
try {
|
|
374
|
+
const bodyBytes = decodeBase64Url(body);
|
|
375
|
+
if (bodyBytes === void 0) return void 0;
|
|
376
|
+
decoded = JSON.parse(bodyBytes.toString("utf8"));
|
|
377
|
+
} catch {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (!isRecord(decoded) || decoded.version !== COOKIE_PAYLOAD_VERSION || typeof decoded.authority !== "string" || !Number.isSafeInteger(decoded.issuedAt) || !Number.isSafeInteger(decoded.expiresAt)) return void 0;
|
|
381
|
+
if (decoded.displayName !== void 0 && !boundedDisplayName(decoded.displayName)) return void 0;
|
|
382
|
+
return decoded;
|
|
383
|
+
}
|
|
384
|
+
/** Accept a display name the launching host attached: trimmed, bounded, control-free. */
|
|
385
|
+
function boundedDisplayName(value) {
|
|
386
|
+
if (typeof value !== "string" || value.length === 0 || value.length > DISPLAY_NAME_MAX_CODE_UNITS) return false;
|
|
387
|
+
return !/[\u0000-\u001f\u007f]/u.test(value);
|
|
388
|
+
}
|
|
389
|
+
/** The facts a request without a verifiable device session carries. */
|
|
390
|
+
const UNIDENTIFIED = {
|
|
391
|
+
authenticated: false,
|
|
392
|
+
accountName: null
|
|
393
|
+
};
|
|
394
|
+
async function initializeSecret(credentials) {
|
|
395
|
+
const generated = {
|
|
396
|
+
version: STORED_SECRET_VERSION,
|
|
397
|
+
secret: encodeBase64Url(randomBytes(SECRET_BYTES))
|
|
398
|
+
};
|
|
399
|
+
const secret = storedSecret(await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => {
|
|
400
|
+
if (current !== void 0) {
|
|
401
|
+
storedSecret(current);
|
|
402
|
+
return Promise.resolve(void 0);
|
|
403
|
+
}
|
|
404
|
+
return Promise.resolve({
|
|
405
|
+
kind: "grant",
|
|
406
|
+
payload: generated
|
|
407
|
+
});
|
|
408
|
+
}));
|
|
409
|
+
if (secret === void 0) throw new Error("client-connection: browser-session credential record was not created");
|
|
410
|
+
return secret;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Process launch-token exchange and persistent signed-cookie verification.
|
|
414
|
+
* Connection loads the credential provider's signing secret during activation
|
|
415
|
+
* and retains it for synchronous request authentication.
|
|
416
|
+
*/
|
|
417
|
+
var BrowserAuth = class BrowserAuth {
|
|
418
|
+
secret;
|
|
419
|
+
launchToken;
|
|
420
|
+
maxAgeMilliseconds;
|
|
421
|
+
constructor(processOwner, secret, maxAgeDays) {
|
|
422
|
+
this.secret = secret;
|
|
423
|
+
this.launchToken = processLaunchToken(processOwner);
|
|
424
|
+
this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS;
|
|
425
|
+
if (!Number.isSafeInteger(this.maxAgeMilliseconds) || !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) throw new Error("client-connection: cookieMaxAgeDays exceeds the safe timestamp range");
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Initialize browser authentication and create its durable signing secret
|
|
429
|
+
* when this Harness home has none.
|
|
430
|
+
* @param processOwner - root application context retaining one token across Connection reloads.
|
|
431
|
+
* @param credentials - persistent credential provider for the Web profile.
|
|
432
|
+
* @param maxAgeDays - positive absolute browser-cookie lifetime in days.
|
|
433
|
+
* @returns initialized authentication owner with the process owner's launch token.
|
|
434
|
+
*/
|
|
435
|
+
static async create(processOwner, credentials, maxAgeDays) {
|
|
436
|
+
return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Add this process's launch token to the ordinary application root URL.
|
|
440
|
+
* @param baseUrl - canonical browser origin without credentials.
|
|
441
|
+
* @param displayName - optional operator name the exchange carries into the device cookie for display.
|
|
442
|
+
* @returns root URL carrying the process token as its sole authentication input.
|
|
443
|
+
*/
|
|
444
|
+
authenticatedUrl(baseUrl, displayName) {
|
|
445
|
+
const url = new URL(baseUrl);
|
|
446
|
+
url.pathname = WEB_ENTRY_PATH;
|
|
447
|
+
url.search = "";
|
|
448
|
+
url.hash = "";
|
|
449
|
+
url.searchParams.set(TOKEN_QUERY, this.launchToken);
|
|
450
|
+
if (displayName !== void 0) url.searchParams.set(DISPLAY_NAME_QUERY, displayName);
|
|
451
|
+
return url.href;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Authenticate an index request. A valid entry-path query token mints the
|
|
455
|
+
* device cookie and redirects to the clean entry path; a valid cookie lets
|
|
456
|
+
* the caller serve the index; every other request receives the same minimal
|
|
457
|
+
* 401 response.
|
|
458
|
+
* @param req - incoming entry or configured-index request.
|
|
459
|
+
* @param res - response owned when this method returns false.
|
|
460
|
+
* @returns true only when the caller may serve index.html.
|
|
461
|
+
*/
|
|
462
|
+
authorizeIndex(req, res) {
|
|
463
|
+
const url = requestUrl(req);
|
|
464
|
+
if (url.searchParams.getAll(TOKEN_QUERY).length > 0) {
|
|
465
|
+
if (this.exchangeToken(req, res)) return false;
|
|
466
|
+
if (req.method === "GET" && url.pathname === "/workspace" && this.isAuthenticated(req)) {
|
|
467
|
+
writeEntryRedirect(res);
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
this.writeUnauthorized(req, res);
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
if (this.isAuthenticated(req)) return true;
|
|
474
|
+
this.writeUnauthorized(req, res);
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Mint the device cookie for one valid launch token and redirect to the clean
|
|
479
|
+
* entry path: the handoff that turns the printed URL into a browser session.
|
|
480
|
+
* @param req - incoming request; its only token input is the query.
|
|
481
|
+
* @param res - response owned when the exchange applies.
|
|
482
|
+
* @returns true when a valid token was exchanged and the redirect written.
|
|
483
|
+
*/
|
|
484
|
+
exchangeToken(req, res) {
|
|
485
|
+
const url = requestUrl(req);
|
|
486
|
+
const tokens = url.searchParams.getAll(TOKEN_QUERY);
|
|
487
|
+
const authority = requestAuthority(req.headers);
|
|
488
|
+
if (req.method !== "GET" || url.pathname !== "/workspace" || tokens.length !== 1 || authority === void 0 || !tokenMatches(tokens.join(""), this.launchToken)) return false;
|
|
489
|
+
const requested = url.searchParams.get(DISPLAY_NAME_QUERY)?.trim();
|
|
490
|
+
const issuedAt = Date.now();
|
|
491
|
+
const expiresAt = issuedAt + this.maxAgeMilliseconds;
|
|
492
|
+
const value = encodeCookie({
|
|
493
|
+
version: COOKIE_PAYLOAD_VERSION,
|
|
494
|
+
authority,
|
|
495
|
+
issuedAt,
|
|
496
|
+
expiresAt,
|
|
497
|
+
...requested === void 0 || requested === "" || !boundedDisplayName(requested) ? {} : { displayName: requested }
|
|
498
|
+
}, this.secret);
|
|
499
|
+
res.writeHead(303, {
|
|
500
|
+
"cache-control": "no-store",
|
|
501
|
+
"location": WEB_ENTRY_PATH,
|
|
502
|
+
"referrer-policy": "no-referrer",
|
|
503
|
+
"set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3))
|
|
504
|
+
});
|
|
505
|
+
res.end();
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Verify the authority-bound browser cookie on a Host request.
|
|
510
|
+
* @param request - request headers carrying Host and Cookie.
|
|
511
|
+
* @returns true only for an unexpired cookie signed by this activation's loaded secret.
|
|
512
|
+
*/
|
|
513
|
+
isAuthenticated(request) {
|
|
514
|
+
return this.identity(request).authenticated;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Read the device session behind a Host request: its authentication fact and
|
|
518
|
+
* the operator display name the launching host attached, if any. This is the
|
|
519
|
+
* identity channel for deployments without the account gate, and the gate's
|
|
520
|
+
* own status route stays authoritative wherever that is mounted.
|
|
521
|
+
* @param request - request headers carrying Host and Cookie.
|
|
522
|
+
* @returns the session fact and display name of the request's device session.
|
|
523
|
+
*/
|
|
524
|
+
identity(request) {
|
|
525
|
+
const authority = requestAuthority(request.headers);
|
|
526
|
+
const rawCookie = header(request.headers, "cookie");
|
|
527
|
+
if (authority === void 0 || rawCookie === void 0) return UNIDENTIFIED;
|
|
528
|
+
const value = cookieValue(rawCookie, cookieName(authority));
|
|
529
|
+
if (value === void 0) return UNIDENTIFIED;
|
|
530
|
+
const payload = decodeCookie(value, this.secret);
|
|
531
|
+
if (payload === void 0 || payload.authority !== authority) return UNIDENTIFIED;
|
|
532
|
+
const now = Date.now();
|
|
533
|
+
const authenticated = payload.issuedAt <= now && payload.expiresAt > now && payload.expiresAt > payload.issuedAt && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds;
|
|
534
|
+
return {
|
|
535
|
+
authenticated,
|
|
536
|
+
accountName: authenticated ? payload.displayName ?? null : null
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
writeUnauthorized(req, res) {
|
|
540
|
+
res.writeHead(401, {
|
|
541
|
+
"cache-control": "no-store",
|
|
542
|
+
"content-type": "text/plain; charset=utf-8"
|
|
543
|
+
});
|
|
544
|
+
res.end(req.method === "HEAD" ? void 0 : "qilin web authentication required; reopen the URL printed by qilin web.\n");
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
//#endregion
|
|
548
|
+
//#region lib/types/rpc.js
|
|
549
|
+
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
|
550
|
+
/**
|
|
551
|
+
* Brand one validated string as a Connection correlation id.
|
|
552
|
+
* @param id - validated wire identity.
|
|
553
|
+
* @returns the same string with the correlation-id brand.
|
|
554
|
+
*/
|
|
555
|
+
function RpcId(id) {
|
|
556
|
+
return id;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Convert a rejected transport operation into a generic failure result.
|
|
560
|
+
* @param error - rejected transport value.
|
|
561
|
+
* @returns an `internal` failure preserving the available message.
|
|
562
|
+
*/
|
|
563
|
+
function transportError(error) {
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
error: {
|
|
567
|
+
code: "gateway/internal",
|
|
568
|
+
message: error instanceof Error ? error.message : String(error),
|
|
569
|
+
details: {}
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region lib/types/rpc-schema.js
|
|
575
|
+
/** Runtime validation for Connection RPC envelopes. */
|
|
576
|
+
/** Correlation id after wire validation. */
|
|
577
|
+
const rpcIdSchema = z$1.string();
|
|
578
|
+
/** Generic endpoint failure carried in a response envelope. */
|
|
579
|
+
const rpcErrorSchema = z$1.object({
|
|
580
|
+
code: z$1.string(),
|
|
581
|
+
message: z$1.string(),
|
|
582
|
+
details: z$1.record(z$1.string(), z$1.unknown())
|
|
583
|
+
});
|
|
584
|
+
/**
|
|
585
|
+
* Build the result parser for one endpoint value parser.
|
|
586
|
+
* @param value - endpoint-owned success-value parser.
|
|
587
|
+
* @returns parser for either a success value or generic failure.
|
|
588
|
+
*/
|
|
589
|
+
function rpcResultSchema(value) {
|
|
590
|
+
return z$1.union([z$1.object({
|
|
591
|
+
ok: z$1.literal(true),
|
|
592
|
+
value
|
|
593
|
+
}), z$1.object({
|
|
594
|
+
ok: z$1.literal(false),
|
|
595
|
+
error: rpcErrorSchema
|
|
596
|
+
})]);
|
|
597
|
+
}
|
|
598
|
+
/** Client request envelope; endpoint payload validation belongs to its owner. */
|
|
599
|
+
const clientRequestSchema = z$1.object({
|
|
600
|
+
type: z$1.literal("client-request"),
|
|
601
|
+
rpcId: rpcIdSchema,
|
|
602
|
+
method: z$1.string(),
|
|
603
|
+
payload: z$1.unknown()
|
|
604
|
+
});
|
|
605
|
+
/** Server response envelope; endpoint value validation belongs to its caller. */
|
|
606
|
+
const serverResponseSchema = z$1.object({
|
|
607
|
+
type: z$1.literal("server-response"),
|
|
608
|
+
rpcId: rpcIdSchema,
|
|
609
|
+
result: rpcResultSchema(z$1.unknown().optional())
|
|
610
|
+
});
|
|
611
|
+
/** Either Connection RPC envelope direction. */
|
|
612
|
+
const rpcMessageSchema = z$1.discriminatedUnion("type", [clientRequestSchema, serverResponseSchema]);
|
|
613
|
+
//#endregion
|
|
614
|
+
//#region lib/types/operator-peer.js
|
|
615
|
+
/**
|
|
616
|
+
* The operator Peer: the one party this Host answers to. Connection owns it
|
|
617
|
+
* for its own lifetime, admits every request as it, and hands it to each
|
|
618
|
+
* Remote call as `invocation.peer`.
|
|
619
|
+
* @module @qilin-agent/client-connection/src/operator-peer
|
|
620
|
+
*/
|
|
621
|
+
/**
|
|
622
|
+
* The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
|
|
623
|
+
* returns it and events dispatched with `scopeTarget(subject, peer)` reach
|
|
624
|
+
* listeners registered through `peer.ctx` and nobody else.
|
|
625
|
+
*/
|
|
626
|
+
var OperatorPeer = class {
|
|
627
|
+
id = randomUUID();
|
|
628
|
+
ctx;
|
|
629
|
+
scope;
|
|
630
|
+
/** @param owner - Connection plugin context the scope fiber hangs under. */
|
|
631
|
+
constructor(owner) {
|
|
632
|
+
this.scope = createScope(owner, this);
|
|
633
|
+
this.ctx = this.scope.ctx;
|
|
634
|
+
}
|
|
635
|
+
/** Tear down every connection-lifetime registration; racing calls share one completion. */
|
|
636
|
+
dispose() {
|
|
637
|
+
return this.scope.dispose();
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region lib/types/rpc-host.js
|
|
642
|
+
/** Host registry and HTTP adapter for generic Connection RPC channels. */
|
|
643
|
+
const INVALID_REQUEST_RPC_ID = RpcId("invalid-request");
|
|
644
|
+
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/;
|
|
645
|
+
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
|
|
646
|
+
/**
|
|
647
|
+
* The status read the account gate answers when its bundle is mounted. When a
|
|
648
|
+
* deployment disables that bundle, Connection answers this path from the
|
|
649
|
+
* device session instead — display-only identity for the account menu, with no
|
|
650
|
+
* gate semantics: no setup, no registration, no sign-out row.
|
|
651
|
+
*/
|
|
652
|
+
const AUTH_STATUS_PATH = "/api/auth/status";
|
|
653
|
+
/** Host Connection service whose channel registrations belong to the caller fiber. */
|
|
654
|
+
var HostConnectionService = class extends Service {
|
|
655
|
+
trustedHosts;
|
|
656
|
+
browserAuth;
|
|
657
|
+
/** The operator Peer every admitted request speaks for. */
|
|
658
|
+
operator;
|
|
659
|
+
interceptors = /* @__PURE__ */ new Map();
|
|
660
|
+
fetchRoutes = /* @__PURE__ */ new Map();
|
|
661
|
+
sessionAuthority;
|
|
662
|
+
/**
|
|
663
|
+
* Provide the Host half over the active HTTP server.
|
|
664
|
+
* @param ctx - owning Connection plugin context.
|
|
665
|
+
* @param trustedHosts - deployment authorities accepted by the Host/Origin fence.
|
|
666
|
+
* @param browserAuth - process token and persistent browser-session owner.
|
|
667
|
+
*/
|
|
668
|
+
constructor(ctx, trustedHosts, browserAuth) {
|
|
669
|
+
super(ctx, "connection");
|
|
670
|
+
this.trustedHosts = trustedHosts;
|
|
671
|
+
this.browserAuth = browserAuth;
|
|
672
|
+
this.operator = new OperatorPeer(ctx);
|
|
673
|
+
ctx.effect(() => () => this.operator.dispose(), "client-connection: operator Peer");
|
|
674
|
+
}
|
|
675
|
+
/** Generic channel registry scoped to the Context reading this service. */
|
|
676
|
+
get rpc() {
|
|
677
|
+
const owner = this.ctx;
|
|
678
|
+
return {
|
|
679
|
+
handle: (channel, handler) => this.register(owner, channel, handler),
|
|
680
|
+
intercept: (channel, matches, handler) => this.registerInterceptor(owner, channel, matches, handler)
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/** Exact Fetch-route registry scoped to the Context reading this service. */
|
|
684
|
+
get fetch() {
|
|
685
|
+
const owner = this.ctx;
|
|
686
|
+
return { register: (route) => this.registerFetchRoute(owner, route) };
|
|
687
|
+
}
|
|
688
|
+
/** Request path serving the application document. */
|
|
689
|
+
get entryPath() {
|
|
690
|
+
return WEB_ENTRY_PATH;
|
|
691
|
+
}
|
|
692
|
+
/** Account-session gate seat scoped to the Context reading this service. */
|
|
693
|
+
get session() {
|
|
694
|
+
const owner = this.ctx;
|
|
695
|
+
return { install: (authority) => {
|
|
696
|
+
this.installSessionAuthority(owner, authority);
|
|
697
|
+
} };
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Apply the configured Host/Origin fence, then whichever authentication owns
|
|
701
|
+
* this deployment: an installed account-session authority, or the device
|
|
702
|
+
* cookie minted by the launch-token exchange.
|
|
703
|
+
*/
|
|
704
|
+
requestRejection(request) {
|
|
705
|
+
const carrier = this.ctx.get("webServer");
|
|
706
|
+
if (!isTrustedApiRequest(request, this.trustedHosts, carrier?.host, carrier?.protocol ?? "http:")) return 403;
|
|
707
|
+
const authority = this.sessionAuthority;
|
|
708
|
+
if (authority === void 0) return this.browserAuth.isAuthenticated(request) ? void 0 : 401;
|
|
709
|
+
if (authority.isPublicApiRequest(request)) return void 0;
|
|
710
|
+
return authority.verify(request) ? void 0 : 401;
|
|
711
|
+
}
|
|
712
|
+
/** A request that passes the fence and authentication speaks for the operator. */
|
|
713
|
+
admit(request) {
|
|
714
|
+
const rejection = this.requestRejection(request);
|
|
715
|
+
return rejection === void 0 ? { peer: this.operator } : { rejection };
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Authenticate an index request: the launch-token exchange first, then the
|
|
719
|
+
* installed account-session authority, or the device cookie when no
|
|
720
|
+
* authority claims the seat.
|
|
721
|
+
*/
|
|
722
|
+
authorizeIndex(request, response) {
|
|
723
|
+
const authority = this.sessionAuthority;
|
|
724
|
+
if (authority === void 0) return this.browserAuth.authorizeIndex(request, response);
|
|
725
|
+
if (this.browserAuth.exchangeToken(request, response)) return false;
|
|
726
|
+
return authority.authorizeIndex(request, response);
|
|
727
|
+
}
|
|
728
|
+
/** Add this process's launch token, and an optional display name, to the clean application URL. */
|
|
729
|
+
authenticatedUrl(baseUrl, displayName) {
|
|
730
|
+
return this.browserAuth.authenticatedUrl(baseUrl, displayName);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Compose one shared-channel Fetch handler from exact routes and its interceptor.
|
|
734
|
+
* @param channel - shared channel mounted by Connection.
|
|
735
|
+
* @returns Fetch handler that selects one owner or returns 404.
|
|
736
|
+
*/
|
|
737
|
+
createSharedFetchHandler(channel) {
|
|
738
|
+
return {
|
|
739
|
+
requestBodyMode: ({ method, url }) => {
|
|
740
|
+
const route = this.fetchRoutes.get(url.pathname);
|
|
741
|
+
return route?.methods.has(method) === true ? route.requestBody : "buffered";
|
|
742
|
+
},
|
|
743
|
+
fetch: (request) => {
|
|
744
|
+
const pathname = new URL(request.url).pathname;
|
|
745
|
+
const route = this.fetchRoutes.get(pathname);
|
|
746
|
+
if (route?.methods.has(request.method) === true) return route.fetch(request);
|
|
747
|
+
if (request.method === "GET" && pathname === AUTH_STATUS_PATH) return Promise.resolve(this.deviceStatusAnswer(request));
|
|
748
|
+
const endpoint = endpointFromPath(channel, pathname);
|
|
749
|
+
const interceptor = this.interceptors.get(channel);
|
|
750
|
+
if (endpoint === void 0 || interceptor === void 0 || !interceptor.matches(endpoint)) return Promise.resolve(new Response("not found", { status: 404 }));
|
|
751
|
+
return interceptor.fetchHandler.fetch(request);
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Answer the status read from the device session alone. Exact routes are
|
|
757
|
+
* consulted first, so a mounted account gate keeps owning this path; this
|
|
758
|
+
* fallback exists for the forms that disable it and would otherwise leave
|
|
759
|
+
* the menu without any identity channel.
|
|
760
|
+
*/
|
|
761
|
+
deviceStatusAnswer(request) {
|
|
762
|
+
const identity = this.browserAuth.identity(request);
|
|
763
|
+
return Response.json({
|
|
764
|
+
enabled: false,
|
|
765
|
+
needsSetup: false,
|
|
766
|
+
registrationOpen: false,
|
|
767
|
+
authenticated: identity.authenticated,
|
|
768
|
+
user: null,
|
|
769
|
+
accountName: identity.accountName,
|
|
770
|
+
signOutAvailable: false
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
installSessionAuthority(owner, authority) {
|
|
774
|
+
owner.effect(() => {
|
|
775
|
+
if (this.sessionAuthority !== void 0) throw new Error("connection: an account-session authority is already installed");
|
|
776
|
+
this.sessionAuthority = authority;
|
|
777
|
+
return () => {
|
|
778
|
+
this.sessionAuthority = void 0;
|
|
779
|
+
};
|
|
780
|
+
}, "client-connection: account-session authority");
|
|
781
|
+
}
|
|
782
|
+
registerFetchRoute(owner, route) {
|
|
783
|
+
assertFetchRoute(route);
|
|
784
|
+
const registered = {
|
|
785
|
+
methods: new Set(route.methods),
|
|
786
|
+
requestBody: route.requestBody,
|
|
787
|
+
fetch: route.fetch
|
|
788
|
+
};
|
|
789
|
+
return owner.effect(() => {
|
|
790
|
+
if (this.fetchRoutes.has(route.path)) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`);
|
|
791
|
+
this.fetchRoutes.set(route.path, registered);
|
|
792
|
+
return () => {
|
|
793
|
+
this.fetchRoutes.delete(route.path);
|
|
794
|
+
};
|
|
795
|
+
}, `client-connection: ${route.path} Fetch route`);
|
|
796
|
+
}
|
|
797
|
+
register(owner, channel, handler) {
|
|
798
|
+
assertChannel(channel);
|
|
799
|
+
const fetchHandler = rpcFetchHandler(channel, handler, this.operator);
|
|
800
|
+
const route = {
|
|
801
|
+
kind: "prefix",
|
|
802
|
+
path: channel,
|
|
803
|
+
handler: async (req, res) => {
|
|
804
|
+
const admission = this.admit(req);
|
|
805
|
+
if ("rejection" in admission) {
|
|
806
|
+
res.writeHead(admission.rejection);
|
|
807
|
+
res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
await bridge(req, res, fetchHandler);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
return owner.effect(() => owner.webServer.register(route), `client-connection: ${channel} rpc channel`);
|
|
814
|
+
}
|
|
815
|
+
registerInterceptor(owner, channel, matches, handler) {
|
|
816
|
+
if (channel !== "/api") throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`);
|
|
817
|
+
const interceptor = {
|
|
818
|
+
matches,
|
|
819
|
+
fetchHandler: rpcFetchHandler(channel, handler, this.operator)
|
|
820
|
+
};
|
|
821
|
+
return owner.effect(() => {
|
|
822
|
+
if (this.interceptors.has(channel)) throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`);
|
|
823
|
+
this.interceptors.set(channel, interceptor);
|
|
824
|
+
return () => {
|
|
825
|
+
this.interceptors.delete(channel);
|
|
826
|
+
};
|
|
827
|
+
}, `client-connection: ${channel} rpc interceptor`);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
function rpcFetchHandler(channel, handler, peer) {
|
|
831
|
+
return {
|
|
832
|
+
requestBodyMode: () => "buffered",
|
|
833
|
+
async fetch(request) {
|
|
834
|
+
const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
|
|
835
|
+
if (request.method !== "POST" || endpoint === void 0) return new Response("not found", { status: 404 });
|
|
836
|
+
if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("content type must be application/json", { status: 415 });
|
|
837
|
+
let body;
|
|
838
|
+
try {
|
|
839
|
+
body = await request.json();
|
|
840
|
+
} catch {
|
|
841
|
+
return new Response("body is not JSON", { status: 400 });
|
|
842
|
+
}
|
|
843
|
+
const envelope = clientRequestSchema.safeParse(body);
|
|
844
|
+
if (!envelope.success) return invalidEnvelopeResponse(body, envelope.error.issues);
|
|
845
|
+
const message = envelope.data;
|
|
846
|
+
if (message.method !== endpoint) return errorResponse(message.rpcId, {
|
|
847
|
+
code: "gateway/bad-request",
|
|
848
|
+
message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
|
849
|
+
details: { issues: [] }
|
|
850
|
+
});
|
|
851
|
+
try {
|
|
852
|
+
const result = await handler(endpoint, message.payload, request.signal, peer);
|
|
853
|
+
return fullResponse(message.rpcId, result);
|
|
854
|
+
} catch (error) {
|
|
855
|
+
return new Response(`handler failure: ${String(error)}`, { status: 500 });
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function invalidEnvelopeResponse(body, issues) {
|
|
861
|
+
const rawId = body?.rpcId;
|
|
862
|
+
return errorResponse(typeof rawId === "string" ? RpcId(rawId) : INVALID_REQUEST_RPC_ID, {
|
|
863
|
+
code: "gateway/bad-request",
|
|
864
|
+
message: "invalid client-request message",
|
|
865
|
+
details: { issues }
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
function endpointFromPath(channel, pathname) {
|
|
869
|
+
if (!pathname.startsWith(`${channel}/`)) return void 0;
|
|
870
|
+
const endpoint = pathname.slice(channel.length + 1);
|
|
871
|
+
if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) return;
|
|
872
|
+
return endpoint;
|
|
873
|
+
}
|
|
874
|
+
function errorResponse(rpcId, error) {
|
|
875
|
+
return fullResponse(rpcId, {
|
|
876
|
+
ok: false,
|
|
877
|
+
error
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
function fullResponse(rpcId, result) {
|
|
881
|
+
const body = {
|
|
882
|
+
type: "server-response",
|
|
883
|
+
rpcId,
|
|
884
|
+
result
|
|
885
|
+
};
|
|
886
|
+
return Response.json(body);
|
|
887
|
+
}
|
|
888
|
+
function assertChannel(channel) {
|
|
889
|
+
if (!CHANNEL_PATTERN.test(channel) || channel === "/api") throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`);
|
|
890
|
+
}
|
|
891
|
+
function assertFetchRoute(route) {
|
|
892
|
+
if (endpointFromPath("/api", route.path) === void 0) throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`);
|
|
893
|
+
if (route.methods.length === 0) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`);
|
|
894
|
+
if (new Set(route.methods).size !== route.methods.length) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`);
|
|
895
|
+
}
|
|
896
|
+
//#endregion
|
|
897
|
+
//#region lib/types/recovery-config.js
|
|
898
|
+
/** Shared validation for Host-configured and browser-local connection recovery. */
|
|
899
|
+
const MAX_TIMER_MS = 2147483647;
|
|
900
|
+
/** Schema shared by the Host plugin and the Client's recovery input parser. */
|
|
901
|
+
const ConnectionRecoveryConfigSchema = z.object({
|
|
902
|
+
backoffBaseMs: z.natural().min(1).max(MAX_TIMER_MS).default(500),
|
|
903
|
+
backoffFactor: z.number().min(1).max(Number.MAX_VALUE).default(2),
|
|
904
|
+
backoffMaxMs: z.natural().min(1).max(MAX_TIMER_MS).default(1e4),
|
|
905
|
+
generationReadyWarnMs: z.natural().min(1).max(MAX_TIMER_MS).default(3e3),
|
|
906
|
+
generationReadyTimeoutMs: z.natural().min(1).max(MAX_TIMER_MS).default(15e3)
|
|
907
|
+
});
|
|
908
|
+
/**
|
|
909
|
+
* Validate recovery input and supply every timing default before starting work.
|
|
910
|
+
* @param config - Host configuration, page bootstrap data, or direct loop options.
|
|
911
|
+
* @returns validated, complete recovery timing.
|
|
912
|
+
*/
|
|
913
|
+
function resolveConnectionConfig(config = {}) {
|
|
914
|
+
const resolved = ConnectionRecoveryConfigSchema(config);
|
|
915
|
+
if (!Number.isFinite(resolved.backoffFactor)) throw new RangeError("connection recovery backoffFactor must be finite");
|
|
916
|
+
return resolved;
|
|
917
|
+
}
|
|
918
|
+
//#endregion
|
|
919
|
+
//#region lib/types/index.js
|
|
920
|
+
/** Stable Cordis plugin name. */
|
|
921
|
+
const name = "client-connection";
|
|
922
|
+
/** Headroom for RPC JSON fields around aggregate base64 image payloads. */
|
|
923
|
+
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024;
|
|
924
|
+
function assertImageBodyCapacity(ctx, maxRequestBodyBytes) {
|
|
925
|
+
const attachments = ctx.get("attachments");
|
|
926
|
+
if (attachments === void 0) return;
|
|
927
|
+
const requiredImageBodyBytes = Math.ceil(attachments.imageLimits.maxMessageImageBytes * 4 / 3) + REQUEST_ENVELOPE_HEADROOM_BYTES;
|
|
928
|
+
if (maxRequestBodyBytes < requiredImageBodyBytes) throw new Error(`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least ${String(requiredImageBodyBytes)} for the configured aggregate image limit`);
|
|
929
|
+
}
|
|
930
|
+
/** Services required before providing Connection. */
|
|
931
|
+
const inject = ["credentials"];
|
|
932
|
+
const Config = z.object({
|
|
933
|
+
recovery: ConnectionRecoveryConfigSchema.default({}),
|
|
934
|
+
trustedHosts: z.array(String).default([]),
|
|
935
|
+
cookieMaxAgeDays: z.natural().min(1).default(30),
|
|
936
|
+
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES)
|
|
937
|
+
});
|
|
938
|
+
/**
|
|
939
|
+
* Provides carrier-neutral RPC and Fetch registries. When `webServer` is
|
|
940
|
+
* present, the plugin also mounts the `/api` browser transport with Host/Origin
|
|
941
|
+
* checks and persistent browser authentication.
|
|
942
|
+
* @param ctx - Host plugin context.
|
|
943
|
+
* @param config - resolved plugin config (schema defaults applied).
|
|
944
|
+
*/
|
|
945
|
+
async function apply(ctx, config) {
|
|
946
|
+
const recovery = resolveConnectionConfig(config?.recovery);
|
|
947
|
+
const trustedHosts = config?.trustedHosts ?? [];
|
|
948
|
+
const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30;
|
|
949
|
+
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? 314572800;
|
|
950
|
+
for (const entry of trustedHosts) assertTrustedAuthority(entry);
|
|
951
|
+
assertImageBodyCapacity(ctx, maxRequestBodyBytes);
|
|
952
|
+
const connection = new HostConnectionService(ctx, trustedHosts, await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays));
|
|
953
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
954
|
+
assertImageBodyCapacity(webCtx, maxRequestBodyBytes);
|
|
955
|
+
webCtx.on("webserver/index-inject", (table) => {
|
|
956
|
+
table.push({
|
|
957
|
+
kind: "global",
|
|
958
|
+
name: "__QILIN_CONNECTION_RECOVERY__",
|
|
959
|
+
value: recovery
|
|
960
|
+
});
|
|
961
|
+
});
|
|
962
|
+
const fetchHandler = connection.createSharedFetchHandler(API_PATH);
|
|
963
|
+
const route = {
|
|
964
|
+
kind: "prefix",
|
|
965
|
+
path: API_PATH,
|
|
966
|
+
handler: async (req, res) => {
|
|
967
|
+
const admission = connection.admit(req);
|
|
968
|
+
if ("rejection" in admission) {
|
|
969
|
+
res.writeHead(admission.rejection);
|
|
970
|
+
res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
await webCtx.waterfall("connection/request", req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes));
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
webCtx.effect(() => webCtx.webServer.register(route), "client-connection: /api route");
|
|
977
|
+
});
|
|
978
|
+
ctx.inject(["attachments"], (attachmentCtx) => {
|
|
979
|
+
assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes);
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
//#endregion
|
|
983
|
+
export { API_PATH, Config, HostConnectionService, OperatorPeer, RpcId, WEB_ENTRY_PATH, apply, clientRequestSchema, inject, name, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, transportError };
|