workerdeck 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,8 @@ port. `--insecure` overrides that, for when something in front is doing the auth
45
45
  | `--host <addr>` | `WORKERDECK_HOST` | `127.0.0.1` |
46
46
  | `--auth-key <secret>` | `WORKERDECK_AUTH_KEY` | none (no auth) |
47
47
  | `--cwd-root <path>` (repeatable) | `WORKERDECK_CWD_ROOTS` (`:`-separated) | unrestricted |
48
+ | `--fs-root <path>` (repeatable) | `WORKERDECK_FS_ROOTS` (`:`-separated) | narrows `/v1/fs`; unset, reading follows `--cwd-root` |
49
+ | `--fs-write` | — | off (browse and read only) |
48
50
  | `--profile <name=dir>` (repeatable) | — | auto-detected from `~/.claude` |
49
51
  | `--state-dir <path>` | `WORKERDECK_STATE_DIR` | beside the config file, else `~/.workerdeck` |
50
52
  | `--no-parking-store` | — | durable parking on |
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-kupxU5UD.mjs";
2
+ import { a as ConfigError, d as parseArgs, f as resolveInstanceConfig, r as startInstance, u as loadConfigFile } from "./instance-DbIqlX-e.mjs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
@@ -36,6 +36,15 @@ Options
36
36
  --profile <name=dir> Claude config dir a session may run under (repeatable)
37
37
  --cwd-root <path> restrict session cwds to this root (repeatable,
38
38
  WORKERDECK_CWD_ROOTS as a ':'-separated list)
39
+ --fs-root <path> narrow which host directories /v1/fs serves
40
+ (repeatable, WORKERDECK_FS_ROOTS as a ':'-separated
41
+ list). Reading otherwise follows --cwd-root: a caller
42
+ who may start a session in a tree can already read it
43
+ through the agent. With neither, the routes 404.
44
+ --fs-write also accept writes over /v1/fs. Its own switch because
45
+ an agent's writes go through the permission flow and a
46
+ PUT does not. Every write is still conditional on the
47
+ hash the client last read.
39
48
  --state-dir <path> where parked sessions are persisted
40
49
  (default: beside the config file, else ~/.workerdeck)
41
50
  --no-parking-store keep parked sessions in memory only; a restart drops them
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 --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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDb,eAAe,cAA+B;CAE5C,MAAM,UAAU,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,EAAE,MAAM,eAAe;AACnF,KAAI;EACF,MAAM,MAAM,MAAM,SAAS,SAAS,OAAO;AAC3C,SAAQ,KAAK,MAAM,IAAI,CAA0B,WAAW;SACtD;AACN,SAAO;;;;AAKX,SAAS,cAAc,KAAmB;CACxC,MAAM,UACJ,QAAQ,aAAa,WAAW,SAAS,QAAQ,aAAa,UAAU,UAAU;AACpF,KAAI;EACF,MAAM,QAAQ,MAAM,SAAS,CAAC,IAAI,EAAE;GAClC,OAAO;GACP,UAAU;GACV,OAAO,QAAQ,aAAa;GAC7B,CAAC;AACF,QAAM,GAAG,eAAe,GAAG;AAC3B,QAAM,OAAO;SACP;;AAKV,eAAe,KAAK,MAAiC;AACnD,KAAI,KAAK,OAAO,SAAS;EACvB,MAAM,EAAE,aAAa,MAAM,OAAO,wBAAA,MAAA,MAAA,EAAA,EAAA;AAClC,SAAO,MAAM,SAAS,KAAK,MAAM,EAAE,CAAC;;CAGtC,MAAM,QAAQ,UAAU,KAAK;AAC7B,KAAI,MAAM,MAAM;AACd,UAAQ,OAAO,MAAM,KAAK;AAC1B,SAAO;;AAET,KAAI,MAAM,SAAS;AACjB,UAAQ,OAAO,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI;AAChD,SAAO;;CAIT,MAAM,SAAS,sBAAsB,OAAO,MADvB,eAAe,MAAM,OAAO,CACE;CACnD,MAAM,WAAW,MAAM,cAAc,OAAO;AAE5C,KAAI,OAAO,KAAM,eAAc,SAAS,IAAI;CAE5C,MAAM,YAAY,WAAyB;AACzC,UAAQ,OAAO,MAAM,kBAAkB,OAAO,oBAAoB;AAGlE,WACG,OAAO,CACP,WAAW,QAAQ,KAAK,EAAE,CAAC,CAC3B,YAAY,QAAQ,KAAK,EAAE,CAAC;;AAEjC,SAAQ,GAAG,gBAAgB,SAAS,SAAS,CAAC;AAC9C,SAAQ,GAAG,iBAAiB,SAAS,UAAU,CAAC;AAGhD,OAAM,SAAS;AACf,QAAO;;AAGT,KAAK,QAAQ,KAAK,MAAM,EAAE,CAAC,CACxB,MAAM,SAAS;AACd,KAAI,SAAS,EAAG,SAAQ,KAAK,KAAK;EAClC,CACD,OAAO,UAAmB;AACzB,KAAI,iBAAiB,aAAa;AAChC,UAAQ,OAAO,MAAM,gBAAgB,MAAM,QAAQ,IAAI;AACvD,UAAQ,KAAK,EAAE;;AAEjB,SAAQ,OAAO,MACb,gBAAgB,iBAAiB,QAAS,MAAM,SAAS,MAAM,UAAW,OAAO,MAAM,CAAC,IACzF;AACD,SAAQ,KAAK,EAAE;EACf"}
1
+ {"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { spawn } from 'node:child_process'\nimport { readFile } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { ConfigError, loadConfigFile, parseArgs, resolveInstanceConfig } from './config.ts'\nimport { startInstance } from './instance.ts'\n\nconst HELP = `workerdeck — run a workerdeck instance: session gateway + dashboard, one port.\n\nUsage\n workerdeck [options]\n workerdeck guard [options] check whether it is safe to restart an instance\n\nOptions\n -p, --port <n> port to listen on (default 8787, WORKERDECK_PORT)\n --host <addr> interface to bind (default 127.0.0.1, WORKERDECK_HOST)\n --auth-key <secret> shared secret, min 12 chars; browsers log in with it,\n services send it as x-workerdeck-key\n (WORKERDECK_AUTH_KEY). Unset = no auth on loopback;\n on any other interface a key is generated instead,\n printed once, and stored in <state-dir>/auth-key for\n later starts to reuse.\n --trust-proxy trust x-forwarded-proto/-host/-for from one reverse\n proxy. Required behind TLS termination, or the session\n cookie loses its Secure flag and the origin check\n computes http:// where the browser says https://.\n --allowed-origin <o> extra origin accepted on browser requests, for when a\n proxy rewrites Host (repeatable)\n --allowed-host <name> extra Host header accepted when running without auth\n (repeatable; loopback names are always accepted)\n --insecure-host <name>\n bind host that may serve without auth — no key demanded,\n none generated — and, while unauthenticated, also\n accepted as a Host header (repeatable; config:\n insecureHosts). Names the host alone, no port.\n --profile <name=dir> Claude config dir a session may run under (repeatable)\n --cwd-root <path> restrict session cwds to this root (repeatable,\n WORKERDECK_CWD_ROOTS as a ':'-separated list)\n --fs-root <path> narrow which host directories /v1/fs serves\n (repeatable, WORKERDECK_FS_ROOTS as a ':'-separated\n list). Reading otherwise follows --cwd-root: a caller\n who may start a session in a tree can already read it\n through the agent. With neither, the routes 404.\n --fs-write also accept writes over /v1/fs. Its own switch because\n an agent's writes go through the permission flow and a\n PUT does not. Every write is still conditional on the\n hash the client last read.\n --state-dir <path> where parked sessions are persisted\n (default: beside the config file, else ~/.workerdeck)\n --no-parking-store keep parked sessions in memory only; a restart drops them\n -c, --config <path> config file (default: ./workerdeck.config.mjs)\n --insecure allow no-auth on a non-loopback address. Only when\n something in front is doing the authenticating.\n --open open the dashboard in a browser once it is up\n -h, --help show this\n -v, --version print the version\n\nConfig file\n Options that cannot fit on a command line — \\`authenticate\\`, \\`buildRunnerConfig\\`,\n \\`createEngineRunner\\` are functions — live in workerdeck.config.mjs, which\n default-exports the createWorkerServer options (or a function returning them).\n Flags and env override it. Supplying your own \\`authenticate\\` turns the built-in\n shared-secret auth off entirely.\n\nCredentials\n workerdeck implements no Anthropic auth of its own: the official SDK/CLI\n resolves credentials from the environment, per profile. --auth-key protects this\n gateway, nothing else.\n`\n\nasync function readVersion(): Promise<string> {\n // src/cli.ts and build/cli.mjs are both one level under the package root.\n const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')\n try {\n const raw = await readFile(pkgPath, 'utf8')\n return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'\n } catch {\n return 'unknown'\n }\n}\n\n/** Best-effort: a browser that won't open is a convenience missed, not a failure. */\nfunction openInBrowser(url: string): void {\n const command =\n process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'\n try {\n const child = spawn(command, [url], {\n stdio: 'ignore',\n detached: true,\n shell: process.platform === 'win32',\n })\n child.on('error', () => {})\n child.unref()\n } catch {\n // ignored\n }\n}\n\nasync function main(argv: string[]): Promise<number> {\n if (argv[0] === 'guard') {\n const { runGuard } = await import('./guard.ts')\n return await runGuard(argv.slice(1))\n }\n\n const flags = parseArgs(argv)\n if (flags.help) {\n process.stdout.write(HELP)\n return 0\n }\n if (flags.version) {\n process.stdout.write(`${await readVersion()}\\n`)\n return 0\n }\n\n const loaded = await loadConfigFile(flags.config)\n const config = resolveInstanceConfig(flags, loaded)\n const instance = await startInstance(config)\n\n if (config.open) openInBrowser(instance.url)\n\n const shutdown = (signal: string): void => {\n process.stdout.write(`\\n[workerdeck] ${signal} — shutting down\\n`)\n // Parked sessions are already on disk; this is about letting in-flight\n // requests finish rather than dropping sockets on the floor.\n instance\n .close()\n .then(() => process.exit(0))\n .catch(() => process.exit(1))\n }\n process.on('SIGINT', () => shutdown('SIGINT'))\n process.on('SIGTERM', () => shutdown('SIGTERM'))\n\n // Resolves only on close; the process stays up serving.\n await instance.closed\n return 0\n}\n\nmain(process.argv.slice(2))\n .then((code) => {\n if (code !== 0) process.exit(code)\n })\n .catch((error: unknown) => {\n if (error instanceof ConfigError) {\n process.stderr.write(`[workerdeck] ${error.message}\\n`)\n process.exit(2)\n }\n process.stderr.write(\n `[workerdeck] ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n )\n process.exit(1)\n })\n"],"mappings":";;;;;;;AAQA,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+Db,eAAe,cAA+B;CAE5C,MAAM,UAAU,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,EAAE,MAAM,eAAe;AACnF,KAAI;EACF,MAAM,MAAM,MAAM,SAAS,SAAS,OAAO;AAC3C,SAAQ,KAAK,MAAM,IAAI,CAA0B,WAAW;SACtD;AACN,SAAO;;;;AAKX,SAAS,cAAc,KAAmB;CACxC,MAAM,UACJ,QAAQ,aAAa,WAAW,SAAS,QAAQ,aAAa,UAAU,UAAU;AACpF,KAAI;EACF,MAAM,QAAQ,MAAM,SAAS,CAAC,IAAI,EAAE;GAClC,OAAO;GACP,UAAU;GACV,OAAO,QAAQ,aAAa;GAC7B,CAAC;AACF,QAAM,GAAG,eAAe,GAAG;AAC3B,QAAM,OAAO;SACP;;AAKV,eAAe,KAAK,MAAiC;AACnD,KAAI,KAAK,OAAO,SAAS;EACvB,MAAM,EAAE,aAAa,MAAM,OAAO,wBAAA,MAAA,MAAA,EAAA,EAAA;AAClC,SAAO,MAAM,SAAS,KAAK,MAAM,EAAE,CAAC;;CAGtC,MAAM,QAAQ,UAAU,KAAK;AAC7B,KAAI,MAAM,MAAM;AACd,UAAQ,OAAO,MAAM,KAAK;AAC1B,SAAO;;AAET,KAAI,MAAM,SAAS;AACjB,UAAQ,OAAO,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI;AAChD,SAAO;;CAIT,MAAM,SAAS,sBAAsB,OAAO,MADvB,eAAe,MAAM,OAAO,CACE;CACnD,MAAM,WAAW,MAAM,cAAc,OAAO;AAE5C,KAAI,OAAO,KAAM,eAAc,SAAS,IAAI;CAE5C,MAAM,YAAY,WAAyB;AACzC,UAAQ,OAAO,MAAM,kBAAkB,OAAO,oBAAoB;AAGlE,WACG,OAAO,CACP,WAAW,QAAQ,KAAK,EAAE,CAAC,CAC3B,YAAY,QAAQ,KAAK,EAAE,CAAC;;AAEjC,SAAQ,GAAG,gBAAgB,SAAS,SAAS,CAAC;AAC9C,SAAQ,GAAG,iBAAiB,SAAS,UAAU,CAAC;AAGhD,OAAM,SAAS;AACf,QAAO;;AAGT,KAAK,QAAQ,KAAK,MAAM,EAAE,CAAC,CACxB,MAAM,SAAS;AACd,KAAI,SAAS,EAAG,SAAQ,KAAK,KAAK;EAClC,CACD,OAAO,UAAmB;AACzB,KAAI,iBAAiB,aAAa;AAChC,UAAQ,OAAO,MAAM,gBAAgB,MAAM,QAAQ,IAAI;AACvD,UAAQ,KAAK,EAAE;;AAEjB,SAAQ,OAAO,MACb,gBAAgB,iBAAiB,QAAS,MAAM,SAAS,MAAM,UAAW,OAAO,MAAM,CAAC,IACzF;AACD,SAAQ,KAAK,EAAE;EACf"}
package/build/index.d.mts CHANGED
@@ -1,7 +1,74 @@
1
1
  import { Authenticator, WorkerServer, WorkerServerOptions } from "@workerdeck/server";
2
+ import { KeyObject } from "node:crypto";
2
3
  import { IncomingMessage, ServerResponse } from "node:http";
3
- import { ProfileInfo } from "@workerdeck/protocol";
4
+ import { ProfileInfo, SessionNotification } from "@workerdeck/protocol";
4
5
 
6
+ //#region src/apns/client.d.ts
7
+ /**
8
+ * A minimal APNs provider client: an HTTP/2 POST carrying an ES256 JWT.
9
+ *
10
+ * Hand-rolled rather than a dependency, because that is the whole of the
11
+ * protocol and the published CLI's zero-runtime-dep posture is worth more than
12
+ * the eighty lines. Note `fetch`/undici will not do: it does not speak HTTP/2,
13
+ * and APNs accepts nothing else — hence `node:http2` directly.
14
+ *
15
+ * Token authentication, not certificates: one `.p8` serves every app in the team
16
+ * and both environments, and it does not expire. Certificates are per-app,
17
+ * per-environment, and expire annually.
18
+ */
19
+ type ApnsEnvironment = 'development' | 'production';
20
+ type ApnsConfig = {
21
+ /** Path to the `.p8`. A path, never the contents — a secret pasted into a
22
+ * config file is a secret in every backup of that file. */
23
+ keyFile: string; /** The 10-character Key ID shown beside the key in the developer portal. */
24
+ keyId: string; /** The 10-character Team ID from the top right of the portal. */
25
+ teamId: string; /** The APNs topic, which is the app's bundle id (`bi.atomic.workerdeck.ios`). */
26
+ topic: string;
27
+ /** Environment for a device that registered without naming one. Devices that
28
+ * do name one are always routed by their own answer — see the note on
29
+ * `ApnsEnvironment` below. */
30
+ production?: boolean;
31
+ };
32
+ type ApnsRequest = {
33
+ deviceToken: string;
34
+ environment: ApnsEnvironment;
35
+ payload: unknown; /** 10 for something a person is waiting on, 5 for everything else. */
36
+ priority?: 5 | 10; /** Unix seconds after which Apple stops trying. 0 means "one attempt". */
37
+ expiration?: number;
38
+ /** Later pushes with the same id replace an undelivered earlier one. Max 64
39
+ * bytes, so never pass a raw identifier of unbounded length. */
40
+ collapseId?: string;
41
+ };
42
+ type ApnsResult = {
43
+ ok: true;
44
+ apnsId?: string;
45
+ } | {
46
+ ok: false;
47
+ status: number;
48
+ reason: string;
49
+ /** The token is dead: drop it from the registry rather than retrying it
50
+ * forever. The app re-registers on its next launch anyway. */
51
+ unregistered: boolean;
52
+ };
53
+ type ApnsClient = {
54
+ send(request: ApnsRequest): Promise<ApnsResult>;
55
+ close(): void;
56
+ };
57
+ /**
58
+ * Load and sanity-check the auth key. Done once at startup rather than at the
59
+ * first push, so a mistyped path is a launch error with a clear message instead
60
+ * of a notification that silently never arrives.
61
+ */
62
+ declare function loadApnsKey(keyFile: string): Promise<KeyObject>;
63
+ declare function createApnsClient(config: ApnsConfig, key: KeyObject,
64
+ /** Test seam: point the two environments at a local HTTP/2 server. Nothing in
65
+ * production should pass this — the real endpoints are not configurable, and
66
+ * an operator who could redirect them could exfiltrate every push. */
67
+
68
+ options?: {
69
+ hosts?: Record<ApnsEnvironment, string>;
70
+ }): ApnsClient;
71
+ //#endregion
5
72
  //#region src/auth.d.ts
6
73
  /**
7
74
  * Gateway auth for the turnkey CLI: one shared operator secret, two transports.
@@ -124,6 +191,17 @@ type WorkerDeckConfig = WorkerServerOptions & {
124
191
  */
125
192
  insecureHosts?: string[]; /** Serve a dashboard build from here instead of the bundled one. */
126
193
  webRoot?: string;
194
+ /**
195
+ * Forward session notifications to Apple Push Notification service, for the
196
+ * iOS app. Absent turns the forwarder off entirely — including its
197
+ * `/apns/devices` route, so a gateway without this answers 404 there and the
198
+ * app quietly stops asking.
199
+ *
200
+ * Lives here rather than in `packages/server` on purpose: this is the only
201
+ * place in the project that holds a push credential, and the OSS gateway
202
+ * stays credential-free. `keyFile` is a path, never key contents.
203
+ */
204
+ apns?: ApnsConfig;
127
205
  };
128
206
  type CliFlags = {
129
207
  config?: string;
@@ -132,6 +210,8 @@ type CliFlags = {
132
210
  authKey?: string;
133
211
  profiles: ProfileInfo[];
134
212
  cwdRoots: string[];
213
+ fsRoots: string[];
214
+ fsWrite?: boolean;
135
215
  allowedOrigins: string[];
136
216
  allowedHosts: string[];
137
217
  insecureHosts: string[];
@@ -185,6 +265,9 @@ type ResolvedConfig = {
185
265
  */
186
266
  allowedHosts: Set<string> | null; /** Dashboard build to serve; resolved from the package when unset. */
187
267
  webRoot?: string;
268
+ /** APNs forwarder settings with `keyFile` made absolute, or undefined for an
269
+ * instance that does not push. */
270
+ apns?: ApnsConfig;
188
271
  open: boolean;
189
272
  options: WorkerServerOptions;
190
273
  };
@@ -282,5 +365,89 @@ type LoginPageOptions = {
282
365
  };
283
366
  declare function renderLoginPage(options: LoginPageOptions): string;
284
367
  //#endregion
285
- export { type CliAuth, type CliAuthOptions, type CliFlags, type CliPrincipal, ConfigError, type Instance, type LoadedConfig, type LoginPageOptions, type MaterializedAuthKey, type ResolvedConfig, type WorkerDeckConfig, createCliAuth, createHostGuard, defaultStateDir, hostnameOf, isLoopback, isLoopbackHostname, loadConfigFile, materializeAuthKey, parseArgs, renderLoginPage, resolveInstanceConfig, resolveWebRoot, runGuard, startInstance };
368
+ //#region src/apns/devices.d.ts
369
+ /**
370
+ * The device-token registry, and the route that fills it.
371
+ *
372
+ * This has to live *somewhere*, and the point of the webhooks-first decision is
373
+ * that it is not `packages/server`: the OSS gateway stays credential-free and
374
+ * knows nothing about APNs. So the turnkey CLI mounts its own route through the
375
+ * server's `fallback` hook — the same seam that serves the dashboard — and
376
+ * registration lands on the gateway's own origin, behind the auth key that
377
+ * already guards everything else.
378
+ *
379
+ * Storage is a JSON file under the state dir with the same 0600 posture as
380
+ * `auth-key`. Device tokens are not secrets in the way an auth key is, but they
381
+ * are a list of which phones belong to the operator, and there is no reason for
382
+ * every user on the machine to read it.
383
+ */
384
+ type DeviceRecord = {
385
+ /** Hex APNs device token. */token: string;
386
+ environment: ApnsEnvironment;
387
+ /**
388
+ * Opaque to us: the client's own id for this gateway, echoed back in every
389
+ * payload. It is what lets an app configured with two gateways tell which one
390
+ * woke it — we store the string and never interpret it.
391
+ */
392
+ hostId?: string;
393
+ bundleId?: string;
394
+ platform?: string;
395
+ updatedAt: number;
396
+ };
397
+ type DeviceRegistry = {
398
+ list(): DeviceRecord[];
399
+ register(record: Omit<DeviceRecord, 'updatedAt'>): Promise<void>;
400
+ remove(token: string): Promise<void>;
401
+ };
402
+ /**
403
+ * `dir` null keeps the registry in memory: a restart then forgets every token,
404
+ * which is survivable because the app re-registers on launch, but it does mean
405
+ * an instance with no state dir goes quiet until each phone is next opened.
406
+ */
407
+ declare function createDeviceRegistry(options: {
408
+ dir: string | null;
409
+ onError?: (error: unknown, context: {
410
+ op: string;
411
+ path: string;
412
+ }) => void;
413
+ }): Promise<DeviceRegistry>;
414
+ /**
415
+ * `POST /apns/devices` to register a token, `DELETE /apns/devices` to drop one.
416
+ * Returns true when it consumed the request.
417
+ *
418
+ * Deliberately outside `/v1`: this is the forwarder's own surface, not part of
419
+ * the protocol `packages/protocol` defines, and a client that finds a 404 here
420
+ * has simply reached a gateway running without push configured.
421
+ *
422
+ * DELETE exists so removing a gateway from the app can stop its pushes. Without
423
+ * it a forgotten server keeps buzzing a phone that no longer has any way to act
424
+ * on what it says.
425
+ */
426
+ declare function createDeviceRoute(registry: DeviceRegistry, authenticate: (req: IncomingMessage) => unknown): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
427
+ //#endregion
428
+ //#region src/apns/forwarder.d.ts
429
+ type ApnsForwarder = {
430
+ /** Hand to `createWorkerServer({ notifications: { onNotification } })`. */onNotification: (notification: SessionNotification) => void; /** Mount ahead of the static host; true when it consumed the request. */
431
+ handleRequest: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>; /** How many devices are registered, for the startup banner. */
432
+ deviceCount: () => number;
433
+ close: () => void;
434
+ };
435
+ /**
436
+ * Build the push for one notification.
437
+ *
438
+ * The payload carries routing and nothing else — `sessionId` to deep-link,
439
+ * `requestId` because a lock-screen Approve has nothing to POST to without it,
440
+ * and `hostId` so a client with two gateways knows which one this came from.
441
+ * Everything else the app fetches over REST the moment it opens; a transcript
442
+ * has no business in a 4 KB envelope.
443
+ */
444
+ declare function buildPush(notification: SessionNotification, hostId: string | undefined): Omit<ApnsRequest, 'deviceToken' | 'environment'>;
445
+ declare function createApnsForwarder(options: {
446
+ config: ApnsConfig; /** Where the device registry is persisted; null keeps it in memory. */
447
+ stateDir: string | null; /** Guards `/apns/devices` — the instance's own `authenticate`. */
448
+ authenticate: (req: IncomingMessage) => unknown;
449
+ warn?: (message: string) => void;
450
+ }): Promise<ApnsForwarder>;
451
+ //#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 };
286
453
  //# 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 { a as ConfigError, c as isLoopback, d as parseArgs, f as resolveInstanceConfig, i as renderLoginPage, l as isLoopbackHostname, m as materializeAuthKey, n as resolveWebRoot, o as defaultStateDir, p as createCliAuth, r as startInstance, s as hostnameOf, t as createHostGuard, u as loadConfigFile } from "./instance-kupxU5UD.mjs";
3
- export { ConfigError, createCliAuth, createHostGuard, defaultStateDir, hostnameOf, isLoopback, isLoopbackHostname, loadConfigFile, materializeAuthKey, parseArgs, renderLoginPage, resolveInstanceConfig, resolveWebRoot, runGuard, startInstance };
2
+ import { _ as createDeviceRegistry, a as ConfigError, b as loadApnsKey, c as isLoopback, d as parseArgs, f as resolveInstanceConfig, g as createApnsForwarder, h as buildPush, i as renderLoginPage, l as isLoopbackHostname, m as materializeAuthKey, n as resolveWebRoot, o as defaultStateDir, p as createCliAuth, r as startInstance, s as hostnameOf, t as createHostGuard, u as loadConfigFile, v as createDeviceRoute, y as createApnsClient } from "./instance-DbIqlX-e.mjs";
3
+ export { ConfigError, buildPush, createApnsClient, createApnsForwarder, createCliAuth, createDeviceRegistry, createDeviceRoute, createHostGuard, defaultStateDir, hostnameOf, isLoopback, isLoopbackHostname, loadApnsKey, loadConfigFile, materializeAuthKey, parseArgs, renderLoginPage, resolveInstanceConfig, resolveWebRoot, runGuard, startInstance };