@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/README.md +10 -7
- package/docs/AGENT.md +23 -0
- package/package.json +1 -1
- package/skills/relay/SKILL.md +26 -3
- package/src/cli.js +96 -9
- package/src/mcp.js +4 -3
- package/src/server.js +262 -22
- package/src/spec.js +8 -1
- package/src/ui/annotate.js +35 -7
- package/src/ui/app.js +152 -4
- package/src/ui/blocks.css +62 -0
- package/src/ui/blocks.js +151 -0
- package/src/ui/style.css +52 -1
package/src/server.js
CHANGED
|
@@ -96,7 +96,46 @@ function vendorPresent(file) {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
function
|
|
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
|
|
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
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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, '
|
|
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
|
-
|
|
784
|
-
|
|
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'] }],
|
package/src/ui/annotate.js
CHANGED
|
@@ -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
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
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
|
})();
|