@qilin-agent/cli 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.
@@ -0,0 +1,1500 @@
1
+ import { a as isTenantIdentity, i as TenantRuntime, n as LOOPBACK_HOSTS, r as TENANT_VARIABLE, t as BOOTSTRAP_DIRECTORY } from "./tenant-runtime-ChD53FKq.js";
2
+ import { resolveQilinHome } from "@qilin-agent/home-paths";
3
+ import { randomBytes, timingSafeEqual } from "node:crypto";
4
+ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
5
+ import { join, resolve } from "node:path";
6
+ import { readFileSync } from "node:fs";
7
+ import { spawn } from "node:child_process";
8
+ import { Readable } from "node:stream";
9
+ import { AccountStore, DEPLOYMENT_AUTHORITY_HEADER, accountsFilePath } from "@qilin-agent/accounts-local";
10
+ import { createServer, request } from "node:http";
11
+ import { createServer as createServer$1 } from "node:https";
12
+ import { connect } from "node:net";
13
+ //#region lib/types/tenant-proxy.js
14
+ /**
15
+ * The hub's transport hop into one tenant runtime: an HTTP request forwarded
16
+ * with that runtime's own authority, and an upgraded socket piped the same way.
17
+ *
18
+ * The hop changes transport facts only. It rewrites `Host` to the tenant's
19
+ * authority — the tenant's session cookie is named after the authority it was
20
+ * issued for, so one browser can hold one cookie per tenant on one origin —
21
+ * and rewrites `Origin` to that same authority after checking the browser's
22
+ * own Origin names the hub, which keeps the tenant's same-origin fence as
23
+ * strong as it is without a proxy. Everything else, including `Range`,
24
+ * `Set-Cookie`, `Location`, and partial-content answers, crosses unchanged.
25
+ *
26
+ * The hop decides no access: the tenant runtime's own account gate answers
27
+ * every request, so a forged routing cookie reaches a runtime that does not
28
+ * recognize the caller.
29
+ * @module @qilin-agent/cli/tenant-proxy
30
+ */
31
+ /**
32
+ * Headers that name one connection rather than one message: forwarded, they
33
+ * would promise the browser a connection the hub does not have.
34
+ */
35
+ const HOP_BY_HOP = new Set([
36
+ "connection",
37
+ "keep-alive",
38
+ "proxy-authenticate",
39
+ "proxy-authorization",
40
+ "te",
41
+ "trailer",
42
+ "transfer-encoding",
43
+ "upgrade"
44
+ ]);
45
+ /**
46
+ * Whether a browser-labeled request may be forwarded at all.
47
+ *
48
+ * Over plain HTTP a read carries neither Origin nor Fetch-Metadata, so an
49
+ * absent Origin is accepted and the tenant's own Host fence still binds the
50
+ * request. A present Origin must name the hub exactly, and an explicit
51
+ * cross-site marker is refused: the hub is the outermost hop, so this is the
52
+ * only place those markers can be read before the tenant's authority replaces
53
+ * them.
54
+ * @param headers - browser headers, verbatim.
55
+ * @param hubAuthority - authority the browser used to reach the hub.
56
+ * @returns true when the request may be forwarded.
57
+ */
58
+ function hubAcceptsOrigin(headers, hubAuthority) {
59
+ if (headers["sec-fetch-site"] === "cross-site") return false;
60
+ const origin = headers.origin;
61
+ if (origin === void 0) return true;
62
+ try {
63
+ return new URL(origin).host === hubAuthority;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+ /**
69
+ * Headers to forward upstream: the browser's own, minus connection-scoped
70
+ * entries, with the authority-bearing ones rewritten to the tenant.
71
+ * @param headers - browser headers, verbatim.
72
+ * @param tenantAuthority - the runtime's own `host:port`.
73
+ * @param deploymentAuthority - authority the browser used to reach the hub, and
74
+ * therefore the one the runtime names its sessions for; empty when the request
75
+ * reached a runtime directly, which then keeps its own authority as the name.
76
+ * Required rather than optional because every hop of this deployment knows it,
77
+ * and an upgrade that forgot it authenticates against a cookie no browser holds.
78
+ * @returns the forwarded header set.
79
+ */
80
+ function forwardedHeaders(headers, tenantAuthority, deploymentAuthority) {
81
+ const forwarded = {};
82
+ for (const [name, value] of Object.entries(headers)) {
83
+ const lower = name.toLowerCase();
84
+ if (HOP_BY_HOP.has(lower) || lower === "host" || lower === "origin" || lower === DEPLOYMENT_AUTHORITY_HEADER) continue;
85
+ forwarded[name] = value;
86
+ }
87
+ forwarded.host = tenantAuthority;
88
+ if (headers.origin !== void 0) forwarded.origin = `http://${tenantAuthority}`;
89
+ if (deploymentAuthority !== "") forwarded[DEPLOYMENT_AUTHORITY_HEADER] = deploymentAuthority;
90
+ return forwarded;
91
+ }
92
+ /**
93
+ * Send one request to a tenant runtime and hand back its streaming answer.
94
+ *
95
+ * The hub's own origin fence runs here, in the operation that would forward the
96
+ * request, so no caller can forward a browser request whose Origin names
97
+ * another site.
98
+ * @param options - the runtime, the path, headers, and body to forward.
99
+ * @returns the runtime's status, headers, and body, or the refusal.
100
+ * @throws Error when the runtime is unreachable or refuses the connection.
101
+ */
102
+ async function proxyRequest(options) {
103
+ const origin = new URL(options.origin);
104
+ if (!hubAcceptsOrigin(options.headers, options.hubAuthority)) return {
105
+ status: 403,
106
+ headers: { "content-type": "text/plain; charset=utf-8" },
107
+ stream: Readable.from("qilin tenants: that origin is not this hub\n")
108
+ };
109
+ const answered = Promise.withResolvers();
110
+ const upstream = request({
111
+ protocol: origin.protocol,
112
+ hostname: origin.hostname,
113
+ port: origin.port,
114
+ method: options.method,
115
+ path: options.path,
116
+ headers: forwardedHeaders(options.headers, origin.host, options.hubAuthority)
117
+ });
118
+ upstream.on("response", (response) => {
119
+ answered.resolve({
120
+ status: response.statusCode ?? 502,
121
+ headers: response.headers,
122
+ stream: response
123
+ });
124
+ });
125
+ upstream.on("error", (error) => {
126
+ answered.reject(error);
127
+ });
128
+ options.body?.pipe(upstream);
129
+ if (options.body === void 0) upstream.end();
130
+ return await answered.promise;
131
+ }
132
+ /**
133
+ * Write one upstream answer to the browser, streaming the body.
134
+ * @param answer - the runtime's status, headers, and body.
135
+ * @param response - the browser's response.
136
+ */
137
+ function relayAnswer(answer, response) {
138
+ const headers = {};
139
+ for (const [name, value] of Object.entries(answer.headers)) {
140
+ if (HOP_BY_HOP.has(name.toLowerCase())) continue;
141
+ headers[name] = value;
142
+ }
143
+ response.writeHead(answer.status, headers);
144
+ answer.stream.pipe(response);
145
+ }
146
+ /**
147
+ * Pipe one upgraded browser socket to the same path on a tenant runtime, with
148
+ * the tenant's authority in place of the hub's.
149
+ *
150
+ * The upgrade is piped, never terminated: the hub holds no copy of the frames,
151
+ * so the tenant's own mux and its trust fence stay in charge of the stream.
152
+ * @param request - the browser's upgrade request.
153
+ * @param socket - the browser's socket.
154
+ * @param head - bytes already read from the browser.
155
+ * @param target - origin of the runtime the socket belongs to.
156
+ * @param headers - already-forwarded headers, with the tenant's authority set.
157
+ */
158
+ function relayUpgrade(request, socket, head, target, headers) {
159
+ const origin = new URL(target);
160
+ const upstream = connect({
161
+ host: origin.hostname,
162
+ port: Number(origin.port)
163
+ });
164
+ const lines = [`${request.method ?? "GET"} ${request.url ?? "/"} HTTP/1.1`];
165
+ lines.push("connection: Upgrade");
166
+ const upgrade = request.headers.upgrade;
167
+ lines.push(`upgrade: ${typeof upgrade === "string" && upgrade !== "" ? upgrade : "websocket"}`);
168
+ for (const [name, value] of Object.entries(headers)) {
169
+ if (value === void 0) continue;
170
+ for (const entry of Array.isArray(value) ? value : [value]) lines.push(`${name}: ${entry}`);
171
+ }
172
+ upstream.on("connect", () => {
173
+ upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
174
+ if (head.length > 0) upstream.write(head);
175
+ upstream.pipe(socket);
176
+ socket.pipe(upstream);
177
+ });
178
+ const closeBoth = () => {
179
+ upstream.destroy();
180
+ socket.destroy();
181
+ };
182
+ upstream.on("error", closeBoth);
183
+ socket.on("error", closeBoth);
184
+ socket.on("close", () => {
185
+ upstream.destroy();
186
+ });
187
+ }
188
+ function usageDirectory(home) {
189
+ return join(home, "tenants", ".usage");
190
+ }
191
+ /** Absolute path of one account's ledger file. */
192
+ function usageFilePath(home, accountId) {
193
+ return join(usageDirectory(home), `${accountId}.jsonl`);
194
+ }
195
+ /**
196
+ * Append one report to an account's ledger, creating the directory on first use.
197
+ * @param home - deployment home.
198
+ * @param accountId - the account the reporting runtime serves.
199
+ * @param report - the values it reported.
200
+ */
201
+ async function appendUsage(home, accountId, report) {
202
+ await mkdir(usageDirectory(home), {
203
+ recursive: true,
204
+ mode: 448
205
+ });
206
+ await appendFile(usageFilePath(home, accountId), `${JSON.stringify(report)}\n`, {
207
+ encoding: "utf8",
208
+ mode: 384
209
+ });
210
+ }
211
+ /** Whether one report's time falls inside a window. */
212
+ function insideWindow(report, window) {
213
+ if (window?.from !== void 0 && report.at < window.from) return false;
214
+ if (window?.to !== void 0 && report.at > window.to) return false;
215
+ return true;
216
+ }
217
+ /**
218
+ * Read one account's records, latest line per Turn, oldest first.
219
+ *
220
+ * A Turn reported twice keeps the later line and an out-of-order earlier line
221
+ * never replaces it; a line that is not a report is skipped rather than failing
222
+ * the read, because runtimes append to this file while the hub reads it.
223
+ * @param home - deployment home.
224
+ * @param accountId - the account whose ledger to read.
225
+ * @param window - report times to include.
226
+ * @returns the records inside the window, sorted by report time.
227
+ */
228
+ async function readUsageRecords(home, accountId, window) {
229
+ let source;
230
+ try {
231
+ source = await readFile(usageFilePath(home, accountId), "utf8");
232
+ } catch (error) {
233
+ if (error.code === "ENOENT") return [];
234
+ throw error;
235
+ }
236
+ const perTurn = /* @__PURE__ */ new Map();
237
+ for (const line of source.split("\n")) {
238
+ const report = parseUsageReport(line);
239
+ if (report === void 0 || !insideWindow(report, window)) continue;
240
+ const key = `${report.sessionId}\n${String(report.turn)}`;
241
+ const previous = perTurn.get(key);
242
+ if (previous === void 0 || previous.at <= report.at) perTurn.set(key, report);
243
+ }
244
+ return [...perTurn.values()].sort((left, right) => left.at - right.at);
245
+ }
246
+ /**
247
+ * Total the records of one account's ledger inside a window.
248
+ * @param home - deployment home.
249
+ * @param accountId - the account whose ledger to total.
250
+ * @param window - report times to include.
251
+ * @returns the total, zeroes when nothing was reported in the window.
252
+ */
253
+ async function readUsageTotal(home, accountId, window) {
254
+ return totalUsage(await readUsageRecords(home, accountId, window));
255
+ }
256
+ /**
257
+ * The report-time window one read asks for.
258
+ * @param params - query parameters of the read.
259
+ * @returns the window, or undefined when a bound is not a whole non-negative
260
+ * number of epoch milliseconds or the bounds are inverted.
261
+ */
262
+ function readUsageWindow(params) {
263
+ const bound = (name) => {
264
+ const raw = params.get(name);
265
+ if (raw === null || raw === "") return void 0;
266
+ const value = Number(raw);
267
+ return Number.isSafeInteger(value) && value >= 0 ? value : "invalid";
268
+ };
269
+ const from = bound("from");
270
+ const to = bound("to");
271
+ if (from === "invalid" || to === "invalid") return void 0;
272
+ if (from !== void 0 && to !== void 0 && to < from) return void 0;
273
+ return {
274
+ ...from === void 0 ? {} : { from },
275
+ ...to === void 0 ? {} : { to }
276
+ };
277
+ }
278
+ /**
279
+ * The newest records one answer carries, and whether older ones were left out.
280
+ *
281
+ * Records are sorted by report time, so a chart drawn from the newest slice is
282
+ * missing its oldest days rather than scattered holes; the flag says so.
283
+ * @param records - records in report-time order.
284
+ * @param cap - most records one answer may carry.
285
+ * @returns the records to send and whether the cap dropped any.
286
+ */
287
+ function capUsageRecords(records, cap) {
288
+ if (cap <= 0) return {
289
+ records: [],
290
+ truncated: records.length > 0
291
+ };
292
+ return records.length <= cap ? {
293
+ records: [...records],
294
+ truncated: false
295
+ } : {
296
+ records: records.slice(records.length - cap),
297
+ truncated: true
298
+ };
299
+ }
300
+ /**
301
+ * Sum one account's records into a total.
302
+ * @param records - records already deduplicated per Turn.
303
+ * @returns the total they add up to.
304
+ */
305
+ function totalUsage(records) {
306
+ const sessions = /* @__PURE__ */ new Set();
307
+ let inputTokens = 0;
308
+ let outputTokens = 0;
309
+ let cacheReadTokens = 0;
310
+ let cacheWriteTokens = 0;
311
+ let at = 0;
312
+ for (const record of records) {
313
+ sessions.add(record.sessionId);
314
+ inputTokens += record.inputTokens;
315
+ outputTokens += record.outputTokens;
316
+ cacheReadTokens += record.cacheReadTokens ?? 0;
317
+ cacheWriteTokens += record.cacheWriteTokens ?? 0;
318
+ at = Math.max(at, record.at);
319
+ }
320
+ const prompt = inputTokens + cacheReadTokens;
321
+ return {
322
+ sessions: sessions.size,
323
+ turns: records.length,
324
+ inputTokens,
325
+ outputTokens,
326
+ cacheReadTokens,
327
+ cacheWriteTokens,
328
+ cacheHitRate: prompt === 0 ? 0 : cacheReadTokens / prompt,
329
+ at
330
+ };
331
+ }
332
+ /**
333
+ * Total every account's ledger inside a window.
334
+ * @param home - deployment home.
335
+ * @param window - report times to include.
336
+ * @returns one total per account that reported in the window.
337
+ */
338
+ async function readUsageTotals(home, window) {
339
+ const directory = usageDirectory(home);
340
+ let entries;
341
+ try {
342
+ entries = await readdir(directory);
343
+ } catch (error) {
344
+ if (error.code === "ENOENT") return {};
345
+ throw error;
346
+ }
347
+ const totals = {};
348
+ for (const entry of entries) {
349
+ if (!entry.endsWith(".jsonl")) continue;
350
+ totals[entry.slice(0, -6)] = await readUsageTotal(home, entry.slice(0, -6), window);
351
+ }
352
+ return totals;
353
+ }
354
+ /**
355
+ * Sum every account's total into the deployment's own.
356
+ *
357
+ * The session and turn counts add up rather than deduplicating, because one
358
+ * session belongs to one account: two accounts reporting a session each
359
+ * reported two.
360
+ * @param totals - totals keyed by account identity.
361
+ * @returns the summed total, zeroes when no account has reported.
362
+ */
363
+ function sumUsageTotals(totals) {
364
+ let sessions = 0;
365
+ let turns = 0;
366
+ let inputTokens = 0;
367
+ let outputTokens = 0;
368
+ let cacheReadTokens = 0;
369
+ let cacheWriteTokens = 0;
370
+ let at = 0;
371
+ for (const total of Object.values(totals)) {
372
+ sessions += total.sessions;
373
+ turns += total.turns;
374
+ inputTokens += total.inputTokens;
375
+ outputTokens += total.outputTokens;
376
+ cacheReadTokens += total.cacheReadTokens;
377
+ cacheWriteTokens += total.cacheWriteTokens;
378
+ at = Math.max(at, total.at);
379
+ }
380
+ const prompt = inputTokens + cacheReadTokens;
381
+ return {
382
+ sessions,
383
+ turns,
384
+ inputTokens,
385
+ outputTokens,
386
+ cacheReadTokens,
387
+ cacheWriteTokens,
388
+ cacheHitRate: prompt === 0 ? 0 : cacheReadTokens / prompt,
389
+ at
390
+ };
391
+ }
392
+ /**
393
+ * Read one ledger line as a report.
394
+ * @param line - one line of an account's ledger.
395
+ * @returns the report, or undefined when the line is empty, torn, or not a report.
396
+ */
397
+ function parseUsageReport(line) {
398
+ if (line === "") return void 0;
399
+ let parsed;
400
+ try {
401
+ parsed = JSON.parse(line);
402
+ } catch (error) {
403
+ if (error instanceof SyntaxError) return void 0;
404
+ throw error;
405
+ }
406
+ return isUsageReport(parsed) ? parsed : void 0;
407
+ }
408
+ /**
409
+ * Whether a parsed value carries the report fields with usable values.
410
+ * @param value - the parsed line.
411
+ * @returns true when it is one report.
412
+ */
413
+ function isUsageReport(value) {
414
+ if (typeof value !== "object" || value === null) return false;
415
+ const report = value;
416
+ if (typeof report["sessionId"] !== "string" || report["sessionId"] === "") return false;
417
+ if (!isCount(report["inputTokens"]) || !isCount(report["outputTokens"])) return false;
418
+ if (!isCount(report["turn"])) return false;
419
+ if (report["cacheReadTokens"] !== void 0 && !isCount(report["cacheReadTokens"])) return false;
420
+ if (report["cacheWriteTokens"] !== void 0 && !isCount(report["cacheWriteTokens"])) return false;
421
+ return typeof report["at"] === "number" && Number.isSafeInteger(report["at"]) && report["at"] >= 0;
422
+ }
423
+ /** Whether a value is a whole, non-negative count. */
424
+ function isCount(value) {
425
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
426
+ }
427
+ //#endregion
428
+ //#region lib/types/tenants.js
429
+ /**
430
+ * `qilin tenants serve`: one hub over a shared Harness home, one isolated Web
431
+ * runtime per account. Sessions, tasks, and workspaces stay with the account
432
+ * that owns them; plugins, model settings, credentials, and the account file
433
+ * stay shared, so every tenant runtime is that one deployment.
434
+ *
435
+ * The hub authenticates nobody: a handoff is a redirect, not a credential.
436
+ * Each tenant runtime keeps the shared home's account gate and admits only its
437
+ * own account, so the sign-in happens at the runtime and the runtime is the
438
+ * boundary. The hub must be reachable where its account list is not a secret —
439
+ * it binds loopback by default and lists usernames.
440
+ *
441
+ * Idle reclamation counts from the last handoff. A browser that reached a tenant
442
+ * origin keeps talking to that origin directly, which the hub cannot observe, so
443
+ * a deployment that leaves long-lived tabs open raises `--idle-ms` or disables
444
+ * reclamation with 0; `--single-origin` moves that same traffic through the hub
445
+ * instead, and reclamation then counts proxied requests.
446
+ *
447
+ * With `--single-origin` the hub serves one origin and routes by a routing
448
+ * cookie it sets at the handoff: `/api`, the mux upgrade, and media requests are
449
+ * absolute paths on the page origin, so only cookie routing can attribute them
450
+ * to a tenant. The cookie selects a runtime, it does not authorize: the runtime
451
+ * still answers with its own account gate, so a forged cookie reaches a
452
+ * runtime that does not recognize the caller.
453
+ *
454
+ * `--max-tenants` bounds how many runtimes live at once: a handoff that would
455
+ * exceed the bound reclaims the least recently used one, and every tenant's
456
+ * Sessions stay on disk. A runtime that fails to start is not retried on every
457
+ * visit: the hub doubles a wait per consecutive failure, and `/status` reports
458
+ * the count and the earliest retry.
459
+ * @module @qilin-agent/cli/tenants
460
+ */
461
+ /** Routing cookie the hub sets so later requests, whatever their path, name their tenant. */
462
+ const TENANT_COOKIE = "qilin_tenant";
463
+ /** Largest usage report the ledger accepts; one report is a few dozen bytes. */
464
+ const MAX_USAGE_BODY_BYTES = 4 * 1024;
465
+ /**
466
+ * Most ledger records one read answers with. A bound on the response rather than
467
+ * a deployment choice: a long-lived account reports one record per Turn, so the
468
+ * newest slice is what a chart shows and `truncated` says the rest was left out.
469
+ */
470
+ const USAGE_RECORDS_CAP = 5e3;
471
+ /**
472
+ * Whether every runtime of this deployment must mark its session cookies
473
+ * `Secure`.
474
+ *
475
+ * The browser reaches the deployment through the hub, so a deployment that
476
+ * terminates TLS itself is the one case where the client's own connection is
477
+ * encrypted; a runtime behind a plain-HTTP hub cannot know otherwise, and it
478
+ * never guesses.
479
+ * @param options - the deployment's own TLS material, when it terminates TLS.
480
+ * @returns true only when this deployment terminates TLS.
481
+ */
482
+ function secureSessionCookies(options) {
483
+ return options.tls !== void 0;
484
+ }
485
+ /** The text one proxied answer carries. */
486
+ async function answerText(stream) {
487
+ const chunks = [];
488
+ for await (const chunk of stream) chunks.push(chunk);
489
+ return Buffer.concat(chunks).toString("utf8");
490
+ }
491
+ /** Store key of the runtime a deployment with no accounts uses. */
492
+ const BOOTSTRAP_ACCOUNT = "first-run";
493
+ /**
494
+ * The routing cookie one handoff sets.
495
+ *
496
+ * It selects a runtime; it authorizes nothing. The runtime still answers with
497
+ * its own account gate, so the value is an account identity and not a secret,
498
+ * and `HttpOnly` keeps it out of page scripts that have no use for it.
499
+ * @param accountId - account identity the browser should reach from now on.
500
+ * @returns one `Set-Cookie` value.
501
+ */
502
+ function routingCookie(accountId) {
503
+ return `${TENANT_COOKIE}=${accountId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`;
504
+ }
505
+ /** The routing cookie's removal, for a browser whose tenant no longer exists. */
506
+ function clearedRoutingCookie() {
507
+ return `${TENANT_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
508
+ }
509
+ /** Documents the deployment itself answers, whatever account a browser chose. */
510
+ const DEPLOYMENT_DOORS = new Set(["/login", "/setup"]);
511
+ /**
512
+ * Whether a path belongs to the deployment's own doors: the sign-in and setup
513
+ * documents, and the account surface they call.
514
+ *
515
+ * These are where a browser picks, or creates, the account it works as, so they
516
+ * are answered by the runtime that carries the deployment's registration policy
517
+ * rather than by whichever account's runtime this browser happens to hold.
518
+ * @param pathname - the request path.
519
+ * @returns true for the sign-in surface.
520
+ */
521
+ function deploymentDoor(pathname) {
522
+ return DEPLOYMENT_DOORS.has(pathname) || pathname.startsWith("/api/auth/");
523
+ }
524
+ /**
525
+ * One cookie value from a request, decoded no further than substitution.
526
+ * @param header - the browser's `Cookie` header.
527
+ * @param name - cookie name to read.
528
+ * @returns the value, or undefined when the browser did not send it.
529
+ */
530
+ function cookieValue(header, name) {
531
+ if (header === void 0) return void 0;
532
+ for (const part of header.split(";")) {
533
+ const separator = part.indexOf("=");
534
+ if (separator === -1) continue;
535
+ if (part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim();
536
+ }
537
+ }
538
+ /**
539
+ * One path segment read as an account name.
540
+ * @param segment - the raw path segment.
541
+ * @returns the decoded segment, or undefined when it is not valid percent-encoding.
542
+ */
543
+ function decodeIdentifier(segment) {
544
+ try {
545
+ return decodeURIComponent(segment);
546
+ } catch {
547
+ return;
548
+ }
549
+ }
550
+ /**
551
+ * One PEM file the deployment address serves with.
552
+ * @param path - the file the invocation named.
553
+ * @param flag - the flag that named it, for the refusal.
554
+ * @returns the file's bytes.
555
+ * @throws Error when the file cannot be read.
556
+ */
557
+ function readPem(path, flag) {
558
+ try {
559
+ return readFileSync(path);
560
+ } catch (error) {
561
+ throw new Error(`qilin tenants: ${flag} ${path} is not readable: ${String(error)}`);
562
+ }
563
+ }
564
+ /** One answered request. */
565
+ function respond(response, status, contentType, body) {
566
+ response.writeHead(status, {
567
+ "cache-control": "no-store",
568
+ "content-type": contentType,
569
+ "content-length": Buffer.byteLength(body)
570
+ });
571
+ response.end(body);
572
+ }
573
+ /** One JSON answer. */
574
+ function json(response, status, body) {
575
+ respond(response, status, "application/json; charset=utf-8", `${JSON.stringify(body)}\n`);
576
+ }
577
+ /**
578
+ * Serve the hub until it is asked to stop.
579
+ * @param options - the interface, port, tenant profile, and idle window.
580
+ * @returns the process exit code, 0 after a clean shutdown.
581
+ */
582
+ /**
583
+ * What the deployment's own address and its tenant interface mean for reaching
584
+ * them, as the lines a start prints.
585
+ *
586
+ * A hub on loopback is reachable from this machine only, and account runtimes
587
+ * bind loopback unless `--tenant-host` names another interface. Naming a
588
+ * reachable one exposes every runtime there directly — plain HTTP, no entry in
589
+ * between — which an operator has to know before treating the hub address as the
590
+ * only door.
591
+ * @param host - interface the entry bound.
592
+ * @param tenantHost - interface each account runtime binds, when the invocation named one.
593
+ * @param port - port the entry bound.
594
+ * @returns one line per fact the operator needs, in print order.
595
+ */
596
+ function addressNotices(host, tenantHost, port) {
597
+ const runtimes = tenantHost === void 0 || LOOPBACK_HOSTS.includes(tenantHost) ? "every account runtime stays on loopback" : `each account runtime is reachable at ${tenantHost} over plain HTTP, with no entry in between`;
598
+ if (!LOOPBACK_HOSTS.includes(host)) return [`qilin tenants: this address is reachable at ${host}:${String(port)}; ${runtimes}`];
599
+ return tenantHost === void 0 || LOOPBACK_HOSTS.includes(tenantHost) ? [] : [`qilin tenants: ${runtimes}`];
600
+ }
601
+ /**
602
+ * The runtime a handoff stops to make room, or undefined when there is room or
603
+ * nothing may be reclaimed yet.
604
+ *
605
+ * Only a running runtime is a candidate: one that is still starting has no last
606
+ * use to compare, so the next handoff reclaims it once it is serving. The
607
+ * runtime about to start is never its own candidate.
608
+ * @param live - every runtime holding a child, in any state.
609
+ * @param target - the runtime about to start.
610
+ * @param maxTenants - cap on live runtimes; zero or less means no cap.
611
+ * @returns the runtime to stop, if any.
612
+ */
613
+ function reclaimFor(live, target, maxTenants) {
614
+ if (maxTenants <= 0) return void 0;
615
+ const others = live.filter((runtime) => runtime !== target);
616
+ if (others.length < maxTenants) return void 0;
617
+ let oldest;
618
+ for (const runtime of others) {
619
+ if (runtime.status().state !== "running") continue;
620
+ if (oldest === void 0 || runtime.lastUsedAt <= oldest.lastUsedAt) oldest = runtime;
621
+ }
622
+ return oldest;
623
+ }
624
+ /**
625
+ * One queue that runs handoffs one at a time.
626
+ *
627
+ * Two handoffs that passed the cap together would both start a runtime, so each
628
+ * handoff's make-room-and-start runs behind the previous one. A handoff that
629
+ * fails settles its own promise and leaves the queue ready for the next.
630
+ * @returns a function that queues one handoff.
631
+ */
632
+ function createHandoffQueue() {
633
+ let tail = Promise.resolve();
634
+ return (work) => {
635
+ const next = tail.then(work, work);
636
+ tail = next.then(() => void 0, () => void 0);
637
+ return next;
638
+ };
639
+ }
640
+ async function runTenantsServe(options) {
641
+ const home = options.home ?? resolveQilinHome();
642
+ const binPath = process.argv[1];
643
+ if (binPath === void 0) throw new Error("qilin tenants: the CLI entry path is unknown, so no tenant runtime can be started");
644
+ const startRuntime = options.createRuntime ?? ((runtimeOptions) => new TenantRuntime(runtimeOptions));
645
+ const runtimes = /* @__PURE__ */ new Map();
646
+ const usageTokens = /* @__PURE__ */ new Map();
647
+ let usageUrl = "";
648
+ /** Runtimes holding a child, whether or not it has printed its URL yet. */
649
+ const active = () => [...runtimes.values()].filter((runtime) => runtime.status().state === "running" || runtime.status().state === "starting");
650
+ const runtimeFor = (accountId) => {
651
+ const existing = runtimes.get(accountId);
652
+ if (existing !== void 0) return existing;
653
+ const usageToken = randomBytes(32).toString("hex");
654
+ usageTokens.set(usageToken, accountId);
655
+ const created = startRuntime({
656
+ usage: {
657
+ url: usageUrl,
658
+ token: usageToken
659
+ },
660
+ home,
661
+ accountId,
662
+ profile: options.profile,
663
+ binPath,
664
+ execArgv: process.execArgv,
665
+ host: options.tenantHost ?? void 0,
666
+ restartBackoffMilliseconds: options.restartBackoffMilliseconds,
667
+ patches: options.patches,
668
+ appArguments: options.appArguments,
669
+ sessionCookieSecure: secureSessionCookies(options),
670
+ log: (line) => {
671
+ process.stderr.write(`qilin tenants: [${accountId}] ${line}\n`);
672
+ }
673
+ });
674
+ runtimes.set(accountId, created);
675
+ return created;
676
+ };
677
+ /**
678
+ * The runtime a deployment serves the product's own surface from: the landing
679
+ * page, the sign-in form, registration, and first-run initialization.
680
+ *
681
+ * The registration mode is the deployment's own policy, not a consequence of
682
+ * holding an account: a deployment that accepts new accounts keeps accepting
683
+ * them once the first one exists, and only an operator asking for `closed`
684
+ * turns that off.
685
+ * @returns the first-run runtime.
686
+ */
687
+ const bootstrapRuntime = () => {
688
+ const existing = runtimes.get(BOOTSTRAP_ACCOUNT);
689
+ if (existing !== void 0) return existing;
690
+ const created = startRuntime({
691
+ home,
692
+ accountId: BOOTSTRAP_ACCOUNT,
693
+ directoryName: BOOTSTRAP_DIRECTORY,
694
+ bootstrap: true,
695
+ registration: options.registration,
696
+ profile: options.profile,
697
+ binPath,
698
+ execArgv: process.execArgv,
699
+ host: options.tenantHost ?? void 0,
700
+ restartBackoffMilliseconds: options.restartBackoffMilliseconds,
701
+ patches: options.patches,
702
+ appArguments: options.appArguments,
703
+ sessionCookieSecure: secureSessionCookies(options),
704
+ log: (line) => {
705
+ process.stderr.write(`qilin tenants: [first-run] ${line}\n`);
706
+ }
707
+ });
708
+ runtimes.set(BOOTSTRAP_ACCOUNT, created);
709
+ return created;
710
+ };
711
+ /**
712
+ * Bring the live runtimes under the cap by reclaiming the least recently used
713
+ * ones. Every tenant's Sessions stay on disk, so reclaiming costs one boot.
714
+ * @param target - the runtime about to start, which is never reclaimed.
715
+ */
716
+ const makeRoom = async (target) => {
717
+ for (;;) {
718
+ const oldest = reclaimFor(active(), target, options.maxTenants);
719
+ if (oldest === void 0) return;
720
+ process.stderr.write(`qilin tenants: ${String(options.maxTenants)} runtime(s) are already alive; reclaiming ${oldest.status().accountId} to start ${target.status().accountId}\n`);
721
+ await oldest.stop();
722
+ }
723
+ };
724
+ /** Handoffs run one at a time, so two of them cannot both pass the cap and start. */
725
+ const serialize = createHandoffQueue();
726
+ const roster = async () => await AccountStore.open(accountsFilePath(home));
727
+ const status = async () => {
728
+ const store = await roster();
729
+ return {
730
+ mode: options.singleOrigin ? "single-origin" : "handoff",
731
+ maxTenants: options.maxTenants,
732
+ alive: active().length,
733
+ tenants: [...runtimes.values()].map((runtime) => {
734
+ const reported = runtime.status();
735
+ return {
736
+ ...reported,
737
+ username: store.byId(reported.accountId)?.username ?? null
738
+ };
739
+ })
740
+ };
741
+ };
742
+ /**
743
+ * Resolve the runtime one browser is driving, starting it when it is not serving.
744
+ * @param store - the roster this request read.
745
+ * @param accountId - the account the browser's routing cookie names.
746
+ * @returns the runtime, or undefined when the roster no longer holds that account.
747
+ */
748
+ const routedRuntime = async (store, accountId) => {
749
+ if (store.byId(accountId) === void 0) return void 0;
750
+ const runtime = runtimeFor(accountId);
751
+ await serialize(async () => {
752
+ await makeRoom(runtime);
753
+ await runtime.ensure();
754
+ });
755
+ return runtime;
756
+ };
757
+ /**
758
+ * Forward one browser request to a runtime and relay its answer.
759
+ * @param request - the browser's request.
760
+ * @param response - the browser's response.
761
+ * @param runtime - the runtime the request belongs to.
762
+ * @param path - path and query to request upstream.
763
+ * @param extraCookies - cookies to add to the answer.
764
+ */
765
+ const forward = async (request, response, runtime, path, extraCookies) => {
766
+ const origin = runtime.proxyTarget();
767
+ if (origin === void 0) throw new Error("the runtime is not serving");
768
+ runtime.lastUsedAt = Date.now();
769
+ const answered = await proxyRequest({
770
+ origin,
771
+ method: request.method ?? "GET",
772
+ path,
773
+ headers: request.headers,
774
+ hubAuthority: request.headers.host ?? "",
775
+ body: request
776
+ });
777
+ const upstreamCookies = answered.headers["set-cookie"] === void 0 ? [] : Array.isArray(answered.headers["set-cookie"]) ? answered.headers["set-cookie"] : [answered.headers["set-cookie"]];
778
+ relayAnswer({
779
+ ...answered,
780
+ headers: {
781
+ ...answered.headers,
782
+ "set-cookie": [...upstreamCookies, ...extraCookies]
783
+ }
784
+ }, response);
785
+ };
786
+ /**
787
+ * The runtime the product's own surface is served from, running.
788
+ *
789
+ * The product hides or shows its register entry from the answer this runtime
790
+ * reports, and the runtime refuses the call it would hide: both follow the
791
+ * deployment's registration policy, so an operator who accepts new accounts
792
+ * sees the entry for every account the roster already holds.
793
+ * @returns the first-run runtime, running.
794
+ */
795
+ const runningFirstRun = async () => {
796
+ const runtime = bootstrapRuntime();
797
+ await serialize(async () => {
798
+ await makeRoom(runtime);
799
+ await runtime.ensure();
800
+ });
801
+ return runtime;
802
+ };
803
+ /**
804
+ * Hand a request to the runtime a deployment without accounts uses, so the
805
+ * product's own first-run form creates the first account instead of the
806
+ * deployment describing what an operator should type.
807
+ * @param request - the browser's request.
808
+ * @param response - the browser's answer.
809
+ * @param path - request path, preserved so the application sees its own route.
810
+ * @param extraCookies - cookies this deployment appends to the answer.
811
+ */
812
+ const serveFirstRun = async (request, response, path, extraCookies = []) => {
813
+ try {
814
+ await forward(request, response, await runningFirstRun(), path, extraCookies);
815
+ } catch (error) {
816
+ process.stderr.write(`qilin tenants: the first-run runtime did not start: ${String(error)}\n`);
817
+ respond(response, 503, "text/plain; charset=utf-8", "qilin tenants: this deployment could not start its first-run runtime\n");
818
+ }
819
+ };
820
+ /**
821
+ * Which account the browser's session names, as the runtime that issued it
822
+ * reports it.
823
+ *
824
+ * The deployment never reads a session itself: the runtime owns that answer,
825
+ * and this asks it the same question the product's landing page asks.
826
+ * @param request - the browser's request, for the cookie that carries the session.
827
+ * @returns the account id, or undefined when no session names one.
828
+ */
829
+ const signedInAccountId = async (request) => {
830
+ const origin = (await runningFirstRun()).proxyTarget();
831
+ if (origin === void 0) return void 0;
832
+ const answered = await proxyRequest({
833
+ origin,
834
+ method: "GET",
835
+ path: "/api/auth/status",
836
+ headers: {
837
+ cookie: request.headers.cookie ?? "",
838
+ host: request.headers.host ?? ""
839
+ },
840
+ hubAuthority: request.headers.host ?? ""
841
+ });
842
+ if (answered.status !== 200) {
843
+ answered.stream.resume();
844
+ return;
845
+ }
846
+ const body = JSON.parse(await answerText(answered.stream));
847
+ if (typeof body !== "object" || body === null) return void 0;
848
+ const user = body.user;
849
+ if (typeof user !== "object" || user === null) return void 0;
850
+ const id = user.id;
851
+ return typeof id === "string" ? id : void 0;
852
+ };
853
+ const bearerToken = (header) => {
854
+ if (header === void 0) return void 0;
855
+ const separator = header.indexOf(" ");
856
+ if (separator === -1) return void 0;
857
+ if (header.slice(0, separator).toLowerCase() !== "bearer") return void 0;
858
+ const value = header.slice(separator + 1).trim();
859
+ return value === "" ? void 0 : value;
860
+ };
861
+ /** The account one presented token was minted for, compared without leaking its prefix. */
862
+ const accountForToken = (presented) => {
863
+ const candidate = Buffer.from(presented, "utf8");
864
+ for (const [token, accountId] of usageTokens) {
865
+ const known = Buffer.from(token, "utf8");
866
+ if (known.length === candidate.length && timingSafeEqual(known, candidate)) return accountId;
867
+ }
868
+ };
869
+ /** Read one report body, bounded, or undefined when it is oversized or not JSON. */
870
+ const readReportBody = async (request) => {
871
+ const chunks = [];
872
+ let size = 0;
873
+ for await (const chunk of request) {
874
+ const buffer = chunk;
875
+ size += buffer.length;
876
+ if (size > MAX_USAGE_BODY_BYTES) return void 0;
877
+ chunks.push(buffer);
878
+ }
879
+ try {
880
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
881
+ } catch (error) {
882
+ if (error instanceof SyntaxError) return void 0;
883
+ throw error;
884
+ }
885
+ };
886
+ /**
887
+ * Answer a usage read for one account: the administrator's seat widens the
888
+ * answer to every account's records, tagged; everyone else reads their own.
889
+ * Shared by the ledger channel (a runtime reporting under its token) and the
890
+ * browser's own read, which the hub answers directly from its ledger.
891
+ * @param response - the answer to write.
892
+ * @param accountId - the account the read belongs to.
893
+ * @param search - the read's query, carrying the window.
894
+ */
895
+ const answerUsageRead = async (response, accountId, search) => {
896
+ const window = readUsageWindow(search);
897
+ if (window === void 0) {
898
+ json(response, 400, { error: "invalid-window" });
899
+ return;
900
+ }
901
+ const store = await roster();
902
+ if (store.byId(accountId)?.admin === true) {
903
+ const tagged = [];
904
+ for (const account of store.all()) for (const record of await readUsageRecords(home, account.id, window)) tagged.push({
905
+ accountId: account.id,
906
+ record
907
+ });
908
+ tagged.sort((left, right) => left.record.at - right.record.at);
909
+ const capped = capUsageRecords(tagged, USAGE_RECORDS_CAP);
910
+ const totals = await readUsageTotals(home);
911
+ json(response, 200, {
912
+ scope: "deployment",
913
+ totals,
914
+ total: sumUsageTotals(totals),
915
+ records: capped.records.map((entry) => ({
916
+ ...entry.record,
917
+ accountId: entry.accountId
918
+ })),
919
+ truncated: capped.truncated
920
+ });
921
+ return;
922
+ }
923
+ const capped = capUsageRecords(await readUsageRecords(home, accountId, window), USAGE_RECORDS_CAP);
924
+ json(response, 200, {
925
+ accountId,
926
+ total: await readUsageTotal(home, accountId),
927
+ records: capped.records,
928
+ truncated: capped.truncated
929
+ });
930
+ };
931
+ /**
932
+ * Answer one runtime over the ledger channel: a report books spend, and a read
933
+ * answers what that same token's account spent. Only a token this hub minted
934
+ * is accepted, so neither verb can name another account.
935
+ *
936
+ * The account's own seat widens the read: the deployment's administrator reads
937
+ * every account's ledger, and the seat is read from the roster on each request
938
+ * so it follows the account it belongs to rather than the token's age.
939
+ */
940
+ const handleUsage = async (request, response) => {
941
+ if (request.method !== "POST" && request.method !== "GET") {
942
+ respond(response, 405, "text/plain; charset=utf-8", "qilin tenants: the ledger reports and reads\n");
943
+ return;
944
+ }
945
+ const presented = bearerToken(request.headers.authorization);
946
+ const accountId = presented === void 0 ? void 0 : accountForToken(presented);
947
+ if (accountId === void 0 || !isTenantIdentity(accountId)) {
948
+ json(response, 401, { error: "unauthorized" });
949
+ return;
950
+ }
951
+ if (request.method === "GET") {
952
+ await answerUsageRead(response, accountId, new URL(request.url ?? "/", "http://qilin.invalid").searchParams);
953
+ return;
954
+ }
955
+ const body = await readReportBody(request);
956
+ if (!isUsageReport(body)) {
957
+ json(response, 400, { error: "invalid-body" });
958
+ return;
959
+ }
960
+ await appendUsage(home, accountId, body);
961
+ response.writeHead(204);
962
+ response.end();
963
+ };
964
+ const handle = async (request, response) => {
965
+ const url = new URL(request.url ?? "/", "http://qilin.invalid");
966
+ if (url.pathname === "/healthz") {
967
+ json(response, 200, { ok: true });
968
+ return;
969
+ }
970
+ if (url.pathname === "/status") {
971
+ json(response, 200, await status());
972
+ return;
973
+ }
974
+ const productPath = () => {
975
+ if (!url.pathname.startsWith("/t/")) return `${url.pathname}${url.search}`;
976
+ const rest = url.pathname.slice(3);
977
+ const separator = rest.indexOf("/");
978
+ return separator === -1 ? `/${url.search}` : `${rest.slice(separator)}${url.search}`;
979
+ };
980
+ const store = await roster();
981
+ if (store.all().length === 0) {
982
+ await serveFirstRun(request, response, productPath());
983
+ return;
984
+ }
985
+ const hubOwnRoute = url.pathname === "/";
986
+ if (request.method !== "GET" && !(options.singleOrigin && !hubOwnRoute)) {
987
+ respond(response, 405, "text/plain; charset=utf-8", "qilin tenants: the hub answers reads only\n");
988
+ return;
989
+ }
990
+ if (url.pathname.startsWith("/t/")) {
991
+ const rest = url.pathname.slice(3);
992
+ const separator = rest.indexOf("/");
993
+ const username = decodeIdentifier(separator === -1 ? rest : rest.slice(0, separator));
994
+ if (username === void 0) {
995
+ respond(response, 400, "text/plain; charset=utf-8", "qilin tenants: that account path is not valid UTF-8\n");
996
+ return;
997
+ }
998
+ const account = store.byIdentifier(username);
999
+ if (account === void 0) {
1000
+ await serveFirstRun(request, response, productPath());
1001
+ return;
1002
+ }
1003
+ try {
1004
+ const runtime = runtimeFor(account.id);
1005
+ const launchUrl = await serialize(async () => {
1006
+ await makeRoom(runtime);
1007
+ return await runtime.ensure();
1008
+ });
1009
+ const deeper = separator === -1 ? "/" : url.pathname.slice(3 + username.length);
1010
+ if (!options.singleOrigin) {
1011
+ response.writeHead(303, {
1012
+ "cache-control": "no-store",
1013
+ "location": `${new URL(launchUrl).origin}${deeper}${url.search}`,
1014
+ "referrer-policy": "no-referrer"
1015
+ });
1016
+ response.end();
1017
+ return;
1018
+ }
1019
+ await forward(request, response, runtime, `${deeper}${url.search}`, [routingCookie(account.id)]);
1020
+ } catch (error) {
1021
+ process.stderr.write(`qilin tenants: the runtime for ${account.username} did not start: ${String(error)}\n`);
1022
+ respond(response, 503, "text/plain; charset=utf-8", `qilin tenants: the runtime for ${account.username} did not start\n`);
1023
+ }
1024
+ return;
1025
+ }
1026
+ if (options.singleOrigin) {
1027
+ const accountId = cookieValue(request.headers.cookie, TENANT_COOKIE);
1028
+ if (deploymentDoor(url.pathname)) {
1029
+ await serveFirstRun(request, response, productPath(), [clearedRoutingCookie()]);
1030
+ return;
1031
+ }
1032
+ if (url.pathname === "/api/usage/me" && request.method === "GET") {
1033
+ const accountId = cookieValue(request.headers.cookie, TENANT_COOKIE) ?? await signedInAccountId(request);
1034
+ if (accountId === void 0 || store.byId(accountId) === void 0) {
1035
+ json(response, 401, { error: "unauthorized" });
1036
+ return;
1037
+ }
1038
+ await answerUsageRead(response, accountId, new URL(request.url ?? "/", "http://qilin.invalid").searchParams);
1039
+ return;
1040
+ }
1041
+ if (accountId !== void 0) {
1042
+ const runtime = await routedRuntime(store, accountId);
1043
+ if (runtime !== void 0) {
1044
+ try {
1045
+ await forward(request, response, runtime, `${url.pathname}${url.search}`, []);
1046
+ } catch (error) {
1047
+ process.stderr.write(`qilin tenants: no runtime served ${url.pathname}: ${String(error)}\n`);
1048
+ respond(response, 503, "text/plain; charset=utf-8", "qilin tenants: that tenant is not serving\n");
1049
+ }
1050
+ return;
1051
+ }
1052
+ response.setHeader("set-cookie", clearedRoutingCookie());
1053
+ }
1054
+ }
1055
+ if (options.singleOrigin && request.method === "GET" && request.headers.cookie !== void 0 && (request.headers.accept ?? "").includes("text/html")) {
1056
+ const accountId = await signedInAccountId(request);
1057
+ if (accountId !== void 0 && store.byId(accountId) !== void 0) {
1058
+ if ((await routedRuntime(store, accountId))?.proxyTarget() !== void 0) {
1059
+ response.writeHead(303, {
1060
+ "cache-control": "no-store",
1061
+ "location": `${url.pathname}${url.search}`,
1062
+ "referrer-policy": "no-referrer",
1063
+ "set-cookie": routingCookie(accountId)
1064
+ });
1065
+ response.end();
1066
+ return;
1067
+ }
1068
+ }
1069
+ }
1070
+ if (options.singleOrigin && request.headers.cookie !== void 0) {
1071
+ const signedIn = await signedInAccountId(request);
1072
+ if (signedIn !== void 0 && store.byId(signedIn) !== void 0) {
1073
+ const runtime = await routedRuntime(store, signedIn);
1074
+ if (runtime?.proxyTarget() !== void 0) {
1075
+ await forward(request, response, runtime, `${url.pathname}${url.search}`, [routingCookie(signedIn)]);
1076
+ return;
1077
+ }
1078
+ }
1079
+ }
1080
+ await serveFirstRun(request, response, productPath());
1081
+ };
1082
+ const onRequest = (request, response) => {
1083
+ handle(request, response).catch((error) => {
1084
+ process.stderr.write(`qilin tenants: request failed: ${String(error)}\n`);
1085
+ if (!response.headersSent) respond(response, 500, "text/plain; charset=utf-8", "qilin tenants: request failed\n");
1086
+ else response.end();
1087
+ });
1088
+ };
1089
+ const server = options.tls === void 0 ? createServer(onRequest) : createServer$1({
1090
+ cert: readPem(options.tls.certFile, "--tls-cert"),
1091
+ key: readPem(options.tls.keyFile, "--tls-key")
1092
+ }, onRequest);
1093
+ if (options.singleOrigin) server.on("upgrade", (request, socket, head) => {
1094
+ (async () => {
1095
+ if (!hubAcceptsOrigin(request.headers, request.headers.host ?? "")) {
1096
+ socket.destroy();
1097
+ return;
1098
+ }
1099
+ let accountId = cookieValue(request.headers.cookie, TENANT_COOKIE);
1100
+ if (accountId === void 0 && request.headers.cookie !== void 0) accountId = await signedInAccountId(request);
1101
+ const runtime = accountId === void 0 ? void 0 : await routedRuntime(await roster(), accountId);
1102
+ const target = runtime?.proxyTarget();
1103
+ if (runtime === void 0 || target === void 0) {
1104
+ socket.destroy();
1105
+ return;
1106
+ }
1107
+ runtime.lastUsedAt = Date.now();
1108
+ relayUpgrade(request, socket, head, target, forwardedHeaders(request.headers, new URL(target).host, request.headers.host ?? ""));
1109
+ })().catch((error) => {
1110
+ process.stderr.write(`qilin tenants: the upgrade to ${request.url ?? ""} failed: ${String(error)}\n`);
1111
+ socket.destroy();
1112
+ });
1113
+ });
1114
+ const listening = Promise.withResolvers();
1115
+ server.on("error", (error) => {
1116
+ listening.reject(error);
1117
+ });
1118
+ server.listen(options.port, options.host, () => {
1119
+ const address = server.address();
1120
+ listening.resolve(typeof address === "object" && address !== null ? address.port : options.port);
1121
+ });
1122
+ const port = await listening.promise;
1123
+ const usageServer = createServer((request, response) => {
1124
+ handleUsage(request, response).catch((error) => {
1125
+ process.stderr.write(`qilin tenants: the usage report failed: ${String(error)}\n`);
1126
+ if (response.headersSent) response.end();
1127
+ else json(response, 500, { error: "ledger-failed" });
1128
+ });
1129
+ });
1130
+ const usagePort = await new Promise((resolve, reject) => {
1131
+ usageServer.once("error", reject);
1132
+ usageServer.listen(0, "127.0.0.1", () => {
1133
+ const bound = usageServer.address();
1134
+ resolve(typeof bound === "object" && bound !== null ? bound.port : 0);
1135
+ });
1136
+ });
1137
+ usageUrl = `http://127.0.0.1:${String(usagePort)}/usage`;
1138
+ const printableHost = options.host === "0.0.0.0" || options.host === "::" ? "127.0.0.1" : options.host;
1139
+ const address = `${options.tls === void 0 ? "http" : "https"}://${printableHost}:${String(port)}/`;
1140
+ process.stdout.write(`qilin tenants: ${address}\n`);
1141
+ for (const notice of addressNotices(options.host, options.tenantHost, port)) process.stderr.write(`${notice}\n`);
1142
+ if ((await roster()).isEmpty && options.registration !== "open") process.stderr.write("qilin tenants: no account exists yet and this deployment does not accept new ones; create the first with `qilin tenants add <name>`\n");
1143
+ options.onListening?.(address);
1144
+ const sweep = Math.max(250, Math.min(Math.floor(options.idleMilliseconds / 4), 3e4));
1145
+ const idle = options.idleMilliseconds <= 0 ? void 0 : setInterval(() => {
1146
+ const now = Date.now();
1147
+ for (const runtime of runtimes.values()) {
1148
+ if (runtime.status().state !== "running") continue;
1149
+ if (now - runtime.lastUsedAt < options.idleMilliseconds) continue;
1150
+ process.stderr.write(`qilin tenants: reclaiming the idle runtime for ${runtime.status().accountId}\n`);
1151
+ runtime.stop();
1152
+ }
1153
+ }, sweep);
1154
+ const stopping = Promise.withResolvers();
1155
+ const shutdown = () => {
1156
+ (async () => {
1157
+ if (idle !== void 0) clearInterval(idle);
1158
+ server.closeAllConnections();
1159
+ usageServer.closeAllConnections();
1160
+ await new Promise((resolve) => {
1161
+ server.close(() => {
1162
+ resolve();
1163
+ });
1164
+ });
1165
+ await new Promise((resolve) => {
1166
+ usageServer.close(() => {
1167
+ resolve();
1168
+ });
1169
+ });
1170
+ usageTokens.clear();
1171
+ await Promise.all([...runtimes.values()].map(async (runtime) => {
1172
+ await runtime.stop();
1173
+ }));
1174
+ stopping.resolve(0);
1175
+ })();
1176
+ };
1177
+ process.once("SIGINT", shutdown);
1178
+ process.once("SIGTERM", shutdown);
1179
+ if (options.signal !== void 0) if (options.signal.aborted) shutdown();
1180
+ else options.signal.addEventListener("abort", shutdown, { once: true });
1181
+ return await stopping.promise;
1182
+ }
1183
+ //#endregion
1184
+ //#region lib/types/web-entry.js
1185
+ /**
1186
+ * The deployment entry: one public address, one supervisor, and one runtime per
1187
+ * account.
1188
+ *
1189
+ * Every profile that serves a browser address is a deployment: `qilin web` and
1190
+ * the product surface `qilin` both come here. This process binds the address a
1191
+ * browser reaches, terminates TLS when the invocation carries a certificate and
1192
+ * key, and starts one child runtime per account on demand. A child boots the same
1193
+ * profile with {@link TENANT_VARIABLE} set, so the application is what a runtime
1194
+ * process runs and the supervisor is what the deployment runs.
1195
+ *
1196
+ * Runtimes stay on loopback: the public address is the supervisor's. That is
1197
+ * why this entry serves every account through its own origin rather than
1198
+ * redirecting a browser to a runtime port — a redirect target a client cannot
1199
+ * reach is not a deployment.
1200
+ *
1201
+ * The entry reads the deployment's own flags and hands every other argument to
1202
+ * each runtime's command line, so an application flag reaches the process that
1203
+ * implements it instead of being dropped here. `--patch` is the exception it
1204
+ * forwards as a launcher flag, because an overlay applies to the composition
1205
+ * each runtime boots.
1206
+ *
1207
+ * The desktop shell boots the product profile in-process through
1208
+ * `profile-boot`, so it never reaches this module and keeps serving one
1209
+ * account directly.
1210
+ * @module @qilin-agent/cli/web-entry
1211
+ */
1212
+ /** The profiles whose boot is a deployment of the product surface. */
1213
+ const ENTRY_PROFILES = ["qilin", "web"];
1214
+ /** Idle window a deployment gets before it sets one. */
1215
+ const DEFAULT_IDLE_MILLISECONDS = 18e5;
1216
+ /** Where the public address binds when the invocation names none. */
1217
+ const DEFAULT_HOST = "127.0.0.1";
1218
+ /** Which port the public address binds when the invocation names none. */
1219
+ const DEFAULT_PORT = 8790;
1220
+ /** First wait before retrying a runtime that failed to start. */
1221
+ const DEFAULT_RESTART_BACKOFF_MILLISECONDS = 1e3;
1222
+ /**
1223
+ * Addresses that mean "every interface". The served application refuses them
1224
+ * because binding every interface exposes remote execution to the network, and
1225
+ * the deployment address is the only address a browser reaches.
1226
+ */
1227
+ const WILDCARD_HOSTS = [
1228
+ "0.0.0.0",
1229
+ "::",
1230
+ "[::]",
1231
+ "*"
1232
+ ];
1233
+ /** Deployment flags that accumulate every occurrence instead of keeping the first. */
1234
+ const REPEATABLE_VALUE_FLAGS = ["--patch"];
1235
+ /** Deployment flags that carry a value. */
1236
+ const VALUE_FLAGS = [
1237
+ "--host",
1238
+ "--port",
1239
+ "--patch",
1240
+ "--tls-cert",
1241
+ "--tls-key",
1242
+ "--tenant-profile",
1243
+ "--idle-ms",
1244
+ "--max-tenants",
1245
+ "--restart-backoff-ms",
1246
+ "--tenant-host",
1247
+ "--registration"
1248
+ ];
1249
+ /** Deployment flags that stand alone. */
1250
+ const SWITCH_FLAGS = [
1251
+ "--no-open",
1252
+ "--per-account-ports",
1253
+ "-h",
1254
+ "--help"
1255
+ ];
1256
+ /**
1257
+ * Split one invocation's arguments into the deployment's own flags and the
1258
+ * application's.
1259
+ * @param args - the arguments after the launcher's own flags.
1260
+ * @returns the deployment values, its repeatable lists, its switches, and the application's arguments.
1261
+ * @throws Error when a deployment flag that carries a value names none.
1262
+ */
1263
+ function splitArguments(args) {
1264
+ const values = /* @__PURE__ */ new Map();
1265
+ const lists = /* @__PURE__ */ new Map();
1266
+ const switches = /* @__PURE__ */ new Set();
1267
+ const appArguments = [];
1268
+ for (let index = 0; index < args.length; index += 1) {
1269
+ const token = args[index] ?? "";
1270
+ const separator = token.indexOf("=");
1271
+ const name = separator === -1 ? token : token.slice(0, separator);
1272
+ const inline = separator === -1 ? void 0 : token.slice(separator + 1);
1273
+ if (SWITCH_FLAGS.includes(name)) {
1274
+ if (inline !== void 0) throw new Error(`qilin web: ${name} takes no value, received ${JSON.stringify(token)}`);
1275
+ switches.add(name);
1276
+ continue;
1277
+ }
1278
+ if (VALUE_FLAGS.includes(name)) {
1279
+ const value = inline ?? args[index + 1];
1280
+ if (inline === void 0) index += 1;
1281
+ if (value === void 0 || inline === void 0 && value.startsWith("--")) throw new Error(`qilin web: ${name} needs a value`);
1282
+ if (REPEATABLE_VALUE_FLAGS.includes(name)) {
1283
+ lists.set(name, [...lists.get(name) ?? [], value]);
1284
+ continue;
1285
+ }
1286
+ if (!values.has(name)) values.set(name, value);
1287
+ continue;
1288
+ }
1289
+ appArguments.push(token);
1290
+ }
1291
+ return {
1292
+ values,
1293
+ lists,
1294
+ switches,
1295
+ appArguments
1296
+ };
1297
+ }
1298
+ /**
1299
+ * One whole-number deployment flag.
1300
+ * @param values - the deployment values the invocation named.
1301
+ * @param name - the flag to read.
1302
+ * @param fallback - the value to use when the flag is absent.
1303
+ * @param minimum - the smallest accepted value.
1304
+ * @returns the parsed value.
1305
+ * @throws Error when the flag is present but not a whole number at or above the floor.
1306
+ */
1307
+ function wholeNumber(values, name, fallback, minimum) {
1308
+ const raw = values.get(name);
1309
+ if (raw === void 0) return fallback;
1310
+ if (!/^[0-9]+$/u.test(raw) || Number(raw) < minimum) throw new Error(`qilin web: ${name} needs a whole number of at least ${String(minimum)}, received ${JSON.stringify(raw)}`);
1311
+ return Number(raw);
1312
+ }
1313
+ /**
1314
+ * The certificate and key the public address serves with.
1315
+ * @param values - the deployment values the invocation named.
1316
+ * @returns the resolved pair, or undefined when the invocation named none.
1317
+ * @throws Error when only one of the two flags is present.
1318
+ */
1319
+ function tlsOptions(values) {
1320
+ const certFile = values.get("--tls-cert");
1321
+ const keyFile = values.get("--tls-key");
1322
+ if (certFile === void 0 && keyFile === void 0) return void 0;
1323
+ if (certFile === void 0 || keyFile === void 0) throw new Error("qilin web: --tls-cert and --tls-key must be supplied together");
1324
+ return {
1325
+ certFile: resolve(certFile),
1326
+ keyFile: resolve(keyFile)
1327
+ };
1328
+ }
1329
+ /**
1330
+ * Read who may create accounts on this deployment.
1331
+ *
1332
+ * A loopback address is this machine's own, so the product's form may create the
1333
+ * accounts it is asked to; an address other machines reach answers the same
1334
+ * question with the operator, who runs `qilin tenants add` where the decision is
1335
+ * deliberate.
1336
+ * @param values - the deployment values the invocation named.
1337
+ * @param host - interface the public address binds.
1338
+ * @returns the effective policy.
1339
+ * @throws Error when the flag names a policy this deployment does not have.
1340
+ */
1341
+ function registrationPolicy(values, host) {
1342
+ const named = values.get("--registration");
1343
+ if (named === void 0) return LOOPBACK_HOSTS.includes(host) ? "open" : "closed";
1344
+ if (named !== "open" && named !== "closed") throw new Error(`qilin web: --registration is open or closed, received ${JSON.stringify(named)}`);
1345
+ return named;
1346
+ }
1347
+ /**
1348
+ * Read one deployment invocation.
1349
+ * @param args - the arguments after the launcher's own flags.
1350
+ * @param servedProfile - profile this deployment serves when the invocation names none.
1351
+ * @returns what the entry should serve.
1352
+ * @throws Error when a named flag cannot be honoured.
1353
+ */
1354
+ function deploymentOptions(args, servedProfile) {
1355
+ const { values, lists, switches, appArguments } = splitArguments(args);
1356
+ const host = values.get("--host") ?? DEFAULT_HOST;
1357
+ if (switches.has("-h") || switches.has("--help")) return {
1358
+ host,
1359
+ port: DEFAULT_PORT,
1360
+ tenantProfile: values.get("--tenant-profile") ?? servedProfile,
1361
+ idleMilliseconds: DEFAULT_IDLE_MILLISECONDS,
1362
+ maxTenants: 0,
1363
+ restartBackoffMilliseconds: DEFAULT_RESTART_BACKOFF_MILLISECONDS,
1364
+ singleOrigin: !switches.has("--per-account-ports"),
1365
+ tenantHost: values.get("--tenant-host"),
1366
+ registration: LOOPBACK_HOSTS.includes(host) ? "open" : "closed",
1367
+ tls: void 0,
1368
+ help: true,
1369
+ patches: (lists.get("--patch") ?? []).map((path) => resolve(path)),
1370
+ appArguments
1371
+ };
1372
+ if (WILDCARD_HOSTS.includes(host)) throw new Error(`qilin web: --host ${host} is an unspecified (wildcard) address, which is not supported: bind one concrete address instead`);
1373
+ const singleOrigin = !switches.has("--per-account-ports");
1374
+ const tls = tlsOptions(values);
1375
+ if (tls !== void 0 && !singleOrigin) throw new Error("qilin web: --tls-cert terminates TLS at this address, so it cannot be combined with --per-account-ports");
1376
+ return {
1377
+ host,
1378
+ port: wholeNumber(values, "--port", DEFAULT_PORT, 0),
1379
+ tenantProfile: values.get("--tenant-profile") ?? servedProfile,
1380
+ idleMilliseconds: wholeNumber(values, "--idle-ms", DEFAULT_IDLE_MILLISECONDS, 0),
1381
+ maxTenants: wholeNumber(values, "--max-tenants", 0, 0),
1382
+ restartBackoffMilliseconds: wholeNumber(values, "--restart-backoff-ms", DEFAULT_RESTART_BACKOFF_MILLISECONDS, 0),
1383
+ singleOrigin,
1384
+ tenantHost: values.get("--tenant-host"),
1385
+ registration: registrationPolicy(values, host),
1386
+ tls,
1387
+ help: false,
1388
+ patches: (lists.get("--patch") ?? []).map((path) => resolve(path)),
1389
+ appArguments
1390
+ };
1391
+ }
1392
+ /**
1393
+ * Whether this process is the deployment entry rather than one account's runtime.
1394
+ * @returns true when no tenant marker is present.
1395
+ */
1396
+ function isWebEntry() {
1397
+ return process.env[TENANT_VARIABLE] === void 0;
1398
+ }
1399
+ /**
1400
+ * The deployment's own help, which the served application's help cannot answer
1401
+ * for: this process owns the address every account is reached through.
1402
+ * @returns the help text.
1403
+ */
1404
+ function helpText() {
1405
+ return [
1406
+ "Usage: qilin --profile web [deployment options] [app options...]",
1407
+ " qilin [deployment options] [app options...]",
1408
+ "",
1409
+ "Serve one public address, with one isolated runtime per account.",
1410
+ "",
1411
+ "Deployment options:",
1412
+ ` --host <host> address to bind (default ${DEFAULT_HOST}); wildcard addresses are refused`,
1413
+ ` --port <port> listen port; 0 lets the OS pick one (default ${String(DEFAULT_PORT)})`,
1414
+ " --patch <path> extra patch-list overlay every runtime boots with (repeatable)",
1415
+ " --tls-cert <file> PEM server certificate chain; requires --tls-key",
1416
+ " --tls-key <file> unencrypted PEM private key; requires --tls-cert",
1417
+ " --tenant-profile <name> profile each account runtime boots (default: the served profile)",
1418
+ " --tenant-host <host> interface each account runtime binds (default loopback)",
1419
+ " --per-account-ports give every account an origin of its own",
1420
+ ` --idle-ms <ms> reclaim an idle runtime after this long; 0 keeps every one (default ${String(DEFAULT_IDLE_MILLISECONDS)})`,
1421
+ " --max-tenants <count> most runtimes alive at once; 0 keeps every one",
1422
+ " --restart-backoff-ms <ms> first wait before retrying a runtime that failed to start",
1423
+ " --registration open|closed who may create accounts (default open on a loopback address, closed otherwise)",
1424
+ " --no-open do not open the address in the default browser",
1425
+ " -h, --help print this help",
1426
+ "",
1427
+ "Every other argument passes to the command line of each account runtime.",
1428
+ "",
1429
+ "Examples:",
1430
+ " qilin web serve the deployment on 127.0.0.1:8790",
1431
+ " qilin web --port 8080 --no-open serve another port without opening a browser",
1432
+ " qilin web --tls-cert ./chain.pem --tls-key ./key.pem",
1433
+ " serve HTTPS, terminating TLS here",
1434
+ " qilin web --patch ./overlay.yml compose every runtime with one overlay",
1435
+ ""
1436
+ ].join("\n");
1437
+ }
1438
+ /**
1439
+ * Hand the deployment's address to the operating system's default browser.
1440
+ *
1441
+ * The launcher is the platform's own, the same call the Web application makes
1442
+ * for its runtime; it is repeated here because this process is the deployment
1443
+ * rather than a runtime, and the CLI does not depend on that bundle.
1444
+ * @param url - the address to open.
1445
+ * @returns nothing once the launcher is started.
1446
+ */
1447
+ function openAddress(url) {
1448
+ const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
1449
+ "cmd",
1450
+ "/c",
1451
+ "start",
1452
+ "",
1453
+ url
1454
+ ] : ["xdg-open", url];
1455
+ const child = spawn(command, args, {
1456
+ stdio: "ignore",
1457
+ detached: true
1458
+ });
1459
+ child.once("error", (error) => {
1460
+ process.stderr.write(`qilin web: could not open ${url}: ${String(error)}\n`);
1461
+ });
1462
+ child.unref();
1463
+ }
1464
+ /**
1465
+ * Serve the deployment: the public address, and one runtime per account.
1466
+ * @param invocation - the served profile, its overlays, and the app's arguments.
1467
+ * @returns the process exit code.
1468
+ * @throws Error when the invocation names a flag the deployment cannot honour.
1469
+ */
1470
+ async function runWebEntry(invocation) {
1471
+ if (invocation.fromDefaultProfile !== void 0) throw new Error("qilin web: --from-default-profile initializes a custom profile, and a deployment serves a shipped profile; omit it");
1472
+ const options = deploymentOptions(invocation.args, invocation.profile);
1473
+ if (options.help) {
1474
+ process.stdout.write(helpText());
1475
+ return 0;
1476
+ }
1477
+ return await runTenantsServe({
1478
+ host: options.host,
1479
+ port: options.port,
1480
+ profile: options.tenantProfile,
1481
+ idleMilliseconds: options.idleMilliseconds,
1482
+ maxTenants: options.maxTenants,
1483
+ restartBackoffMilliseconds: options.restartBackoffMilliseconds,
1484
+ singleOrigin: options.singleOrigin,
1485
+ tenantHost: options.tenantHost,
1486
+ registration: options.registration,
1487
+ patches: [...invocation.patches, ...options.patches].map((path) => resolve(path)),
1488
+ appArguments: options.appArguments,
1489
+ ...options.tls === void 0 ? {} : { tls: options.tls },
1490
+ ...invocation.args.includes("--no-open") ? {} : { onListening: (url) => {
1491
+ try {
1492
+ openAddress(url);
1493
+ } catch (error) {
1494
+ process.stderr.write(`qilin web: could not open ${url}: ${String(error)}\n`);
1495
+ }
1496
+ } }
1497
+ });
1498
+ }
1499
+ //#endregion
1500
+ export { ENTRY_PROFILES, isWebEntry, runWebEntry };