workerdeck 0.23.0 → 1.0.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.
@@ -8,60 +8,32 @@ import { connect, constants } from "node:http2";
8
8
  import { homedir } from "node:os";
9
9
  import { pathToFileURL } from "node:url";
10
10
  //#region src/apns/client.ts
11
- /**
12
- * The environment is per *device token*, not per deployment, and that is the
13
- * single most expensive thing to get wrong here: a build run from Xcode gets a
14
- * sandbox token, a TestFlight build gets a production one, and the two
15
- * namespaces do not overlap. Same key, same phone, different token — cross them
16
- * and Apple answers `BadDeviceToken`. So the app declares which environment it
17
- * registered in and the forwarder routes each token to its own host.
18
- */
19
11
  const HOSTS = {
20
12
  development: "https://api.sandbox.push.apple.com",
21
13
  production: "https://api.push.apple.com"
22
14
  };
23
- /**
24
- * Apple rejects a provider token older than an hour, and rate-limits refreshing
25
- * one (`TooManyProviderTokenUpdates`) if you re-sign much more often than every
26
- * twenty minutes. Forty sits in the middle of that window with room for clock
27
- * skew at both ends — and re-signing per push, the obvious-looking thing, is
28
- * exactly what the rate limit exists to punish.
29
- */
30
15
  const TOKEN_TTL_MS = 2400 * 1e3;
31
16
  const REQUEST_TIMEOUT_MS = 1e4;
32
- /** Per-address budget for a dial. Node defaults this to 250ms, which is under
33
- * Apple's observed handshake on at least one real path; see the comment at the
34
- * `connect()` call. Generous rather than tuned — the cost of being slow to give
35
- * up on an address is a slower dial, and the cost of being quick is a lost
36
- * notification. */
37
17
  const DIAL_ATTEMPT_TIMEOUT_MS = 2e3;
38
- const base64url = (input) => Buffer.from(input).toString("base64url");
39
- /**
40
- * Load and sanity-check the auth key. Done once at startup rather than at the
41
- * first push, so a mistyped path is a launch error with a clear message instead
42
- * of a notification that silently never arrives.
43
- */
18
+ function base64url(input) {
19
+ return Buffer.from(input).toString("base64url");
20
+ }
44
21
  async function loadApnsKey(keyFile) {
45
22
  let pem;
46
23
  try {
47
24
  pem = await readFile(keyFile, "utf8");
48
25
  } catch (error) {
49
- throw new Error(`apns: cannot read the auth key at ${keyFile}: ${error instanceof Error ? error.message : String(error)}`);
26
+ throw new Error(`apns: cannot read the auth key at ${keyFile}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
50
27
  }
51
28
  let key;
52
29
  try {
53
30
  key = createPrivateKey(pem);
54
31
  } catch (error) {
55
- throw new Error(`apns: ${keyFile} is not a private key (${error instanceof Error ? error.message : String(error)})`);
32
+ throw new Error(`apns: ${keyFile} is not a private key (${error instanceof Error ? error.message : String(error)})`, { cause: error });
56
33
  }
57
34
  if (key.asymmetricKeyType !== "ec") throw new Error(`apns: ${keyFile} is a ${key.asymmetricKeyType ?? "unknown"} key, not EC — an APNs auth key is the .p8 downloaded from Keys in the developer portal, not a certificate`);
58
35
  return key;
59
36
  }
60
- /**
61
- * A cached provider JWT. The signature is over `{alg:ES256,kid}` + `{iss,iat}`,
62
- * and JWS wants the raw `r||s` pair — `sign()` produces a DER SEQUENCE unless
63
- * told otherwise, which Apple rejects with a bare 403 and no explanation.
64
- */
65
37
  function createProviderToken(key, keyId, teamId) {
66
38
  let cached = null;
67
39
  return {
@@ -89,12 +61,6 @@ function createProviderToken(key, keyId, teamId) {
89
61
  }
90
62
  };
91
63
  }
92
- /**
93
- * One long-lived HTTP/2 session per environment, reconnected on demand. APNs
94
- * sends GOAWAY routinely (it rebalances connections), so a dead session is a
95
- * normal event and not an error worth surfacing — the next push simply dials
96
- * again.
97
- */
98
64
  function createSessionPool(hosts) {
99
65
  const sessions = /* @__PURE__ */ new Map();
100
66
  const failures = /* @__PURE__ */ new Map();
@@ -132,21 +98,13 @@ function createSessionPool(hosts) {
132
98
  }
133
99
  };
134
100
  }
135
- /**
136
- * A stream that never left this machine is destroyed with
137
- * ERR_HTTP2_STREAM_CANCEL, which buries the real failure in `cause` — and when
138
- * every connect attempt fails (Happy Eyeballs walks both address families of
139
- * api.push.apple.com), that cause is an AggregateError whose own message is
140
- * EMPTY, so the text ends in "(caused by: )" and names nothing. Mine the
141
- * aggregate so the log says ECONNREFUSED/EHOSTUNREACH instead of nothing.
142
- */
143
- const describeStreamError = (error) => {
101
+ function describeStreamError(error) {
144
102
  const cause = error.cause;
145
103
  if (!(cause instanceof AggregateError)) return error.message;
146
104
  const parts = cause.errors.map((inner) => inner instanceof Error ? inner.message : String(inner)).filter((message) => message !== "");
147
105
  if (parts.length === 0) return error.message;
148
106
  return `${error.message.replace(" (caused by: )", "")} (caused by: ${parts.join("; ")})`;
149
- };
107
+ }
150
108
  function createApnsClient(config, key, options = {}) {
151
109
  const providerToken = createProviderToken(key, config.keyId, config.teamId);
152
110
  const pool = createSessionPool(options.hosts ?? HOSTS);
@@ -191,18 +149,11 @@ function createApnsClient(config, key, options = {}) {
191
149
  retry
192
150
  });
193
151
  };
194
- /** Classify a transport-level death. `pending` is true only while the
195
- * stream has no id — its HEADERS frame was never handed to nghttp2, so a
196
- * retry cannot duplicate anything. REFUSED_STREAM is Apple's explicit
197
- * "received but not processed", defined by the RFC as safe to retry. */
198
152
  const transportRetry = () => {
199
153
  if (stream.pending) return "redial";
200
154
  if (stream.rstCode === constants.NGHTTP2_REFUSED_STREAM) return "now";
201
155
  return "never";
202
156
  };
203
- /** A stream still pending when its attempt dies marks a connect that is
204
- * failing or hanging; without this, every later push (and the retry)
205
- * would queue behind the same doomed dial until the OS gave up on it. */
206
157
  const dropDoomedDial = () => {
207
158
  if (stream.pending && session.connecting) pool.discard(request.environment, session);
208
159
  };
@@ -285,18 +236,51 @@ function createApnsClient(config, key, options = {}) {
285
236
  };
286
237
  }
287
238
  //#endregion
239
+ //#region src/lib/http.ts
240
+ /**
241
+ * Read a request body, bounded. Resolves `null` if the body exceeds `maxBytes` or the socket
242
+ * errors — the caller answers 413 either way; it never resolves twice, and it never buffers
243
+ * past the cap, which is the whole point of not using a body parser here.
244
+ */
245
+ function readBody(req, maxBytes) {
246
+ return new Promise((resolve) => {
247
+ const chunks = [];
248
+ let size = 0;
249
+ let settled = false;
250
+ const finish = (value) => {
251
+ if (!settled) {
252
+ settled = true;
253
+ resolve(value);
254
+ }
255
+ };
256
+ req.on("data", (chunk) => {
257
+ size += chunk.length;
258
+ if (size > maxBytes) {
259
+ finish(null);
260
+ return;
261
+ }
262
+ chunks.push(chunk);
263
+ });
264
+ req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
265
+ req.on("error", () => finish(null));
266
+ });
267
+ }
268
+ /** A JSON answer that is never cached — these routes all carry auth state. */
269
+ function respondJson(res, status, body, headers) {
270
+ res.writeHead(status, {
271
+ "content-type": "application/json",
272
+ "cache-control": "no-store",
273
+ ...headers
274
+ }).end(JSON.stringify(body));
275
+ }
276
+ //#endregion
288
277
  //#region src/apns/devices.ts
289
278
  const FILENAME = "apns-devices.json";
290
- /** Apple's tokens are 64 hex chars today. The upper bound is generous because
291
- * Apple has reserved the right to grow them; the lower one just rejects junk. */
292
279
  const TOKEN_PATTERN = /^[0-9a-fA-F]{32,200}$/;
293
280
  const MAX_BODY_BYTES = 4096;
294
- const isEnvironment = (value) => value === "development" || value === "production";
295
- /**
296
- * `dir` null keeps the registry in memory: a restart then forgets every token,
297
- * which is survivable because the app re-registers on launch, but it does mean
298
- * an instance with no state dir goes quiet until each phone is next opened.
299
- */
281
+ function isEnvironment(value) {
282
+ return value === "development" || value === "production";
283
+ }
300
284
  async function createDeviceRegistry(options) {
301
285
  const path = options.dir === null ? null : join(options.dir, FILENAME);
302
286
  const devices = /* @__PURE__ */ new Map();
@@ -337,45 +321,6 @@ async function createDeviceRegistry(options) {
337
321
  }
338
322
  };
339
323
  }
340
- const respond = (res, status, body) => {
341
- res.writeHead(status, {
342
- "content-type": "application/json",
343
- "cache-control": "no-store"
344
- }).end(JSON.stringify(body));
345
- };
346
- const readBody = (req) => new Promise((resolve) => {
347
- const chunks = [];
348
- let size = 0;
349
- let settled = false;
350
- const finish = (value) => {
351
- if (!settled) {
352
- settled = true;
353
- resolve(value);
354
- }
355
- };
356
- req.on("data", (chunk) => {
357
- size += chunk.length;
358
- if (size > MAX_BODY_BYTES) {
359
- finish(null);
360
- return;
361
- }
362
- chunks.push(chunk);
363
- });
364
- req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
365
- req.on("error", () => finish(null));
366
- });
367
- /**
368
- * `POST /apns/devices` to register a token, `DELETE /apns/devices` to drop one.
369
- * Returns true when it consumed the request.
370
- *
371
- * Deliberately outside `/v1`: this is the forwarder's own surface, not part of
372
- * the protocol `packages/protocol` defines, and a client that finds a 404 here
373
- * has simply reached a gateway running without push configured.
374
- *
375
- * DELETE exists so removing a gateway from the app can stop its pushes. Without
376
- * it a forgotten server keeps buzzing a phone that no longer has any way to act
377
- * on what it says.
378
- */
379
324
  function createDeviceRoute(registry, authenticate) {
380
325
  return async (req, res) => {
381
326
  let pathname;
@@ -390,12 +335,12 @@ function createDeviceRoute(registry, authenticate) {
390
335
  return true;
391
336
  }
392
337
  if (authenticate(req) === null) {
393
- respond(res, 401, { error: "unauthorized" });
338
+ respondJson(res, 401, { error: "unauthorized" });
394
339
  return true;
395
340
  }
396
- const raw = await readBody(req);
341
+ const raw = await readBody(req, MAX_BODY_BYTES);
397
342
  if (raw === null) {
398
- respond(res, 413, { error: "body too large" });
343
+ respondJson(res, 413, { error: "body too large" });
399
344
  res.once("finish", () => req.destroy());
400
345
  return true;
401
346
  }
@@ -403,12 +348,12 @@ function createDeviceRoute(registry, authenticate) {
403
348
  try {
404
349
  body = JSON.parse(raw);
405
350
  } catch {
406
- respond(res, 400, { error: "invalid JSON body" });
351
+ respondJson(res, 400, { error: "invalid JSON body" });
407
352
  return true;
408
353
  }
409
354
  const token = body.token;
410
355
  if (typeof token !== "string" || !TOKEN_PATTERN.test(token)) {
411
- respond(res, 400, { error: "token must be a hex APNs device token" });
356
+ respondJson(res, 400, { error: "token must be a hex APNs device token" });
412
357
  return true;
413
358
  }
414
359
  if (req.method === "DELETE") {
@@ -417,7 +362,7 @@ function createDeviceRoute(registry, authenticate) {
417
362
  return true;
418
363
  }
419
364
  if (!isEnvironment(body.environment)) {
420
- respond(res, 400, { error: "environment must be 'development' or 'production'" });
365
+ respondJson(res, 400, { error: "environment must be 'development' or 'production'" });
421
366
  return true;
422
367
  }
423
368
  const optionalString = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
@@ -428,7 +373,7 @@ function createDeviceRoute(registry, authenticate) {
428
373
  bundleId: optionalString(body.bundleId),
429
374
  platform: optionalString(body.platform)
430
375
  });
431
- respond(res, 200, {
376
+ respondJson(res, 200, {
432
377
  registered: true,
433
378
  environment: body.environment
434
379
  });
@@ -437,42 +382,26 @@ function createDeviceRoute(registry, authenticate) {
437
382
  }
438
383
  //#endregion
439
384
  //#region src/apns/forwarder.ts
440
- /**
441
- * Turns the server's session notifications into APNs pushes.
442
- *
443
- * The architectural point, restated because it is easy to erode: session
444
- * webhooks are the primitive and this is one consumer of them. It hooks
445
- * `notifications.onNotification` in-process — same process as the gateway, so
446
- * there is no HTTP hop — but nothing about the server knows that, and the same
447
- * events can just as well drive Slack or a custom relay. Push credentials live
448
- * here and nowhere in `packages/server`.
449
- */
450
- /** APNs caps a payload at 4 KB. Staying well under leaves room for the alert
451
- * dictionary to grow without anyone rediscovering the limit the hard way. */
452
385
  const MAX_PAYLOAD_BYTES = 3800;
453
386
  const BODY_LIMIT = 300;
454
- /** Category identifiers are wire contract with the app: it registers the
455
- * Approve/Deny actions under this exact string, and a mismatch means a
456
- * notification that arrives with no buttons on it. */
457
387
  const CATEGORY = {
458
388
  permission: "PERMISSION_REQUEST",
459
389
  event: "SESSION_EVENT"
460
390
  };
461
- const label = (session) => {
391
+ function label(session) {
462
392
  const title = session.title?.trim();
463
393
  if (title !== void 0 && title !== "") return title;
464
394
  const leaf = session.cwd.split("/").filter(Boolean).at(-1);
465
395
  return leaf !== void 0 && leaf !== "" ? leaf : session.id;
466
- };
467
- const oneLine = (text, limit = BODY_LIMIT) => {
396
+ }
397
+ function oneLine(text, limit = BODY_LIMIT) {
468
398
  const flat = (text ?? "").replace(/\s+/g, " ").trim();
469
399
  return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
470
- };
471
- /** A collapse id is capped at 64 bytes and a session id has no such bound, so
472
- * hash rather than truncate — two sessions sharing a prefix must not collapse
473
- * into each other. */
474
- const collapseKey = (sessionId) => createHash("sha256").update(sessionId).digest("base64url").slice(0, 32);
475
- const titleFor = (notification, name) => {
400
+ }
401
+ function collapseKey(sessionId) {
402
+ return createHash("sha256").update(sessionId).digest("base64url").slice(0, 32);
403
+ }
404
+ function titleFor(notification, name) {
476
405
  switch (notification.type) {
477
406
  case "permission_requested": return `Approval needed — ${name}`;
478
407
  case "turn_completed": return notification.result?.isError === true ? `Turn failed — ${name}` : name;
@@ -480,8 +409,8 @@ const titleFor = (notification, name) => {
480
409
  case "session_closed": return `Session ended — ${name}`;
481
410
  default: return name;
482
411
  }
483
- };
484
- const bodyFor = (notification) => {
412
+ }
413
+ function bodyFor(notification) {
485
414
  const preview = oneLine(notification.preview);
486
415
  if (preview !== "") return preview;
487
416
  switch (notification.type) {
@@ -490,16 +419,7 @@ const bodyFor = (notification) => {
490
419
  case "session_closed": return `Closed by the ${notification.reason ?? "server"}.`;
491
420
  default: return "Something needs your attention.";
492
421
  }
493
- };
494
- /**
495
- * Build the push for one notification.
496
- *
497
- * The payload carries routing and nothing else — `sessionId` to deep-link,
498
- * `requestId` because a lock-screen Approve has nothing to POST to without it,
499
- * and `hostId` so a client with two gateways knows which one this came from.
500
- * Everything else the app fetches over REST the moment it opens; a transcript
501
- * has no business in a 4 KB envelope.
502
- */
422
+ }
503
423
  function buildPush(notification, hostId) {
504
424
  const permission = notification.type === "permission_requested";
505
425
  const name = label(notification.session);
@@ -542,9 +462,6 @@ async function createApnsForwarder(options) {
542
462
  });
543
463
  const handleRequest = createDeviceRoute(registry, options.authenticate);
544
464
  const fallbackEnvironment = options.config.production === false ? "development" : "production";
545
- /** Per-session delivery chain, so a session's pushes arrive in the order the
546
- * events happened — which matters precisely because `turn_completed` collapses
547
- * and an out-of-order pair would leave the older text on screen. */
548
465
  const chains = /* @__PURE__ */ new Map();
549
466
  const deliver = async (notification) => {
550
467
  const devices = registry.list();
@@ -581,15 +498,13 @@ async function createApnsForwarder(options) {
581
498
  }
582
499
  //#endregion
583
500
  //#region src/auth/auth-key.ts
584
- /** 48 hex chars — far past `createCliAuth`'s 12-char floor, and header-safe. */
585
- const generateKey = () => randomBytes(24).toString("hex");
586
- /** A stored key must be one printable-ASCII line long enough to be a secret:
587
- * both transports (HTTP header, login form) choke on anything else, and a
588
- * truncated or garbage file should regenerate, not crash or half-work. */
589
- const usableStoredKey = (raw) => {
501
+ function generateKey() {
502
+ return randomBytes(24).toString("hex");
503
+ }
504
+ function usableStoredKey(raw) {
590
505
  const line = raw.split("\n", 1)[0]?.trim() ?? "";
591
506
  return line.length >= 12 && /^[\x21-\x7e]+$/.test(line) ? line : null;
592
- };
507
+ }
593
508
  async function materializeAuthKey(stateDir, options = {}) {
594
509
  const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] ${message}\n`));
595
510
  if (stateDir === null) return {
@@ -633,29 +548,9 @@ async function materializeAuthKey(stateDir, options = {}) {
633
548
  }
634
549
  //#endregion
635
550
  //#region src/auth/auth-sessions.ts
636
- /**
637
- * Durable browser-login sessions for the turnkey CLI — the other half of
638
- * `materializeAuthKey`, and here for the same reason: the key survives a
639
- * restart so clients stay paired, and the login cookie should too. Without
640
- * this the cookie outlives the table it points into, so a restart signs every
641
- * browser out while the browser still holds a perfectly valid-looking cookie.
642
- *
643
- * What lands on disk is **not credential material**: `createCliAuth` keys its
644
- * table by `HMAC-SHA256(secret, token)`, so a stolen file yields neither the
645
- * operator secret nor any cookie value (inverting either needs a preimage of a
646
- * 256-bit-entropy input). That keying is also what makes key rotation
647
- * invalidate every outstanding cookie for free — entries written under the old
648
- * secret simply never match a lookup again, and age out on their own expiry.
649
- *
650
- * Writes are whole-file and serialized behind one promise chain (the table is
651
- * capped at `MAX_SESSIONS`, so "whole file" is a few kilobytes), and go through
652
- * a temp file + rename so a crash mid-write cannot leave a truncated table.
653
- * Every failure is a warning, never a throw: losing durability signs the
654
- * operator out, losing the gateway does much worse.
655
- */
656
551
  const FORMAT_VERSION = 1;
657
552
  const FILE_NAME = "auth-sessions.json";
658
- const parseSessions = (raw, now) => {
553
+ function parseSessions(raw, now) {
659
554
  let parsed;
660
555
  try {
661
556
  parsed = JSON.parse(raw);
@@ -673,7 +568,7 @@ const parseSessions = (raw, now) => {
673
568
  entries.push([key, { expiresAt }]);
674
569
  }
675
570
  return entries;
676
- };
571
+ }
677
572
  async function createAuthSessionStore(options) {
678
573
  const now = options.now ?? Date.now;
679
574
  const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] ${message}\n`));
@@ -728,8 +623,6 @@ const DEFAULT_TTL_MS = 10080 * 60 * 1e3;
728
623
  const DEFAULT_THROTTLE_WINDOW_MS = 900 * 1e3;
729
624
  const DEFAULT_MAX_FAILURES_PER_IP = 10;
730
625
  const DEFAULT_MAX_FAILURES_GLOBAL = 100;
731
- /** Only successful logins insert, so this cap only fences the secret-holder's
732
- * own memory use (every login within the ttl is a live entry). Oldest goes. */
733
626
  const MAX_SESSIONS = 100;
734
627
  const MAX_LOGIN_BODY_BYTES = 4096;
735
628
  const SAFE_METHODS = new Set([
@@ -737,7 +630,9 @@ const SAFE_METHODS = new Set([
737
630
  "HEAD",
738
631
  "OPTIONS"
739
632
  ]);
740
- const sha256 = (value) => createHash("sha256").update(value).digest();
633
+ function sha256(value) {
634
+ return createHash("sha256").update(value).digest();
635
+ }
741
636
  function createCliAuth(options = {}) {
742
637
  const { secret } = options;
743
638
  const enabled = secret !== void 0;
@@ -756,27 +651,8 @@ function createCliAuth(options = {}) {
756
651
  throw new Error(`createCliAuth: allowedOrigins entry is not a valid origin: ${JSON.stringify(entry)}`);
757
652
  }
758
653
  }));
759
- /** Both the raw secret and session tokens are compared as fixed-length SHA-256
760
- * digests via timingSafeEqual / digest-keyed lookup, so no code path compares
761
- * secret material byte-by-byte with early exit — and unequal input lengths
762
- * leak nothing either. */
763
654
  const secretDigest = secret === void 0 ? void 0 : sha256(secret);
764
655
  const secretMatches = (candidate) => secretDigest !== void 0 && timingSafeEqual(sha256(candidate), secretDigest);
765
- /**
766
- * Browser sessions are a server-side table, not signed tokens: logout must
767
- * actually invalidate, and a stateless HMAC token stays valid until expiry no
768
- * matter what the server thinks. This is one long-lived process (multi-node
769
- * is a non-goal), so "table" means one Map — optionally mirrored to a
770
- * `CliSessionStore` so a restart does not sign every browser out while the
771
- * browser still holds a cookie the ttl says is good for a week.
772
- *
773
- * Keys are `HMAC-SHA256(secret, token)`, which buys three things at once:
774
- * recovering a token from a key (or from lookup timing) needs a preimage;
775
- * what a store writes to disk is not credential material; and rotating the
776
- * operator secret invalidates every outstanding cookie **for free**, since
777
- * rows written under the old secret can no longer be looked up and age out on
778
- * their own expiry.
779
- */
780
656
  const sessions = /* @__PURE__ */ new Map();
781
657
  const store = options.sessions;
782
658
  const tokenKey = (token) => secret === void 0 ? sha256(token).toString("hex") : createHmac("sha256", secret).update(token).digest("hex");
@@ -797,7 +673,7 @@ function createCliAuth(options = {}) {
797
673
  };
798
674
  const cookieToken = (req) => {
799
675
  const header = req.headers.cookie;
800
- if (typeof header !== "string") return void 0;
676
+ if (typeof header !== "string") return;
801
677
  for (const part of header.split(";")) {
802
678
  const eq = part.indexOf("=");
803
679
  if (eq === -1) continue;
@@ -817,10 +693,8 @@ function createCliAuth(options = {}) {
817
693
  }
818
694
  return true;
819
695
  };
820
- /** Last value of a possibly comma-joined forwarded header — the one appended
821
- * (or set) by the trusted proxy; every earlier position is client-writable. */
822
696
  const forwardedLast = (value) => {
823
- if (value === void 0) return void 0;
697
+ if (value === void 0) return;
824
698
  const last = (Array.isArray(value) ? value.join(",") : value).split(",").at(-1)?.trim();
825
699
  return last === "" ? void 0 : last;
826
700
  };
@@ -828,8 +702,6 @@ function createCliAuth(options = {}) {
828
702
  if (req.socket.encrypted === true) return true;
829
703
  return trustProxy && forwardedLast(req.headers["x-forwarded-proto"])?.toLowerCase() === "https";
830
704
  };
831
- /** The origin this server believes it is being served as, from the request's
832
- * own Host (or the proxy's forwarded host) — the only self-knowledge we have. */
833
705
  const expectedOrigin = (req) => {
834
706
  const host = (trustProxy ? forwardedLast(req.headers["x-forwarded-host"]) : void 0) ?? req.headers.host;
835
707
  if (host === void 0 || host === "") return null;
@@ -839,20 +711,6 @@ function createCliAuth(options = {}) {
839
711
  return null;
840
712
  }
841
713
  };
842
- /**
843
- * The CSRF core. `SameSite=Lax` alone is not enough for two reasons: same
844
- * *site* is not same *origin* (another port on localhost — any other local
845
- * web app — is same-site, cookies attach), and the WS handshake is exempt
846
- * from CORS, so a foreign page that gets the cookie attached can read the
847
- * stream. So the Origin header is checked explicitly, against the request's
848
- * own origin (full scheme + authority: an http:// page on the same host must
849
- * not drive the https:// dashboard) or the operator's allowlist. `Origin:
850
- * null` and unparseable values are foreign. Verdicts are tri-state because
851
- * absence means different things per call site: every current browser sends
852
- * Origin on cross-site POSTs and every WS handshake, so absence means a
853
- * non-browser client — which carries no ambient cookie and gets to decide
854
- * per-endpoint below.
855
- */
856
714
  const originVerdict = (req) => {
857
715
  const raw = req.headers.origin;
858
716
  if (raw === void 0) return "absent";
@@ -883,20 +741,8 @@ function createCliAuth(options = {}) {
883
741
  const upgrade = req.headers.upgrade;
884
742
  return typeof upgrade === "string" && upgrade.toLowerCase().includes("websocket");
885
743
  };
886
- /**
887
- * The secret from `?key=` — **accepted on WebSocket upgrades only**.
888
- *
889
- * A browser cannot put a header on a WS handshake, so a tab attaching to a
890
- * gateway that is not its own origin (where the cookie would ride) has no
891
- * other way to present the key. That is the whole reason this exists.
892
- *
893
- * Restricting it to upgrades is what keeps the blast radius at "one attach":
894
- * a key in a query string is not a transport we want anywhere else, because
895
- * URLs land in proxy access logs and browser history in a way headers do not.
896
- * A REST call with `?key=` is therefore *not* authenticated by it.
897
- */
898
744
  const querySecret = (req) => {
899
- if (!isUpgradeRequest(req)) return void 0;
745
+ if (!isUpgradeRequest(req)) return;
900
746
  const key = new URL(req.url ?? "/", "http://internal").searchParams.get("key");
901
747
  return key !== null && key !== "" ? key : void 0;
902
748
  };
@@ -918,16 +764,6 @@ function createCliAuth(options = {}) {
918
764
  canManageProfiles: true
919
765
  };
920
766
  };
921
- /**
922
- * Login throttle: the secret is the only factor and the endpoint is reachable
923
- * by anyone who can reach the port, so guessing must be rate-limited. Failed
924
- * attempts count per client IP inside a fixed window, with a global cap
925
- * behind it so rotating IPs (trivial over IPv6) buys an attacker nothing.
926
- * Only wrong secrets count — malformed requests and foreign-Origin posts are
927
- * refused earlier precisely so a hostile page cannot burn a victim IP's
928
- * budget cross-site. The global cap also bounds this map's size: expired
929
- * entries are swept once it grows past a nominal size.
930
- */
931
767
  const failures = /* @__PURE__ */ new Map();
932
768
  const globalFailures = {
933
769
  count: 0,
@@ -970,13 +806,6 @@ function createCliAuth(options = {}) {
970
806
  "Max-Age=0",
971
807
  ...cookieAttributes(req)
972
808
  ].join("; ");
973
- const respondJson = (res, status, body, headers) => {
974
- res.writeHead(status, {
975
- "content-type": "application/json",
976
- "cache-control": "no-store",
977
- ...headers
978
- }).end(JSON.stringify(body));
979
- };
980
809
  const respondRedirect = (res, location, headers) => {
981
810
  res.writeHead(303, {
982
811
  location,
@@ -984,31 +813,7 @@ function createCliAuth(options = {}) {
984
813
  ...headers
985
814
  }).end();
986
815
  };
987
- /** JSON responses when the client asks for them, 303 redirects otherwise —
988
- * so a dependency-free `<form method="post">` login page works without JS,
989
- * and a fetch()-based one gets real status codes. */
990
816
  const wantsJson = (req) => (req.headers.accept ?? "").includes("application/json");
991
- const readBody = (req, maxBytes) => new Promise((resolve) => {
992
- const chunks = [];
993
- let size = 0;
994
- let settled = false;
995
- const finish = (value) => {
996
- if (!settled) {
997
- settled = true;
998
- resolve(value);
999
- }
1000
- };
1001
- req.on("data", (chunk) => {
1002
- size += chunk.length;
1003
- if (size > maxBytes) {
1004
- finish(null);
1005
- return;
1006
- }
1007
- chunks.push(chunk);
1008
- });
1009
- req.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
1010
- req.on("error", () => finish(null));
1011
- });
1012
817
  const handleLogin = async (req, res) => {
1013
818
  const json = wantsJson(req);
1014
819
  if (!enabled) {
@@ -1135,17 +940,6 @@ function createCliAuth(options = {}) {
1135
940
  }
1136
941
  //#endregion
1137
942
  //#region src/config.ts
1138
- /**
1139
- * The config surface has to be JavaScript, not JSON: the two options a real
1140
- * deployment always needs — `authenticate` and `buildRunnerConfig` — are
1141
- * functions. So the file default-exports `WorkerServerOptions` (optionally as a
1142
- * function, sync or async, for config that has to await something), and flags
1143
- * and env cover the cases that fit on a command line.
1144
- *
1145
- * Precedence, narrowest wins: flags > env > config file > defaults. A config
1146
- * file that sets `authenticate` itself opts out of the built-in shared-secret
1147
- * auth entirely — see `resolveInstanceConfig`.
1148
- */
1149
943
  const CONFIG_BASENAMES = [
1150
944
  "workerdeck.config.mjs",
1151
945
  "workerdeck.config.js",
@@ -1157,11 +951,6 @@ function parsePort(raw, source) {
1157
951
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError(`${source}: not a valid port: ${raw}`);
1158
952
  return port;
1159
953
  }
1160
- /**
1161
- * Hand-rolled rather than a dependency: the CLI's whole value is that `npx
1162
- * workerdeck` pulls down a small tree, and an arg parser is a hundred lines
1163
- * of it.
1164
- */
1165
954
  function parseArgs(argv) {
1166
955
  const flags = {
1167
956
  profiles: [],
@@ -1271,12 +1060,6 @@ function parseArgs(argv) {
1271
1060
  }
1272
1061
  return flags;
1273
1062
  }
1274
- /**
1275
- * Explicit `--config` must exist — a typo that silently starts a default
1276
- * instance is worse than a failure. An implicit one is looked up in cwd only:
1277
- * walking parent directories would make what a given command does depend on
1278
- * where it was run from.
1279
- */
1280
1063
  async function loadConfigFile(explicit, cwd = process.cwd()) {
1281
1064
  let path = null;
1282
1065
  if (explicit) {
@@ -1311,18 +1094,9 @@ const LOOPBACK = new Set([
1311
1094
  function isLoopback(host) {
1312
1095
  return LOOPBACK.has(host);
1313
1096
  }
1314
- /**
1315
- * Durable parking is on by default because this is a long-lived instance: a
1316
- * turnkey tool that silently drops parked work on every restart is the wrong
1317
- * default. The store writes whole transcripts in plaintext, so it goes beside
1318
- * the config file (or under the home directory) rather than anywhere temporary,
1319
- * and one directory serves exactly one instance — the store is single-process
1320
- * by design, which the single-port model already implies.
1321
- */
1322
1097
  function defaultStateDir(configPath) {
1323
1098
  return configPath ? join(dirname(configPath), ".workerdeck") : join(homedir(), ".workerdeck");
1324
1099
  }
1325
- /** Hostname out of a Host header, minus the port and any IPv6 brackets. */
1326
1100
  function hostnameOf(hostHeader) {
1327
1101
  try {
1328
1102
  return new URL(`http://${hostHeader}`).hostname.replace(/^\[|\]$/g, "").toLowerCase();
@@ -1330,20 +1104,10 @@ function hostnameOf(hostHeader) {
1330
1104
  return "";
1331
1105
  }
1332
1106
  }
1333
- /** 127.0.0.0/8, ::1, and the names that mean them. */
1334
1107
  function isLoopbackHostname(hostname) {
1335
1108
  if (LOOPBACK.has(hostname)) return true;
1336
1109
  return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
1337
1110
  }
1338
- /**
1339
- * An `insecureHosts` entry names a host, never an endpoint: it is compared
1340
- * against the bind host and against Host headers, and both are portless by the
1341
- * time they are compared. An entry carrying a port would therefore never match
1342
- * anything — a gate that looks armed and is not — so it is rejected loudly, as
1343
- * is anything that does not parse as a host name or address. Bare IPv6 is
1344
- * bracketed before parsing (WHATWG URL requires that), and the result is
1345
- * lowercased to match `hostnameOf`'s normal form.
1346
- */
1347
1111
  function normalizeInsecureHost(raw) {
1348
1112
  const entry = raw.trim();
1349
1113
  const looksLikeNamePort = /^[^:]+:\d+$/.test(entry);
@@ -1364,8 +1128,6 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
1364
1128
  const authKey = flags.authKey || env.WORKERDECK_AUTH_KEY || loaded.options.auth?.secret || void 0;
1365
1129
  const hostAuthenticates = typeof loaded.options.authenticate === "function";
1366
1130
  const insecureHosts = new Set([...flags.insecureHosts, ...loaded.options.insecureHosts ?? []].map(normalizeInsecureHost));
1367
- /** The bind host in the same normal form the entries were put in. It never
1368
- * carries a port — that is a separate flag — so only brackets and case vary. */
1369
1131
  const bindHost = host.trim().replace(/^\[|\]$/g, "").toLowerCase();
1370
1132
  const generateAuthKey = !authKey && !hostAuthenticates && !loaded.options.allowUnauthenticated && !isLoopback(host) && !flags.insecure && !insecureHosts.has(bindHost);
1371
1133
  const stateDir = flags.parking === false || loaded.options.stateDir === null ? null : flags.stateDir ?? env.WORKERDECK_STATE_DIR ?? loaded.options.stateDir ?? defaultStateDir(loaded.path);
@@ -1424,16 +1186,9 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
1424
1186
  options
1425
1187
  };
1426
1188
  }
1427
- /**
1428
- * A relative `keyFile` resolves against the config file's own directory, not the
1429
- * cwd — the same convention `defaultStateDir` uses, and the one that makes a
1430
- * deployment directory self-contained. Missing required fields throw here rather
1431
- * than at the first push, since a half-configured forwarder is a notification
1432
- * that silently never arrives.
1433
- */
1434
1189
  function resolveApns(loaded) {
1435
1190
  const apns = loaded.options.apns;
1436
- if (!apns) return void 0;
1191
+ if (!apns) return;
1437
1192
  for (const field of [
1438
1193
  "keyFile",
1439
1194
  "keyId",
@@ -1448,13 +1203,15 @@ function resolveApns(loaded) {
1448
1203
  }
1449
1204
  //#endregion
1450
1205
  //#region src/auth/login-page.ts
1451
- const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({
1452
- "&": "&amp;",
1453
- "<": "&lt;",
1454
- ">": "&gt;",
1455
- "\"": "&quot;",
1456
- "'": "&#39;"
1457
- })[c] ?? c);
1206
+ function escapeHtml(value) {
1207
+ return value.replace(/[&<>"']/g, (c) => ({
1208
+ "&": "&amp;",
1209
+ "<": "&lt;",
1210
+ ">": "&gt;",
1211
+ "\"": "&quot;",
1212
+ "'": "&#39;"
1213
+ })[c] ?? c);
1214
+ }
1458
1215
  function renderLoginPage(options) {
1459
1216
  const { action, field, error, redirectTo, redirectField } = options;
1460
1217
  const hidden = redirectField && redirectTo ? `<input type="hidden" name="${escapeHtml(redirectField)}" value="${escapeHtml(redirectTo)}">` : "";
@@ -1521,12 +1278,6 @@ function renderLoginPage(options) {
1521
1278
  }
1522
1279
  //#endregion
1523
1280
  //#region src/lib/static.ts
1524
- /**
1525
- * Static file serving for the bundled dashboard. Deliberately policy-free: what
1526
- * counts as a document, and whether an unauthenticated visitor gets the app or a
1527
- * login page, is decided by the caller (see `instance.ts`). This module only
1528
- * answers "is there such a file, and what headers does it want".
1529
- */
1530
1281
  const CONTENT_TYPES = {
1531
1282
  ".html": "text/html; charset=utf-8",
1532
1283
  ".js": "text/javascript; charset=utf-8",
@@ -1552,18 +1303,11 @@ function contentTypeFor(pathname) {
1552
1303
  if (dot < 0) return "application/octet-stream";
1553
1304
  return CONTENT_TYPES[pathname.slice(dot).toLowerCase()] ?? "application/octet-stream";
1554
1305
  }
1555
- /** A request for a file rather than an app route: anything with a known extension. */
1556
1306
  function looksLikeAsset(pathname) {
1557
1307
  const dot = pathname.lastIndexOf(".");
1558
1308
  if (dot < 0) return false;
1559
1309
  return pathname.slice(dot).toLowerCase() in CONTENT_TYPES;
1560
1310
  }
1561
- /**
1562
- * Resolve `pathname` inside `root`, or null if it escapes. Vite emits every
1563
- * asset under a content-hashed name, so the only paths that ever reach here are
1564
- * ones the app itself generated — but this server is reachable by anything that
1565
- * can open a socket, and `..` in a URL is the oldest trick there is.
1566
- */
1567
1311
  function resolveWithinRoot(root, pathname) {
1568
1312
  let decoded;
1569
1313
  try {
@@ -1589,12 +1333,6 @@ function sendHtml(req, res, status, html, cache) {
1589
1333
  });
1590
1334
  res.end(req.method === "HEAD" ? void 0 : body);
1591
1335
  }
1592
- /**
1593
- * Stream a file out of `root`. `immutable` is the caller's call, because it is a
1594
- * promise about the URL, not the file: Vite's hashed assets can be cached
1595
- * forever, but index.html must be revalidated every time or a deployed update
1596
- * never reaches a browser that already has the old one.
1597
- */
1598
1336
  async function serveFile(req, res, filePath, options = {}) {
1599
1337
  if (req.method !== "GET" && req.method !== "HEAD") return "method-not-allowed";
1600
1338
  let size;
@@ -1628,27 +1366,10 @@ async function serveFile(req, res, filePath, options = {}) {
1628
1366
  }
1629
1367
  //#endregion
1630
1368
  //#region src/lib/instance.ts
1631
- /**
1632
- * The dashboard comes from `@workerdeck/web`, which ships it prebuilt and
1633
- * exports the path to it. Depending on the package rather than vendoring a copy
1634
- * means one dashboard, versioned in lockstep with everything else.
1635
- *
1636
- * In a checkout that directory only exists once the app has been built — dev
1637
- * never builds — so the miss is worth a real message rather than a stack trace
1638
- * from the static host.
1639
- */
1640
1369
  function resolveWebRoot() {
1641
1370
  if (existsSync(join(dashboardDir, "index.html"))) return dashboardDir;
1642
1371
  throw new Error(`no dashboard build at ${dashboardDir}\n in a checkout: pnpm --filter @workerdeck/web run build`);
1643
1372
  }
1644
- /**
1645
- * The Host-header gate for an unauthenticated instance. `allowedHosts` is null
1646
- * whenever auth is on, and then this is the identity function — with a
1647
- * credential in play a rebound origin holds no cookie and fails `authenticate`
1648
- * anyway. Loopback *names* are what's checked, not the socket: the attacker in
1649
- * this scenario controls DNS, so the connection genuinely arrives on 127.0.0.1;
1650
- * what they cannot control is the name the victim's browser writes into Host.
1651
- */
1652
1373
  function createHostGuard(allowedHosts) {
1653
1374
  if (allowedHosts === null) return () => true;
1654
1375
  return (req) => {
@@ -1659,8 +1380,6 @@ function createHostGuard(allowedHosts) {
1659
1380
  return isLoopbackHostname(hostname) || allowedHosts.has(hostname);
1660
1381
  };
1661
1382
  }
1662
- /** The request path, or null when the target is malformed enough that `URL`
1663
- * refuses it — a caller matching a fixed route wants a miss, not a throw. */
1664
1383
  function pathnameOf(req) {
1665
1384
  try {
1666
1385
  return new URL(req.url ?? "/", "http://internal").pathname;
@@ -1668,27 +1387,6 @@ function pathnameOf(req) {
1668
1387
  return null;
1669
1388
  }
1670
1389
  }
1671
- /**
1672
- * Everything outside `/v1`. Order matters: the auth endpoints first (they are
1673
- * how a browser gets a session in the first place), then assets, which stay
1674
- * ungated — they are the app's own code, hold no secrets, and gating them would
1675
- * only mean the login page could not be styled by the app it gates. Documents
1676
- * come last, and that is the single place the auth decision is made.
1677
- *
1678
- * The APNs device route sits between auth and assets: it does its own
1679
- * authentication (header only — it is never called by a browser), and it must
1680
- * not fall through to the SPA's catch-all, which would answer a failed
1681
- * registration with a 200 and an HTML document.
1682
- *
1683
- * That last rule holds whether or not a forwarder is configured, which is why
1684
- * the path is claimed unconditionally below. With no `apns` config the route
1685
- * does not exist, and an unclaimed `/apns/devices` reaches the catch-all, where
1686
- * a registration POST draws a 405 (`GET, HEAD`) rather than the documented 404.
1687
- * A client cannot tell that from a broken gateway: the iOS app reads only 404
1688
- * as `unsupported`, so it throws instead, never marks the host synced, and
1689
- * retries on every foreground with a visible error — for what is the normal
1690
- * state of every gateway that never wanted push.
1691
- */
1692
1390
  function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
1693
1391
  return async (req, res) => {
1694
1392
  if (!hostAllowed(req)) {
@@ -1824,6 +1522,7 @@ async function startInstance(config, options = {}) {
1824
1522
  url,
1825
1523
  port,
1826
1524
  closed,
1525
+ drain: (drainOptions) => server.drain(drainOptions),
1827
1526
  close: async () => {
1828
1527
  await server.close();
1829
1528
  await sessions?.flush?.();
@@ -1835,4 +1534,4 @@ async function startInstance(config, options = {}) {
1835
1534
  //#endregion
1836
1535
  export { createApnsForwarder as _, ConfigError as a, createApnsClient as b, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, buildPush as g, materializeAuthKey as h, renderLoginPage as i, isLoopbackHostname as l, createAuthSessionStore as m, resolveWebRoot as n, defaultStateDir as o, createCliAuth as p, startInstance as r, hostnameOf as s, createHostGuard as t, loadConfigFile as u, createDeviceRegistry as v, loadApnsKey as x, createDeviceRoute as y };
1837
1536
 
1838
- //# sourceMappingURL=instance-iBl_fOW2.mjs.map
1537
+ //# sourceMappingURL=instance-5sdv2Rrw.mjs.map