workerdeck 0.13.0 → 0.16.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.
package/README.md CHANGED
@@ -30,6 +30,10 @@ The cookie is not a convenience. A browser cannot set a header on a WebSocket ha
30
30
  a cookie is the only credential a tab can present when it attaches to a session — and a cookie only
31
31
  rides requests to the origin that set it. That is why the dashboard and the API share a port.
32
32
 
33
+ Those logins persist in `<state-dir>/auth-sessions.json` (mode 600), so a restart doesn't sign
34
+ every open tab out. Each row is an HMAC of the cookie under the operator secret — so the file is
35
+ worth nothing on its own, and rotating the secret invalidates every outstanding login.
36
+
33
37
  Neither transport authenticates *who* the person is; the secret is a door key. Put an
34
38
  identity-aware proxy in front if you need more than that.
35
39
 
@@ -105,3 +109,17 @@ a normal dependency of this package. Point `webRoot` in the config file at your
105
109
  a fork instead.
106
110
 
107
111
  MIT
112
+
113
+ ## Rules you cannot infer from the types
114
+
115
+ - **Single origin is load-bearing.** The gateway and the dashboard share one port because a tab
116
+ cannot header a WebSocket handshake, so a cookie is the only credential an attach can carry —
117
+ and cookies are per-origin. This is why there is no "dashboard on another port" mode.
118
+ - **Loopback runs keyless; off loopback the CLI generates a key rather than serving open.** Only an
119
+ explicit `--insecure` / `insecureHosts` declaration serves unauthenticated, and those entries
120
+ double as accepted `Host` headers.
121
+ - **Browser logins are keyed by `HMAC(secret, token)`.** That is what makes the session file
122
+ worthless to a reader — and what makes rotating the auth key invalidate every cookie for free.
123
+ - **A config file supplying its own `authenticate` turns the built-in auth off entirely**, rather
124
+ than layering on top of it. Two auth systems agreeing is harder to reason about than one.
125
+
package/build/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as ConfigError, d as parseArgs, f as resolveInstanceConfig, r as startInstance, u as loadConfigFile } from "./instance-CS-1IaTm.mjs";
2
+ import { a as ConfigError, d as parseArgs, f as resolveInstanceConfig, r as startInstance, u as loadConfigFile } from "./instance-CJkPdp34.mjs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
@@ -99,7 +99,7 @@ function openInBrowser(url) {
99
99
  }
100
100
  async function main(argv) {
101
101
  if (argv[0] === "guard") {
102
- const { runGuard } = await import("./guard-D3VN855w.mjs").then((n) => n.t);
102
+ const { runGuard } = await import("./guard-DkovUj7o.mjs").then((n) => n.t);
103
103
  return await runGuard(argv.slice(1));
104
104
  }
105
105
  const flags = parseArgs(argv);
package/build/cli.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { spawn } from 'node:child_process'\nimport { readFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { ConfigError, loadConfigFile, parseArgs, resolveInstanceConfig } from './config.ts'\nimport { startInstance } from './instance.ts'\n\nconst HELP = `workerdeck — run a workerdeck instance: session gateway + dashboard, one port.\n\nUsage\n workerdeck [options]\n workerdeck guard [options] check whether it is safe to restart an instance\n\nOptions\n -p, --port <n> port to listen on (default 8787, WORKERDECK_PORT)\n --host <addr> interface to bind (default 127.0.0.1, WORKERDECK_HOST)\n --auth-key <secret> shared secret, min 12 chars; browsers log in with it,\n services send it as x-workerdeck-key\n (WORKERDECK_AUTH_KEY). Unset = no auth on loopback;\n on any other interface a key is generated instead,\n printed once, and stored in <state-dir>/auth-key for\n later starts to reuse.\n --trust-proxy trust x-forwarded-proto/-host/-for from one reverse\n proxy. Required behind TLS termination, or the session\n cookie loses its Secure flag and the origin check\n computes http:// where the browser says https://.\n --allowed-origin <o> extra origin accepted on browser requests, for when a\n proxy rewrites Host (repeatable)\n --allowed-host <name> extra Host header accepted when running without auth\n (repeatable; loopback names are always accepted)\n --insecure-host <name>\n bind host that may serve without auth — no key demanded,\n none generated — and, while unauthenticated, also\n accepted as a Host header (repeatable; config:\n insecureHosts). Names the host alone, no port.\n --profile <name=dir> Claude config dir a session may run under (repeatable)\n --cwd-root <path> restrict session cwds to this root (repeatable,\n WORKERDECK_CWD_ROOTS as a ':'-separated list)\n --fs-root <path> narrow which host directories /v1/fs serves\n (repeatable, WORKERDECK_FS_ROOTS as a ':'-separated\n list). Reading otherwise follows --cwd-root: a caller\n who may start a session in a tree can already read it\n through the agent. With neither, the routes 404.\n --fs-write also accept writes over /v1/fs. Its own switch because\n an agent's writes go through the permission flow and a\n PUT does not. Every write is still conditional on the\n hash the client last read.\n --state-dir <path> where parked sessions are persisted\n (default: beside the config file, else ~/.workerdeck)\n --no-parking-store keep parked sessions in memory only; a restart drops them\n -c, --config <path> config file (default: ./workerdeck.config.mjs)\n --insecure allow no-auth on a non-loopback address. Only when\n something in front is doing the authenticating.\n --cors-origin <o> browser origin allowed to call /v1 cross-origin, for a\n dashboard served elsewhere (repeatable, exact origin, no\n wildcard; config: corsOrigins). Refused without auth. The\n key is still required — this only permits the call.\n --no-web don't serve the web dashboard (config: web: false).\n /v1 and the auth routes are unchanged; everything else\n 404s. For a gateway reached only from the VS Code\n extension, the phone, or another host's dashboard.\n --open open the dashboard in a browser once it is up\n -h, --help show this\n -v, --version print the version\n\nConfig file\n Options that cannot fit on a command line — \\`authenticate\\`, \\`buildRunnerConfig\\`,\n \\`createEngineRunner\\` are functions — live in workerdeck.config.mjs, which\n default-exports the createWorkerServer options (or a function returning them).\n Flags and env override it. Supplying your own \\`authenticate\\` turns the built-in\n shared-secret auth off entirely.\n\nCredentials\n workerdeck implements no Anthropic auth of its own: the official SDK/CLI\n resolves credentials from the environment, per profile. --auth-key protects this\n gateway, nothing else.\n`\n\nasync function readVersion(): Promise<string> {\n // src/cli.ts and build/cli.mjs are both one level under the package root.\n const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')\n try {\n const raw = await readFile(pkgPath, 'utf8')\n return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'\n } catch {\n return 'unknown'\n }\n}\n\n/** Best-effort: a browser that won't open is a convenience missed, not a failure. */\nfunction openInBrowser(url: string): void {\n const command =\n process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'\n try {\n const child = spawn(command, [url], {\n stdio: 'ignore',\n detached: true,\n shell: process.platform === 'win32',\n })\n child.on('error', () => {})\n child.unref()\n } catch {\n // ignored\n }\n}\n\nasync function main(argv: string[]): Promise<number> {\n if (argv[0] === 'guard') {\n const { runGuard } = await import('./guard.ts')\n return await runGuard(argv.slice(1))\n }\n\n const flags = parseArgs(argv)\n if (flags.help) {\n process.stdout.write(HELP)\n return 0\n }\n if (flags.version) {\n process.stdout.write(`${await readVersion()}\\n`)\n return 0\n }\n\n const loaded = await loadConfigFile(flags.config)\n const config = resolveInstanceConfig(flags, loaded)\n const instance = await startInstance(config)\n\n if (config.open) openInBrowser(instance.url)\n\n const shutdown = (signal: string): void => {\n process.stdout.write(`\\n[workerdeck] ${signal} — shutting down\\n`)\n // Parked sessions are already on disk; this is about letting in-flight\n // requests finish rather than dropping sockets on the floor.\n instance\n .close()\n .then(() => process.exit(0))\n .catch(() => process.exit(1))\n }\n process.on('SIGINT', () => shutdown('SIGINT'))\n process.on('SIGTERM', () => shutdown('SIGTERM'))\n\n // Resolves only on close; the process stays up serving.\n await instance.closed\n return 0\n}\n\nmain(process.argv.slice(2))\n .then((code) => {\n if (code !== 0) process.exit(code)\n })\n .catch((error: unknown) => {\n if (error instanceof ConfigError) {\n process.stderr.write(`[workerdeck] ${error.message}\\n`)\n process.exit(2)\n }\n process.stderr.write(\n `[workerdeck] ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n )\n process.exit(1)\n })\n"],"mappings":";;;;;;;AAQA,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuEb,eAAe,cAA+B;CAE5C,MAAM,UAAU,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,EAAE,MAAM,eAAe;AACnF,KAAI;EACF,MAAM,MAAM,MAAM,SAAS,SAAS,OAAO;AAC3C,SAAQ,KAAK,MAAM,IAAI,CAA0B,WAAW;SACtD;AACN,SAAO;;;;AAKX,SAAS,cAAc,KAAmB;CACxC,MAAM,UACJ,QAAQ,aAAa,WAAW,SAAS,QAAQ,aAAa,UAAU,UAAU;AACpF,KAAI;EACF,MAAM,QAAQ,MAAM,SAAS,CAAC,IAAI,EAAE;GAClC,OAAO;GACP,UAAU;GACV,OAAO,QAAQ,aAAa;GAC7B,CAAC;AACF,QAAM,GAAG,eAAe,GAAG;AAC3B,QAAM,OAAO;SACP;;AAKV,eAAe,KAAK,MAAiC;AACnD,KAAI,KAAK,OAAO,SAAS;EACvB,MAAM,EAAE,aAAa,MAAM,OAAO,wBAAA,MAAA,MAAA,EAAA,EAAA;AAClC,SAAO,MAAM,SAAS,KAAK,MAAM,EAAE,CAAC;;CAGtC,MAAM,QAAQ,UAAU,KAAK;AAC7B,KAAI,MAAM,MAAM;AACd,UAAQ,OAAO,MAAM,KAAK;AAC1B,SAAO;;AAET,KAAI,MAAM,SAAS;AACjB,UAAQ,OAAO,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI;AAChD,SAAO;;CAIT,MAAM,SAAS,sBAAsB,OAAO,MADvB,eAAe,MAAM,OAAO,CACE;CACnD,MAAM,WAAW,MAAM,cAAc,OAAO;AAE5C,KAAI,OAAO,KAAM,eAAc,SAAS,IAAI;CAE5C,MAAM,YAAY,WAAyB;AACzC,UAAQ,OAAO,MAAM,kBAAkB,OAAO,oBAAoB;AAGlE,WACG,OAAO,CACP,WAAW,QAAQ,KAAK,EAAE,CAAC,CAC3B,YAAY,QAAQ,KAAK,EAAE,CAAC;;AAEjC,SAAQ,GAAG,gBAAgB,SAAS,SAAS,CAAC;AAC9C,SAAQ,GAAG,iBAAiB,SAAS,UAAU,CAAC;AAGhD,OAAM,SAAS;AACf,QAAO;;AAGT,KAAK,QAAQ,KAAK,MAAM,EAAE,CAAC,CACxB,MAAM,SAAS;AACd,KAAI,SAAS,EAAG,SAAQ,KAAK,KAAK;EAClC,CACD,OAAO,UAAmB;AACzB,KAAI,iBAAiB,aAAa;AAChC,UAAQ,OAAO,MAAM,gBAAgB,MAAM,QAAQ,IAAI;AACvD,UAAQ,KAAK,EAAE;;AAEjB,SAAQ,OAAO,MACb,gBAAgB,iBAAiB,QAAS,MAAM,SAAS,MAAM,UAAW,OAAO,MAAM,CAAC,IACzF;AACD,SAAQ,KAAK,EAAE;EACf"}
1
+ {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { spawn } from 'node:child_process'\nimport { readFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { ConfigError, loadConfigFile, parseArgs, resolveInstanceConfig } from './config.ts'\nimport { startInstance } from './lib/instance.ts'\n\nconst HELP = `workerdeck — run a workerdeck instance: session gateway + dashboard, one port.\n\nUsage\n workerdeck [options]\n workerdeck guard [options] check whether it is safe to restart an instance\n\nOptions\n -p, --port <n> port to listen on (default 8787, WORKERDECK_PORT)\n --host <addr> interface to bind (default 127.0.0.1, WORKERDECK_HOST)\n --auth-key <secret> shared secret, min 12 chars; browsers log in with it,\n services send it as x-workerdeck-key\n (WORKERDECK_AUTH_KEY). Unset = no auth on loopback;\n on any other interface a key is generated instead,\n printed once, and stored in <state-dir>/auth-key for\n later starts to reuse.\n --trust-proxy trust x-forwarded-proto/-host/-for from one reverse\n proxy. Required behind TLS termination, or the session\n cookie loses its Secure flag and the origin check\n computes http:// where the browser says https://.\n --allowed-origin <o> extra origin accepted on browser requests, for when a\n proxy rewrites Host (repeatable)\n --allowed-host <name> extra Host header accepted when running without auth\n (repeatable; loopback names are always accepted)\n --insecure-host <name>\n bind host that may serve without auth — no key demanded,\n none generated — and, while unauthenticated, also\n accepted as a Host header (repeatable; config:\n insecureHosts). Names the host alone, no port.\n --profile <name=dir> Claude config dir a session may run under (repeatable)\n --cwd-root <path> restrict session cwds to this root (repeatable,\n WORKERDECK_CWD_ROOTS as a ':'-separated list)\n --fs-root <path> narrow which host directories /v1/fs serves\n (repeatable, WORKERDECK_FS_ROOTS as a ':'-separated\n list). Reading otherwise follows --cwd-root: a caller\n who may start a session in a tree can already read it\n through the agent. With neither, the routes 404.\n --fs-write also accept writes over /v1/fs. Its own switch because\n an agent's writes go through the permission flow and a\n PUT does not. Every write is still conditional on the\n hash the client last read.\n --state-dir <path> where parked sessions are persisted\n (default: beside the config file, else ~/.workerdeck)\n --no-parking-store keep parked sessions in memory only; a restart drops them\n -c, --config <path> config file (default: ./workerdeck.config.mjs)\n --insecure allow no-auth on a non-loopback address. Only when\n something in front is doing the authenticating.\n --cors-origin <o> browser origin allowed to call /v1 cross-origin, for a\n dashboard served elsewhere (repeatable, exact origin, no\n wildcard; config: corsOrigins). Refused without auth. The\n key is still required — this only permits the call.\n --no-web don't serve the web dashboard (config: web: false).\n /v1 and the auth routes are unchanged; everything else\n 404s. For a gateway reached only from the VS Code\n extension, the phone, or another host's dashboard.\n --open open the dashboard in a browser once it is up\n -h, --help show this\n -v, --version print the version\n\nConfig file\n Options that cannot fit on a command line — \\`authenticate\\`, \\`buildRunnerConfig\\`,\n \\`createEngineRunner\\` are functions — live in workerdeck.config.mjs, which\n default-exports the createWorkerServer options (or a function returning them).\n Flags and env override it. Supplying your own \\`authenticate\\` turns the built-in\n shared-secret auth off entirely.\n\nCredentials\n workerdeck implements no Anthropic auth of its own: the official SDK/CLI\n resolves credentials from the environment, per profile. --auth-key protects this\n gateway, nothing else.\n`\n\nasync function readVersion(): Promise<string> {\n // src/cli.ts and build/cli.mjs are both one level under the package root.\n const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')\n try {\n const raw = await readFile(pkgPath, 'utf8')\n return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'\n } catch {\n return 'unknown'\n }\n}\n\n/** Best-effort: a browser that won't open is a convenience missed, not a failure. */\nfunction openInBrowser(url: string): void {\n const command =\n process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'\n try {\n const child = spawn(command, [url], {\n stdio: 'ignore',\n detached: true,\n shell: process.platform === 'win32',\n })\n child.on('error', () => {})\n child.unref()\n } catch {\n // ignored\n }\n}\n\nasync function main(argv: string[]): Promise<number> {\n if (argv[0] === 'guard') {\n const { runGuard } = await import('./lib/guard.ts')\n return await runGuard(argv.slice(1))\n }\n\n const flags = parseArgs(argv)\n if (flags.help) {\n process.stdout.write(HELP)\n return 0\n }\n if (flags.version) {\n process.stdout.write(`${await readVersion()}\\n`)\n return 0\n }\n\n const loaded = await loadConfigFile(flags.config)\n const config = resolveInstanceConfig(flags, loaded)\n const instance = await startInstance(config)\n\n if (config.open) openInBrowser(instance.url)\n\n const shutdown = (signal: string): void => {\n process.stdout.write(`\\n[workerdeck] ${signal} — shutting down\\n`)\n // Parked sessions are already on disk; this is about letting in-flight\n // requests finish rather than dropping sockets on the floor.\n instance\n .close()\n .then(() => process.exit(0))\n .catch(() => process.exit(1))\n }\n process.on('SIGINT', () => shutdown('SIGINT'))\n process.on('SIGTERM', () => shutdown('SIGTERM'))\n\n // Resolves only on close; the process stays up serving.\n await instance.closed\n return 0\n}\n\nmain(process.argv.slice(2))\n .then((code) => {\n if (code !== 0) process.exit(code)\n })\n .catch((error: unknown) => {\n if (error instanceof ConfigError) {\n process.stderr.write(`[workerdeck] ${error.message}\\n`)\n process.exit(2)\n }\n process.stderr.write(\n `[workerdeck] ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n )\n process.exit(1)\n })\n"],"mappings":";;;;;;;AAQA,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuEb,eAAe,cAA+B;CAE5C,MAAM,UAAU,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,EAAE,MAAM,eAAe;AACnF,KAAI;EACF,MAAM,MAAM,MAAM,SAAS,SAAS,OAAO;AAC3C,SAAQ,KAAK,MAAM,IAAI,CAA0B,WAAW;SACtD;AACN,SAAO;;;;AAKX,SAAS,cAAc,KAAmB;CACxC,MAAM,UACJ,QAAQ,aAAa,WAAW,SAAS,QAAQ,aAAa,UAAU,UAAU;AACpF,KAAI;EACF,MAAM,QAAQ,MAAM,SAAS,CAAC,IAAI,EAAE;GAClC,OAAO;GACP,UAAU;GACV,OAAO,QAAQ,aAAa;GAC7B,CAAC;AACF,QAAM,GAAG,eAAe,GAAG;AAC3B,QAAM,OAAO;SACP;;AAKV,eAAe,KAAK,MAAiC;AACnD,KAAI,KAAK,OAAO,SAAS;EACvB,MAAM,EAAE,aAAa,MAAM,OAAO,wBAAA,MAAA,MAAA,EAAA,EAAA;AAClC,SAAO,MAAM,SAAS,KAAK,MAAM,EAAE,CAAC;;CAGtC,MAAM,QAAQ,UAAU,KAAK;AAC7B,KAAI,MAAM,MAAM;AACd,UAAQ,OAAO,MAAM,KAAK;AAC1B,SAAO;;AAET,KAAI,MAAM,SAAS;AACjB,UAAQ,OAAO,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI;AAChD,SAAO;;CAIT,MAAM,SAAS,sBAAsB,OAAO,MADvB,eAAe,MAAM,OAAO,CACE;CACnD,MAAM,WAAW,MAAM,cAAc,OAAO;AAE5C,KAAI,OAAO,KAAM,eAAc,SAAS,IAAI;CAE5C,MAAM,YAAY,WAAyB;AACzC,UAAQ,OAAO,MAAM,kBAAkB,OAAO,oBAAoB;AAGlE,WACG,OAAO,CACP,WAAW,QAAQ,KAAK,EAAE,CAAC,CAC3B,YAAY,QAAQ,KAAK,EAAE,CAAC;;AAEjC,SAAQ,GAAG,gBAAgB,SAAS,SAAS,CAAC;AAC9C,SAAQ,GAAG,iBAAiB,SAAS,UAAU,CAAC;AAGhD,OAAM,SAAS;AACf,QAAO;;AAGT,KAAK,QAAQ,KAAK,MAAM,EAAE,CAAC,CACxB,MAAM,SAAS;AACd,KAAI,SAAS,EAAG,SAAQ,KAAK,KAAK;EAClC,CACD,OAAO,UAAmB;AACzB,KAAI,iBAAiB,aAAa;AAChC,UAAQ,OAAO,MAAM,gBAAgB,MAAM,QAAQ,IAAI;AACvD,UAAQ,KAAK,EAAE;;AAEjB,SAAQ,OAAO,MACb,gBAAgB,iBAAiB,QAAS,MAAM,SAAS,MAAM,UAAW,OAAO,MAAM,CAAC,IACzF;AACD,SAAQ,KAAK,EAAE;EACf"}
@@ -11,7 +11,7 @@ var __exportAll = (all, no_symbols) => {
11
11
  return target;
12
12
  };
13
13
  //#endregion
14
- //#region src/guard.ts
14
+ //#region src/lib/guard.ts
15
15
  var guard_exports = /* @__PURE__ */ __exportAll({ runGuard: () => runGuard });
16
16
  /**
17
17
  * Restart guard for a deploy: ask a running instance whether anything would be
@@ -238,4 +238,4 @@ async function runGuard(argv) {
238
238
  //#endregion
239
239
  export { runGuard as n, guard_exports as t };
240
240
 
241
- //# sourceMappingURL=guard-D3VN855w.mjs.map
241
+ //# sourceMappingURL=guard-DkovUj7o.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard-DkovUj7o.mjs","names":[],"sources":["../src/lib/guard.ts"],"sourcesContent":["import { parseArgs } from 'node:util'\n\n/**\n * Restart guard for a deploy: ask a running instance whether anything would be\n * lost by stopping it, and exit non-zero while the answer is yes.\n *\n * workerdeck guard --wait 300 --allow-parked && launchctl kickstart -k …\n *\n * What a restart costs, and why this is policy and not a server route: an\n * in-flight turn dies with the process (the CLI subprocess and the provider\n * request both go), a pending permission request dies with it, and a running job\n * is left claimed. Two more depend on configuration rather than on state, so each\n * has an opt-out flag that says \"I made that durable\":\n *\n * --allow-parked parked sessions survive with a durable SessionStore, not\n * with the in-memory default. Note this covers the *session*:\n * a parked job's queue-side record lives in the QueueAdapter,\n * so with the bundled in-memory adapter the woken session\n * completes with no job attached to finish.\n * --allow-queued queued jobs survive only in a durable QueueAdapter; the\n * bundled one loses them with the process.\n *\n * Exit codes: 0 safe to restart, 1 still busy, 2 could not tell (bad URL, auth,\n * or an unexpected response — never treated as safe).\n */\n\nconst BUSY_STATUSES = new Set(['starting', 'running', 'awaiting_approval'])\n\nconst HELP = `usage: workerdeck guard [--url URL] [--token TOKEN] [--header name=value]\n [--wait SECONDS] [--interval SECONDS]\n [--allow-parked] [--allow-queued] [--json]\n\nexit 0 = safe to restart, 1 = busy, 2 = could not tell\n`\n\ntype GetResult =\n | { ok: true; body: Record<string, unknown> }\n | { ok: false; status: 'unreachable' }\n | { ok: false; code?: number; detail: string }\n\ntype Verdict = {\n reasons: string[]\n notes: string[]\n unreachable?: boolean\n error?: string\n sessions?: number\n parked?: number\n}\n\nclass GuardError extends Error {}\n\nexport async function runGuard(argv: string[]): Promise<number> {\n let values: {\n url: string\n token?: string\n header: string[]\n wait: string\n interval: string\n 'allow-parked': boolean\n 'allow-queued': boolean\n json: boolean\n help: boolean\n }\n try {\n values = parseArgs({\n args: argv,\n options: {\n url: {\n type: 'string',\n default: process.env.WORKERDECK_URL ?? 'http://127.0.0.1:8787/v1',\n },\n token: { type: 'string', default: process.env.WORKERDECK_TOKEN },\n header: { type: 'string', multiple: true, default: [] },\n wait: { type: 'string', default: '0' },\n interval: { type: 'string', default: '5' },\n 'allow-parked': { type: 'boolean', default: false },\n 'allow-queued': { type: 'boolean', default: false },\n json: { type: 'boolean', default: false },\n help: { type: 'boolean', default: false },\n },\n }).values as typeof values\n } catch (error) {\n process.stderr.write(`guard: ${error instanceof Error ? error.message : String(error)}\\n`)\n return 2\n }\n\n if (values.help) {\n process.stdout.write(HELP)\n return 0\n }\n\n const base = values.url.replace(/\\/$/, '')\n const headers: Record<string, string> = { accept: 'application/json' }\n // The token covers the common `Authorization: Bearer` case, including this\n // CLI's own --auth-key; --header covers hosts whose `authenticate` hook reads\n // something else.\n if (values.token) headers.authorization = `Bearer ${values.token}`\n\n const seconds = (flag: string, raw: string): number => {\n const value = Number(raw)\n if (!Number.isFinite(value) || value < 0) {\n throw new GuardError(`${flag} must be a non-negative number of seconds`)\n }\n return value\n }\n\n let waitMs: number\n let intervalMs: number\n try {\n for (const entry of values.header) {\n const at = entry.indexOf('=')\n if (at < 1) throw new GuardError(`--header must be name=value, got '${entry}'`)\n headers[entry.slice(0, at).trim()] = entry.slice(at + 1).trim()\n }\n waitMs = seconds('--wait', values.wait) * 1000\n intervalMs = Math.max(seconds('--interval', values.interval), 1) * 1000\n } catch (error) {\n process.stderr.write(`guard: ${error instanceof Error ? error.message : String(error)}\\n`)\n return 2\n }\n\n const get = async (path: string): Promise<GetResult> => {\n let res: Response\n try {\n res = await fetch(base + path, { headers })\n } catch (error) {\n // Nothing listening on the URL we were pointed at: there is no session to lose.\n const code = (error as { cause?: { code?: string } })?.cause?.code\n if (code === 'ECONNREFUSED') return { ok: false, status: 'unreachable' }\n return { ok: false, detail: String(error instanceof Error ? error.message : error) }\n }\n if (!res.ok) return { ok: false, code: res.status, detail: `HTTP ${res.status}` }\n try {\n return { ok: true, body: (await res.json()) as Record<string, unknown> }\n } catch {\n return { ok: false, code: res.status, detail: 'response was not JSON' }\n }\n }\n\n /** One look at the server. Returns the reasons a restart would cost something. */\n const inspect = async (): Promise<Verdict> => {\n const sessions = await get('/sessions')\n if (!sessions.ok && 'status' in sessions) return { unreachable: true, reasons: [], notes: [] }\n if (!sessions.ok) return { error: `GET ${base}/sessions → ${sessions.detail}`, reasons: [], notes: [] }\n const listed = sessions.body.sessions\n if (!Array.isArray(listed)) {\n return { error: `GET ${base}/sessions returned no session list`, reasons: [], notes: [] }\n }\n const all = listed as { id: string; status: string }[]\n\n const reasons: string[] = []\n /** Worth saying out loud, but not worth blocking a deploy over. */\n const notes: string[] = []\n for (const session of all.filter((s) => BUSY_STATUSES.has(s.status))) {\n reasons.push(`session ${session.id} is ${session.status}`)\n }\n const parked = all.filter((s) => s.status === 'parked')\n if (parked.length > 0 && !values['allow-parked']) {\n reasons.push(\n `${parked.length} parked session(s) — pass --allow-parked once the server ` +\n 'runs a durable SessionStore, or they are lost on restart',\n )\n }\n\n // A queue is optional: 404 here means this server declares none.\n const queue = await get('/queue')\n if (!queue.ok && !('status' in queue) && queue.code !== 404) {\n return { error: `GET ${base}/queue → ${queue.detail}`, reasons: [], notes: [] }\n }\n const stats = queue.ok\n ? (queue.body.stats as { running?: number; queued?: number; parked?: number } | undefined)\n : undefined\n if ((stats?.running ?? 0) > 0) reasons.push(`${stats!.running} job(s) running`)\n if ((stats?.queued ?? 0) > 0 && !values['allow-queued']) {\n reasons.push(\n `${stats!.queued} job(s) queued — pass --allow-queued once the server runs a ` +\n 'durable QueueAdapter, or they are lost on restart',\n )\n }\n if ((stats?.parked ?? 0) > 0 && values['allow-parked']) {\n // Not a blocking reason: the operator has already said parks are durable.\n // But session durability is not job durability, and only one of the two is theirs.\n notes.push(\n `${stats!.parked} parked job(s): their queue-side records are the QueueAdapter's, ` +\n \"not the SessionStore's — with the in-memory adapter they never finish\",\n )\n }\n return { reasons, notes, sessions: all.length, parked: parked.length }\n }\n\n const report = (verdict: string, detail: Verdict): void => {\n if (values.json) {\n process.stdout.write(`${JSON.stringify({ verdict, ...detail })}\\n`)\n return\n }\n const lines = [\n ...detail.reasons.map((reason) => ` - ${reason}`),\n ...detail.notes.map((note) => ` note: ${note}`),\n ].join('\\n')\n process.stdout.write(`guard: ${verdict}${lines ? `\\n${lines}` : ''}\\n`)\n }\n\n const deadline = Date.now() + waitMs\n for (;;) {\n const result = await inspect()\n if (result.error) {\n report('unknown', { ...result, reasons: [result.error] })\n return 2\n }\n if (result.unreachable) {\n report('safe to restart (nothing listening on the given URL)', result)\n return 0\n }\n if (result.reasons.length === 0) {\n report('safe to restart', result)\n return 0\n }\n if (Date.now() + intervalMs > deadline) {\n report('busy — not safe to restart', result)\n return 1\n }\n report(`busy, waiting up to ${Math.round((deadline - Date.now()) / 1000)}s`, result)\n await new Promise((resolve) => setTimeout(resolve, intervalMs))\n }\n}\n"],"mappings":"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"}
package/build/index.d.mts CHANGED
@@ -69,7 +69,7 @@ options?: {
69
69
  hosts?: Record<ApnsEnvironment, string>;
70
70
  }): ApnsClient;
71
71
  //#endregion
72
- //#region src/auth.d.ts
72
+ //#region src/auth/auth.d.ts
73
73
  /**
74
74
  * Gateway auth for the turnkey CLI: one shared operator secret, three transports.
75
75
  *
@@ -117,9 +117,9 @@ type CliAuthOptions = {
117
117
  * Browser session lifetime, default 7 days. Fixed, not sliding: the auth
118
118
  * hooks only see the request, so a renewed cookie has nowhere to ride — and
119
119
  * for a dashboard whose whole login is retyping one secret, a periodic
120
- * re-login is cheaper than a refresh endpoint. Expiry (or a process restart —
121
- * sessions are in-memory by design) simply lands the operator back on the
122
- * login page.
120
+ * re-login is cheaper than a refresh endpoint. Expiry simply lands the
121
+ * operator back on the login page — as does a restart, unless a `sessions`
122
+ * store is supplied (the CLI supplies one whenever it has a state dir).
123
123
  */
124
124
  ttlMs?: number;
125
125
  /**
@@ -146,6 +146,27 @@ type CliAuthOptions = {
146
146
  maxFailuresPerIp?: number;
147
147
  maxFailuresGlobal?: number;
148
148
  };
149
+ /**
150
+ * Makes browser logins survive a restart. Absent, the session table is
151
+ * in-memory and a restart signs every browser out — see the table's own note.
152
+ * `createAuthSessionStore` is the CLI's file-backed implementation.
153
+ */
154
+ sessions?: CliSessionStore;
155
+ };
156
+ /** One session-table row as it is handed to a store; the key is opaque here. */
157
+ type StoredSession = {
158
+ expiresAt: number;
159
+ };
160
+ /**
161
+ * The durability seam for browser logins. Deliberately narrow and
162
+ * fire-and-forget: the auth paths are synchronous, so a store may not make them
163
+ * wait, and a store that cannot write must degrade to "logins do not survive a
164
+ * restart" rather than refuse a login.
165
+ */
166
+ type CliSessionStore = {
167
+ /** Rows recovered at startup, already pruned of expired ones. */initial?: Iterable<[string, StoredSession]>; /** The whole live table after a mutation. Must not throw. */
168
+ save(entries: [string, StoredSession][]): void; /** Resolves once queued writes have landed — for tests and shutdown. */
169
+ flush?(): Promise<void>;
149
170
  };
150
171
  /** What `authenticate` hands the worker server as the request principal. */
151
172
  type CliPrincipal = {
@@ -324,7 +345,7 @@ declare function hostnameOf(hostHeader: string): string;
324
345
  declare function isLoopbackHostname(hostname: string): boolean;
325
346
  declare function resolveInstanceConfig(flags: CliFlags, loaded: LoadedConfig, env?: NodeJS.ProcessEnv, cwd?: string): ResolvedConfig;
326
347
  //#endregion
327
- //#region src/instance.d.ts
348
+ //#region src/lib/instance.d.ts
328
349
  type Instance = {
329
350
  server: WorkerServer;
330
351
  url: string;
@@ -355,10 +376,10 @@ declare function startInstance(config: ResolvedConfig, options?: {
355
376
  quiet?: boolean;
356
377
  }): Promise<Instance>;
357
378
  //#endregion
358
- //#region src/guard.d.ts
379
+ //#region src/lib/guard.d.ts
359
380
  declare function runGuard(argv: string[]): Promise<number>;
360
381
  //#endregion
361
- //#region src/auth-key.d.ts
382
+ //#region src/auth/auth-key.d.ts
362
383
  /**
363
384
  * Materializes the key that `resolveInstanceConfig` promised via
364
385
  * `generateAuthKey`: resolution is pure and synchronous, reading a key file is
@@ -383,7 +404,15 @@ declare function materializeAuthKey(stateDir: string | null, options?: {
383
404
  warn?: (message: string) => void;
384
405
  }): Promise<MaterializedAuthKey>;
385
406
  //#endregion
386
- //#region src/login-page.d.ts
407
+ //#region src/auth/auth-sessions.d.ts
408
+ type AuthSessionStoreOptions = {
409
+ /** Where `<stateDir>/auth-sessions.json` lives. */stateDir: string; /** Defaults to a `[workerdeck]` line on stderr. */
410
+ warn?: (message: string) => void; /** Test seam; defaults to `Date.now`. */
411
+ now?: () => number;
412
+ };
413
+ declare function createAuthSessionStore(options: AuthSessionStoreOptions): Promise<CliSessionStore>;
414
+ //#endregion
415
+ //#region src/auth/login-page.d.ts
387
416
  /**
388
417
  * The login page is the CLI's, not the dashboard's. That split is deliberate:
389
418
  * the SPA ships prebuilt and is also served straight from vite in dev, so making
@@ -487,5 +516,5 @@ declare function createApnsForwarder(options: {
487
516
  warn?: (message: string) => void;
488
517
  }): Promise<ApnsForwarder>;
489
518
  //#endregion
490
- export { type ApnsClient, type ApnsConfig, type ApnsEnvironment, type ApnsForwarder, type ApnsRequest, type ApnsResult, type CliAuth, type CliAuthOptions, type CliFlags, type CliPrincipal, ConfigError, type DeviceRecord, type DeviceRegistry, type Instance, type LoadedConfig, type LoginPageOptions, type MaterializedAuthKey, type ResolvedConfig, type WorkerDeckConfig, buildPush, createApnsClient, createApnsForwarder, createCliAuth, createDeviceRegistry, createDeviceRoute, createHostGuard, defaultStateDir, hostnameOf, isLoopback, isLoopbackHostname, loadApnsKey, loadConfigFile, materializeAuthKey, parseArgs, renderLoginPage, resolveInstanceConfig, resolveWebRoot, runGuard, startInstance };
519
+ export { type ApnsClient, type ApnsConfig, type ApnsEnvironment, type ApnsForwarder, type ApnsRequest, type ApnsResult, type AuthSessionStoreOptions, type CliAuth, type CliAuthOptions, type CliFlags, type CliPrincipal, type CliSessionStore, ConfigError, type DeviceRecord, type DeviceRegistry, type Instance, type LoadedConfig, type LoginPageOptions, type MaterializedAuthKey, type ResolvedConfig, type StoredSession, type WorkerDeckConfig, buildPush, createApnsClient, createApnsForwarder, createAuthSessionStore, createCliAuth, createDeviceRegistry, createDeviceRoute, createHostGuard, defaultStateDir, hostnameOf, isLoopback, isLoopbackHostname, loadApnsKey, loadConfigFile, materializeAuthKey, parseArgs, renderLoginPage, resolveInstanceConfig, resolveWebRoot, runGuard, startInstance };
491
520
  //# sourceMappingURL=index.d.mts.map
package/build/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { n as runGuard } from "./guard-D3VN855w.mjs";
2
- import { _ as createDeviceRegistry, a as ConfigError, b as loadApnsKey, c as isLoopback, d as parseArgs, f as resolveInstanceConfig, g as createApnsForwarder, h as buildPush, i as renderLoginPage, l as isLoopbackHostname, m as materializeAuthKey, n as resolveWebRoot, o as defaultStateDir, p as createCliAuth, r as startInstance, s as hostnameOf, t as createHostGuard, u as loadConfigFile, v as createDeviceRoute, y as createApnsClient } from "./instance-CS-1IaTm.mjs";
3
- export { ConfigError, buildPush, createApnsClient, createApnsForwarder, createCliAuth, createDeviceRegistry, createDeviceRoute, createHostGuard, defaultStateDir, hostnameOf, isLoopback, isLoopbackHostname, loadApnsKey, loadConfigFile, materializeAuthKey, parseArgs, renderLoginPage, resolveInstanceConfig, resolveWebRoot, runGuard, startInstance };
1
+ import { n as runGuard } from "./guard-DkovUj7o.mjs";
2
+ import { _ as createApnsForwarder, a as ConfigError, b as createApnsClient, c as isLoopback, d as parseArgs, f as resolveInstanceConfig, g as buildPush, h as materializeAuthKey, i as renderLoginPage, l as isLoopbackHostname, m as createAuthSessionStore, n as resolveWebRoot, o as defaultStateDir, p as createCliAuth, r as startInstance, s as hostnameOf, t as createHostGuard, u as loadConfigFile, v as createDeviceRegistry, x as loadApnsKey, y as createDeviceRoute } from "./instance-CJkPdp34.mjs";
3
+ export { ConfigError, buildPush, createApnsClient, createApnsForwarder, createAuthSessionStore, createCliAuth, createDeviceRegistry, createDeviceRoute, createHostGuard, defaultStateDir, hostnameOf, isLoopback, isLoopbackHostname, loadApnsKey, loadConfigFile, materializeAuthKey, parseArgs, renderLoginPage, resolveInstanceConfig, resolveWebRoot, runGuard, startInstance };
@@ -2,8 +2,8 @@ import { createReadStream, existsSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
3
  import { createFileSessionStore, createWorkerServer } from "@workerdeck/server";
4
4
  import { dashboardDir } from "@workerdeck/web";
5
- import { createHash, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
6
- import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
+ import { createHash, createHmac, createPrivateKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
6
+ import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
7
7
  import { connect } from "node:http2";
8
8
  import { homedir } from "node:os";
9
9
  import { pathToFileURL } from "node:url";
@@ -512,7 +512,7 @@ async function createApnsForwarder(options) {
512
512
  };
513
513
  }
514
514
  //#endregion
515
- //#region src/auth-key.ts
515
+ //#region src/auth/auth-key.ts
516
516
  /** 48 hex chars — far past `createCliAuth`'s 12-char floor, and header-safe. */
517
517
  const generateKey = () => randomBytes(24).toString("hex");
518
518
  /** A stored key must be one printable-ASCII line long enough to be a secret:
@@ -564,7 +564,96 @@ async function materializeAuthKey(stateDir, options = {}) {
564
564
  };
565
565
  }
566
566
  //#endregion
567
- //#region src/auth.ts
567
+ //#region src/auth/auth-sessions.ts
568
+ /**
569
+ * Durable browser-login sessions for the turnkey CLI — the other half of
570
+ * `materializeAuthKey`, and here for the same reason: the key survives a
571
+ * restart so clients stay paired, and the login cookie should too. Without
572
+ * this the cookie outlives the table it points into, so a restart signs every
573
+ * browser out while the browser still holds a perfectly valid-looking cookie.
574
+ *
575
+ * What lands on disk is **not credential material**: `createCliAuth` keys its
576
+ * table by `HMAC-SHA256(secret, token)`, so a stolen file yields neither the
577
+ * operator secret nor any cookie value (inverting either needs a preimage of a
578
+ * 256-bit-entropy input). That keying is also what makes key rotation
579
+ * invalidate every outstanding cookie for free — entries written under the old
580
+ * secret simply never match a lookup again, and age out on their own expiry.
581
+ *
582
+ * Writes are whole-file and serialized behind one promise chain (the table is
583
+ * capped at `MAX_SESSIONS`, so "whole file" is a few kilobytes), and go through
584
+ * a temp file + rename so a crash mid-write cannot leave a truncated table.
585
+ * Every failure is a warning, never a throw: losing durability signs the
586
+ * operator out, losing the gateway does much worse.
587
+ */
588
+ const FORMAT_VERSION = 1;
589
+ const FILE_NAME = "auth-sessions.json";
590
+ const parseSessions = (raw, now) => {
591
+ let parsed;
592
+ try {
593
+ parsed = JSON.parse(raw);
594
+ } catch {
595
+ return [];
596
+ }
597
+ const file = parsed;
598
+ if (file?.version !== FORMAT_VERSION || !Array.isArray(file.sessions)) return [];
599
+ const entries = [];
600
+ for (const entry of file.sessions) {
601
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
602
+ const [key, expiresAt] = entry;
603
+ if (typeof key !== "string" || key === "") continue;
604
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= now) continue;
605
+ entries.push([key, { expiresAt }]);
606
+ }
607
+ return entries;
608
+ };
609
+ async function createAuthSessionStore(options) {
610
+ const now = options.now ?? Date.now;
611
+ const warn = options.warn ?? ((message) => process.stderr.write(`[workerdeck] ${message}\n`));
612
+ const path = join(options.stateDir, FILE_NAME);
613
+ let initial = [];
614
+ try {
615
+ initial = parseSessions(await readFile(path, "utf8"), now());
616
+ try {
617
+ const { mode } = await stat(path);
618
+ if ((mode & 63) !== 0) warn(`session file ${path} is readable by other users (mode ${(mode & 511).toString(8)}) — run: chmod 600 ${path}`);
619
+ } catch {}
620
+ } catch {
621
+ initial = [];
622
+ }
623
+ let pending = Promise.resolve();
624
+ let warned = false;
625
+ const write = async (entries) => {
626
+ const body = {
627
+ version: FORMAT_VERSION,
628
+ sessions: entries.map(([key, s]) => [key, s.expiresAt])
629
+ };
630
+ const tmp = `${path}.${process.pid}.tmp`;
631
+ try {
632
+ await mkdir(dirname(path), {
633
+ recursive: true,
634
+ mode: 448
635
+ });
636
+ await writeFile(tmp, `${JSON.stringify(body)}\n`, { mode: 384 });
637
+ await chmod(tmp, 384);
638
+ await rename(tmp, path);
639
+ warned = false;
640
+ } catch (error) {
641
+ if (!warned) {
642
+ warned = true;
643
+ warn(`could not persist browser logins to ${path}: ${error instanceof Error ? error.message : String(error)}`);
644
+ }
645
+ }
646
+ };
647
+ return {
648
+ initial,
649
+ save(entries) {
650
+ pending = pending.then(() => write(entries));
651
+ },
652
+ flush: () => pending
653
+ };
654
+ }
655
+ //#endregion
656
+ //#region src/auth/auth.ts
568
657
  const MIN_SECRET_LENGTH = 12;
569
658
  const DEFAULT_COOKIE_NAME = "workerdeck_session";
570
659
  const DEFAULT_TTL_MS = 10080 * 60 * 1e3;
@@ -609,13 +698,25 @@ function createCliAuth(options = {}) {
609
698
  * Browser sessions are a server-side table, not signed tokens: logout must
610
699
  * actually invalidate, and a stateless HMAC token stays valid until expiry no
611
700
  * matter what the server thinks. This is one long-lived process (multi-node
612
- * is a non-goal), so "table" means one Map — the cost is that a restart signs
613
- * every browser out, which for an operator dashboard one secret away from
614
- * re-login is the right trade. Keys are token digests: recovering an entry's
615
- * token from lookup timing would need a SHA-256 preimage.
701
+ * is a non-goal), so "table" means one Map — optionally mirrored to a
702
+ * `CliSessionStore` so a restart does not sign every browser out while the
703
+ * browser still holds a cookie the ttl says is good for a week.
704
+ *
705
+ * Keys are `HMAC-SHA256(secret, token)`, which buys three things at once:
706
+ * recovering a token from a key (or from lookup timing) needs a preimage;
707
+ * what a store writes to disk is not credential material; and rotating the
708
+ * operator secret invalidates every outstanding cookie **for free**, since
709
+ * rows written under the old secret can no longer be looked up and age out on
710
+ * their own expiry.
616
711
  */
617
712
  const sessions = /* @__PURE__ */ new Map();
618
- const tokenKey = (token) => sha256(token).toString("hex");
713
+ const store = options.sessions;
714
+ const tokenKey = (token) => secret === void 0 ? sha256(token).toString("hex") : createHmac("sha256", secret).update(token).digest("hex");
715
+ if (store?.initial !== void 0) for (const [key, entry] of store.initial) {
716
+ if (sessions.size >= MAX_SESSIONS) break;
717
+ sessions.set(key, { expiresAt: entry.expiresAt });
718
+ }
719
+ const persist = () => store?.save([...sessions]);
619
720
  const createSession = () => {
620
721
  if (sessions.size >= MAX_SESSIONS) {
621
722
  const oldest = sessions.keys().next().value;
@@ -623,6 +724,7 @@ function createCliAuth(options = {}) {
623
724
  }
624
725
  const token = randomBytes(32).toString("base64url");
625
726
  sessions.set(tokenKey(token), { expiresAt: Date.now() + ttlMs });
727
+ persist();
626
728
  return token;
627
729
  };
628
730
  const cookieToken = (req) => {
@@ -642,6 +744,7 @@ function createCliAuth(options = {}) {
642
744
  if (entry === void 0) return false;
643
745
  if (entry.expiresAt <= Date.now()) {
644
746
  sessions.delete(key);
747
+ persist();
645
748
  return false;
646
749
  }
647
750
  return true;
@@ -901,7 +1004,7 @@ function createCliAuth(options = {}) {
901
1004
  return;
902
1005
  }
903
1006
  const token = cookieToken(req);
904
- if (token !== void 0 && token !== "") sessions.delete(tokenKey(token));
1007
+ if (token !== void 0 && token !== "" && sessions.delete(tokenKey(token))) persist();
905
1008
  }
906
1009
  const cookie = clearCookieValue(req);
907
1010
  if (json) res.writeHead(204, {
@@ -1276,7 +1379,7 @@ function resolveApns(loaded) {
1276
1379
  };
1277
1380
  }
1278
1381
  //#endregion
1279
- //#region src/login-page.ts
1382
+ //#region src/auth/login-page.ts
1280
1383
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({
1281
1384
  "&": "&amp;",
1282
1385
  "<": "&lt;",
@@ -1349,7 +1452,7 @@ function renderLoginPage(options) {
1349
1452
  `;
1350
1453
  }
1351
1454
  //#endregion
1352
- //#region src/static.ts
1455
+ //#region src/lib/static.ts
1353
1456
  /**
1354
1457
  * Static file serving for the bundled dashboard. Deliberately policy-free: what
1355
1458
  * counts as a document, and whether an unauthenticated visitor gets the app or a
@@ -1456,7 +1559,7 @@ async function serveFile(req, res, filePath, options = {}) {
1456
1559
  return "served";
1457
1560
  }
1458
1561
  //#endregion
1459
- //#region src/instance.ts
1562
+ //#region src/lib/instance.ts
1460
1563
  /**
1461
1564
  * The dashboard comes from `@workerdeck/web`, which ships it prebuilt and
1462
1565
  * exports the path to it. Depending on the package rather than vendoring a copy
@@ -1551,13 +1654,18 @@ function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
1551
1654
  async function startInstance(config, options = {}) {
1552
1655
  const webRoot = config.web ? config.webRoot ?? resolveWebRoot() : void 0;
1553
1656
  const generated = config.generateAuthKey && !config.hostAuthenticates ? await materializeAuthKey(config.stateDir) : null;
1554
- const auth = createCliAuth(config.hostAuthenticates ? {
1657
+ const authOptions = config.hostAuthenticates ? {
1555
1658
  ...config.auth,
1556
1659
  secret: void 0
1557
1660
  } : generated ? {
1558
1661
  ...config.auth,
1559
1662
  secret: generated.key
1560
- } : config.auth);
1663
+ } : config.auth;
1664
+ const sessions = authOptions.secret !== void 0 && config.stateDir ? await createAuthSessionStore({ stateDir: config.stateDir }) : void 0;
1665
+ const auth = createCliAuth(sessions ? {
1666
+ ...authOptions,
1667
+ sessions
1668
+ } : authOptions);
1561
1669
  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");
1562
1670
  if (config.corsOrigins.length > 0 && !config.hostAuthenticates && !auth.enabled) throw new Error("refusing to serve: corsOrigins is set but this instance has no auth — a cross-origin page could drive it with no credential. Set --auth-key.");
1563
1671
  const hostAllowed = createHostGuard(config.allowedHosts);
@@ -1627,12 +1735,13 @@ async function startInstance(config, options = {}) {
1627
1735
  closed,
1628
1736
  close: async () => {
1629
1737
  await server.close();
1738
+ await sessions?.flush?.();
1630
1739
  apns?.close();
1631
1740
  resolveClosed();
1632
1741
  }
1633
1742
  };
1634
1743
  }
1635
1744
  //#endregion
1636
- export { createDeviceRegistry as _, ConfigError as a, loadApnsKey as b, isLoopback as c, parseArgs as d, resolveInstanceConfig as f, createApnsForwarder as g, buildPush as h, 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, createDeviceRoute as v, createApnsClient as y };
1745
+ 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 };
1637
1746
 
1638
- //# sourceMappingURL=instance-CS-1IaTm.mjs.map
1747
+ //# sourceMappingURL=instance-CJkPdp34.mjs.map