@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
package/lib/routes.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { readFile,
|
|
1
|
+
import { readFile, mkdir, rename, unlink, realpath } from "node:fs/promises";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { join, extname } from "node:path";
|
|
3
|
+
import { join, extname, basename } from "node:path";
|
|
4
4
|
import { gzipSync } from "node:zlib";
|
|
5
|
-
import
|
|
6
|
-
import { errorMessage } from "./types.js";
|
|
5
|
+
import busboy from "busboy";
|
|
6
|
+
import { errorMessage, MessageIngressSchema } from "./types.js";
|
|
7
7
|
import { interruptBashProc } from "./session-manager.js";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { createWriteStream } from "node:fs";
|
|
10
|
+
import { handleShareRoutes } from "./share/routes.js";
|
|
11
|
+
import { authenticate, isWhitelistedPath } from "./auth-middleware.js";
|
|
12
|
+
import { enrichStoredEventsForDisplay } from "./attachment-labels.js";
|
|
13
|
+
import { log } from "./log.js";
|
|
14
|
+
const rlog = log.scope("routes");
|
|
15
|
+
const plog = rlog.scope("prompt");
|
|
16
|
+
const slog = rlog.scope("session");
|
|
17
|
+
const mlog = rlog.scope("msg");
|
|
18
|
+
import { signAttachmentUrl, verifyAttachmentSig, reSignAttachmentUrlsInJson, } from "./auth.js";
|
|
19
|
+
import { buildContentDisposition, classifyKind, isInlineMime, mimeToExt, normalizeDisplayName, sniffMime, } from "./attachments.js";
|
|
8
20
|
const IS_WIN = process.platform === "win32";
|
|
9
21
|
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
10
22
|
const MIME = {
|
|
@@ -19,19 +31,91 @@ const MIME = {
|
|
|
19
31
|
".gif": "image/gif",
|
|
20
32
|
".webp": "image/webp",
|
|
21
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* HTML entrypoints served by this app. Any new HTML page MUST be registered
|
|
36
|
+
* here. Tests in `test/html-entrypoints.test.ts`, `test/csp.test.ts` and
|
|
37
|
+
* `test/inline-assets.test.ts` rely on this list to enforce security
|
|
38
|
+
* invariants (CSP header, no inline scripts/styles).
|
|
39
|
+
*/
|
|
40
|
+
export const HTML_ENTRYPOINTS = [
|
|
41
|
+
{ urlPath: "/", file: "index.html" },
|
|
42
|
+
{ urlPath: "/login", file: "login.html" },
|
|
43
|
+
// Share viewer — served by share's own dispatcher at /s/:token
|
|
44
|
+
// (see src/share/routes.ts → handleViewerHtml). The urlPath here is a
|
|
45
|
+
// pseudo-path used only by the test invariants (CSP/inline checks); the
|
|
46
|
+
// real route is /s/:token. Keep both views in sync if either changes.
|
|
47
|
+
{ urlPath: "/s", file: "share-viewer.html" },
|
|
48
|
+
];
|
|
49
|
+
/**
|
|
50
|
+
* Strict Content-Security-Policy for HTML responses.
|
|
51
|
+
*
|
|
52
|
+
* - default-src 'self': everything same-origin only
|
|
53
|
+
* - img-src adds data: + blob: for image-upload preview
|
|
54
|
+
* - script-src 'self' (no inline; theme bootstrap is /theme-init.js)
|
|
55
|
+
* - style-src 'self' (no inline; login styles live in /styles.css)
|
|
56
|
+
* - object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self'
|
|
57
|
+
* - connect-src 'self' for fetch + EventSource
|
|
58
|
+
*/
|
|
59
|
+
export const CSP_POLICY = [
|
|
60
|
+
"default-src 'self'",
|
|
61
|
+
"img-src 'self' data: blob:",
|
|
62
|
+
"script-src 'self'",
|
|
63
|
+
"style-src 'self'",
|
|
64
|
+
"connect-src 'self'",
|
|
65
|
+
"object-src 'none'",
|
|
66
|
+
"frame-ancestors 'none'",
|
|
67
|
+
"base-uri 'self'",
|
|
68
|
+
"form-action 'self'",
|
|
69
|
+
].join("; ");
|
|
22
70
|
/** Read the full request body as a string. */
|
|
23
71
|
function readBody(req) {
|
|
24
72
|
return new Promise((resolve, reject) => {
|
|
25
73
|
const chunks = [];
|
|
26
74
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
27
|
-
req.on("end", () =>
|
|
75
|
+
req.on("end", () => {
|
|
76
|
+
resolve(Buffer.concat(chunks).toString());
|
|
77
|
+
});
|
|
28
78
|
req.on("error", reject);
|
|
29
79
|
});
|
|
30
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* client-server-split M2: idempotency helpers for mutating REST.
|
|
83
|
+
* Frontend generates a UUID per user-initiated action and sends it as
|
|
84
|
+
* `X-Client-Op-Id`. Replays (after SSE/network reconnect) return the
|
|
85
|
+
* cached response instead of re-executing side effects. Missing header →
|
|
86
|
+
* non-idempotent path (back-compat for curl / older clients).
|
|
87
|
+
*/
|
|
88
|
+
function getClientOpId(req) {
|
|
89
|
+
const v = req.headers["x-client-op-id"];
|
|
90
|
+
if (typeof v === "string" && v.length > 0 && v.length <= 128)
|
|
91
|
+
return v;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
function tryReplayClientOp(req, res, store, sessionId) {
|
|
95
|
+
const opId = getClientOpId(req);
|
|
96
|
+
if (!opId)
|
|
97
|
+
return { opId: null, replayed: false };
|
|
98
|
+
const cached = store.getClientOp(sessionId, opId);
|
|
99
|
+
if (cached &&
|
|
100
|
+
typeof cached === "object" &&
|
|
101
|
+
"status" in cached &&
|
|
102
|
+
"body" in cached) {
|
|
103
|
+
json(res, cached.status, cached.body, req);
|
|
104
|
+
return { opId, replayed: true };
|
|
105
|
+
}
|
|
106
|
+
return { opId, replayed: false };
|
|
107
|
+
}
|
|
108
|
+
function saveClientOpResult(store, opId, sessionId, status, body) {
|
|
109
|
+
if (!opId)
|
|
110
|
+
return;
|
|
111
|
+
store.saveClientOp(sessionId, opId, { status, body });
|
|
112
|
+
}
|
|
31
113
|
/** Send a JSON response, gzip-compressed when the client supports it. */
|
|
32
114
|
function json(res, status, data, req) {
|
|
33
115
|
const body = JSON.stringify(data);
|
|
34
|
-
if (req &&
|
|
116
|
+
if (req &&
|
|
117
|
+
body.length > 1024 &&
|
|
118
|
+
(req.headers["accept-encoding"] ?? "").includes("gzip")) {
|
|
35
119
|
const compressed = gzipSync(body);
|
|
36
120
|
res.writeHead(status, {
|
|
37
121
|
"Content-Type": "application/json",
|
|
@@ -45,10 +129,274 @@ function json(res, status, data, req) {
|
|
|
45
129
|
res.end(body);
|
|
46
130
|
}
|
|
47
131
|
}
|
|
132
|
+
/** Per-request principal storage. WeakMap keeps it tied to the request lifetime
|
|
133
|
+
* without monkey-patching IncomingMessage or relying on `any`. */
|
|
134
|
+
const principalByRequest = new WeakMap();
|
|
135
|
+
export function getPrincipal(req) {
|
|
136
|
+
return principalByRequest.get(req);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Multipart upload handler. Streams the `file` field straight to disk under
|
|
140
|
+
* <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp, atomic-renames on
|
|
141
|
+
* success, deletes on any failure path. Inserts an attachments row with the
|
|
142
|
+
* resolved realpath so the bridge / permission interceptor can match it
|
|
143
|
+
* later.
|
|
144
|
+
*
|
|
145
|
+
* Wire format expected:
|
|
146
|
+
* - Content-Type: multipart/form-data; boundary=...
|
|
147
|
+
* - One file field named `file` (additional file fields are rejected).
|
|
148
|
+
* - Optional text fields are ignored — displayName comes from the file
|
|
149
|
+
* part's filename header, classification comes from its content-type.
|
|
150
|
+
*/
|
|
151
|
+
async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
152
|
+
const { store, dataDir, limits, sessions } = deps;
|
|
153
|
+
const fileUploadLimit = limits.file_upload ?? 52_428_800;
|
|
154
|
+
if (!store.getSession(sessionId)) {
|
|
155
|
+
json(res, 404, { error: "Session not found" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const dir = join(dataDir, "sessions", sessionId, "attachments");
|
|
159
|
+
await mkdir(dir, { recursive: true });
|
|
160
|
+
const uploadId = randomUUID();
|
|
161
|
+
let tmpPath = null;
|
|
162
|
+
let finalPath = null;
|
|
163
|
+
let bytesWritten = 0;
|
|
164
|
+
let fileMime = "";
|
|
165
|
+
let fileExt = "bin";
|
|
166
|
+
let displayName = null;
|
|
167
|
+
let kind = "file";
|
|
168
|
+
let limit = Math.max(limits.image_upload, fileUploadLimit);
|
|
169
|
+
let limitExceeded = false;
|
|
170
|
+
let sawFile = false;
|
|
171
|
+
let aborted = false;
|
|
172
|
+
let writeError = null;
|
|
173
|
+
let resolved = false;
|
|
174
|
+
// Resolves when the write stream backing the file part has flushed and
|
|
175
|
+
// closed. busboy's `close` can fire before fs has finished writing the
|
|
176
|
+
// last chunk to disk, so we must await this before renaming the .tmp.
|
|
177
|
+
let writeDone = Promise.resolve();
|
|
178
|
+
// Single-shot response. We only ever respond to the request once even
|
|
179
|
+
// though multiple busboy callbacks could converge on the same outcome
|
|
180
|
+
// (e.g. file-too-large + close-after-finish).
|
|
181
|
+
const respond = (status, body) => {
|
|
182
|
+
if (resolved)
|
|
183
|
+
return;
|
|
184
|
+
resolved = true;
|
|
185
|
+
json(res, status, body);
|
|
186
|
+
};
|
|
187
|
+
const cleanupTmp = async () => {
|
|
188
|
+
if (tmpPath) {
|
|
189
|
+
await unlink(tmpPath).catch(() => { });
|
|
190
|
+
tmpPath = null;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
return new Promise((resolveOuter) => {
|
|
194
|
+
const finish = async (status, body) => {
|
|
195
|
+
respond(status, body);
|
|
196
|
+
resolveOuter();
|
|
197
|
+
};
|
|
198
|
+
let bb;
|
|
199
|
+
try {
|
|
200
|
+
bb = busboy({
|
|
201
|
+
headers: req.headers,
|
|
202
|
+
defParamCharset: "utf8",
|
|
203
|
+
limits: {
|
|
204
|
+
// We enforce the size cap manually via per-file byte tracking
|
|
205
|
+
// (so we can pick the right cap based on classified kind and
|
|
206
|
+
// emit a 413 the moment we cross the line). Cap files = 1
|
|
207
|
+
// and field count = 16 as belt-and-suspenders.
|
|
208
|
+
files: 1,
|
|
209
|
+
fields: 16,
|
|
210
|
+
fieldNameSize: 100,
|
|
211
|
+
fieldSize: 1024,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
void finish(400, { error: "Invalid multipart" });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
bb.on("file", (fieldName, stream, info) => {
|
|
220
|
+
if (sawFile) {
|
|
221
|
+
// Extra file part — drain and ignore (busboy `files: 1` should
|
|
222
|
+
// already prevent this, but be defensive).
|
|
223
|
+
stream.resume();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
sawFile = true;
|
|
227
|
+
if (fieldName !== "file") {
|
|
228
|
+
stream.resume();
|
|
229
|
+
void finish(400, { error: "Unexpected field name" });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
fileMime = (info.mimeType || "application/octet-stream").toLowerCase();
|
|
233
|
+
kind = classifyKind(fileMime);
|
|
234
|
+
limit = kind === "image" ? limits.image_upload : fileUploadLimit;
|
|
235
|
+
fileExt = mimeToExt(fileMime);
|
|
236
|
+
displayName = info.filename ? normalizeDisplayName(info.filename) : null;
|
|
237
|
+
displayName ??= kind === "image" ? "image" : "file";
|
|
238
|
+
tmpPath = join(dir, `${uploadId}.${fileExt}.tmp`);
|
|
239
|
+
finalPath = join(dir, `${uploadId}.${fileExt}`);
|
|
240
|
+
const ws = createWriteStream(tmpPath);
|
|
241
|
+
writeDone = new Promise((resolveWs) => {
|
|
242
|
+
ws.on("close", () => {
|
|
243
|
+
resolveWs();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
stream.on("data", (chunk) => {
|
|
247
|
+
bytesWritten += chunk.length;
|
|
248
|
+
if (bytesWritten > limit) {
|
|
249
|
+
limitExceeded = true;
|
|
250
|
+
stream.unpipe(ws);
|
|
251
|
+
ws.destroy();
|
|
252
|
+
stream.resume();
|
|
253
|
+
// Abort the whole busboy pipeline. We respond on `close`.
|
|
254
|
+
req.unpipe(bb);
|
|
255
|
+
req.resume();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
stream.on("error", (err) => {
|
|
259
|
+
writeError = err;
|
|
260
|
+
});
|
|
261
|
+
ws.on("error", (err) => {
|
|
262
|
+
writeError = err;
|
|
263
|
+
});
|
|
264
|
+
stream.pipe(ws);
|
|
265
|
+
});
|
|
266
|
+
bb.on("error", (err) => {
|
|
267
|
+
writeError = err instanceof Error ? err : new Error(String(err));
|
|
268
|
+
});
|
|
269
|
+
req.on("aborted", () => {
|
|
270
|
+
aborted = true;
|
|
271
|
+
});
|
|
272
|
+
bb.on("close", () => {
|
|
273
|
+
void (async () => {
|
|
274
|
+
try {
|
|
275
|
+
await writeDone;
|
|
276
|
+
if (aborted) {
|
|
277
|
+
await cleanupTmp();
|
|
278
|
+
await finish(400, { error: "Upload aborted" });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (limitExceeded) {
|
|
282
|
+
await cleanupTmp();
|
|
283
|
+
await finish(413, { error: "Upload too large" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (writeError) {
|
|
287
|
+
await cleanupTmp();
|
|
288
|
+
await finish(500, { error: "Upload failed" });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!sawFile || !tmpPath || !finalPath || !displayName) {
|
|
292
|
+
await cleanupTmp();
|
|
293
|
+
await finish(400, { error: "Missing file part" });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// Sniff the actual mime from file content (magic bytes + UTF-8
|
|
297
|
+
// text fallback). Clients lie about Content-Type — browsers send
|
|
298
|
+
// application/octet-stream for any extension the OS doesn't know
|
|
299
|
+
// (.clj, .lua, .rs, ...), and ACP agents (Copilot CLI) refuse to
|
|
300
|
+
// read attachments tagged octet-stream. The sniffed mime wins
|
|
301
|
+
// silently; we override fileMime / kind / extension / final path
|
|
302
|
+
// before insertAttachment so DB and disk reflect reality.
|
|
303
|
+
const head = await readFile(tmpPath).catch(() => Buffer.alloc(0));
|
|
304
|
+
const headSlice = head.subarray(0, 4096);
|
|
305
|
+
const sniffed = await sniffMime(headSlice);
|
|
306
|
+
if (sniffed !== fileMime) {
|
|
307
|
+
fileMime = sniffed;
|
|
308
|
+
kind = classifyKind(fileMime);
|
|
309
|
+
const newExt = mimeToExt(fileMime);
|
|
310
|
+
if (newExt !== fileExt) {
|
|
311
|
+
fileExt = newExt;
|
|
312
|
+
const newTmp = join(dir, `${uploadId}.${fileExt}.tmp`);
|
|
313
|
+
if (newTmp !== tmpPath) {
|
|
314
|
+
await rename(tmpPath, newTmp);
|
|
315
|
+
tmpPath = newTmp;
|
|
316
|
+
}
|
|
317
|
+
finalPath = join(dir, `${uploadId}.${fileExt}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
await rename(tmpPath, finalPath);
|
|
321
|
+
const rp = await realpath(finalPath);
|
|
322
|
+
const row = store.insertAttachment({
|
|
323
|
+
id: uploadId,
|
|
324
|
+
sessionId,
|
|
325
|
+
kind,
|
|
326
|
+
name: displayName,
|
|
327
|
+
mime: fileMime,
|
|
328
|
+
size: bytesWritten,
|
|
329
|
+
realpath: rp,
|
|
330
|
+
});
|
|
331
|
+
// Invalidate the per-session attachment label cache so the
|
|
332
|
+
// next egress (SSE broadcast or replay) sees this new row.
|
|
333
|
+
sessions?.invalidateLabelCache(sessionId);
|
|
334
|
+
const fileName = `${row.id}.${fileExt}`;
|
|
335
|
+
const basePath = `/api/v1/sessions/${sessionId}/attachments/${fileName}`;
|
|
336
|
+
// 1h signed URL — long enough that the browser holds the rendered
|
|
337
|
+
// image in <img> cache for the full session lifetime, short enough
|
|
338
|
+
// that a leaked URL (screenshot, link share) expires within the day.
|
|
339
|
+
const fileUrl = deps.attachmentSecret
|
|
340
|
+
? `${basePath}?${signAttachmentUrl(basePath, deps.attachmentSecret, 3600)}`
|
|
341
|
+
: basePath;
|
|
342
|
+
await finish(200, {
|
|
343
|
+
attachmentId: row.id,
|
|
344
|
+
displayName: row.name,
|
|
345
|
+
mimeType: row.mime,
|
|
346
|
+
size: row.size,
|
|
347
|
+
kind: row.kind,
|
|
348
|
+
path: `sessions/${sessionId}/attachments/${fileName}`,
|
|
349
|
+
url: fileUrl,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
await cleanupTmp();
|
|
354
|
+
await finish(500, { error: errorMessage(err) });
|
|
355
|
+
}
|
|
356
|
+
})();
|
|
357
|
+
});
|
|
358
|
+
req.pipe(bb);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
48
361
|
export function createRequestHandler(deps) {
|
|
49
362
|
const { store, sessions, getBridge, sseManager, titleService } = deps;
|
|
363
|
+
// eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
|
|
50
364
|
return async (req, res) => {
|
|
51
365
|
const url = req.url ?? "/";
|
|
366
|
+
// --- Auth gate: any /api/** outside whitelist requires Bearer ---
|
|
367
|
+
if (deps.authStore && url.startsWith("/api/")) {
|
|
368
|
+
const path = url.split("?")[0] ?? url;
|
|
369
|
+
const method = req.method ?? "GET";
|
|
370
|
+
if (!isWhitelistedPath(method, path)) {
|
|
371
|
+
const result = authenticate(req.headers, deps.authStore);
|
|
372
|
+
if (!result.ok) {
|
|
373
|
+
res.writeHead(401, {
|
|
374
|
+
"Content-Type": "application/json",
|
|
375
|
+
"WWW-Authenticate": "Bearer",
|
|
376
|
+
});
|
|
377
|
+
res.end(JSON.stringify({ error: "Unauthorized", reason: result.reason }));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
principalByRequest.set(req, result.principal);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Share routes — early dispatch so /s/* and /api/v1/shares* claim
|
|
384
|
+
// their URL space before the generic /api/v1 branch. When
|
|
385
|
+
// `shareConfig.enabled === false` handleShareRoutes is a no-op.
|
|
386
|
+
// The auth gate above has already enforced Bearer on owner endpoints
|
|
387
|
+
// (/api/v1/sessions/:id/share*, /api/v1/shares); viewer endpoints
|
|
388
|
+
// (/s/:token, /api/v1/shared/:token/events) must be whitelisted in
|
|
389
|
+
// auth-middleware.ts so they remain public.
|
|
390
|
+
if (deps.shareConfig &&
|
|
391
|
+
(await handleShareRoutes(req, res, {
|
|
392
|
+
store,
|
|
393
|
+
sessions,
|
|
394
|
+
config: deps.shareConfig,
|
|
395
|
+
dataDir: deps.dataDir,
|
|
396
|
+
publicDir: deps.publicDir,
|
|
397
|
+
}))) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
52
400
|
// --- API routes ---
|
|
53
401
|
if (url === "/api/v1" || url.startsWith("/api/v1/")) {
|
|
54
402
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -58,6 +406,7 @@ export function createRequestHandler(deps) {
|
|
|
58
406
|
version: "v1",
|
|
59
407
|
endpoints: {
|
|
60
408
|
sessions: "/api/v1/sessions",
|
|
409
|
+
paths: "/api/v1/recent-paths",
|
|
61
410
|
config: "/api/v1/config",
|
|
62
411
|
events_stream: "/api/v1/events/stream",
|
|
63
412
|
prompt: "/api/beta/prompt",
|
|
@@ -68,7 +417,9 @@ export function createRequestHandler(deps) {
|
|
|
68
417
|
return;
|
|
69
418
|
}
|
|
70
419
|
// GET /api/v1/sessions
|
|
71
|
-
if (url.startsWith("/api/v1/sessions") &&
|
|
420
|
+
if (url.startsWith("/api/v1/sessions") &&
|
|
421
|
+
!url.slice("/api/v1/sessions".length).match(/^\//) &&
|
|
422
|
+
req.method === "GET") {
|
|
72
423
|
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
73
424
|
const source = params.get("source") ?? undefined;
|
|
74
425
|
res.end(JSON.stringify(store.listSessions(source ? { source } : undefined)));
|
|
@@ -79,7 +430,21 @@ export function createRequestHandler(deps) {
|
|
|
79
430
|
json(res, 200, {
|
|
80
431
|
configOptions: sessions?.cachedConfigOptions ?? [],
|
|
81
432
|
cancelTimeout: deps.limits.cancel_timeout ?? 0,
|
|
433
|
+
recentPathsLimit: deps.limits.recent_paths ?? 10,
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
// GET /api/v1/recent-paths
|
|
438
|
+
if (url.startsWith("/api/v1/recent-paths") && req.method === "GET") {
|
|
439
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
440
|
+
const limitParam = params.get("limit");
|
|
441
|
+
const limit = limitParam != null ? Math.max(0, parseInt(limitParam, 10)) : 0;
|
|
442
|
+
const ttlDays = deps.limits.recent_paths_ttl ?? 30;
|
|
443
|
+
const paths = store.listRecentPaths({
|
|
444
|
+
limit: isNaN(limit) ? 0 : limit,
|
|
445
|
+
ttlDays,
|
|
82
446
|
});
|
|
447
|
+
json(res, 200, paths);
|
|
83
448
|
return;
|
|
84
449
|
}
|
|
85
450
|
// GET /api/v1/version
|
|
@@ -90,6 +455,136 @@ export function createRequestHandler(deps) {
|
|
|
90
455
|
});
|
|
91
456
|
return;
|
|
92
457
|
}
|
|
458
|
+
// GET /api/v1/auth/verify — token validation probe
|
|
459
|
+
if (url === "/api/v1/auth/verify" && req.method === "GET") {
|
|
460
|
+
const principal = principalByRequest.get(req);
|
|
461
|
+
if (!principal) {
|
|
462
|
+
json(res, 401, { error: "Unauthorized" });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
json(res, 200, {
|
|
466
|
+
ok: true,
|
|
467
|
+
name: principal.name,
|
|
468
|
+
scope: principal.scope,
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// POST /api/v1/sse-ticket — mint short-lived ticket for EventSource
|
|
473
|
+
if (url === "/api/v1/sse-ticket" && req.method === "POST") {
|
|
474
|
+
const principal = principalByRequest.get(req);
|
|
475
|
+
if (!principal) {
|
|
476
|
+
json(res, 401, { error: "Unauthorized" });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (!deps.ticketStore) {
|
|
480
|
+
json(res, 501, { error: "SSE not available" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const ticket = deps.ticketStore.mint({
|
|
484
|
+
tokenName: principal.name,
|
|
485
|
+
scope: principal.scope,
|
|
486
|
+
});
|
|
487
|
+
json(res, 200, { ticket, expiresIn: 60 });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// --- Token management (admin scope) ---
|
|
491
|
+
// GET /api/v1/tokens — list tokens.
|
|
492
|
+
// admin: full list. api: own token only (self-row, so the slash
|
|
493
|
+
// menu can show the user's own metadata without leaking peers).
|
|
494
|
+
if (url === "/api/v1/tokens" && req.method === "GET") {
|
|
495
|
+
const principal = principalByRequest.get(req);
|
|
496
|
+
if (!deps.authStore || !principal) {
|
|
497
|
+
json(res, 401, { error: "Unauthorized" });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const all = deps.authStore.list();
|
|
501
|
+
const visible = principal.scope === "admin"
|
|
502
|
+
? all
|
|
503
|
+
: all.filter((t) => t.name === principal.name);
|
|
504
|
+
const list = visible.map((t) => ({
|
|
505
|
+
name: t.name,
|
|
506
|
+
scope: t.scope,
|
|
507
|
+
createdAt: t.createdAt,
|
|
508
|
+
lastUsedAt: t.lastUsedAt,
|
|
509
|
+
isSelf: t.name === principal.name,
|
|
510
|
+
}));
|
|
511
|
+
json(res, 200, list);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// POST /api/v1/tokens — create new api-scope token, return raw value once
|
|
515
|
+
if (url === "/api/v1/tokens" && req.method === "POST") {
|
|
516
|
+
const principal = principalByRequest.get(req);
|
|
517
|
+
if (!deps.authStore || !principal) {
|
|
518
|
+
json(res, 401, { error: "Unauthorized" });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (principal.scope !== "admin") {
|
|
522
|
+
json(res, 403, { error: "Forbidden" });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
let body;
|
|
526
|
+
try {
|
|
527
|
+
body = JSON.parse(await readBody(req));
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
534
|
+
try {
|
|
535
|
+
const created = await deps.authStore.addToken(name, "api");
|
|
536
|
+
json(res, 201, {
|
|
537
|
+
token: created.token,
|
|
538
|
+
name: created.record.name,
|
|
539
|
+
scope: created.record.scope,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
const msg = errorMessage(err);
|
|
544
|
+
if (/already exists|duplicate/i.test(msg)) {
|
|
545
|
+
json(res, 409, { error: msg });
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
json(res, 400, { error: msg });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// DELETE /api/v1/tokens/:name — revoke
|
|
554
|
+
const tokenDelMatch = url.match(/^\/api\/v1\/tokens\/([^/?]+)\/?$/);
|
|
555
|
+
if (tokenDelMatch && req.method === "DELETE") {
|
|
556
|
+
const principal = principalByRequest.get(req);
|
|
557
|
+
if (!deps.authStore || !principal) {
|
|
558
|
+
json(res, 401, { error: "Unauthorized" });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (principal.scope !== "admin") {
|
|
562
|
+
json(res, 403, { error: "Forbidden" });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const name = decodeURIComponent(tokenDelMatch[1]);
|
|
566
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
|
|
567
|
+
json(res, 400, { error: "Invalid token name" });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (name === principal.name) {
|
|
571
|
+
json(res, 400, { error: "Cannot revoke the token you are using" });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
const ok = await deps.authStore.revokeToken(name);
|
|
576
|
+
if (!ok) {
|
|
577
|
+
json(res, 404, { error: "Token not found" });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
res.writeHead(204);
|
|
581
|
+
res.end();
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
json(res, 400, { error: errorMessage(err) });
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
93
588
|
// --- POST /api/v1/bridge/reload ---
|
|
94
589
|
if (url === "/api/v1/bridge/reload" && req.method === "POST") {
|
|
95
590
|
const bridge = getBridge?.();
|
|
@@ -124,6 +619,9 @@ export function createRequestHandler(deps) {
|
|
|
124
619
|
if (permActionMatch && req.method === "POST") {
|
|
125
620
|
const sessionId = decodeURIComponent(permActionMatch[1]);
|
|
126
621
|
const requestId = decodeURIComponent(permActionMatch[2]);
|
|
622
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
|
|
623
|
+
if (replayed)
|
|
624
|
+
return;
|
|
127
625
|
const perm = sessions?.pendingPermissions.get(requestId);
|
|
128
626
|
if (!perm) {
|
|
129
627
|
json(res, 404, { error: "Permission not found" });
|
|
@@ -150,25 +648,34 @@ export function createRequestHandler(deps) {
|
|
|
150
648
|
json(res, 400, { error: "Provide optionId or denied:true" });
|
|
151
649
|
return;
|
|
152
650
|
}
|
|
153
|
-
const denied =
|
|
651
|
+
const denied = Boolean(body.denied);
|
|
154
652
|
const optionId = body.optionId ?? "deny";
|
|
155
|
-
const optionName = perm.options.find(o => o.optionId === optionId)?.label ?? optionId;
|
|
653
|
+
const optionName = perm.options.find((o) => o.optionId === optionId)?.label ?? optionId;
|
|
156
654
|
if (denied) {
|
|
157
|
-
|
|
655
|
+
bridge.denyPermission(requestId);
|
|
158
656
|
}
|
|
159
657
|
else {
|
|
160
|
-
|
|
658
|
+
bridge.resolvePermission(requestId, optionId);
|
|
161
659
|
}
|
|
162
660
|
sessions.pendingPermissions.delete(requestId);
|
|
661
|
+
sessions.syncPendingPermissions(sessionId);
|
|
163
662
|
// Store event and broadcast (same type so SSE drops are recoverable via sync)
|
|
164
663
|
const permEventData = { requestId, optionName, denied };
|
|
165
|
-
store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId });
|
|
664
|
+
store.saveEvent(perm.sessionId, "permission_response", { ...permEventData, optionId }, { from_ref: "user" });
|
|
166
665
|
sseManager.broadcast({
|
|
167
666
|
type: "permission_response",
|
|
168
667
|
sessionId: perm.sessionId,
|
|
169
668
|
...permEventData,
|
|
170
669
|
});
|
|
171
|
-
|
|
670
|
+
// Cross-device banner recall: close the permission banner on
|
|
671
|
+
// every subscribed endpoint now that the permission has been
|
|
672
|
+
// handled by this client.
|
|
673
|
+
if (deps.pushService) {
|
|
674
|
+
void deps.pushService.sendClose(`sess-${perm.sessionId}-perm-${requestId}`);
|
|
675
|
+
}
|
|
676
|
+
const okBody = { ok: true };
|
|
677
|
+
saveClientOpResult(store, opId, sessionId, 200, okBody);
|
|
678
|
+
json(res, 200, okBody);
|
|
172
679
|
return;
|
|
173
680
|
}
|
|
174
681
|
// --- POST /api/v1/sessions/:id/cancel ---
|
|
@@ -185,6 +692,9 @@ export function createRequestHandler(deps) {
|
|
|
185
692
|
json(res, 503, { error: "Agent not ready yet" });
|
|
186
693
|
return;
|
|
187
694
|
}
|
|
695
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
|
|
696
|
+
if (replayed)
|
|
697
|
+
return;
|
|
188
698
|
// Kill running bash process if any
|
|
189
699
|
const proc = sessions?.runningBashProcs.get(sessionId);
|
|
190
700
|
if (proc) {
|
|
@@ -196,7 +706,16 @@ export function createRequestHandler(deps) {
|
|
|
196
706
|
await bridge.cancel(sessionId);
|
|
197
707
|
sessions.activePrompts.delete(sessionId);
|
|
198
708
|
}
|
|
199
|
-
|
|
709
|
+
// Arm backend safety net: if prompt_done doesn't arrive within the
|
|
710
|
+
// configured timeout, force-clear busy so the UI unstalls. Replaces
|
|
711
|
+
// the old frontend-side cancel timer.
|
|
712
|
+
const cancelTimeout = deps.limits.cancel_timeout ?? 0;
|
|
713
|
+
if (sessions && cancelTimeout > 0)
|
|
714
|
+
sessions.state.armCancelSafety(sessionId, cancelTimeout);
|
|
715
|
+
sessions?.syncBusy(sessionId);
|
|
716
|
+
const okBody = { ok: true };
|
|
717
|
+
saveClientOpResult(store, opId, sessionId, 200, okBody);
|
|
718
|
+
json(res, 200, okBody);
|
|
200
719
|
return;
|
|
201
720
|
}
|
|
202
721
|
// --- GET /api/v1/sessions/:id/status ---
|
|
@@ -217,6 +736,45 @@ export function createRequestHandler(deps) {
|
|
|
217
736
|
});
|
|
218
737
|
return;
|
|
219
738
|
}
|
|
739
|
+
// --- GET /api/v1/sessions/:id/snapshot ---
|
|
740
|
+
// client-server-split M1: single source of truth for "what state is
|
|
741
|
+
// this session in right now". Frontend calls this on connect / reconnect
|
|
742
|
+
// / after long backgrounding, then applies incremental `state_patch`
|
|
743
|
+
// SSE events.
|
|
744
|
+
const snapshotMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/snapshot\/?$/);
|
|
745
|
+
if (snapshotMatch && req.method === "GET") {
|
|
746
|
+
const sessionId = decodeURIComponent(snapshotMatch[1]);
|
|
747
|
+
const session = store.getSession(sessionId);
|
|
748
|
+
if (!session) {
|
|
749
|
+
json(res, 404, { error: "Session not found" });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (!sessions) {
|
|
753
|
+
json(res, 503, { error: "Session manager not available" });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
// Make sure runtime reflects the current activePrompts/bash state even
|
|
757
|
+
// if no patch has been emitted yet for this session.
|
|
758
|
+
sessions.syncBusy(sessionId);
|
|
759
|
+
sessions.syncPendingPermissions(sessionId);
|
|
760
|
+
const runtimeState = sessions.state.getState(sessionId);
|
|
761
|
+
const lastEventSeq = store.getLastEventSeq(sessionId);
|
|
762
|
+
json(res, 200, {
|
|
763
|
+
version: 1,
|
|
764
|
+
seq: runtimeState.seq,
|
|
765
|
+
session: {
|
|
766
|
+
id: session.id,
|
|
767
|
+
title: session.title,
|
|
768
|
+
cwd: session.cwd,
|
|
769
|
+
model: session.model,
|
|
770
|
+
mode: session.mode,
|
|
771
|
+
createdAt: session.created_at,
|
|
772
|
+
lastEventSeq,
|
|
773
|
+
},
|
|
774
|
+
runtime: runtimeState.runtime,
|
|
775
|
+
}, req);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
220
778
|
// --- POST /api/v1/sessions/:id/prompt ---
|
|
221
779
|
const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
|
|
222
780
|
if (promptMatch && req.method === "POST") {
|
|
@@ -235,12 +793,17 @@ export function createRequestHandler(deps) {
|
|
|
235
793
|
json(res, 503, { error: "Session manager not available" });
|
|
236
794
|
return;
|
|
237
795
|
}
|
|
796
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, sessionId);
|
|
797
|
+
if (replayed)
|
|
798
|
+
return;
|
|
238
799
|
// Ensure session is live in ACP before prompting (awaits in-flight resume)
|
|
239
800
|
try {
|
|
240
801
|
await sessions.ensureResumed(bridge, sessionId);
|
|
241
802
|
}
|
|
242
803
|
catch (err) {
|
|
243
|
-
json(res, 500, {
|
|
804
|
+
json(res, 500, {
|
|
805
|
+
error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`,
|
|
806
|
+
});
|
|
244
807
|
return;
|
|
245
808
|
}
|
|
246
809
|
// Check if session is busy
|
|
@@ -261,27 +824,101 @@ export function createRequestHandler(deps) {
|
|
|
261
824
|
json(res, 400, { error: "Missing required field: text" });
|
|
262
825
|
return;
|
|
263
826
|
}
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
827
|
+
// Validate attachment shape: client must NEVER supply uri/data/path,
|
|
828
|
+
// only the four canonical fields. Anything else → 400 immediately so
|
|
829
|
+
// we don't even hand it to the dispatcher fallback.
|
|
830
|
+
const attachments = body.attachments;
|
|
831
|
+
if (attachments) {
|
|
832
|
+
if (!Array.isArray(attachments)) {
|
|
833
|
+
json(res, 400, { error: "attachments must be an array" });
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
for (const raw of attachments) {
|
|
837
|
+
const att = raw;
|
|
838
|
+
if (!att ||
|
|
839
|
+
typeof att !== "object" ||
|
|
840
|
+
(att.kind !== "image" && att.kind !== "file") ||
|
|
841
|
+
typeof att.attachmentId !== "string" ||
|
|
842
|
+
typeof att.displayName !== "string" ||
|
|
843
|
+
typeof att.mimeType !== "string") {
|
|
844
|
+
json(res, 400, { error: "Invalid attachment entry" });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (typeof att.uri === "string" ||
|
|
848
|
+
typeof att.data === "string" ||
|
|
849
|
+
typeof att.path === "string") {
|
|
850
|
+
json(res, 400, {
|
|
851
|
+
error: "Client must not supply uri/data/path",
|
|
852
|
+
});
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Stored shape mirrors the wire shape PLUS a server-derived `path`
|
|
858
|
+
// for renderers. The path is the unsigned base URL
|
|
859
|
+
// (`/api/v1/sessions/<sid>/attachments/<filename>`); reSign on
|
|
860
|
+
// egress (history GET + SSE broadcast) appends a fresh `?sig=&exp=`.
|
|
861
|
+
// Renderers use it to mount `<img>` (kind=image) or `<a>` (kind=file).
|
|
862
|
+
// Refs whose attachment row is missing are dropped (defense — the
|
|
863
|
+
// dispatcher's [attachment removed] fallback covers that case).
|
|
864
|
+
const storedAttachments = attachments?.flatMap((a) => {
|
|
865
|
+
const row = store.getAttachment(sessionId, a.attachmentId);
|
|
866
|
+
if (!row)
|
|
867
|
+
return [];
|
|
868
|
+
const fileName = basename(row.realpath);
|
|
869
|
+
return [
|
|
870
|
+
{
|
|
871
|
+
kind: a.kind,
|
|
872
|
+
attachmentId: a.attachmentId,
|
|
873
|
+
displayName: a.displayName,
|
|
874
|
+
mimeType: a.mimeType,
|
|
875
|
+
path: `/api/v1/sessions/${sessionId}/attachments/${fileName}`,
|
|
876
|
+
},
|
|
877
|
+
];
|
|
878
|
+
});
|
|
879
|
+
store.saveEvent(sessionId, "user_message", {
|
|
880
|
+
text: body.text,
|
|
881
|
+
...(storedAttachments?.length
|
|
882
|
+
? { attachments: storedAttachments }
|
|
883
|
+
: {}),
|
|
884
|
+
}, { from_ref: "user" });
|
|
267
885
|
store.updateSessionLastActive(sessionId);
|
|
268
|
-
|
|
886
|
+
store.touchRecentPath(session.cwd);
|
|
887
|
+
const userMsgEvent = {
|
|
888
|
+
type: "user_message",
|
|
889
|
+
sessionId,
|
|
890
|
+
text: body.text,
|
|
891
|
+
attachments: storedAttachments,
|
|
892
|
+
};
|
|
269
893
|
sseManager.broadcast(userMsgEvent);
|
|
270
894
|
// Generate title (fire-and-forget)
|
|
271
|
-
if (titleService &&
|
|
895
|
+
if (titleService &&
|
|
896
|
+
sessions && // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- optional dep
|
|
897
|
+
!sessions.sessionHasTitle.has(sessionId)) {
|
|
272
898
|
titleService.generate(bridge, body.text, sessionId, (title) => {
|
|
273
|
-
const titleEvent = {
|
|
899
|
+
const titleEvent = {
|
|
900
|
+
type: "session_title_updated",
|
|
901
|
+
sessionId,
|
|
902
|
+
title,
|
|
903
|
+
};
|
|
274
904
|
sseManager.broadcast(titleEvent);
|
|
275
905
|
});
|
|
276
906
|
}
|
|
277
907
|
// Fire prompt asynchronously (don't await — response is 202)
|
|
278
908
|
sessions.activePrompts.add(sessionId);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
909
|
+
sessions.syncBusy(sessionId);
|
|
910
|
+
bridge
|
|
911
|
+
.prompt(sessionId, body.text, attachments)
|
|
912
|
+
.catch((err) => {
|
|
913
|
+
plog.error("error", { sessionId, error: err });
|
|
914
|
+
})
|
|
915
|
+
.finally(() => {
|
|
282
916
|
sessions.activePrompts.delete(sessionId);
|
|
917
|
+
sessions.syncBusy(sessionId);
|
|
283
918
|
});
|
|
284
|
-
|
|
919
|
+
const acceptedBody = { status: "accepted" };
|
|
920
|
+
saveClientOpResult(store, opId, sessionId, 202, acceptedBody);
|
|
921
|
+
json(res, 202, acceptedBody);
|
|
285
922
|
return;
|
|
286
923
|
}
|
|
287
924
|
// --- POST /api/v1/sessions/:id/bash ---
|
|
@@ -298,7 +935,9 @@ export function createRequestHandler(deps) {
|
|
|
298
935
|
return;
|
|
299
936
|
}
|
|
300
937
|
if (sessions.runningBashProcs.has(sessionId)) {
|
|
301
|
-
json(res, 409, {
|
|
938
|
+
json(res, 409, {
|
|
939
|
+
error: "A bash command is already running in this session",
|
|
940
|
+
});
|
|
302
941
|
return;
|
|
303
942
|
}
|
|
304
943
|
let body;
|
|
@@ -314,11 +953,19 @@ export function createRequestHandler(deps) {
|
|
|
314
953
|
return;
|
|
315
954
|
}
|
|
316
955
|
const cwd = sessions.getSessionCwd(sessionId);
|
|
317
|
-
store.saveEvent(sessionId, "bash_command", { command: body.command });
|
|
318
|
-
const bashCmdEvent = {
|
|
956
|
+
store.saveEvent(sessionId, "bash_command", { command: body.command }, { from_ref: "user" });
|
|
957
|
+
const bashCmdEvent = {
|
|
958
|
+
type: "bash_command",
|
|
959
|
+
sessionId,
|
|
960
|
+
command: body.command,
|
|
961
|
+
};
|
|
319
962
|
sseManager.broadcast(bashCmdEvent);
|
|
320
|
-
const shell = IS_WIN
|
|
321
|
-
|
|
963
|
+
const shell = IS_WIN
|
|
964
|
+
? (process.env.COMSPEC ?? "cmd.exe")
|
|
965
|
+
: (process.env.SHELL ?? "bash");
|
|
966
|
+
const shellArgs = IS_WIN
|
|
967
|
+
? ["/s", "/c", body.command]
|
|
968
|
+
: ["-c", body.command];
|
|
322
969
|
const child = spawn(shell, shellArgs, {
|
|
323
970
|
cwd,
|
|
324
971
|
detached: !IS_WIN,
|
|
@@ -326,6 +973,7 @@ export function createRequestHandler(deps) {
|
|
|
326
973
|
stdio: ["ignore", "pipe", "pipe"],
|
|
327
974
|
});
|
|
328
975
|
sessions.runningBashProcs.set(sessionId, child);
|
|
976
|
+
sessions.syncBusy(sessionId);
|
|
329
977
|
let output = "";
|
|
330
978
|
let outputTruncated = false;
|
|
331
979
|
const limit = deps.limits.bash_output;
|
|
@@ -341,23 +989,41 @@ export function createRequestHandler(deps) {
|
|
|
341
989
|
else {
|
|
342
990
|
output = (output + text).slice(-limit);
|
|
343
991
|
}
|
|
344
|
-
const bashOutEvent = {
|
|
992
|
+
const bashOutEvent = {
|
|
993
|
+
type: "bash_output",
|
|
994
|
+
sessionId,
|
|
995
|
+
text,
|
|
996
|
+
stream,
|
|
997
|
+
};
|
|
345
998
|
sseManager.broadcast(bashOutEvent);
|
|
346
999
|
};
|
|
347
1000
|
child.stdout.on("data", onData("stdout"));
|
|
348
1001
|
child.stderr.on("data", onData("stderr"));
|
|
349
1002
|
child.on("close", (code, signal) => {
|
|
350
1003
|
sessions.runningBashProcs.delete(sessionId);
|
|
1004
|
+
sessions.syncBusy(sessionId);
|
|
351
1005
|
const stored = outputTruncated ? "[truncated]\n" + output : output;
|
|
352
|
-
store.saveEvent(sessionId, "bash_result", { output: stored, code, signal });
|
|
353
|
-
const bashDoneEvent = {
|
|
1006
|
+
store.saveEvent(sessionId, "bash_result", { output: stored, code, signal }, { from_ref: "system" });
|
|
1007
|
+
const bashDoneEvent = {
|
|
1008
|
+
type: "bash_done",
|
|
1009
|
+
sessionId,
|
|
1010
|
+
code,
|
|
1011
|
+
signal,
|
|
1012
|
+
};
|
|
354
1013
|
sseManager.broadcast(bashDoneEvent);
|
|
355
1014
|
});
|
|
356
1015
|
child.on("error", (err) => {
|
|
357
1016
|
sessions.runningBashProcs.delete(sessionId);
|
|
1017
|
+
sessions.syncBusy(sessionId);
|
|
358
1018
|
const errMsg = errorMessage(err);
|
|
359
|
-
store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
|
|
360
|
-
const bashErrEvent = {
|
|
1019
|
+
store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null }, { from_ref: "system" });
|
|
1020
|
+
const bashErrEvent = {
|
|
1021
|
+
type: "bash_done",
|
|
1022
|
+
sessionId,
|
|
1023
|
+
code: -1,
|
|
1024
|
+
signal: null,
|
|
1025
|
+
error: errMsg,
|
|
1026
|
+
};
|
|
361
1027
|
sseManager.broadcast(bashErrEvent);
|
|
362
1028
|
});
|
|
363
1029
|
json(res, 202, { status: "accepted" });
|
|
@@ -409,12 +1075,23 @@ export function createRequestHandler(deps) {
|
|
|
409
1075
|
for (const opt of configOptions) {
|
|
410
1076
|
store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
|
|
411
1077
|
}
|
|
412
|
-
sseManager.broadcast({
|
|
413
|
-
|
|
1078
|
+
sseManager.broadcast({
|
|
1079
|
+
type: "config_option_update",
|
|
1080
|
+
sessionId,
|
|
1081
|
+
configOptions,
|
|
1082
|
+
});
|
|
1083
|
+
sseManager.broadcast({
|
|
1084
|
+
type: "config_set",
|
|
1085
|
+
sessionId,
|
|
1086
|
+
configId,
|
|
1087
|
+
value: body.value,
|
|
1088
|
+
});
|
|
414
1089
|
json(res, 200, { configOptions });
|
|
415
1090
|
}
|
|
416
1091
|
catch (err) {
|
|
417
|
-
json(res, 500, {
|
|
1092
|
+
json(res, 500, {
|
|
1093
|
+
error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1094
|
+
});
|
|
418
1095
|
}
|
|
419
1096
|
return;
|
|
420
1097
|
}
|
|
@@ -444,8 +1121,12 @@ export function createRequestHandler(deps) {
|
|
|
444
1121
|
sessions.sessionHasTitle.add(sessionId);
|
|
445
1122
|
const bridge = getBridge?.();
|
|
446
1123
|
if (titleService && bridge)
|
|
447
|
-
titleService.cancel(sessionId, bridge);
|
|
448
|
-
const titleEvent = {
|
|
1124
|
+
void titleService.cancel(sessionId, bridge);
|
|
1125
|
+
const titleEvent = {
|
|
1126
|
+
type: "session_title_updated",
|
|
1127
|
+
sessionId,
|
|
1128
|
+
title: body.value,
|
|
1129
|
+
};
|
|
449
1130
|
sseManager.broadcast(titleEvent);
|
|
450
1131
|
json(res, 200, { title: body.value });
|
|
451
1132
|
return;
|
|
@@ -463,53 +1144,112 @@ export function createRequestHandler(deps) {
|
|
|
463
1144
|
json(res, 404, { error: "Session not found" });
|
|
464
1145
|
return;
|
|
465
1146
|
}
|
|
466
|
-
// Start resume in background (non-blocking) so the client gets metadata fast
|
|
1147
|
+
// Start resume in background (non-blocking) so the client gets metadata fast.
|
|
1148
|
+
// BUT: if cache is cold (cold-restart case), block on resume up to 8s so
|
|
1149
|
+
// configOptions returns inline. This eliminates the race where the
|
|
1150
|
+
// post-resume `config_option_update` broadcast can arrive before the
|
|
1151
|
+
// client's SSE is fully wired up — a flake observed on slow CI runners.
|
|
467
1152
|
const wasLive = sessions?.liveSessions.has(sessionId) ?? true;
|
|
1153
|
+
let resumePromise = null;
|
|
468
1154
|
if (sessions && getBridge && !wasLive) {
|
|
469
1155
|
const bridge = getBridge();
|
|
470
1156
|
if (bridge) {
|
|
471
|
-
|
|
1157
|
+
resumePromise = sessions.ensureResumed(bridge, sessionId);
|
|
1158
|
+
// Broadcast config_option_update too, so any OTHER client viewing
|
|
1159
|
+
// the same session (whose own GET may have raced and returned cold)
|
|
1160
|
+
// also picks up the warm values.
|
|
1161
|
+
resumePromise
|
|
1162
|
+
.then(() => {
|
|
1163
|
+
const cur = store.getSession(sessionId);
|
|
1164
|
+
if (!cur || !sessions.cachedConfigOptions.length)
|
|
1165
|
+
return;
|
|
1166
|
+
const opts = sessions.cachedConfigOptions.map((opt) => {
|
|
1167
|
+
const stored = {
|
|
1168
|
+
model: cur.model,
|
|
1169
|
+
mode: cur.mode,
|
|
1170
|
+
reasoning_effort: cur.reasoning_effort,
|
|
1171
|
+
};
|
|
1172
|
+
const override = stored[opt.id];
|
|
1173
|
+
return override ? { ...opt, currentValue: override } : opt;
|
|
1174
|
+
});
|
|
1175
|
+
sseManager.broadcast({
|
|
1176
|
+
type: "config_option_update",
|
|
1177
|
+
sessionId,
|
|
1178
|
+
configOptions: opts,
|
|
1179
|
+
});
|
|
1180
|
+
})
|
|
1181
|
+
.catch(() => { });
|
|
472
1182
|
// Auto-retry if the last turn was interrupted (must wait for resume)
|
|
473
1183
|
const hasInterrupted = store.hasInterruptedTurn(sessionId);
|
|
474
1184
|
if (hasInterrupted) {
|
|
475
1185
|
// Optimistically mark busy so concurrent POST sees the session as active
|
|
476
1186
|
sessions.activePrompts.add(sessionId);
|
|
477
|
-
|
|
1187
|
+
sessions.syncBusy(sessionId);
|
|
1188
|
+
void resumePromise
|
|
1189
|
+
.then(() => {
|
|
478
1190
|
if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
|
|
479
1191
|
// Retry not needed after all — release the optimistic lock
|
|
480
1192
|
sessions.activePrompts.delete(sessionId);
|
|
1193
|
+
sessions.syncBusy(sessionId);
|
|
481
1194
|
}
|
|
482
|
-
})
|
|
1195
|
+
})
|
|
1196
|
+
.catch(() => {
|
|
483
1197
|
sessions.activePrompts.delete(sessionId);
|
|
1198
|
+
sessions.syncBusy(sessionId);
|
|
484
1199
|
});
|
|
485
1200
|
}
|
|
486
1201
|
else {
|
|
487
1202
|
resumePromise.catch((err) => {
|
|
488
|
-
|
|
1203
|
+
slog.error("background resume failed", {
|
|
1204
|
+
sessionId: sessionId.slice(0, 8) + "…",
|
|
1205
|
+
error: err,
|
|
1206
|
+
});
|
|
489
1207
|
});
|
|
490
1208
|
}
|
|
491
1209
|
}
|
|
492
1210
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1211
|
+
// If cache is cold and we kicked off a resume, wait briefly so the
|
|
1212
|
+
// GET response includes warm configOptions. Bounded to 8s — past that
|
|
1213
|
+
// we fall back to returning empty configOptions and rely on the
|
|
1214
|
+
// broadcast above. The frontend retries (re-fetches) on broadcast.
|
|
1215
|
+
if (resumePromise && !sessions?.cachedConfigOptions.length) {
|
|
1216
|
+
let timer = null;
|
|
1217
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
1218
|
+
timer = setTimeout(resolve, 8000);
|
|
499
1219
|
});
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
1220
|
+
await Promise.race([
|
|
1221
|
+
resumePromise.finally(() => {
|
|
1222
|
+
if (timer)
|
|
1223
|
+
clearTimeout(timer);
|
|
1224
|
+
}),
|
|
1225
|
+
timeoutPromise,
|
|
1226
|
+
]).catch(() => { });
|
|
1227
|
+
}
|
|
1228
|
+
// Re-read session in case resume mutated stored config
|
|
1229
|
+
const freshSession = store.getSession(sessionId) ?? session;
|
|
1230
|
+
const configOptions = sessions
|
|
1231
|
+
? (() => {
|
|
1232
|
+
// Build configOptions from cached + stored overrides
|
|
1233
|
+
const opts = sessions.cachedConfigOptions.map((opt) => {
|
|
1234
|
+
const stored = {
|
|
1235
|
+
model: freshSession.model,
|
|
1236
|
+
mode: freshSession.mode,
|
|
1237
|
+
reasoning_effort: freshSession.reasoning_effort,
|
|
1238
|
+
};
|
|
1239
|
+
const override = stored[opt.id];
|
|
1240
|
+
return override ? { ...opt, currentValue: override } : opt;
|
|
1241
|
+
});
|
|
1242
|
+
return opts;
|
|
1243
|
+
})()
|
|
1244
|
+
: [];
|
|
503
1245
|
json(res, 200, {
|
|
504
|
-
id:
|
|
505
|
-
cwd:
|
|
506
|
-
title:
|
|
507
|
-
source:
|
|
508
|
-
model:
|
|
509
|
-
mode:
|
|
1246
|
+
id: freshSession.id,
|
|
1247
|
+
cwd: freshSession.cwd,
|
|
1248
|
+
title: freshSession.title,
|
|
1249
|
+
source: freshSession.source,
|
|
1250
|
+
model: freshSession.model,
|
|
1251
|
+
mode: freshSession.mode,
|
|
510
1252
|
configOptions,
|
|
511
|
-
busy: busyKind != null,
|
|
512
|
-
busyKind,
|
|
513
1253
|
}, req);
|
|
514
1254
|
return;
|
|
515
1255
|
}
|
|
@@ -566,7 +1306,11 @@ export function createRequestHandler(deps) {
|
|
|
566
1306
|
// ACP's session_created event fires before inheritance runs, so
|
|
567
1307
|
// broadcast final configOptions so SSE clients get the inherited values.
|
|
568
1308
|
if (configOptions.length) {
|
|
569
|
-
sseManager.broadcast({
|
|
1309
|
+
sseManager.broadcast({
|
|
1310
|
+
type: "config_option_update",
|
|
1311
|
+
sessionId,
|
|
1312
|
+
configOptions,
|
|
1313
|
+
});
|
|
570
1314
|
}
|
|
571
1315
|
json(res, 201, {
|
|
572
1316
|
id: sessionId,
|
|
@@ -591,14 +1335,17 @@ export function createRequestHandler(deps) {
|
|
|
591
1335
|
const eventsMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events(\?.*)?$/);
|
|
592
1336
|
if (eventsMatch && req.method === "GET") {
|
|
593
1337
|
const sessionId = decodeURIComponent(eventsMatch[1]);
|
|
594
|
-
const
|
|
1338
|
+
const queryPart = eventsMatch[2];
|
|
1339
|
+
const params = new URLSearchParams(queryPart ? queryPart.slice(1) : "");
|
|
595
1340
|
const excludeThinking = params.get("thinking") === "0";
|
|
596
1341
|
const afterRaw = params.get("after");
|
|
597
1342
|
const afterSeq = afterRaw != null ? Number(afterRaw) : undefined;
|
|
598
1343
|
const beforeRaw = params.get("before");
|
|
599
1344
|
const beforeSeq = beforeRaw != null ? Number(beforeRaw) : undefined;
|
|
600
1345
|
const limitRaw = params.get("limit");
|
|
601
|
-
const limit = limitRaw != null
|
|
1346
|
+
const limit = limitRaw != null
|
|
1347
|
+
? Math.max(1, Math.min(10000, Number(limitRaw)))
|
|
1348
|
+
: undefined;
|
|
602
1349
|
const session = store.getSession(sessionId);
|
|
603
1350
|
if (!session) {
|
|
604
1351
|
json(res, 404, { error: "Session not found" });
|
|
@@ -619,15 +1366,45 @@ export function createRequestHandler(deps) {
|
|
|
619
1366
|
sessions.flushAssistantBuffer(sessionId);
|
|
620
1367
|
}
|
|
621
1368
|
}
|
|
622
|
-
const events = store.getEvents(sessionId, {
|
|
1369
|
+
const events = store.getEvents(sessionId, {
|
|
1370
|
+
excludeThinking,
|
|
1371
|
+
afterSeq,
|
|
1372
|
+
beforeSeq,
|
|
1373
|
+
limit,
|
|
1374
|
+
});
|
|
1375
|
+
// Replace internal uuid attachment paths with `<name> [#<id4>]`
|
|
1376
|
+
// labels at egress (CLAUDE.md "Attachment label egress
|
|
1377
|
+
// rewrite"). DB rows still hold raw paths.
|
|
1378
|
+
if (sessions) {
|
|
1379
|
+
enrichStoredEventsForDisplay(events, sessions.getLabelMap(sessionId));
|
|
1380
|
+
}
|
|
1381
|
+
// Re-sign image URLs at egress so 1h-old stored URLs become valid
|
|
1382
|
+
// again — the user can reload history days later and images still
|
|
1383
|
+
// resolve. Mutates `data` in-place; safe because store.getEvents
|
|
1384
|
+
// returns fresh records.
|
|
1385
|
+
if (deps.attachmentSecret) {
|
|
1386
|
+
for (const ev of events) {
|
|
1387
|
+
if (typeof ev.data === "string" &&
|
|
1388
|
+
ev.data.includes("/attachments/")) {
|
|
1389
|
+
ev.data = reSignAttachmentUrlsInJson(ev.data, deps.attachmentSecret);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
623
1393
|
const envelope = {
|
|
624
1394
|
events,
|
|
625
|
-
streaming: {
|
|
1395
|
+
streaming: {
|
|
1396
|
+
thinking: streamingThinking,
|
|
1397
|
+
assistant: streamingAssistant,
|
|
1398
|
+
},
|
|
626
1399
|
};
|
|
627
1400
|
if (limit != null) {
|
|
628
1401
|
const total = store.getEventCount(sessionId, { excludeThinking });
|
|
629
1402
|
const hasMore = events.length > 0
|
|
630
|
-
?
|
|
1403
|
+
? store.getEvents(sessionId, {
|
|
1404
|
+
excludeThinking,
|
|
1405
|
+
beforeSeq: events[0].seq,
|
|
1406
|
+
limit: 1,
|
|
1407
|
+
}).length > 0
|
|
631
1408
|
: false;
|
|
632
1409
|
envelope.total = total;
|
|
633
1410
|
envelope.hasMore = hasMore;
|
|
@@ -638,32 +1415,54 @@ export function createRequestHandler(deps) {
|
|
|
638
1415
|
// --- SSE stream endpoints ---
|
|
639
1416
|
// GET /api/v1/events/stream — global SSE stream
|
|
640
1417
|
if (url.startsWith("/api/v1/events/stream") && req.method === "GET") {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
1418
|
+
// Ticket-based auth (EventSource cannot send Authorization header).
|
|
1419
|
+
// When authStore is configured, every SSE connection MUST present a
|
|
1420
|
+
// valid single-use ticket from POST /api/v1/sse-ticket.
|
|
1421
|
+
let tokenName;
|
|
1422
|
+
if (deps.authStore) {
|
|
1423
|
+
const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
|
|
1424
|
+
const principal = deps.ticketStore?.consume(ticket);
|
|
1425
|
+
if (!principal) {
|
|
1426
|
+
json(res, 401, { error: "Invalid or expired ticket" });
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
tokenName = principal.tokenName;
|
|
644
1430
|
}
|
|
645
|
-
const sseManager = deps.sseManager;
|
|
646
1431
|
const clientId = sseManager.generateClientId();
|
|
647
1432
|
res.writeHead(200, {
|
|
648
1433
|
"Content-Type": "text/event-stream",
|
|
649
1434
|
"Cache-Control": "no-cache",
|
|
650
|
-
|
|
1435
|
+
Connection: "keep-alive",
|
|
651
1436
|
});
|
|
652
|
-
const client = {
|
|
1437
|
+
const client = {
|
|
1438
|
+
id: clientId,
|
|
1439
|
+
res,
|
|
1440
|
+
tokenName,
|
|
1441
|
+
};
|
|
653
1442
|
sseManager.add(client);
|
|
654
1443
|
// Send connected event
|
|
655
|
-
sseManager.sendEvent(client, {
|
|
1444
|
+
sseManager.sendEvent(client, {
|
|
1445
|
+
type: "connected",
|
|
1446
|
+
clientId,
|
|
1447
|
+
debugLevel: deps.debugLevel ?? "off",
|
|
1448
|
+
});
|
|
1449
|
+
sseManager.writeHeartbeat(client);
|
|
656
1450
|
return;
|
|
657
1451
|
}
|
|
658
1452
|
// GET /api/v1/sessions/:id/events/stream — per-session SSE stream
|
|
659
1453
|
const sseSessionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events\/stream(\?.*)?$/);
|
|
660
1454
|
if (sseSessionMatch && req.method === "GET") {
|
|
661
|
-
if (!deps.sseManager) {
|
|
662
|
-
json(res, 501, { error: "SSE not available" });
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
const sseManager = deps.sseManager;
|
|
666
1455
|
const sessionId = decodeURIComponent(sseSessionMatch[1]);
|
|
1456
|
+
let tokenName;
|
|
1457
|
+
if (deps.authStore) {
|
|
1458
|
+
const ticket = new URLSearchParams(url.split("?")[1] ?? "").get("ticket") ?? "";
|
|
1459
|
+
const principal = deps.ticketStore?.consume(ticket);
|
|
1460
|
+
if (!principal) {
|
|
1461
|
+
json(res, 401, { error: "Invalid or expired ticket" });
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
tokenName = principal.tokenName;
|
|
1465
|
+
}
|
|
667
1466
|
const session = store.getSession(sessionId);
|
|
668
1467
|
if (!session) {
|
|
669
1468
|
json(res, 404, { error: "Session not found" });
|
|
@@ -673,12 +1472,22 @@ export function createRequestHandler(deps) {
|
|
|
673
1472
|
res.writeHead(200, {
|
|
674
1473
|
"Content-Type": "text/event-stream",
|
|
675
1474
|
"Cache-Control": "no-cache",
|
|
676
|
-
|
|
1475
|
+
Connection: "keep-alive",
|
|
677
1476
|
});
|
|
678
|
-
const client = {
|
|
1477
|
+
const client = {
|
|
1478
|
+
id: clientId,
|
|
1479
|
+
res,
|
|
1480
|
+
sessionId,
|
|
1481
|
+
tokenName,
|
|
1482
|
+
};
|
|
679
1483
|
sseManager.add(client);
|
|
680
1484
|
// Send connected event
|
|
681
|
-
sseManager.sendEvent(client, {
|
|
1485
|
+
sseManager.sendEvent(client, {
|
|
1486
|
+
type: "connected",
|
|
1487
|
+
clientId,
|
|
1488
|
+
debugLevel: deps.debugLevel ?? "off",
|
|
1489
|
+
});
|
|
1490
|
+
sseManager.writeHeartbeat(client);
|
|
682
1491
|
// Replay events from Last-Event-ID if provided
|
|
683
1492
|
const lastEventId = req.headers["last-event-id"];
|
|
684
1493
|
if (lastEventId) {
|
|
@@ -687,7 +1496,10 @@ export function createRequestHandler(deps) {
|
|
|
687
1496
|
const events = store.getEvents(sessionId, { afterSeq });
|
|
688
1497
|
for (const evt of events) {
|
|
689
1498
|
try {
|
|
690
|
-
sseManager.sendEvent(client, {
|
|
1499
|
+
sseManager.sendEvent(client, {
|
|
1500
|
+
type: evt.type,
|
|
1501
|
+
...JSON.parse(evt.data),
|
|
1502
|
+
}, evt.seq);
|
|
691
1503
|
}
|
|
692
1504
|
catch {
|
|
693
1505
|
// Skip malformed event data
|
|
@@ -697,69 +1509,75 @@ export function createRequestHandler(deps) {
|
|
|
697
1509
|
}
|
|
698
1510
|
return;
|
|
699
1511
|
}
|
|
700
|
-
// ---
|
|
701
|
-
// POST /api/v1/sessions/:id/
|
|
702
|
-
|
|
1512
|
+
// --- Attachments (session-scoped) ---
|
|
1513
|
+
// POST /api/v1/sessions/:id/attachments — multipart/form-data upload.
|
|
1514
|
+
//
|
|
1515
|
+
// Wire format: a single `file` field. busboy streams chunks straight
|
|
1516
|
+
// to <data_dir>/sessions/<sid>/attachments/<uuid>.<ext>.tmp; on close
|
|
1517
|
+
// we atomic-rename to the final name and insert an attachments row.
|
|
1518
|
+
// Aborts / mid-stream errors / oversize / wrong field name all leave
|
|
1519
|
+
// the .tmp removed before responding.
|
|
1520
|
+
const imgUploadMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/?$/);
|
|
703
1521
|
if (imgUploadMatch && req.method === "POST") {
|
|
704
1522
|
const sessionId = decodeURIComponent(imgUploadMatch[1]);
|
|
705
1523
|
if (!SAFE_ID.test(sessionId)) {
|
|
706
1524
|
json(res, 400, { error: "Invalid session ID" });
|
|
707
1525
|
return;
|
|
708
1526
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
json(res, 413, { error: "Upload too large" });
|
|
1527
|
+
const ctype = req.headers["content-type"] ?? "";
|
|
1528
|
+
if (!ctype.toLowerCase().startsWith("multipart/form-data")) {
|
|
1529
|
+
json(res, 400, { error: "Expected multipart/form-data" });
|
|
713
1530
|
return;
|
|
714
1531
|
}
|
|
715
|
-
|
|
716
|
-
let totalSize = 0;
|
|
717
|
-
for await (const chunk of req) {
|
|
718
|
-
totalSize += chunk.length;
|
|
719
|
-
if (totalSize > deps.limits.image_upload) {
|
|
720
|
-
json(res, 413, { error: "Upload too large" });
|
|
721
|
-
return;
|
|
722
|
-
}
|
|
723
|
-
chunks.push(chunk);
|
|
724
|
-
}
|
|
725
|
-
let body;
|
|
726
|
-
try {
|
|
727
|
-
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
728
|
-
}
|
|
729
|
-
catch {
|
|
730
|
-
json(res, 400, { error: "Invalid JSON" });
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
const { data, mimeType } = body;
|
|
734
|
-
const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "png";
|
|
735
|
-
const seq = Date.now();
|
|
736
|
-
const fileName = `${seq}.${ext}`;
|
|
737
|
-
const relPath = `images/${sessionId}/${fileName}`;
|
|
738
|
-
const absPath = join(deps.dataDir, relPath);
|
|
739
|
-
await mkdir(join(deps.dataDir, "images", sessionId), { recursive: true });
|
|
740
|
-
await writeFile(absPath, Buffer.from(data, "base64"));
|
|
741
|
-
const imgUrl = `/api/v1/sessions/${sessionId}/images/${fileName}`;
|
|
742
|
-
json(res, 200, { path: relPath, url: imgUrl });
|
|
1532
|
+
await handleAttachmentUpload(req, res, sessionId, deps);
|
|
743
1533
|
return;
|
|
744
1534
|
}
|
|
745
|
-
// GET /api/v1/sessions/:id/
|
|
746
|
-
|
|
1535
|
+
// GET /api/v1/sessions/:id/attachments/:file — serve a previously
|
|
1536
|
+
// uploaded attachment. Mime + displayName are looked up from the
|
|
1537
|
+
// attachments table so the response carries the original filename
|
|
1538
|
+
// (RFC 5987) and the per-mime inline/attachment disposition.
|
|
1539
|
+
const imgGetMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/attachments\/([^/?]+)(\?.*)?$/);
|
|
747
1540
|
if (imgGetMatch && req.method === "GET") {
|
|
748
1541
|
const sessionId = decodeURIComponent(imgGetMatch[1]);
|
|
749
1542
|
const file = decodeURIComponent(imgGetMatch[2]);
|
|
750
|
-
|
|
751
|
-
|
|
1543
|
+
// When secret is configured, GET requires sig+exp in query — there
|
|
1544
|
+
// is no Bearer fallback because <img src=...> / <a href=...> can't
|
|
1545
|
+
// carry headers. Verify before any disk I/O.
|
|
1546
|
+
if (deps.attachmentSecret) {
|
|
1547
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
1548
|
+
const sig = params.get("sig") ?? "";
|
|
1549
|
+
const exp = params.get("exp") ?? "";
|
|
1550
|
+
const basePath = `/api/v1/sessions/${sessionId}/attachments/${file}`;
|
|
1551
|
+
if (!verifyAttachmentSig(basePath, exp, sig, deps.attachmentSecret)) {
|
|
1552
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1553
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const filePath = join(deps.dataDir, "sessions", sessionId, "attachments", file);
|
|
1558
|
+
if (!filePath.startsWith(join(deps.dataDir, "sessions"))) {
|
|
752
1559
|
res.writeHead(403);
|
|
753
1560
|
res.end("Forbidden");
|
|
754
1561
|
return;
|
|
755
1562
|
}
|
|
1563
|
+
// Look up the row to recover the original mime + display name.
|
|
1564
|
+
// Pre-attachments-table uploads (none in v0.4+) would miss here;
|
|
1565
|
+
// we degrade gracefully to extension-based mime.
|
|
1566
|
+
const row = store.getAttachmentByFile(sessionId, file);
|
|
756
1567
|
try {
|
|
757
1568
|
const fileData = await readFile(filePath);
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
1569
|
+
const mime = row?.mime ??
|
|
1570
|
+
(MIME[extname(filePath)] || "application/octet-stream");
|
|
1571
|
+
const headers = {
|
|
1572
|
+
"Content-Type": mime,
|
|
761
1573
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
762
|
-
|
|
1574
|
+
"X-Content-Type-Options": "nosniff",
|
|
1575
|
+
};
|
|
1576
|
+
if (row) {
|
|
1577
|
+
const disposition = isInlineMime(mime) ? "inline" : "attachment";
|
|
1578
|
+
headers["Content-Disposition"] = buildContentDisposition(disposition, row.name);
|
|
1579
|
+
}
|
|
1580
|
+
res.writeHead(200, headers);
|
|
763
1581
|
res.end(fileData);
|
|
764
1582
|
}
|
|
765
1583
|
catch {
|
|
@@ -768,6 +1586,203 @@ export function createRequestHandler(deps) {
|
|
|
768
1586
|
}
|
|
769
1587
|
return;
|
|
770
1588
|
}
|
|
1589
|
+
// --- Inbox messages (Stage B primitive) ---
|
|
1590
|
+
// POST /api/v1/messages — create ingress message
|
|
1591
|
+
if (url === "/api/v1/messages" && req.method === "POST") {
|
|
1592
|
+
// client-server-split M2: idempotency for the ingress message
|
|
1593
|
+
// creator. /messages has no real session id, so we scope the
|
|
1594
|
+
// cache under the synthetic key "__ingress__".
|
|
1595
|
+
const { opId, replayed } = tryReplayClientOp(req, res, store, "__ingress__");
|
|
1596
|
+
if (replayed)
|
|
1597
|
+
return;
|
|
1598
|
+
let raw;
|
|
1599
|
+
try {
|
|
1600
|
+
raw = await readBody(req);
|
|
1601
|
+
}
|
|
1602
|
+
catch {
|
|
1603
|
+
json(res, 400, { error: "Failed to read body" });
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
let parsed;
|
|
1607
|
+
try {
|
|
1608
|
+
parsed = JSON.parse(raw);
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
const validation = MessageIngressSchema.safeParse(parsed);
|
|
1615
|
+
if (!validation.success) {
|
|
1616
|
+
json(res, 400, {
|
|
1617
|
+
error: "Invalid body",
|
|
1618
|
+
issues: validation.error.issues,
|
|
1619
|
+
});
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const input = validation.data;
|
|
1623
|
+
const id = `msg-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
1624
|
+
if (input.to.startsWith("session:")) {
|
|
1625
|
+
const targetSid = input.to.slice("session:".length);
|
|
1626
|
+
const session = store.getSession(targetSid);
|
|
1627
|
+
if (!session) {
|
|
1628
|
+
json(res, 400, { error: "session_not_found" });
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
sessions?.flushBuffers(targetSid);
|
|
1632
|
+
const data = {
|
|
1633
|
+
message_id: id,
|
|
1634
|
+
from_ref: input.from_ref,
|
|
1635
|
+
from_label: input.from_label ?? null,
|
|
1636
|
+
title: input.title,
|
|
1637
|
+
body: input.body,
|
|
1638
|
+
cwd: input.cwd ?? null,
|
|
1639
|
+
};
|
|
1640
|
+
store.saveEvent(targetSid, "message", data, {
|
|
1641
|
+
from_ref: input.from_ref,
|
|
1642
|
+
});
|
|
1643
|
+
sseManager.broadcast({
|
|
1644
|
+
type: "message",
|
|
1645
|
+
sessionId: targetSid,
|
|
1646
|
+
...data,
|
|
1647
|
+
});
|
|
1648
|
+
if (deps.pushService) {
|
|
1649
|
+
void deps.pushService.sendForMessage({
|
|
1650
|
+
id,
|
|
1651
|
+
to: input.to,
|
|
1652
|
+
body: input.body,
|
|
1653
|
+
from_label: input.from_label,
|
|
1654
|
+
from_ref: input.from_ref,
|
|
1655
|
+
deliver: input.deliver,
|
|
1656
|
+
dedup_key: input.dedup_key ?? null,
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
mlog.info("ingress bound", {
|
|
1660
|
+
msg_id: id,
|
|
1661
|
+
sess_id: targetSid.slice(0, 8),
|
|
1662
|
+
});
|
|
1663
|
+
const boundBody = { id, delivered: "session" };
|
|
1664
|
+
saveClientOpResult(store, opId, "__ingress__", 200, boundBody);
|
|
1665
|
+
json(res, 200, boundBody);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
// Unbound: to=user → rows in `messages` table
|
|
1669
|
+
const dedupKey = input.dedup_key ?? null;
|
|
1670
|
+
if (dedupKey) {
|
|
1671
|
+
const prior = store.findBySupersede(input.to, dedupKey);
|
|
1672
|
+
if (prior) {
|
|
1673
|
+
store.deleteMessage(prior.id);
|
|
1674
|
+
mlog.info("dedup_key supersede", {
|
|
1675
|
+
to: input.to,
|
|
1676
|
+
dedup_key: dedupKey,
|
|
1677
|
+
old_msg_id: prior.id,
|
|
1678
|
+
new_msg_id: id,
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
store.createMessage({
|
|
1683
|
+
id,
|
|
1684
|
+
from_ref: input.from_ref,
|
|
1685
|
+
from_label: input.from_label ?? null,
|
|
1686
|
+
to_ref: input.to,
|
|
1687
|
+
deliver: input.deliver,
|
|
1688
|
+
dedup_key: dedupKey,
|
|
1689
|
+
title: input.title,
|
|
1690
|
+
body: input.body,
|
|
1691
|
+
cwd: input.cwd ?? null,
|
|
1692
|
+
created_at: Date.now(),
|
|
1693
|
+
});
|
|
1694
|
+
sseManager.broadcast({ type: "message_created", messageId: id });
|
|
1695
|
+
if (deps.pushService) {
|
|
1696
|
+
void deps.pushService.sendForMessage({
|
|
1697
|
+
id,
|
|
1698
|
+
to: input.to,
|
|
1699
|
+
body: input.body,
|
|
1700
|
+
from_label: input.from_label,
|
|
1701
|
+
from_ref: input.from_ref,
|
|
1702
|
+
deliver: input.deliver,
|
|
1703
|
+
dedup_key: dedupKey,
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
mlog.info("ingress unbound", {
|
|
1707
|
+
msg_id: id,
|
|
1708
|
+
from_ref: input.from_ref,
|
|
1709
|
+
});
|
|
1710
|
+
const unboundBody = { id, delivered: "pending" };
|
|
1711
|
+
saveClientOpResult(store, opId, "__ingress__", 200, unboundBody);
|
|
1712
|
+
json(res, 200, unboundBody);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
// GET /api/v1/messages — list unprocessed
|
|
1716
|
+
if (url === "/api/v1/messages" && req.method === "GET") {
|
|
1717
|
+
json(res, 200, { messages: store.listUnprocessed() });
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
// /api/v1/messages/:id... — GET single, POST :id/consume, POST :id/ack, DELETE :id
|
|
1721
|
+
if (url.startsWith("/api/v1/messages/")) {
|
|
1722
|
+
const tail = url.slice("/api/v1/messages/".length);
|
|
1723
|
+
const consumeMatch = tail.match(/^([^/?]+)\/consume\/?$/);
|
|
1724
|
+
if (consumeMatch && req.method === "POST") {
|
|
1725
|
+
const id = decodeURIComponent(consumeMatch[1]);
|
|
1726
|
+
const newSid = randomUUID();
|
|
1727
|
+
let out;
|
|
1728
|
+
try {
|
|
1729
|
+
out = store.consumeMessageTx(id, { sessionId: newSid });
|
|
1730
|
+
}
|
|
1731
|
+
catch (err) {
|
|
1732
|
+
if (/message not found/.test(errorMessage(err))) {
|
|
1733
|
+
json(res, 404, { error: "Message not found" });
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
throw err;
|
|
1737
|
+
}
|
|
1738
|
+
sseManager.broadcast({
|
|
1739
|
+
type: "message_consumed",
|
|
1740
|
+
messageId: id,
|
|
1741
|
+
sessionId: out.sessionId,
|
|
1742
|
+
});
|
|
1743
|
+
if (!out.alreadyConsumed && deps.pushService) {
|
|
1744
|
+
void deps.pushService.sendClose(id);
|
|
1745
|
+
}
|
|
1746
|
+
mlog.info("consume", {
|
|
1747
|
+
msg_id: id,
|
|
1748
|
+
sess_id: out.sessionId.slice(0, 8),
|
|
1749
|
+
already_consumed: out.alreadyConsumed,
|
|
1750
|
+
});
|
|
1751
|
+
json(res, 200, {
|
|
1752
|
+
sessionId: out.sessionId,
|
|
1753
|
+
alreadyConsumed: out.alreadyConsumed,
|
|
1754
|
+
});
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
const ackPost = tail.match(/^([^/?]+)\/ack\/?$/);
|
|
1758
|
+
const idOnly = tail.match(/^([^/?]+)\/?$/);
|
|
1759
|
+
const isAck = (ackPost !== null && req.method === "POST") ||
|
|
1760
|
+
(idOnly !== null && req.method === "DELETE");
|
|
1761
|
+
if (isAck) {
|
|
1762
|
+
const id = decodeURIComponent((ackPost ?? idOnly)[1]);
|
|
1763
|
+
const changes = store.deleteMessage(id);
|
|
1764
|
+
if (changes === 0) {
|
|
1765
|
+
json(res, 404, { error: "Message not found" });
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
sseManager.broadcast({ type: "message_acked", messageId: id });
|
|
1769
|
+
if (deps.pushService)
|
|
1770
|
+
void deps.pushService.sendClose(id);
|
|
1771
|
+
mlog.info("ack", { msg_id: id });
|
|
1772
|
+
json(res, 200, { ok: true });
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (idOnly && req.method === "GET") {
|
|
1776
|
+
const id = decodeURIComponent(idOnly[1]);
|
|
1777
|
+
const row = store.getMessage(id);
|
|
1778
|
+
if (!row) {
|
|
1779
|
+
json(res, 404, { error: "Message not found" });
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
json(res, 200, row);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
771
1786
|
json(res, 404, { error: "Not found" });
|
|
772
1787
|
return;
|
|
773
1788
|
}
|
|
@@ -798,34 +1813,45 @@ export function createRequestHandler(deps) {
|
|
|
798
1813
|
json(res, 400, { error: "Missing required field: text" });
|
|
799
1814
|
return;
|
|
800
1815
|
}
|
|
801
|
-
const cwd = body.cwd
|
|
1816
|
+
const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
|
|
802
1817
|
const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
|
|
803
1818
|
const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
|
|
804
1819
|
json(res, 202, { sessionId, streamUrl });
|
|
805
1820
|
// Fire-and-forget: send the prompt asynchronously, tracking busy state
|
|
806
1821
|
sessions.activePrompts.add(sessionId);
|
|
1822
|
+
sessions.syncBusy(sessionId);
|
|
807
1823
|
// Generate title (fire-and-forget)
|
|
808
1824
|
if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
|
|
809
1825
|
titleService.generate(bridge, text, sessionId, (title) => {
|
|
810
|
-
const titleEvent = {
|
|
1826
|
+
const titleEvent = {
|
|
1827
|
+
type: "session_title_updated",
|
|
1828
|
+
sessionId,
|
|
1829
|
+
title,
|
|
1830
|
+
};
|
|
811
1831
|
sseManager.broadcast(titleEvent);
|
|
812
1832
|
});
|
|
813
1833
|
}
|
|
814
|
-
bridge
|
|
1834
|
+
bridge
|
|
1835
|
+
.prompt(sessionId, text)
|
|
815
1836
|
.catch(() => { })
|
|
816
|
-
.finally(() =>
|
|
1837
|
+
.finally(() => {
|
|
1838
|
+
sessions.activePrompts.delete(sessionId);
|
|
1839
|
+
sessions.syncBusy(sessionId);
|
|
1840
|
+
});
|
|
817
1841
|
return;
|
|
818
1842
|
}
|
|
819
1843
|
// POST /api/beta/clients/:clientId/visibility
|
|
820
1844
|
const visMatch = url.match(/^\/api\/beta\/clients\/([^/]+)\/visibility$/);
|
|
821
1845
|
if (visMatch && req.method === "POST") {
|
|
822
|
-
if (!deps.sseManager) {
|
|
823
|
-
json(res, 501, { error: "SSE not available" });
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
const sseManager = deps.sseManager;
|
|
827
1846
|
const clientId = decodeURIComponent(visMatch[1]);
|
|
828
|
-
if (
|
|
1847
|
+
// Trust boundary: accept if the client is (a) currently connected
|
|
1848
|
+
// via SSE, or (b) known to the ClientRegistry (populated on /hello,
|
|
1849
|
+
// persists across SSE disconnect). Registry check fixes the
|
|
1850
|
+
// pagehide-beacon race where iOS PWA suspension drops the SSE TCP
|
|
1851
|
+
// connection before the beacon egresses.
|
|
1852
|
+
const clientKnown = sseManager.clients.has(clientId) ||
|
|
1853
|
+
deps.clientRegistry?.get(clientId) !== undefined;
|
|
1854
|
+
if (!clientKnown) {
|
|
829
1855
|
json(res, 404, { error: "Client not found" });
|
|
830
1856
|
return;
|
|
831
1857
|
}
|
|
@@ -841,11 +1867,42 @@ export function createRequestHandler(deps) {
|
|
|
841
1867
|
json(res, 400, { error: "Missing or invalid 'visible' field" });
|
|
842
1868
|
return;
|
|
843
1869
|
}
|
|
844
|
-
//
|
|
1870
|
+
// sessionId patch semantics: absent = preserve, null = clear,
|
|
1871
|
+
// string = replace. Zod can't distinguish omitted from explicit
|
|
1872
|
+
// null after parse, so branch on raw body key.
|
|
1873
|
+
const hasSessionIdKey = Object.prototype.hasOwnProperty.call(body, "sessionId");
|
|
1874
|
+
let sessionIdPatch;
|
|
1875
|
+
if (!hasSessionIdKey) {
|
|
1876
|
+
sessionIdPatch = undefined;
|
|
1877
|
+
}
|
|
1878
|
+
else if (body.sessionId === null) {
|
|
1879
|
+
sessionIdPatch = null;
|
|
1880
|
+
}
|
|
1881
|
+
else if (typeof body.sessionId === "string" &&
|
|
1882
|
+
body.sessionId.length > 0) {
|
|
1883
|
+
sessionIdPatch = body.sessionId;
|
|
1884
|
+
}
|
|
1885
|
+
else {
|
|
1886
|
+
sessionIdPatch = null;
|
|
1887
|
+
}
|
|
845
1888
|
if (deps.pushService) {
|
|
846
|
-
deps.pushService.
|
|
847
|
-
|
|
848
|
-
|
|
1889
|
+
const { becameVisibleForSession } = deps.pushService.updateClient(clientId, {
|
|
1890
|
+
visible: body.visible,
|
|
1891
|
+
sessionId: sessionIdPatch,
|
|
1892
|
+
});
|
|
1893
|
+
// Edge-triggered only: heartbeat refreshes repeat the same
|
|
1894
|
+
// (visible:true, sessionId:X) POST every 15s — firing sendClose
|
|
1895
|
+
// on each would hammer banner recall. Only the first such
|
|
1896
|
+
// transition after a change should recall stale banners.
|
|
1897
|
+
if (becameVisibleForSession) {
|
|
1898
|
+
void deps.pushService.sendClose(`sess-${becameVisibleForSession}-done`);
|
|
1899
|
+
if (sessions) {
|
|
1900
|
+
for (const perm of sessions.pendingPermissions.values()) {
|
|
1901
|
+
if (perm.sessionId === becameVisibleForSession) {
|
|
1902
|
+
void deps.pushService.sendClose(`sess-${becameVisibleForSession}-perm-${perm.requestId}`);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
849
1906
|
}
|
|
850
1907
|
}
|
|
851
1908
|
json(res, 200, { ok: true });
|
|
@@ -878,11 +1935,12 @@ export function createRequestHandler(deps) {
|
|
|
878
1935
|
json(res, 400, { error: "Invalid JSON" });
|
|
879
1936
|
return;
|
|
880
1937
|
}
|
|
881
|
-
if (!body.endpoint || !body.keys?.auth || !body.keys
|
|
1938
|
+
if (!body.endpoint || !body.keys?.auth || !body.keys.p256dh) {
|
|
882
1939
|
json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
|
|
883
1940
|
return;
|
|
884
1941
|
}
|
|
885
1942
|
store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
|
|
1943
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive
|
|
886
1944
|
if (body.clientId && deps.pushService) {
|
|
887
1945
|
deps.pushService.registerClient(body.clientId, body.endpoint);
|
|
888
1946
|
}
|
|
@@ -941,7 +1999,11 @@ export function createRequestHandler(deps) {
|
|
|
941
1999
|
return;
|
|
942
2000
|
}
|
|
943
2001
|
// --- Static files ---
|
|
944
|
-
|
|
2002
|
+
let staticPath = url;
|
|
2003
|
+
const htmlEntry = HTML_ENTRYPOINTS.find((e) => e.urlPath === staticPath);
|
|
2004
|
+
if (htmlEntry)
|
|
2005
|
+
staticPath = "/" + htmlEntry.file;
|
|
2006
|
+
const filePath = join(deps.publicDir, staticPath);
|
|
945
2007
|
if (!filePath.startsWith(deps.publicDir)) {
|
|
946
2008
|
res.writeHead(403);
|
|
947
2009
|
res.end("Forbidden");
|
|
@@ -950,7 +2012,19 @@ export function createRequestHandler(deps) {
|
|
|
950
2012
|
try {
|
|
951
2013
|
const data = await readFile(filePath);
|
|
952
2014
|
const ext = extname(filePath);
|
|
953
|
-
|
|
2015
|
+
const base = filePath.slice(filePath.lastIndexOf("/") + 1);
|
|
2016
|
+
const isHashedAsset = /\.[A-Za-z0-9_-]{8,}\.(js|css)$/.test(base);
|
|
2017
|
+
const cacheControl = isHashedAsset
|
|
2018
|
+
? "public, max-age=31536000, immutable"
|
|
2019
|
+
: "no-cache";
|
|
2020
|
+
const headers = {
|
|
2021
|
+
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
2022
|
+
"Cache-Control": cacheControl,
|
|
2023
|
+
};
|
|
2024
|
+
// CSP applies to HTML entrypoints (where script/style execute).
|
|
2025
|
+
if (htmlEntry)
|
|
2026
|
+
headers["Content-Security-Policy"] = CSP_POLICY;
|
|
2027
|
+
res.writeHead(200, headers);
|
|
954
2028
|
res.end(data);
|
|
955
2029
|
}
|
|
956
2030
|
catch {
|