@erdemtuna/doc-review 0.8.0 → 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
@@ -10,6 +10,10 @@ import { isMarkdown, renderMarkdownPage } from "./markdown.js";
10
10
  import { canonicalTarget, ensureStateDir, localUrl, SERVER_PROTOCOL, serverPath, stateDir, targetKey } from "./paths.js";
11
11
  import { acquireServerLock, releaseServerLock, removeOwnedServerRecord } from "./server-lock.js";
12
12
  import { invocation, shellQuote } from "./setup.js";
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";
13
17
 
14
18
  const here = path.dirname(fileURLToPath(import.meta.url));
15
19
 
@@ -49,8 +53,8 @@ const MAX_LOCAL_PAGE_BYTES = 24 * 1024 * 1024;
49
53
 
50
54
  /**
51
55
  * File reviews may contain agent-generated or otherwise untrusted JavaScript.
52
- * Only the nonce-bearing Doc Review SDK may execute in those artifacts;
53
- * 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.
54
58
  */
55
59
  const fileReviewCsp = (nonce) =>
56
60
  `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`;
@@ -224,8 +228,13 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
224
228
  const current = hash(html);
225
229
  // Our own autosave must never bounce back as a reload.
226
230
  if (lastWritten.get(key) === current) return;
231
+ try {
232
+ store.setPristine(key, html, { keepEdits: true });
233
+ } catch (err) {
234
+ console.error(`Could not refresh review baseline for ${page.file}: ${err.message}`);
235
+ return;
236
+ }
227
237
  lastWritten.set(key, current);
228
- store.setPristine(key, html);
229
238
  for (const session of sessionsForKey(key)) {
230
239
  invalidateSessionRender(session);
231
240
  emit(session, "reload", { key });
@@ -284,6 +293,8 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
284
293
  kind: e.kind,
285
294
  before: e.before,
286
295
  after: e.after,
296
+ ...(e.feedback_only ? { feedback_only: true } : {}),
297
+ ...(e.truncated ? { truncated: true, truncated_fields: e.truncated_fields } : {}),
287
298
  ...(e.before_html !== undefined && e.before_html !== e.before ? { before_html: e.before_html } : {}),
288
299
  ...(e.after_html !== undefined && e.after_html !== e.after ? { after_html: e.after_html } : {}),
289
300
  ...(Array.isArray(e.staged_assets) && e.staged_assets.length ? { staged_assets: e.staged_assets } : {}),
@@ -304,16 +315,19 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
304
315
  }));
305
316
  }
306
317
 
307
- function sendBatch(sessionId, note) {
318
+ function sendBatch(sessionId, note, historyInput) {
308
319
  const session = sessions.get(sessionId);
309
320
  if (!session) return { error: "unknown session" };
310
321
 
311
322
  const pages = collectPages(session);
312
323
  if (!pages.length && !note) return { error: "nothing to send" };
324
+ const historyContext = history.prepareBaseline(session, pages, historyInput);
313
325
 
314
326
  const hasMarkdown = pages.some((p) => p.kind === "file" && isMarkdown(p.file));
315
327
  const hasUrl = pages.some((p) => p.kind === "url");
328
+ const hasTrustedEdits = pages.some((p) => p.edits.some((edit) => edit.feedback_only));
316
329
  const hasCorrections = pages.some((p) => p.comments.some((c) => c.correction));
330
+ const hasTruncation = pages.some((p) => p.edits.some((e) => e.truncated));
317
331
  const id = `b_${crypto.randomBytes(12).toString("hex")}`;
318
332
  const entry = store.page(session.entryKey);
319
333
  const pollTarget = entry?.kind === "url" ? entry.url : entry?.file;
@@ -326,9 +340,14 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
326
340
  sent_at: new Date().toISOString(),
327
341
  next_step:
328
342
  "Apply this feedback. Each entry in `pages` names the reviewed file or localhost URL. Items under `edits` are " +
329
- "changes the human already made: `after` is their exact new wording, so carry it across verbatim, and " +
343
+ "changes the human already made: unless marked `truncated`, `after` is their exact new wording, so carry it across verbatim, and " +
330
344
  "never revert it. When an edit carries `after_html`, the human changed formatting (bold, italic, links) — " +
331
345
  "use the HTML version, translated into the source's own syntax. " +
346
+ (hasTruncation
347
+ ? "Some edits are marked `truncated`; `truncated_fields` lists incomplete fields. Never apply incomplete text or HTML " +
348
+ "as a complete replacement or invent missing content. Recover the full edit only from an authoritative source, " +
349
+ "or ask the user for it. Do not acknowledge this batch until all feedback is handled. "
350
+ : "") +
332
351
  (hasMarkdown
333
352
  ? "Markdown pages were reviewed rendered, so quotes and `after` wording use the rendered text — apply " +
334
353
  "the change to the Markdown source, keeping its formatting syntax. "
@@ -339,6 +358,11 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
339
358
  "When an edit includes `staged_assets`, copy each local image into the app's appropriate asset folder, replace its " +
340
359
  "temporary preview URL in `after_html`, and preserve the image at the user's insertion point. "
341
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
+ : "") +
342
366
  (hasCorrections
343
367
  ? "Comments marked `correction` replace their `correction_of` instruction; follow the correction and do not apply the older wording. "
344
368
  : "") +
@@ -354,10 +378,18 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
354
378
  sentAt: Date.now(),
355
379
  })),
356
380
  };
357
- store.setBatch(session.entryKey, record);
381
+ store.setBatch(session.entryKey, { ...record, ...(historyContext ? { history: historyContext } : {}) });
358
382
  deliver(session.entryKey);
359
383
  broadcastAgent(session.entryKey);
360
- 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
+ };
361
393
  }
362
394
 
363
395
  function deleteStagedAsset(file) {
@@ -382,13 +414,15 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
382
414
  function ack(entryKey, id) {
383
415
  const result = store.acknowledgeBatch(entryKey, id);
384
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;
385
419
  // The JSON transition is already durable. Files are cleanup only and must
386
420
  // never disappear before the receipt and page cleanup commit succeeds.
387
421
  for (const file of result.staged) deleteStagedAsset(file);
388
422
  for (const session of sessionsForEntry(entryKey)) emit(session, "refresh", {});
389
423
  // File targets reload through fs.watch. URL targets have no source file to
390
424
  // watch, so acknowledgement is the signal to fetch the rebuilt route.
391
- for (const key of result.keys) {
425
+ for (const key of new Set([...result.keys, ...(acknowledgedRound?.targets.map((target) => target.key) || [])])) {
392
426
  if (store.page(key)?.kind === "url") {
393
427
  for (const session of sessionsForKey(key)) {
394
428
  invalidateSessionRender(session);
@@ -397,6 +431,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
397
431
  }
398
432
  }
399
433
  broadcastAgent(entryKey);
434
+ history.changed(entryKey, result.roundId);
400
435
  return true;
401
436
  }
402
437
 
@@ -513,6 +548,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
513
548
  const entry = session ? store.page(session.entryKey) : null;
514
549
  const currentTarget = page.kind === "url" ? page.url : page.file;
515
550
  const pollTarget = entry ? (entry.kind === "url" ? entry.url : entry.file) : currentTarget;
551
+ const policy = sourcePolicy(page, undefined, session);
516
552
  return {
517
553
  key: page.key,
518
554
  kind: page.kind === "url" ? "url" : "file",
@@ -520,14 +556,77 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
520
556
  ...(page.kind === "url" ? { url: page.url } : {}),
521
557
  filename: page.kind === "url" ? new URL(page.url).pathname || page.url : path.basename(page.file),
522
558
  markdown: page.kind !== "url" && isMarkdown(page.file),
523
- feedbackOnly: page.kind === "url",
559
+ ...policy,
560
+ ...(page.kind !== "url" && !isMarkdown(page.file)
561
+ ? { executionPreference: session?.executionPreferences?.get(key) || "auto" } : {}),
524
562
  comments: page.comments,
525
563
  edits: page.edits,
526
- 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,
527
565
  pollCommand: `${cliInvocation} poll ${shellQuote(pollTarget)}`,
566
+ historySupported: true,
528
567
  };
529
568
  }
530
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
+
531
630
  const server = http.createServer(async (req, res) => {
532
631
  touch();
533
632
  const url = new URL(req.url, "http://127.0.0.1");
@@ -558,6 +657,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
558
657
  const ok = provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
559
658
  if (!ok) return json(res, 401, { error: "missing or invalid token" });
560
659
  }
660
+ if (await history.handle(req, res, url)) return undefined;
561
661
 
562
662
  // --- static chrome assets
563
663
  if (route === "/chrome.css") return serveFile(res, path.join(here, "chrome.css"));
@@ -574,6 +674,48 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
574
674
  if (route === "/click-target.js") return serveFile(res, path.join(here, "click-target.js"), opaqueModuleCors(req));
575
675
  if (route === "/serialize.js") return serveFile(res, path.join(here, "serialize.js"), opaqueModuleCors(req));
576
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
+ }
577
719
 
578
720
  // --- open a browser session for a file or localhost URL
579
721
  if (route === "/api/session" && req.method === "POST") {
@@ -598,6 +740,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
598
740
  activeKey: page.key,
599
741
  generation: 0,
600
742
  renderId: null,
743
+ executionPreferences: new Map(),
601
744
  visited: new Set([page.key]),
602
745
  clients: new Set(),
603
746
  lastSeen: Date.now(),
@@ -674,8 +817,17 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
674
817
  ) {
675
818
  return json(res, 403, { error: "invalid render capability" });
676
819
  }
820
+ if (render.documentState !== "served") return json(res, 409, { error: "render has not been served" });
677
821
  render.capability = null;
678
- 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
+ });
679
831
  }
680
832
 
681
833
  // --- the reviewed page itself, plus sibling assets for its render
@@ -700,6 +852,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
700
852
  let sdkOptions = {};
701
853
  let extraHeaders = {};
702
854
  if (page.kind === "url") {
855
+ Object.assign(render, sourcePolicy(page));
703
856
  try {
704
857
  const fetched = await fetchLocalPage(page.url);
705
858
  html = fetched.html;
@@ -720,17 +873,22 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
720
873
  return res.end(`Could not load ${page.url}: ${err.message}`);
721
874
  }
722
875
  } else {
876
+ let originalBytes;
723
877
  try {
724
- html = fs.readFileSync(page.file, "utf8");
725
- } catch {
878
+ originalBytes = fs.readFileSync(page.file);
879
+ html = originalBytes.toString("utf8");
880
+ } catch (error) {
726
881
  render.documentState = "registered";
727
- res.writeHead(404, {
882
+ res.writeHead(error.code === "ENOENT" ? 404 : 500, {
728
883
  "content-type": "text/plain",
729
884
  "cache-control": "no-store",
730
885
  "referrer-policy": "no-referrer",
731
886
  });
732
- return res.end("File is gone");
887
+ return res.end(`Could not read document: ${error.message}`);
733
888
  }
889
+ render.sourceHash = hash(stripSdk(html));
890
+ render.sourceCapturedAt = new Date().toISOString();
891
+ Object.assign(render, sourcePolicy(page, originalBytes, sessions.get(render.sessionId)));
734
892
  // Markdown reviews render on the fly; the source file stays untouched.
735
893
  if (isMarkdown(page.file)) html = renderMarkdownPage(html, page.file);
736
894
  sdkOptions = {
@@ -739,7 +897,15 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
739
897
  pageKey: render.pageKey,
740
898
  src: `http://${host}/sdk.js`,
741
899
  };
742
- 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) };
743
909
  }
744
910
  res.writeHead(200, {
745
911
  "content-type": MIME[".html"],
@@ -750,18 +916,15 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
750
916
  render.documentState = "served";
751
917
  return res.end(injectSdk(html, renderId, sdkOptions));
752
918
  }
753
- if (page.kind === "url") {
754
- const stagedPrefix = "__doc_review_paste__/";
755
- if (asset.startsWith(stagedPrefix)) {
919
+ const stagedPrefix = "__doc_review_paste__/";
920
+ if (asset.startsWith(stagedPrefix)) {
756
921
  const name = asset.slice(stagedPrefix.length);
757
- if (!name || path.basename(name) !== name) {
922
+ if (!name || !/^[\w-]+\.(png|jpg|gif|webp)$/.test(name) || path.basename(name) !== name) {
758
923
  res.writeHead(403, { "content-type": "text/plain" });
759
924
  return res.end("Forbidden");
760
- }
761
- return serveFile(res, path.join(stateDir(), "pasted", render.pageKey, name));
762
- }
763
- res.writeHead(404, { "content-type": "text/plain" });
764
- 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));
765
928
  }
766
929
  const target = resolveAsset(page.file, asset.split("?")[0]);
767
930
  if (!target) {
@@ -873,7 +1036,11 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
873
1036
  const body = await readBody(req);
874
1037
  const label = String(body.label || "Document");
875
1038
  const kind = body.kind === "deleted" ? "deleted" : body.kind === "moved" ? "moved" : "edited";
876
- const cap = (s) => (typeof s === "string" ? s.slice(0, 4000) : undefined);
1039
+ const limited = limitEditFields({
1040
+ before: body.before, after: body.after, before_html: body.before_html, after_html: body.after_html,
1041
+ ...(kind === "moved" ? { moved_after: body.moved_after, moved_before: body.moved_before } : {}),
1042
+ });
1043
+ const fields = limited.fields;
877
1044
  const stagedRoot = path.join(stateDir(), "pasted", key);
878
1045
  const stagedAssets = Array.isArray(body.staged_assets)
879
1046
  ? body.staged_assets
@@ -893,11 +1060,14 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
893
1060
  .map(({ path: assetPath, preview_src }) => ({ path: assetPath, preview_src }))
894
1061
  : [];
895
1062
  const extra = {
896
- ...(kind === "moved" ? { moved_after: cap(body.moved_after) || "", moved_before: cap(body.moved_before) || "" } : {}),
1063
+ truncated: limited.truncated,
1064
+ truncated_fields: limited.truncated_fields,
1065
+ ...(kind === "moved" ? { moved_after: fields.moved_after || "", moved_before: fields.moved_before || "" } : {}),
897
1066
  ...(stagedAssets.length ? { staged_assets: stagedAssets } : {}),
1067
+ ...(feedbackOnly(key, body.savePolicy, body) ? { feedback_only: true } : {}),
898
1068
  };
899
- store.addEdit(key, label, kind, cap(body.before), cap(body.after), cap(body.before_html), cap(body.after_html), extra);
900
- return json(res, 200, { page: pageState(key) });
1069
+ store.addEdit(key, label, kind, fields.before, fields.after, fields.before_html, fields.after_html, extra);
1070
+ return json(res, 200, { page: pageState(key, sessions.get(body.sessionId)) });
901
1071
  }
902
1072
 
903
1073
  // File reviews keep pasted images beside the document. Localhost
@@ -909,7 +1079,11 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
909
1079
  if (!ext) return json(res, 400, { error: `unsupported image type: ${type || "unknown"}` });
910
1080
  const bytes = await readRawBody(req);
911
1081
  if (!bytes.length) return json(res, 400, { error: "empty image" });
912
- 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
+ });
913
1087
  const dir = staged ? path.join(stateDir(), "pasted", key) : path.join(path.dirname(page.file), "assets");
914
1088
  fs.mkdirSync(dir, { recursive: true });
915
1089
  const base = staged
@@ -937,26 +1111,22 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
937
1111
  if (page.kind === "url" || isMarkdown(page.file)) {
938
1112
  return json(res, 400, { error: page.kind === "url" ? "localhost edits must be applied to app source" : "markdown pages are feedback-only" });
939
1113
  }
940
- const body = await readBody(req);
941
- if (typeof body.html !== "string" || !body.html.trim()) {
1114
+ const body = await readFileWriteBody(req);
1115
+ if (typeof body?.html !== "string" || !body.html.trim()) {
942
1116
  return json(res, 400, { error: "empty html" });
943
1117
  }
944
- // A save based on an older version of the file must lose, not win:
945
- // otherwise a debounced autosave that lands just after an agent
946
- // rewrite silently overwrites the agent's work.
947
- if (typeof body.baseHash === "string") {
948
- let current = "";
949
- try {
950
- current = fs.readFileSync(page.file, "utf8");
951
- } catch {
952
- return json(res, 404, { error: "file is gone" });
953
- }
954
- if (hash(stripSdk(current)) !== body.baseHash) {
955
- return json(res, 409, { error: "the file changed on disk since this edit began" });
956
- }
957
- }
1118
+ const savingRender = enforceFileWrite(key, body);
1119
+ const editedPolicy = sourcePolicy(page, stripSdk(body.html));
958
1120
  try {
959
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
+ }
960
1130
  return json(res, 200, { savedAt: Date.now(), hash: hash(clean) });
961
1131
  } catch (err) {
962
1132
  return json(res, 500, { error: String(err.message || err) });
@@ -965,22 +1135,25 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
965
1135
 
966
1136
  if (action === "revert" && req.method === "POST") {
967
1137
  const page = store.page(key);
968
- 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);
969
1141
  if (!page.pristine) return json(res, 400, { error: "nothing to revert to" });
1142
+ sourcePolicy(page, stripSdk(page.pristine));
970
1143
  writePage(key, page.pristine);
971
1144
  store.clearEdits(key);
972
1145
  for (const session of sessionsForKey(key)) {
973
1146
  invalidateSessionRender(session);
974
1147
  emit(session, "reload", { key });
975
1148
  }
976
- return json(res, 200, { page: pageState(key) });
1149
+ return json(res, 200, { page: pageState(key, sessions.get(body.sessionId)) });
977
1150
  }
978
1151
 
979
1152
  if (action === "send" && req.method === "POST") {
980
1153
  const body = await readBody(req);
981
- const result = sendBatch(body.sessionId, body.note);
1154
+ const result = sendBatch(body.sessionId, body.note, body.history);
982
1155
  if (result.error) return json(res, 400, result);
983
- return json(res, 200, { ok: true, page: pageState(key) });
1156
+ return json(res, 200, { ...result, page: pageState(key, sessions.get(body.sessionId)) });
984
1157
  }
985
1158
  }
986
1159
 
@@ -1039,7 +1212,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1039
1212
  invalidateSessionRender(session);
1040
1213
  session.activeKey = page.key;
1041
1214
  session.visited.add(page.key);
1042
- return json(res, 200, { key: page.key, page: pageState(page.key) });
1215
+ return json(res, 200, { key: page.key, page: pageState(page.key, session) });
1043
1216
  }
1044
1217
  const targetFile = resolveAsset(from.file, String(body.href || "").split(/[?#]/)[0]);
1045
1218
  if (!targetFile || !fs.existsSync(targetFile) || !/\.(x?html?|md|markdown)$/i.test(targetFile)) {
@@ -1052,7 +1225,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1052
1225
  invalidateSessionRender(session);
1053
1226
  session.activeKey = page.key;
1054
1227
  session.visited.add(page.key);
1055
- return json(res, 200, { key: page.key, page: pageState(page.key) });
1228
+ return json(res, 200, { key: page.key, page: pageState(page.key, session) });
1056
1229
  }
1057
1230
 
1058
1231
  // --- server-sent events for one window
@@ -1117,7 +1290,10 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1117
1290
  res.writeHead(404, { "content-type": "text/plain" });
1118
1291
  return res.end("Not found");
1119
1292
  } catch (err) {
1120
- 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 } : {}) });
1121
1297
  }
1122
1298
  });
1123
1299
  server.on("connection", (socket) => {
@@ -1132,8 +1308,19 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
1132
1308
  serverClosed = true;
1133
1309
  });
1134
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
+
1135
1321
  const sweep = setInterval(() => {
1136
1322
  const now = Date.now();
1323
+ collectHistoryGarbage();
1137
1324
 
1138
1325
  // A window with no SSE client for a while is closed; forget its session.
1139
1326
  for (const [id, session] of sessions) {