@khanglvm/relay 0.14.1 → 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
 
@@ -698,11 +710,22 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
698
710
  // Mutation token: only `rly update` (which reads the running-file record)
699
711
  // can authenticate to POST /api/update. Never embedded in the page/boot.
700
712
  const token = crypto.randomBytes(16).toString('hex');
713
+ const savedShares = record.share && typeof record.share === 'object' ? record.share : {};
714
+ const savedShareVersion = Number(savedShares.version) || 1;
701
715
  const shareTokens = {
702
- collab: crypto.randomBytes(16).toString('hex'),
703
- review: crypto.randomBytes(16).toString('hex'),
716
+ collab: typeof savedShares.collab?.token === 'string' ? savedShares.collab.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'),
723
+ };
724
+ const activeShares = {
725
+ collab: savedShares.collab?.active === true,
726
+ review: savedShareVersion >= SHARE_STATE_VERSION && savedShares.review?.active === true,
727
+ read: savedShares.read?.active === true,
704
728
  };
705
- const activeShares = { collab: false, review: false };
706
729
  let rev = 1;
707
730
  let draftRev = Number.isFinite(record.draftRev) ? record.draftRev : 0;
708
731
  let status = 'open';
@@ -712,8 +735,9 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
712
735
  // working and still submit. Surfaced via /api/status so the page can show a
713
736
  // calm "agent stopped waiting" note instead of disconnecting.
714
737
  let softTimedOut = false;
715
- // Latest client presence ping (null until the first ping arrives).
716
- 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 };
717
741
  let actualPort = 0;
718
742
  let url = '';
719
743
  const advertisedHost = shareHost();
@@ -728,6 +752,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
728
752
  canComment: true,
729
753
  canEditComments: true,
730
754
  canDeleteComments: true,
755
+ canEditBlocks: true,
756
+ canFinalize: true,
731
757
  };
732
758
  }
733
759
  const tokenValue = String(req.headers['x-relay-share-token'] || (reqUrl && reqUrl.searchParams.get('token')) || '');
@@ -741,18 +767,37 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
741
767
  canComment: true,
742
768
  canEditComments: true,
743
769
  canDeleteComments: true,
770
+ canEditBlocks: true,
771
+ canFinalize: true,
744
772
  };
745
773
  }
746
774
  if (activeShares.review && tokenValue === shareTokens.review) {
747
775
  return {
748
776
  role: 'review',
749
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,
750
793
  canShare: false,
751
794
  canSubmit: false,
752
795
  canEditAnswers: false,
753
- canComment: true,
796
+ canComment: false,
754
797
  canEditComments: false,
755
798
  canDeleteComments: false,
799
+ canEditBlocks: false,
800
+ canFinalize: false,
756
801
  };
757
802
  }
758
803
  return {
@@ -763,13 +808,29 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
763
808
  canComment: false,
764
809
  canEditComments: false,
765
810
  canDeleteComments: false,
811
+ canEditBlocks: false,
812
+ canFinalize: false,
766
813
  };
767
814
  };
768
815
  const shareUrlFor = (role) => advertisedHost ? `http://${advertisedHost}:${actualPort}/?share=${role}&token=${shareTokens[role]}` : null;
816
+ const persistShareState = () => {
817
+ record.share = {
818
+ version: SHARE_STATE_VERSION,
819
+ collab: { active: activeShares.collab === true, token: shareTokens.collab },
820
+ review: { active: activeShares.review === true, token: shareTokens.review },
821
+ read: { active: activeShares.read === true, token: shareTokens.read },
822
+ updatedAt: new Date().toISOString(),
823
+ };
824
+ saveBoard(record);
825
+ };
769
826
  let resolveDone;
770
827
  const done = new Promise((r) => {
771
828
  resolveDone = r;
772
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
+ };
773
834
 
774
835
  const server = http.createServer(async (req, res) => {
775
836
  const reqUrl = new URL(req.url, 'http://localhost');
@@ -781,17 +842,41 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
781
842
  if (access.role === 'locked') {
782
843
  sendHtml(res, buildLockedPage(record.spec.title));
783
844
  } else {
784
- 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);
785
852
  }
786
853
  } else if (req.method === 'GET' && pathname === '/api/board') {
787
- if (accessFor(req, reqUrl).role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
788
- 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 });
789
860
  } else if (req.method === 'GET' && pathname === '/api/status') {
790
- 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 });
791
867
  } else if (req.method === 'GET' && pathname === '/api/draft') {
792
- if (accessFor(req, reqUrl).role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
793
- 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 });
794
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' });
795
880
  const body = JSON.parse((await readBody(req)) || '{}');
796
881
  // Validate body shape: visible/focused booleans, idleMs finite >= 0.
797
882
  if (
@@ -800,10 +885,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
800
885
  Number.isFinite(body.idleMs) &&
801
886
  body.idleMs >= 0
802
887
  ) {
803
- 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 };
804
889
  }
805
890
  sendJson(res, 200, { ok: true });
806
891
  } else if (req.method === 'GET' && pathname === '/api/presence') {
892
+ const presence = finalizerPresence();
807
893
  if (!presence) {
808
894
  sendJson(res, 200, { open: true, seen: false });
809
895
  } else {
@@ -846,7 +932,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
846
932
  const blockId = decodeURIComponent(pathname.slice('/html/b/'.length));
847
933
  const block = findHtmlBlock(record.spec, blockId);
848
934
  if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
849
- sendHtml(res, wrapFragment(block.html || '', theme));
935
+ const access = accessFor(req, reqUrl);
936
+ sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true));
850
937
  } else if (req.method === 'GET' && pathname.startsWith('/img/b/')) {
851
938
  // Embedded image bytes (image blocks authored from local files).
852
939
  const blockId = decodeURIComponent(pathname.slice('/img/b/'.length));
@@ -870,13 +957,15 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
870
957
  } else if (req.method === 'GET' && pathname === '/html/board') {
871
958
  // Legacy alias → the board's first html block.
872
959
  const block = firstBoardHtml(record.spec);
873
- sendHtml(res, wrapFragment((block && block.html) || '', theme));
960
+ const access = accessFor(req, reqUrl);
961
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
874
962
  } else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
875
963
  const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
876
964
  const q = record.spec.questions.find((q) => q.id === qid);
877
965
  if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
878
966
  const block = firstQuestionHtml(q);
879
- sendHtml(res, wrapFragment((block && block.html) || '', theme));
967
+ const access = accessFor(req, reqUrl);
968
+ sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
880
969
  } else if (req.method === 'POST' && pathname === '/api/pref') {
881
970
  const body = JSON.parse((await readBody(req)) || '{}');
882
971
  if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
@@ -911,17 +1000,23 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
911
1000
  } else if (req.method === 'POST' && pathname === '/api/draft') {
912
1001
  const access = accessFor(req, reqUrl);
913
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
+ }
914
1006
  const body = JSON.parse((await readBody(req)) || '{}');
915
1007
  if (access.role === 'review') {
916
- const current = record.draft && typeof record.draft === 'object' ? record.draft : {};
917
- record.draft = {
918
- answers: current.answers && typeof current.answers === 'object' ? current.answers : {},
919
- comment: typeof current.comment === 'string' ? current.comment : '',
920
- notes: current.notes && typeof current.notes === 'object' ? current.notes : {},
921
- annotations: mergeReviewAnnotations(current.annotations, body.annotations),
922
- 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,
923
1017
  updatedAt: new Date().toISOString(),
924
1018
  };
1019
+ pruneReviewDrafts(record);
925
1020
  } else {
926
1021
  record.draft = {
927
1022
  answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
@@ -932,17 +1027,48 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
932
1027
  updatedAt: new Date().toISOString(),
933
1028
  };
934
1029
  }
935
- record.draftRev = ++draftRev;
1030
+ if (access.role !== 'review') record.draftRev = ++draftRev;
936
1031
  saveBoard(record);
937
- 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 });
938
1036
  } else if (req.method === 'POST' && pathname === '/api/submit') {
939
1037
  const access = accessFor(req, reqUrl);
940
1038
  if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
941
- 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' });
942
1040
  if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
943
1041
  const body = JSON.parse((await readBody(req)) || '{}');
944
1042
  const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
945
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
+ }
946
1072
  sendJson(res, 200, { ok: true });
947
1073
  finish({
948
1074
  status: record.spec.questions.length ? 'submitted' : 'acknowledged',
@@ -956,7 +1082,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
956
1082
  } else if (req.method === 'GET' && pathname === '/api/share') {
957
1083
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
958
1084
  const roles = {};
959
- for (const role of ['collab', 'review']) {
1085
+ for (const role of ['collab', 'review', 'read']) {
960
1086
  roles[role] = {
961
1087
  active: activeShares[role] === true,
962
1088
  url: activeShares[role] === true ? shareUrlFor(role) : null,
@@ -967,21 +1093,24 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
967
1093
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can activate sharing' });
968
1094
  if (!advertisedHost) return sendJson(res, 400, { error: 'no LAN IPv4 address found for sharing' });
969
1095
  const body = JSON.parse((await readBody(req)) || '{}');
970
- const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : null;
971
- 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"' });
972
1098
  activeShares[role] = true;
1099
+ persistShareState();
973
1100
  sendJson(res, 200, { ok: true, role, url: shareUrlFor(role) });
974
1101
  } else if (req.method === 'DELETE' && pathname === '/api/share') {
975
1102
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
976
1103
  const body = JSON.parse((await readBody(req)) || '{}');
977
- const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'all' ? 'all' : null;
978
- 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"' });
979
1106
  if (role === 'all') {
980
1107
  activeShares.collab = false;
981
1108
  activeShares.review = false;
1109
+ activeShares.read = false;
982
1110
  } else {
983
1111
  activeShares[role] = false;
984
1112
  }
1113
+ persistShareState();
985
1114
  sendJson(res, 200, { ok: true, role, active: false });
986
1115
  } else {
987
1116
  sendJson(res, 404, { error: 'not found' });
@@ -1040,15 +1169,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1040
1169
  });
1041
1170
 
1042
1171
  let timer = null;
1043
- let idleTimer = null;
1044
1172
  // The detached server's timeout is SOFT (keepAliveOnTimeout): at the deadline
1045
1173
  // we hand a `timeout` result back to the waiting agent (so `rly wait` returns
1046
1174
  // with the autosaved draft) but keep the server listening, so the user can
1047
1175
  // keep working and still submit. A late submit overwrites the result with
1048
1176
  // `submitted` and re-fires the push-wake; the board only truly closes on
1049
- // submit, an explicit stop, or once the user has clearly left (idle
1050
- // watchdog). A BLOCKING `rly ask` has no separate waiter to hand back to, so
1051
- // its timeout stays hard (close + resolve, exit 2).
1177
+ // submit or an explicit stop. A BLOCKING `rly ask` has no separate waiter to
1178
+ // hand back to, so its timeout stays hard (close + resolve, exit 2).
1052
1179
  if (timeoutSec > 0) {
1053
1180
  timer = setTimeout(keepAliveOnTimeout ? softTimeout : () => finish({ status: 'timeout' }), timeoutSec * 1000);
1054
1181
  }
@@ -1078,6 +1205,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1078
1205
  notes: partial.notes ?? (record.draft?.notes || {}),
1079
1206
  annotations: partial.annotations ?? (record.draft?.annotations || []),
1080
1207
  blockEdits,
1208
+ sideReviews: sideReviewState(record),
1081
1209
  createdAt: record.createdAt,
1082
1210
  finishedAt: new Date().toISOString(),
1083
1211
  durationMs: Date.now() - startedAt,
@@ -1097,7 +1225,6 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1097
1225
  function closeServer(result) {
1098
1226
  removeRunning(record.id);
1099
1227
  if (timer) clearTimeout(timer);
1100
- if (idleTimer) clearInterval(idleTimer);
1101
1228
  process.removeListener('SIGINT', onSignal);
1102
1229
  process.removeListener('SIGTERM', onSignal);
1103
1230
  setTimeout(() => {
@@ -1131,30 +1258,6 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1131
1258
  softTimedOut = true;
1132
1259
  const result = persistResult({ status: 'timeout' });
1133
1260
  runOnResult(record.onResult, result, { quiet });
1134
- startIdleWatchdog();
1135
- }
1136
-
1137
- // After a soft timeout, close the board for real once the user has clearly
1138
- // left (no presence ping for IDLE_CLOSE_MS) or a hard absolute cap is hit, so
1139
- // an abandoned board doesn't keep a server alive forever. Re-persists the
1140
- // latest draft as the final `timeout` result; no double push-wake.
1141
- function startIdleWatchdog() {
1142
- const IDLE_CLOSE_MS = 15 * 60 * 1000;
1143
- const HARD_CAP_MS = 6 * 60 * 60 * 1000;
1144
- const softAt = Date.now();
1145
- idleTimer = setInterval(() => {
1146
- if (finished) {
1147
- clearInterval(idleTimer);
1148
- return;
1149
- }
1150
- const lastSeen = presence ? presence.atMs : softAt;
1151
- if (Date.now() - lastSeen > IDLE_CLOSE_MS || Date.now() - softAt > HARD_CAP_MS) {
1152
- finished = true;
1153
- clearInterval(idleTimer);
1154
- closeServer(persistResult({ status: 'timeout' }));
1155
- }
1156
- }, 60 * 1000);
1157
- idleTimer.unref?.();
1158
1261
  }
1159
1262
 
1160
1263
  if (open) openUrl(url);
@@ -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.