@dreb/dashboard 2.35.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.
Files changed (48) hide show
  1. package/README.md +146 -0
  2. package/dist/index.d.ts +30 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +251 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/server/auth.d.ts +164 -0
  7. package/dist/server/auth.d.ts.map +1 -0
  8. package/dist/server/auth.js +396 -0
  9. package/dist/server/auth.js.map +1 -0
  10. package/dist/server/event-hub.d.ts +31 -0
  11. package/dist/server/event-hub.d.ts.map +1 -0
  12. package/dist/server/event-hub.js +73 -0
  13. package/dist/server/event-hub.js.map +1 -0
  14. package/dist/server/files.d.ts +48 -0
  15. package/dist/server/files.d.ts.map +1 -0
  16. package/dist/server/files.js +194 -0
  17. package/dist/server/files.js.map +1 -0
  18. package/dist/server/pairing-storage.d.ts +18 -0
  19. package/dist/server/pairing-storage.d.ts.map +1 -0
  20. package/dist/server/pairing-storage.js +83 -0
  21. package/dist/server/pairing-storage.js.map +1 -0
  22. package/dist/server/runtime-pool.d.ts +89 -0
  23. package/dist/server/runtime-pool.d.ts.map +1 -0
  24. package/dist/server/runtime-pool.js +377 -0
  25. package/dist/server/runtime-pool.js.map +1 -0
  26. package/dist/server/server.d.ts +30 -0
  27. package/dist/server/server.d.ts.map +1 -0
  28. package/dist/server/server.js +557 -0
  29. package/dist/server/server.js.map +1 -0
  30. package/dist/server/subagent-log.d.ts +42 -0
  31. package/dist/server/subagent-log.d.ts.map +1 -0
  32. package/dist/server/subagent-log.js +155 -0
  33. package/dist/server/subagent-log.js.map +1 -0
  34. package/dist/shared/protocol.d.ts +261 -0
  35. package/dist/shared/protocol.d.ts.map +1 -0
  36. package/dist/shared/protocol.js +26 -0
  37. package/dist/shared/protocol.js.map +1 -0
  38. package/dist/static/assets/index-Cgwg2zRA.js +77 -0
  39. package/dist/static/assets/index-DnFrQoI9.css +1 -0
  40. package/dist/static/icons/apple-touch-icon.png +0 -0
  41. package/dist/static/icons/favicon-32.png +0 -0
  42. package/dist/static/icons/punk-1024.png +0 -0
  43. package/dist/static/icons/punk-192.png +0 -0
  44. package/dist/static/icons/punk-512.png +0 -0
  45. package/dist/static/index.html +32 -0
  46. package/dist/static/manifest.webmanifest +30 -0
  47. package/dist/static/sw.js +110 -0
  48. package/package.json +57 -0
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # @dreb/dashboard
2
+
3
+ Web dashboard for [dreb](https://github.com/aebrer/dreb) — a visual, real-time,
4
+ mobile-friendly interface for browsing projects and sessions, controlling
5
+ multiple dreb agents, watching background subagents live, and using dreb from
6
+ devices that are not sitting at the host terminal.
7
+
8
+ Live agent control goes through RPC: the dashboard spawns `dreb --mode rpc`
9
+ child processes (one per live session). The server also uses dreb's public
10
+ session APIs for on-disk inventory/delete and serves its own host file API.
11
+
12
+ ## Install & launch
13
+
14
+ ```bash
15
+ npm install -g @dreb/dashboard
16
+
17
+ # local-only (default): binds 127.0.0.1, no auth needed
18
+ dreb-dashboard
19
+
20
+ # If you installed the main dreb CLI (@dreb/coding-agent), the same server is
21
+ # also available through:
22
+ dreb dashboard
23
+
24
+ # remote over Tailscale with HTTPS (mobile PWA + notifications)
25
+ dreb dashboard --remote --allow you@example.com \
26
+ --https --cert /path/cert.pem --key /path/key.pem
27
+ ```
28
+
29
+ Open `http://127.0.0.1:5343`.
30
+
31
+ ## Screens
32
+
33
+ - **Fleet** (home) — live-first: every live session in one grid at the top
34
+ (stable order by project path, then session start time; each card shows its
35
+ project path, status chip, current activity, live subagents, task progress,
36
+ ctx%, model, cost, last-assistant preview). Below, past sessions grouped by project — three
37
+ compact rows each with an "all N on disk" expander — with resume/delete.
38
+ `+ new session` anywhere.
39
+ - **Session view** — full chat parity: markdown streaming transcript, tool
40
+ cards, thinking blocks, compaction summaries, per-message copy, tasks panel,
41
+ suggest-next chip, slash-command autocomplete, image attach/paste,
42
+ queued-message restore, footer-parity info bar (branch, tokens, cost, ctx%,
43
+ median tok/s), stats/loaded-context/fork modals, steer/follow-up composer
44
+ modes, ■ abort, model/thinking switchers, extension-UI modals, export HTML,
45
+ and live auto-naming.
46
+ - **Subagent drill-in** — read-only transcript of a background agent: live
47
+ events via the relay, hydrated from the agent's on-disk session log so the
48
+ view survives browser reloads. No composer: the parent session controls the
49
+ agent.
50
+ - **Files** — host-wide browse with places shortcuts, upload (collision
51
+ prompts before overwrite), download, new-folder, "new session here".
52
+ - **Settings** — persistent defaults (provider-grouped model dropdown,
53
+ thinking, queue modes, image handling, skill commands, nested context,
54
+ transport, hide-thinking, compaction/retry), per-agent model fallback editor,
55
+ dashboard-local preferences (thinking expansion and notification permission),
56
+ current pairing code, and paired-devices management.
57
+ - **Pairing** — remote first-login rotating-code flow.
58
+
59
+ ## Security model — exactly two modes
60
+
61
+ **Local (default).** The server binds the loopback interface only. LAN packets
62
+ never reach the process. No login. Host/Origin headers are validated on every
63
+ request (DNS-rebinding defense).
64
+
65
+ **Remote (opt-in).** Requires [Tailscale](https://tailscale.com):
66
+
67
+ ```bash
68
+ dreb-dashboard --remote --allow you@example.com
69
+ ```
70
+
71
+ Enforcement layers, all fail-closed:
72
+
73
+ 1. Tailscale identity resolution of the peer address (`tailscale status`)
74
+ 2. Identity allowlist — empty allowlist denies everyone
75
+ 3. First-login pairing code: 6 digits, rotates every 30 seconds, shown live in
76
+ the dashboard Settings tab on the host machine (also printed at startup as a
77
+ headless fallback)
78
+ 4. Signed per-device cookie thereafter; devices are listed and unpair-able in
79
+ settings
80
+
81
+ **There is no LAN mode.** Access from another device — even on the same LAN —
82
+ goes through Tailscale.
83
+
84
+ A paired device has the same power as sitting at the terminal: it can chat
85
+ with agents, run commands through them, browse the host's files, and
86
+ upload/download. Every file operation is logged server-side. Browser
87
+ notifications are opt-in per device from settings; the tab-title attention
88
+ badge works without permission.
89
+
90
+ ## Options
91
+
92
+ | Flag | Description |
93
+ |---|---|
94
+ | `--port <n>` | Port (default 5343) |
95
+ | `--remote` | Enable remote mode (requires Tailscale) |
96
+ | `--allow <identity>` | Tailscale login name allowed to pair (repeatable, required with `--remote`) |
97
+ | `--https` | Terminate TLS on the dashboard itself (native TLS, no reverse proxy, no auth-model change). Requires `--cert` and `--key`. Mainly for `--remote` (loopback is already a secure context); use `tailscale cert` files for a tailnet hostname so mobile PWAs + notifications work. Note: with `--https` the server speaks TLS only, so the host's plain-HTTP local tab (`http://127.0.0.1`) stops working — use the tailnet hostname (`https://…`) there. |
98
+ | `--cert <path>` | PEM certificate file (required with `--https`; hot-reloaded on file change) |
99
+ | `--key <path>` | PEM private key file (required with `--https`; hot-reloaded on file change) |
100
+
101
+ ## PWA + mobile notifications
102
+
103
+ The dashboard is an **installable PWA** — web app manifest, service worker, and
104
+ icon set. Install to the home screen on Android Chrome and iOS Safari 16.4+ for
105
+ a standalone, no-URL-bar app.
106
+
107
+ Needs-attention notifications go through the **service worker**
108
+ (`registration.showNotification`) — the only path that works on Android Chrome
109
+ (which removed the page `Notification` constructor) and on iOS (installed PWA
110
+ only, 16.4+). The tab-title `◆` badge is the in-tab fallback. Notifications
111
+ require a **secure context** — `localhost`/`127.0.0.1` already qualifies, so
112
+ local-mode works with no setup. For remote access over the tailnet, enable
113
+ native TLS (`--https --cert --key`) with `tailscale cert` files; the auth model
114
+ is unchanged (the peer address stays the real tailnet IP). See
115
+ `packages/coding-agent/docs/dashboard.md` for the full setup walkthrough.
116
+
117
+ ## Architecture
118
+
119
+ ```
120
+ Browser (SolidJS, hash-routed SPA)
121
+ ⇄ REST + SSE (Express server, fail-closed auth middleware)
122
+ ⇄ RpcClient pool — one `dreb --mode rpc` child per live session
123
+ ```
124
+
125
+ - Events stream over a single SSE connection carrying `{seq, key, event}`
126
+ envelopes; reconnects catch up via `Last-Event-ID` against a bounded ring
127
+ buffer (a gap triggers a `dashboard_resync` + full refetch). Deleting a
128
+ runtime publishes a synthetic `runtime_removed` event so clients evict that
129
+ session's state. Clients that stop draining the stream past a bounded
130
+ write buffer are disconnected and rely on reconnect catch-up.
131
+ - Background subagent transcripts arrive over the same pipe via the
132
+ `background_agent_event` relay (see `docs/rpc.md` in
133
+ `@dreb/coding-agent`) — no session-file tailing.
134
+ - The visual language is `tokens.css` (`src/client/styles/tokens.css`),
135
+ the dashboard's design system, adopted unmodified from the accepted
136
+ design phase.
137
+
138
+ ## Development
139
+
140
+ ```bash
141
+ npm run build # server (tsgo) + client (vite) → dist/
142
+ npm test # server, reducer, and screen smoke tests
143
+ ```
144
+
145
+ See `packages/coding-agent/docs/dashboard.md` in the repo for the full
146
+ product documentation.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dreb-dashboard — launch the dreb web dashboard server.
4
+ *
5
+ * Modes (exactly two, no LAN mode):
6
+ * default loopback bind (127.0.0.1), no auth, no Tailscale needed
7
+ * --remote requires Tailscale; binds all interfaces but every request
8
+ * passes identity allowlist + pairing code + device cookies
9
+ *
10
+ * Usage:
11
+ * dreb-dashboard [--port 5343] [--remote --allow me@example.com [--allow ...]]
12
+ */
13
+ export { DashboardAuth, TailscaleStatusResolver } from "./server/auth.js";
14
+ export { EventHub } from "./server/event-hub.js";
15
+ export { canonicalizePath, FileApi } from "./server/files.js";
16
+ export { FilePairingStorage, loadOrCreateDashboardSecret } from "./server/pairing-storage.js";
17
+ export { RuntimePool, resolveDrebCliPath } from "./server/runtime-pool.js";
18
+ export { createDashboardServer, parseDeviceCookie } from "./server/server.js";
19
+ export type * from "./shared/protocol.js";
20
+ interface CliArgs {
21
+ port: number;
22
+ remote: boolean;
23
+ allow: string[];
24
+ help: boolean;
25
+ https: boolean;
26
+ cert?: string;
27
+ key?: string;
28
+ }
29
+ export declare function parseArgs(argv: string[]): CliArgs;
30
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;GAUG;AAaH,OAAO,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC1E,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC9D,OAAO,EAAE,kBAAkB,EAAE,2BAA2B,EAAE,MAAM,6BAA6B,CAAC;AAC9F,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC3E,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC9E,mBAAmB,sBAAsB,CAAC;AAI1C,UAAU,OAAO;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CA6CjD","sourcesContent":["#!/usr/bin/env node\n/**\n * dreb-dashboard — launch the dreb web dashboard server.\n *\n * Modes (exactly two, no LAN mode):\n * default loopback bind (127.0.0.1), no auth, no Tailscale needed\n * --remote requires Tailscale; binds all interfaces but every request\n * passes identity allowlist + pairing code + device cookies\n *\n * Usage:\n * dreb-dashboard [--port 5343] [--remote --allow me@example.com [--allow ...]]\n */\n\nimport { existsSync, readFileSync, watch } from \"node:fs\";\nimport { createServer as createHttpServer, type Server as HttpServer } from \"node:http\";\nimport { createServer as createHttpsServer, type Server as HttpsServer } from \"node:https\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { DashboardAuth } from \"./server/auth.js\";\nimport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nimport { RuntimePool } from \"./server/runtime-pool.js\";\nimport { createDashboardServer } from \"./server/server.js\";\n\nexport { DashboardAuth, TailscaleStatusResolver } from \"./server/auth.js\";\nexport { EventHub } from \"./server/event-hub.js\";\nexport { canonicalizePath, FileApi } from \"./server/files.js\";\nexport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nexport { RuntimePool, resolveDrebCliPath } from \"./server/runtime-pool.js\";\nexport { createDashboardServer, parseDeviceCookie } from \"./server/server.js\";\nexport type * from \"./shared/protocol.js\";\n\nconst DEFAULT_PORT = 5343;\n\ninterface CliArgs {\n\tport: number;\n\tremote: boolean;\n\tallow: string[];\n\thelp: boolean;\n\thttps: boolean;\n\tcert?: string;\n\tkey?: string;\n}\n\nexport function parseArgs(argv: string[]): CliArgs {\n\tconst args: CliArgs = { port: DEFAULT_PORT, remote: false, allow: [], help: false, https: false };\n\tfor (let i = 0; i < argv.length; i++) {\n\t\tconst arg = argv[i];\n\t\tif (arg === \"--port\") {\n\t\t\tconst value = argv[++i];\n\t\t\tconst port = Number.parseInt(value ?? \"\", 10);\n\t\t\tif (!Number.isInteger(port) || port < 1 || port > 65535) {\n\t\t\t\tthrow new Error(`Invalid --port value: ${value}`);\n\t\t\t}\n\t\t\targs.port = port;\n\t\t} else if (arg === \"--remote\") {\n\t\t\targs.remote = true;\n\t\t} else if (arg === \"--allow\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--allow requires an identity (Tailscale login name)\");\n\t\t\targs.allow.push(value);\n\t\t} else if (arg === \"--https\") {\n\t\t\targs.https = true;\n\t\t} else if (arg === \"--cert\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--cert requires a path to a PEM certificate file\");\n\t\t\targs.cert = value;\n\t\t} else if (arg === \"--key\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--key requires a path to a PEM private key file\");\n\t\t\targs.key = value;\n\t\t} else if (arg === \"--help\" || arg === \"-h\") {\n\t\t\targs.help = true;\n\t\t} else {\n\t\t\tthrow new Error(`Unknown argument: ${arg}`);\n\t\t}\n\t}\n\tif (args.remote && args.allow.length === 0) {\n\t\tthrow new Error(\"--remote requires at least one --allow <tailscale-login> (empty allowlist denies everyone)\");\n\t}\n\t// Native TLS. `https` is opt-in; it requires cert AND key (no silent\n\t// fallback to plain HTTP). The dashboard terminates TLS itself — no reverse\n\t// proxy, no auth-model change. `req.socket.remoteAddress` stays the real\n\t// tailnet IP, so identity resolution + allowlist + pairing keep working.\n\tif (args.https) {\n\t\tif (!args.cert) throw new Error(\"--https requires --cert <path>\");\n\t\tif (!args.key) throw new Error(\"--https requires --key <path>\");\n\t}\n\treturn args;\n}\n\nconst HELP = `dreb-dashboard — dreb web dashboard server\n\nUsage: dreb-dashboard [options]\n\nOptions:\n --port <n> Port to listen on (default ${DEFAULT_PORT})\n --remote Enable remote mode (requires Tailscale). Without this\n flag the server binds 127.0.0.1 only — no LAN access.\n --allow <identity> Tailscale login name allowed to pair (repeatable;\n required with --remote)\n --https Terminate TLS on the dashboard itself (native TLS). No\n reverse proxy, no auth-model change. Requires --cert\n and --key. Mainly for --remote (loopback is already a\n secure context): use 'tailscale cert' files for a\n tailnet hostname so mobile PWAs + notifications work.\n NOTE: with --https the server speaks TLS only, so the\n host's plain-http local tab (http://127.0.0.1) stops\n working — use the tailnet hostname (https://...) there.\n --cert <path> PEM certificate file (required with --https)\n --key <path> PEM private key file (required with --https)\n --help Show this help\n`;\n\nasync function main(): Promise<void> {\n\tlet args: CliArgs;\n\ttry {\n\t\targs = parseArgs(process.argv.slice(2));\n\t} catch (err) {\n\t\tconsole.error(`error: ${err instanceof Error ? err.message : String(err)}`);\n\t\tconsole.error(HELP);\n\t\tprocess.exit(1);\n\t}\n\tif (args.help) {\n\t\tconsole.log(HELP);\n\t\treturn;\n\t}\n\n\tconst agentDir = join(homedir(), \".dreb\", \"agent\");\n\tconst auth = new DashboardAuth({\n\t\tremoteEnabled: args.remote,\n\t\tallowedIdentities: args.allow,\n\t\tstorage: new FilePairingStorage(join(agentDir, \"dashboard-pairings.json\")),\n\t\tsecret: loadOrCreateDashboardSecret(join(agentDir, \"dashboard-auth-secret\")),\n\t\tlogger: (line) => console.warn(`[dashboard-auth] ${line}`),\n\t});\n\tconst pool = new RuntimePool();\n\n\t// Static client assets live next to the compiled server (dist/static).\n\tconst staticDir = join(dirname(fileURLToPath(import.meta.url)), \"static\");\n\n\t// The server's own build version (dist/index.js → ../package.json) — surfaced\n\t// in the settings footer so a stale long-running service is spottable.\n\tlet serverVersion: string | undefined;\n\ttry {\n\t\tconst pkgPath = join(dirname(fileURLToPath(import.meta.url)), \"..\", \"package.json\");\n\t\tserverVersion = JSON.parse(readFileSync(pkgPath, \"utf8\")).version;\n\t} catch {\n\t\tserverVersion = undefined;\n\t}\n\n\t// Restart hook: exit non-zero so a supervisor (systemd Restart=on-failure,\n\t// etc.) respawns the process with the freshly-built dist. Assigned the http\n\t// server below via a mutable holder so the closure can close it first.\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tconst onRestart = () => {\n\t\tconsole.log(\"restart requested — exiting for supervisor to respawn\");\n\t\thttpServer?.close();\n\t\tpool.stopAll().finally(() => process.exit(1));\n\t};\n\n\tconst { SessionManager } = await import(\"@dreb/coding-agent\");\n\tconst app = createDashboardServer({\n\t\tauth,\n\t\tpool,\n\t\tstaticDir: existsSync(staticDir) ? staticDir : undefined,\n\t\tserverVersion,\n\t\tonRestart,\n\t\tlistAllSessions: () => SessionManager.listAll(),\n\t\tdeleteSession: async (path: string) => {\n\t\t\tconst result = await SessionManager.deleteSession(path, {});\n\t\t\tif (!result.ok) throw new Error(result.error ?? \"Unknown deletion error\");\n\t\t\treturn { method: result.method };\n\t\t},\n\t});\n\n\tconst host = args.remote ? \"0.0.0.0\" : \"127.0.0.1\";\n\tconst scheme = args.https ? \"https\" : \"http\";\n\n\t// Native TLS: the dashboard terminates TLS itself (no reverse proxy). The\n\t// auth model is unchanged — `req.socket.remoteAddress` is still the real\n\t// tailnet IP, so Tailscale identity resolution + allowlist + pairing keep\n\t// working. Local mode never sets --https (loopback is already a secure\n\t// context), but it's allowed if the operator wants it.\n\tlet server: HttpServer | HttpsServer;\n\tif (args.https && args.cert && args.key) {\n\t\tlet tlsOptions: { cert: string; key: string };\n\t\ttry {\n\t\t\ttlsOptions = {\n\t\t\t\tcert: readFileSync(args.cert, \"utf-8\"),\n\t\t\t\tkey: readFileSync(args.key, \"utf-8\"),\n\t\t\t};\n\t\t} catch (err) {\n\t\t\tconsole.error(`error: failed to read TLS cert/key: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconst httpsServer = createHttpsServer(tlsOptions, app);\n\t\t// Hot-reload the cert on file change (zero-downtime renewal — e.g. a\n\t\t// systemd timer rewrites the `tailscale cert` files daily). New\n\t\t// connections pick up the new cert; existing ones finish on the old.\n\t\tconst reloadTls = () => {\n\t\t\ttry {\n\t\t\t\thttpsServer.setSecureContext({\n\t\t\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t\t\t});\n\t\t\t\tconsole.log(\"tls certificate reloaded\");\n\t\t\t} catch (err) {\n\t\t\t\tconsole.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t}\n\t\t};\n\t\t// Debounce: cert + key are often rewritten in quick succession.\n\t\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\t\tfor (const file of [args.cert, args.key]) {\n\t\t\twatch(file!, () => {\n\t\t\t\tif (reloadTimer) clearTimeout(reloadTimer);\n\t\t\t\treloadTimer = setTimeout(reloadTls, 500);\n\t\t\t});\n\t\t}\n\t\tserver = httpsServer;\n\t} else {\n\t\tserver = createHttpServer(app);\n\t}\n\tserver.listen(args.port, host, () => {\n\t\tconsole.log(\n\t\t\t`dreb dashboard listening on ${scheme}://${host === \"0.0.0.0\" ? \"<tailscale-ip>\" : host}:${args.port}`,\n\t\t);\n\t\tif (args.remote) {\n\t\t\tconsole.log(`remote mode: allowed identities = ${args.allow.join(\", \")}`);\n\t\t\tconst { code, expiresInMs } = auth.currentPairingCode();\n\t\t\tconsole.log(\n\t\t\t\t`pairing code: ${code} (rotates every 30s; current code rolls in ${Math.ceil(expiresInMs / 1000)}s)`,\n\t\t\t);\n\t\t\tconsole.log(\"new devices enter this code; see it live in dashboard Settings on the host machine\");\n\t\t} else {\n\t\t\tconsole.log(\"local mode: loopback only — use --remote for Tailscale access\");\n\t\t}\n\t\tif (args.https) {\n\t\t\tconsole.log(\"tls: native HTTPS enabled (cert + key hot-reload on file change)\");\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => {\n\t\tconsole.log(\"shutting down…\");\n\t\tserver.close();\n\t\tpool.stopAll().finally(() => process.exit(0));\n\t};\n\tprocess.on(\"SIGINT\", shutdown);\n\tprocess.on(\"SIGTERM\", shutdown);\n}\n\n// Only run when executed directly (not when imported for the library exports).\nconst entryPath = process.argv[1];\nif (entryPath && import.meta.url === new URL(`file://${entryPath}`).href) {\n\tmain().catch((err) => {\n\t\tconsole.error(`fatal: ${err instanceof Error ? err.message : String(err)}`);\n\t\tprocess.exit(1);\n\t});\n}\n"]}
package/dist/index.js ADDED
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dreb-dashboard — launch the dreb web dashboard server.
4
+ *
5
+ * Modes (exactly two, no LAN mode):
6
+ * default loopback bind (127.0.0.1), no auth, no Tailscale needed
7
+ * --remote requires Tailscale; binds all interfaces but every request
8
+ * passes identity allowlist + pairing code + device cookies
9
+ *
10
+ * Usage:
11
+ * dreb-dashboard [--port 5343] [--remote --allow me@example.com [--allow ...]]
12
+ */
13
+ import { existsSync, readFileSync, watch } from "node:fs";
14
+ import { createServer as createHttpServer } from "node:http";
15
+ import { createServer as createHttpsServer } from "node:https";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { DashboardAuth } from "./server/auth.js";
20
+ import { FilePairingStorage, loadOrCreateDashboardSecret } from "./server/pairing-storage.js";
21
+ import { RuntimePool } from "./server/runtime-pool.js";
22
+ import { createDashboardServer } from "./server/server.js";
23
+ export { DashboardAuth, TailscaleStatusResolver } from "./server/auth.js";
24
+ export { EventHub } from "./server/event-hub.js";
25
+ export { canonicalizePath, FileApi } from "./server/files.js";
26
+ export { FilePairingStorage, loadOrCreateDashboardSecret } from "./server/pairing-storage.js";
27
+ export { RuntimePool, resolveDrebCliPath } from "./server/runtime-pool.js";
28
+ export { createDashboardServer, parseDeviceCookie } from "./server/server.js";
29
+ const DEFAULT_PORT = 5343;
30
+ export function parseArgs(argv) {
31
+ const args = { port: DEFAULT_PORT, remote: false, allow: [], help: false, https: false };
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const arg = argv[i];
34
+ if (arg === "--port") {
35
+ const value = argv[++i];
36
+ const port = Number.parseInt(value ?? "", 10);
37
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
38
+ throw new Error(`Invalid --port value: ${value}`);
39
+ }
40
+ args.port = port;
41
+ }
42
+ else if (arg === "--remote") {
43
+ args.remote = true;
44
+ }
45
+ else if (arg === "--allow") {
46
+ const value = argv[++i];
47
+ if (!value)
48
+ throw new Error("--allow requires an identity (Tailscale login name)");
49
+ args.allow.push(value);
50
+ }
51
+ else if (arg === "--https") {
52
+ args.https = true;
53
+ }
54
+ else if (arg === "--cert") {
55
+ const value = argv[++i];
56
+ if (!value)
57
+ throw new Error("--cert requires a path to a PEM certificate file");
58
+ args.cert = value;
59
+ }
60
+ else if (arg === "--key") {
61
+ const value = argv[++i];
62
+ if (!value)
63
+ throw new Error("--key requires a path to a PEM private key file");
64
+ args.key = value;
65
+ }
66
+ else if (arg === "--help" || arg === "-h") {
67
+ args.help = true;
68
+ }
69
+ else {
70
+ throw new Error(`Unknown argument: ${arg}`);
71
+ }
72
+ }
73
+ if (args.remote && args.allow.length === 0) {
74
+ throw new Error("--remote requires at least one --allow <tailscale-login> (empty allowlist denies everyone)");
75
+ }
76
+ // Native TLS. `https` is opt-in; it requires cert AND key (no silent
77
+ // fallback to plain HTTP). The dashboard terminates TLS itself — no reverse
78
+ // proxy, no auth-model change. `req.socket.remoteAddress` stays the real
79
+ // tailnet IP, so identity resolution + allowlist + pairing keep working.
80
+ if (args.https) {
81
+ if (!args.cert)
82
+ throw new Error("--https requires --cert <path>");
83
+ if (!args.key)
84
+ throw new Error("--https requires --key <path>");
85
+ }
86
+ return args;
87
+ }
88
+ const HELP = `dreb-dashboard — dreb web dashboard server
89
+
90
+ Usage: dreb-dashboard [options]
91
+
92
+ Options:
93
+ --port <n> Port to listen on (default ${DEFAULT_PORT})
94
+ --remote Enable remote mode (requires Tailscale). Without this
95
+ flag the server binds 127.0.0.1 only — no LAN access.
96
+ --allow <identity> Tailscale login name allowed to pair (repeatable;
97
+ required with --remote)
98
+ --https Terminate TLS on the dashboard itself (native TLS). No
99
+ reverse proxy, no auth-model change. Requires --cert
100
+ and --key. Mainly for --remote (loopback is already a
101
+ secure context): use 'tailscale cert' files for a
102
+ tailnet hostname so mobile PWAs + notifications work.
103
+ NOTE: with --https the server speaks TLS only, so the
104
+ host's plain-http local tab (http://127.0.0.1) stops
105
+ working — use the tailnet hostname (https://...) there.
106
+ --cert <path> PEM certificate file (required with --https)
107
+ --key <path> PEM private key file (required with --https)
108
+ --help Show this help
109
+ `;
110
+ async function main() {
111
+ let args;
112
+ try {
113
+ args = parseArgs(process.argv.slice(2));
114
+ }
115
+ catch (err) {
116
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
117
+ console.error(HELP);
118
+ process.exit(1);
119
+ }
120
+ if (args.help) {
121
+ console.log(HELP);
122
+ return;
123
+ }
124
+ const agentDir = join(homedir(), ".dreb", "agent");
125
+ const auth = new DashboardAuth({
126
+ remoteEnabled: args.remote,
127
+ allowedIdentities: args.allow,
128
+ storage: new FilePairingStorage(join(agentDir, "dashboard-pairings.json")),
129
+ secret: loadOrCreateDashboardSecret(join(agentDir, "dashboard-auth-secret")),
130
+ logger: (line) => console.warn(`[dashboard-auth] ${line}`),
131
+ });
132
+ const pool = new RuntimePool();
133
+ // Static client assets live next to the compiled server (dist/static).
134
+ const staticDir = join(dirname(fileURLToPath(import.meta.url)), "static");
135
+ // The server's own build version (dist/index.js → ../package.json) — surfaced
136
+ // in the settings footer so a stale long-running service is spottable.
137
+ let serverVersion;
138
+ try {
139
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
140
+ serverVersion = JSON.parse(readFileSync(pkgPath, "utf8")).version;
141
+ }
142
+ catch {
143
+ serverVersion = undefined;
144
+ }
145
+ // Restart hook: exit non-zero so a supervisor (systemd Restart=on-failure,
146
+ // etc.) respawns the process with the freshly-built dist. Assigned the http
147
+ // server below via a mutable holder so the closure can close it first.
148
+ let httpServer;
149
+ const onRestart = () => {
150
+ console.log("restart requested — exiting for supervisor to respawn");
151
+ httpServer?.close();
152
+ pool.stopAll().finally(() => process.exit(1));
153
+ };
154
+ const { SessionManager } = await import("@dreb/coding-agent");
155
+ const app = createDashboardServer({
156
+ auth,
157
+ pool,
158
+ staticDir: existsSync(staticDir) ? staticDir : undefined,
159
+ serverVersion,
160
+ onRestart,
161
+ listAllSessions: () => SessionManager.listAll(),
162
+ deleteSession: async (path) => {
163
+ const result = await SessionManager.deleteSession(path, {});
164
+ if (!result.ok)
165
+ throw new Error(result.error ?? "Unknown deletion error");
166
+ return { method: result.method };
167
+ },
168
+ });
169
+ const host = args.remote ? "0.0.0.0" : "127.0.0.1";
170
+ const scheme = args.https ? "https" : "http";
171
+ // Native TLS: the dashboard terminates TLS itself (no reverse proxy). The
172
+ // auth model is unchanged — `req.socket.remoteAddress` is still the real
173
+ // tailnet IP, so Tailscale identity resolution + allowlist + pairing keep
174
+ // working. Local mode never sets --https (loopback is already a secure
175
+ // context), but it's allowed if the operator wants it.
176
+ let server;
177
+ if (args.https && args.cert && args.key) {
178
+ let tlsOptions;
179
+ try {
180
+ tlsOptions = {
181
+ cert: readFileSync(args.cert, "utf-8"),
182
+ key: readFileSync(args.key, "utf-8"),
183
+ };
184
+ }
185
+ catch (err) {
186
+ console.error(`error: failed to read TLS cert/key: ${err instanceof Error ? err.message : String(err)}`);
187
+ process.exit(1);
188
+ }
189
+ const httpsServer = createHttpsServer(tlsOptions, app);
190
+ // Hot-reload the cert on file change (zero-downtime renewal — e.g. a
191
+ // systemd timer rewrites the `tailscale cert` files daily). New
192
+ // connections pick up the new cert; existing ones finish on the old.
193
+ const reloadTls = () => {
194
+ try {
195
+ httpsServer.setSecureContext({
196
+ cert: readFileSync(args.cert, "utf-8"),
197
+ key: readFileSync(args.key, "utf-8"),
198
+ });
199
+ console.log("tls certificate reloaded");
200
+ }
201
+ catch (err) {
202
+ console.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);
203
+ }
204
+ };
205
+ // Debounce: cert + key are often rewritten in quick succession.
206
+ let reloadTimer;
207
+ for (const file of [args.cert, args.key]) {
208
+ watch(file, () => {
209
+ if (reloadTimer)
210
+ clearTimeout(reloadTimer);
211
+ reloadTimer = setTimeout(reloadTls, 500);
212
+ });
213
+ }
214
+ server = httpsServer;
215
+ }
216
+ else {
217
+ server = createHttpServer(app);
218
+ }
219
+ server.listen(args.port, host, () => {
220
+ console.log(`dreb dashboard listening on ${scheme}://${host === "0.0.0.0" ? "<tailscale-ip>" : host}:${args.port}`);
221
+ if (args.remote) {
222
+ console.log(`remote mode: allowed identities = ${args.allow.join(", ")}`);
223
+ const { code, expiresInMs } = auth.currentPairingCode();
224
+ console.log(`pairing code: ${code} (rotates every 30s; current code rolls in ${Math.ceil(expiresInMs / 1000)}s)`);
225
+ console.log("new devices enter this code; see it live in dashboard Settings on the host machine");
226
+ }
227
+ else {
228
+ console.log("local mode: loopback only — use --remote for Tailscale access");
229
+ }
230
+ if (args.https) {
231
+ console.log("tls: native HTTPS enabled (cert + key hot-reload on file change)");
232
+ }
233
+ });
234
+ httpServer = server;
235
+ const shutdown = () => {
236
+ console.log("shutting down…");
237
+ server.close();
238
+ pool.stopAll().finally(() => process.exit(0));
239
+ };
240
+ process.on("SIGINT", shutdown);
241
+ process.on("SIGTERM", shutdown);
242
+ }
243
+ // Only run when executed directly (not when imported for the library exports).
244
+ const entryPath = process.argv[1];
245
+ if (entryPath && import.meta.url === new URL(`file://${entryPath}`).href) {
246
+ main().catch((err) => {
247
+ console.error(`fatal: ${err instanceof Error ? err.message : String(err)}`);
248
+ process.exit(1);
249
+ });
250
+ }
251
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n/**\n * dreb-dashboard — launch the dreb web dashboard server.\n *\n * Modes (exactly two, no LAN mode):\n * default loopback bind (127.0.0.1), no auth, no Tailscale needed\n * --remote requires Tailscale; binds all interfaces but every request\n * passes identity allowlist + pairing code + device cookies\n *\n * Usage:\n * dreb-dashboard [--port 5343] [--remote --allow me@example.com [--allow ...]]\n */\n\nimport { existsSync, readFileSync, watch } from \"node:fs\";\nimport { createServer as createHttpServer, type Server as HttpServer } from \"node:http\";\nimport { createServer as createHttpsServer, type Server as HttpsServer } from \"node:https\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { DashboardAuth } from \"./server/auth.js\";\nimport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nimport { RuntimePool } from \"./server/runtime-pool.js\";\nimport { createDashboardServer } from \"./server/server.js\";\n\nexport { DashboardAuth, TailscaleStatusResolver } from \"./server/auth.js\";\nexport { EventHub } from \"./server/event-hub.js\";\nexport { canonicalizePath, FileApi } from \"./server/files.js\";\nexport { FilePairingStorage, loadOrCreateDashboardSecret } from \"./server/pairing-storage.js\";\nexport { RuntimePool, resolveDrebCliPath } from \"./server/runtime-pool.js\";\nexport { createDashboardServer, parseDeviceCookie } from \"./server/server.js\";\nexport type * from \"./shared/protocol.js\";\n\nconst DEFAULT_PORT = 5343;\n\ninterface CliArgs {\n\tport: number;\n\tremote: boolean;\n\tallow: string[];\n\thelp: boolean;\n\thttps: boolean;\n\tcert?: string;\n\tkey?: string;\n}\n\nexport function parseArgs(argv: string[]): CliArgs {\n\tconst args: CliArgs = { port: DEFAULT_PORT, remote: false, allow: [], help: false, https: false };\n\tfor (let i = 0; i < argv.length; i++) {\n\t\tconst arg = argv[i];\n\t\tif (arg === \"--port\") {\n\t\t\tconst value = argv[++i];\n\t\t\tconst port = Number.parseInt(value ?? \"\", 10);\n\t\t\tif (!Number.isInteger(port) || port < 1 || port > 65535) {\n\t\t\t\tthrow new Error(`Invalid --port value: ${value}`);\n\t\t\t}\n\t\t\targs.port = port;\n\t\t} else if (arg === \"--remote\") {\n\t\t\targs.remote = true;\n\t\t} else if (arg === \"--allow\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--allow requires an identity (Tailscale login name)\");\n\t\t\targs.allow.push(value);\n\t\t} else if (arg === \"--https\") {\n\t\t\targs.https = true;\n\t\t} else if (arg === \"--cert\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--cert requires a path to a PEM certificate file\");\n\t\t\targs.cert = value;\n\t\t} else if (arg === \"--key\") {\n\t\t\tconst value = argv[++i];\n\t\t\tif (!value) throw new Error(\"--key requires a path to a PEM private key file\");\n\t\t\targs.key = value;\n\t\t} else if (arg === \"--help\" || arg === \"-h\") {\n\t\t\targs.help = true;\n\t\t} else {\n\t\t\tthrow new Error(`Unknown argument: ${arg}`);\n\t\t}\n\t}\n\tif (args.remote && args.allow.length === 0) {\n\t\tthrow new Error(\"--remote requires at least one --allow <tailscale-login> (empty allowlist denies everyone)\");\n\t}\n\t// Native TLS. `https` is opt-in; it requires cert AND key (no silent\n\t// fallback to plain HTTP). The dashboard terminates TLS itself — no reverse\n\t// proxy, no auth-model change. `req.socket.remoteAddress` stays the real\n\t// tailnet IP, so identity resolution + allowlist + pairing keep working.\n\tif (args.https) {\n\t\tif (!args.cert) throw new Error(\"--https requires --cert <path>\");\n\t\tif (!args.key) throw new Error(\"--https requires --key <path>\");\n\t}\n\treturn args;\n}\n\nconst HELP = `dreb-dashboard — dreb web dashboard server\n\nUsage: dreb-dashboard [options]\n\nOptions:\n --port <n> Port to listen on (default ${DEFAULT_PORT})\n --remote Enable remote mode (requires Tailscale). Without this\n flag the server binds 127.0.0.1 only — no LAN access.\n --allow <identity> Tailscale login name allowed to pair (repeatable;\n required with --remote)\n --https Terminate TLS on the dashboard itself (native TLS). No\n reverse proxy, no auth-model change. Requires --cert\n and --key. Mainly for --remote (loopback is already a\n secure context): use 'tailscale cert' files for a\n tailnet hostname so mobile PWAs + notifications work.\n NOTE: with --https the server speaks TLS only, so the\n host's plain-http local tab (http://127.0.0.1) stops\n working — use the tailnet hostname (https://...) there.\n --cert <path> PEM certificate file (required with --https)\n --key <path> PEM private key file (required with --https)\n --help Show this help\n`;\n\nasync function main(): Promise<void> {\n\tlet args: CliArgs;\n\ttry {\n\t\targs = parseArgs(process.argv.slice(2));\n\t} catch (err) {\n\t\tconsole.error(`error: ${err instanceof Error ? err.message : String(err)}`);\n\t\tconsole.error(HELP);\n\t\tprocess.exit(1);\n\t}\n\tif (args.help) {\n\t\tconsole.log(HELP);\n\t\treturn;\n\t}\n\n\tconst agentDir = join(homedir(), \".dreb\", \"agent\");\n\tconst auth = new DashboardAuth({\n\t\tremoteEnabled: args.remote,\n\t\tallowedIdentities: args.allow,\n\t\tstorage: new FilePairingStorage(join(agentDir, \"dashboard-pairings.json\")),\n\t\tsecret: loadOrCreateDashboardSecret(join(agentDir, \"dashboard-auth-secret\")),\n\t\tlogger: (line) => console.warn(`[dashboard-auth] ${line}`),\n\t});\n\tconst pool = new RuntimePool();\n\n\t// Static client assets live next to the compiled server (dist/static).\n\tconst staticDir = join(dirname(fileURLToPath(import.meta.url)), \"static\");\n\n\t// The server's own build version (dist/index.js → ../package.json) — surfaced\n\t// in the settings footer so a stale long-running service is spottable.\n\tlet serverVersion: string | undefined;\n\ttry {\n\t\tconst pkgPath = join(dirname(fileURLToPath(import.meta.url)), \"..\", \"package.json\");\n\t\tserverVersion = JSON.parse(readFileSync(pkgPath, \"utf8\")).version;\n\t} catch {\n\t\tserverVersion = undefined;\n\t}\n\n\t// Restart hook: exit non-zero so a supervisor (systemd Restart=on-failure,\n\t// etc.) respawns the process with the freshly-built dist. Assigned the http\n\t// server below via a mutable holder so the closure can close it first.\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tconst onRestart = () => {\n\t\tconsole.log(\"restart requested — exiting for supervisor to respawn\");\n\t\thttpServer?.close();\n\t\tpool.stopAll().finally(() => process.exit(1));\n\t};\n\n\tconst { SessionManager } = await import(\"@dreb/coding-agent\");\n\tconst app = createDashboardServer({\n\t\tauth,\n\t\tpool,\n\t\tstaticDir: existsSync(staticDir) ? staticDir : undefined,\n\t\tserverVersion,\n\t\tonRestart,\n\t\tlistAllSessions: () => SessionManager.listAll(),\n\t\tdeleteSession: async (path: string) => {\n\t\t\tconst result = await SessionManager.deleteSession(path, {});\n\t\t\tif (!result.ok) throw new Error(result.error ?? \"Unknown deletion error\");\n\t\t\treturn { method: result.method };\n\t\t},\n\t});\n\n\tconst host = args.remote ? \"0.0.0.0\" : \"127.0.0.1\";\n\tconst scheme = args.https ? \"https\" : \"http\";\n\n\t// Native TLS: the dashboard terminates TLS itself (no reverse proxy). The\n\t// auth model is unchanged — `req.socket.remoteAddress` is still the real\n\t// tailnet IP, so Tailscale identity resolution + allowlist + pairing keep\n\t// working. Local mode never sets --https (loopback is already a secure\n\t// context), but it's allowed if the operator wants it.\n\tlet server: HttpServer | HttpsServer;\n\tif (args.https && args.cert && args.key) {\n\t\tlet tlsOptions: { cert: string; key: string };\n\t\ttry {\n\t\t\ttlsOptions = {\n\t\t\t\tcert: readFileSync(args.cert, \"utf-8\"),\n\t\t\t\tkey: readFileSync(args.key, \"utf-8\"),\n\t\t\t};\n\t\t} catch (err) {\n\t\t\tconsole.error(`error: failed to read TLS cert/key: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconst httpsServer = createHttpsServer(tlsOptions, app);\n\t\t// Hot-reload the cert on file change (zero-downtime renewal — e.g. a\n\t\t// systemd timer rewrites the `tailscale cert` files daily). New\n\t\t// connections pick up the new cert; existing ones finish on the old.\n\t\tconst reloadTls = () => {\n\t\t\ttry {\n\t\t\t\thttpsServer.setSecureContext({\n\t\t\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t\t\t});\n\t\t\t\tconsole.log(\"tls certificate reloaded\");\n\t\t\t} catch (err) {\n\t\t\t\tconsole.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t}\n\t\t};\n\t\t// Debounce: cert + key are often rewritten in quick succession.\n\t\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\t\tfor (const file of [args.cert, args.key]) {\n\t\t\twatch(file!, () => {\n\t\t\t\tif (reloadTimer) clearTimeout(reloadTimer);\n\t\t\t\treloadTimer = setTimeout(reloadTls, 500);\n\t\t\t});\n\t\t}\n\t\tserver = httpsServer;\n\t} else {\n\t\tserver = createHttpServer(app);\n\t}\n\tserver.listen(args.port, host, () => {\n\t\tconsole.log(\n\t\t\t`dreb dashboard listening on ${scheme}://${host === \"0.0.0.0\" ? \"<tailscale-ip>\" : host}:${args.port}`,\n\t\t);\n\t\tif (args.remote) {\n\t\t\tconsole.log(`remote mode: allowed identities = ${args.allow.join(\", \")}`);\n\t\t\tconst { code, expiresInMs } = auth.currentPairingCode();\n\t\t\tconsole.log(\n\t\t\t\t`pairing code: ${code} (rotates every 30s; current code rolls in ${Math.ceil(expiresInMs / 1000)}s)`,\n\t\t\t);\n\t\t\tconsole.log(\"new devices enter this code; see it live in dashboard Settings on the host machine\");\n\t\t} else {\n\t\t\tconsole.log(\"local mode: loopback only — use --remote for Tailscale access\");\n\t\t}\n\t\tif (args.https) {\n\t\t\tconsole.log(\"tls: native HTTPS enabled (cert + key hot-reload on file change)\");\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => {\n\t\tconsole.log(\"shutting down…\");\n\t\tserver.close();\n\t\tpool.stopAll().finally(() => process.exit(0));\n\t};\n\tprocess.on(\"SIGINT\", shutdown);\n\tprocess.on(\"SIGTERM\", shutdown);\n}\n\n// Only run when executed directly (not when imported for the library exports).\nconst entryPath = process.argv[1];\nif (entryPath && import.meta.url === new URL(`file://${entryPath}`).href) {\n\tmain().catch((err) => {\n\t\tconsole.error(`fatal: ${err instanceof Error ? err.message : String(err)}`);\n\t\tprocess.exit(1);\n\t});\n}\n"]}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Dashboard auth — exactly two modes (this file is the authority):
3
+ *
4
+ * Mode A — local-only (default): the server binds loopback only. Requests are
5
+ * additionally checked for loopback source address AND an allowlisted Host
6
+ * header (DNS-rebinding defense: a malicious website can point its own domain
7
+ * at 127.0.0.1 and drive the API from the victim's browser unless Host is
8
+ * validated). No login, no pairing.
9
+ *
10
+ * Mode B — remote (explicit opt-in): requires Tailscale. Enforcement layers,
11
+ * all fail-closed: (1) Tailscale identity resolution of the peer address,
12
+ * (2) identity allowlist (empty allowlist = deny all), (3) first-login
13
+ * rotating pairing code (visible only from the host/local dashboard),
14
+ * (4) signed per-device cookie thereafter.
15
+ *
16
+ * There is no LAN mode. Any auth-subsystem error denies the request.
17
+ */
18
+ /** Normalize an address for comparison (strip IPv6-mapped IPv4 prefix and zone). */
19
+ export declare function normalizeAddress(address: string | undefined): string;
20
+ /** True when the (normalized) address is a loopback address. */
21
+ export declare function isLoopbackAddress(address: string | undefined): boolean;
22
+ /**
23
+ * Validate a Host header against the loopback allowlist. Rejecting foreign
24
+ * hosts breaks DNS rebinding: the attacker's page can reach 127.0.0.1, but its
25
+ * requests carry the attacker's hostname in Host.
26
+ */
27
+ export declare function isAllowedLocalHost(hostHeader: string | undefined): boolean;
28
+ export interface TailscaleIdentity {
29
+ /** Login name (e.g. "alice@example.com") — the allowlist unit. */
30
+ loginName: string;
31
+ /** Device host name, when known. */
32
+ device?: string;
33
+ }
34
+ export interface TailscaleResolver {
35
+ /** Resolve a peer IP to a Tailscale identity, or null when unknown. */
36
+ resolve(address: string): Promise<TailscaleIdentity | null>;
37
+ }
38
+ /** Resolves identities via `tailscale status --json`. Any failure resolves null (deny). */
39
+ export declare class TailscaleStatusResolver implements TailscaleResolver {
40
+ resolve(address: string): Promise<TailscaleIdentity | null>;
41
+ }
42
+ export interface PairedDevice {
43
+ id: string;
44
+ identity: string;
45
+ device?: string;
46
+ createdAt: string;
47
+ expiresAt: string;
48
+ }
49
+ export interface StoredPairing extends PairedDevice {
50
+ /** HMAC of the device token (raw token never stored). */
51
+ tokenHmac: string;
52
+ }
53
+ export interface PairingState {
54
+ pairings: StoredPairing[];
55
+ consumedPairingWindows: number[];
56
+ }
57
+ export interface PairingStorage {
58
+ load(): Promise<PairingState>;
59
+ save(state: PairingState): Promise<void>;
60
+ }
61
+ /** In-memory storage — used in tests and as the base for the file store. */
62
+ export declare class MemoryPairingStorage implements PairingStorage {
63
+ private state;
64
+ load(): Promise<PairingState>;
65
+ save(state: PairingState): Promise<void>;
66
+ }
67
+ export interface DashboardAuthOptions {
68
+ /** Remote (Tailscale) mode. Default false — loopback only. */
69
+ remoteEnabled?: boolean;
70
+ /** Allowed Tailscale login names. Empty = deny all remote. */
71
+ allowedIdentities?: string[];
72
+ /** Device pairing validity. Default 30 days. */
73
+ pairingTtlMs?: number;
74
+ resolver?: TailscaleResolver;
75
+ storage?: PairingStorage;
76
+ /** HMAC/TOTP secret for device tokens and pairing codes. Production passes a per-install persisted secret. */
77
+ secret?: Buffer;
78
+ /** Failed PIN attempts before temporary lockout. Default 5. */
79
+ pairingMaxAttempts?: number;
80
+ /** Temporary lockout duration after too many failed PIN attempts. Default 60s. */
81
+ pairingLockoutMs?: number;
82
+ /** Security/audit log sink for repeated failed pairing attempts. */
83
+ logger?: (line: string) => void;
84
+ /** Clock override for tests. */
85
+ now?: () => number;
86
+ }
87
+ export type AuthDecision = {
88
+ allowed: true;
89
+ mode: "local";
90
+ } | {
91
+ allowed: true;
92
+ mode: "remote";
93
+ identity: TailscaleIdentity;
94
+ } | {
95
+ allowed: false;
96
+ status: number;
97
+ reason: string;
98
+ /** Set when an allowed identity needs pairing-code entry. */
99
+ needsPairing?: boolean;
100
+ identity?: TailscaleIdentity;
101
+ };
102
+ export interface AuthRequestInfo {
103
+ remoteAddress: string | undefined;
104
+ hostHeader: string | undefined;
105
+ /** Origin header when present. Non-loopback origins are rejected on local requests. */
106
+ originHeader: string | undefined;
107
+ /** Value of the dashboard device cookie, when present. */
108
+ deviceToken: string | undefined;
109
+ }
110
+ export declare class DashboardAuth {
111
+ private readonly remoteEnabled;
112
+ private readonly allowedIdentities;
113
+ private readonly pairingTtlMs;
114
+ private readonly resolver;
115
+ private readonly storage;
116
+ private readonly secret;
117
+ private readonly pairingMaxAttempts;
118
+ private readonly pairingLockoutMs;
119
+ private readonly logger;
120
+ private readonly now;
121
+ private pairingMutation;
122
+ private readonly pairingFailures;
123
+ constructor(options?: DashboardAuthOptions);
124
+ get isRemoteEnabled(): boolean;
125
+ private hmac;
126
+ private pairingCodeForWindow;
127
+ /** Current RFC-6238-style rotating code for pairing new remote devices. */
128
+ currentPairingCode(): {
129
+ code: string;
130
+ expiresInMs: number;
131
+ };
132
+ private currentPairingWindow;
133
+ private matchingPairingCodeWindow;
134
+ private pruneConsumedPairingWindows;
135
+ private samePairingWindows;
136
+ private pairingFailureKey;
137
+ private assertPairingNotLocked;
138
+ private recordPairingFailure;
139
+ private clearPairingFailures;
140
+ private withPairingMutation;
141
+ /**
142
+ * Authenticate a request. Fail-closed: any resolver/storage error results in
143
+ * a deny, never a pass-through.
144
+ */
145
+ authenticate(info: AuthRequestInfo): Promise<AuthDecision>;
146
+ private authenticateInner;
147
+ /**
148
+ * Complete pairing for an allowed remote identity using the current rotating
149
+ * code. Returns the device token to set as a cookie. Throws (with `status`) on
150
+ * any failure.
151
+ */
152
+ pair(info: AuthRequestInfo, code: string): Promise<{
153
+ token: string;
154
+ device: PairedDevice;
155
+ }>;
156
+ /** List paired devices (live only). */
157
+ listDevices(): Promise<PairedDevice[]>;
158
+ /** Remove a paired device by id. Returns true when something was removed. */
159
+ unpair(deviceId: string): Promise<boolean>;
160
+ private isPaired;
161
+ private loadLive;
162
+ private loadLiveState;
163
+ }
164
+ //# sourceMappingURL=auth.d.ts.map