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 +18 -0
- package/build/cli.mjs +2 -2
- package/build/cli.mjs.map +1 -1
- package/build/{guard-D3VN855w.mjs → guard-DkovUj7o.mjs} +2 -2
- package/build/guard-DkovUj7o.mjs.map +1 -0
- package/build/index.d.mts +38 -9
- package/build/index.mjs +3 -3
- package/build/{instance-CS-1IaTm.mjs → instance-CJkPdp34.mjs} +126 -17
- package/build/instance-CJkPdp34.mjs.map +1 -0
- package/package.json +4 -4
- package/build/guard-D3VN855w.mjs.map +0 -1
- package/build/instance-CS-1IaTm.mjs.map +0 -1
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-
|
|
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-
|
|
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-
|
|
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
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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/
|
|
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-
|
|
2
|
-
import { _ as
|
|
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 —
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
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
|
|
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 !== ""
|
|
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
|
"&": "&",
|
|
1282
1385
|
"<": "<",
|
|
@@ -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
|
|
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 {
|
|
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-
|
|
1747
|
+
//# sourceMappingURL=instance-CJkPdp34.mjs.map
|