@khanglvm/relay 0.13.8 → 0.14.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
@@ -96,7 +96,46 @@ function vendorPresent(file) {
96
96
  }
97
97
  }
98
98
 
99
- function buildPage(record, rev) {
99
+ function cleanShareHost(raw) {
100
+ const s = String(raw || '').trim();
101
+ if (!s) return null;
102
+ let host = s;
103
+ try {
104
+ host = s.includes('://') ? new URL(s).hostname : s;
105
+ } catch {
106
+ host = s;
107
+ }
108
+ host = host.replace(/^https?:\/\//, '').split('/')[0].split(':')[0].trim();
109
+ if (!host || host === '127.0.0.1' || host === 'localhost') return null;
110
+ return host;
111
+ }
112
+
113
+ function isPrivateIpv4(address) {
114
+ if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(address)) return false;
115
+ const parts = address.split('.').map((n) => Number(n));
116
+ if (parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
117
+ return (
118
+ parts[0] === 10 ||
119
+ (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
120
+ (parts[0] === 192 && parts[1] === 168)
121
+ );
122
+ }
123
+
124
+ function shareHost() {
125
+ const forced = cleanShareHost(process.env.RLY_SHARE_HOST);
126
+ if (forced) return forced;
127
+ const candidates = [];
128
+ const nets = os.networkInterfaces();
129
+ for (const list of Object.values(nets)) {
130
+ for (const net of list || []) {
131
+ if (!net || net.internal || net.family !== 'IPv4') continue;
132
+ candidates.push(net.address);
133
+ }
134
+ }
135
+ return candidates.find(isPrivateIpv4) || candidates[0] || null;
136
+ }
137
+
138
+ function buildPage(record, rev, { access = null, draftRev = 0 } = {}) {
100
139
  const html = fs.readFileSync(path.join(UI_DIR, 'index.html'), 'utf8');
101
140
  const css = readUi('style.css');
102
141
  const blocksCss = readUi('blocks.css');
@@ -136,7 +175,7 @@ function buildPage(record, rev) {
136
175
  updatedAt: record.draft.updatedAt || null,
137
176
  }
138
177
  : null;
139
- const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
178
+ const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev, draftRev, access };
140
179
  const json = JSON.stringify(boot).replace(/</g, '\\u003c');
141
180
  return html
142
181
  .split('__TITLE__').join(escapeHtml(spec.title))
@@ -149,6 +188,14 @@ function buildPage(record, rev) {
149
188
  .split('__BOOT_JSON__').join(json);
150
189
  }
151
190
 
191
+ function buildLockedPage(title = 'Relay board') {
192
+ return '<!doctype html><html><head><meta charset="utf-8">' +
193
+ '<meta name="viewport" content="width=device-width, initial-scale=1">' +
194
+ '<title>' + escapeHtml(title) + '</title>' +
195
+ '<style>body{margin:0;font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#fcfbf9;color:#1c1b19;display:grid;min-height:100vh;place-items:center}.box{max-width:420px;padding:28px;text-align:center}.title{font-size:22px;font-weight:650;margin-bottom:8px}.sub{color:#57534e}</style>' +
196
+ '</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
+ }
198
+
152
199
  // Validates + sanitizes one annotation's threaded replies. Keeps only
153
200
  // well-formed {author, text, createdAt} entries; caps at 50; coerces author.
154
201
  function sanitizeReplies(value) {
@@ -184,6 +231,50 @@ function sanitizeAnnotations(value) {
184
231
  return out;
185
232
  }
186
233
 
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
+
187
278
  function limitString(v, max) {
188
279
  return typeof v === 'string' ? v.slice(0, max) : '';
189
280
  }
@@ -218,9 +309,40 @@ function sanitizeConflictResolution(value) {
218
309
  return Object.keys(out.resolutions).length ? out : null;
219
310
  }
220
311
 
312
+ function sanitizeDiffReview(value) {
313
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
314
+ if (value.type !== 'diff-review') return null;
315
+ const out = {
316
+ type: 'diff-review',
317
+ reviewKind: limitString(value.reviewKind, 80),
318
+ commit: limitString(value.commit, 120),
319
+ title: limitString(value.title, 500),
320
+ resolved: value.resolved === true,
321
+ hunks: {},
322
+ };
323
+ const raw = value.hunks && typeof value.hunks === 'object' && !Array.isArray(value.hunks)
324
+ ? value.hunks
325
+ : {};
326
+ let n = 0;
327
+ for (const id of Object.keys(raw)) {
328
+ if (n >= 500) break;
329
+ const h = raw[id];
330
+ if (!h || typeof h !== 'object' || Array.isArray(h)) continue;
331
+ const choice = limitString(h.choice, 30);
332
+ if (!['apply', 'skip', 'hold'].includes(choice)) continue;
333
+ out.hunks[limitString(id, 80)] = {
334
+ choice,
335
+ file: limitString(h.file, 1200),
336
+ header: limitString(h.header, 500),
337
+ };
338
+ n++;
339
+ }
340
+ return Object.keys(out.hunks).length ? out : null;
341
+ }
342
+
221
343
  // Validates + sanitizes an incoming blockEdits map (from draft/submit).
222
344
  // String values are editable Mermaid source. Structured git-conflict-resolution
223
- // values carry per-hunk choices plus resolved file content. Caps prevent a draft
345
+ // and diff-review values carry bounded per-hunk choices. Caps prevent a draft
224
346
  // from bloating board storage; invalid entries are dropped.
225
347
  function sanitizeBlockEdits(value) {
226
348
  if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
@@ -234,7 +356,7 @@ function sanitizeBlockEdits(value) {
234
356
  if (v.length > 20000) continue;
235
357
  out[key] = v;
236
358
  } else {
237
- const edit = sanitizeConflictResolution(v);
359
+ const edit = sanitizeConflictResolution(v) || sanitizeDiffReview(v);
238
360
  if (!edit) continue;
239
361
  out[key] = edit;
240
362
  }
@@ -359,12 +481,13 @@ function buildOpenAllowlist(spec, baseCwd) {
359
481
  // True when an Origin header (if present) belongs to this board's own server.
360
482
  // Same-origin fetches send no Origin or our own; a foreign Origin is a
361
483
  // cross-site POST and must not be allowed to open a local file.
362
- function sameOrigin(req, port) {
484
+ function sameOrigin(req, port, extraHosts = []) {
363
485
  const origin = req.headers.origin;
364
486
  if (!origin) return true;
365
487
  try {
366
- const h = new URL(origin).host;
367
- return h === `127.0.0.1:${port}` || h === `localhost:${port}`;
488
+ const u = new URL(origin);
489
+ if (u.port !== String(port)) return false;
490
+ return new Set(['127.0.0.1', 'localhost', ...extraHosts.filter(Boolean)]).has(u.hostname);
368
491
  } catch {
369
492
  return false;
370
493
  }
@@ -575,7 +698,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
575
698
  // Mutation token: only `rly update` (which reads the running-file record)
576
699
  // can authenticate to POST /api/update. Never embedded in the page/boot.
577
700
  const token = crypto.randomBytes(16).toString('hex');
701
+ const shareTokens = {
702
+ collab: crypto.randomBytes(16).toString('hex'),
703
+ review: crypto.randomBytes(16).toString('hex'),
704
+ };
705
+ const activeShares = { collab: false, review: false };
578
706
  let rev = 1;
707
+ let draftRev = Number.isFinite(record.draftRev) ? record.draftRev : 0;
579
708
  let status = 'open';
580
709
  let finished = false;
581
710
  // Soft timeout: the board's time is up and a `timeout` result was handed back
@@ -585,6 +714,58 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
585
714
  let softTimedOut = false;
586
715
  // Latest client presence ping (null until the first ping arrives).
587
716
  let presence = null;
717
+ let actualPort = 0;
718
+ let url = '';
719
+ const advertisedHost = shareHost();
720
+ const accessFor = (req, reqUrl = null) => {
721
+ const host = String(req.headers.host || '').split(':')[0];
722
+ if (host === '127.0.0.1' || host === 'localhost') {
723
+ return {
724
+ role: 'owner',
725
+ canShare: true,
726
+ canSubmit: true,
727
+ canEditAnswers: true,
728
+ canComment: true,
729
+ canEditComments: true,
730
+ canDeleteComments: true,
731
+ };
732
+ }
733
+ const tokenValue = String(req.headers['x-relay-share-token'] || (reqUrl && reqUrl.searchParams.get('token')) || '');
734
+ if (activeShares.collab && tokenValue === shareTokens.collab) {
735
+ return {
736
+ role: 'collab',
737
+ token: shareTokens.collab,
738
+ canShare: false,
739
+ canSubmit: true,
740
+ canEditAnswers: true,
741
+ canComment: true,
742
+ canEditComments: true,
743
+ canDeleteComments: true,
744
+ };
745
+ }
746
+ if (activeShares.review && tokenValue === shareTokens.review) {
747
+ return {
748
+ role: 'review',
749
+ token: shareTokens.review,
750
+ canShare: false,
751
+ canSubmit: false,
752
+ canEditAnswers: false,
753
+ canComment: true,
754
+ canEditComments: false,
755
+ canDeleteComments: false,
756
+ };
757
+ }
758
+ return {
759
+ role: 'locked',
760
+ canShare: false,
761
+ canSubmit: false,
762
+ canEditAnswers: false,
763
+ canComment: false,
764
+ canEditComments: false,
765
+ canDeleteComments: false,
766
+ };
767
+ };
768
+ const shareUrlFor = (role) => advertisedHost ? `http://${advertisedHost}:${actualPort}/?share=${role}&token=${shareTokens[role]}` : null;
588
769
  let resolveDone;
589
770
  const done = new Promise((r) => {
590
771
  resolveDone = r;
@@ -596,11 +777,20 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
596
777
  const theme = reqUrl.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
597
778
  try {
598
779
  if (req.method === 'GET' && pathname === '/') {
599
- sendHtml(res, buildPage(record, rev));
780
+ const access = accessFor(req, reqUrl);
781
+ if (access.role === 'locked') {
782
+ sendHtml(res, buildLockedPage(record.spec.title));
783
+ } else {
784
+ sendHtml(res, buildPage(record, rev, { access, draftRev }));
785
+ }
600
786
  } 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' });
601
788
  sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
602
789
  } else if (req.method === 'GET' && pathname === '/api/status') {
603
- sendJson(res, 200, { status, rev, softTimedOut });
790
+ sendJson(res, 200, { status, rev, draftRev, softTimedOut });
791
+ } 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 });
604
794
  } else if (req.method === 'POST' && pathname === '/api/ping') {
605
795
  const body = JSON.parse((await readBody(req)) || '{}');
606
796
  // Validate body shape: visible/focused booleans, idleMs finite >= 0.
@@ -697,7 +887,9 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
697
887
  } else if (req.method === 'POST' && pathname === '/api/open') {
698
888
  // Open a board-referenced local file in the OS default app. Guarded by
699
889
  // a same-origin check + an allowlist of paths the board actually links.
700
- if (!sameOrigin(req, actualPort)) return sendJson(res, 403, { error: 'cross-origin requests cannot open files' });
890
+ if (!sameOrigin(req, actualPort, [advertisedHost])) return sendJson(res, 403, { error: 'cross-origin requests cannot open files' });
891
+ const access = accessFor(req, reqUrl);
892
+ if (access.role !== 'owner' && access.role !== 'collab') return sendJson(res, 403, { error: 'this share cannot open local files' });
701
893
  const body = JSON.parse((await readBody(req)) || '{}');
702
894
  const raw = typeof body.path === 'string' ? body.path : '';
703
895
  if (!raw.trim()) return sendJson(res, 400, { error: 'missing "path"' });
@@ -717,18 +909,36 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
717
909
  if (!openUrl(target)) return sendJson(res, 500, { error: 'could not open the file' });
718
910
  sendJson(res, 200, { ok: true, path: target, name: path.basename(target) });
719
911
  } else if (req.method === 'POST' && pathname === '/api/draft') {
912
+ const access = accessFor(req, reqUrl);
913
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
720
914
  const body = JSON.parse((await readBody(req)) || '{}');
721
- record.draft = {
722
- answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
723
- comment: typeof body.comment === 'string' ? body.comment : '',
724
- notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
725
- annotations: sanitizeAnnotations(body.annotations),
726
- blockEdits: sanitizeBlockEdits(body.blockEdits),
727
- updatedAt: new Date().toISOString(),
728
- };
915
+ 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 : {},
923
+ updatedAt: new Date().toISOString(),
924
+ };
925
+ } else {
926
+ record.draft = {
927
+ answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
928
+ comment: typeof body.comment === 'string' ? body.comment : '',
929
+ notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
930
+ annotations: sanitizeAnnotations(body.annotations),
931
+ blockEdits: sanitizeBlockEdits(body.blockEdits),
932
+ updatedAt: new Date().toISOString(),
933
+ };
934
+ }
935
+ record.draftRev = ++draftRev;
729
936
  saveBoard(record);
730
- sendJson(res, 200, { ok: true });
937
+ sendJson(res, 200, { ok: true, draftRev });
731
938
  } else if (req.method === 'POST' && pathname === '/api/submit') {
939
+ const access = accessFor(req, reqUrl);
940
+ 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' });
732
942
  if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
733
943
  const body = JSON.parse((await readBody(req)) || '{}');
734
944
  const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
@@ -743,6 +953,36 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
743
953
  annotations: sanitizeAnnotations(body.annotations),
744
954
  blockEdits: sanitizeBlockEdits(body.blockEdits),
745
955
  });
956
+ } else if (req.method === 'GET' && pathname === '/api/share') {
957
+ if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
958
+ const roles = {};
959
+ for (const role of ['collab', 'review']) {
960
+ roles[role] = {
961
+ active: activeShares[role] === true,
962
+ url: activeShares[role] === true ? shareUrlFor(role) : null,
963
+ };
964
+ }
965
+ sendJson(res, 200, { ok: true, roles });
966
+ } else if (req.method === 'POST' && pathname === '/api/share') {
967
+ if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can activate sharing' });
968
+ if (!advertisedHost) return sendJson(res, 400, { error: 'no LAN IPv4 address found for sharing' });
969
+ 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"' });
972
+ activeShares[role] = true;
973
+ sendJson(res, 200, { ok: true, role, url: shareUrlFor(role) });
974
+ } else if (req.method === 'DELETE' && pathname === '/api/share') {
975
+ if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
976
+ 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"' });
979
+ if (role === 'all') {
980
+ activeShares.collab = false;
981
+ activeShares.review = false;
982
+ } else {
983
+ activeShares[role] = false;
984
+ }
985
+ sendJson(res, 200, { ok: true, role, active: false });
746
986
  } else {
747
987
  sendJson(res, 404, { error: 'not found' });
748
988
  }
@@ -773,15 +1013,15 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
773
1013
  }
774
1014
  };
775
1015
  server.once('error', onErr);
776
- server.listen(p, '127.0.0.1', () => {
1016
+ server.listen(p, '0.0.0.0', () => {
777
1017
  server.removeListener('error', onErr);
778
1018
  resolve();
779
1019
  });
780
1020
  };
781
1021
  bind(port, true);
782
1022
  });
783
- const actualPort = server.address().port;
784
- const url = `http://127.0.0.1:${actualPort}/`;
1023
+ actualPort = server.address().port;
1024
+ url = `http://127.0.0.1:${actualPort}/`;
785
1025
  // Remember the port this board last bound, so `rly rescue <id>` can re-serve
786
1026
  // on the SAME port — letting a still-open (but disconnected) browser tab
787
1027
  // reconnect to its relative /api/* URLs without the user touching anything.
package/src/spec.js CHANGED
@@ -492,6 +492,10 @@ function normalizeBlock(rawBlock, id, cwd, where) {
492
492
  const block = { id, type: 'diff', diff };
493
493
  if (rawBlock.lang !== undefined) block.lang = asStr(rawBlock.lang);
494
494
  if (rawBlock.filename !== undefined) block.filename = asStr(rawBlock.filename);
495
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
496
+ if (rawBlock.review === true) block.review = true;
497
+ if (rawBlock.reviewKind !== undefined) block.reviewKind = asStr(rawBlock.reviewKind);
498
+ if (rawBlock.commit !== undefined) block.commit = asStr(rawBlock.commit);
495
499
  const view = asStr(rawBlock.view).trim().toLowerCase();
496
500
  if (view === 'split' || view === 'unified') block.view = view;
497
501
  if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
@@ -1035,6 +1039,9 @@ const BLOCK_SCHEMA = {
1035
1039
  diff: { type: 'string', description: 'diff: a unified diff (git diff / diff -u output) — rendered as a colored, line-numbered comparison with +added / −removed / context rows and file/hunk headers. No git needed; just write/paste the diff text.' },
1036
1040
  diffFile: { type: 'string', description: 'diff: path to a local file containing a unified diff (alternative to "diff"). Resolved against the CWD.' },
1037
1041
  view: { type: 'string', enum: ['unified', 'split'], description: 'diff: initial layout — "unified" (default, one column) or "split" (side-by-side old vs new). The viewer also has a live toggle either way.' },
1042
+ review: { type: 'boolean', description: 'diff: when true, render per-hunk Apply / Skip / Hold controls and return choices in result.blockEdits[blockId] as {type:"diff-review", hunks:{...}}. Used by `rly git cherry-pick --code`.' },
1043
+ reviewKind: { type: 'string', description: 'diff review: optional label for the review workflow, such as "cherry-pick" or "pick".' },
1044
+ commit: { type: 'string', description: 'diff review: optional commit SHA associated with this diff.' },
1038
1045
  content: { type: 'string', description: 'git-conflict: inline file content containing conflict markers (<<<<<<< / ======= / >>>>>>>). The board auto-detects each hunk and returns resolutions in result.blockEdits[blockId].' },
1039
1046
  text: { type: 'string', description: 'git-conflict: alias for inline "content".' },
1040
1047
  file: { type: 'string', description: 'git-conflict: local conflicted file path to load, parse, and resolve. Resolved against the CWD.' },
@@ -1179,7 +1186,7 @@ export const SPEC_SCHEMA = {
1179
1186
  type: 'object',
1180
1187
  readOnly: true,
1181
1188
  description:
1182
- 'Returned in the result (not part of the input spec). A map blockId→edited block payload. Editable mermaid blocks return edited source strings. git-conflict blocks return {type:"git-conflict-resolution", resolutions, content, resolved, filename, file}. null when the user made no edits.',
1189
+ 'Returned in the result (not part of the input spec). A map blockId→edited block payload. Editable mermaid blocks return edited source strings. git-conflict blocks return {type:"git-conflict-resolution", resolutions, content, resolved, filename, file}. diff review blocks return {type:"diff-review", hunks, resolved, commit}. null when the user made no edits.',
1183
1190
  },
1184
1191
  },
1185
1192
  anyOf: [{ required: ['questions'] }, { required: ['blocks'] }, { required: ['html'] }, { required: ['htmlFile'] }],
@@ -114,6 +114,7 @@
114
114
  // Frozen by the host (e.g. relay's connection-lost block): suppress every way
115
115
  // to START a comment, so the user can't type feedback that won't be saved.
116
116
  let disabled = false;
117
+ let permissions = { add: true, edit: true, delete: true, reply: true };
117
118
  let badgeTimer = 0;
118
119
  let railOpen = false;
119
120
 
@@ -513,6 +514,7 @@
513
514
  try { delConfirmSkip = localStorage.getItem(DEL_SKIP_KEY) === '1'; } catch { /* sandbox: no storage */ }
514
515
 
515
516
  function requestDelete(id, after) {
517
+ if (!permissions.delete) return;
516
518
  const done = () => { removeAnnotation(id); if (after) after(); };
517
519
  if (delConfirmSkip) { done(); return; }
518
520
  showDeleteConfirm(done);
@@ -655,6 +657,7 @@
655
657
 
656
658
  // ---------- mutations ----------
657
659
  function addAnnotation(info, text) {
660
+ if (!permissions.add) return;
658
661
  annotations.push({
659
662
  id: 'a' + (++idCounter),
660
663
  questionId: info.questionId !== undefined ? info.questionId : null,
@@ -687,6 +690,7 @@
687
690
  // Append a user reply to a top-level annotation's thread (cap 50, like the
688
691
  // server). Empty text is ignored by callers.
689
692
  function addReply(id, text) {
693
+ if (!permissions.reply) return;
690
694
  const a = annotations.find((x) => x.id === id);
691
695
  if (!a) return;
692
696
  if (!Array.isArray(a.replies)) a.replies = [];
@@ -701,6 +705,7 @@
701
705
 
702
706
  // Edit a top-level comment's text in place (author edits their own comment).
703
707
  function editAnnotation(id, text) {
708
+ if (!permissions.edit) return;
704
709
  const a = annotations.find((x) => x.id === id);
705
710
  if (!a) return;
706
711
  const t = String(text).slice(0, 5000).trim();
@@ -710,6 +715,7 @@
710
715
  }
711
716
 
712
717
  function removeAnnotation(id) {
718
+ if (!permissions.delete) return;
713
719
  const i = annotations.findIndex((a) => a.id === id);
714
720
  if (i < 0) return;
715
721
  const removed = annotations[i];
@@ -844,11 +850,9 @@
844
850
  }
845
851
 
846
852
  // ---------- public API ----------
847
- function init(opts = {}) {
848
- ensureDom();
849
- // Normalize threads: missing author -> 'user', missing replies -> [].
850
- annotations = Array.isArray(opts.initial)
851
- ? opts.initial.map((a) => ({
853
+ function normalizeList(value) {
854
+ return Array.isArray(value)
855
+ ? value.map((a) => ({
852
856
  ...a,
853
857
  author: a.author === 'agent' ? 'agent' : 'user',
854
858
  replies: Array.isArray(a.replies)
@@ -862,12 +866,28 @@
862
866
  : [],
863
867
  }))
864
868
  : [];
865
- onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
869
+ }
870
+
871
+ function resetCounter() {
866
872
  idCounter = 0;
867
873
  for (const a of annotations) {
868
874
  const m = /^a(\d+)$/.exec(String(a.id || ''));
869
875
  if (m) idCounter = Math.max(idCounter, parseInt(m[1], 10));
870
876
  }
877
+ }
878
+
879
+ function init(opts = {}) {
880
+ ensureDom();
881
+ // Normalize threads: missing author -> 'user', missing replies -> [].
882
+ annotations = normalizeList(opts.initial);
883
+ onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
884
+ permissions = {
885
+ add: !(opts.permissions && opts.permissions.add === false),
886
+ edit: !(opts.permissions && opts.permissions.edit === false),
887
+ delete: !(opts.permissions && opts.permissions.delete === false),
888
+ reply: !(opts.permissions && opts.permissions.reply === false),
889
+ };
890
+ resetCounter();
871
891
  scheduleBadgeRefresh();
872
892
  renderSummaries();
873
893
  refreshRailChrome();
@@ -938,6 +958,14 @@
938
958
  return annotations.slice();
939
959
  }
940
960
 
961
+ function setList(next) {
962
+ annotations = normalizeList(next);
963
+ resetCounter();
964
+ scheduleBadgeRefresh();
965
+ renderSummaries();
966
+ refreshRailChrome();
967
+ }
968
+
941
969
  // True while the user is mid-comment: the popover is open, which hosts both
942
970
  // the "Add a comment" box and every thread's reply input. That text lives
943
971
  // only in the DOM until Save, so the board must not reload it away.
@@ -968,5 +996,5 @@
968
996
  renderSummaryInto(target);
969
997
  }
970
998
 
971
- window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown, isComposing, flushOpen, setDisabled };
999
+ window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, setList, renderSummary, onBadgeRefresh, teardown, isComposing, flushOpen, setDisabled };
972
1000
  })();