@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.
- package/README.md +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +102 -3
- package/dist/index.html +64 -41
- package/dist/js/app.GSAIYHML.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.CGWFHJI2.js +76 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.6DT53STL.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +53 -0
- package/dist/styles.012p32dz.css +1443 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +102 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +60 -0
- package/lib/config.js +127 -9
- package/lib/daemon.js +185 -40
- package/lib/event-handler.js +209 -90
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/preflight.js +195 -0
- package/lib/push-service.js +338 -45
- package/lib/routes.js +1218 -144
- package/lib/server.js +159 -32
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +94 -0
- package/lib/store.js +654 -24
- package/lib/title-service.js +42 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +38 -4
- package/dist/js/app.4FZ67UW4.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- 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
|
+
}
|
package/lib/bootstrap.js
ADDED
|
@@ -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
|
+
}
|