workerdeck 0.0.0 → 0.6.0

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,1019 @@
1
+ import { createReadStream, existsSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
+ import { createFileSessionStore, createWorkerServer } from "@workerdeck/server";
4
+ import { dashboardDir } from "@workerdeck/web";
5
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
6
+ import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { pathToFileURL } from "node:url";
9
+ //#region src/auth-key.ts
10
+ /** 48 hex chars — far past `createCliAuth`'s 12-char floor, and header-safe. */
11
+ const generateKey = () => randomBytes(24).toString("hex");
12
+ /** A stored key must be one printable-ASCII line long enough to be a secret:
13
+ * both transports (HTTP header, login form) choke on anything else, and a
14
+ * truncated or garbage file should regenerate, not crash or half-work. */
15
+ const usableStoredKey = (raw) => {
16
+ const line = raw.split("\n", 1)[0]?.trim() ?? "";
17
+ return line.length >= 12 && /^[\x21-\x7e]+$/.test(line) ? line : null;
18
+ };
19
+ async function materializeAuthKey(stateDir, options = {}) {
20
+ const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] ${message}\n`));
21
+ if (stateDir === null) return {
22
+ key: generateKey(),
23
+ source: "ephemeral",
24
+ path: null
25
+ };
26
+ const path = join(stateDir, "auth-key");
27
+ let raw = null;
28
+ try {
29
+ raw = await readFile(path, "utf8");
30
+ } catch {
31
+ raw = null;
32
+ }
33
+ if (raw !== null) {
34
+ const key = usableStoredKey(raw);
35
+ if (key !== null) {
36
+ try {
37
+ const { mode } = await stat(path);
38
+ if ((mode & 63) !== 0) warn(`auth key file ${path} is readable by other users (mode ${(mode & 511).toString(8)}) — run: chmod 600 ${path}`);
39
+ } catch {}
40
+ return {
41
+ key,
42
+ source: "stored",
43
+ path
44
+ };
45
+ }
46
+ }
47
+ const key = generateKey();
48
+ await mkdir(stateDir, {
49
+ recursive: true,
50
+ mode: 448
51
+ });
52
+ await writeFile(path, `${key}\n`, { mode: 384 });
53
+ await chmod(path, 384);
54
+ return {
55
+ key,
56
+ source: "created",
57
+ path
58
+ };
59
+ }
60
+ //#endregion
61
+ //#region src/auth.ts
62
+ const MIN_SECRET_LENGTH = 12;
63
+ const DEFAULT_COOKIE_NAME = "workerdeck_session";
64
+ const DEFAULT_TTL_MS = 10080 * 60 * 1e3;
65
+ const DEFAULT_THROTTLE_WINDOW_MS = 900 * 1e3;
66
+ const DEFAULT_MAX_FAILURES_PER_IP = 10;
67
+ const DEFAULT_MAX_FAILURES_GLOBAL = 100;
68
+ /** Only successful logins insert, so this cap only fences the secret-holder's
69
+ * own memory use (every login within the ttl is a live entry). Oldest goes. */
70
+ const MAX_SESSIONS = 100;
71
+ const MAX_LOGIN_BODY_BYTES = 4096;
72
+ const SAFE_METHODS = new Set([
73
+ "GET",
74
+ "HEAD",
75
+ "OPTIONS"
76
+ ]);
77
+ const sha256 = (value) => createHash("sha256").update(value).digest();
78
+ function createCliAuth(options = {}) {
79
+ const { secret } = options;
80
+ const enabled = secret !== void 0;
81
+ if (secret !== void 0 && secret.length < MIN_SECRET_LENGTH) throw new Error(`createCliAuth: secret must be at least ${MIN_SECRET_LENGTH} characters — use a long random value, or leave it unset to run without auth on loopback`);
82
+ const cookieName = options.cookieName ?? DEFAULT_COOKIE_NAME;
83
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
84
+ if (!(ttlMs > 0)) throw new Error("createCliAuth: ttlMs must be positive");
85
+ const trustProxy = options.trustProxy === true;
86
+ const windowMs = options.throttle?.windowMs ?? DEFAULT_THROTTLE_WINDOW_MS;
87
+ const maxFailuresPerIp = options.throttle?.maxFailuresPerIp ?? DEFAULT_MAX_FAILURES_PER_IP;
88
+ const maxFailuresGlobal = options.throttle?.maxFailuresGlobal ?? DEFAULT_MAX_FAILURES_GLOBAL;
89
+ const allowedOrigins = new Set((options.allowedOrigins ?? []).map((entry) => {
90
+ try {
91
+ return new URL(entry).origin;
92
+ } catch {
93
+ throw new Error(`createCliAuth: allowedOrigins entry is not a valid origin: ${JSON.stringify(entry)}`);
94
+ }
95
+ }));
96
+ /** Both the raw secret and session tokens are compared as fixed-length SHA-256
97
+ * digests via timingSafeEqual / digest-keyed lookup, so no code path compares
98
+ * secret material byte-by-byte with early exit — and unequal input lengths
99
+ * leak nothing either. */
100
+ const secretDigest = secret === void 0 ? void 0 : sha256(secret);
101
+ const secretMatches = (candidate) => secretDigest !== void 0 && timingSafeEqual(sha256(candidate), secretDigest);
102
+ /**
103
+ * Browser sessions are a server-side table, not signed tokens: logout must
104
+ * actually invalidate, and a stateless HMAC token stays valid until expiry no
105
+ * matter what the server thinks. This is one long-lived process (multi-node
106
+ * is a non-goal), so "table" means one Map — the cost is that a restart signs
107
+ * every browser out, which for an operator dashboard one secret away from
108
+ * re-login is the right trade. Keys are token digests: recovering an entry's
109
+ * token from lookup timing would need a SHA-256 preimage.
110
+ */
111
+ const sessions = /* @__PURE__ */ new Map();
112
+ const tokenKey = (token) => sha256(token).toString("hex");
113
+ const createSession = () => {
114
+ if (sessions.size >= MAX_SESSIONS) {
115
+ const oldest = sessions.keys().next().value;
116
+ if (oldest !== void 0) sessions.delete(oldest);
117
+ }
118
+ const token = randomBytes(32).toString("base64url");
119
+ sessions.set(tokenKey(token), { expiresAt: Date.now() + ttlMs });
120
+ return token;
121
+ };
122
+ const cookieToken = (req) => {
123
+ const header = req.headers.cookie;
124
+ if (typeof header !== "string") return void 0;
125
+ for (const part of header.split(";")) {
126
+ const eq = part.indexOf("=");
127
+ if (eq === -1) continue;
128
+ if (part.slice(0, eq).trim() === cookieName) return part.slice(eq + 1).trim();
129
+ }
130
+ };
131
+ const hasSession = (req) => {
132
+ const token = cookieToken(req);
133
+ if (token === void 0 || token === "") return false;
134
+ const key = tokenKey(token);
135
+ const entry = sessions.get(key);
136
+ if (entry === void 0) return false;
137
+ if (entry.expiresAt <= Date.now()) {
138
+ sessions.delete(key);
139
+ return false;
140
+ }
141
+ return true;
142
+ };
143
+ /** Last value of a possibly comma-joined forwarded header — the one appended
144
+ * (or set) by the trusted proxy; every earlier position is client-writable. */
145
+ const forwardedLast = (value) => {
146
+ if (value === void 0) return void 0;
147
+ const last = (Array.isArray(value) ? value.join(",") : value).split(",").at(-1)?.trim();
148
+ return last === "" ? void 0 : last;
149
+ };
150
+ const isSecure = (req) => {
151
+ if (req.socket.encrypted === true) return true;
152
+ return trustProxy && forwardedLast(req.headers["x-forwarded-proto"])?.toLowerCase() === "https";
153
+ };
154
+ /** The origin this server believes it is being served as, from the request's
155
+ * own Host (or the proxy's forwarded host) — the only self-knowledge we have. */
156
+ const expectedOrigin = (req) => {
157
+ const host = (trustProxy ? forwardedLast(req.headers["x-forwarded-host"]) : void 0) ?? req.headers.host;
158
+ if (host === void 0 || host === "") return null;
159
+ try {
160
+ return new URL(`${isSecure(req) ? "https" : "http"}://${host}`).origin;
161
+ } catch {
162
+ return null;
163
+ }
164
+ };
165
+ /**
166
+ * The CSRF core. `SameSite=Lax` alone is not enough for two reasons: same
167
+ * *site* is not same *origin* (another port on localhost — any other local
168
+ * web app — is same-site, cookies attach), and the WS handshake is exempt
169
+ * from CORS, so a foreign page that gets the cookie attached can read the
170
+ * stream. So the Origin header is checked explicitly, against the request's
171
+ * own origin (full scheme + authority: an http:// page on the same host must
172
+ * not drive the https:// dashboard) or the operator's allowlist. `Origin:
173
+ * null` and unparseable values are foreign. Verdicts are tri-state because
174
+ * absence means different things per call site: every current browser sends
175
+ * Origin on cross-site POSTs and every WS handshake, so absence means a
176
+ * non-browser client — which carries no ambient cookie and gets to decide
177
+ * per-endpoint below.
178
+ */
179
+ const originVerdict = (req) => {
180
+ const raw = req.headers.origin;
181
+ if (raw === void 0) return "absent";
182
+ let origin;
183
+ try {
184
+ origin = new URL(raw).origin;
185
+ } catch {
186
+ return "foreign";
187
+ }
188
+ if (allowedOrigins.has(origin)) return "ok";
189
+ const expected = expectedOrigin(req);
190
+ return expected !== null && origin === expected ? "ok" : "foreign";
191
+ };
192
+ const headerSecret = (req) => {
193
+ const key = req.headers["x-workerdeck-key"];
194
+ if (typeof key === "string" && key !== "") return key;
195
+ const authorization = req.headers.authorization;
196
+ if (typeof authorization === "string") {
197
+ const match = /^Bearer\s+(.+)$/i.exec(authorization);
198
+ if (match !== null) return match[1];
199
+ }
200
+ };
201
+ const openPrincipal = {
202
+ via: "open",
203
+ canManageProfiles: true
204
+ };
205
+ const authenticate = (req) => {
206
+ if (!enabled) return openPrincipal;
207
+ const provided = headerSecret(req);
208
+ if (provided !== void 0) return secretMatches(provided) ? {
209
+ via: "header",
210
+ canManageProfiles: true
211
+ } : null;
212
+ if (!hasSession(req)) return null;
213
+ const verdict = originVerdict(req);
214
+ if (verdict === "foreign") return null;
215
+ const upgrade = req.headers.upgrade;
216
+ const isUpgrade = typeof upgrade === "string" && upgrade.toLowerCase().includes("websocket");
217
+ const unsafe = !SAFE_METHODS.has((req.method ?? "GET").toUpperCase());
218
+ if ((isUpgrade || unsafe) && verdict !== "ok") return null;
219
+ return {
220
+ via: "cookie",
221
+ canManageProfiles: true
222
+ };
223
+ };
224
+ /**
225
+ * Login throttle: the secret is the only factor and the endpoint is reachable
226
+ * by anyone who can reach the port, so guessing must be rate-limited. Failed
227
+ * attempts count per client IP inside a fixed window, with a global cap
228
+ * behind it so rotating IPs (trivial over IPv6) buys an attacker nothing.
229
+ * Only wrong secrets count — malformed requests and foreign-Origin posts are
230
+ * refused earlier precisely so a hostile page cannot burn a victim IP's
231
+ * budget cross-site. The global cap also bounds this map's size: expired
232
+ * entries are swept once it grows past a nominal size.
233
+ */
234
+ const failures = /* @__PURE__ */ new Map();
235
+ const globalFailures = {
236
+ count: 0,
237
+ windowStart: 0
238
+ };
239
+ const clientIp = (req) => (trustProxy ? forwardedLast(req.headers["x-forwarded-for"]) : void 0) ?? req.socket.remoteAddress ?? "unknown";
240
+ const blockedMs = (entry, max, now) => entry !== void 0 && entry.count >= max && now - entry.windowStart < windowMs ? entry.windowStart + windowMs - now : 0;
241
+ const loginBlockedMs = (ip, now) => Math.max(blockedMs(failures.get(ip), maxFailuresPerIp, now), blockedMs(globalFailures, maxFailuresGlobal, now));
242
+ const recordFailure = (ip, now) => {
243
+ if (failures.size > 256) {
244
+ for (const [key, entry] of failures) if (now - entry.windowStart >= windowMs) failures.delete(key);
245
+ }
246
+ const entry = failures.get(ip);
247
+ if (entry === void 0 || now - entry.windowStart >= windowMs) failures.set(ip, {
248
+ count: 1,
249
+ windowStart: now
250
+ });
251
+ else entry.count += 1;
252
+ if (now - globalFailures.windowStart >= windowMs) {
253
+ globalFailures.count = 1;
254
+ globalFailures.windowStart = now;
255
+ } else globalFailures.count += 1;
256
+ };
257
+ const cookieAttributes = (req) => {
258
+ const attrs = [
259
+ "Path=/",
260
+ "HttpOnly",
261
+ "SameSite=Lax"
262
+ ];
263
+ if (isSecure(req)) attrs.push("Secure");
264
+ return attrs;
265
+ };
266
+ const setCookieValue = (token, req) => [
267
+ `${cookieName}=${token}`,
268
+ `Max-Age=${Math.ceil(ttlMs / 1e3)}`,
269
+ ...cookieAttributes(req)
270
+ ].join("; ");
271
+ const clearCookieValue = (req) => [
272
+ `${cookieName}=`,
273
+ "Max-Age=0",
274
+ ...cookieAttributes(req)
275
+ ].join("; ");
276
+ const respondJson = (res, status, body, headers) => {
277
+ res.writeHead(status, {
278
+ "content-type": "application/json",
279
+ "cache-control": "no-store",
280
+ ...headers
281
+ }).end(JSON.stringify(body));
282
+ };
283
+ const respondRedirect = (res, location, headers) => {
284
+ res.writeHead(303, {
285
+ location,
286
+ "cache-control": "no-store",
287
+ ...headers
288
+ }).end();
289
+ };
290
+ /** JSON responses when the client asks for them, 303 redirects otherwise —
291
+ * so a dependency-free `<form method="post">` login page works without JS,
292
+ * and a fetch()-based one gets real status codes. */
293
+ const wantsJson = (req) => (req.headers.accept ?? "").includes("application/json");
294
+ const readBody = (req, maxBytes) => new Promise((resolve) => {
295
+ const chunks = [];
296
+ let size = 0;
297
+ let settled = false;
298
+ const finish = (value) => {
299
+ if (!settled) {
300
+ settled = true;
301
+ resolve(value);
302
+ }
303
+ };
304
+ req.on("data", (chunk) => {
305
+ size += chunk.length;
306
+ if (size > maxBytes) {
307
+ finish(null);
308
+ return;
309
+ }
310
+ chunks.push(chunk);
311
+ });
312
+ req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
313
+ req.on("error", () => finish(null));
314
+ });
315
+ const handleLogin = async (req, res) => {
316
+ const json = wantsJson(req);
317
+ if (!enabled) {
318
+ respondJson(res, 409, { error: "auth is disabled: no secret is configured" });
319
+ return;
320
+ }
321
+ if (originVerdict(req) === "foreign") {
322
+ respondJson(res, 403, { error: "origin not allowed" });
323
+ return;
324
+ }
325
+ const ip = clientIp(req);
326
+ const blocked = loginBlockedMs(ip, Date.now());
327
+ if (blocked > 0) {
328
+ const retryAfter = String(Math.ceil(blocked / 1e3));
329
+ if (json) respondJson(res, 429, { error: "too many failed attempts" }, { "retry-after": retryAfter });
330
+ else respondRedirect(res, "/?auth=throttled", { "retry-after": retryAfter });
331
+ return;
332
+ }
333
+ const body = await readBody(req, MAX_LOGIN_BODY_BYTES);
334
+ if (body === null) {
335
+ respondJson(res, 413, { error: "body too large" });
336
+ res.once("finish", () => req.destroy());
337
+ return;
338
+ }
339
+ const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
340
+ let candidate;
341
+ if (contentType === "application/x-www-form-urlencoded") candidate = new URLSearchParams(body).get("secret");
342
+ else if (contentType === "application/json") try {
343
+ candidate = JSON.parse(body).secret;
344
+ } catch {
345
+ respondJson(res, 400, { error: "invalid body" });
346
+ return;
347
+ }
348
+ else {
349
+ respondJson(res, 415, { error: "expected application/x-www-form-urlencoded or application/json" });
350
+ return;
351
+ }
352
+ if (typeof candidate !== "string" || candidate === "") {
353
+ respondJson(res, 400, { error: "missing secret" });
354
+ return;
355
+ }
356
+ if (!secretMatches(candidate)) {
357
+ recordFailure(ip, Date.now());
358
+ if (json) respondJson(res, 401, { error: "invalid secret" });
359
+ else respondRedirect(res, "/?auth=failed");
360
+ return;
361
+ }
362
+ failures.delete(ip);
363
+ const cookie = setCookieValue(createSession(), req);
364
+ if (json) res.writeHead(204, {
365
+ "set-cookie": cookie,
366
+ "cache-control": "no-store"
367
+ }).end();
368
+ else respondRedirect(res, "/", { "set-cookie": cookie });
369
+ };
370
+ const handleLogout = (req, res) => {
371
+ const json = wantsJson(req);
372
+ if (enabled) {
373
+ if (originVerdict(req) === "foreign") {
374
+ respondJson(res, 403, { error: "origin not allowed" });
375
+ return;
376
+ }
377
+ const token = cookieToken(req);
378
+ if (token !== void 0 && token !== "") sessions.delete(tokenKey(token));
379
+ }
380
+ const cookie = clearCookieValue(req);
381
+ if (json) res.writeHead(204, {
382
+ "set-cookie": cookie,
383
+ "cache-control": "no-store"
384
+ }).end();
385
+ else respondRedirect(res, "/", { "set-cookie": cookie });
386
+ };
387
+ const handleAuthRoute = async (pathname, req, res) => {
388
+ if (pathname === "/auth/status") {
389
+ if (req.method !== "GET") respondJson(res, 405, { error: "method not allowed" }, { allow: "GET" });
390
+ else respondJson(res, 200, {
391
+ enabled,
392
+ authenticated: enabled ? hasSession(req) : true
393
+ });
394
+ return;
395
+ }
396
+ if (pathname === "/auth/login") {
397
+ if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
398
+ else await handleLogin(req, res);
399
+ return;
400
+ }
401
+ if (pathname === "/auth/logout") {
402
+ if (req.method !== "POST") respondJson(res, 405, { error: "method not allowed" }, { allow: "POST" });
403
+ else handleLogout(req, res);
404
+ return;
405
+ }
406
+ respondJson(res, 404, { error: "not found" });
407
+ };
408
+ const handleAuthRequest = (req, res) => {
409
+ let pathname;
410
+ try {
411
+ pathname = new URL(req.url ?? "/", "http://internal").pathname;
412
+ } catch {
413
+ return false;
414
+ }
415
+ if (pathname !== "/auth" && !pathname.startsWith("/auth/")) return false;
416
+ return handleAuthRoute(pathname, req, res).then(() => true);
417
+ };
418
+ const loginPage = (req) => {
419
+ let reason = null;
420
+ try {
421
+ reason = new URL(req.url ?? "/", "http://internal").searchParams.get("auth");
422
+ } catch {
423
+ reason = null;
424
+ }
425
+ return {
426
+ action: "/auth/login",
427
+ field: "secret",
428
+ error: reason === "failed" ? "Invalid access key. Try again." : reason === "throttled" ? "Too many failed attempts. Wait a few minutes, then try again." : void 0
429
+ };
430
+ };
431
+ return {
432
+ enabled,
433
+ authenticate,
434
+ handleAuthRequest,
435
+ hasValidSession: (req) => enabled ? hasSession(req) : true,
436
+ loginPage
437
+ };
438
+ }
439
+ //#endregion
440
+ //#region src/config.ts
441
+ /**
442
+ * The config surface has to be JavaScript, not JSON: the two options a real
443
+ * deployment always needs — `authenticate` and `buildRunnerConfig` — are
444
+ * functions. So the file default-exports `WorkerServerOptions` (optionally as a
445
+ * function, sync or async, for config that has to await something), and flags
446
+ * and env cover the cases that fit on a command line.
447
+ *
448
+ * Precedence, narrowest wins: flags > env > config file > defaults. A config
449
+ * file that sets `authenticate` itself opts out of the built-in shared-secret
450
+ * auth entirely — see `resolveInstanceConfig`.
451
+ */
452
+ const CONFIG_BASENAMES = [
453
+ "workerdeck.config.mjs",
454
+ "workerdeck.config.js",
455
+ "workerdeck.config.cjs"
456
+ ];
457
+ var ConfigError = class extends Error {};
458
+ function parsePort(raw, source) {
459
+ const port = Number(raw);
460
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError(`${source}: not a valid port: ${raw}`);
461
+ return port;
462
+ }
463
+ /**
464
+ * Hand-rolled rather than a dependency: the CLI's whole value is that `npx
465
+ * workerdeck` pulls down a small tree, and an arg parser is a hundred lines
466
+ * of it.
467
+ */
468
+ function parseArgs(argv) {
469
+ const flags = {
470
+ profiles: [],
471
+ cwdRoots: [],
472
+ allowedOrigins: [],
473
+ allowedHosts: [],
474
+ insecureHosts: []
475
+ };
476
+ const next = (i, name) => {
477
+ const value = argv[i + 1];
478
+ if (value === void 0 || value.startsWith("-")) throw new ConfigError(`${name} requires a value`);
479
+ return value;
480
+ };
481
+ for (let i = 0; i < argv.length; i++) {
482
+ const arg = argv[i];
483
+ switch (arg) {
484
+ case "-h":
485
+ case "--help":
486
+ flags.help = true;
487
+ break;
488
+ case "-v":
489
+ case "--version":
490
+ flags.version = true;
491
+ break;
492
+ case "-c":
493
+ case "--config":
494
+ flags.config = next(i, arg);
495
+ i++;
496
+ break;
497
+ case "-p":
498
+ case "--port":
499
+ flags.port = parsePort(next(i, arg), arg);
500
+ i++;
501
+ break;
502
+ case "--host":
503
+ flags.host = next(i, arg);
504
+ i++;
505
+ break;
506
+ case "--auth-key":
507
+ flags.authKey = next(i, arg);
508
+ i++;
509
+ break;
510
+ case "--profile": {
511
+ const raw = next(i, arg);
512
+ i++;
513
+ const eq = raw.indexOf("=");
514
+ if (eq <= 0) throw new ConfigError(`--profile expects name=dir, got: ${raw}`);
515
+ const name = raw.slice(0, eq);
516
+ const dir = raw.slice(eq + 1);
517
+ if (!dir) throw new ConfigError(`--profile ${name}= is missing a directory`);
518
+ flags.profiles.push({
519
+ name,
520
+ configDir: resolve(dir)
521
+ });
522
+ break;
523
+ }
524
+ case "--cwd-root":
525
+ flags.cwdRoots.push(resolve(next(i, arg)));
526
+ i++;
527
+ break;
528
+ case "--allowed-origin":
529
+ flags.allowedOrigins.push(next(i, arg));
530
+ i++;
531
+ break;
532
+ case "--allowed-host":
533
+ flags.allowedHosts.push(next(i, arg));
534
+ i++;
535
+ break;
536
+ case "--insecure-host":
537
+ flags.insecureHosts.push(next(i, arg));
538
+ i++;
539
+ break;
540
+ case "--trust-proxy":
541
+ flags.trustProxy = true;
542
+ break;
543
+ case "--state-dir":
544
+ flags.stateDir = resolve(next(i, arg));
545
+ i++;
546
+ break;
547
+ case "--no-parking-store":
548
+ flags.parking = false;
549
+ break;
550
+ case "--insecure":
551
+ flags.insecure = true;
552
+ break;
553
+ case "--open":
554
+ flags.open = true;
555
+ break;
556
+ default: throw new ConfigError(`unknown option: ${arg}`);
557
+ }
558
+ }
559
+ return flags;
560
+ }
561
+ /**
562
+ * Explicit `--config` must exist — a typo that silently starts a default
563
+ * instance is worse than a failure. An implicit one is looked up in cwd only:
564
+ * walking parent directories would make what a given command does depend on
565
+ * where it was run from.
566
+ */
567
+ async function loadConfigFile(explicit, cwd = process.cwd()) {
568
+ let path = null;
569
+ if (explicit) {
570
+ path = isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
571
+ if (!existsSync(path)) throw new ConfigError(`no config file at ${path}`);
572
+ } else path = CONFIG_BASENAMES.map((name) => join(cwd, name)).find((p) => existsSync(p)) ?? null;
573
+ if (!path) return {
574
+ path: null,
575
+ options: {}
576
+ };
577
+ let mod;
578
+ try {
579
+ mod = await import(pathToFileURL(path).href);
580
+ } catch (error) {
581
+ throw new ConfigError(`failed to load ${path}: ${error instanceof Error ? error.message : String(error)}`);
582
+ }
583
+ const exported = mod.default;
584
+ if (exported === void 0) throw new ConfigError(`${path} has no default export (expected WorkerServerOptions)`);
585
+ const options = typeof exported === "function" ? await exported() : exported;
586
+ if (typeof options !== "object" || options === null) throw new ConfigError(`${path} default export is not an options object`);
587
+ return {
588
+ path,
589
+ options
590
+ };
591
+ }
592
+ const LOOPBACK = new Set([
593
+ "127.0.0.1",
594
+ "::1",
595
+ "localhost",
596
+ "::ffff:127.0.0.1"
597
+ ]);
598
+ function isLoopback(host) {
599
+ return LOOPBACK.has(host);
600
+ }
601
+ /**
602
+ * Durable parking is on by default because this is a long-lived instance: a
603
+ * turnkey tool that silently drops parked work on every restart is the wrong
604
+ * default. The store writes whole transcripts in plaintext, so it goes beside
605
+ * the config file (or under the home directory) rather than anywhere temporary,
606
+ * and one directory serves exactly one instance — the store is single-process
607
+ * by design, which the single-port model already implies.
608
+ */
609
+ function defaultStateDir(configPath) {
610
+ return configPath ? join(dirname(configPath), ".workerdeck") : join(homedir(), ".workerdeck");
611
+ }
612
+ /** Hostname out of a Host header, minus the port and any IPv6 brackets. */
613
+ function hostnameOf(hostHeader) {
614
+ try {
615
+ return new URL(`http://${hostHeader}`).hostname.replace(/^\[|\]$/g, "").toLowerCase();
616
+ } catch {
617
+ return "";
618
+ }
619
+ }
620
+ /** 127.0.0.0/8, ::1, and the names that mean them. */
621
+ function isLoopbackHostname(hostname) {
622
+ if (LOOPBACK.has(hostname)) return true;
623
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
624
+ }
625
+ /**
626
+ * An `insecureHosts` entry names a host, never an endpoint: it is compared
627
+ * against the bind host and against Host headers, and both are portless by the
628
+ * time they are compared. An entry carrying a port would therefore never match
629
+ * anything — a gate that looks armed and is not — so it is rejected loudly, as
630
+ * is anything that does not parse as a host name or address. Bare IPv6 is
631
+ * bracketed before parsing (WHATWG URL requires that), and the result is
632
+ * lowercased to match `hostnameOf`'s normal form.
633
+ */
634
+ function normalizeInsecureHost(raw) {
635
+ const entry = raw.trim();
636
+ const looksLikeNamePort = /^[^:]+:\d+$/.test(entry);
637
+ const candidate = entry.includes(":") && !entry.startsWith("[") && !looksLikeNamePort ? `[${entry}]` : entry;
638
+ let url;
639
+ try {
640
+ url = new URL(`http://${candidate}`);
641
+ } catch {
642
+ throw new ConfigError(`not a host name or address in insecureHosts: ${JSON.stringify(raw)}`);
643
+ }
644
+ if (url.port !== "") throw new ConfigError(`insecureHosts entry ${JSON.stringify(raw)} carries a port — name the host alone; the bind host and the Host header are both compared portless`);
645
+ return url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
646
+ }
647
+ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.cwd()) {
648
+ const envPort = env.WORKERDECK_PORT ? parsePort(env.WORKERDECK_PORT, "WORKERDECK_PORT") : void 0;
649
+ const port = flags.port ?? envPort ?? loaded.options.port ?? 8787;
650
+ const host = flags.host ?? env.WORKERDECK_HOST ?? loaded.options.host ?? "127.0.0.1";
651
+ const authKey = flags.authKey || env.WORKERDECK_AUTH_KEY || loaded.options.auth?.secret || void 0;
652
+ const hostAuthenticates = typeof loaded.options.authenticate === "function";
653
+ const insecureHosts = new Set([...flags.insecureHosts, ...loaded.options.insecureHosts ?? []].map(normalizeInsecureHost));
654
+ /** The bind host in the same normal form the entries were put in. It never
655
+ * carries a port — that is a separate flag — so only brackets and case vary. */
656
+ const bindHost = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
657
+ const generateAuthKey = !authKey && !hostAuthenticates && !loaded.options.allowUnauthenticated && !isLoopback(host) && !flags.insecure && !insecureHosts.has(bindHost);
658
+ const stateDir = flags.parking === false || loaded.options.stateDir === null ? null : flags.stateDir ?? env.WORKERDECK_STATE_DIR ?? loaded.options.stateDir ?? defaultStateDir(loaded.path);
659
+ const envCwdRoots = env.WORKERDECK_CWD_ROOTS?.split(":").filter(Boolean).map((p) => resolve(cwd, p));
660
+ const cwdRoots = flags.cwdRoots.length ? flags.cwdRoots : envCwdRoots;
661
+ const auth = {
662
+ ...loaded.options.auth,
663
+ secret: authKey,
664
+ trustProxy: flags.trustProxy ?? loaded.options.auth?.trustProxy,
665
+ allowedOrigins: [...loaded.options.auth?.allowedOrigins ?? [], ...flags.allowedOrigins]
666
+ };
667
+ const allowedHosts = Boolean(authKey) || hostAuthenticates || generateAuthKey ? null : new Set([
668
+ ...LOOPBACK,
669
+ ...[...flags.allowedHosts, ...loaded.options.allowedHosts ?? []].map((name) => name.toLowerCase()),
670
+ ...insecureHosts
671
+ ]);
672
+ const { port: _p, host: _h, auth: _a, stateDir: _s, allowedHosts: _ah, insecureHosts: _ih, webRoot: _w, ...serverOptions } = loaded.options;
673
+ const options = { ...serverOptions };
674
+ if (cwdRoots?.length) options.allowedCwdRoots = cwdRoots;
675
+ if (flags.profiles.length) options.profiles = flags.profiles;
676
+ return {
677
+ port,
678
+ host,
679
+ authKey,
680
+ auth,
681
+ stateDir,
682
+ configPath: loaded.path,
683
+ hostAuthenticates,
684
+ generateAuthKey,
685
+ allowedHosts,
686
+ webRoot: loaded.options.webRoot,
687
+ open: flags.open ?? false,
688
+ options
689
+ };
690
+ }
691
+ //#endregion
692
+ //#region src/login-page.ts
693
+ const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({
694
+ "&": "&amp;",
695
+ "<": "&lt;",
696
+ ">": "&gt;",
697
+ "\"": "&quot;",
698
+ "'": "&#39;"
699
+ })[c] ?? c);
700
+ function renderLoginPage(options) {
701
+ const { action, field, error, redirectTo, redirectField } = options;
702
+ const hidden = redirectField && redirectTo ? `<input type="hidden" name="${escapeHtml(redirectField)}" value="${escapeHtml(redirectTo)}">` : "";
703
+ const alert = error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : "";
704
+ return `<!doctype html>
705
+ <html lang="en">
706
+ <head>
707
+ <meta charset="utf-8">
708
+ <meta name="viewport" content="width=device-width, initial-scale=1">
709
+ <meta name="robots" content="noindex">
710
+ <title>workerdeck</title>
711
+ <style>
712
+ :root {
713
+ color-scheme: light dark;
714
+ --bg: #fbfaf9; --fg: #1c1917; --muted: #78716c;
715
+ --card: #ffffff; --border: #e7e5e4; --accent: #c2410c; --error: #b91c1c;
716
+ }
717
+ @media (prefers-color-scheme: dark) {
718
+ :root {
719
+ --bg: #1c1917; --fg: #fafaf9; --muted: #a8a29e;
720
+ --card: #292524; --border: #44403c; --accent: #fb923c; --error: #fca5a5;
721
+ }
722
+ }
723
+ * { box-sizing: border-box; }
724
+ body {
725
+ margin: 0; min-height: 100dvh; display: grid; place-items: center; padding: 1.5rem;
726
+ background: var(--bg); color: var(--fg);
727
+ font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
728
+ }
729
+ form {
730
+ width: 100%; max-width: 22rem; background: var(--card);
731
+ border: 1px solid var(--border); border-radius: 12px; padding: 1.75rem;
732
+ }
733
+ h1 { margin: 0 0 .25rem; font-size: 1.05rem; letter-spacing: -0.01em; }
734
+ p.sub { margin: 0 0 1.25rem; color: var(--muted); font-size: .875rem; }
735
+ label { display: block; font-size: .8125rem; font-weight: 500; margin-bottom: .375rem; }
736
+ input[type=password] {
737
+ width: 100%; padding: .5rem .625rem; font: inherit; color: inherit;
738
+ background: var(--bg); border: 1px solid var(--border); border-radius: 7px;
739
+ }
740
+ input[type=password]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
741
+ button {
742
+ width: 100%; margin-top: 1rem; padding: .5rem .75rem; font: inherit; font-weight: 500;
743
+ color: var(--bg); background: var(--fg); border: 0; border-radius: 7px; cursor: pointer;
744
+ }
745
+ button:hover { opacity: .9; }
746
+ .error { margin: 0 0 1rem; color: var(--error); font-size: .8125rem; }
747
+ </style>
748
+ </head>
749
+ <body>
750
+ <form method="post" action="${escapeHtml(action)}">
751
+ <h1>workerdeck</h1>
752
+ <p class="sub">This instance is protected. Enter its access key to continue.</p>
753
+ ${alert}
754
+ <label for="key">Access key</label>
755
+ <input id="key" name="${escapeHtml(field)}" type="password" autocomplete="current-password"
756
+ autofocus required spellcheck="false">
757
+ ${hidden}
758
+ <button type="submit">Sign in</button>
759
+ </form>
760
+ </body>
761
+ </html>
762
+ `;
763
+ }
764
+ //#endregion
765
+ //#region src/static.ts
766
+ /**
767
+ * Static file serving for the bundled dashboard. Deliberately policy-free: what
768
+ * counts as a document, and whether an unauthenticated visitor gets the app or a
769
+ * login page, is decided by the caller (see `instance.ts`). This module only
770
+ * answers "is there such a file, and what headers does it want".
771
+ */
772
+ const CONTENT_TYPES = {
773
+ ".html": "text/html; charset=utf-8",
774
+ ".js": "text/javascript; charset=utf-8",
775
+ ".mjs": "text/javascript; charset=utf-8",
776
+ ".css": "text/css; charset=utf-8",
777
+ ".json": "application/json; charset=utf-8",
778
+ ".map": "application/json; charset=utf-8",
779
+ ".svg": "image/svg+xml",
780
+ ".png": "image/png",
781
+ ".jpg": "image/jpeg",
782
+ ".jpeg": "image/jpeg",
783
+ ".webp": "image/webp",
784
+ ".avif": "image/avif",
785
+ ".ico": "image/x-icon",
786
+ ".woff": "font/woff",
787
+ ".woff2": "font/woff2",
788
+ ".ttf": "font/ttf",
789
+ ".wasm": "application/wasm",
790
+ ".txt": "text/plain; charset=utf-8"
791
+ };
792
+ function contentTypeFor(pathname) {
793
+ const dot = pathname.lastIndexOf(".");
794
+ if (dot < 0) return "application/octet-stream";
795
+ return CONTENT_TYPES[pathname.slice(dot).toLowerCase()] ?? "application/octet-stream";
796
+ }
797
+ /** A request for a file rather than an app route: anything with a known extension. */
798
+ function looksLikeAsset(pathname) {
799
+ const dot = pathname.lastIndexOf(".");
800
+ if (dot < 0) return false;
801
+ return pathname.slice(dot).toLowerCase() in CONTENT_TYPES;
802
+ }
803
+ /**
804
+ * Resolve `pathname` inside `root`, or null if it escapes. Vite emits every
805
+ * asset under a content-hashed name, so the only paths that ever reach here are
806
+ * ones the app itself generated — but this server is reachable by anything that
807
+ * can open a socket, and `..` in a URL is the oldest trick there is.
808
+ */
809
+ function resolveWithinRoot(root, pathname) {
810
+ let decoded;
811
+ try {
812
+ decoded = decodeURIComponent(pathname);
813
+ } catch {
814
+ return null;
815
+ }
816
+ if (decoded.includes("\0")) return null;
817
+ const candidate = resolve(join(root, decoded));
818
+ const base = resolve(root);
819
+ if (candidate !== base && !candidate.startsWith(base + sep)) return null;
820
+ return candidate;
821
+ }
822
+ function sendHtml(req, res, status, html, cache) {
823
+ const body = Buffer.from(html, "utf8");
824
+ res.writeHead(status, {
825
+ "content-type": "text/html; charset=utf-8",
826
+ "content-length": body.byteLength,
827
+ "cache-control": cache,
828
+ "x-frame-options": "DENY",
829
+ "x-content-type-options": "nosniff",
830
+ "referrer-policy": "same-origin"
831
+ });
832
+ res.end(req.method === "HEAD" ? void 0 : body);
833
+ }
834
+ /**
835
+ * Stream a file out of `root`. `immutable` is the caller's call, because it is a
836
+ * promise about the URL, not the file: Vite's hashed assets can be cached
837
+ * forever, but index.html must be revalidated every time or a deployed update
838
+ * never reaches a browser that already has the old one.
839
+ */
840
+ async function serveFile(req, res, filePath, options = {}) {
841
+ if (req.method !== "GET" && req.method !== "HEAD") return "method-not-allowed";
842
+ let size;
843
+ try {
844
+ const info = await stat(filePath);
845
+ if (!info.isFile()) return "not-found";
846
+ size = info.size;
847
+ } catch {
848
+ return "not-found";
849
+ }
850
+ res.writeHead(200, {
851
+ "content-type": contentTypeFor(filePath),
852
+ "content-length": size,
853
+ "cache-control": options.immutable ? "public, max-age=31536000, immutable" : "no-cache, must-revalidate",
854
+ "x-content-type-options": "nosniff"
855
+ });
856
+ if (req.method === "HEAD") {
857
+ res.end();
858
+ return "served";
859
+ }
860
+ await new Promise((resolvePromise) => {
861
+ const stream = createReadStream(filePath);
862
+ stream.on("error", () => {
863
+ res.destroy();
864
+ resolvePromise();
865
+ });
866
+ stream.on("end", () => resolvePromise());
867
+ stream.pipe(res);
868
+ });
869
+ return "served";
870
+ }
871
+ //#endregion
872
+ //#region src/instance.ts
873
+ /**
874
+ * The dashboard comes from `@workerdeck/web`, which ships it prebuilt and
875
+ * exports the path to it. Depending on the package rather than vendoring a copy
876
+ * means one dashboard, versioned in lockstep with everything else.
877
+ *
878
+ * In a checkout that directory only exists once the app has been built — dev
879
+ * never builds — so the miss is worth a real message rather than a stack trace
880
+ * from the static host.
881
+ */
882
+ function resolveWebRoot() {
883
+ if (existsSync(join(dashboardDir, "index.html"))) return dashboardDir;
884
+ throw new Error(`no dashboard build at ${dashboardDir}\n in a checkout: pnpm --filter @workerdeck/web run build`);
885
+ }
886
+ /**
887
+ * The Host-header gate for an unauthenticated instance. `allowedHosts` is null
888
+ * whenever auth is on, and then this is the identity function — with a
889
+ * credential in play a rebound origin holds no cookie and fails `authenticate`
890
+ * anyway. Loopback *names* are what's checked, not the socket: the attacker in
891
+ * this scenario controls DNS, so the connection genuinely arrives on 127.0.0.1;
892
+ * what they cannot control is the name the victim's browser writes into Host.
893
+ */
894
+ function createHostGuard(allowedHosts) {
895
+ if (allowedHosts === null) return () => true;
896
+ return (req) => {
897
+ const header = req.headers.host;
898
+ if (header === void 0) return true;
899
+ const hostname = hostnameOf(header);
900
+ if (hostname === "") return false;
901
+ return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
902
+ };
903
+ }
904
+ /**
905
+ * Everything outside `/v1`. Order matters: the auth endpoints first (they are
906
+ * how a browser gets a session in the first place), then assets, which stay
907
+ * ungated — they are the app's own code, hold no secrets, and gating them would
908
+ * only mean the login page could not be styled by the app it gates. Documents
909
+ * come last, and that is the single place the auth decision is made.
910
+ */
911
+ function createFallback(auth, webRoot, hostAllowed) {
912
+ return async (req, res) => {
913
+ if (!hostAllowed(req)) {
914
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
915
+ res.end("unrecognised Host header.\n\nThis instance runs without auth, so it only answers to loopback host names and the ones it was told to expect. Declare this name with --insecure-host <name> (which also lets it be bound without a key) or --allowed-host <name>, or set --auth-key.\n");
916
+ return;
917
+ }
918
+ if (await auth.handleAuthRequest(req, res)) return;
919
+ const pathname = new URL(req.url ?? "/", "http://internal").pathname;
920
+ if (looksLikeAsset(pathname)) {
921
+ const filePath = resolveWithinRoot(webRoot, pathname);
922
+ if (!filePath) {
923
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
924
+ res.end("bad request");
925
+ return;
926
+ }
927
+ const result = await serveFile(req, res, filePath, { immutable: pathname.startsWith("/assets/") });
928
+ if (result === "served") return;
929
+ if (result === "method-not-allowed") {
930
+ res.writeHead(405, { allow: "GET, HEAD" });
931
+ res.end();
932
+ return;
933
+ }
934
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
935
+ res.end("not found");
936
+ return;
937
+ }
938
+ if (req.method !== "GET" && req.method !== "HEAD") {
939
+ res.writeHead(405, { allow: "GET, HEAD" });
940
+ res.end();
941
+ return;
942
+ }
943
+ if (auth.enabled && !auth.hasValidSession(req)) {
944
+ sendHtml(req, res, 401, renderLoginPage(auth.loginPage(req)), "no-store");
945
+ return;
946
+ }
947
+ if (await serveFile(req, res, join(webRoot, "index.html"), { immutable: false }) !== "served") {
948
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
949
+ res.end("dashboard build is missing its entry document");
950
+ }
951
+ };
952
+ }
953
+ async function startInstance(config, options = {}) {
954
+ const webRoot = config.webRoot ?? resolveWebRoot();
955
+ const generated = config.generateAuthKey && !config.hostAuthenticates ? await materializeAuthKey(config.stateDir) : null;
956
+ const auth = createCliAuth(config.hostAuthenticates ? {
957
+ ...config.auth,
958
+ secret: void 0
959
+ } : generated ? {
960
+ ...config.auth,
961
+ secret: generated.key
962
+ } : config.auth);
963
+ if (config.allowedHosts === null && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: the resolved config expects auth but no shared secret was materialized — this instance would be open while believing itself authenticated");
964
+ const hostAllowed = createHostGuard(config.allowedHosts);
965
+ const fallback = createFallback(auth, webRoot, hostAllowed);
966
+ const parking = { ...config.options.parking };
967
+ if (config.stateDir && !parking.store) parking.store = createFileSessionStore({
968
+ dir: join(config.stateDir, "parked"),
969
+ onError: (error, context) => {
970
+ process.stderr.write(`[workerdeck] parked-session store ${context.op} failed for ${context.path}: ${error instanceof Error ? error.message : String(error)}\n`);
971
+ }
972
+ });
973
+ const server = createWorkerServer({
974
+ ...config.options,
975
+ checkCredentials: config.options.checkCredentials ?? true,
976
+ parking,
977
+ fallback,
978
+ authenticate: config.hostAuthenticates ? (req) => hostAllowed(req) ? config.options.authenticate(req) : null : (req) => hostAllowed(req) ? auth.authenticate(req) : null
979
+ });
980
+ const { port } = await server.listen(config.port, config.host);
981
+ const displayHost = config.host === "0.0.0.0" || config.host === "::" ? "localhost" : config.host;
982
+ const url = `http://${displayHost.includes(":") ? `[${displayHost}]` : displayHost}:${port}`;
983
+ let resolveClosed = () => {};
984
+ const closed = new Promise((r) => {
985
+ resolveClosed = r;
986
+ });
987
+ if (!options.quiet) {
988
+ const line = (text) => void process.stdout.write(`${text}\n`);
989
+ line("");
990
+ line(` workerdeck ${url}`);
991
+ if (config.hostAuthenticates) line(" auth: the config file supplies its own `authenticate`");
992
+ else if (generated?.source === "created") {
993
+ line(` auth: generated key ${generated.key}`);
994
+ line(` stored in ${generated.path} — later starts reuse it without printing it`);
995
+ } else if (generated?.source === "ephemeral") {
996
+ line(` auth: generated key ${generated.key}`);
997
+ line(" ephemeral — no state dir to keep it, so the next start mints a new one");
998
+ } else if (generated?.source === "stored") line(` auth: shared key from ${generated.path}`);
999
+ else if (auth.enabled) line(" auth: shared key — browsers sign in, services send a header");
1000
+ else line(" NO AUTH — anyone who can reach this port gets a session");
1001
+ line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only — a restart drops them");
1002
+ if (config.configPath) line(` config ${config.configPath}`);
1003
+ line("");
1004
+ }
1005
+ return {
1006
+ server,
1007
+ url,
1008
+ port,
1009
+ closed,
1010
+ close: async () => {
1011
+ await server.close();
1012
+ resolveClosed();
1013
+ }
1014
+ };
1015
+ }
1016
+ //#endregion
1017
+ export { ConfigError as a, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, renderLoginPage as i, isLoopbackHostname as l, materializeAuthKey as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u };
1018
+
1019
+ //# sourceMappingURL=instance-kupxU5UD.mjs.map