create-egregore 0.18.0 → 0.18.1

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.
Files changed (38) hide show
  1. package/package.json +4 -3
  2. package/runtime/codex/AGENTS.md +4 -292
  3. package/runtime/codex/bin/activity-data.sh +3 -6
  4. package/runtime/codex/bin/agent.sh +0 -0
  5. package/runtime/codex/bin/codex-install-skills.sh +0 -0
  6. package/runtime/codex/bin/codex-render-spec.mjs +0 -0
  7. package/runtime/codex/bin/codex-session-start.sh +0 -0
  8. package/runtime/codex/bin/codex-skill-render.mjs +0 -0
  9. package/runtime/codex/bin/codex-sync-skills.sh +0 -0
  10. package/runtime/codex/bin/dashboard-data.sh +2 -6
  11. package/runtime/codex/bin/graph-batch.sh +0 -0
  12. package/runtime/codex/bin/graph-op.sh +5 -22
  13. package/runtime/codex/bin/graph-wal.sh +0 -0
  14. package/runtime/codex/bin/graph.sh +0 -0
  15. package/runtime/codex/bin/handoff-pr-backfill.sh +0 -0
  16. package/runtime/codex/bin/handoff-run.sh +6 -144
  17. package/runtime/codex/bin/handoff-save-egregore.sh +0 -0
  18. package/runtime/codex/bin/index-handoff.sh +0 -0
  19. package/runtime/codex/bin/lib/artifact-id.sh +0 -0
  20. package/runtime/codex/bin/lib/config.sh +0 -0
  21. package/runtime/codex/bin/lib/context.sh +0 -48
  22. package/runtime/codex/bin/lib/ensure-develop.sh +0 -0
  23. package/runtime/codex/bin/lib/greeting.sh +2 -20
  24. package/runtime/codex/bin/lib/hash.sh +0 -0
  25. package/runtime/codex/bin/lib/time.sh +0 -0
  26. package/runtime/codex/bin/notify.sh +0 -0
  27. package/runtime/codex/bin/publish-artifact.sh +1 -15
  28. package/runtime/codex/bin/publish-references.sh +0 -0
  29. package/runtime/codex/bin/repo-state.sh +0 -0
  30. package/runtime/codex/bin/telemetry.sh +0 -0
  31. package/runtime/codex/bin/worktree-create.sh +8 -5
  32. package/runtime/codex/bin/worktree-remove.sh +0 -0
  33. package/runtime/codex/bin/worktree.sh +4 -5
  34. package/runtime/codex/manifest.json +3 -10
  35. package/runtime/codex/.codex/skills/comment/SKILL.md +0 -25
  36. package/runtime/codex/.codex/skills/edit/SKILL.md +0 -25
  37. package/runtime/codex/.codex/skills/edit-process/SKILL.md +0 -25
  38. package/runtime/codex/bin/lib/edit-loopback.mjs +0 -652
@@ -1,652 +0,0 @@
1
- // /api/edit/* loopback handlers for the /edit skill.
2
- //
3
- // Spec: notes/edit-spec-contract.md (locked 2026-05-04).
4
- // Wired into bin/view-server.mjs. Co-exists with /api/comment — does not
5
- // modify any existing /view or /api/comment behavior.
6
- //
7
- // Sections referenced below map to the contract:
8
- // §2 AnnotationSidecar JSON schema
9
- // §3 Loopback API surface
10
- // §4 Source-map renderer extension points
11
- // §5 Token + session lifecycle
12
- // §7 Path encoding / containment
13
- //
14
- // Design notes:
15
- // - One helper module instead of inlining everything in view-server.mjs to keep
16
- // the existing comment path untouched and easy to read.
17
- // - The renderer (A2's work in packages/egregore-artifacts/lib/markdown.js) is
18
- // imported softly: if `buildParagraphIndex` or the new `renderMarkdown(text,
19
- // {sourceMap})` signature is missing, /api/edit/render returns a 503 with a
20
- // clear error code. The other endpoints stay functional.
21
- // - Token derivation reuses the existing session-id at .egregore-session-id.
22
- // No new on-disk token files (Contract §5 explicitly forbids it).
23
-
24
- import http from 'node:http';
25
- import fs from 'node:fs';
26
- import path from 'node:path';
27
- import crypto from 'node:crypto';
28
- import { pathToFileURL } from 'node:url';
29
-
30
- // --- Config ---------------------------------------------------------------
31
-
32
- const ALLOWED_ORIGIN = 'http://127.0.0.1:7321';
33
- const BLOCKED_DIRS = ['node_modules/', '.git/', 'bin/', '.claude/'];
34
- const SIDECAR_SUFFIX = '.annotations.json';
35
- const SESSION_ID_PATTERN = /^[0-9]{8}T[0-9]{6}-[a-z0-9_-]+-[0-9]+$/i;
36
- const ANN_ID_PATTERN = /^ann_[0-9TZ:.\-]+_[a-f0-9]{6}$/;
37
- const REP_ID_PATTERN = /^rep_[0-9TZ:.\-]+_[a-f0-9]{6}$/;
38
- const REPLY_KINDS = new Set(['proposal', 'acknowledge', 'discussion', 'applied']);
39
- const CLASSIFICATIONS = new Set(['green', 'yellow', 'red', null]);
40
- const STATUSES = new Set(['active', 'accepted', 'discarded', 'orphaned']);
41
-
42
- // State machine (Contract §2.4)
43
- const ALLOWED_TRANSITIONS = {
44
- active: new Set(['accepted', 'discarded', 'orphaned']),
45
- orphaned: new Set(['active']), // v0.2 manual re-anchor
46
- accepted: new Set(),
47
- discarded: new Set(),
48
- };
49
-
50
- // --- HTTP error helper ----------------------------------------------------
51
-
52
- class HttpError extends Error {
53
- constructor(status, code, message) {
54
- super(message || code);
55
- this.status = status;
56
- this.code = code;
57
- }
58
- }
59
-
60
- // --- Body / response helpers ---------------------------------------------
61
-
62
- function readJsonBody(req, max = 1024 * 1024) {
63
- return new Promise((resolve, reject) => {
64
- let total = 0;
65
- const chunks = [];
66
- req.on('data', (c) => {
67
- total += c.length;
68
- if (total > max) {
69
- reject(new HttpError(413, 'body_too_large', 'request body too large'));
70
- req.destroy();
71
- return;
72
- }
73
- chunks.push(c);
74
- });
75
- req.on('end', () => {
76
- const text = Buffer.concat(chunks).toString('utf-8');
77
- if (!text.trim()) return resolve(null);
78
- try { resolve(JSON.parse(text)); }
79
- catch (e) { reject(new HttpError(400, 'invalid_json', 'invalid JSON: ' + e.message)); }
80
- });
81
- req.on('error', (e) => reject(new HttpError(400, 'request_error', e.message)));
82
- });
83
- }
84
-
85
- function sendEdit(res, status, body) {
86
- res.writeHead(status, {
87
- 'Content-Type': 'application/json',
88
- 'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
89
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
90
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
91
- 'Vary': 'Origin',
92
- });
93
- res.end(JSON.stringify(body));
94
- }
95
-
96
- function sendEditError(res, err) {
97
- if (err instanceof HttpError) {
98
- return sendEdit(res, err.status, { error: err.message, code: err.code });
99
- }
100
- return sendEdit(res, 500, { error: err && err.message ? err.message : 'internal error' });
101
- }
102
-
103
- // --- Path validation (Contract §7) ---------------------------------------
104
-
105
- export function validateDocPath(encoded, repoRoot) {
106
- if (!encoded || typeof encoded !== 'string') {
107
- throw new HttpError(400, 'missing_doc', 'missing ?doc query parameter');
108
- }
109
- let decoded;
110
- try {
111
- decoded = Buffer.from(encoded, 'base64url').toString('utf8');
112
- } catch {
113
- throw new HttpError(400, 'invalid_encoding', 'doc is not valid base64url');
114
- }
115
- if (!decoded) throw new HttpError(400, 'empty_path', 'decoded path is empty');
116
- if (decoded.startsWith('/')) throw new HttpError(400, 'absolute_path', 'absolute paths not allowed');
117
- if (decoded.includes('..')) throw new HttpError(400, 'parent_traversal', '`..` not allowed in path');
118
- if (!decoded.endsWith('.md')) throw new HttpError(400, 'not_markdown', 'doc must be a .md file');
119
- if (BLOCKED_DIRS.some((b) => decoded.startsWith(b))) {
120
- throw new HttpError(400, 'blocked_dir', `paths under ${BLOCKED_DIRS.join(', ')} are blocked`);
121
- }
122
- const resolved = path.resolve(repoRoot, decoded);
123
- // Use path.sep guard so /repo and /repo-bak aren't conflated.
124
- if (resolved !== repoRoot && !resolved.startsWith(repoRoot + path.sep)) {
125
- throw new HttpError(400, 'outside_repo', 'resolved path falls outside repo root');
126
- }
127
- return {
128
- docPath: decoded,
129
- resolved,
130
- sidecarPath: resolved + SIDECAR_SUFFIX,
131
- };
132
- }
133
-
134
- // --- Token store (Contract §5) -------------------------------------------
135
- //
136
- // Keyed by sessionId. Holds {nonce, issuedAt}. Lost on view-server restart
137
- // (fail-secure — Contract §5.3). Never written to disk.
138
-
139
- const tokenStore = new Map();
140
-
141
- function readSessionIdFromDisk(repoRoot) {
142
- const projectLocal = path.join(repoRoot, '.egregore-session-id');
143
- try {
144
- const v = fs.readFileSync(projectLocal, 'utf8').trim();
145
- if (v) return v;
146
- } catch { /* fall through */ }
147
- // Fallback: ~/.egregore/session-<projHash>.id
148
- try {
149
- const projHash = crypto.createHash('md5').update(repoRoot).digest('hex');
150
- const home = process.env.HOME || process.env.USERPROFILE;
151
- if (home) {
152
- const v = fs.readFileSync(path.join(home, '.egregore', `session-${projHash}.id`), 'utf8').trim();
153
- if (v) return v;
154
- }
155
- } catch { /* fall through */ }
156
- return null;
157
- }
158
-
159
- function deriveToken(sessionId, nonce) {
160
- return crypto
161
- .createHmac('sha256', sessionId)
162
- .update(nonce)
163
- .digest('base64url')
164
- .slice(0, 32);
165
- }
166
-
167
- function timingSafeEq(a, b) {
168
- if (typeof a !== 'string' || typeof b !== 'string') return false;
169
- const A = Buffer.from(a);
170
- const B = Buffer.from(b);
171
- if (A.length !== B.length) return false;
172
- return crypto.timingSafeEqual(A, B);
173
- }
174
-
175
- export function issueToken(sessionId, repoRoot) {
176
- if (!sessionId || !SESSION_ID_PATTERN.test(sessionId)) {
177
- throw new HttpError(400, 'invalid_session_id', 'sessionId format invalid');
178
- }
179
- const onDisk = readSessionIdFromDisk(repoRoot);
180
- if (!onDisk) {
181
- throw new HttpError(400, 'no_session_on_disk', 'no .egregore-session-id found in repo root');
182
- }
183
- if (onDisk !== sessionId) {
184
- throw new HttpError(400, 'session_mismatch', 'sessionId does not match current project session');
185
- }
186
- // Reissue with a fresh nonce on every call — invalidates any prior token
187
- // for this sessionId. Cheap (16 hex chars) and avoids stale-token confusion.
188
- const nonce = crypto.randomBytes(8).toString('hex');
189
- const token = deriveToken(sessionId, nonce);
190
- tokenStore.set(sessionId, { nonce, issuedAt: new Date().toISOString() });
191
- return { token, port: 7321, expiresHint: 'session-bound' };
192
- }
193
-
194
- function authenticate(req, repoRoot) {
195
- const auth = req.headers['authorization'] || req.headers['Authorization'];
196
- if (!auth || !auth.startsWith('Bearer ')) {
197
- throw new HttpError(401, 'missing_token', 'missing Authorization: Bearer token');
198
- }
199
- const supplied = auth.slice('Bearer '.length).trim();
200
- const sessionId = readSessionIdFromDisk(repoRoot);
201
- if (!sessionId) {
202
- throw new HttpError(401, 'no_session', 'view-server cannot read current session id');
203
- }
204
- const entry = tokenStore.get(sessionId);
205
- if (!entry) {
206
- throw new HttpError(401, 'no_active_token', 'no token issued for current session — call POST /api/edit/session/init');
207
- }
208
- const expected = deriveToken(sessionId, entry.nonce);
209
- if (!timingSafeEq(expected, supplied)) {
210
- throw new HttpError(401, 'bad_token', 'token does not match current session');
211
- }
212
- return { sessionId };
213
- }
214
-
215
- function checkOrigin(req, methodIsWrite) {
216
- if (!methodIsWrite) return; // §3 only requires Origin check on writes
217
- const origin = req.headers['origin'];
218
- // Allow missing Origin (e.g. curl / agent-side calls); browsers will always
219
- // send one, and a forged Origin from a hostile page is the threat we care
220
- // about. Anything that doesn't match the locked origin is rejected.
221
- if (origin && origin !== ALLOWED_ORIGIN) {
222
- throw new HttpError(403, 'bad_origin', `Origin must be ${ALLOWED_ORIGIN}`);
223
- }
224
- }
225
-
226
- // --- Sidecar I/O ---------------------------------------------------------
227
-
228
- function emptySidecar(docPath, sessionId) {
229
- return {
230
- schemaVersion: 1,
231
- doc: docPath,
232
- sessionId: sessionId || null,
233
- annotations: [],
234
- };
235
- }
236
-
237
- function readSidecar(sidecarPath, docPath, sessionId) {
238
- if (!fs.existsSync(sidecarPath)) {
239
- return emptySidecar(docPath, sessionId);
240
- }
241
- const raw = fs.readFileSync(sidecarPath, 'utf8');
242
- let parsed;
243
- try { parsed = JSON.parse(raw); }
244
- catch (e) {
245
- throw new HttpError(500, 'sidecar_corrupt', 'sidecar is not valid JSON: ' + e.message);
246
- }
247
- if (!parsed || typeof parsed !== 'object') {
248
- throw new HttpError(500, 'sidecar_corrupt', 'sidecar root is not an object');
249
- }
250
- if (parsed.schemaVersion !== 1) {
251
- throw new HttpError(500, 'sidecar_version', 'unsupported sidecar schemaVersion');
252
- }
253
- parsed.annotations = Array.isArray(parsed.annotations) ? parsed.annotations : [];
254
- return parsed;
255
- }
256
-
257
- function atomicWriteJson(targetPath, obj) {
258
- const dir = path.dirname(targetPath);
259
- fs.mkdirSync(dir, { recursive: true });
260
- const tmp = `${targetPath}.tmp.${process.pid}.${Date.now()}`;
261
- const data = JSON.stringify(obj, null, 2) + '\n';
262
- const fd = fs.openSync(tmp, 'w');
263
- try {
264
- fs.writeSync(fd, data);
265
- fs.fsyncSync(fd);
266
- } finally {
267
- fs.closeSync(fd);
268
- }
269
- fs.renameSync(tmp, targetPath);
270
- }
271
-
272
- // --- ID + timestamp helpers ----------------------------------------------
273
-
274
- function fsTimestamp() {
275
- // ISO without colons (Contract §2.3 — colons stripped for fs safety):
276
- // 2026-05-04T10-23-45.123Z → strip ms+Z to match the regex shape used in
277
- // existing tooling. We keep ms precision in createdAt but the ID uses
278
- // seconds-only.
279
- const d = new Date();
280
- const pad = (n, w = 2) => String(n).padStart(w, '0');
281
- return (
282
- d.getUTCFullYear() +
283
- '-' + pad(d.getUTCMonth() + 1) +
284
- '-' + pad(d.getUTCDate()) +
285
- 'T' + pad(d.getUTCHours()) +
286
- '-' + pad(d.getUTCMinutes()) +
287
- '-' + pad(d.getUTCSeconds())
288
- );
289
- }
290
-
291
- function rand6hex() {
292
- return crypto.randomBytes(3).toString('hex');
293
- }
294
-
295
- function newAnnotationId() {
296
- return `ann_${fsTimestamp()}_${rand6hex()}`;
297
- }
298
-
299
- function newReplyId() {
300
- return `rep_${fsTimestamp()}_${rand6hex()}`;
301
- }
302
-
303
- // --- Validators (Contract §2.2) ------------------------------------------
304
-
305
- function validateAnchor(anchor) {
306
- if (!anchor || typeof anchor !== 'object') {
307
- throw new HttpError(400, 'invalid_anchor', 'selection is required');
308
- }
309
- for (const k of ['section', 'paragraphId', 'charRange', 'fingerprint']) {
310
- if (!(k in anchor)) throw new HttpError(400, 'invalid_anchor', `selection.${k} required`);
311
- }
312
- if (typeof anchor.section !== 'string' || !/^§[0-9]+(\.[0-9]+)?\.? .+/.test(anchor.section)) {
313
- throw new HttpError(400, 'invalid_anchor', 'selection.section format invalid');
314
- }
315
- if (typeof anchor.paragraphId !== 'string' || !/^sha256:[a-f0-9]{16}$/.test(anchor.paragraphId)) {
316
- throw new HttpError(400, 'invalid_anchor', 'selection.paragraphId format invalid');
317
- }
318
- if (!Array.isArray(anchor.charRange) || anchor.charRange.length !== 2 ||
319
- !Number.isInteger(anchor.charRange[0]) || !Number.isInteger(anchor.charRange[1]) ||
320
- anchor.charRange[0] < 0 || anchor.charRange[1] < 0) {
321
- throw new HttpError(400, 'invalid_anchor', 'selection.charRange must be [int,int]');
322
- }
323
- if (typeof anchor.fingerprint !== 'string' || anchor.fingerprint.length === 0 ||
324
- anchor.fingerprint.length > 64) {
325
- throw new HttpError(400, 'invalid_anchor', 'selection.fingerprint must be 1-64 chars');
326
- }
327
- }
328
-
329
- function validateAnnotationInput(ann) {
330
- if (!ann || typeof ann !== 'object') {
331
- throw new HttpError(400, 'invalid_annotation', 'annotation body required');
332
- }
333
- validateAnchor(ann.selection);
334
- if ('classification' in ann && !CLASSIFICATIONS.has(ann.classification)) {
335
- throw new HttpError(400, 'invalid_classification', 'classification must be green|yellow|red|null');
336
- }
337
- if (typeof ann.comment !== 'string' || ann.comment.length === 0) {
338
- throw new HttpError(400, 'invalid_comment', 'comment is required');
339
- }
340
- if (typeof ann.author !== 'string' || ann.author.length === 0) {
341
- throw new HttpError(400, 'invalid_author', 'author is required');
342
- }
343
- if ('status' in ann && !STATUSES.has(ann.status)) {
344
- throw new HttpError(400, 'invalid_status', 'status must be active|accepted|discarded|orphaned');
345
- }
346
- if (ann.id !== undefined && (typeof ann.id !== 'string' || !ANN_ID_PATTERN.test(ann.id))) {
347
- throw new HttpError(400, 'invalid_id', 'annotation id format invalid');
348
- }
349
- }
350
-
351
- function validateReplyInput(rep) {
352
- if (!rep || typeof rep !== 'object') {
353
- throw new HttpError(400, 'invalid_reply', 'reply body required');
354
- }
355
- if (typeof rep.author !== 'string' || rep.author.length === 0) {
356
- throw new HttpError(400, 'invalid_reply', 'author required');
357
- }
358
- if (!REPLY_KINDS.has(rep.kind)) {
359
- throw new HttpError(400, 'invalid_reply', 'kind must be proposal|acknowledge|discussion|applied');
360
- }
361
- if (typeof rep.text !== 'string') {
362
- throw new HttpError(400, 'invalid_reply', 'text required (may be empty)');
363
- }
364
- if (rep.id !== undefined && (typeof rep.id !== 'string' || !REP_ID_PATTERN.test(rep.id))) {
365
- throw new HttpError(400, 'invalid_reply', 'reply id format invalid');
366
- }
367
- }
368
-
369
- // --- Anchor validation against current paragraphIndex (Contract task 5) --
370
-
371
- function reanchor(annotation, paragraphIndex) {
372
- // If the renderer produced no index, skip — leave status as-is.
373
- if (!Array.isArray(paragraphIndex) || paragraphIndex.length === 0) return annotation;
374
- const sel = annotation.selection;
375
- if (!sel) return annotation;
376
-
377
- const exact = paragraphIndex.find((p) => p.paragraphId === sel.paragraphId);
378
- if (exact) {
379
- if (annotation.status === 'orphaned') {
380
- annotation.status = 'active'; // re-anchored; allowed transition
381
- }
382
- return annotation;
383
- }
384
-
385
- // Fuzzy: paragraph text contains the fingerprint, or fingerprint contains
386
- // ≥80% of paragraph text. Cheap heuristic — anchor algorithms can grow.
387
- if (sel.fingerprint) {
388
- const fp = sel.fingerprint.trim();
389
- const candidate = paragraphIndex.find((p) => {
390
- if (!p.text) return false;
391
- if (p.text.includes(fp)) return true;
392
- if (fp.length >= 12 && fp.includes(p.text.trim().slice(0, 32))) return true;
393
- return false;
394
- });
395
- if (candidate) {
396
- annotation.selection = { ...sel, paragraphId: candidate.paragraphId, section: candidate.section || sel.section };
397
- if (annotation.status === 'orphaned') annotation.status = 'active';
398
- return annotation;
399
- }
400
- }
401
-
402
- // No match — orphan it (only if currently active; respects state machine).
403
- if (annotation.status === 'active') annotation.status = 'orphaned';
404
- return annotation;
405
- }
406
-
407
- // --- Renderer integration (soft import) ----------------------------------
408
- //
409
- // A2 is extending packages/egregore-artifacts/lib/markdown.js. Until they
410
- // land buildParagraphIndex + the (text, opts) signature of renderMarkdown,
411
- // /api/edit/render returns 503 with code "renderer_not_ready".
412
-
413
- let cachedRenderer = null;
414
-
415
- async function loadRenderer(repoRoot) {
416
- if (cachedRenderer) return cachedRenderer;
417
- const mdPath = path.join(repoRoot, 'packages', 'egregore-artifacts', 'lib', 'markdown.js');
418
- if (!fs.existsSync(mdPath)) {
419
- throw new HttpError(503, 'renderer_not_ready', 'markdown.js not found at expected path');
420
- }
421
- let mod;
422
- try {
423
- mod = await import(pathToFileURL(mdPath).href);
424
- } catch (e) {
425
- throw new HttpError(503, 'renderer_not_ready', 'failed to load markdown.js: ' + e.message);
426
- }
427
- // renderMarkdownToHtml wraps renderMarkdown + renderToStaticMarkup so
428
- // react-dom/server resolves from the artifact package's own node_modules.
429
- if (typeof mod.renderMarkdownToHtml !== 'function' || typeof mod.buildParagraphIndex !== 'function') {
430
- throw new HttpError(503, 'renderer_not_ready',
431
- 'renderer missing renderMarkdownToHtml/buildParagraphIndex exports');
432
- }
433
- cachedRenderer = mod;
434
- return mod;
435
- }
436
-
437
- // --- Endpoint handlers ---------------------------------------------------
438
-
439
- async function handleSessionInit(req, res, repoRoot) {
440
- // POST /api/edit/session/init
441
- // No auth required (it issues the auth). Origin still checked.
442
- checkOrigin(req, true);
443
- const body = await readJsonBody(req);
444
- if (!body || typeof body.sessionId !== 'string') {
445
- throw new HttpError(400, 'invalid_session_id', 'request body must include sessionId');
446
- }
447
- const result = issueToken(body.sessionId, repoRoot);
448
- sendEdit(res, 200, result);
449
- }
450
-
451
- async function handleRender(req, res, url, repoRoot) {
452
- authenticate(req, repoRoot);
453
- const { docPath, resolved } = validateDocPath(url.searchParams.get('doc'), repoRoot);
454
- if (!fs.existsSync(resolved)) throw new HttpError(404, 'doc_not_found', `doc not found: ${docPath}`);
455
- const text = fs.readFileSync(resolved, 'utf8');
456
- const renderer = await loadRenderer(repoRoot);
457
- const html = renderer.renderMarkdownToHtml(text, { sourceMap: true });
458
- const paragraphIndex = renderer.buildParagraphIndex(text);
459
- sendEdit(res, 200, { html, sourceText: text, paragraphIndex });
460
- }
461
-
462
- async function handleGetAnnotations(req, res, url, repoRoot) {
463
- const { sessionId } = authenticate(req, repoRoot);
464
- const { docPath, sidecarPath } = validateDocPath(url.searchParams.get('doc'), repoRoot);
465
- const sidecar = readSidecar(sidecarPath, docPath, sessionId);
466
- sendEdit(res, 200, sidecar);
467
- }
468
-
469
- async function handlePostAnnotation(req, res, url, repoRoot) {
470
- const { sessionId } = authenticate(req, repoRoot);
471
- checkOrigin(req, true);
472
- const { docPath, resolved, sidecarPath } = validateDocPath(url.searchParams.get('doc'), repoRoot);
473
- const ann = await readJsonBody(req);
474
- validateAnnotationInput(ann);
475
-
476
- const sidecar = readSidecar(sidecarPath, docPath, sessionId);
477
- const idx = ann.id ? sidecar.annotations.findIndex((a) => a.id === ann.id) : -1;
478
-
479
- // Status-transition enforcement on update
480
- if (idx >= 0) {
481
- const prev = sidecar.annotations[idx];
482
- if (ann.status && ann.status !== prev.status) {
483
- const allowed = ALLOWED_TRANSITIONS[prev.status];
484
- if (!allowed || !allowed.has(ann.status)) {
485
- throw new HttpError(409, 'bad_status_transition',
486
- `cannot move from ${prev.status} to ${ann.status}`);
487
- }
488
- }
489
- }
490
-
491
- // Optional: re-anchor against current paragraphIndex. Only attempt if the
492
- // renderer is ready; if it isn't, skip silently — anchoring stays as-is.
493
- let paragraphIndex = null;
494
- try {
495
- if (fs.existsSync(resolved)) {
496
- const renderer = await loadRenderer(repoRoot);
497
- paragraphIndex = renderer.buildParagraphIndex(fs.readFileSync(resolved, 'utf8'));
498
- }
499
- } catch (e) {
500
- if (!(e instanceof HttpError && e.code === 'renderer_not_ready')) throw e;
501
- }
502
-
503
- const now = new Date().toISOString();
504
- const merged = idx >= 0
505
- ? { ...sidecar.annotations[idx], ...ann, id: sidecar.annotations[idx].id }
506
- : {
507
- id: ann.id || newAnnotationId(),
508
- selection: ann.selection,
509
- classification: ann.classification ?? null,
510
- autoClassified: !!ann.autoClassified,
511
- comment: ann.comment,
512
- author: ann.author,
513
- createdAt: ann.createdAt || now,
514
- thread: Array.isArray(ann.thread) ? ann.thread : [],
515
- status: ann.status || 'active',
516
- };
517
-
518
- if (paragraphIndex) reanchor(merged, paragraphIndex);
519
-
520
- if (idx >= 0) sidecar.annotations[idx] = merged;
521
- else sidecar.annotations.push(merged);
522
-
523
- if (!sidecar.sessionId) sidecar.sessionId = sessionId;
524
- atomicWriteJson(sidecarPath, sidecar);
525
-
526
- sendEdit(res, idx >= 0 ? 200 : 201, { id: merged.id, annotation: merged });
527
- }
528
-
529
- async function handlePostReply(req, res, url, repoRoot) {
530
- const { sessionId } = authenticate(req, repoRoot);
531
- checkOrigin(req, true);
532
- const { docPath, sidecarPath } = validateDocPath(url.searchParams.get('doc'), repoRoot);
533
- const annId = (url.searchParams.get('id') || '').trim();
534
- if (!annId || !ANN_ID_PATTERN.test(annId)) {
535
- throw new HttpError(400, 'invalid_id', 'missing or malformed ?id=<annotationId>');
536
- }
537
- const reply = await readJsonBody(req);
538
- validateReplyInput(reply);
539
-
540
- const sidecar = readSidecar(sidecarPath, docPath, sessionId);
541
- const target = sidecar.annotations.find((a) => a.id === annId);
542
- if (!target) throw new HttpError(404, 'annotation_not_found', `annotation ${annId} not found`);
543
- if (!Array.isArray(target.thread)) target.thread = [];
544
-
545
- const now = new Date().toISOString();
546
- const newReply = {
547
- id: reply.id || newReplyId(),
548
- author: reply.author,
549
- kind: reply.kind,
550
- createdAt: reply.createdAt || now,
551
- text: reply.text,
552
- };
553
- if (reply.metadata && typeof reply.metadata === 'object') newReply.metadata = reply.metadata;
554
- target.thread.push(newReply);
555
-
556
- atomicWriteJson(sidecarPath, sidecar);
557
- sendEdit(res, 201, { replyId: newReply.id, annotationId: annId });
558
- }
559
-
560
- async function handleDeleteAnnotation(req, res, url, repoRoot) {
561
- const { sessionId } = authenticate(req, repoRoot);
562
- checkOrigin(req, true);
563
- const { docPath, sidecarPath } = validateDocPath(url.searchParams.get('doc'), repoRoot);
564
- const annId = (url.searchParams.get('id') || '').trim();
565
- if (!annId || !ANN_ID_PATTERN.test(annId)) {
566
- throw new HttpError(400, 'invalid_id', 'missing or malformed ?id=<annotationId>');
567
- }
568
- const sidecar = readSidecar(sidecarPath, docPath, sessionId);
569
- const target = sidecar.annotations.find((a) => a.id === annId);
570
- if (!target) throw new HttpError(404, 'annotation_not_found', `annotation ${annId} not found`);
571
-
572
- const allowed = ALLOWED_TRANSITIONS[target.status] || new Set();
573
- if (target.status === 'discarded' || !allowed.has('discarded')) {
574
- if (target.status === 'discarded') {
575
- // idempotent — already discarded; report 200 + current state
576
- return sendEdit(res, 200, { id: target.id, status: target.status });
577
- }
578
- throw new HttpError(409, 'bad_status_transition',
579
- `cannot move from ${target.status} to discarded`);
580
- }
581
- target.status = 'discarded';
582
- atomicWriteJson(sidecarPath, sidecar);
583
- sendEdit(res, 200, { id: target.id, status: target.status });
584
- }
585
-
586
- // --- Top-level dispatcher -------------------------------------------------
587
-
588
- export function isEditPath(reqUrl) {
589
- const u = (reqUrl || '').split('?')[0];
590
- return u.startsWith('/api/edit/');
591
- }
592
-
593
- // Returns true if handled (so view-server skips its own 404 fallback).
594
- export async function handleEditRequest(req, res, repoRoot) {
595
- const url = new URL(req.url, `http://127.0.0.1:7321`);
596
- const pathname = url.pathname;
597
-
598
- // OPTIONS preflight (Contract §3.7) — locked-down origin
599
- if (req.method === 'OPTIONS') {
600
- res.writeHead(204, {
601
- 'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
602
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
603
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
604
- 'Access-Control-Max-Age': '600',
605
- 'Vary': 'Origin',
606
- });
607
- res.end();
608
- return true;
609
- }
610
-
611
- try {
612
- if (req.method === 'POST' && pathname === '/api/edit/session/init') {
613
- await handleSessionInit(req, res, repoRoot);
614
- return true;
615
- }
616
- if (req.method === 'GET' && pathname === '/api/edit/render') {
617
- await handleRender(req, res, url, repoRoot);
618
- return true;
619
- }
620
- if (req.method === 'GET' && pathname === '/api/edit/annotations') {
621
- await handleGetAnnotations(req, res, url, repoRoot);
622
- return true;
623
- }
624
- if (req.method === 'POST' && pathname === '/api/edit/annotations') {
625
- await handlePostAnnotation(req, res, url, repoRoot);
626
- return true;
627
- }
628
- if (req.method === 'POST' && pathname === '/api/edit/replies') {
629
- await handlePostReply(req, res, url, repoRoot);
630
- return true;
631
- }
632
- if (req.method === 'DELETE' && pathname === '/api/edit/annotations') {
633
- await handleDeleteAnnotation(req, res, url, repoRoot);
634
- return true;
635
- }
636
- sendEdit(res, 404, { error: 'edit endpoint not found', code: 'not_found' });
637
- return true;
638
- } catch (err) {
639
- sendEditError(res, err);
640
- return true;
641
- }
642
- }
643
-
644
- // --- Test exports (for smoke tests) --------------------------------------
645
-
646
- export const _testing = {
647
- validateDocPath,
648
- deriveToken,
649
- tokenStore,
650
- ALLOWED_ORIGIN,
651
- BLOCKED_DIRS,
652
- };