@khanglvm/relay 0.14.2 → 0.16.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
@@ -5,13 +5,28 @@ import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { loadBoard, saveBoard, saveRunning, removeRunning, loadPref, savePref } from './store.js';
8
+ import {
9
+ loadBoard,
10
+ saveBoard,
11
+ saveRunning,
12
+ removeRunning,
13
+ loadPref,
14
+ savePref,
15
+ saveBoardArtifact,
16
+ artifactDirPath,
17
+ } from './store.js';
9
18
  import { openUrl } from './open.js';
10
19
  import { assertSpecReady } from './spec.js';
11
20
 
12
21
  const UI_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui');
13
22
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
14
23
  const VENDOR_DIR = path.join(PKG_ROOT, 'vendor');
24
+ const ARTIFACT_MAX_BYTES = 4 * 1024 * 1024;
25
+ const ARTIFACT_MIMES = {
26
+ 'image/png': 'png',
27
+ 'image/jpeg': 'jpg',
28
+ 'image/webp': 'webp',
29
+ };
15
30
 
16
31
  const escapeHtml = (s) =>
17
32
  s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -162,17 +177,24 @@ function buildPage(record, rev, { access = null, draftRev = 0 } = {}) {
162
177
  };
163
178
  // Prefill from the LIVE draft at request time (drafts autosave in real time),
164
179
  // so a mid-fill page reload restores everything the user already entered.
165
- const prefill = record.draft
180
+ // Reviewer answers are an independent, reference-only draft. They must never
181
+ // prefill from or overwrite the owner's final-answer draft. Read-only viewers
182
+ // see the owner's current state but cannot mutate it.
183
+ const reviewDrafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
184
+ const sourceDraft = access && access.role === 'review'
185
+ ? (reviewDrafts[access.reviewSessionId] || null)
186
+ : record.draft;
187
+ const prefill = sourceDraft
166
188
  ? {
167
- answers: record.draft.answers || {},
168
- comment: record.draft.comment || '',
169
- notes: record.draft.notes || {},
170
- annotations: record.draft.annotations || [],
171
- blockEdits: record.draft.blockEdits || {},
189
+ answers: sourceDraft.answers || {},
190
+ comment: sourceDraft.comment || '',
191
+ notes: sourceDraft.notes || {},
192
+ annotations: sourceDraft.annotations || [],
193
+ blockEdits: sourceDraft.blockEdits || {},
172
194
  // The server draft's save time, so the client can pick the NEWER of this
173
195
  // vs. its localStorage mirror (a tab that kept typing while the server
174
196
  // was unreachable holds fresher input than the last server save).
175
- updatedAt: record.draft.updatedAt || null,
197
+ updatedAt: sourceDraft.updatedAt || null,
176
198
  }
177
199
  : null;
178
200
  const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev, draftRev, access };
@@ -196,6 +218,45 @@ function buildLockedPage(title = 'Relay board') {
196
218
  '</head><body><div class="box"><div class="title">Share link not active</div><div class="sub">Ask the board owner to use Share and choose a permission before opening this board from another device.</div></div></body></html>';
197
219
  }
198
220
 
221
+ const SHARE_STATE_VERSION = 2;
222
+ const REVIEW_SESSION_COOKIE = 'relay_review_session';
223
+
224
+ function reviewSessionId(req) {
225
+ if (req._relayReviewSessionId) return req._relayReviewSessionId;
226
+ const explicit = String(req.headers['x-relay-review-session'] || '');
227
+ const cookies = String(req.headers.cookie || '').split(';').map((s) => s.trim());
228
+ const cookie = cookies.find((s) => s.startsWith(REVIEW_SESSION_COOKIE + '='));
229
+ let value = explicit || (cookie ? decodeURIComponent(cookie.slice(REVIEW_SESSION_COOKIE.length + 1)) : '');
230
+ if (!/^[A-Za-z0-9_-]{8,80}$/.test(value)) value = 'rv-' + crypto.randomBytes(12).toString('hex');
231
+ req._relayReviewSessionId = value;
232
+ return value;
233
+ }
234
+
235
+ function reviewSessionCookie(sessionId) {
236
+ return `${REVIEW_SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=2592000`;
237
+ }
238
+
239
+ function sideReviewState(record) {
240
+ const drafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
241
+ const submissions = Array.isArray(record.sideReviews) ? record.sideReviews : [];
242
+ return {
243
+ referenceOnly: true,
244
+ note: 'Side reviews are reference only. Wait for the board owner (or owner-authorized collaborator) to submit the final answer.',
245
+ submissions,
246
+ drafts,
247
+ };
248
+ }
249
+
250
+ function pruneReviewDrafts(record, max = 50) {
251
+ const drafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
252
+ const entries = Object.entries(drafts).sort((a, b) => {
253
+ const at = Date.parse(a[1] && a[1].updatedAt || '') || 0;
254
+ const bt = Date.parse(b[1] && b[1].updatedAt || '') || 0;
255
+ return bt - at;
256
+ });
257
+ record.reviewDrafts = Object.fromEntries(entries.slice(0, max));
258
+ }
259
+
199
260
  // Validates + sanitizes one annotation's threaded replies. Keeps only
200
261
  // well-formed {author, text, createdAt} entries; caps at 50; coerces author.
201
262
  function sanitizeReplies(value) {
@@ -216,7 +277,51 @@ function sanitizeReplies(value) {
216
277
  // Drops anything that isn't a well-formed annotation object; caps at 500.
217
278
  // Each annotation may carry an optional author ('user'|'agent', default
218
279
  // 'user') and a threaded replies array (validated + capped at 50).
219
- function sanitizeAnnotations(value) {
280
+ function sanitizeAnnotationCrop(value, boardId) {
281
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !boardId) return null;
282
+ const rawPath = typeof value.path === 'string' ? value.path : '';
283
+ if (!rawPath) return null;
284
+ const base = path.resolve(artifactDirPath(boardId)) + path.sep;
285
+ const resolved = path.resolve(rawPath);
286
+ if (!resolved.startsWith(base)) return null;
287
+ let stat;
288
+ try { stat = fs.statSync(resolved); } catch { return null; }
289
+ if (!stat.isFile() || stat.size < 1 || stat.size > ARTIFACT_MAX_BYTES) return null;
290
+ const mime = ARTIFACT_MIMES[value.mime] ? value.mime : 'image/png';
291
+ return {
292
+ path: resolved,
293
+ name: path.basename(resolved),
294
+ mime,
295
+ bytes: stat.size,
296
+ width: Math.max(1, Math.min(10000, Number.parseInt(value.width, 10) || 1)),
297
+ height: Math.max(1, Math.min(10000, Number.parseInt(value.height, 10) || 1)),
298
+ };
299
+ }
300
+
301
+ function sanitizeAnnotationTarget(value, boardId) {
302
+ const clean = { ...value };
303
+ delete clean.cropDataUrl;
304
+ if (clean.kind !== 'image-region') {
305
+ delete clean.crop;
306
+ return clean;
307
+ }
308
+ const unit = (v) => Math.max(0, Math.min(1, Number(v) || 0));
309
+ clean.x = unit(clean.x);
310
+ clean.y = unit(clean.y);
311
+ clean.w = unit(clean.w);
312
+ clean.h = unit(clean.h);
313
+ if (clean.x + clean.w > 1) clean.w = 1 - clean.x;
314
+ if (clean.y + clean.h > 1) clean.h = 1 - clean.y;
315
+ clean.label = limitString(clean.label, 200);
316
+ if (clean.side === 'before' || clean.side === 'after') clean.side = clean.side;
317
+ else delete clean.side;
318
+ const crop = sanitizeAnnotationCrop(clean.crop, boardId);
319
+ if (crop) clean.crop = crop;
320
+ else delete clean.crop;
321
+ return clean;
322
+ }
323
+
324
+ function sanitizeAnnotations(value, boardId) {
220
325
  if (!Array.isArray(value)) return [];
221
326
  const out = [];
222
327
  for (const a of value) {
@@ -224,57 +329,17 @@ function sanitizeAnnotations(value) {
224
329
  if (a === null || typeof a !== 'object' || Array.isArray(a)) continue;
225
330
  if (typeof a.text !== 'string' || a.text.length > 5000) continue;
226
331
  if (a.target === null || typeof a.target !== 'object' || Array.isArray(a.target)) continue;
227
- const clean = { ...a, author: a.author === 'agent' ? 'agent' : 'user' };
332
+ const clean = {
333
+ ...a,
334
+ target: sanitizeAnnotationTarget(a.target, boardId),
335
+ author: a.author === 'agent' ? 'agent' : 'user',
336
+ };
228
337
  if (a.replies !== undefined) clean.replies = sanitizeReplies(a.replies);
229
338
  out.push(clean);
230
339
  }
231
340
  return out;
232
341
  }
233
342
 
234
- function nextAnnotationId(existing, used) {
235
- let max = 0;
236
- for (const a of existing) {
237
- const m = /^a(\d+)$/.exec(String(a && a.id || ''));
238
- if (m) max = Math.max(max, Number.parseInt(m[1], 10) || 0);
239
- }
240
- let id;
241
- do {
242
- id = 'a' + (++max);
243
- } while (used.has(id));
244
- used.add(id);
245
- return id;
246
- }
247
-
248
- function sameReply(a, b) {
249
- return a && b && a.author === b.author && a.text === b.text && a.createdAt === b.createdAt;
250
- }
251
-
252
- function mergeReviewAnnotations(existingValue, incomingValue) {
253
- const existing = sanitizeAnnotations(existingValue);
254
- const incoming = sanitizeAnnotations(incomingValue);
255
- const out = existing.map((a) => ({ ...a, replies: Array.isArray(a.replies) ? [...a.replies] : [] }));
256
- const byId = new Map(out.map((a) => [String(a.id || ''), a]));
257
- const used = new Set(out.map((a) => String(a.id || '')).filter(Boolean));
258
- for (const ann of incoming) {
259
- const id = String(ann.id || '');
260
- const current = byId.get(id);
261
- if (!current) {
262
- const next = { ...ann, id: id && !used.has(id) ? id : nextAnnotationId(out, used) };
263
- next.replies = Array.isArray(next.replies) ? next.replies : [];
264
- out.push(next);
265
- byId.set(next.id, next);
266
- used.add(next.id);
267
- continue;
268
- }
269
- const replies = Array.isArray(current.replies) ? current.replies : (current.replies = []);
270
- for (const r of Array.isArray(ann.replies) ? ann.replies : []) {
271
- if (replies.length >= 50) break;
272
- if (!replies.some((x) => sameReply(x, r))) replies.push(r);
273
- }
274
- }
275
- return out.slice(0, 500);
276
- }
277
-
278
343
  function limitString(v, max) {
279
344
  return typeof v === 'string' ? v.slice(0, max) : '';
280
345
  }
@@ -499,8 +564,8 @@ function sendJson(res, code, obj) {
499
564
  res.end(JSON.stringify(obj));
500
565
  }
501
566
 
502
- function sendHtml(res, body) {
503
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
567
+ function sendHtml(res, body, headers = {}) {
568
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
504
569
  res.end(body);
505
570
  }
506
571
 
@@ -597,14 +662,15 @@ function injectBeforeBodyEnd(html, snippet) {
597
662
  // Custom-HTML fragments (no <html> tag) get wrapped in a minimal document that
598
663
  // matches the user's theme, so e.g. "<b>hi</b>" doesn't paint a stark white
599
664
  // block in dark mode. Full documents are served verbatim — their authors can
600
- // read the ?theme=light|dark query param themselves. Either way the annotate
601
- // bootstrap is injected so every element is hover-commentable.
602
- function wrapFragment(content, theme) {
603
- if (/<html[\s>]/i.test(content)) return injectBeforeBodyEnd(content, ANNOTATE_BOOTSTRAP);
665
+ // read the ?theme=light|dark query param themselves. The annotate bootstrap is
666
+ // omitted for read-only shares so their iframe content has no false comment
667
+ // affordances; existing feedback remains visible in the parent comments rail.
668
+ function wrapFragment(content, theme, annotate = true) {
669
+ if (/<html[\s>]/i.test(content)) return annotate ? injectBeforeBodyEnd(content, ANNOTATE_BOOTSTRAP) : content;
604
670
  const dark = theme === 'dark';
605
671
  const bg = dark ? '#282624' : '#ffffff';
606
672
  const fg = dark ? '#edeae4' : '#1c1b19';
607
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}${ANNOTATE_BOOTSTRAP}</body></html>`;
673
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}${annotate ? ANNOTATE_BOOTSTRAP : ''}</body></html>`;
608
674
  }
609
675
 
610
676
  function readBody(req, limit = 5 * 1024 * 1024) {
@@ -625,6 +691,14 @@ function readBody(req, limit = 5 * 1024 * 1024) {
625
691
  });
626
692
  }
627
693
 
694
+ function artifactBytesMatch(mime, bytes) {
695
+ if (!Buffer.isBuffer(bytes) || bytes.length < 4) return false;
696
+ if (mime === 'image/png') return bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
697
+ if (mime === 'image/jpeg') return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9;
698
+ if (mime === 'image/webp') return bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP';
699
+ return false;
700
+ }
701
+
628
702
  // Push-wake: run the agent's own local shell command after a board finishes.
629
703
  // The full result JSON is written to the command's stdin; RLY_BOARD_ID /
630
704
  // RLY_STATUS / RLY_URL are exported. Failures are swallowed (best effort) —
@@ -670,7 +744,7 @@ function runOnResult(cmd, result, { quiet = false } = {}) {
670
744
  // (submitted / acknowledged / timeout / cancelled). The result is also
671
745
  // persisted into the board record so `rly wait` / `rly result` can read it
672
746
  // from another process.
673
- export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, quiet = false, keepAliveOnTimeout = false }) {
747
+ export async function runBoard({ id, port = 0, open = true, timeoutSec = 86400, quiet = false, keepAliveOnTimeout = false }) {
674
748
  const record = loadBoard(id);
675
749
  if (!record) throw new Error(`board ${id} not found`);
676
750
  const spec = record.spec;
@@ -689,8 +763,17 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
689
763
  updatedAt: new Date().toISOString(),
690
764
  };
691
765
  }
692
- record.pastResults = [...(record.pastResults || []), record.result].slice(-10);
766
+ // A soft-timeout result may predate side reviews submitted while the board
767
+ // stayed live. Refresh that reference-only snapshot before archiving so a
768
+ // later reopen does not discard late reviews/drafts from the historical run.
769
+ const archivedResult = { ...record.result, sideReviews: sideReviewState(record) };
770
+ record.pastResults = [...(record.pastResults || []), archivedResult].slice(-10);
693
771
  record.result = null;
772
+ // Side reviews belong to the archived run. A reopened board starts a fresh
773
+ // review round; carrying them forward would present stale reference input
774
+ // as current and duplicate it into the next final result.
775
+ record.sideReviews = [];
776
+ record.reviewDrafts = {};
694
777
  saveBoard(record);
695
778
  }
696
779
 
@@ -699,13 +782,20 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
699
782
  // can authenticate to POST /api/update. Never embedded in the page/boot.
700
783
  const token = crypto.randomBytes(16).toString('hex');
701
784
  const savedShares = record.share && typeof record.share === 'object' ? record.share : {};
785
+ const savedShareVersion = Number(savedShares.version) || 1;
702
786
  const shareTokens = {
703
787
  collab: typeof savedShares.collab?.token === 'string' ? savedShares.collab.token : crypto.randomBytes(16).toString('hex'),
704
- review: typeof savedShares.review?.token === 'string' ? savedShares.review.token : crypto.randomBytes(16).toString('hex'),
788
+ // v1 reviewer links were comments-only. Rotate + deactivate them instead of
789
+ // silently granting answer/side-submit permission after this upgrade.
790
+ review: savedShareVersion >= SHARE_STATE_VERSION && typeof savedShares.review?.token === 'string'
791
+ ? savedShares.review.token
792
+ : crypto.randomBytes(16).toString('hex'),
793
+ read: typeof savedShares.read?.token === 'string' ? savedShares.read.token : crypto.randomBytes(16).toString('hex'),
705
794
  };
706
795
  const activeShares = {
707
796
  collab: savedShares.collab?.active === true,
708
- review: savedShares.review?.active === true,
797
+ review: savedShareVersion >= SHARE_STATE_VERSION && savedShares.review?.active === true,
798
+ read: savedShares.read?.active === true,
709
799
  };
710
800
  let rev = 1;
711
801
  let draftRev = Number.isFinite(record.draftRev) ? record.draftRev : 0;
@@ -716,8 +806,9 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
716
806
  // working and still submit. Surfaced via /api/status so the page can show a
717
807
  // calm "agent stopped waiting" note instead of disconnecting.
718
808
  let softTimedOut = false;
719
- // Latest client presence ping (null until the first ping arrives).
720
- let presence = null;
809
+ // Presence that can lead to the terminal answer. Reviewer/read-only activity
810
+ // must not keep an agent waiting for a submission those roles cannot finalize.
811
+ const presenceByRole = { owner: null, collab: null, review: null, read: null };
721
812
  let actualPort = 0;
722
813
  let url = '';
723
814
  const advertisedHost = shareHost();
@@ -732,6 +823,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
732
823
  canComment: true,
733
824
  canEditComments: true,
734
825
  canDeleteComments: true,
826
+ canEditBlocks: true,
827
+ canFinalize: true,
735
828
  };
736
829
  }
737
830
  const tokenValue = String(req.headers['x-relay-share-token'] || (reqUrl && reqUrl.searchParams.get('token')) || '');
@@ -745,18 +838,37 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
745
838
  canComment: true,
746
839
  canEditComments: true,
747
840
  canDeleteComments: true,
841
+ canEditBlocks: true,
842
+ canFinalize: true,
748
843
  };
749
844
  }
750
845
  if (activeShares.review && tokenValue === shareTokens.review) {
751
846
  return {
752
847
  role: 'review',
753
848
  token: shareTokens.review,
849
+ reviewSessionId: reviewSessionId(req),
850
+ canShare: false,
851
+ canSubmit: true,
852
+ canEditAnswers: true,
853
+ canComment: true,
854
+ canEditComments: false,
855
+ canDeleteComments: false,
856
+ canEditBlocks: false,
857
+ canFinalize: false,
858
+ };
859
+ }
860
+ if (activeShares.read && tokenValue === shareTokens.read) {
861
+ return {
862
+ role: 'read',
863
+ token: shareTokens.read,
754
864
  canShare: false,
755
865
  canSubmit: false,
756
866
  canEditAnswers: false,
757
- canComment: true,
867
+ canComment: false,
758
868
  canEditComments: false,
759
869
  canDeleteComments: false,
870
+ canEditBlocks: false,
871
+ canFinalize: false,
760
872
  };
761
873
  }
762
874
  return {
@@ -767,13 +879,17 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
767
879
  canComment: false,
768
880
  canEditComments: false,
769
881
  canDeleteComments: false,
882
+ canEditBlocks: false,
883
+ canFinalize: false,
770
884
  };
771
885
  };
772
886
  const shareUrlFor = (role) => advertisedHost ? `http://${advertisedHost}:${actualPort}/?share=${role}&token=${shareTokens[role]}` : null;
773
887
  const persistShareState = () => {
774
888
  record.share = {
889
+ version: SHARE_STATE_VERSION,
775
890
  collab: { active: activeShares.collab === true, token: shareTokens.collab },
776
891
  review: { active: activeShares.review === true, token: shareTokens.review },
892
+ read: { active: activeShares.read === true, token: shareTokens.read },
777
893
  updatedAt: new Date().toISOString(),
778
894
  };
779
895
  saveBoard(record);
@@ -782,6 +898,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
782
898
  const done = new Promise((r) => {
783
899
  resolveDone = r;
784
900
  });
901
+ const finalizerPresence = () => {
902
+ const candidates = [presenceByRole.owner, presenceByRole.collab].filter(Boolean);
903
+ return candidates.sort((a, b) => b.atMs - a.atMs)[0] || null;
904
+ };
785
905
 
786
906
  const server = http.createServer(async (req, res) => {
787
907
  const reqUrl = new URL(req.url, 'http://localhost');
@@ -793,17 +913,41 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
793
913
  if (access.role === 'locked') {
794
914
  sendHtml(res, buildLockedPage(record.spec.title));
795
915
  } else {
796
- sendHtml(res, buildPage(record, rev, { access, draftRev }));
916
+ const accessDraftRev = access.role === 'review'
917
+ ? Number(record.reviewDrafts?.[access.reviewSessionId]?.draftRev) || 0
918
+ : draftRev;
919
+ const headers = access.role === 'review'
920
+ ? { 'set-cookie': reviewSessionCookie(access.reviewSessionId) }
921
+ : {};
922
+ sendHtml(res, buildPage(record, rev, { access, draftRev: accessDraftRev }), headers);
797
923
  }
798
924
  } else if (req.method === 'GET' && pathname === '/api/board') {
799
- if (accessFor(req, reqUrl).role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
800
- sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
925
+ const access = accessFor(req, reqUrl);
926
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
927
+ const sourceDraft = access.role === 'review'
928
+ ? (record.reviewDrafts?.[access.reviewSessionId] || null)
929
+ : record.draft;
930
+ sendJson(res, 200, { id: record.id, spec: record.spec, draft: sourceDraft, result: record.result });
801
931
  } else if (req.method === 'GET' && pathname === '/api/status') {
802
- sendJson(res, 200, { status, rev, draftRev, softTimedOut });
932
+ const access = accessFor(req, reqUrl);
933
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
934
+ const accessDraftRev = access.role === 'review'
935
+ ? Number(record.reviewDrafts?.[access.reviewSessionId]?.draftRev) || 0
936
+ : draftRev;
937
+ sendJson(res, 200, { status, rev, draftRev: accessDraftRev, softTimedOut });
803
938
  } else if (req.method === 'GET' && pathname === '/api/draft') {
804
- if (accessFor(req, reqUrl).role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
805
- sendJson(res, 200, { draft: record.draft || null, draftRev });
939
+ const access = accessFor(req, reqUrl);
940
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
941
+ const sourceDraft = access.role === 'review'
942
+ ? (record.reviewDrafts?.[access.reviewSessionId] || null)
943
+ : record.draft;
944
+ const accessDraftRev = access.role === 'review'
945
+ ? Number(sourceDraft?.draftRev) || 0
946
+ : draftRev;
947
+ sendJson(res, 200, { draft: sourceDraft || null, draftRev: accessDraftRev });
806
948
  } else if (req.method === 'POST' && pathname === '/api/ping') {
949
+ const access = accessFor(req, reqUrl);
950
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
807
951
  const body = JSON.parse((await readBody(req)) || '{}');
808
952
  // Validate body shape: visible/focused booleans, idleMs finite >= 0.
809
953
  if (
@@ -812,10 +956,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
812
956
  Number.isFinite(body.idleMs) &&
813
957
  body.idleMs >= 0
814
958
  ) {
815
- presence = { atMs: Date.now(), visible: body.visible, focused: body.focused, idleMs: body.idleMs };
959
+ presenceByRole[access.role] = { atMs: Date.now(), visible: body.visible, focused: body.focused, idleMs: body.idleMs };
816
960
  }
817
961
  sendJson(res, 200, { ok: true });
818
962
  } else if (req.method === 'GET' && pathname === '/api/presence') {
963
+ const presence = finalizerPresence();
819
964
  if (!presence) {
820
965
  sendJson(res, 200, { open: true, seen: false });
821
966
  } else {
@@ -858,7 +1003,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
858
1003
  const blockId = decodeURIComponent(pathname.slice('/html/b/'.length));
859
1004
  const block = findHtmlBlock(record.spec, blockId);
860
1005
  if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
861
- sendHtml(res, wrapFragment(block.html || '', theme));
1006
+ const access = accessFor(req, reqUrl);
1007
+ sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true && record.spec.responseRequired !== false));
862
1008
  } else if (req.method === 'GET' && pathname.startsWith('/img/b/')) {
863
1009
  // Embedded image bytes (image blocks authored from local files).
864
1010
  const blockId = decodeURIComponent(pathname.slice('/img/b/'.length));
@@ -867,6 +1013,32 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
867
1013
  if (!m) return sendJson(res, 404, { error: `no embedded image block "${blockId}"` });
868
1014
  res.writeHead(200, { 'content-type': m[1], 'cache-control': 'no-store' });
869
1015
  res.end(Buffer.from(m[2], 'base64'));
1016
+ } else if (req.method === 'POST' && pathname === '/api/artifact') {
1017
+ if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not collect feedback' });
1018
+ if (!sameOrigin(req, actualPort, [advertisedHost])) return sendJson(res, 403, { error: 'cross-origin artifact upload rejected' });
1019
+ const access = accessFor(req, reqUrl);
1020
+ if (!access.canComment) return sendJson(res, 403, { error: 'this share cannot add comments' });
1021
+ const body = JSON.parse((await readBody(req, 6 * 1024 * 1024)) || '{}');
1022
+ const blockId = typeof body.blockId === 'string' ? body.blockId : '';
1023
+ const block = findBlock(record.spec, blockId, 'image') || findBlock(record.spec, blockId, 'compare');
1024
+ if (!block) return sendJson(res, 404, { error: `no image or comparison block "${blockId}"` });
1025
+ const match = typeof body.dataUrl === 'string'
1026
+ ? body.dataUrl.match(/^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/)
1027
+ : null;
1028
+ if (!match || !ARTIFACT_MIMES[match[1]]) return sendJson(res, 400, { error: 'artifact must be a PNG, JPEG, or WebP data URL' });
1029
+ const bytes = Buffer.from(match[2], 'base64');
1030
+ if (bytes.length < 1 || bytes.length > ARTIFACT_MAX_BYTES) return sendJson(res, 400, { error: 'artifact image is empty or larger than 4 MB' });
1031
+ if (!artifactBytesMatch(match[1], bytes)) return sendJson(res, 400, { error: 'artifact bytes do not match the declared image type' });
1032
+ const target = saveBoardArtifact(record.id, bytes, ARTIFACT_MIMES[match[1]]);
1033
+ sendJson(res, 200, {
1034
+ ok: true,
1035
+ path: target,
1036
+ name: path.basename(target),
1037
+ mime: match[1],
1038
+ bytes: bytes.length,
1039
+ width: Math.max(1, Math.min(10000, Number.parseInt(body.width, 10) || 1)),
1040
+ height: Math.max(1, Math.min(10000, Number.parseInt(body.height, 10) || 1)),
1041
+ });
870
1042
  } else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/video/b/')) {
871
1043
  // Local video bytes, Range-streamed so the <video> element can seek.
872
1044
  const blockId = decodeURIComponent(pathname.slice('/video/b/'.length));
@@ -882,13 +1054,15 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
882
1054
  } else if (req.method === 'GET' && pathname === '/html/board') {
883
1055
  // Legacy alias → the board's first html block.
884
1056
  const block = firstBoardHtml(record.spec);
885
- sendHtml(res, wrapFragment((block && block.html) || '', theme));
1057
+ const access = accessFor(req, reqUrl);
1058
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true && record.spec.responseRequired !== false));
886
1059
  } else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
887
1060
  const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
888
1061
  const q = record.spec.questions.find((q) => q.id === qid);
889
1062
  if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
890
1063
  const block = firstQuestionHtml(q);
891
- sendHtml(res, wrapFragment((block && block.html) || '', theme));
1064
+ const access = accessFor(req, reqUrl);
1065
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true && record.spec.responseRequired !== false));
892
1066
  } else if (req.method === 'POST' && pathname === '/api/pref') {
893
1067
  const body = JSON.parse((await readBody(req)) || '{}');
894
1068
  if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
@@ -921,40 +1095,79 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
921
1095
  if (!openUrl(target)) return sendJson(res, 500, { error: 'could not open the file' });
922
1096
  sendJson(res, 200, { ok: true, path: target, name: path.basename(target) });
923
1097
  } else if (req.method === 'POST' && pathname === '/api/draft') {
1098
+ if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not collect feedback' });
924
1099
  const access = accessFor(req, reqUrl);
925
1100
  if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
1101
+ if (!access.canEditAnswers && !access.canComment && !access.canEditBlocks) {
1102
+ return sendJson(res, 403, { error: 'this share is read only' });
1103
+ }
926
1104
  const body = JSON.parse((await readBody(req)) || '{}');
927
1105
  if (access.role === 'review') {
928
- const current = record.draft && typeof record.draft === 'object' ? record.draft : {};
929
- record.draft = {
930
- answers: current.answers && typeof current.answers === 'object' ? current.answers : {},
931
- comment: typeof current.comment === 'string' ? current.comment : '',
932
- notes: current.notes && typeof current.notes === 'object' ? current.notes : {},
933
- annotations: mergeReviewAnnotations(current.annotations, body.annotations),
934
- blockEdits: current.blockEdits && typeof current.blockEdits === 'object' ? current.blockEdits : {},
1106
+ record.reviewDrafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
1107
+ const priorRev = Number(record.reviewDrafts[access.reviewSessionId]?.draftRev) || 0;
1108
+ record.reviewDrafts[access.reviewSessionId] = {
1109
+ answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
1110
+ comment: typeof body.comment === 'string' ? body.comment : '',
1111
+ notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1112
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1113
+ blockEdits: {},
1114
+ draftRev: priorRev + 1,
935
1115
  updatedAt: new Date().toISOString(),
936
1116
  };
1117
+ pruneReviewDrafts(record);
937
1118
  } else {
938
1119
  record.draft = {
939
1120
  answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
940
1121
  comment: typeof body.comment === 'string' ? body.comment : '',
941
1122
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
942
- annotations: sanitizeAnnotations(body.annotations),
1123
+ annotations: sanitizeAnnotations(body.annotations, record.id),
943
1124
  blockEdits: sanitizeBlockEdits(body.blockEdits),
944
1125
  updatedAt: new Date().toISOString(),
945
1126
  };
946
1127
  }
947
- record.draftRev = ++draftRev;
1128
+ if (access.role !== 'review') record.draftRev = ++draftRev;
948
1129
  saveBoard(record);
949
- sendJson(res, 200, { ok: true, draftRev });
1130
+ const accessDraftRev = access.role === 'review'
1131
+ ? Number(record.reviewDrafts?.[access.reviewSessionId]?.draftRev) || 0
1132
+ : draftRev;
1133
+ sendJson(res, 200, { ok: true, draftRev: accessDraftRev });
950
1134
  } else if (req.method === 'POST' && pathname === '/api/submit') {
1135
+ if (record.spec.responseRequired === false) return sendJson(res, 409, { error: 'display-only boards do not accept submissions' });
951
1136
  const access = accessFor(req, reqUrl);
952
1137
  if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
953
- if (!access.canSubmit) return sendJson(res, 403, { error: 'this share can comment only' });
1138
+ if (!access.canSubmit) return sendJson(res, 403, { error: 'this share is read only' });
954
1139
  if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
955
1140
  const body = JSON.parse((await readBody(req)) || '{}');
956
1141
  const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
957
1142
  const skipped = record.spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
1143
+ if (access.role === 'review') {
1144
+ const review = {
1145
+ id: 'sr-' + Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex'),
1146
+ status: 'side-review',
1147
+ final: false,
1148
+ referenceOnly: true,
1149
+ reviewSessionId: access.reviewSessionId,
1150
+ answers,
1151
+ skipped,
1152
+ comment: typeof body.comment === 'string' ? body.comment : '',
1153
+ notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1154
+ annotations: sanitizeAnnotations(body.annotations, record.id),
1155
+ blockEdits: {},
1156
+ submittedAt: new Date().toISOString(),
1157
+ };
1158
+ record.sideReviews = [...(Array.isArray(record.sideReviews) ? record.sideReviews : []), review].slice(-50);
1159
+ if (record.reviewDrafts && typeof record.reviewDrafts === 'object') delete record.reviewDrafts[access.reviewSessionId];
1160
+ saveBoard(record);
1161
+ // Deliberately no finish(), record.result write, on-result hook, or
1162
+ // server close: a side review never wakes/completes the waiting agent.
1163
+ return sendJson(res, 200, {
1164
+ ok: true,
1165
+ sideReview: true,
1166
+ final: false,
1167
+ reviewId: review.id,
1168
+ message: 'Saved as a reference-only side review. The board still awaits the owner final answer.',
1169
+ });
1170
+ }
958
1171
  sendJson(res, 200, { ok: true });
959
1172
  finish({
960
1173
  status: record.spec.questions.length ? 'submitted' : 'acknowledged',
@@ -962,13 +1175,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
962
1175
  skipped,
963
1176
  comment: typeof body.comment === 'string' ? body.comment : '',
964
1177
  notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
965
- annotations: sanitizeAnnotations(body.annotations),
1178
+ annotations: sanitizeAnnotations(body.annotations, record.id),
966
1179
  blockEdits: sanitizeBlockEdits(body.blockEdits),
967
1180
  });
968
1181
  } else if (req.method === 'GET' && pathname === '/api/share') {
969
1182
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
970
1183
  const roles = {};
971
- for (const role of ['collab', 'review']) {
1184
+ for (const role of ['collab', 'review', 'read']) {
972
1185
  roles[role] = {
973
1186
  active: activeShares[role] === true,
974
1187
  url: activeShares[role] === true ? shareUrlFor(role) : null,
@@ -979,19 +1192,20 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
979
1192
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can activate sharing' });
980
1193
  if (!advertisedHost) return sendJson(res, 400, { error: 'no LAN IPv4 address found for sharing' });
981
1194
  const body = JSON.parse((await readBody(req)) || '{}');
982
- const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : null;
983
- if (!role) return sendJson(res, 400, { error: 'role must be "collab" or "review"' });
1195
+ const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'read' ? 'read' : null;
1196
+ if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", or "read"' });
984
1197
  activeShares[role] = true;
985
1198
  persistShareState();
986
1199
  sendJson(res, 200, { ok: true, role, url: shareUrlFor(role) });
987
1200
  } else if (req.method === 'DELETE' && pathname === '/api/share') {
988
1201
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
989
1202
  const body = JSON.parse((await readBody(req)) || '{}');
990
- const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'all' ? 'all' : null;
991
- if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", or "all"' });
1203
+ const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'read' ? 'read' : body.role === 'all' ? 'all' : null;
1204
+ if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", "read", or "all"' });
992
1205
  if (role === 'all') {
993
1206
  activeShares.collab = false;
994
1207
  activeShares.review = false;
1208
+ activeShares.read = false;
995
1209
  } else {
996
1210
  activeShares[role] = false;
997
1211
  }
@@ -1090,6 +1304,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1090
1304
  notes: partial.notes ?? (record.draft?.notes || {}),
1091
1305
  annotations: partial.annotations ?? (record.draft?.annotations || []),
1092
1306
  blockEdits,
1307
+ sideReviews: sideReviewState(record),
1093
1308
  createdAt: record.createdAt,
1094
1309
  finishedAt: new Date().toISOString(),
1095
1310
  durationMs: Date.now() - startedAt,