@khanglvm/relay 0.14.2 → 0.15.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
@@ -162,17 +162,24 @@ function buildPage(record, rev, { access = null, draftRev = 0 } = {}) {
162
162
  };
163
163
  // Prefill from the LIVE draft at request time (drafts autosave in real time),
164
164
  // so a mid-fill page reload restores everything the user already entered.
165
- const prefill = record.draft
165
+ // Reviewer answers are an independent, reference-only draft. They must never
166
+ // prefill from or overwrite the owner's final-answer draft. Read-only viewers
167
+ // see the owner's current state but cannot mutate it.
168
+ const reviewDrafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
169
+ const sourceDraft = access && access.role === 'review'
170
+ ? (reviewDrafts[access.reviewSessionId] || null)
171
+ : record.draft;
172
+ const prefill = sourceDraft
166
173
  ? {
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 || {},
174
+ answers: sourceDraft.answers || {},
175
+ comment: sourceDraft.comment || '',
176
+ notes: sourceDraft.notes || {},
177
+ annotations: sourceDraft.annotations || [],
178
+ blockEdits: sourceDraft.blockEdits || {},
172
179
  // The server draft's save time, so the client can pick the NEWER of this
173
180
  // vs. its localStorage mirror (a tab that kept typing while the server
174
181
  // was unreachable holds fresher input than the last server save).
175
- updatedAt: record.draft.updatedAt || null,
182
+ updatedAt: sourceDraft.updatedAt || null,
176
183
  }
177
184
  : null;
178
185
  const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev, draftRev, access };
@@ -196,6 +203,45 @@ function buildLockedPage(title = 'Relay board') {
196
203
  '</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
204
  }
198
205
 
206
+ const SHARE_STATE_VERSION = 2;
207
+ const REVIEW_SESSION_COOKIE = 'relay_review_session';
208
+
209
+ function reviewSessionId(req) {
210
+ if (req._relayReviewSessionId) return req._relayReviewSessionId;
211
+ const explicit = String(req.headers['x-relay-review-session'] || '');
212
+ const cookies = String(req.headers.cookie || '').split(';').map((s) => s.trim());
213
+ const cookie = cookies.find((s) => s.startsWith(REVIEW_SESSION_COOKIE + '='));
214
+ let value = explicit || (cookie ? decodeURIComponent(cookie.slice(REVIEW_SESSION_COOKIE.length + 1)) : '');
215
+ if (!/^[A-Za-z0-9_-]{8,80}$/.test(value)) value = 'rv-' + crypto.randomBytes(12).toString('hex');
216
+ req._relayReviewSessionId = value;
217
+ return value;
218
+ }
219
+
220
+ function reviewSessionCookie(sessionId) {
221
+ return `${REVIEW_SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=2592000`;
222
+ }
223
+
224
+ function sideReviewState(record) {
225
+ const drafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
226
+ const submissions = Array.isArray(record.sideReviews) ? record.sideReviews : [];
227
+ return {
228
+ referenceOnly: true,
229
+ note: 'Side reviews are reference only. Wait for the board owner (or owner-authorized collaborator) to submit the final answer.',
230
+ submissions,
231
+ drafts,
232
+ };
233
+ }
234
+
235
+ function pruneReviewDrafts(record, max = 50) {
236
+ const drafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
237
+ const entries = Object.entries(drafts).sort((a, b) => {
238
+ const at = Date.parse(a[1] && a[1].updatedAt || '') || 0;
239
+ const bt = Date.parse(b[1] && b[1].updatedAt || '') || 0;
240
+ return bt - at;
241
+ });
242
+ record.reviewDrafts = Object.fromEntries(entries.slice(0, max));
243
+ }
244
+
199
245
  // Validates + sanitizes one annotation's threaded replies. Keeps only
200
246
  // well-formed {author, text, createdAt} entries; caps at 50; coerces author.
201
247
  function sanitizeReplies(value) {
@@ -231,50 +277,6 @@ function sanitizeAnnotations(value) {
231
277
  return out;
232
278
  }
233
279
 
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
280
  function limitString(v, max) {
279
281
  return typeof v === 'string' ? v.slice(0, max) : '';
280
282
  }
@@ -499,8 +501,8 @@ function sendJson(res, code, obj) {
499
501
  res.end(JSON.stringify(obj));
500
502
  }
501
503
 
502
- function sendHtml(res, body) {
503
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
504
+ function sendHtml(res, body, headers = {}) {
505
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
504
506
  res.end(body);
505
507
  }
506
508
 
@@ -597,14 +599,15 @@ function injectBeforeBodyEnd(html, snippet) {
597
599
  // Custom-HTML fragments (no <html> tag) get wrapped in a minimal document that
598
600
  // matches the user's theme, so e.g. "<b>hi</b>" doesn't paint a stark white
599
601
  // 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);
602
+ // read the ?theme=light|dark query param themselves. The annotate bootstrap is
603
+ // omitted for read-only shares so their iframe content has no false comment
604
+ // affordances; existing feedback remains visible in the parent comments rail.
605
+ function wrapFragment(content, theme, annotate = true) {
606
+ if (/<html[\s>]/i.test(content)) return annotate ? injectBeforeBodyEnd(content, ANNOTATE_BOOTSTRAP) : content;
604
607
  const dark = theme === 'dark';
605
608
  const bg = dark ? '#282624' : '#ffffff';
606
609
  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>`;
610
+ 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
611
  }
609
612
 
610
613
  function readBody(req, limit = 5 * 1024 * 1024) {
@@ -689,8 +692,17 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
689
692
  updatedAt: new Date().toISOString(),
690
693
  };
691
694
  }
692
- record.pastResults = [...(record.pastResults || []), record.result].slice(-10);
695
+ // A soft-timeout result may predate side reviews submitted while the board
696
+ // stayed live. Refresh that reference-only snapshot before archiving so a
697
+ // later reopen does not discard late reviews/drafts from the historical run.
698
+ const archivedResult = { ...record.result, sideReviews: sideReviewState(record) };
699
+ record.pastResults = [...(record.pastResults || []), archivedResult].slice(-10);
693
700
  record.result = null;
701
+ // Side reviews belong to the archived run. A reopened board starts a fresh
702
+ // review round; carrying them forward would present stale reference input
703
+ // as current and duplicate it into the next final result.
704
+ record.sideReviews = [];
705
+ record.reviewDrafts = {};
694
706
  saveBoard(record);
695
707
  }
696
708
 
@@ -699,13 +711,20 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
699
711
  // can authenticate to POST /api/update. Never embedded in the page/boot.
700
712
  const token = crypto.randomBytes(16).toString('hex');
701
713
  const savedShares = record.share && typeof record.share === 'object' ? record.share : {};
714
+ const savedShareVersion = Number(savedShares.version) || 1;
702
715
  const shareTokens = {
703
716
  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'),
717
+ // v1 reviewer links were comments-only. Rotate + deactivate them instead of
718
+ // silently granting answer/side-submit permission after this upgrade.
719
+ review: savedShareVersion >= SHARE_STATE_VERSION && typeof savedShares.review?.token === 'string'
720
+ ? savedShares.review.token
721
+ : crypto.randomBytes(16).toString('hex'),
722
+ read: typeof savedShares.read?.token === 'string' ? savedShares.read.token : crypto.randomBytes(16).toString('hex'),
705
723
  };
706
724
  const activeShares = {
707
725
  collab: savedShares.collab?.active === true,
708
- review: savedShares.review?.active === true,
726
+ review: savedShareVersion >= SHARE_STATE_VERSION && savedShares.review?.active === true,
727
+ read: savedShares.read?.active === true,
709
728
  };
710
729
  let rev = 1;
711
730
  let draftRev = Number.isFinite(record.draftRev) ? record.draftRev : 0;
@@ -716,8 +735,9 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
716
735
  // working and still submit. Surfaced via /api/status so the page can show a
717
736
  // calm "agent stopped waiting" note instead of disconnecting.
718
737
  let softTimedOut = false;
719
- // Latest client presence ping (null until the first ping arrives).
720
- let presence = null;
738
+ // Presence that can lead to the terminal answer. Reviewer/read-only activity
739
+ // must not keep an agent waiting for a submission those roles cannot finalize.
740
+ const presenceByRole = { owner: null, collab: null, review: null, read: null };
721
741
  let actualPort = 0;
722
742
  let url = '';
723
743
  const advertisedHost = shareHost();
@@ -732,6 +752,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
732
752
  canComment: true,
733
753
  canEditComments: true,
734
754
  canDeleteComments: true,
755
+ canEditBlocks: true,
756
+ canFinalize: true,
735
757
  };
736
758
  }
737
759
  const tokenValue = String(req.headers['x-relay-share-token'] || (reqUrl && reqUrl.searchParams.get('token')) || '');
@@ -745,18 +767,37 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
745
767
  canComment: true,
746
768
  canEditComments: true,
747
769
  canDeleteComments: true,
770
+ canEditBlocks: true,
771
+ canFinalize: true,
748
772
  };
749
773
  }
750
774
  if (activeShares.review && tokenValue === shareTokens.review) {
751
775
  return {
752
776
  role: 'review',
753
777
  token: shareTokens.review,
778
+ reviewSessionId: reviewSessionId(req),
779
+ canShare: false,
780
+ canSubmit: true,
781
+ canEditAnswers: true,
782
+ canComment: true,
783
+ canEditComments: false,
784
+ canDeleteComments: false,
785
+ canEditBlocks: false,
786
+ canFinalize: false,
787
+ };
788
+ }
789
+ if (activeShares.read && tokenValue === shareTokens.read) {
790
+ return {
791
+ role: 'read',
792
+ token: shareTokens.read,
754
793
  canShare: false,
755
794
  canSubmit: false,
756
795
  canEditAnswers: false,
757
- canComment: true,
796
+ canComment: false,
758
797
  canEditComments: false,
759
798
  canDeleteComments: false,
799
+ canEditBlocks: false,
800
+ canFinalize: false,
760
801
  };
761
802
  }
762
803
  return {
@@ -767,13 +808,17 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
767
808
  canComment: false,
768
809
  canEditComments: false,
769
810
  canDeleteComments: false,
811
+ canEditBlocks: false,
812
+ canFinalize: false,
770
813
  };
771
814
  };
772
815
  const shareUrlFor = (role) => advertisedHost ? `http://${advertisedHost}:${actualPort}/?share=${role}&token=${shareTokens[role]}` : null;
773
816
  const persistShareState = () => {
774
817
  record.share = {
818
+ version: SHARE_STATE_VERSION,
775
819
  collab: { active: activeShares.collab === true, token: shareTokens.collab },
776
820
  review: { active: activeShares.review === true, token: shareTokens.review },
821
+ read: { active: activeShares.read === true, token: shareTokens.read },
777
822
  updatedAt: new Date().toISOString(),
778
823
  };
779
824
  saveBoard(record);
@@ -782,6 +827,10 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
782
827
  const done = new Promise((r) => {
783
828
  resolveDone = r;
784
829
  });
830
+ const finalizerPresence = () => {
831
+ const candidates = [presenceByRole.owner, presenceByRole.collab].filter(Boolean);
832
+ return candidates.sort((a, b) => b.atMs - a.atMs)[0] || null;
833
+ };
785
834
 
786
835
  const server = http.createServer(async (req, res) => {
787
836
  const reqUrl = new URL(req.url, 'http://localhost');
@@ -793,17 +842,41 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
793
842
  if (access.role === 'locked') {
794
843
  sendHtml(res, buildLockedPage(record.spec.title));
795
844
  } else {
796
- sendHtml(res, buildPage(record, rev, { access, draftRev }));
845
+ const accessDraftRev = access.role === 'review'
846
+ ? Number(record.reviewDrafts?.[access.reviewSessionId]?.draftRev) || 0
847
+ : draftRev;
848
+ const headers = access.role === 'review'
849
+ ? { 'set-cookie': reviewSessionCookie(access.reviewSessionId) }
850
+ : {};
851
+ sendHtml(res, buildPage(record, rev, { access, draftRev: accessDraftRev }), headers);
797
852
  }
798
853
  } 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 });
854
+ const access = accessFor(req, reqUrl);
855
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
856
+ const sourceDraft = access.role === 'review'
857
+ ? (record.reviewDrafts?.[access.reviewSessionId] || null)
858
+ : record.draft;
859
+ sendJson(res, 200, { id: record.id, spec: record.spec, draft: sourceDraft, result: record.result });
801
860
  } else if (req.method === 'GET' && pathname === '/api/status') {
802
- sendJson(res, 200, { status, rev, draftRev, softTimedOut });
861
+ const access = accessFor(req, reqUrl);
862
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
863
+ const accessDraftRev = access.role === 'review'
864
+ ? Number(record.reviewDrafts?.[access.reviewSessionId]?.draftRev) || 0
865
+ : draftRev;
866
+ sendJson(res, 200, { status, rev, draftRev: accessDraftRev, softTimedOut });
803
867
  } 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 });
868
+ const access = accessFor(req, reqUrl);
869
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
870
+ const sourceDraft = access.role === 'review'
871
+ ? (record.reviewDrafts?.[access.reviewSessionId] || null)
872
+ : record.draft;
873
+ const accessDraftRev = access.role === 'review'
874
+ ? Number(sourceDraft?.draftRev) || 0
875
+ : draftRev;
876
+ sendJson(res, 200, { draft: sourceDraft || null, draftRev: accessDraftRev });
806
877
  } else if (req.method === 'POST' && pathname === '/api/ping') {
878
+ const access = accessFor(req, reqUrl);
879
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
807
880
  const body = JSON.parse((await readBody(req)) || '{}');
808
881
  // Validate body shape: visible/focused booleans, idleMs finite >= 0.
809
882
  if (
@@ -812,10 +885,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
812
885
  Number.isFinite(body.idleMs) &&
813
886
  body.idleMs >= 0
814
887
  ) {
815
- presence = { atMs: Date.now(), visible: body.visible, focused: body.focused, idleMs: body.idleMs };
888
+ presenceByRole[access.role] = { atMs: Date.now(), visible: body.visible, focused: body.focused, idleMs: body.idleMs };
816
889
  }
817
890
  sendJson(res, 200, { ok: true });
818
891
  } else if (req.method === 'GET' && pathname === '/api/presence') {
892
+ const presence = finalizerPresence();
819
893
  if (!presence) {
820
894
  sendJson(res, 200, { open: true, seen: false });
821
895
  } else {
@@ -858,7 +932,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
858
932
  const blockId = decodeURIComponent(pathname.slice('/html/b/'.length));
859
933
  const block = findHtmlBlock(record.spec, blockId);
860
934
  if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
861
- sendHtml(res, wrapFragment(block.html || '', theme));
935
+ const access = accessFor(req, reqUrl);
936
+ sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true));
862
937
  } else if (req.method === 'GET' && pathname.startsWith('/img/b/')) {
863
938
  // Embedded image bytes (image blocks authored from local files).
864
939
  const blockId = decodeURIComponent(pathname.slice('/img/b/'.length));
@@ -882,13 +957,15 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
882
957
  } else if (req.method === 'GET' && pathname === '/html/board') {
883
958
  // Legacy alias → the board's first html block.
884
959
  const block = firstBoardHtml(record.spec);
885
- sendHtml(res, wrapFragment((block && block.html) || '', theme));
960
+ const access = accessFor(req, reqUrl);
961
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
886
962
  } else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
887
963
  const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
888
964
  const q = record.spec.questions.find((q) => q.id === qid);
889
965
  if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
890
966
  const block = firstQuestionHtml(q);
891
- sendHtml(res, wrapFragment((block && block.html) || '', theme));
967
+ const access = accessFor(req, reqUrl);
968
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
892
969
  } else if (req.method === 'POST' && pathname === '/api/pref') {
893
970
  const body = JSON.parse((await readBody(req)) || '{}');
894
971
  if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
@@ -923,17 +1000,23 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
923
1000
  } else if (req.method === 'POST' && pathname === '/api/draft') {
924
1001
  const access = accessFor(req, reqUrl);
925
1002
  if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
1003
+ if (!access.canEditAnswers && !access.canComment && !access.canEditBlocks) {
1004
+ return sendJson(res, 403, { error: 'this share is read only' });
1005
+ }
926
1006
  const body = JSON.parse((await readBody(req)) || '{}');
927
1007
  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 : {},
1008
+ record.reviewDrafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
1009
+ const priorRev = Number(record.reviewDrafts[access.reviewSessionId]?.draftRev) || 0;
1010
+ record.reviewDrafts[access.reviewSessionId] = {
1011
+ answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
1012
+ comment: typeof body.comment === 'string' ? body.comment : '',
1013
+ notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1014
+ annotations: sanitizeAnnotations(body.annotations),
1015
+ blockEdits: {},
1016
+ draftRev: priorRev + 1,
935
1017
  updatedAt: new Date().toISOString(),
936
1018
  };
1019
+ pruneReviewDrafts(record);
937
1020
  } else {
938
1021
  record.draft = {
939
1022
  answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
@@ -944,17 +1027,48 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
944
1027
  updatedAt: new Date().toISOString(),
945
1028
  };
946
1029
  }
947
- record.draftRev = ++draftRev;
1030
+ if (access.role !== 'review') record.draftRev = ++draftRev;
948
1031
  saveBoard(record);
949
- sendJson(res, 200, { ok: true, draftRev });
1032
+ const accessDraftRev = access.role === 'review'
1033
+ ? Number(record.reviewDrafts?.[access.reviewSessionId]?.draftRev) || 0
1034
+ : draftRev;
1035
+ sendJson(res, 200, { ok: true, draftRev: accessDraftRev });
950
1036
  } else if (req.method === 'POST' && pathname === '/api/submit') {
951
1037
  const access = accessFor(req, reqUrl);
952
1038
  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' });
1039
+ if (!access.canSubmit) return sendJson(res, 403, { error: 'this share is read only' });
954
1040
  if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
955
1041
  const body = JSON.parse((await readBody(req)) || '{}');
956
1042
  const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
957
1043
  const skipped = record.spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
1044
+ if (access.role === 'review') {
1045
+ const review = {
1046
+ id: 'sr-' + Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex'),
1047
+ status: 'side-review',
1048
+ final: false,
1049
+ referenceOnly: true,
1050
+ reviewSessionId: access.reviewSessionId,
1051
+ answers,
1052
+ skipped,
1053
+ comment: typeof body.comment === 'string' ? body.comment : '',
1054
+ notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
1055
+ annotations: sanitizeAnnotations(body.annotations),
1056
+ blockEdits: {},
1057
+ submittedAt: new Date().toISOString(),
1058
+ };
1059
+ record.sideReviews = [...(Array.isArray(record.sideReviews) ? record.sideReviews : []), review].slice(-50);
1060
+ if (record.reviewDrafts && typeof record.reviewDrafts === 'object') delete record.reviewDrafts[access.reviewSessionId];
1061
+ saveBoard(record);
1062
+ // Deliberately no finish(), record.result write, on-result hook, or
1063
+ // server close: a side review never wakes/completes the waiting agent.
1064
+ return sendJson(res, 200, {
1065
+ ok: true,
1066
+ sideReview: true,
1067
+ final: false,
1068
+ reviewId: review.id,
1069
+ message: 'Saved as a reference-only side review. The board still awaits the owner final answer.',
1070
+ });
1071
+ }
958
1072
  sendJson(res, 200, { ok: true });
959
1073
  finish({
960
1074
  status: record.spec.questions.length ? 'submitted' : 'acknowledged',
@@ -968,7 +1082,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
968
1082
  } else if (req.method === 'GET' && pathname === '/api/share') {
969
1083
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
970
1084
  const roles = {};
971
- for (const role of ['collab', 'review']) {
1085
+ for (const role of ['collab', 'review', 'read']) {
972
1086
  roles[role] = {
973
1087
  active: activeShares[role] === true,
974
1088
  url: activeShares[role] === true ? shareUrlFor(role) : null,
@@ -979,19 +1093,20 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
979
1093
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can activate sharing' });
980
1094
  if (!advertisedHost) return sendJson(res, 400, { error: 'no LAN IPv4 address found for sharing' });
981
1095
  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"' });
1096
+ const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'read' ? 'read' : null;
1097
+ if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", or "read"' });
984
1098
  activeShares[role] = true;
985
1099
  persistShareState();
986
1100
  sendJson(res, 200, { ok: true, role, url: shareUrlFor(role) });
987
1101
  } else if (req.method === 'DELETE' && pathname === '/api/share') {
988
1102
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
989
1103
  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"' });
1104
+ const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'read' ? 'read' : body.role === 'all' ? 'all' : null;
1105
+ if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", "read", or "all"' });
992
1106
  if (role === 'all') {
993
1107
  activeShares.collab = false;
994
1108
  activeShares.review = false;
1109
+ activeShares.read = false;
995
1110
  } else {
996
1111
  activeShares[role] = false;
997
1112
  }
@@ -1090,6 +1205,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1090
1205
  notes: partial.notes ?? (record.draft?.notes || {}),
1091
1206
  annotations: partial.annotations ?? (record.draft?.annotations || []),
1092
1207
  blockEdits,
1208
+ sideReviews: sideReviewState(record),
1093
1209
  createdAt: record.createdAt,
1094
1210
  finishedAt: new Date().toISOString(),
1095
1211
  durationMs: Date.now() - startedAt,
@@ -393,7 +393,7 @@
393
393
 
394
394
  // ---------- hover pin ----------
395
395
  function showPin(entry) {
396
- if (disabled) return;
396
+ if (disabled || !permissions.add) return;
397
397
  clearTimeout(pinTimer);
398
398
  pinEntry = entry;
399
399
  const rect = entry.el.getBoundingClientRect();
@@ -547,6 +547,11 @@
547
547
  function openPopover(info, anchorEl) {
548
548
  if (disabled) return;
549
549
  ensureDom();
550
+ const hasExisting = matching(info).length > 0;
551
+ // Read-only viewers can open existing threads from their badges, but a
552
+ // target with no feedback has nothing to show and must not expose an empty
553
+ // add-comment dialog.
554
+ if (!permissions.add && !hasExisting) return;
550
555
  closePopover();
551
556
  hidePin();
552
557
  const pop = dom.pop;
@@ -586,7 +591,10 @@
586
591
  ta.focus();
587
592
  });
588
593
  // agent replies aren't editable here — only the top-level user comment
589
- const actions = el('div', { class: 'ann-thread-actions' }, a.author === 'user' ? edit : null, del);
594
+ const actions = el('div', { class: 'ann-thread-actions' },
595
+ permissions.edit && a.author === 'user' ? edit : null,
596
+ permissions.delete ? del : null
597
+ );
590
598
  const thread = el('div', { class: 'ann-thread' },
591
599
  el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), actions),
592
600
  textEl
@@ -616,7 +624,7 @@
616
624
  submitReply();
617
625
  }
618
626
  });
619
- thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
627
+ if (permissions.reply) thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
620
628
  existingWrap.append(thread);
621
629
  }
622
630
  };
@@ -633,7 +641,7 @@
633
641
  };
634
642
  save.addEventListener('click', () => popSave && popSave());
635
643
  cancel.addEventListener('click', closePopover);
636
- pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
644
+ if (permissions.add) pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
637
645
 
638
646
  // Position: prefer below the anchor, flip above when out of room,
639
647
  // clamp to the viewport with an 8px margin. Fixed positioning, so we
@@ -652,7 +660,8 @@
652
660
  pop.style.visibility = '';
653
661
  popOpen = true;
654
662
  popScrollY = window.scrollY;
655
- ta.focus();
663
+ if (permissions.add) ta.focus();
664
+ else popClose.focus();
656
665
  }
657
666
 
658
667
  // ---------- mutations ----------
@@ -742,7 +751,7 @@
742
751
  }
743
752
 
744
753
  function maybeShowSelBtn(rootEl, baseInfo) {
745
- if (disabled) return hideSelBtn();
754
+ if (disabled || !permissions.add) return hideSelBtn();
746
755
  const sel = window.getSelection();
747
756
  if (!sel || sel.isCollapsed || !sel.rangeCount) return hideSelBtn();
748
757
  if (!rootEl.contains(sel.anchorNode) || !rootEl.contains(sel.focusNode)) return hideSelBtn();
@@ -821,7 +830,7 @@
821
830
  el('div', { class: 'ann-sum-text' }, a.text),
822
831
  meta.length ? el('div', { class: 'ann-sum-meta' }, meta) : null
823
832
  ),
824
- del
833
+ permissions.delete ? del : null
825
834
  );
826
835
  // Clicking a row jumps to (and flashes) the matching element: a
827
836
  // registered block element, or the inline highlight for a text comment.