@erdemtuna/doc-review 0.8.1 → 0.9.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 +112 -22
- package/package.json +4 -2
- package/src/SKILL.md +28 -4
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +4 -0
- package/src/comment-target.js +87 -0
- package/src/comparison-view.js +337 -0
- package/src/document-execution.js +103 -0
- package/src/document-trust.js +2 -0
- package/src/execution-client.js +63 -0
- package/src/frame-policy.js +38 -0
- package/src/history-client.js +104 -0
- package/src/history-coordinator.js +186 -0
- package/src/history-policy.js +43 -0
- package/src/history-server.js +463 -0
- package/src/paths.js +1 -1
- package/src/review-mode.js +1 -1
- package/src/revision-diff.js +440 -0
- package/src/revision-schema.js +219 -0
- package/src/revision-store.js +177 -0
- package/src/sdk.js +419 -55
- package/src/semantic-snapshot.js +230 -0
- package/src/server.js +217 -49
- package/src/state.js +240 -15
- package/src/view-identity.js +99 -0
package/src/server.js
CHANGED
|
@@ -11,6 +11,9 @@ import { canonicalTarget, ensureStateDir, localUrl, SERVER_PROTOCOL, serverPath,
|
|
|
11
11
|
import { acquireServerLock, releaseServerLock, removeOwnedServerRecord } from "./server-lock.js";
|
|
12
12
|
import { invocation, shellQuote } from "./setup.js";
|
|
13
13
|
import { limitEditFields } from "./edit-limits.js";
|
|
14
|
+
import { createHistoryController, HistoryRequestError, historyErrorStatus } from "./history-server.js";
|
|
15
|
+
import { documentExecutionPolicy, transformInteractiveHtml } from "./document-execution.js";
|
|
16
|
+
import { interactiveFileCsp } from "./frame-policy.js";
|
|
14
17
|
|
|
15
18
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
19
|
|
|
@@ -50,8 +53,8 @@ const MAX_LOCAL_PAGE_BYTES = 24 * 1024 * 1024;
|
|
|
50
53
|
|
|
51
54
|
/**
|
|
52
55
|
* File reviews may contain agent-generated or otherwise untrusted JavaScript.
|
|
53
|
-
*
|
|
54
|
-
* authored scripts and inline event handlers remain
|
|
56
|
+
* Static and recovery frames execute only the nonce-bearing Doc Review SDK;
|
|
57
|
+
* authored scripts and inline event handlers remain inert.
|
|
55
58
|
*/
|
|
56
59
|
const fileReviewCsp = (nonce) =>
|
|
57
60
|
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`;
|
|
@@ -226,7 +229,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
226
229
|
// Our own autosave must never bounce back as a reload.
|
|
227
230
|
if (lastWritten.get(key) === current) return;
|
|
228
231
|
try {
|
|
229
|
-
store.setPristine(key, html, { keepEdits:
|
|
232
|
+
store.setPristine(key, html, { keepEdits: true });
|
|
230
233
|
} catch (err) {
|
|
231
234
|
console.error(`Could not refresh review baseline for ${page.file}: ${err.message}`);
|
|
232
235
|
return;
|
|
@@ -290,6 +293,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
290
293
|
kind: e.kind,
|
|
291
294
|
before: e.before,
|
|
292
295
|
after: e.after,
|
|
296
|
+
...(e.feedback_only ? { feedback_only: true } : {}),
|
|
293
297
|
...(e.truncated ? { truncated: true, truncated_fields: e.truncated_fields } : {}),
|
|
294
298
|
...(e.before_html !== undefined && e.before_html !== e.before ? { before_html: e.before_html } : {}),
|
|
295
299
|
...(e.after_html !== undefined && e.after_html !== e.after ? { after_html: e.after_html } : {}),
|
|
@@ -311,15 +315,17 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
311
315
|
}));
|
|
312
316
|
}
|
|
313
317
|
|
|
314
|
-
function sendBatch(sessionId, note) {
|
|
318
|
+
function sendBatch(sessionId, note, historyInput) {
|
|
315
319
|
const session = sessions.get(sessionId);
|
|
316
320
|
if (!session) return { error: "unknown session" };
|
|
317
321
|
|
|
318
322
|
const pages = collectPages(session);
|
|
319
323
|
if (!pages.length && !note) return { error: "nothing to send" };
|
|
324
|
+
const historyContext = history.prepareBaseline(session, pages, historyInput);
|
|
320
325
|
|
|
321
326
|
const hasMarkdown = pages.some((p) => p.kind === "file" && isMarkdown(p.file));
|
|
322
327
|
const hasUrl = pages.some((p) => p.kind === "url");
|
|
328
|
+
const hasTrustedEdits = pages.some((p) => p.edits.some((edit) => edit.feedback_only));
|
|
323
329
|
const hasCorrections = pages.some((p) => p.comments.some((c) => c.correction));
|
|
324
330
|
const hasTruncation = pages.some((p) => p.edits.some((e) => e.truncated));
|
|
325
331
|
const id = `b_${crypto.randomBytes(12).toString("hex")}`;
|
|
@@ -352,6 +358,11 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
352
358
|
"When an edit includes `staged_assets`, copy each local image into the app's appropriate asset folder, replace its " +
|
|
353
359
|
"temporary preview URL in `after_html`, and preserve the image at the user's insertion point. "
|
|
354
360
|
: "") +
|
|
361
|
+
(hasTrustedEdits
|
|
362
|
+
? "Edits marked `feedback_only` came from feedback-only documents. Apply that wording or formatting to the original source; " +
|
|
363
|
+
"it has not been autosaved. Never replace the source with script-generated runtime markup. " +
|
|
364
|
+
"Copy any `staged_assets` into the document's asset folder and replace their temporary references. "
|
|
365
|
+
: "") +
|
|
355
366
|
(hasCorrections
|
|
356
367
|
? "Comments marked `correction` replace their `correction_of` instruction; follow the correction and do not apply the older wording. "
|
|
357
368
|
: "") +
|
|
@@ -367,10 +378,18 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
367
378
|
sentAt: Date.now(),
|
|
368
379
|
})),
|
|
369
380
|
};
|
|
370
|
-
store.setBatch(session.entryKey, record);
|
|
381
|
+
store.setBatch(session.entryKey, { ...record, ...(historyContext ? { history: historyContext } : {}) });
|
|
371
382
|
deliver(session.entryKey);
|
|
372
383
|
broadcastAgent(session.entryKey);
|
|
373
|
-
|
|
384
|
+
const round = historyContext
|
|
385
|
+
? store.listHistory(session.entryKey).find((item) => item.batchId === id)
|
|
386
|
+
: null;
|
|
387
|
+
if (round) history.changed(session.entryKey, round.roundId);
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
...(round ? { roundId: round.roundId } : {}),
|
|
391
|
+
...(historyContext?.unavailable.length ? { historyUnavailable: historyContext.unavailable } : {}),
|
|
392
|
+
};
|
|
374
393
|
}
|
|
375
394
|
|
|
376
395
|
function deleteStagedAsset(file) {
|
|
@@ -395,13 +414,15 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
395
414
|
function ack(entryKey, id) {
|
|
396
415
|
const result = store.acknowledgeBatch(entryKey, id);
|
|
397
416
|
if (!result.acknowledged) return false;
|
|
417
|
+
if (result.roundId) history.captureSources(entryKey, result.roundId);
|
|
418
|
+
const acknowledgedRound = result.roundId ? store.getRound(entryKey, result.roundId) : null;
|
|
398
419
|
// The JSON transition is already durable. Files are cleanup only and must
|
|
399
420
|
// never disappear before the receipt and page cleanup commit succeeds.
|
|
400
421
|
for (const file of result.staged) deleteStagedAsset(file);
|
|
401
422
|
for (const session of sessionsForEntry(entryKey)) emit(session, "refresh", {});
|
|
402
423
|
// File targets reload through fs.watch. URL targets have no source file to
|
|
403
424
|
// watch, so acknowledgement is the signal to fetch the rebuilt route.
|
|
404
|
-
for (const key of result.keys) {
|
|
425
|
+
for (const key of new Set([...result.keys, ...(acknowledgedRound?.targets.map((target) => target.key) || [])])) {
|
|
405
426
|
if (store.page(key)?.kind === "url") {
|
|
406
427
|
for (const session of sessionsForKey(key)) {
|
|
407
428
|
invalidateSessionRender(session);
|
|
@@ -410,6 +431,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
410
431
|
}
|
|
411
432
|
}
|
|
412
433
|
broadcastAgent(entryKey);
|
|
434
|
+
history.changed(entryKey, result.roundId);
|
|
413
435
|
return true;
|
|
414
436
|
}
|
|
415
437
|
|
|
@@ -526,6 +548,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
526
548
|
const entry = session ? store.page(session.entryKey) : null;
|
|
527
549
|
const currentTarget = page.kind === "url" ? page.url : page.file;
|
|
528
550
|
const pollTarget = entry ? (entry.kind === "url" ? entry.url : entry.file) : currentTarget;
|
|
551
|
+
const policy = sourcePolicy(page, undefined, session);
|
|
529
552
|
return {
|
|
530
553
|
key: page.key,
|
|
531
554
|
kind: page.kind === "url" ? "url" : "file",
|
|
@@ -533,14 +556,77 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
533
556
|
...(page.kind === "url" ? { url: page.url } : {}),
|
|
534
557
|
filename: page.kind === "url" ? new URL(page.url).pathname || page.url : path.basename(page.file),
|
|
535
558
|
markdown: page.kind !== "url" && isMarkdown(page.file),
|
|
536
|
-
|
|
559
|
+
...policy,
|
|
560
|
+
...(page.kind !== "url" && !isMarkdown(page.file)
|
|
561
|
+
? { executionPreference: session?.executionPreferences?.get(key) || "auto" } : {}),
|
|
537
562
|
comments: page.comments,
|
|
538
563
|
edits: page.edits,
|
|
539
|
-
canRevert:
|
|
564
|
+
canRevert: policy.savePolicy === "writable" && typeof page.pristine === "string" && page.pristine.length > 0,
|
|
540
565
|
pollCommand: `${cliInvocation} poll ${shellQuote(pollTarget)}`,
|
|
566
|
+
historySupported: true,
|
|
541
567
|
};
|
|
542
568
|
}
|
|
543
569
|
|
|
570
|
+
function sourcePolicy(page, bytes, session) {
|
|
571
|
+
const markdown = page.kind !== "url" && isMarkdown(page.file);
|
|
572
|
+
const source = page.kind === "url" || markdown ? undefined : bytes ?? fs.readFileSync(page.file);
|
|
573
|
+
return documentExecutionPolicy({ ...page, markdown }, source, session?.executionPreferences?.get(page.key) || "auto");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function feedbackOnly(key, requestedPolicy, identity) {
|
|
577
|
+
if (requestedPolicy === "feedback-only" || sourcePolicy(store.page(key)).feedbackOnly) return true;
|
|
578
|
+
if (identity?.renderId != null) {
|
|
579
|
+
const render = typeof identity.renderId === "string" ? currentRender(identity.renderId) : null;
|
|
580
|
+
// Retired frames cannot prove their edits match writable source. Preserve
|
|
581
|
+
// their feedback and pasted images without retaining retired capabilities.
|
|
582
|
+
return !render || render.documentState !== "served" || render.pageKey !== key ||
|
|
583
|
+
render.sessionId !== identity.sessionId || render.generation !== identity.generation ||
|
|
584
|
+
render.savePolicy !== "writable";
|
|
585
|
+
}
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function enforceFileWrite(key, body) {
|
|
590
|
+
if (!body || typeof body !== "object" || Array.isArray(body) ||
|
|
591
|
+
typeof body.baseHash !== "string" || !/^[a-f0-9]{40}$/.test(body.baseHash) ||
|
|
592
|
+
typeof body.sessionId !== "string" || !body.sessionId ||
|
|
593
|
+
typeof body.renderId !== "string" || !body.renderId ||
|
|
594
|
+
!Number.isSafeInteger(body.generation) || body.generation < 1) {
|
|
595
|
+
throw new HistoryRequestError("A source hash and complete frame identity are required.", {
|
|
596
|
+
status: 400, code: "invalid_file_write",
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
const render = currentRender(body.renderId);
|
|
600
|
+
if (!render || render.pageKey !== key || render.sessionId !== body.sessionId ||
|
|
601
|
+
render.generation !== body.generation || render.documentState !== "served") {
|
|
602
|
+
throw new HistoryRequestError("Reload the document before writing; this request has no current frame.", {
|
|
603
|
+
status: 409, code: "stale_file_write",
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
const page = store.page(key);
|
|
607
|
+
const current = fs.readFileSync(page.file);
|
|
608
|
+
if (render.savePolicy !== "writable" || sourcePolicy(page, current).savePolicy !== "writable") {
|
|
609
|
+
throw new HistoryRequestError("This document is feedback-only. Apply edits to the original source through the agent.", {
|
|
610
|
+
status: 409, code: "file_feedback_only",
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
if (hash(stripSdk(current.toString("utf8"))) !== body.baseHash || render.sourceHash !== body.baseHash) {
|
|
614
|
+
throw new HistoryRequestError("The file changed on disk since this edit began.", { status: 409, code: "stale_file_write" });
|
|
615
|
+
}
|
|
616
|
+
return render;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function readFileWriteBody(req) {
|
|
620
|
+
try {
|
|
621
|
+
return await readBody(req);
|
|
622
|
+
} catch {
|
|
623
|
+
throw new HistoryRequestError("Invalid file write request.", { status: 400, code: "invalid_file_write" });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const history = createHistoryController({ store, sessions, currentRender, readBody, json, emit, pageState });
|
|
628
|
+
for (const entryKey of Object.keys(store.data.histories || {})) history.captureSources(entryKey);
|
|
629
|
+
|
|
544
630
|
const server = http.createServer(async (req, res) => {
|
|
545
631
|
touch();
|
|
546
632
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
@@ -571,6 +657,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
571
657
|
const ok = provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
|
|
572
658
|
if (!ok) return json(res, 401, { error: "missing or invalid token" });
|
|
573
659
|
}
|
|
660
|
+
if (await history.handle(req, res, url)) return undefined;
|
|
574
661
|
|
|
575
662
|
// --- static chrome assets
|
|
576
663
|
if (route === "/chrome.css") return serveFile(res, path.join(here, "chrome.css"));
|
|
@@ -587,6 +674,48 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
587
674
|
if (route === "/click-target.js") return serveFile(res, path.join(here, "click-target.js"), opaqueModuleCors(req));
|
|
588
675
|
if (route === "/serialize.js") return serveFile(res, path.join(here, "serialize.js"), opaqueModuleCors(req));
|
|
589
676
|
if (route === "/frame-channel.js") return serveFile(res, path.join(here, "frame-channel.js"), opaqueModuleCors(req));
|
|
677
|
+
if (route === "/semantic-snapshot.js") return serveFile(res, path.join(here, "semantic-snapshot.js"), opaqueModuleCors(req));
|
|
678
|
+
if (route === "/revision-schema.js") return serveFile(res, path.join(here, "revision-schema.js"), opaqueModuleCors(req));
|
|
679
|
+
if (route === "/history-client.js") return serveFile(res, path.join(here, "history-client.js"));
|
|
680
|
+
if (route === "/view-identity.js") return serveFile(res, path.join(here, "view-identity.js"), opaqueModuleCors(req));
|
|
681
|
+
if (route === "/comparison-view.js") return serveFile(res, path.join(here, "comparison-view.js"));
|
|
682
|
+
if (route === "/history-coordinator.js") return serveFile(res, path.join(here, "history-coordinator.js"));
|
|
683
|
+
if (route === "/execution-client.js") return serveFile(res, path.join(here, "execution-client.js"));
|
|
684
|
+
|
|
685
|
+
const trustMatch = route.match(/^\/api\/session\/([^/]+)\/trust$/);
|
|
686
|
+
if (trustMatch) {
|
|
687
|
+
return json(res, 410, { error: "Version approvals are no longer used.", code: "trust_workflow_removed" });
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const executionMatch = route.match(/^\/api\/session\/([^/]+)\/execution$/);
|
|
691
|
+
if (executionMatch) {
|
|
692
|
+
if (req.method !== "POST") return json(res, 405, { error: "Method not allowed.", code: "method_not_allowed" });
|
|
693
|
+
const session = sessions.get(executionMatch[1]);
|
|
694
|
+
if (!session) return json(res, 404, { error: "Unknown session.", code: "execution_target_missing" });
|
|
695
|
+
let body;
|
|
696
|
+
try { body = await readBody(req); } catch {
|
|
697
|
+
return json(res, 400, { error: "Invalid execution request.", code: "invalid_execution_request" });
|
|
698
|
+
}
|
|
699
|
+
if (!body || typeof body !== "object" || Array.isArray(body) ||
|
|
700
|
+
typeof body.key !== "string" || !body.key || !["auto", "static"].includes(body.preference)) {
|
|
701
|
+
return json(res, 400, { error: "Expected an HTML page key and auto or static preference.", code: "invalid_execution_request" });
|
|
702
|
+
}
|
|
703
|
+
if (!store.page(body.key)) return json(res, 404, { error: "Unknown page.", code: "execution_target_missing" });
|
|
704
|
+
if (body.key !== session.activeKey) return json(res, 409, { error: "The reviewed target changed.", code: "execution_target_changed" });
|
|
705
|
+
const page = store.page(session.activeKey);
|
|
706
|
+
if (!page || page.kind === "url" || isMarkdown(page.file)) {
|
|
707
|
+
return json(res, 400, { error: "Execution preferences apply only to local HTML.", code: "invalid_execution_request" });
|
|
708
|
+
}
|
|
709
|
+
sourcePolicy(page);
|
|
710
|
+
seen(session);
|
|
711
|
+
const reloadRequired = (session.executionPreferences.get(page.key) || "auto") !== body.preference;
|
|
712
|
+
session.executionPreferences.set(page.key, body.preference);
|
|
713
|
+
if (reloadRequired) {
|
|
714
|
+
invalidateSessionRender(session);
|
|
715
|
+
emit(session, "reload", { key: page.key, reason: "execution-preference-changed" });
|
|
716
|
+
}
|
|
717
|
+
return json(res, 200, { ok: true, page: pageState(page.key, session), reloadRequired });
|
|
718
|
+
}
|
|
590
719
|
|
|
591
720
|
// --- open a browser session for a file or localhost URL
|
|
592
721
|
if (route === "/api/session" && req.method === "POST") {
|
|
@@ -611,6 +740,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
611
740
|
activeKey: page.key,
|
|
612
741
|
generation: 0,
|
|
613
742
|
renderId: null,
|
|
743
|
+
executionPreferences: new Map(),
|
|
614
744
|
visited: new Set([page.key]),
|
|
615
745
|
clients: new Set(),
|
|
616
746
|
lastSeen: Date.now(),
|
|
@@ -687,8 +817,17 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
687
817
|
) {
|
|
688
818
|
return json(res, 403, { error: "invalid render capability" });
|
|
689
819
|
}
|
|
820
|
+
if (render.documentState !== "served") return json(res, 409, { error: "render has not been served" });
|
|
690
821
|
render.capability = null;
|
|
691
|
-
return json(res, 200, {
|
|
822
|
+
return json(res, 200, {
|
|
823
|
+
ok: true,
|
|
824
|
+
sourceHash: render.sourceHash ?? null,
|
|
825
|
+
sourceCapturedAt: render.sourceCapturedAt ?? null,
|
|
826
|
+
executionMode: render.executionMode,
|
|
827
|
+
savePolicy: render.savePolicy,
|
|
828
|
+
feedbackOnly: render.savePolicy === "feedback-only",
|
|
829
|
+
executionNotice: render.executionNotice || null,
|
|
830
|
+
});
|
|
692
831
|
}
|
|
693
832
|
|
|
694
833
|
// --- the reviewed page itself, plus sibling assets for its render
|
|
@@ -713,6 +852,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
713
852
|
let sdkOptions = {};
|
|
714
853
|
let extraHeaders = {};
|
|
715
854
|
if (page.kind === "url") {
|
|
855
|
+
Object.assign(render, sourcePolicy(page));
|
|
716
856
|
try {
|
|
717
857
|
const fetched = await fetchLocalPage(page.url);
|
|
718
858
|
html = fetched.html;
|
|
@@ -733,17 +873,22 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
733
873
|
return res.end(`Could not load ${page.url}: ${err.message}`);
|
|
734
874
|
}
|
|
735
875
|
} else {
|
|
876
|
+
let originalBytes;
|
|
736
877
|
try {
|
|
737
|
-
|
|
738
|
-
|
|
878
|
+
originalBytes = fs.readFileSync(page.file);
|
|
879
|
+
html = originalBytes.toString("utf8");
|
|
880
|
+
} catch (error) {
|
|
739
881
|
render.documentState = "registered";
|
|
740
|
-
res.writeHead(404, {
|
|
882
|
+
res.writeHead(error.code === "ENOENT" ? 404 : 500, {
|
|
741
883
|
"content-type": "text/plain",
|
|
742
884
|
"cache-control": "no-store",
|
|
743
885
|
"referrer-policy": "no-referrer",
|
|
744
886
|
});
|
|
745
|
-
return res.end(
|
|
887
|
+
return res.end(`Could not read document: ${error.message}`);
|
|
746
888
|
}
|
|
889
|
+
render.sourceHash = hash(stripSdk(html));
|
|
890
|
+
render.sourceCapturedAt = new Date().toISOString();
|
|
891
|
+
Object.assign(render, sourcePolicy(page, originalBytes, sessions.get(render.sessionId)));
|
|
747
892
|
// Markdown reviews render on the fly; the source file stays untouched.
|
|
748
893
|
if (isMarkdown(page.file)) html = renderMarkdownPage(html, page.file);
|
|
749
894
|
sdkOptions = {
|
|
@@ -752,7 +897,15 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
752
897
|
pageKey: render.pageKey,
|
|
753
898
|
src: `http://${host}/sdk.js`,
|
|
754
899
|
};
|
|
755
|
-
|
|
900
|
+
if (!isMarkdown(page.file) && render.savePolicy === "feedback-only") {
|
|
901
|
+
const transformed = transformInteractiveHtml(html);
|
|
902
|
+
html = transformed.html;
|
|
903
|
+
render.executionNotice = render.executionMode === "static"
|
|
904
|
+
? transformed.notice.replace("Inline page interactions run automatically.", "Page interactions are disabled for this review.")
|
|
905
|
+
: transformed.notice;
|
|
906
|
+
extraHeaders = { "content-security-policy": render.executionMode === "static"
|
|
907
|
+
? fileReviewCsp(render.capability) : interactiveFileCsp(`http://${host}`) };
|
|
908
|
+
} else extraHeaders = { "content-security-policy": fileReviewCsp(render.capability) };
|
|
756
909
|
}
|
|
757
910
|
res.writeHead(200, {
|
|
758
911
|
"content-type": MIME[".html"],
|
|
@@ -763,18 +916,15 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
763
916
|
render.documentState = "served";
|
|
764
917
|
return res.end(injectSdk(html, renderId, sdkOptions));
|
|
765
918
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
if (asset.startsWith(stagedPrefix)) {
|
|
919
|
+
const stagedPrefix = "__doc_review_paste__/";
|
|
920
|
+
if (asset.startsWith(stagedPrefix)) {
|
|
769
921
|
const name = asset.slice(stagedPrefix.length);
|
|
770
|
-
if (!name || path.basename(name) !== name) {
|
|
922
|
+
if (!name || !/^[\w-]+\.(png|jpg|gif|webp)$/.test(name) || path.basename(name) !== name) {
|
|
771
923
|
res.writeHead(403, { "content-type": "text/plain" });
|
|
772
924
|
return res.end("Forbidden");
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
res.writeHead(404, { "content-type": "text/plain" });
|
|
777
|
-
return res.end("Localhost assets load from the reviewed development server.");
|
|
925
|
+
}
|
|
926
|
+
// Keep staged previews reachable across source-policy changes.
|
|
927
|
+
return serveFile(res, path.join(stateDir(), "pasted", render.pageKey, name));
|
|
778
928
|
}
|
|
779
929
|
const target = resolveAsset(page.file, asset.split("?")[0]);
|
|
780
930
|
if (!target) {
|
|
@@ -914,9 +1064,10 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
914
1064
|
truncated_fields: limited.truncated_fields,
|
|
915
1065
|
...(kind === "moved" ? { moved_after: fields.moved_after || "", moved_before: fields.moved_before || "" } : {}),
|
|
916
1066
|
...(stagedAssets.length ? { staged_assets: stagedAssets } : {}),
|
|
1067
|
+
...(feedbackOnly(key, body.savePolicy, body) ? { feedback_only: true } : {}),
|
|
917
1068
|
};
|
|
918
1069
|
store.addEdit(key, label, kind, fields.before, fields.after, fields.before_html, fields.after_html, extra);
|
|
919
|
-
return json(res, 200, { page: pageState(key) });
|
|
1070
|
+
return json(res, 200, { page: pageState(key, sessions.get(body.sessionId)) });
|
|
920
1071
|
}
|
|
921
1072
|
|
|
922
1073
|
// File reviews keep pasted images beside the document. Localhost
|
|
@@ -928,7 +1079,11 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
928
1079
|
if (!ext) return json(res, 400, { error: `unsupported image type: ${type || "unknown"}` });
|
|
929
1080
|
const bytes = await readRawBody(req);
|
|
930
1081
|
if (!bytes.length) return json(res, 400, { error: "empty image" });
|
|
931
|
-
const staged =
|
|
1082
|
+
const staged = feedbackOnly(key, url.searchParams.get("savePolicy"), {
|
|
1083
|
+
sessionId: url.searchParams.get("sessionId"),
|
|
1084
|
+
renderId: url.searchParams.get("renderId"),
|
|
1085
|
+
generation: Number(url.searchParams.get("generation")),
|
|
1086
|
+
});
|
|
932
1087
|
const dir = staged ? path.join(stateDir(), "pasted", key) : path.join(path.dirname(page.file), "assets");
|
|
933
1088
|
fs.mkdirSync(dir, { recursive: true });
|
|
934
1089
|
const base = staged
|
|
@@ -956,26 +1111,22 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
956
1111
|
if (page.kind === "url" || isMarkdown(page.file)) {
|
|
957
1112
|
return json(res, 400, { error: page.kind === "url" ? "localhost edits must be applied to app source" : "markdown pages are feedback-only" });
|
|
958
1113
|
}
|
|
959
|
-
const body = await
|
|
960
|
-
if (typeof body
|
|
1114
|
+
const body = await readFileWriteBody(req);
|
|
1115
|
+
if (typeof body?.html !== "string" || !body.html.trim()) {
|
|
961
1116
|
return json(res, 400, { error: "empty html" });
|
|
962
1117
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
// rewrite silently overwrites the agent's work.
|
|
966
|
-
if (typeof body.baseHash === "string") {
|
|
967
|
-
let current = "";
|
|
968
|
-
try {
|
|
969
|
-
current = fs.readFileSync(page.file, "utf8");
|
|
970
|
-
} catch {
|
|
971
|
-
return json(res, 404, { error: "file is gone" });
|
|
972
|
-
}
|
|
973
|
-
if (hash(stripSdk(current)) !== body.baseHash) {
|
|
974
|
-
return json(res, 409, { error: "the file changed on disk since this edit began" });
|
|
975
|
-
}
|
|
976
|
-
}
|
|
1118
|
+
const savingRender = enforceFileWrite(key, body);
|
|
1119
|
+
const editedPolicy = sourcePolicy(page, stripSdk(body.html));
|
|
977
1120
|
try {
|
|
978
1121
|
const clean = writePage(key, body.html);
|
|
1122
|
+
savingRender.sourceHash = hash(clean);
|
|
1123
|
+
savingRender.sourceCapturedAt = new Date().toISOString();
|
|
1124
|
+
if (editedPolicy.savePolicy === "feedback-only") {
|
|
1125
|
+
for (const session of sessionsForKey(key)) {
|
|
1126
|
+
invalidateSessionRender(session);
|
|
1127
|
+
emit(session, "reload", { key, reason: "source-execution-changed" });
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
979
1130
|
return json(res, 200, { savedAt: Date.now(), hash: hash(clean) });
|
|
980
1131
|
} catch (err) {
|
|
981
1132
|
return json(res, 500, { error: String(err.message || err) });
|
|
@@ -984,22 +1135,25 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
984
1135
|
|
|
985
1136
|
if (action === "revert" && req.method === "POST") {
|
|
986
1137
|
const page = store.page(key);
|
|
987
|
-
if (page.kind === "url") return json(res, 400, { error: "
|
|
1138
|
+
if (page.kind === "url" || isMarkdown(page.file)) return json(res, 400, { error: "feedback-only pages have no directly writable HTML to revert" });
|
|
1139
|
+
const body = await readFileWriteBody(req);
|
|
1140
|
+
enforceFileWrite(key, body);
|
|
988
1141
|
if (!page.pristine) return json(res, 400, { error: "nothing to revert to" });
|
|
1142
|
+
sourcePolicy(page, stripSdk(page.pristine));
|
|
989
1143
|
writePage(key, page.pristine);
|
|
990
1144
|
store.clearEdits(key);
|
|
991
1145
|
for (const session of sessionsForKey(key)) {
|
|
992
1146
|
invalidateSessionRender(session);
|
|
993
1147
|
emit(session, "reload", { key });
|
|
994
1148
|
}
|
|
995
|
-
return json(res, 200, { page: pageState(key) });
|
|
1149
|
+
return json(res, 200, { page: pageState(key, sessions.get(body.sessionId)) });
|
|
996
1150
|
}
|
|
997
1151
|
|
|
998
1152
|
if (action === "send" && req.method === "POST") {
|
|
999
1153
|
const body = await readBody(req);
|
|
1000
|
-
const result = sendBatch(body.sessionId, body.note);
|
|
1154
|
+
const result = sendBatch(body.sessionId, body.note, body.history);
|
|
1001
1155
|
if (result.error) return json(res, 400, result);
|
|
1002
|
-
return json(res, 200, {
|
|
1156
|
+
return json(res, 200, { ...result, page: pageState(key, sessions.get(body.sessionId)) });
|
|
1003
1157
|
}
|
|
1004
1158
|
}
|
|
1005
1159
|
|
|
@@ -1058,7 +1212,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1058
1212
|
invalidateSessionRender(session);
|
|
1059
1213
|
session.activeKey = page.key;
|
|
1060
1214
|
session.visited.add(page.key);
|
|
1061
|
-
return json(res, 200, { key: page.key, page: pageState(page.key) });
|
|
1215
|
+
return json(res, 200, { key: page.key, page: pageState(page.key, session) });
|
|
1062
1216
|
}
|
|
1063
1217
|
const targetFile = resolveAsset(from.file, String(body.href || "").split(/[?#]/)[0]);
|
|
1064
1218
|
if (!targetFile || !fs.existsSync(targetFile) || !/\.(x?html?|md|markdown)$/i.test(targetFile)) {
|
|
@@ -1071,7 +1225,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1071
1225
|
invalidateSessionRender(session);
|
|
1072
1226
|
session.activeKey = page.key;
|
|
1073
1227
|
session.visited.add(page.key);
|
|
1074
|
-
return json(res, 200, { key: page.key, page: pageState(page.key) });
|
|
1228
|
+
return json(res, 200, { key: page.key, page: pageState(page.key, session) });
|
|
1075
1229
|
}
|
|
1076
1230
|
|
|
1077
1231
|
// --- server-sent events for one window
|
|
@@ -1136,7 +1290,10 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1136
1290
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
1137
1291
|
return res.end("Not found");
|
|
1138
1292
|
} catch (err) {
|
|
1139
|
-
|
|
1293
|
+
if (err instanceof HistoryRequestError) {
|
|
1294
|
+
return json(res, err.status, { error: err.message, code: err.code, targets: err.targets });
|
|
1295
|
+
}
|
|
1296
|
+
return json(res, historyErrorStatus(err), { error: String(err.message || err), ...(err.code ? { code: err.code } : {}) });
|
|
1140
1297
|
}
|
|
1141
1298
|
});
|
|
1142
1299
|
server.on("connection", (socket) => {
|
|
@@ -1151,8 +1308,19 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1151
1308
|
serverClosed = true;
|
|
1152
1309
|
});
|
|
1153
1310
|
|
|
1311
|
+
function collectHistoryGarbage() {
|
|
1312
|
+
try {
|
|
1313
|
+
const removed = store.collectHistoryGarbage();
|
|
1314
|
+
if (removed.revisions || removed.blobs) console.info("[doc-review]", { event: "history-pruned", ...removed });
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
console.error("[doc-review]", { event: "history-prune-failed", code: error.code || "history_storage_failed" });
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
collectHistoryGarbage();
|
|
1320
|
+
|
|
1154
1321
|
const sweep = setInterval(() => {
|
|
1155
1322
|
const now = Date.now();
|
|
1323
|
+
collectHistoryGarbage();
|
|
1156
1324
|
|
|
1157
1325
|
// A window with no SSE client for a while is closed; forget its session.
|
|
1158
1326
|
for (const [id, session] of sessions) {
|