@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/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
- * Only the nonce-bearing Doc Review SDK may execute in those artifacts;
54
- * authored scripts and inline event handlers remain in the source but stay inert.
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: isMarkdown(page.file) });
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
- return { ok: true };
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
- feedbackOnly: page.kind === "url",
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: page.kind !== "url" && typeof page.pristine === "string" && page.pristine.length > 0,
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, { ok: true });
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
- html = fs.readFileSync(page.file, "utf8");
738
- } catch {
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("File is gone");
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
- extraHeaders = { "content-security-policy": fileReviewCsp(render.capability) };
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
- if (page.kind === "url") {
767
- const stagedPrefix = "__doc_review_paste__/";
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
- return serveFile(res, path.join(stateDir(), "pasted", render.pageKey, name));
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 = page.kind === "url";
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 readBody(req);
960
- if (typeof body.html !== "string" || !body.html.trim()) {
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
- // A save based on an older version of the file must lose, not win:
964
- // otherwise a debounced autosave that lands just after an agent
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: "localhost pages have no directly writable file to revert" });
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, { ok: true, page: pageState(key) });
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
- return json(res, 500, { error: String(err.message || err) });
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) {