@lelouchhe/webagent 0.2.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +58 -23
  2. package/bin/webagent.mjs +119 -8
  3. package/config.toml +102 -3
  4. package/dist/index.html +64 -41
  5. package/dist/js/app.GSAIYHML.js +4 -0
  6. package/dist/js/chunk.AJZBJBMO.js +1 -0
  7. package/dist/js/chunk.CGWFHJI2.js +76 -0
  8. package/dist/js/chunk.D4ZYHJAM.js +1 -0
  9. package/dist/js/chunk.VZXGXFNN.js +5 -0
  10. package/dist/js/login.PYIK52HN.js +1 -0
  11. package/dist/js/viewer.6DT53STL.js +1 -0
  12. package/dist/login.html +49 -0
  13. package/dist/share-viewer.00gubshk.css +114 -0
  14. package/dist/share-viewer.html +53 -0
  15. package/dist/styles.012p32dz.css +1443 -0
  16. package/dist/sw.js +79 -27
  17. package/dist/theme-init.js +6 -0
  18. package/lib/agent-detect.js +110 -0
  19. package/lib/atomic-write.js +50 -0
  20. package/lib/attachment-dispatch.js +86 -0
  21. package/lib/attachment-interceptor.js +130 -0
  22. package/lib/attachment-labels.js +139 -0
  23. package/lib/attachments.js +154 -0
  24. package/lib/auth-middleware.js +102 -0
  25. package/lib/auth-store.js +269 -0
  26. package/lib/auth.js +89 -0
  27. package/lib/bootstrap.js +70 -0
  28. package/lib/bridge.js +244 -93
  29. package/lib/client-registry.js +60 -0
  30. package/lib/config.js +127 -9
  31. package/lib/daemon.js +185 -40
  32. package/lib/event-handler.js +209 -90
  33. package/lib/log-fmt.js +67 -0
  34. package/lib/log.js +83 -0
  35. package/lib/message-cleanup.js +48 -0
  36. package/lib/mode-bucket.js +62 -0
  37. package/lib/preflight.js +195 -0
  38. package/lib/push-service.js +338 -45
  39. package/lib/routes.js +1218 -144
  40. package/lib/server.js +159 -32
  41. package/lib/session-manager.js +164 -18
  42. package/lib/session-state.js +160 -0
  43. package/lib/sessions-anchor.js +28 -0
  44. package/lib/share/cleanup.js +45 -0
  45. package/lib/share/routes.js +972 -0
  46. package/lib/share/sanitize.js +179 -0
  47. package/lib/sse-manager.js +94 -8
  48. package/lib/sse-ticket.js +45 -0
  49. package/lib/startup-checks.js +94 -0
  50. package/lib/store.js +654 -24
  51. package/lib/title-service.js +42 -9
  52. package/lib/tokens.js +50 -0
  53. package/lib/types.js +23 -0
  54. package/package.json +38 -4
  55. package/dist/js/app.4FZ67UW4.js +0 -10
  56. package/dist/styles.008ve1hx.css +0 -669
  57. package/lib/shared/constants.js +0 -17
@@ -0,0 +1,154 @@
1
+ // Helpers for the attachment upload pipeline. The upload handler in
2
+ // routes.ts threads request -> busboy -> stream-to-disk -> DB row using
3
+ // these primitives. Everything here is server-only; clients never see
4
+ // realpaths or temp filenames.
5
+ import { fileTypeFromBuffer } from "file-type";
6
+ /**
7
+ * Server-controlled mime → file extension map (uploads-plan v2.6 §14).
8
+ *
9
+ * The disk extension is derived from the mime type, NOT the client-supplied
10
+ * filename. That way a client lying about the extension cannot trick disk
11
+ * tooling that uses extension-based heuristics. Anything not in the table
12
+ * falls through to `.bin` so `mime` and `ext` stay consistent.
13
+ */
14
+ const MIME_TO_EXT = {
15
+ "image/png": "png",
16
+ "image/jpeg": "jpg",
17
+ "image/gif": "gif",
18
+ "image/webp": "webp",
19
+ "image/svg+xml": "svg",
20
+ "application/pdf": "pdf",
21
+ "text/plain": "txt",
22
+ "text/markdown": "md",
23
+ "text/html": "html",
24
+ "text/csv": "csv",
25
+ "text/javascript": "js",
26
+ "application/json": "json",
27
+ "application/javascript": "js",
28
+ "application/xml": "xml",
29
+ "text/xml": "xml",
30
+ "application/zip": "zip",
31
+ "application/gzip": "gz",
32
+ "application/x-tar": "tar",
33
+ "application/x-7z-compressed": "7z",
34
+ "application/x-rar-compressed": "rar",
35
+ };
36
+ export function mimeToExt(mime) {
37
+ return MIME_TO_EXT[mime.toLowerCase()] ?? "bin";
38
+ }
39
+ /**
40
+ * Detect the real mime type of an uploaded file by inspecting its content,
41
+ * not the client-supplied Content-Type or filename extension. Resolves the
42
+ * "user uploads `.clj` → browser sends application/octet-stream → agents
43
+ * skip reading binary blobs" failure mode (uploads-plan dev log 2026-04-30).
44
+ *
45
+ * Strategy:
46
+ * 1. `file-type` checks magic bytes for ~200 binary formats (PDF, PNG,
47
+ * ZIP, office, audio, video, ...). If it hits, trust that.
48
+ * 2. No magic match → check whether the buffer is plausibly text:
49
+ * - no NUL bytes (binary marker)
50
+ * - decodes cleanly as UTF-8
51
+ * → return "text/plain". Source code (Clojure, Lua, Rust, Go, ...)
52
+ * all land here regardless of how the OS / browser tagged them.
53
+ * 3. Otherwise fall through to "application/octet-stream".
54
+ *
55
+ * Pass `head` as a buffer of at least the first 4 KB of file content; that's
56
+ * enough for every magic signature `file-type` knows about.
57
+ */
58
+ export async function sniffMime(head) {
59
+ const detected = await fileTypeFromBuffer(head);
60
+ if (detected)
61
+ return detected.mime;
62
+ if (looksLikeUtf8Text(head))
63
+ return "text/plain";
64
+ return "application/octet-stream";
65
+ }
66
+ function looksLikeUtf8Text(buf) {
67
+ if (buf.length === 0)
68
+ return true;
69
+ for (const byte of buf) {
70
+ if (byte === 0)
71
+ return false;
72
+ }
73
+ try {
74
+ const decoder = new TextDecoder("utf-8", { fatal: true });
75
+ decoder.decode(buf);
76
+ return true;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * Mimes we are willing to render inline in <img>. Everything else is forced
84
+ * to download via Content-Disposition: attachment so a malicious upload (HTML,
85
+ * SVG, text/plain interpreted as HTML by Chrome) cannot script the page.
86
+ */
87
+ const INLINE_MIMES = new Set([
88
+ "image/png",
89
+ "image/jpeg",
90
+ "image/gif",
91
+ "image/webp",
92
+ ]);
93
+ export function isInlineMime(mime) {
94
+ return INLINE_MIMES.has(mime.toLowerCase());
95
+ }
96
+ /**
97
+ * Classify an upload as "image" (sent as ACP image block, inlined as base64)
98
+ * or "file" (sent as ACP resource_link). Decision is mime-prefix-based —
99
+ * anything with `image/*` is "image", everything else is "file". Clients
100
+ * cannot override this.
101
+ */
102
+ export function classifyKind(mime) {
103
+ return mime.toLowerCase().startsWith("image/") ? "image" : "file";
104
+ }
105
+ /**
106
+ * Normalize a client-supplied filename for safe display + disk use:
107
+ * - NFC-normalize Unicode (combining characters → composed form).
108
+ * - Strip ASCII control characters (CR, LF, tab, NUL, etc).
109
+ * - Strip path separators ("/", "\") so the name cannot encode a
110
+ * sub-path on its own.
111
+ * - Reject "." and ".." outright (path-traversal sentinels).
112
+ * - Cap to 255 UTF-8 bytes (POSIX NAME_MAX).
113
+ *
114
+ * Returns null if normalization would produce an empty name or one of the
115
+ * forbidden sentinels. Callers should fall back to a generated default
116
+ * (e.g. `image-N` / `file-N`).
117
+ */
118
+ export function normalizeDisplayName(raw) {
119
+ if (typeof raw !== "string")
120
+ return null;
121
+ let s = raw.normalize("NFC");
122
+ // eslint-disable-next-line no-control-regex
123
+ s = s.replace(/[\x00-\x1f\x7f]/g, "");
124
+ s = s.replace(/[/\\]/g, "");
125
+ s = s.trim();
126
+ if (s.length === 0)
127
+ return null;
128
+ if (s === "." || s === "..")
129
+ return null;
130
+ // Cap at 255 UTF-8 bytes — slice in code units first as a fast path,
131
+ // then verify byte-length and trim if needed.
132
+ if (Buffer.byteLength(s, "utf8") > 255) {
133
+ while (Buffer.byteLength(s, "utf8") > 255 && s.length > 0) {
134
+ s = s.slice(0, -1);
135
+ }
136
+ if (s.length === 0)
137
+ return null;
138
+ }
139
+ return s;
140
+ }
141
+ /**
142
+ * Build a Content-Disposition value with both an ASCII fallback (filename=)
143
+ * and an RFC 5987 percent-encoded UTF-8 form (filename*=) so non-ASCII names
144
+ * survive every browser's download dialog.
145
+ *
146
+ * `disposition` is "inline" or "attachment". "attachment" forces the browser
147
+ * to download instead of rendering, which is what we use for non-image
148
+ * mimes (decision 3).
149
+ */
150
+ export function buildContentDisposition(disposition, displayName) {
151
+ const ascii = displayName.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "");
152
+ const utf8 = encodeURIComponent(displayName);
153
+ return `${disposition}; filename="${ascii}"; filename*=UTF-8''${utf8}`;
154
+ }
@@ -0,0 +1,102 @@
1
+ // All whitelisted paths must be GETs that read non-sensitive data, or static
2
+ // assets needed before the user can present a token.
3
+ const WHITELIST = [
4
+ // Public probes
5
+ { method: "GET", test: (p) => p === "/api/v1/version" },
6
+ { method: "GET", test: (p) => p === "/api/beta/push/vapid-key" },
7
+ // Static shell + login UI
8
+ { method: "GET", test: (p) => p === "/" },
9
+ { method: "GET", test: (p) => p === "/login" },
10
+ { method: "GET", test: (p) => p === "/login.html" },
11
+ { method: "GET", test: (p) => p === "/manifest.json" },
12
+ { method: "GET", test: (p) => p === "/sw.js" },
13
+ { method: "GET", test: (p) => p === "/favicon.ico" },
14
+ { method: "GET", test: (p) => p === "/theme-init.js" },
15
+ // Hashed bundles (must match build output naming)
16
+ { method: "GET", test: (p) => /^\/js\/[A-Za-z0-9._-]+\.js$/.test(p) },
17
+ { method: "GET", test: (p) => /^\/styles\.[A-Za-z0-9._-]+\.css$/.test(p) },
18
+ { method: "GET", test: (p) => p === "/styles.css" },
19
+ // Icons directory (no traversal: enforced by the check below)
20
+ { method: "GET", test: (p) => /^\/icons\/[A-Za-z0-9._-]+$/.test(p) },
21
+ // SSE streams — authenticated via short-lived ticket in query string
22
+ // (EventSource cannot send custom headers).
23
+ { method: "GET", test: (p) => p === "/api/v1/events/stream" },
24
+ {
25
+ method: "GET",
26
+ test: (p) => /^\/api\/v1\/sessions\/[A-Za-z0-9_-]+\/events\/stream$/.test(p),
27
+ },
28
+ // Image GETs — authenticated via HMAC sig+exp query string (an <img>
29
+ // tag cannot send Authorization headers). The image route handler does
30
+ // its own verification before serving bytes.
31
+ {
32
+ method: "GET",
33
+ test: (p) => /^\/api\/v1\/sessions\/[A-Za-z0-9_-]+\/attachments\/[A-Za-z0-9._-]+$/.test(p),
34
+ },
35
+ // --- Share viewer (public read-only snapshots) ---
36
+ // Viewer HTML shell + image proxy + viewer-namespaced static assets
37
+ // (CSS/JS) all live under /s/* — see src/share/routes.ts. The auth gate
38
+ // only applies to /api/**, so /s/* paths fall through to share routes
39
+ // without needing a whitelist entry. The viewer's event stream is the
40
+ // only public /api/ path: it serves a frozen snapshot identified solely
41
+ // by the share token in the URL.
42
+ {
43
+ method: "GET",
44
+ test: (p) => /^\/api\/v1\/shared\/[A-Za-z0-9_-]{24}\/events$/.test(p),
45
+ },
46
+ ];
47
+ /**
48
+ * True if the request can bypass authentication. Path must be normalized
49
+ * (no `..`, no `//`); we reject anything containing those segments to avoid
50
+ * traversal-based whitelist bypass.
51
+ */
52
+ export function isWhitelistedPath(method, path) {
53
+ if (!path || path.includes("..") || path.includes("//"))
54
+ return false;
55
+ const m = method.toUpperCase();
56
+ for (const entry of WHITELIST) {
57
+ if (entry.method === m && entry.test(path))
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ /**
63
+ * Validate the Authorization header against the auth store.
64
+ * Touches lastUsedAt on success. Pure header parsing + store lookup —
65
+ * no I/O beyond the in-memory map.
66
+ */
67
+ export function authenticate(headers, store) {
68
+ const raw = headers.authorization ?? headers.Authorization;
69
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety for dynamic headers
70
+ if (raw === undefined || raw === null)
71
+ return { ok: false, reason: "missing" };
72
+ if (Array.isArray(raw))
73
+ return { ok: false, reason: "invalid" };
74
+ if (typeof raw !== "string")
75
+ return { ok: false, reason: "invalid" };
76
+ const trimmed = raw.trim();
77
+ if (!trimmed)
78
+ return { ok: false, reason: "missing" };
79
+ // Parse "Bearer <token>" case-insensitively. Reject scheme-only or extra spaces.
80
+ const match = /^Bearer\s+(\S+)\s*$/i.exec(trimmed);
81
+ if (!match)
82
+ return { ok: false, reason: "invalid" };
83
+ const token = match[1];
84
+ const principal = store.findByToken(token);
85
+ if (!principal)
86
+ return { ok: false, reason: "invalid" };
87
+ store.touchLastUsed(token);
88
+ return { ok: true, principal };
89
+ }
90
+ /**
91
+ * Returns true if the auth result has at least the required scope.
92
+ * admin > api (admin is a superset).
93
+ */
94
+ export function requireScope(result, required) {
95
+ if (!result.ok)
96
+ return false;
97
+ const have = result.principal.scope;
98
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive check for type safety
99
+ if (required === "api")
100
+ return have === "api" || have === "admin";
101
+ return have === "admin";
102
+ }
@@ -0,0 +1,269 @@
1
+ import { promises as fs, existsSync, readFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import lockfile from "proper-lockfile";
4
+ import { generateToken, hashToken, verifyToken } from "./auth.js";
5
+ import { atomicWriteFile } from "./atomic-write.js";
6
+ const VALID_NAME = /^[A-Za-z0-9_-]{1,64}$/;
7
+ const VALID_SCOPE = new Set(["admin", "api"]);
8
+ const FILE_MODE = 0o600;
9
+ const LOCK_OPTS = {
10
+ retries: { retries: 10, factor: 2, minTimeout: 20, maxTimeout: 200 },
11
+ stale: 5_000,
12
+ realpath: false,
13
+ };
14
+ export class AuthStore {
15
+ tokens = new Map(); // keyed by hash
16
+ dirtyHashes = new Set(); // touched lastUsedAt waiting to flush
17
+ loaded = false;
18
+ flushTimer = null;
19
+ path;
20
+ flushIntervalMs;
21
+ constructor(path, flushIntervalMs = 60_000) {
22
+ this.path = path;
23
+ this.flushIntervalMs = flushIntervalMs;
24
+ }
25
+ /** Read auth.json into memory. Missing file = empty store. Throws on corrupt JSON. */
26
+ async load() {
27
+ await fs.mkdir(dirname(this.path), { recursive: true });
28
+ if (!existsSync(this.path)) {
29
+ this.tokens = new Map();
30
+ this.loaded = true;
31
+ this.startFlushTimer();
32
+ return;
33
+ }
34
+ const raw = readFileSync(this.path, "utf8");
35
+ const data = parseAuthFile(raw);
36
+ this.tokens = new Map(data.tokens.map((t) => [t.hash, t]));
37
+ this.loaded = true;
38
+ this.startFlushTimer();
39
+ }
40
+ /** Reload from disk, discarding any in-memory dirty state. */
41
+ async reload() {
42
+ this.dirtyHashes.clear();
43
+ this.loaded = false;
44
+ await this.load();
45
+ }
46
+ list() {
47
+ return Array.from(this.tokens.values()).map((t) => ({ ...t }));
48
+ }
49
+ findByToken(token) {
50
+ if (!token.startsWith("wat_"))
51
+ return null;
52
+ const h = hashToken(token);
53
+ const rec = this.tokens.get(h);
54
+ if (!rec)
55
+ return null;
56
+ // defense in depth: also verify timing-safe (cheap, same hash already)
57
+ if (!verifyToken(token, rec.hash))
58
+ return null;
59
+ return { ...rec };
60
+ }
61
+ /** True if a token with this name is still active. Used by SSE heartbeat
62
+ * to detect revocation mid-stream. */
63
+ hasTokenName(name) {
64
+ for (const rec of this.tokens.values()) {
65
+ if (rec.name === name)
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ /** Update lastUsedAt in memory; persisted on next flush(). */
71
+ touchLastUsed(token) {
72
+ if (!token)
73
+ return;
74
+ const h = hashToken(token);
75
+ const rec = this.tokens.get(h);
76
+ if (!rec)
77
+ return;
78
+ rec.lastUsedAt = Date.now();
79
+ this.dirtyHashes.add(h);
80
+ }
81
+ async addToken(name, scope) {
82
+ this.assertLoaded();
83
+ if (!VALID_NAME.test(name)) {
84
+ throw new Error(`Invalid token name: ${JSON.stringify(name)} (use [A-Za-z0-9_-], 1-64 chars)`);
85
+ }
86
+ if (!VALID_SCOPE.has(scope)) {
87
+ throw new Error(`Invalid scope: ${scope}`);
88
+ }
89
+ return this.withLock(async () => {
90
+ // Reload to merge any external changes before mutating.
91
+ const onDisk = await this.readFromDisk();
92
+ const merged = this.mergeWithDisk(onDisk);
93
+ if (merged.some((t) => t.name === name)) {
94
+ throw new Error(`Token name already exists: ${name}`);
95
+ }
96
+ const token = generateToken();
97
+ const record = {
98
+ name,
99
+ scope,
100
+ hash: hashToken(token),
101
+ createdAt: Date.now(),
102
+ lastUsedAt: null,
103
+ };
104
+ merged.push(record);
105
+ await this.writeToDisk({ tokens: merged });
106
+ this.replaceInMemory(merged);
107
+ this.dirtyHashes.clear();
108
+ return { token, record: { ...record } };
109
+ });
110
+ }
111
+ async revokeToken(name) {
112
+ this.assertLoaded();
113
+ return this.withLock(async () => {
114
+ const onDisk = await this.readFromDisk();
115
+ const merged = this.mergeWithDisk(onDisk);
116
+ const idx = merged.findIndex((t) => t.name === name);
117
+ if (idx === -1) {
118
+ // Sync memory with disk anyway.
119
+ this.replaceInMemory(merged);
120
+ this.dirtyHashes.clear();
121
+ return false;
122
+ }
123
+ merged.splice(idx, 1);
124
+ await this.writeToDisk({ tokens: merged });
125
+ this.replaceInMemory(merged);
126
+ this.dirtyHashes.clear();
127
+ return true;
128
+ });
129
+ }
130
+ /**
131
+ * Persist dirty lastUsedAt fields to disk, preserving any external edits
132
+ * (revokes, additions). Safe to call frequently; no-op if nothing dirty.
133
+ */
134
+ async flush() {
135
+ if (this.dirtyHashes.size === 0)
136
+ return;
137
+ await this.withLock(async () => {
138
+ const onDisk = await this.readFromDisk();
139
+ // Build merged list: start from disk (authoritative for membership)
140
+ // then overlay our dirty lastUsedAt where the hash still exists.
141
+ const merged = onDisk.map((diskRec) => {
142
+ if (this.dirtyHashes.has(diskRec.hash)) {
143
+ const memRec = this.tokens.get(diskRec.hash);
144
+ if (memRec?.lastUsedAt &&
145
+ (!diskRec.lastUsedAt || memRec.lastUsedAt > diskRec.lastUsedAt)) {
146
+ return { ...diskRec, lastUsedAt: memRec.lastUsedAt };
147
+ }
148
+ }
149
+ return diskRec;
150
+ });
151
+ await this.writeToDisk({ tokens: merged });
152
+ this.replaceInMemory(merged);
153
+ this.dirtyHashes.clear();
154
+ });
155
+ }
156
+ async close() {
157
+ if (this.flushTimer) {
158
+ clearInterval(this.flushTimer);
159
+ this.flushTimer = null;
160
+ }
161
+ if (this.loaded && this.dirtyHashes.size > 0) {
162
+ try {
163
+ await this.flush();
164
+ }
165
+ catch {
166
+ // best-effort on shutdown
167
+ }
168
+ }
169
+ }
170
+ // ---- internals -----------------------------------------------------------
171
+ assertLoaded() {
172
+ if (!this.loaded)
173
+ throw new Error("AuthStore not loaded; call load() first");
174
+ }
175
+ startFlushTimer() {
176
+ if (this.flushTimer || this.flushIntervalMs <= 0)
177
+ return;
178
+ this.flushTimer = setInterval(() => {
179
+ void this.flush().catch(() => { });
180
+ }, this.flushIntervalMs);
181
+ this.flushTimer.unref();
182
+ }
183
+ async readFromDisk() {
184
+ if (!existsSync(this.path))
185
+ return [];
186
+ const raw = await fs.readFile(this.path, "utf8");
187
+ return parseAuthFile(raw).tokens;
188
+ }
189
+ /**
190
+ * Merge in-memory tokens onto disk-authoritative list:
191
+ * - Membership comes from disk (deletions external = honored).
192
+ * - lastUsedAt: take the max of memory vs disk for matching hashes.
193
+ * - In-memory-only tokens (added by us, not yet on disk) — won't happen
194
+ * because addToken always writes synchronously. So disk is full truth.
195
+ */
196
+ mergeWithDisk(onDisk) {
197
+ return onDisk.map((diskRec) => {
198
+ const memRec = this.tokens.get(diskRec.hash);
199
+ if (!memRec)
200
+ return diskRec;
201
+ const lastUsedAt = Math.max(diskRec.lastUsedAt ?? 0, memRec.lastUsedAt ?? 0) || null;
202
+ return { ...diskRec, lastUsedAt };
203
+ });
204
+ }
205
+ replaceInMemory(records) {
206
+ this.tokens = new Map(records.map((t) => [t.hash, { ...t }]));
207
+ }
208
+ async writeToDisk(data) {
209
+ await atomicWriteFile(this.path, JSON.stringify(data, null, 2), FILE_MODE);
210
+ }
211
+ async withLock(fn) {
212
+ // Ensure file exists (proper-lockfile requires the target to exist).
213
+ if (!existsSync(this.path)) {
214
+ const fh = await fs.open(this.path, "w", FILE_MODE);
215
+ await fh.writeFile(JSON.stringify({ tokens: [] }, null, 2));
216
+ await fh.close();
217
+ }
218
+ const release = await lockfile.lock(this.path, LOCK_OPTS);
219
+ try {
220
+ return await fn();
221
+ }
222
+ finally {
223
+ await release();
224
+ }
225
+ }
226
+ }
227
+ function parseAuthFile(raw) {
228
+ let data;
229
+ try {
230
+ data = JSON.parse(raw);
231
+ }
232
+ catch (err) {
233
+ throw new Error(`auth.json is not valid JSON: ${err.message}`, {
234
+ cause: err,
235
+ });
236
+ }
237
+ if (!data ||
238
+ typeof data !== "object" ||
239
+ !Array.isArray(data.tokens)) {
240
+ throw new Error("auth.json must have a 'tokens' array");
241
+ }
242
+ const tokens = data.tokens.map((entry, i) => {
243
+ if (!entry || typeof entry !== "object")
244
+ throw new Error(`auth.json tokens[${i}] not an object`);
245
+ const e = entry;
246
+ if (typeof e.name !== "string")
247
+ throw new Error(`auth.json tokens[${i}].name missing`);
248
+ if (e.scope !== "admin" && e.scope !== "api")
249
+ throw new Error(`auth.json tokens[${i}].scope invalid`);
250
+ if (typeof e.hash !== "string" || !/^[a-f0-9]{64}$/i.test(e.hash)) {
251
+ throw new Error(`auth.json tokens[${i}].hash invalid`);
252
+ }
253
+ if (typeof e.createdAt !== "number")
254
+ throw new Error(`auth.json tokens[${i}].createdAt missing`);
255
+ const lastUsedAt = e.lastUsedAt;
256
+ if (lastUsedAt !== null && typeof lastUsedAt !== "number") {
257
+ throw new Error(`auth.json tokens[${i}].lastUsedAt invalid`);
258
+ }
259
+ return {
260
+ name: e.name,
261
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- narrowed by line 280 check but TSC needs cast from unknown
262
+ scope: e.scope,
263
+ hash: e.hash.toLowerCase(),
264
+ createdAt: e.createdAt,
265
+ lastUsedAt: lastUsedAt,
266
+ };
267
+ });
268
+ return { tokens };
269
+ }
package/lib/auth.js ADDED
@@ -0,0 +1,89 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { generateApiToken } from "./tokens.js";
3
+ const HASH_HEX_LEN = 64; // SHA-256 hex
4
+ /**
5
+ * Generate a fresh API token. Thin re-export — the canonical generator
6
+ * lives in `src/tokens.ts` alongside the other auth-bearing token
7
+ * generators (share, SSE) for a single audit surface.
8
+ */
9
+ export function generateToken() {
10
+ return generateApiToken();
11
+ }
12
+ /** Hash a token with SHA-256 -> 64-char lowercase hex. */
13
+ export function hashToken(token) {
14
+ return createHash("sha256").update(token).digest("hex");
15
+ }
16
+ /**
17
+ * Constant-time check that the given token hashes to the given hex hash.
18
+ * Returns false on any malformed input rather than throwing.
19
+ */
20
+ export function verifyToken(token, expectedHashHex) {
21
+ if (!token || expectedHashHex.length !== HASH_HEX_LEN) {
22
+ return false;
23
+ }
24
+ if (!/^[a-f0-9]+$/i.test(expectedHashHex))
25
+ return false;
26
+ const actual = Buffer.from(hashToken(token), "hex");
27
+ const expected = Buffer.from(expectedHashHex, "hex");
28
+ if (actual.length !== expected.length)
29
+ return false;
30
+ return timingSafeEqual(actual, expected);
31
+ }
32
+ // --- Image URL signing -------------------------------------------------------
33
+ function hmacHex(secret, data) {
34
+ return createHmac("sha256", secret).update(data).digest("hex");
35
+ }
36
+ /** Build the canonical input string for HMAC. Path + ":" + exp. */
37
+ function canonical(path, exp) {
38
+ return `${path}:${exp}`;
39
+ }
40
+ /**
41
+ * Returns "exp=<unix>&sig=<hex>" — appendable as a query string to the path.
42
+ * ttlSeconds may be negative (yields an already-expired URL, useful in tests).
43
+ */
44
+ export function signAttachmentUrl(path, secret, ttlSeconds) {
45
+ const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
46
+ const sig = hmacHex(secret, canonical(path, exp));
47
+ return `exp=${exp}&sig=${sig}`;
48
+ }
49
+ /**
50
+ * Verify a signed image URL. Returns false on any malformed input or expired/tampered URL.
51
+ * HMAC binds (path, exp) so altering either invalidates the signature.
52
+ */
53
+ export function verifyAttachmentSig(path, expRaw, sigHex, secret) {
54
+ if (!path || !expRaw || !sigHex)
55
+ return false;
56
+ if (!/^\d+$/.test(expRaw))
57
+ return false;
58
+ if (!/^[a-f0-9]+$/i.test(sigHex))
59
+ return false;
60
+ const exp = Number(expRaw);
61
+ if (!Number.isFinite(exp))
62
+ return false;
63
+ if (exp < Math.floor(Date.now() / 1000))
64
+ return false;
65
+ const expected = Buffer.from(hmacHex(secret, canonical(path, expRaw)), "hex");
66
+ let actual;
67
+ try {
68
+ actual = Buffer.from(sigHex, "hex");
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ if (actual.length !== expected.length)
74
+ return false;
75
+ return timingSafeEqual(actual, expected);
76
+ }
77
+ /**
78
+ * Rewrite every `/api/v1/sessions/:id/attachments/:file` URL inside a JSON-serialized
79
+ * payload to carry a fresh `?exp=&sig=`. Applied at egress (history GET, SSE
80
+ * push) so a 1h-old stored URL is re-signed on the way out — the user can
81
+ * reload history days later and images still resolve.
82
+ */
83
+ const ATTACHMENT_URL_RE = /\/api\/v1\/sessions\/[A-Za-z0-9_-]+\/attachments\/[A-Za-z0-9._-]+(?:\?(?:exp=\d+&sig=[a-f0-9]+|sig=[a-f0-9]+&exp=\d+))?/g;
84
+ export function reSignAttachmentUrlsInJson(json, secret, ttlSeconds = 3600) {
85
+ return json.replace(ATTACHMENT_URL_RE, (match) => {
86
+ const basePath = match.split("?")[0];
87
+ return `${basePath}?${signAttachmentUrl(basePath, secret, ttlSeconds)}`;
88
+ });
89
+ }
@@ -0,0 +1,70 @@
1
+ // First-run bootstrap policy + presentation.
2
+ //
3
+ // Pure functions only — no I/O, no globals. Side effects (token mint,
4
+ // stdout write, process exit) belong in server.ts; this module owns the
5
+ // decision and the banner formatting so tests can lock down behavior
6
+ // without spinning up a server.
7
+ //
8
+ // Decision matrix (decideBootstrap):
9
+ // tokenCount > 0 → proceed
10
+ // tokenCount = 0 + authJsonExists → exit-config
11
+ // (file existed but parsed empty = config
12
+ // anomaly; do NOT silently re-mint admin)
13
+ // tokenCount = 0 + !authJsonExists + !isTTY → exit-config
14
+ // (daemon path: user must use --create-token)
15
+ // tokenCount = 0 + !authJsonExists + isTTY + !enabled → exit-config
16
+ // (operator opted out; preserve old UX)
17
+ // tokenCount = 0 + !authJsonExists + isTTY + enabled → mint
18
+ //
19
+ // On mint, the banner prints the token verbatim and asks the operator
20
+ // to paste it into the /login form. We deliberately do NOT print a
21
+ // clickable URL with the token in the fragment: although fragments
22
+ // don't reach the server in HTTP requests, they do leak via browser
23
+ // history sync, history-permission extensions, and "looks like a link
24
+ // → click it" muscle memory. Plain-token + manual paste matches the
25
+ // existing `--create-token` flow's mental model.
26
+ export function decideBootstrap(input) {
27
+ if (input.tokenCount > 0)
28
+ return { kind: "proceed" };
29
+ if (input.authJsonExists)
30
+ return { kind: "exit-config" };
31
+ if (!input.isTTY)
32
+ return { kind: "exit-config" };
33
+ if (!input.firstRunEnabled)
34
+ return { kind: "exit-config" };
35
+ return { kind: "mint" };
36
+ }
37
+ /**
38
+ * Banner printed to stdout on first-run mint. Token is printed verbatim
39
+ * and the operator is asked to paste it into the /login form. ANSI is
40
+ * gated on isTTY so log capture / journald / supervisor pipes get plain
41
+ * text.
42
+ */
43
+ export function formatBootstrapBanner(opts) {
44
+ const { token, port, isTTY } = opts;
45
+ const bold = isTTY ? "\x1b[1m" : "";
46
+ const cyan = isTTY ? "\x1b[36m" : "";
47
+ const dim = isTTY ? "\x1b[2m" : "";
48
+ const reset = isTTY ? "\x1b[0m" : "";
49
+ const url = `http://localhost:${port}/`;
50
+ const lines = [
51
+ "",
52
+ `${bold}┌─ first-run ──────────────────────────────────────────────${reset}`,
53
+ `${bold}│${reset}`,
54
+ `${bold}│${reset} Welcome. WebAgent has minted a one-time admin token`,
55
+ `${bold}│${reset} for this device. Copy it and paste it into the`,
56
+ `${bold}│${reset} login form:`,
57
+ `${bold}│${reset}`,
58
+ `${bold}│${reset} 1. open ${cyan}${url}${reset} in your browser`,
59
+ `${bold}│${reset} 2. paste this token:`,
60
+ `${bold}│${reset}`,
61
+ `${bold}│${reset} ${bold}${cyan}${token}${reset}`,
62
+ `${bold}│${reset}`,
63
+ `${bold}│${reset} ${dim}Treat this token like a password — it appears in${reset}`,
64
+ `${bold}│${reset} ${dim}your terminal scrollback. Revoke from /tokens later${reset}`,
65
+ `${bold}│${reset} ${dim}if needed.${reset}`,
66
+ `${bold}└──────────────────────────────────────────────────────────${reset}`,
67
+ "",
68
+ ];
69
+ return lines.join("\n");
70
+ }