@dreb/dashboard 2.45.2 → 2.45.4

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 (36) hide show
  1. package/README.md +39 -13
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +9 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/server/dashboard-images.d.ts +56 -0
  7. package/dist/server/dashboard-images.d.ts.map +1 -0
  8. package/dist/server/dashboard-images.js +317 -0
  9. package/dist/server/dashboard-images.js.map +1 -0
  10. package/dist/server/event-hub.d.ts +3 -0
  11. package/dist/server/event-hub.d.ts.map +1 -1
  12. package/dist/server/event-hub.js +7 -1
  13. package/dist/server/event-hub.js.map +1 -1
  14. package/dist/server/image-preview-worker.d.ts +7 -0
  15. package/dist/server/image-preview-worker.d.ts.map +1 -0
  16. package/dist/server/image-preview-worker.js +150 -0
  17. package/dist/server/image-preview-worker.js.map +1 -0
  18. package/dist/server/image-preview.d.ts +29 -0
  19. package/dist/server/image-preview.d.ts.map +1 -0
  20. package/dist/server/image-preview.js +76 -0
  21. package/dist/server/image-preview.js.map +1 -0
  22. package/dist/server/server.d.ts +8 -1
  23. package/dist/server/server.d.ts.map +1 -1
  24. package/dist/server/server.js +85 -4
  25. package/dist/server/server.js.map +1 -1
  26. package/dist/shared/protocol.d.ts +16 -0
  27. package/dist/shared/protocol.d.ts.map +1 -1
  28. package/dist/shared/protocol.js +1 -0
  29. package/dist/shared/protocol.js.map +1 -1
  30. package/dist/static/assets/index-BcGevXt-.css +1 -0
  31. package/dist/static/assets/index-d9zGbh25.js +79 -0
  32. package/dist/static/index.html +2 -2
  33. package/dist/static/sw.js +1 -1
  34. package/package.json +2 -1
  35. package/dist/static/assets/index-BJcyaSXg.js +0 -79
  36. package/dist/static/assets/index-DdcUWCjw.css +0 -1
package/README.md CHANGED
@@ -42,7 +42,7 @@ Open `http://127.0.0.1:5343`.
42
42
  cards with sanitized inline PNG/JPEG/GIF/WebP result images, thinking blocks,
43
43
  inline provider/API failures with partial output preserved, compaction summaries,
44
44
  per-message copy, tasks panel, suggest-next chip,
45
- slash-command autocomplete, image attach/paste,
45
+ slash-command autocomplete, image attach/paste with sent-image previews retained in user transcript entries,
46
46
  queued-message restore, persistent session-header live indicator, footer-parity info bar (branch, tokens, cost, ctx%,
47
47
  median tok/s), stats/loaded-context/fork modals, steer/follow-up composer
48
48
  modes, ■ abort, model/thinking switchers, extension-UI modals, export HTML,
@@ -66,23 +66,44 @@ Open `http://127.0.0.1:5343`.
66
66
  global + project settings so external edits appear, while read/parse/write
67
67
  failures are shown instead of stale values. Trust changes are observed by
68
68
  active processes for future lazy loads and cannot retract already injected
69
- context. Also includes dashboard-local preferences (thinking expansion and
70
- notification permission), an appearance section with a curated-theme gallery
69
+ context. Also includes dashboard-local preferences (thinking expansion,
70
+ transcript image display mode, and notification permission), an appearance section with a curated-theme gallery
71
71
  (entropist.ca / Dim / Solarized / Gruvbox / Caves of Qud / Van Gogh /
72
72
  Okabe-Ito / Paul Tol — the last two colorblind-safe — live preview cards,
73
73
  system/light/dark mode selector, saved per browser), current pairing code,
74
74
  and paired-devices management.
75
75
  - **Pairing** — remote first-login rotating-code flow.
76
76
 
77
- ### Tool-result images
78
-
79
- Image blocks returned by any tool render inline for the human viewing the
80
- transcript, even when the active model does not support vision. The dashboard
81
- accepts only PNG, JPEG, GIF, and WebP with valid base64 payloads; SVG and
82
- malformed blocks are dropped. Accepted images also survive refresh/resync and
83
- are embedded in exported HTML transcripts. Oversized live events use the
84
- existing SSE resync barrier and HTTP snapshot path; CSS bounds display
85
- dimensions but does not compress the underlying bytes.
77
+ ### Transcript images
78
+
79
+ Image blocks returned by any tool and images uploaded with a user turn render
80
+ for the human viewing the transcript, even when the active model does not
81
+ support vision. Before browser-facing SSE,
82
+ replay, hydrate, resync, parent-message, or subagent-message serialization, the
83
+ server validates exact base64, an exact PNG/JPEG/GIF/WebP MIME allowlist, and a
84
+ matching raster signature. It stores accepted originals under a content ID and
85
+ sends only `{id, mimeType, size}` references; SVG, malformed data, and MIME
86
+ mismatches are dropped. Image bytes therefore never consume SSE frame/replay
87
+ budgets or cause an `oversized_event` barrier.
88
+
89
+ The browser-local `dreb.dashboard.imageDisplayMode` preference is separate from
90
+ model-input auto-resize/block settings. **Bounded previews** are the default:
91
+ they are generated lazily in a worker, fit within 1024 × 1024 and 256 KiB, and
92
+ a click enlarges the same preview without fetching the original. **Placeholders**
93
+ make no request until the user chooses one. **Automatic originals** are an
94
+ informed Settings opt-in. Every non-original view labels the original size;
95
+ explicit downloads above 1 MiB confirm before assigning an original URL. GIF
96
+ previews are static PNG/JPEG frames, while the original route returns the exact
97
+ animated GIF.
98
+
99
+ Preview/original routes are authenticated and same-origin, return exact
100
+ allowlisted content types, `Content-Length`, `X-Content-Type-Options: nosniff`,
101
+ and private immutable caching. A 64 MiB / 2,000-record LRU deduplicates repeated
102
+ copies. On cache miss or server restart, a route re-reads only that parent or
103
+ subagent's authoritative transcript and fails explicitly if a transient image
104
+ is no longer present. Stopping a runtime revokes its image scopes. Authoritative
105
+ session history remains unchanged, so HTML exports still embed sanitized,
106
+ full-resolution originals and remain self-contained.
86
107
 
87
108
  ### Provider failures and retries
88
109
 
@@ -138,7 +159,8 @@ The server replays reducer-relevant projected envelopes from history bounded by
138
159
  both count and bytes, with a separate byte cap for each replay. A server restart,
139
160
  sequence gap, history eviction, or over-budget replay sends only that reconnect
140
161
  a resync barrier at the current cursor, not a partial replay; an individually
141
- oversized event sends a global barrier because every browser missed it.
162
+ oversized **non-image** event sends a global barrier because every browser missed it;
163
+ tool-image bytes have already been replaced by references before frame sizing.
142
164
 
143
165
  Recovery fetches an authoritative snapshot whose HTTP `barrierSeq` was captured
144
166
  synchronously at the RPC snapshot marker, discards queued envelopes through that
@@ -275,6 +297,10 @@ Browser (SolidJS, hash-routed SPA)
275
297
  - Background subagent transcripts arrive over the same pipe via the
276
298
  `background_agent_event` relay (see `docs/rpc.md` in
277
299
  `@dreb/coding-agent`) — no session-file tailing.
300
+ - Programmatic callers of `createDashboardServer()` must call the returned
301
+ app's idempotent `closeDashboard()` hook when their HTTP server stops. This
302
+ terminates the lazy image-preview worker without requiring access to the
303
+ internally created image service.
278
304
  - The visual language is `tokens.css` (`src/client/styles/tokens.css`),
279
305
  the dashboard's design system. Its defaults are unchanged; `themes.css` is
280
306
  an **additive layer** on top that overrides the design tokens only when a
package/dist/index.d.ts CHANGED
@@ -12,8 +12,10 @@
12
12
  */
13
13
  import { watch } from "node:fs";
14
14
  export { DashboardAuth, TailscaleStatusResolver } from "./server/auth.js";
15
+ export { DashboardImageService } from "./server/dashboard-images.js";
15
16
  export { EventHub } from "./server/event-hub.js";
16
17
  export { canonicalizePath, FileApi } from "./server/files.js";
18
+ export { ImagePreviewWorker } from "./server/image-preview.js";
17
19
  export { FilePairingStorage, loadOrCreateDashboardSecret } from "./server/pairing-storage.js";
18
20
  export { RuntimePool, resolveDrebCliPath } from "./server/runtime-pool.js";
19
21
  export { createDashboardServer, parseDeviceCookie } from "./server/server.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","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 { basename, 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\n/**\n * Warning shown at startup when `--remote` binds the tailnet over plain HTTP\n * (no `--https`). Browsers treat plain HTTP over a non-loopback host as an\n * INSECURE context, so service workers and the Notification API are\n * unavailable — PWA install and mobile notifications (iOS exposes no\n * Notification API at all there) silently never work. Loopback (127.0.0.1) is\n * exempt (it counts as secure), so pure-local mode returns no warning.\n *\n * Returns the multi-line warning string, or `undefined` when TLS is enabled or\n * the server is loopback-only. Kept pure (no console) so it is unit-testable.\n */\nexport function insecureRemoteWarning(args: Pick<CliArgs, \"remote\" | \"https\">): string | undefined {\n\tif (!args.remote || args.https) return undefined;\n\treturn (\n\t\t\"⚠ warning: --remote without --https serves plain HTTP over the tailnet, which browsers treat as an\\n\" +\n\t\t\" INSECURE context — service workers, PWA install, and mobile notifications (especially iOS) will NOT work.\\n\" +\n\t\t\" Enable TLS for the tailnet hostname:\\n\" +\n\t\t\" tailscale cert <host>.<tailnet>.ts.net\\n\" +\n\t\t\" …then relaunch with --https --cert <host>.<tailnet>.ts.net.crt --key <host>.<tailnet>.ts.net.key\\n\" +\n\t\t\" and open the dashboard via https://<host>.<tailnet>.ts.net (the hostname, not the tailnet IP).\"\n\t);\n}\n\ntype ShutdownWatcher = Pick<ReturnType<typeof watch>, \"close\">;\n\ninterface ShutdownDeps {\n\tlog: (message: string) => void;\n\tclearReloadTimer: () => void;\n\twatchers: Iterable<ShutdownWatcher>;\n\tcloseServer: () => void;\n\tstopAll: () => Promise<unknown>;\n\texit: (code: number) => void;\n}\n\nexport function createShutdown(deps: ShutdownDeps): (message: string, exitCode: number) => void {\n\tlet shuttingDown = false;\n\treturn (message: string, exitCode: number) => {\n\t\tif (shuttingDown) return;\n\t\tshuttingDown = true;\n\t\tdeps.log(message);\n\t\tdeps.clearReloadTimer();\n\t\tfor (const watcher of deps.watchers) watcher.close();\n\t\tdeps.closeServer();\n\t\tvoid deps\n\t\t\t.stopAll()\n\t\t\t.catch((err) => {\n\t\t\t\tdeps.log(`shutdown teardown failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t})\n\t\t\t.finally(() => deps.exit(exitCode));\n\t};\n}\n\ntype TlsOptions = { cert: string; key: string };\ntype TlsWatchFilename = string | Buffer | null;\ntype TlsWatchListener = (event: string, filename: TlsWatchFilename) => void;\ntype TlsFileWatcher = ShutdownWatcher & {\n\ton(event: \"error\", listener: (err: unknown) => void): unknown;\n};\n\ninterface TlsReloaderDeps {\n\treadTlsOptions: () => TlsOptions;\n\tsetSecureContext: (options: TlsOptions) => void;\n\tlog: (message: string) => void;\n\twarn: (message: string) => void;\n}\n\nexport function createTlsReloader(deps: TlsReloaderDeps): () => void {\n\treturn () => {\n\t\ttry {\n\t\t\tdeps.setSecureContext(deps.readTlsOptions());\n\t\t\tdeps.log(\"tls certificate reloaded\");\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t};\n}\n\nexport function shouldReloadForFile(filename: TlsWatchFilename, certBase: string, keyBase: string): boolean {\n\tif (filename == null) return true;\n\tif (typeof filename !== \"string\") return false;\n\treturn filename === certBase || filename === keyBase;\n}\n\ninterface TlsWatchDeps {\n\tcertPath: string;\n\tkeyPath: string;\n\twatchDirectory: (directory: string, listener: TlsWatchListener) => TlsFileWatcher;\n\treloadTls: () => void;\n\twarn: (message: string) => void;\n\tdebounceMs?: number;\n}\n\ninterface TlsWatchController {\n\twatchers: TlsFileWatcher[];\n\tclearReloadTimer: () => void;\n}\n\nexport function createTlsWatchers(deps: TlsWatchDeps): TlsWatchController {\n\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\tconst watchers: TlsFileWatcher[] = [];\n\tconst debounceMs = deps.debounceMs ?? 500;\n\tconst certBase = basename(deps.certPath);\n\tconst keyBase = basename(deps.keyPath);\n\tconst clearReloadTimer = () => {\n\t\tif (!reloadTimer) return;\n\t\tclearTimeout(reloadTimer);\n\t\treloadTimer = undefined;\n\t};\n\tconst scheduleReload = () => {\n\t\tclearReloadTimer();\n\t\treloadTimer = setTimeout(deps.reloadTls, debounceMs);\n\t};\n\n\tfor (const dir of new Set([dirname(deps.certPath), dirname(deps.keyPath)])) {\n\t\ttry {\n\t\t\tconst watcher = deps.watchDirectory(dir, (_event, filename) => {\n\t\t\t\t// `fs.watch` directory events on macOS (FSEvents) frequently fire\n\t\t\t\t// with `filename === null`; fall through and reload anyway.\n\t\t\t\t// On Linux (inotify) the basename is carried reliably and filters\n\t\t\t\t// out unrelated files in the parent directory.\n\t\t\t\tif (!shouldReloadForFile(filename, certBase, keyBase)) return;\n\t\t\t\tscheduleReload();\n\t\t\t});\n\t\t\twatcher.on(\"error\", (err) => {\n\t\t\t\tdeps.warn(`tls cert watch error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t\t\twatchers.push(watcher);\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls cert watch setup failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\treturn { watchers, clearReloadTimer };\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 Plain HTTP over the tailnet is an INSECURE context: add\n --https (below) or mobile PWA install + notifications\n (especially iOS) will not work.\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\t// The TLS hot-reload debounce timer and directory watchers are lifted here\n\t// too, so restart and signal shutdown can release them (otherwise they leak\n\t// across restart/exit).\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tlet clearTlsReloadTimer = () => {};\n\tconst tlsWatchers: TlsFileWatcher[] = [];\n\tconst beginShutdown = createShutdown({\n\t\tlog: (message) => console.log(message),\n\t\tclearReloadTimer: () => clearTlsReloadTimer(),\n\t\twatchers: tlsWatchers,\n\t\tcloseServer: () => httpServer?.close(),\n\t\tstopAll: () => pool.stopAll(),\n\t\texit: (code) => process.exit(code),\n\t});\n\tconst onRestart = () => beginShutdown(\"restart requested — exiting for supervisor to respawn\", 1);\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\tconst readTlsOptions = () => ({\n\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t});\n\t\tlet tlsOptions: TlsOptions;\n\t\ttry {\n\t\t\ttlsOptions = readTlsOptions();\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 = createTlsReloader({\n\t\t\treadTlsOptions,\n\t\t\tsetSecureContext: (options) => httpsServer.setSecureContext(options),\n\t\t\tlog: (message) => console.log(message),\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\t// Debounce: cert + key are often rewritten in quick succession. Watch\n\t\t// the PARENT DIRECTORY (not each file) and filter by filename: `fs.watch`\n\t\t// follows the inode, so an atomic renewal (write temp + rename(2))\n\t\t// replaces the inode and a file-level watcher goes silent on the new\n\t\t// file after the first reload. A directory watcher survives renames and\n\t\t// reports the changed basename via the `filename` callback argument.\n\t\tconst tlsWatchController = createTlsWatchers({\n\t\t\tcertPath: args.cert,\n\t\t\tkeyPath: args.key,\n\t\t\twatchDirectory: (dir, listener) => watch(dir, listener),\n\t\t\treloadTls,\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\tclearTlsReloadTimer = tlsWatchController.clearReloadTimer;\n\t\ttlsWatchers.push(...tlsWatchController.watchers);\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} else {\n\t\t\tconst insecureWarning = insecureRemoteWarning(args);\n\t\t\tif (insecureWarning) console.warn(insecureWarning);\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => beginShutdown(\"shutting down…\", 0);\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"]}
1
+ {"version":3,"file":"index.d.ts","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 { basename, dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { DashboardAuth } from \"./server/auth.js\";\nimport { DashboardImageService } from \"./server/dashboard-images.js\";\nimport { ImagePreviewWorker } from \"./server/image-preview.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 { DashboardImageService } from \"./server/dashboard-images.js\";\nexport { EventHub } from \"./server/event-hub.js\";\nexport { canonicalizePath, FileApi } from \"./server/files.js\";\nexport { ImagePreviewWorker } from \"./server/image-preview.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\n/**\n * Warning shown at startup when `--remote` binds the tailnet over plain HTTP\n * (no `--https`). Browsers treat plain HTTP over a non-loopback host as an\n * INSECURE context, so service workers and the Notification API are\n * unavailable — PWA install and mobile notifications (iOS exposes no\n * Notification API at all there) silently never work. Loopback (127.0.0.1) is\n * exempt (it counts as secure), so pure-local mode returns no warning.\n *\n * Returns the multi-line warning string, or `undefined` when TLS is enabled or\n * the server is loopback-only. Kept pure (no console) so it is unit-testable.\n */\nexport function insecureRemoteWarning(args: Pick<CliArgs, \"remote\" | \"https\">): string | undefined {\n\tif (!args.remote || args.https) return undefined;\n\treturn (\n\t\t\"⚠ warning: --remote without --https serves plain HTTP over the tailnet, which browsers treat as an\\n\" +\n\t\t\" INSECURE context — service workers, PWA install, and mobile notifications (especially iOS) will NOT work.\\n\" +\n\t\t\" Enable TLS for the tailnet hostname:\\n\" +\n\t\t\" tailscale cert <host>.<tailnet>.ts.net\\n\" +\n\t\t\" …then relaunch with --https --cert <host>.<tailnet>.ts.net.crt --key <host>.<tailnet>.ts.net.key\\n\" +\n\t\t\" and open the dashboard via https://<host>.<tailnet>.ts.net (the hostname, not the tailnet IP).\"\n\t);\n}\n\ntype ShutdownWatcher = Pick<ReturnType<typeof watch>, \"close\">;\n\ninterface ShutdownDeps {\n\tlog: (message: string) => void;\n\tclearReloadTimer: () => void;\n\twatchers: Iterable<ShutdownWatcher>;\n\tcloseServer: () => void;\n\tstopAll: () => Promise<unknown>;\n\texit: (code: number) => void;\n}\n\nexport function createShutdown(deps: ShutdownDeps): (message: string, exitCode: number) => void {\n\tlet shuttingDown = false;\n\treturn (message: string, exitCode: number) => {\n\t\tif (shuttingDown) return;\n\t\tshuttingDown = true;\n\t\tdeps.log(message);\n\t\tdeps.clearReloadTimer();\n\t\tfor (const watcher of deps.watchers) watcher.close();\n\t\tdeps.closeServer();\n\t\tvoid deps\n\t\t\t.stopAll()\n\t\t\t.catch((err) => {\n\t\t\t\tdeps.log(`shutdown teardown failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t})\n\t\t\t.finally(() => deps.exit(exitCode));\n\t};\n}\n\ntype TlsOptions = { cert: string; key: string };\ntype TlsWatchFilename = string | Buffer | null;\ntype TlsWatchListener = (event: string, filename: TlsWatchFilename) => void;\ntype TlsFileWatcher = ShutdownWatcher & {\n\ton(event: \"error\", listener: (err: unknown) => void): unknown;\n};\n\ninterface TlsReloaderDeps {\n\treadTlsOptions: () => TlsOptions;\n\tsetSecureContext: (options: TlsOptions) => void;\n\tlog: (message: string) => void;\n\twarn: (message: string) => void;\n}\n\nexport function createTlsReloader(deps: TlsReloaderDeps): () => void {\n\treturn () => {\n\t\ttry {\n\t\t\tdeps.setSecureContext(deps.readTlsOptions());\n\t\t\tdeps.log(\"tls certificate reloaded\");\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t};\n}\n\nexport function shouldReloadForFile(filename: TlsWatchFilename, certBase: string, keyBase: string): boolean {\n\tif (filename == null) return true;\n\tif (typeof filename !== \"string\") return false;\n\treturn filename === certBase || filename === keyBase;\n}\n\ninterface TlsWatchDeps {\n\tcertPath: string;\n\tkeyPath: string;\n\twatchDirectory: (directory: string, listener: TlsWatchListener) => TlsFileWatcher;\n\treloadTls: () => void;\n\twarn: (message: string) => void;\n\tdebounceMs?: number;\n}\n\ninterface TlsWatchController {\n\twatchers: TlsFileWatcher[];\n\tclearReloadTimer: () => void;\n}\n\nexport function createTlsWatchers(deps: TlsWatchDeps): TlsWatchController {\n\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\tconst watchers: TlsFileWatcher[] = [];\n\tconst debounceMs = deps.debounceMs ?? 500;\n\tconst certBase = basename(deps.certPath);\n\tconst keyBase = basename(deps.keyPath);\n\tconst clearReloadTimer = () => {\n\t\tif (!reloadTimer) return;\n\t\tclearTimeout(reloadTimer);\n\t\treloadTimer = undefined;\n\t};\n\tconst scheduleReload = () => {\n\t\tclearReloadTimer();\n\t\treloadTimer = setTimeout(deps.reloadTls, debounceMs);\n\t};\n\n\tfor (const dir of new Set([dirname(deps.certPath), dirname(deps.keyPath)])) {\n\t\ttry {\n\t\t\tconst watcher = deps.watchDirectory(dir, (_event, filename) => {\n\t\t\t\t// `fs.watch` directory events on macOS (FSEvents) frequently fire\n\t\t\t\t// with `filename === null`; fall through and reload anyway.\n\t\t\t\t// On Linux (inotify) the basename is carried reliably and filters\n\t\t\t\t// out unrelated files in the parent directory.\n\t\t\t\tif (!shouldReloadForFile(filename, certBase, keyBase)) return;\n\t\t\t\tscheduleReload();\n\t\t\t});\n\t\t\twatcher.on(\"error\", (err) => {\n\t\t\t\tdeps.warn(`tls cert watch error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t\t\twatchers.push(watcher);\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls cert watch setup failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\treturn { watchers, clearReloadTimer };\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 Plain HTTP over the tailnet is an INSECURE context: add\n --https (below) or mobile PWA install + notifications\n (especially iOS) will not work.\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\tconst imageService = new DashboardImageService(new ImagePreviewWorker());\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\t// The TLS hot-reload debounce timer and directory watchers are lifted here\n\t// too, so restart and signal shutdown can release them (otherwise they leak\n\t// across restart/exit).\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tlet clearTlsReloadTimer = () => {};\n\tlet closeDashboard = () => imageService.close();\n\tconst tlsWatchers: TlsFileWatcher[] = [];\n\tconst beginShutdown = createShutdown({\n\t\tlog: (message) => console.log(message),\n\t\tclearReloadTimer: () => clearTlsReloadTimer(),\n\t\twatchers: tlsWatchers,\n\t\tcloseServer: () => httpServer?.close(),\n\t\tstopAll: () => Promise.all([pool.stopAll(), closeDashboard()]),\n\t\texit: (code) => process.exit(code),\n\t});\n\tconst onRestart = () => beginShutdown(\"restart requested — exiting for supervisor to respawn\", 1);\n\n\tconst { SessionManager } = await import(\"@dreb/coding-agent\");\n\tconst app = createDashboardServer({\n\t\tauth,\n\t\tpool,\n\t\timageService,\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\tcloseDashboard = () => app.closeDashboard();\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\tconst readTlsOptions = () => ({\n\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t});\n\t\tlet tlsOptions: TlsOptions;\n\t\ttry {\n\t\t\ttlsOptions = readTlsOptions();\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 = createTlsReloader({\n\t\t\treadTlsOptions,\n\t\t\tsetSecureContext: (options) => httpsServer.setSecureContext(options),\n\t\t\tlog: (message) => console.log(message),\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\t// Debounce: cert + key are often rewritten in quick succession. Watch\n\t\t// the PARENT DIRECTORY (not each file) and filter by filename: `fs.watch`\n\t\t// follows the inode, so an atomic renewal (write temp + rename(2))\n\t\t// replaces the inode and a file-level watcher goes silent on the new\n\t\t// file after the first reload. A directory watcher survives renames and\n\t\t// reports the changed basename via the `filename` callback argument.\n\t\tconst tlsWatchController = createTlsWatchers({\n\t\t\tcertPath: args.cert,\n\t\t\tkeyPath: args.key,\n\t\t\twatchDirectory: (dir, listener) => watch(dir, listener),\n\t\t\treloadTls,\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\tclearTlsReloadTimer = tlsWatchController.clearReloadTimer;\n\t\ttlsWatchers.push(...tlsWatchController.watchers);\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} else {\n\t\t\tconst insecureWarning = insecureRemoteWarning(args);\n\t\t\tif (insecureWarning) console.warn(insecureWarning);\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => beginShutdown(\"shutting down…\", 0);\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 CHANGED
@@ -17,12 +17,16 @@ import { homedir } from "node:os";
17
17
  import { basename, dirname, join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { DashboardAuth } from "./server/auth.js";
20
+ import { DashboardImageService } from "./server/dashboard-images.js";
21
+ import { ImagePreviewWorker } from "./server/image-preview.js";
20
22
  import { FilePairingStorage, loadOrCreateDashboardSecret } from "./server/pairing-storage.js";
21
23
  import { RuntimePool } from "./server/runtime-pool.js";
22
24
  import { createDashboardServer } from "./server/server.js";
23
25
  export { DashboardAuth, TailscaleStatusResolver } from "./server/auth.js";
26
+ export { DashboardImageService } from "./server/dashboard-images.js";
24
27
  export { EventHub } from "./server/event-hub.js";
25
28
  export { canonicalizePath, FileApi } from "./server/files.js";
29
+ export { ImagePreviewWorker } from "./server/image-preview.js";
26
30
  export { FilePairingStorage, loadOrCreateDashboardSecret } from "./server/pairing-storage.js";
27
31
  export { RuntimePool, resolveDrebCliPath } from "./server/runtime-pool.js";
28
32
  export { createDashboardServer, parseDeviceCookie } from "./server/server.js";
@@ -229,6 +233,7 @@ async function main() {
229
233
  logger: (line) => console.warn(`[dashboard-auth] ${line}`),
230
234
  });
231
235
  const pool = new RuntimePool();
236
+ const imageService = new DashboardImageService(new ImagePreviewWorker());
232
237
  // Static client assets live next to the compiled server (dist/static).
233
238
  const staticDir = join(dirname(fileURLToPath(import.meta.url)), "static");
234
239
  // The server's own build version (dist/index.js → ../package.json) — surfaced
@@ -249,13 +254,14 @@ async function main() {
249
254
  // across restart/exit).
250
255
  let httpServer;
251
256
  let clearTlsReloadTimer = () => { };
257
+ let closeDashboard = () => imageService.close();
252
258
  const tlsWatchers = [];
253
259
  const beginShutdown = createShutdown({
254
260
  log: (message) => console.log(message),
255
261
  clearReloadTimer: () => clearTlsReloadTimer(),
256
262
  watchers: tlsWatchers,
257
263
  closeServer: () => httpServer?.close(),
258
- stopAll: () => pool.stopAll(),
264
+ stopAll: () => Promise.all([pool.stopAll(), closeDashboard()]),
259
265
  exit: (code) => process.exit(code),
260
266
  });
261
267
  const onRestart = () => beginShutdown("restart requested — exiting for supervisor to respawn", 1);
@@ -263,6 +269,7 @@ async function main() {
263
269
  const app = createDashboardServer({
264
270
  auth,
265
271
  pool,
272
+ imageService,
266
273
  staticDir: existsSync(staticDir) ? staticDir : undefined,
267
274
  serverVersion,
268
275
  onRestart,
@@ -274,6 +281,7 @@ async function main() {
274
281
  return { method: result.method };
275
282
  },
276
283
  });
284
+ closeDashboard = () => app.closeDashboard();
277
285
  const host = args.remote ? "0.0.0.0" : "127.0.0.1";
278
286
  const scheme = args.https ? "https" : "http";
279
287
  // Native TLS: the dashboard terminates TLS itself (no reverse proxy). The
package/dist/index.js.map CHANGED
@@ -1 +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 { basename, 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\n/**\n * Warning shown at startup when `--remote` binds the tailnet over plain HTTP\n * (no `--https`). Browsers treat plain HTTP over a non-loopback host as an\n * INSECURE context, so service workers and the Notification API are\n * unavailable — PWA install and mobile notifications (iOS exposes no\n * Notification API at all there) silently never work. Loopback (127.0.0.1) is\n * exempt (it counts as secure), so pure-local mode returns no warning.\n *\n * Returns the multi-line warning string, or `undefined` when TLS is enabled or\n * the server is loopback-only. Kept pure (no console) so it is unit-testable.\n */\nexport function insecureRemoteWarning(args: Pick<CliArgs, \"remote\" | \"https\">): string | undefined {\n\tif (!args.remote || args.https) return undefined;\n\treturn (\n\t\t\"⚠ warning: --remote without --https serves plain HTTP over the tailnet, which browsers treat as an\\n\" +\n\t\t\" INSECURE context — service workers, PWA install, and mobile notifications (especially iOS) will NOT work.\\n\" +\n\t\t\" Enable TLS for the tailnet hostname:\\n\" +\n\t\t\" tailscale cert <host>.<tailnet>.ts.net\\n\" +\n\t\t\" …then relaunch with --https --cert <host>.<tailnet>.ts.net.crt --key <host>.<tailnet>.ts.net.key\\n\" +\n\t\t\" and open the dashboard via https://<host>.<tailnet>.ts.net (the hostname, not the tailnet IP).\"\n\t);\n}\n\ntype ShutdownWatcher = Pick<ReturnType<typeof watch>, \"close\">;\n\ninterface ShutdownDeps {\n\tlog: (message: string) => void;\n\tclearReloadTimer: () => void;\n\twatchers: Iterable<ShutdownWatcher>;\n\tcloseServer: () => void;\n\tstopAll: () => Promise<unknown>;\n\texit: (code: number) => void;\n}\n\nexport function createShutdown(deps: ShutdownDeps): (message: string, exitCode: number) => void {\n\tlet shuttingDown = false;\n\treturn (message: string, exitCode: number) => {\n\t\tif (shuttingDown) return;\n\t\tshuttingDown = true;\n\t\tdeps.log(message);\n\t\tdeps.clearReloadTimer();\n\t\tfor (const watcher of deps.watchers) watcher.close();\n\t\tdeps.closeServer();\n\t\tvoid deps\n\t\t\t.stopAll()\n\t\t\t.catch((err) => {\n\t\t\t\tdeps.log(`shutdown teardown failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t})\n\t\t\t.finally(() => deps.exit(exitCode));\n\t};\n}\n\ntype TlsOptions = { cert: string; key: string };\ntype TlsWatchFilename = string | Buffer | null;\ntype TlsWatchListener = (event: string, filename: TlsWatchFilename) => void;\ntype TlsFileWatcher = ShutdownWatcher & {\n\ton(event: \"error\", listener: (err: unknown) => void): unknown;\n};\n\ninterface TlsReloaderDeps {\n\treadTlsOptions: () => TlsOptions;\n\tsetSecureContext: (options: TlsOptions) => void;\n\tlog: (message: string) => void;\n\twarn: (message: string) => void;\n}\n\nexport function createTlsReloader(deps: TlsReloaderDeps): () => void {\n\treturn () => {\n\t\ttry {\n\t\t\tdeps.setSecureContext(deps.readTlsOptions());\n\t\t\tdeps.log(\"tls certificate reloaded\");\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t};\n}\n\nexport function shouldReloadForFile(filename: TlsWatchFilename, certBase: string, keyBase: string): boolean {\n\tif (filename == null) return true;\n\tif (typeof filename !== \"string\") return false;\n\treturn filename === certBase || filename === keyBase;\n}\n\ninterface TlsWatchDeps {\n\tcertPath: string;\n\tkeyPath: string;\n\twatchDirectory: (directory: string, listener: TlsWatchListener) => TlsFileWatcher;\n\treloadTls: () => void;\n\twarn: (message: string) => void;\n\tdebounceMs?: number;\n}\n\ninterface TlsWatchController {\n\twatchers: TlsFileWatcher[];\n\tclearReloadTimer: () => void;\n}\n\nexport function createTlsWatchers(deps: TlsWatchDeps): TlsWatchController {\n\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\tconst watchers: TlsFileWatcher[] = [];\n\tconst debounceMs = deps.debounceMs ?? 500;\n\tconst certBase = basename(deps.certPath);\n\tconst keyBase = basename(deps.keyPath);\n\tconst clearReloadTimer = () => {\n\t\tif (!reloadTimer) return;\n\t\tclearTimeout(reloadTimer);\n\t\treloadTimer = undefined;\n\t};\n\tconst scheduleReload = () => {\n\t\tclearReloadTimer();\n\t\treloadTimer = setTimeout(deps.reloadTls, debounceMs);\n\t};\n\n\tfor (const dir of new Set([dirname(deps.certPath), dirname(deps.keyPath)])) {\n\t\ttry {\n\t\t\tconst watcher = deps.watchDirectory(dir, (_event, filename) => {\n\t\t\t\t// `fs.watch` directory events on macOS (FSEvents) frequently fire\n\t\t\t\t// with `filename === null`; fall through and reload anyway.\n\t\t\t\t// On Linux (inotify) the basename is carried reliably and filters\n\t\t\t\t// out unrelated files in the parent directory.\n\t\t\t\tif (!shouldReloadForFile(filename, certBase, keyBase)) return;\n\t\t\t\tscheduleReload();\n\t\t\t});\n\t\t\twatcher.on(\"error\", (err) => {\n\t\t\t\tdeps.warn(`tls cert watch error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t\t\twatchers.push(watcher);\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls cert watch setup failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\treturn { watchers, clearReloadTimer };\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 Plain HTTP over the tailnet is an INSECURE context: add\n --https (below) or mobile PWA install + notifications\n (especially iOS) will not work.\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\t// The TLS hot-reload debounce timer and directory watchers are lifted here\n\t// too, so restart and signal shutdown can release them (otherwise they leak\n\t// across restart/exit).\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tlet clearTlsReloadTimer = () => {};\n\tconst tlsWatchers: TlsFileWatcher[] = [];\n\tconst beginShutdown = createShutdown({\n\t\tlog: (message) => console.log(message),\n\t\tclearReloadTimer: () => clearTlsReloadTimer(),\n\t\twatchers: tlsWatchers,\n\t\tcloseServer: () => httpServer?.close(),\n\t\tstopAll: () => pool.stopAll(),\n\t\texit: (code) => process.exit(code),\n\t});\n\tconst onRestart = () => beginShutdown(\"restart requested — exiting for supervisor to respawn\", 1);\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\tconst readTlsOptions = () => ({\n\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t});\n\t\tlet tlsOptions: TlsOptions;\n\t\ttry {\n\t\t\ttlsOptions = readTlsOptions();\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 = createTlsReloader({\n\t\t\treadTlsOptions,\n\t\t\tsetSecureContext: (options) => httpsServer.setSecureContext(options),\n\t\t\tlog: (message) => console.log(message),\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\t// Debounce: cert + key are often rewritten in quick succession. Watch\n\t\t// the PARENT DIRECTORY (not each file) and filter by filename: `fs.watch`\n\t\t// follows the inode, so an atomic renewal (write temp + rename(2))\n\t\t// replaces the inode and a file-level watcher goes silent on the new\n\t\t// file after the first reload. A directory watcher survives renames and\n\t\t// reports the changed basename via the `filename` callback argument.\n\t\tconst tlsWatchController = createTlsWatchers({\n\t\t\tcertPath: args.cert,\n\t\t\tkeyPath: args.key,\n\t\t\twatchDirectory: (dir, listener) => watch(dir, listener),\n\t\t\treloadTls,\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\tclearTlsReloadTimer = tlsWatchController.clearReloadTimer;\n\t\ttlsWatchers.push(...tlsWatchController.watchers);\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} else {\n\t\t\tconst insecureWarning = insecureRemoteWarning(args);\n\t\t\tif (insecureWarning) console.warn(insecureWarning);\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => beginShutdown(\"shutting down…\", 0);\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"]}
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 { basename, dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { DashboardAuth } from \"./server/auth.js\";\nimport { DashboardImageService } from \"./server/dashboard-images.js\";\nimport { ImagePreviewWorker } from \"./server/image-preview.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 { DashboardImageService } from \"./server/dashboard-images.js\";\nexport { EventHub } from \"./server/event-hub.js\";\nexport { canonicalizePath, FileApi } from \"./server/files.js\";\nexport { ImagePreviewWorker } from \"./server/image-preview.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\n/**\n * Warning shown at startup when `--remote` binds the tailnet over plain HTTP\n * (no `--https`). Browsers treat plain HTTP over a non-loopback host as an\n * INSECURE context, so service workers and the Notification API are\n * unavailable — PWA install and mobile notifications (iOS exposes no\n * Notification API at all there) silently never work. Loopback (127.0.0.1) is\n * exempt (it counts as secure), so pure-local mode returns no warning.\n *\n * Returns the multi-line warning string, or `undefined` when TLS is enabled or\n * the server is loopback-only. Kept pure (no console) so it is unit-testable.\n */\nexport function insecureRemoteWarning(args: Pick<CliArgs, \"remote\" | \"https\">): string | undefined {\n\tif (!args.remote || args.https) return undefined;\n\treturn (\n\t\t\"⚠ warning: --remote without --https serves plain HTTP over the tailnet, which browsers treat as an\\n\" +\n\t\t\" INSECURE context — service workers, PWA install, and mobile notifications (especially iOS) will NOT work.\\n\" +\n\t\t\" Enable TLS for the tailnet hostname:\\n\" +\n\t\t\" tailscale cert <host>.<tailnet>.ts.net\\n\" +\n\t\t\" …then relaunch with --https --cert <host>.<tailnet>.ts.net.crt --key <host>.<tailnet>.ts.net.key\\n\" +\n\t\t\" and open the dashboard via https://<host>.<tailnet>.ts.net (the hostname, not the tailnet IP).\"\n\t);\n}\n\ntype ShutdownWatcher = Pick<ReturnType<typeof watch>, \"close\">;\n\ninterface ShutdownDeps {\n\tlog: (message: string) => void;\n\tclearReloadTimer: () => void;\n\twatchers: Iterable<ShutdownWatcher>;\n\tcloseServer: () => void;\n\tstopAll: () => Promise<unknown>;\n\texit: (code: number) => void;\n}\n\nexport function createShutdown(deps: ShutdownDeps): (message: string, exitCode: number) => void {\n\tlet shuttingDown = false;\n\treturn (message: string, exitCode: number) => {\n\t\tif (shuttingDown) return;\n\t\tshuttingDown = true;\n\t\tdeps.log(message);\n\t\tdeps.clearReloadTimer();\n\t\tfor (const watcher of deps.watchers) watcher.close();\n\t\tdeps.closeServer();\n\t\tvoid deps\n\t\t\t.stopAll()\n\t\t\t.catch((err) => {\n\t\t\t\tdeps.log(`shutdown teardown failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t})\n\t\t\t.finally(() => deps.exit(exitCode));\n\t};\n}\n\ntype TlsOptions = { cert: string; key: string };\ntype TlsWatchFilename = string | Buffer | null;\ntype TlsWatchListener = (event: string, filename: TlsWatchFilename) => void;\ntype TlsFileWatcher = ShutdownWatcher & {\n\ton(event: \"error\", listener: (err: unknown) => void): unknown;\n};\n\ninterface TlsReloaderDeps {\n\treadTlsOptions: () => TlsOptions;\n\tsetSecureContext: (options: TlsOptions) => void;\n\tlog: (message: string) => void;\n\twarn: (message: string) => void;\n}\n\nexport function createTlsReloader(deps: TlsReloaderDeps): () => void {\n\treturn () => {\n\t\ttry {\n\t\t\tdeps.setSecureContext(deps.readTlsOptions());\n\t\t\tdeps.log(\"tls certificate reloaded\");\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls reload failed (keeping old cert): ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t};\n}\n\nexport function shouldReloadForFile(filename: TlsWatchFilename, certBase: string, keyBase: string): boolean {\n\tif (filename == null) return true;\n\tif (typeof filename !== \"string\") return false;\n\treturn filename === certBase || filename === keyBase;\n}\n\ninterface TlsWatchDeps {\n\tcertPath: string;\n\tkeyPath: string;\n\twatchDirectory: (directory: string, listener: TlsWatchListener) => TlsFileWatcher;\n\treloadTls: () => void;\n\twarn: (message: string) => void;\n\tdebounceMs?: number;\n}\n\ninterface TlsWatchController {\n\twatchers: TlsFileWatcher[];\n\tclearReloadTimer: () => void;\n}\n\nexport function createTlsWatchers(deps: TlsWatchDeps): TlsWatchController {\n\tlet reloadTimer: ReturnType<typeof setTimeout> | undefined;\n\tconst watchers: TlsFileWatcher[] = [];\n\tconst debounceMs = deps.debounceMs ?? 500;\n\tconst certBase = basename(deps.certPath);\n\tconst keyBase = basename(deps.keyPath);\n\tconst clearReloadTimer = () => {\n\t\tif (!reloadTimer) return;\n\t\tclearTimeout(reloadTimer);\n\t\treloadTimer = undefined;\n\t};\n\tconst scheduleReload = () => {\n\t\tclearReloadTimer();\n\t\treloadTimer = setTimeout(deps.reloadTls, debounceMs);\n\t};\n\n\tfor (const dir of new Set([dirname(deps.certPath), dirname(deps.keyPath)])) {\n\t\ttry {\n\t\t\tconst watcher = deps.watchDirectory(dir, (_event, filename) => {\n\t\t\t\t// `fs.watch` directory events on macOS (FSEvents) frequently fire\n\t\t\t\t// with `filename === null`; fall through and reload anyway.\n\t\t\t\t// On Linux (inotify) the basename is carried reliably and filters\n\t\t\t\t// out unrelated files in the parent directory.\n\t\t\t\tif (!shouldReloadForFile(filename, certBase, keyBase)) return;\n\t\t\t\tscheduleReload();\n\t\t\t});\n\t\t\twatcher.on(\"error\", (err) => {\n\t\t\t\tdeps.warn(`tls cert watch error: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t\t\twatchers.push(watcher);\n\t\t} catch (err) {\n\t\t\tdeps.warn(`tls cert watch setup failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\treturn { watchers, clearReloadTimer };\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 Plain HTTP over the tailnet is an INSECURE context: add\n --https (below) or mobile PWA install + notifications\n (especially iOS) will not work.\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\tconst imageService = new DashboardImageService(new ImagePreviewWorker());\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\t// The TLS hot-reload debounce timer and directory watchers are lifted here\n\t// too, so restart and signal shutdown can release them (otherwise they leak\n\t// across restart/exit).\n\tlet httpServer: HttpServer | HttpsServer | undefined;\n\tlet clearTlsReloadTimer = () => {};\n\tlet closeDashboard = () => imageService.close();\n\tconst tlsWatchers: TlsFileWatcher[] = [];\n\tconst beginShutdown = createShutdown({\n\t\tlog: (message) => console.log(message),\n\t\tclearReloadTimer: () => clearTlsReloadTimer(),\n\t\twatchers: tlsWatchers,\n\t\tcloseServer: () => httpServer?.close(),\n\t\tstopAll: () => Promise.all([pool.stopAll(), closeDashboard()]),\n\t\texit: (code) => process.exit(code),\n\t});\n\tconst onRestart = () => beginShutdown(\"restart requested — exiting for supervisor to respawn\", 1);\n\n\tconst { SessionManager } = await import(\"@dreb/coding-agent\");\n\tconst app = createDashboardServer({\n\t\tauth,\n\t\tpool,\n\t\timageService,\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\tcloseDashboard = () => app.closeDashboard();\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\tconst readTlsOptions = () => ({\n\t\t\tcert: readFileSync(args.cert!, \"utf-8\"),\n\t\t\tkey: readFileSync(args.key!, \"utf-8\"),\n\t\t});\n\t\tlet tlsOptions: TlsOptions;\n\t\ttry {\n\t\t\ttlsOptions = readTlsOptions();\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 = createTlsReloader({\n\t\t\treadTlsOptions,\n\t\t\tsetSecureContext: (options) => httpsServer.setSecureContext(options),\n\t\t\tlog: (message) => console.log(message),\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\t// Debounce: cert + key are often rewritten in quick succession. Watch\n\t\t// the PARENT DIRECTORY (not each file) and filter by filename: `fs.watch`\n\t\t// follows the inode, so an atomic renewal (write temp + rename(2))\n\t\t// replaces the inode and a file-level watcher goes silent on the new\n\t\t// file after the first reload. A directory watcher survives renames and\n\t\t// reports the changed basename via the `filename` callback argument.\n\t\tconst tlsWatchController = createTlsWatchers({\n\t\t\tcertPath: args.cert,\n\t\t\tkeyPath: args.key,\n\t\t\twatchDirectory: (dir, listener) => watch(dir, listener),\n\t\t\treloadTls,\n\t\t\twarn: (message) => console.warn(message),\n\t\t});\n\t\tclearTlsReloadTimer = tlsWatchController.clearReloadTimer;\n\t\ttlsWatchers.push(...tlsWatchController.watchers);\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} else {\n\t\t\tconst insecureWarning = insecureRemoteWarning(args);\n\t\t\tif (insecureWarning) console.warn(insecureWarning);\n\t\t}\n\t});\n\thttpServer = server;\n\n\tconst shutdown = () => beginShutdown(\"shutting down…\", 0);\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,56 @@
1
+ import type { DashboardImageReferenceDto } from "../shared/protocol.js";
2
+ import { type ImagePreviewGenerator } from "./image-preview.js";
3
+ export declare const DASHBOARD_IMAGE_CACHE_BYTES: number;
4
+ export declare const DASHBOARD_IMAGE_CACHE_RECORDS = 2000;
5
+ export declare const DASHBOARD_IMAGE_ID_PATTERN: RegExp;
6
+ export declare const DASHBOARD_IMAGE_MIME_TYPES: Set<"image/gif" | "image/jpeg" | "image/png" | "image/webp">;
7
+ export interface DashboardImageScope {
8
+ runtimeKey: string;
9
+ agentId?: string;
10
+ }
11
+ export interface DashboardImageBinary {
12
+ bytes: Uint8Array;
13
+ mimeType: DashboardImageReferenceDto["mimeType"];
14
+ }
15
+ export interface DashboardImageRepositoryOptions {
16
+ maxBytes?: number;
17
+ maxRecords?: number;
18
+ }
19
+ /** Strict standard base64 decode: whitespace, data URLs, and non-canonical padding are rejected. */
20
+ export declare function decodeDashboardImage(value: unknown, mimeType: unknown): DashboardImageBinary | undefined;
21
+ export declare function dashboardImageId(mimeType: string, bytes: Uint8Array): string;
22
+ export declare function isDashboardImageReference(value: unknown): value is DashboardImageReferenceDto;
23
+ export declare class DashboardImageNotFoundError extends Error {
24
+ }
25
+ export declare class DashboardImagePreviewError extends Error {
26
+ }
27
+ /** Synchronous projection + bounded original/preview repository. */
28
+ export declare class DashboardImageService {
29
+ private readonly previews;
30
+ private readonly images;
31
+ private readonly previewFlights;
32
+ private readonly maxBytes;
33
+ private readonly maxRecords;
34
+ private usedBytes;
35
+ private usedRecords;
36
+ private clock;
37
+ constructor(previews: ImagePreviewGenerator, options?: DashboardImageRepositoryOptions);
38
+ get byteSize(): number;
39
+ get recordCount(): number;
40
+ /** Project a browser-facing event without mutating the authoritative source. */
41
+ projectEvent(event: Record<string, unknown>, scope: DashboardImageScope): Record<string, unknown>;
42
+ /** Project messages/snapshots before JSON serialization. */
43
+ project<T>(value: T, scope: DashboardImageScope): T;
44
+ original(scope: DashboardImageScope, id: string, loadAuthoritative: () => Promise<unknown>): Promise<DashboardImageBinary>;
45
+ preview(scope: DashboardImageScope, id: string, loadAuthoritative: () => Promise<unknown>): Promise<DashboardImageBinary>;
46
+ removeRuntime(runtimeKey: string): void;
47
+ close(): Promise<void>;
48
+ private projectNode;
49
+ private insertOriginal;
50
+ private cachedVariant;
51
+ private recoverOriginal;
52
+ private putVariant;
53
+ private evict;
54
+ private deleteImage;
55
+ }
56
+ //# sourceMappingURL=dashboard-images.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard-images.d.ts","sourceRoot":"","sources":["../../src/server/dashboard-images.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uBAAuB,CAAC;AACxE,OAAO,EAEN,KAAK,qBAAqB,EAI1B,MAAM,oBAAoB,CAAC;AAE5B,eAAO,MAAM,2BAA2B,QAAmB,CAAC;AAC5D,eAAO,MAAM,6BAA6B,OAAO,CAAC;AAClD,eAAO,MAAM,0BAA0B,QAAmB,CAAC;AAC3D,eAAO,MAAM,0BAA0B,8DAKrC,CAAC;AAEH,MAAM,WAAW,mBAAmB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACpC,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,0BAA0B,CAAC,UAAU,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,+BAA+B;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AA2ED,oGAAoG;AACpG,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,GAAG,oBAAoB,GAAG,SAAS,CASxG;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,MAAM,CAE5E;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,0BAA0B,CAY7F;AAED,qBAAa,2BAA4B,SAAQ,KAAK;CAAG;AACzD,qBAAa,0BAA2B,SAAQ,KAAK;CAAG;AAExD,oEAAoE;AACpE,qBAAa,qBAAqB;IAUhC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAT1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkC;IACzD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAoD;IACnF,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,KAAK,CAAK;IAElB,YACkB,QAAQ,EAAE,qBAAqB,EAChD,OAAO,GAAE,+BAAoC,EAI7C;IAED,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,gFAAgF;IAChF,YAAY,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,EAAE,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEhG;IAED,4DAA4D;IAC5D,OAAO,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,mBAAmB,GAAG,CAAC,CAGlD;IAEK,QAAQ,CACb,KAAK,EAAE,mBAAmB,EAC1B,EAAE,EAAE,MAAM,EACV,iBAAiB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GACvC,OAAO,CAAC,oBAAoB,CAAC,CAI/B;IAEK,OAAO,CACZ,KAAK,EAAE,mBAAmB,EAC1B,EAAE,EAAE,MAAM,EACV,iBAAiB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GACvC,OAAO,CAAC,oBAAoB,CAAC,CAuC/B;IAED,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAOtC;IAED,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAErB;IAED,OAAO,CAAC,WAAW;IA4BnB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,aAAa;YAaP,eAAe;IA2B7B,OAAO,CAAC,UAAU;IAuBlB,OAAO,CAAC,KAAK;IAoBb,OAAO,CAAC,WAAW;CAQnB","sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { DashboardImageReferenceDto } from \"../shared/protocol.js\";\nimport {\n\ttype GeneratedImagePreview,\n\ttype ImagePreviewGenerator,\n\tMAX_PREVIEW_BYTES,\n\tMAX_PREVIEW_HEIGHT,\n\tMAX_PREVIEW_WIDTH,\n} from \"./image-preview.js\";\n\nexport const DASHBOARD_IMAGE_CACHE_BYTES = 64 * 1024 * 1024;\nexport const DASHBOARD_IMAGE_CACHE_RECORDS = 2000;\nexport const DASHBOARD_IMAGE_ID_PATTERN = /^[0-9a-f]{64}$/;\nexport const DASHBOARD_IMAGE_MIME_TYPES = new Set<DashboardImageReferenceDto[\"mimeType\"]>([\n\t\"image/png\",\n\t\"image/jpeg\",\n\t\"image/gif\",\n\t\"image/webp\",\n]);\n\nexport interface DashboardImageScope {\n\truntimeKey: string;\n\tagentId?: string;\n}\n\nexport interface DashboardImageBinary {\n\tbytes: Uint8Array;\n\tmimeType: DashboardImageReferenceDto[\"mimeType\"];\n}\n\nexport interface DashboardImageRepositoryOptions {\n\tmaxBytes?: number;\n\tmaxRecords?: number;\n}\n\ninterface CachedVariant extends DashboardImageBinary {\n\tlastUsed: number;\n}\n\ninterface CachedImage {\n\tid: string;\n\tmimeType: DashboardImageReferenceDto[\"mimeType\"];\n\tsize: number;\n\tscopes: Set<string>;\n\toriginal?: CachedVariant;\n\tpreview?: CachedVariant;\n}\n\nconst DROP = Symbol(\"drop-dashboard-image\");\nconst JPEG_EOI = Buffer.from([0xff, 0xd9]);\n\nfunction scopeKey(scope: DashboardImageScope): string {\n\treturn `${scope.runtimeKey}\\0${scope.agentId ?? \"\"}`;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn Boolean(value) && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction hasRasterSignature(mimeType: DashboardImageReferenceDto[\"mimeType\"], bytes: Uint8Array): boolean {\n\tconst buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n\tswitch (mimeType) {\n\t\tcase \"image/png\": {\n\t\t\tconst signature =\n\t\t\t\tbytes.length >= 24 &&\n\t\t\t\t[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((byte, i) => bytes[i] === byte);\n\t\t\treturn (\n\t\t\t\tsignature &&\n\t\t\t\tbuffer.subarray(12, 16).toString(\"ascii\") === \"IHDR\" &&\n\t\t\t\tbuffer.readUInt32BE(16) > 0 &&\n\t\t\t\tbuffer.readUInt32BE(20) > 0 &&\n\t\t\t\tbuffer.indexOf(Buffer.from(\"IEND\"), 24) >= 0\n\t\t\t);\n\t\t}\n\t\tcase \"image/jpeg\": {\n\t\t\tif (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) return false;\n\t\t\t// EOI does not have to be the final byte. Phone JPEGs commonly append\n\t\t\t// auxiliary gain-map, motion-photo, or vendor metadata after the primary\n\t\t\t// image. Browsers and Photon decode the JPEG through EOI and preserve the\n\t\t\t// trailing bytes when the exact original is requested.\n\t\t\treturn buffer.lastIndexOf(JPEG_EOI) >= 2;\n\t\t}\n\t\tcase \"image/gif\": {\n\t\t\tconst header = buffer.subarray(0, 6).toString(\"ascii\");\n\t\t\treturn (\n\t\t\t\tbytes.length >= 14 &&\n\t\t\t\t(header === \"GIF87a\" || header === \"GIF89a\") &&\n\t\t\t\tbuffer.readUInt16LE(6) > 0 &&\n\t\t\t\tbuffer.readUInt16LE(8) > 0 &&\n\t\t\t\tbytes[bytes.length - 1] === 0x3b\n\t\t\t);\n\t\t}\n\t\tcase \"image/webp\": {\n\t\t\tif (\n\t\t\t\tbytes.length < 20 ||\n\t\t\t\tbuffer.subarray(0, 4).toString(\"ascii\") !== \"RIFF\" ||\n\t\t\t\tbuffer.subarray(8, 12).toString(\"ascii\") !== \"WEBP\"\n\t\t\t) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst chunk = buffer.subarray(12, 16).toString(\"ascii\");\n\t\t\treturn (\n\t\t\t\tbuffer.readUInt32LE(4) + 8 <= bytes.length && (chunk === \"VP8 \" || chunk === \"VP8L\" || chunk === \"VP8X\")\n\t\t\t);\n\t\t}\n\t}\n}\n\n/** Strict standard base64 decode: whitespace, data URLs, and non-canonical padding are rejected. */\nexport function decodeDashboardImage(value: unknown, mimeType: unknown): DashboardImageBinary | undefined {\n\tif (typeof value !== \"string\" || typeof mimeType !== \"string\") return undefined;\n\tif (!DASHBOARD_IMAGE_MIME_TYPES.has(mimeType as DashboardImageReferenceDto[\"mimeType\"])) return undefined;\n\tif (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return undefined;\n\tconst bytes = Buffer.from(value, \"base64\");\n\tif (bytes.toString(\"base64\") !== value) return undefined;\n\tconst exactMimeType = mimeType as DashboardImageReferenceDto[\"mimeType\"];\n\tif (!hasRasterSignature(exactMimeType, bytes)) return undefined;\n\treturn { bytes, mimeType: exactMimeType };\n}\n\nexport function dashboardImageId(mimeType: string, bytes: Uint8Array): string {\n\treturn createHash(\"sha256\").update(mimeType).update(Uint8Array.of(0)).update(bytes).digest(\"hex\");\n}\n\nexport function isDashboardImageReference(value: unknown): value is DashboardImageReferenceDto {\n\tif (!isRecord(value)) return false;\n\treturn (\n\t\tvalue.type === \"image_reference\" &&\n\t\ttypeof value.id === \"string\" &&\n\t\tDASHBOARD_IMAGE_ID_PATTERN.test(value.id) &&\n\t\ttypeof value.mimeType === \"string\" &&\n\t\tDASHBOARD_IMAGE_MIME_TYPES.has(value.mimeType as DashboardImageReferenceDto[\"mimeType\"]) &&\n\t\ttypeof value.size === \"number\" &&\n\t\tNumber.isSafeInteger(value.size) &&\n\t\tvalue.size > 0\n\t);\n}\n\nexport class DashboardImageNotFoundError extends Error {}\nexport class DashboardImagePreviewError extends Error {}\n\n/** Synchronous projection + bounded original/preview repository. */\nexport class DashboardImageService {\n\tprivate readonly images = new Map<string, CachedImage>();\n\tprivate readonly previewFlights = new Map<string, Promise<DashboardImageBinary>>();\n\tprivate readonly maxBytes: number;\n\tprivate readonly maxRecords: number;\n\tprivate usedBytes = 0;\n\tprivate usedRecords = 0;\n\tprivate clock = 0;\n\n\tconstructor(\n\t\tprivate readonly previews: ImagePreviewGenerator,\n\t\toptions: DashboardImageRepositoryOptions = {},\n\t) {\n\t\tthis.maxBytes = options.maxBytes ?? DASHBOARD_IMAGE_CACHE_BYTES;\n\t\tthis.maxRecords = options.maxRecords ?? DASHBOARD_IMAGE_CACHE_RECORDS;\n\t}\n\n\tget byteSize(): number {\n\t\treturn this.usedBytes;\n\t}\n\n\tget recordCount(): number {\n\t\treturn this.usedRecords;\n\t}\n\n\t/** Project a browser-facing event without mutating the authoritative source. */\n\tprojectEvent(event: Record<string, unknown>, scope: DashboardImageScope): Record<string, unknown> {\n\t\treturn this.projectNode(event, scope) as Record<string, unknown>;\n\t}\n\n\t/** Project messages/snapshots before JSON serialization. */\n\tproject<T>(value: T, scope: DashboardImageScope): T {\n\t\tconst projected = this.projectNode(value, scope);\n\t\treturn (projected === DROP ? undefined : projected) as T;\n\t}\n\n\tasync original(\n\t\tscope: DashboardImageScope,\n\t\tid: string,\n\t\tloadAuthoritative: () => Promise<unknown>,\n\t): Promise<DashboardImageBinary> {\n\t\tconst cached = this.cachedVariant(scope, id, \"original\");\n\t\tif (cached) return cached;\n\t\treturn this.recoverOriginal(scope, id, loadAuthoritative);\n\t}\n\n\tasync preview(\n\t\tscope: DashboardImageScope,\n\t\tid: string,\n\t\tloadAuthoritative: () => Promise<unknown>,\n\t): Promise<DashboardImageBinary> {\n\t\tconst cached = this.cachedVariant(scope, id, \"preview\");\n\t\tif (cached) return cached;\n\t\t// Authorize/recover this scope before joining a content-global flight. A\n\t\t// guessed ID from another runtime must not piggyback its in-flight preview.\n\t\tconst original = await this.original(scope, id, loadAuthoritative);\n\t\tconst recoveredPreview = this.cachedVariant(scope, id, \"preview\");\n\t\tif (recoveredPreview) return recoveredPreview;\n\t\tconst flight = this.previewFlights.get(id);\n\t\tif (flight) return flight;\n\t\tconst promise = (async () => {\n\t\t\tlet generated: GeneratedImagePreview;\n\t\t\ttry {\n\t\t\t\tgenerated = await this.previews.generate(original.bytes, original.mimeType);\n\t\t\t} catch (error) {\n\t\t\t\tthrow new DashboardImagePreviewError(\n\t\t\t\t\t`Preview generation failed: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (\n\t\t\t\tgenerated.bytes.byteLength > MAX_PREVIEW_BYTES ||\n\t\t\t\tgenerated.width < 1 ||\n\t\t\t\tgenerated.height < 1 ||\n\t\t\t\tgenerated.width > MAX_PREVIEW_WIDTH ||\n\t\t\t\tgenerated.height > MAX_PREVIEW_HEIGHT ||\n\t\t\t\t(generated.mimeType !== \"image/png\" && generated.mimeType !== \"image/jpeg\")\n\t\t\t) {\n\t\t\t\tthrow new DashboardImagePreviewError(\"Preview worker returned an invalid or over-budget image\");\n\t\t\t}\n\t\t\tconst binary: DashboardImageBinary = { bytes: generated.bytes, mimeType: generated.mimeType };\n\t\t\tthis.putVariant(id, scope, \"preview\", binary);\n\t\t\treturn binary;\n\t\t})();\n\t\tthis.previewFlights.set(id, promise);\n\t\ttry {\n\t\t\treturn await promise;\n\t\t} finally {\n\t\t\tthis.previewFlights.delete(id);\n\t\t}\n\t}\n\n\tremoveRuntime(runtimeKey: string): void {\n\t\tfor (const image of [...this.images.values()]) {\n\t\t\tfor (const key of image.scopes) {\n\t\t\t\tif (key === runtimeKey || key.startsWith(`${runtimeKey}\\0`)) image.scopes.delete(key);\n\t\t\t}\n\t\t\tif (image.scopes.size === 0) this.deleteImage(image);\n\t\t}\n\t}\n\n\tclose(): Promise<void> {\n\t\treturn this.previews.close();\n\t}\n\n\tprivate projectNode(value: unknown, scope: DashboardImageScope): unknown | typeof DROP {\n\t\tif (Array.isArray(value)) {\n\t\t\tconst projected: unknown[] = [];\n\t\t\tfor (const item of value) {\n\t\t\t\tconst next = this.projectNode(item, scope);\n\t\t\t\tif (next !== DROP) projected.push(next);\n\t\t\t}\n\t\t\treturn projected;\n\t\t}\n\t\tif (!isRecord(value)) return value;\n\t\tif (value.type === \"image\") {\n\t\t\tconst image = decodeDashboardImage(value.data, value.mimeType);\n\t\t\tif (!image) return DROP;\n\t\t\treturn this.insertOriginal(scope, image);\n\t\t}\n\t\tif (value.type === \"image_reference\") return isDashboardImageReference(value) ? { ...value } : DROP;\n\t\tconst childScope =\n\t\t\tvalue.type === \"background_agent_event\" && typeof value.agentId === \"string\"\n\t\t\t\t? { runtimeKey: scope.runtimeKey, agentId: value.agentId }\n\t\t\t\t: scope;\n\t\tconst copy: Record<string, unknown> = {};\n\t\tfor (const [key, item] of Object.entries(value)) {\n\t\t\tconst next = this.projectNode(item, key === \"event\" ? childScope : scope);\n\t\t\tif (next !== DROP) copy[key] = next;\n\t\t}\n\t\treturn copy;\n\t}\n\n\tprivate insertOriginal(scope: DashboardImageScope, binary: DashboardImageBinary): DashboardImageReferenceDto {\n\t\tconst id = dashboardImageId(binary.mimeType, binary.bytes);\n\t\tlet image = this.images.get(id);\n\t\tif (!image) {\n\t\t\timage = { id, mimeType: binary.mimeType, size: binary.bytes.byteLength, scopes: new Set() };\n\t\t\tthis.images.set(id, image);\n\t\t}\n\t\timage.scopes.add(scopeKey(scope));\n\t\tif (!image.original) this.putVariant(id, scope, \"original\", binary);\n\t\telse image.original.lastUsed = ++this.clock;\n\t\treturn { type: \"image_reference\", id, mimeType: binary.mimeType, size: binary.bytes.byteLength };\n\t}\n\n\tprivate cachedVariant(\n\t\tscope: DashboardImageScope,\n\t\tid: string,\n\t\tvariant: \"original\" | \"preview\",\n\t): DashboardImageBinary | undefined {\n\t\tconst image = this.images.get(id);\n\t\tif (!image || !image.scopes.has(scopeKey(scope))) return undefined;\n\t\tconst cached = image[variant];\n\t\tif (!cached) return undefined;\n\t\tcached.lastUsed = ++this.clock;\n\t\treturn { bytes: cached.bytes, mimeType: cached.mimeType };\n\t}\n\n\tprivate async recoverOriginal(\n\t\tscope: DashboardImageScope,\n\t\tid: string,\n\t\tloadAuthoritative: () => Promise<unknown>,\n\t): Promise<DashboardImageBinary> {\n\t\tconst source = await loadAuthoritative();\n\t\tlet recovered: DashboardImageBinary | undefined;\n\t\tconst visit = (value: unknown): void => {\n\t\t\tif (recovered) return;\n\t\t\tif (Array.isArray(value)) {\n\t\t\t\tfor (const item of value) visit(item);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!isRecord(value)) return;\n\t\t\tif (value.type === \"image\") {\n\t\t\t\tconst image = decodeDashboardImage(value.data, value.mimeType);\n\t\t\t\tif (image && dashboardImageId(image.mimeType, image.bytes) === id) recovered = image;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tfor (const item of Object.values(value)) visit(item);\n\t\t};\n\t\tvisit(source);\n\t\tif (!recovered) throw new DashboardImageNotFoundError(\"Image is no longer available from this transcript\");\n\t\tthis.insertOriginal(scope, recovered);\n\t\treturn recovered;\n\t}\n\n\tprivate putVariant(\n\t\tid: string,\n\t\tscope: DashboardImageScope,\n\t\tvariant: \"original\" | \"preview\",\n\t\tbinary: DashboardImageBinary,\n\t): void {\n\t\tlet image = this.images.get(id);\n\t\tif (!image) {\n\t\t\timage = { id, mimeType: binary.mimeType, size: binary.bytes.byteLength, scopes: new Set() };\n\t\t\tthis.images.set(id, image);\n\t\t}\n\t\timage.scopes.add(scopeKey(scope));\n\t\tconst previous = image[variant];\n\t\tif (previous) {\n\t\t\tthis.usedBytes -= previous.bytes.byteLength;\n\t\t} else {\n\t\t\tthis.usedRecords += 1;\n\t\t}\n\t\timage[variant] = { bytes: binary.bytes, mimeType: binary.mimeType, lastUsed: ++this.clock };\n\t\tthis.usedBytes += binary.bytes.byteLength;\n\t\tthis.evict();\n\t}\n\n\tprivate evict(): void {\n\t\twhile (this.usedBytes > this.maxBytes || this.usedRecords > this.maxRecords) {\n\t\t\tlet oldest: { image: CachedImage; variant: \"original\" | \"preview\"; lastUsed: number } | undefined;\n\t\t\tfor (const image of this.images.values()) {\n\t\t\t\tfor (const variant of [\"original\", \"preview\"] as const) {\n\t\t\t\t\tconst candidate = image[variant];\n\t\t\t\t\tif (candidate && (!oldest || candidate.lastUsed < oldest.lastUsed)) {\n\t\t\t\t\t\toldest = { image, variant, lastUsed: candidate.lastUsed };\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (!oldest) break;\n\t\t\tconst removed = oldest.image[oldest.variant]!;\n\t\t\tthis.usedBytes -= removed.bytes.byteLength;\n\t\t\tthis.usedRecords -= 1;\n\t\t\tdelete oldest.image[oldest.variant];\n\t\t\tif (!oldest.image.original && !oldest.image.preview) this.images.delete(oldest.image.id);\n\t\t}\n\t}\n\n\tprivate deleteImage(image: CachedImage): void {\n\t\tfor (const variant of [image.original, image.preview]) {\n\t\t\tif (!variant) continue;\n\t\t\tthis.usedBytes -= variant.bytes.byteLength;\n\t\t\tthis.usedRecords -= 1;\n\t\t}\n\t\tthis.images.delete(image.id);\n\t}\n}\n"]}