workerdeck 0.12.0 → 0.15.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-DbIqlX-e.mjs";
2
+ import { a as ConfigError, d as parseArgs, f as resolveInstanceConfig, r as startInstance, u as loadConfigFile } from "./instance-BiGOs_KV.mjs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
@@ -51,6 +51,14 @@ Options
51
51
  -c, --config <path> config file (default: ./workerdeck.config.mjs)
52
52
  --insecure allow no-auth on a non-loopback address. Only when
53
53
  something in front is doing the authenticating.
54
+ --cors-origin <o> browser origin allowed to call /v1 cross-origin, for a
55
+ dashboard served elsewhere (repeatable, exact origin, no
56
+ wildcard; config: corsOrigins). Refused without auth. The
57
+ key is still required — this only permits the call.
58
+ --no-web don't serve the web dashboard (config: web: false).
59
+ /v1 and the auth routes are unchanged; everything else
60
+ 404s. For a gateway reached only from the VS Code
61
+ extension, the phone, or another host's dashboard.
54
62
  --open open the dashboard in a browser once it is up
55
63
  -h, --help show this
56
64
  -v, --version print the version
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 --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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+Db,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 './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"}
package/build/index.d.mts CHANGED
@@ -71,11 +71,11 @@ options?: {
71
71
  //#endregion
72
72
  //#region src/auth.d.ts
73
73
  /**
74
- * Gateway auth for the turnkey CLI: one shared operator secret, two transports.
74
+ * Gateway auth for the turnkey CLI: one shared operator secret, three transports.
75
75
  *
76
76
  * Services present the secret itself on every request (`x-workerdeck-key`,
77
- * or `Authorization: Bearer`). The dashboard cannot: the SPA is prebuilt to
78
- * call `location.origin + '/v1'` with no headers, and a browser WebSocket
77
+ * or `Authorization: Bearer`). The dashboard *served by this gateway* cannot:
78
+ * it calls `location.origin + '/v1'` with no headers, and a browser WebSocket
79
79
  * handshake carries no custom headers at all. So browsers POST the secret once
80
80
  * to `/auth/login`, get an HttpOnly cookie naming a server-side session, and
81
81
  * the cookie rides same-origin REST and the WS upgrade automatically. That
@@ -83,6 +83,21 @@ options?: {
83
83
  * WS handshake is exempt from CORS, so cross-origin misuse is fenced off by an
84
84
  * explicit Origin check here — not by the browser.
85
85
  *
86
+ * The third transport exists for a dashboard served *elsewhere* attaching to
87
+ * this gateway: it holds the key (the operator typed it in) and can put it on
88
+ * REST as a header, but still cannot put it on the WS handshake — and the
89
+ * cookie is another origin's, so it does not ride either. Such a client passes
90
+ * the key as `?key=` on the upgrade URL, and `querySecret` below accepts it
91
+ * **on upgrades only**.
92
+ *
93
+ * That is a deliberate, narrow concession, and its cost should be understood
94
+ * rather than rediscovered: a key in a query string is a permanent, replayable
95
+ * credential sitting in reverse-proxy access logs, where a header would not be.
96
+ * It is confined to upgrades so a leaked URL buys an attach and nothing else,
97
+ * and the seam it arrives through (`ClientOptions.buildWsUrl`) is the same one
98
+ * a short-lived minted ticket would use — swapping to one later needs no client
99
+ * change.
100
+ *
86
101
  * This file guards the operator's own gateway and nothing else. It never sees
87
102
  * an Anthropic credential — those are resolved by the SDK/CLI from the
88
103
  * operator's environment (root CLAUDE.md, auth red lines).
@@ -102,9 +117,9 @@ type CliAuthOptions = {
102
117
  * Browser session lifetime, default 7 days. Fixed, not sliding: the auth
103
118
  * hooks only see the request, so a renewed cookie has nowhere to ride — and
104
119
  * for a dashboard whose whole login is retyping one secret, a periodic
105
- * re-login is cheaper than a refresh endpoint. Expiry (or a process restart —
106
- * sessions are in-memory by design) simply lands the operator back on the
107
- * 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).
108
123
  */
109
124
  ttlMs?: number;
110
125
  /**
@@ -131,6 +146,27 @@ type CliAuthOptions = {
131
146
  maxFailuresPerIp?: number;
132
147
  maxFailuresGlobal?: number;
133
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>;
134
170
  };
135
171
  /** What `authenticate` hands the worker server as the request principal. */
136
172
  type CliPrincipal = {
@@ -191,6 +227,25 @@ type WorkerDeckConfig = WorkerServerOptions & {
191
227
  */
192
228
  insecureHosts?: string[]; /** Serve a dashboard build from here instead of the bundled one. */
193
229
  webRoot?: string;
230
+ /**
231
+ * Serve the web dashboard at all. Default true — being turnkey is the point
232
+ * of this package.
233
+ *
234
+ * `false` makes the instance a bare gateway: `/v1` and the auth routes answer
235
+ * exactly as before, everything else 404s, and the dashboard build is never
236
+ * even looked for (so a checkout with no `@workerdeck/web` build can still
237
+ * run one). For an operator who reaches this gateway only from the VS Code
238
+ * extension, the phone, or another machine's dashboard, the served copy is
239
+ * surface they were not using.
240
+ */
241
+ web?: boolean;
242
+ /**
243
+ * Browser origins allowed to call this gateway cross-origin — for a dashboard
244
+ * served somewhere else. Exact origins (`https://deck.example`), never a
245
+ * wildcard. Refused unless auth is on: CORS on an open gateway would let any
246
+ * allowlisted page drive it with no credential at all.
247
+ */
248
+ corsOrigins?: string[];
194
249
  /**
195
250
  * Forward session notifications to Apple Push Notification service, for the
196
251
  * iOS app. Absent turns the forwarder off entirely — including its
@@ -219,6 +274,8 @@ type CliFlags = {
219
274
  stateDir?: string;
220
275
  parking?: boolean;
221
276
  insecure?: boolean;
277
+ web?: boolean;
278
+ corsOrigins: string[];
222
279
  open?: boolean;
223
280
  help?: boolean;
224
281
  version?: boolean;
@@ -264,7 +321,9 @@ type ResolvedConfig = {
264
321
  * unauthenticated instance — see `resolveInstanceConfig`.
265
322
  */
266
323
  allowedHosts: Set<string> | null; /** Dashboard build to serve; resolved from the package when unset. */
267
- webRoot?: string;
324
+ webRoot?: string; /** Whether to serve the dashboard at all. False makes this a bare gateway. */
325
+ web: boolean; /** Browser origins allowed to call this gateway cross-origin. Empty = off. */
326
+ corsOrigins: string[];
268
327
  /** APNs forwarder settings with `keyFile` made absolute, or undefined for an
269
328
  * instance that does not push. */
270
329
  apns?: ApnsConfig;
@@ -345,6 +404,14 @@ declare function materializeAuthKey(stateDir: string | null, options?: {
345
404
  warn?: (message: string) => void;
346
405
  }): Promise<MaterializedAuthKey>;
347
406
  //#endregion
407
+ //#region src/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
348
415
  //#region src/login-page.d.ts
349
416
  /**
350
417
  * The login page is the CLI's, not the dashboard's. That split is deliberate:
@@ -449,5 +516,5 @@ declare function createApnsForwarder(options: {
449
516
  warn?: (message: string) => void;
450
517
  }): Promise<ApnsForwarder>;
451
518
  //#endregion
452
- 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 };
453
520
  //# sourceMappingURL=index.d.mts.map
package/build/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
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-DbIqlX-e.mjs";
3
- export { ConfigError, buildPush, createApnsClient, createApnsForwarder, createCliAuth, createDeviceRegistry, createDeviceRoute, createHostGuard, defaultStateDir, hostnameOf, isLoopback, isLoopbackHostname, loadApnsKey, loadConfigFile, materializeAuthKey, parseArgs, renderLoginPage, resolveInstanceConfig, resolveWebRoot, runGuard, startInstance };
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-BiGOs_KV.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";
@@ -564,6 +564,95 @@ async function materializeAuthKey(stateDir, options = {}) {
564
564
  };
565
565
  }
566
566
  //#endregion
567
+ //#region src/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
567
656
  //#region src/auth.ts
568
657
  const MIN_SECRET_LENGTH = 12;
569
658
  const DEFAULT_COOKIE_NAME = "workerdeck_session";
@@ -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;
@@ -708,9 +811,30 @@ function createCliAuth(options = {}) {
708
811
  via: "open",
709
812
  canManageProfiles: true
710
813
  };
814
+ const isUpgradeRequest = (req) => {
815
+ const upgrade = req.headers.upgrade;
816
+ return typeof upgrade === "string" && upgrade.toLowerCase().includes("websocket");
817
+ };
818
+ /**
819
+ * The secret from `?key=` — **accepted on WebSocket upgrades only**.
820
+ *
821
+ * A browser cannot put a header on a WS handshake, so a tab attaching to a
822
+ * gateway that is not its own origin (where the cookie would ride) has no
823
+ * other way to present the key. That is the whole reason this exists.
824
+ *
825
+ * Restricting it to upgrades is what keeps the blast radius at "one attach":
826
+ * a key in a query string is not a transport we want anywhere else, because
827
+ * URLs land in proxy access logs and browser history in a way headers do not.
828
+ * A REST call with `?key=` is therefore *not* authenticated by it.
829
+ */
830
+ const querySecret = (req) => {
831
+ if (!isUpgradeRequest(req)) return void 0;
832
+ const key = new URL(req.url ?? "/", "http://internal").searchParams.get("key");
833
+ return key !== null && key !== "" ? key : void 0;
834
+ };
711
835
  const authenticate = (req) => {
712
836
  if (!enabled) return openPrincipal;
713
- const provided = headerSecret(req);
837
+ const provided = headerSecret(req) ?? querySecret(req);
714
838
  if (provided !== void 0) return secretMatches(provided) ? {
715
839
  via: "header",
716
840
  canManageProfiles: true
@@ -718,8 +842,7 @@ function createCliAuth(options = {}) {
718
842
  if (!hasSession(req)) return null;
719
843
  const verdict = originVerdict(req);
720
844
  if (verdict === "foreign") return null;
721
- const upgrade = req.headers.upgrade;
722
- const isUpgrade = typeof upgrade === "string" && upgrade.toLowerCase().includes("websocket");
845
+ const isUpgrade = isUpgradeRequest(req);
723
846
  const unsafe = !SAFE_METHODS.has((req.method ?? "GET").toUpperCase());
724
847
  if ((isUpgrade || unsafe) && verdict !== "ok") return null;
725
848
  return {
@@ -881,7 +1004,7 @@ function createCliAuth(options = {}) {
881
1004
  return;
882
1005
  }
883
1006
  const token = cookieToken(req);
884
- if (token !== void 0 && token !== "") sessions.delete(tokenKey(token));
1007
+ if (token !== void 0 && token !== "" && sessions.delete(tokenKey(token))) persist();
885
1008
  }
886
1009
  const cookie = clearCookieValue(req);
887
1010
  if (json) res.writeHead(204, {
@@ -978,7 +1101,8 @@ function parseArgs(argv) {
978
1101
  fsRoots: [],
979
1102
  allowedOrigins: [],
980
1103
  allowedHosts: [],
981
- insecureHosts: []
1104
+ insecureHosts: [],
1105
+ corsOrigins: []
982
1106
  };
983
1107
  const next = (i, name) => {
984
1108
  const value = argv[i + 1];
@@ -1064,6 +1188,13 @@ function parseArgs(argv) {
1064
1188
  case "--insecure":
1065
1189
  flags.insecure = true;
1066
1190
  break;
1191
+ case "--no-web":
1192
+ flags.web = false;
1193
+ break;
1194
+ case "--cors-origin":
1195
+ flags.corsOrigins.push(next(i, arg));
1196
+ i++;
1197
+ break;
1067
1198
  case "--open":
1068
1199
  flags.open = true;
1069
1200
  break;
@@ -1185,7 +1316,20 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
1185
1316
  ...[...flags.allowedHosts, ...loaded.options.allowedHosts ?? []].map((name) => name.toLowerCase()),
1186
1317
  ...insecureHosts
1187
1318
  ]);
1188
- const { port: _p, host: _h, auth: _a, stateDir: _s, allowedHosts: _ah, insecureHosts: _ih, webRoot: _w, apns: _ap, ...serverOptions } = loaded.options;
1319
+ const web = flags.web ?? loaded.options.web ?? true;
1320
+ const corsOrigins = flags.corsOrigins.length ? flags.corsOrigins : loaded.options.corsOrigins ?? [];
1321
+ for (const origin of corsOrigins) {
1322
+ if (origin === "*") throw new ConfigError("--cors-origin does not accept \"*\"");
1323
+ let parsed;
1324
+ try {
1325
+ parsed = new URL(origin);
1326
+ } catch {
1327
+ throw new ConfigError(`--cors-origin expects a full origin, got: ${origin}`);
1328
+ }
1329
+ if (parsed.origin !== origin) throw new ConfigError(`--cors-origin expects scheme://host[:port] with no path, got: ${origin}`);
1330
+ }
1331
+ if (!web && flags.open) throw new ConfigError("--open cannot be used with --no-web: there is no dashboard to open");
1332
+ const { port: _p, host: _h, auth: _a, stateDir: _s, allowedHosts: _ah, insecureHosts: _ih, webRoot: _w, web: _web, corsOrigins: _cors, apns: _ap, ...serverOptions } = loaded.options;
1189
1333
  const options = { ...serverOptions };
1190
1334
  if (cwdRoots?.length) options.allowedCwdRoots = cwdRoots;
1191
1335
  if (fsRoots?.length || flags.fsWrite) options.hostFiles = {
@@ -1205,6 +1349,8 @@ function resolveInstanceConfig(flags, loaded, env = process.env, cwd = process.c
1205
1349
  generateAuthKey,
1206
1350
  allowedHosts,
1207
1351
  webRoot: loaded.options.webRoot,
1352
+ web,
1353
+ corsOrigins,
1208
1354
  apns: resolveApns(loaded),
1209
1355
  open: flags.open ?? false,
1210
1356
  options
@@ -1466,6 +1612,11 @@ function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
1466
1612
  }
1467
1613
  if (await auth.handleAuthRequest(req, res)) return;
1468
1614
  if (apnsRoute !== void 0 && await apnsRoute(req, res)) return;
1615
+ if (webRoot === void 0) {
1616
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1617
+ res.end("the web dashboard is disabled on this gateway\n");
1618
+ return;
1619
+ }
1469
1620
  const pathname = new URL(req.url ?? "/", "http://internal").pathname;
1470
1621
  if (looksLikeAsset(pathname)) {
1471
1622
  const filePath = resolveWithinRoot(webRoot, pathname);
@@ -1501,16 +1652,22 @@ function createFallback(auth, webRoot, hostAllowed, apnsRoute) {
1501
1652
  };
1502
1653
  }
1503
1654
  async function startInstance(config, options = {}) {
1504
- const webRoot = config.webRoot ?? resolveWebRoot();
1655
+ const webRoot = config.web ? config.webRoot ?? resolveWebRoot() : void 0;
1505
1656
  const generated = config.generateAuthKey && !config.hostAuthenticates ? await materializeAuthKey(config.stateDir) : null;
1506
- const auth = createCliAuth(config.hostAuthenticates ? {
1657
+ const authOptions = config.hostAuthenticates ? {
1507
1658
  ...config.auth,
1508
1659
  secret: void 0
1509
1660
  } : generated ? {
1510
1661
  ...config.auth,
1511
1662
  secret: generated.key
1512
- } : 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);
1513
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");
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.");
1514
1671
  const hostAllowed = createHostGuard(config.allowedHosts);
1515
1672
  const apns = config.apns === void 0 ? void 0 : await createApnsForwarder({
1516
1673
  config: config.apns,
@@ -1537,6 +1694,7 @@ async function startInstance(config, options = {}) {
1537
1694
  }
1538
1695
  },
1539
1696
  fallback,
1697
+ ...config.corsOrigins.length ? { cors: { origins: config.corsOrigins } } : {},
1540
1698
  authenticate: config.hostAuthenticates ? (req) => hostAllowed(req) ? config.options.authenticate(req) : null : (req) => hostAllowed(req) ? auth.authenticate(req) : null
1541
1699
  });
1542
1700
  const { port } = await server.listen(config.port, config.host);
@@ -1560,6 +1718,8 @@ async function startInstance(config, options = {}) {
1560
1718
  } else if (generated?.source === "stored") line(` auth: shared key from ${generated.path}`);
1561
1719
  else if (auth.enabled) line(" auth: shared key — browsers sign in, services send a header");
1562
1720
  else line(" NO AUTH — anyone who can reach this port gets a session");
1721
+ if (!config.web) line(" dashboard: off — bare gateway, /v1 and /auth only");
1722
+ if (config.corsOrigins.length) line(` cors: ${config.corsOrigins.join(", ")} may call /v1 (still key-gated)`);
1563
1723
  line(config.stateDir ? ` parked sessions persist in ${join(config.stateDir, "parked")}` : " parked sessions are in memory only — a restart drops them");
1564
1724
  if (apns) {
1565
1725
  const count = apns.deviceCount();
@@ -1575,12 +1735,13 @@ async function startInstance(config, options = {}) {
1575
1735
  closed,
1576
1736
  close: async () => {
1577
1737
  await server.close();
1738
+ await sessions?.flush?.();
1578
1739
  apns?.close();
1579
1740
  resolveClosed();
1580
1741
  }
1581
1742
  };
1582
1743
  }
1583
1744
  //#endregion
1584
- 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 };
1585
1746
 
1586
- //# sourceMappingURL=instance-DbIqlX-e.mjs.map
1747
+ //# sourceMappingURL=instance-BiGOs_KV.mjs.map