@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/README.md +3 -2
- package/docs/AGENT.md +14 -9
- package/package.json +1 -1
- package/skills/relay/SKILL.md +11 -5
- package/src/cli.js +20 -10
- package/src/server.js +205 -89
- package/src/ui/annotate.js +16 -7
- package/src/ui/app.js +77 -32
- package/src/ui/blocks.css +5 -3
- package/src/ui/blocks.js +113 -20
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
|
-
|
|
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:
|
|
168
|
-
comment:
|
|
169
|
-
notes:
|
|
170
|
-
annotations:
|
|
171
|
-
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:
|
|
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.
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
720
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
800
|
-
sendJson(res,
|
|
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
|
-
|
|
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
|
-
|
|
805
|
-
sendJson(res,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
929
|
-
record.
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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,
|
package/src/ui/annotate.js
CHANGED
|
@@ -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' },
|
|
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.
|