@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/README.md +155 -19
- package/package.json +4 -2
- package/src/SKILL.md +56 -7
- package/src/atomic-write.js +53 -0
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +52 -130
- 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/edit-limits.js +23 -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/poll-transport.js +222 -0
- 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 +240 -53
- package/src/setup-guidance.js +128 -0
- package/src/setup.js +41 -15
- package/src/state.js +257 -35
- package/src/view-identity.js +99 -0
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
|
-
*
|
|
53
|
-
* 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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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, {
|
|
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
|
-
|
|
725
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
941
|
-
if (typeof body
|
|
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
|
-
|
|
945
|
-
|
|
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: "
|
|
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, {
|
|
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
|
-
|
|
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) {
|