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 +18 -0
- package/build/cli.mjs +9 -1
- package/build/cli.mjs.map +1 -1
- package/build/index.d.mts +75 -8
- package/build/index.mjs +2 -2
- package/build/{instance-DbIqlX-e.mjs → instance-BiGOs_KV.mjs} +179 -18
- package/build/instance-BiGOs_KV.mjs.map +1 -0
- package/package.json +4 -4
- package/build/instance-DbIqlX-e.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-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
|
|
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,
|
|
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
|
|
78
|
-
*
|
|
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
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
|
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 —
|
|
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;
|
|
@@ -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
|
|
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 !== ""
|
|
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
|
|
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
|
|
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 {
|
|
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-
|
|
1747
|
+
//# sourceMappingURL=instance-BiGOs_KV.mjs.map
|