@satanwagen/reviewkit 0.1.2 → 0.1.4

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 (56) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +158 -64
  3. package/dist/activate.cjs +150 -0
  4. package/dist/activate.cjs.map +1 -0
  5. package/dist/activate.d.cts +59 -0
  6. package/dist/activate.d.ts +59 -0
  7. package/dist/activate.js +21 -0
  8. package/dist/activate.js.map +1 -0
  9. package/dist/{chunk-YWBFAV57.js → chunk-5MAFDRUI.js} +1161 -1555
  10. package/dist/chunk-5MAFDRUI.js.map +1 -0
  11. package/dist/{chunk-4YNLMSCK.js → chunk-DG6O5XIC.js} +36 -10
  12. package/dist/chunk-DG6O5XIC.js.map +1 -0
  13. package/dist/chunk-EOX3UJNP.js +36 -0
  14. package/dist/chunk-EOX3UJNP.js.map +1 -0
  15. package/dist/chunk-ETAGGIDN.js +119 -0
  16. package/dist/chunk-ETAGGIDN.js.map +1 -0
  17. package/dist/client/index.cjs +4834 -4244
  18. package/dist/client/index.cjs.map +1 -1
  19. package/dist/client/index.d.cts +1 -1
  20. package/dist/client/index.d.ts +1 -1
  21. package/dist/client/index.js +49 -3
  22. package/dist/client/index.js.map +1 -1
  23. package/dist/effects-EWKHKUDP.js +851 -0
  24. package/dist/effects-EWKHKUDP.js.map +1 -0
  25. package/dist/index-CWGkg1-E.d.cts +86 -0
  26. package/dist/index-DPsjkvaY.d.ts +86 -0
  27. package/dist/index.cjs +4852 -4267
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +35 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/lazy.cjs +9308 -0
  34. package/dist/lazy.cjs.map +1 -0
  35. package/dist/lazy.d.cts +7 -0
  36. package/dist/lazy.d.ts +7 -0
  37. package/dist/lazy.js +8 -0
  38. package/dist/lazy.js.map +1 -0
  39. package/dist/next.cjs +9309 -0
  40. package/dist/next.cjs.map +1 -0
  41. package/dist/next.d.cts +4 -0
  42. package/dist/next.d.ts +4 -0
  43. package/dist/next.js +9 -0
  44. package/dist/next.js.map +1 -0
  45. package/dist/schema.cjs +53 -16
  46. package/dist/schema.cjs.map +1 -1
  47. package/dist/schema.d.cts +20 -5
  48. package/dist/schema.d.ts +20 -5
  49. package/dist/schema.js +30 -2
  50. package/dist/schema.js.map +1 -1
  51. package/package.json +23 -8
  52. package/cli/emblema-sync.mjs +0 -675
  53. package/dist/chunk-4YNLMSCK.js.map +0 -1
  54. package/dist/chunk-YWBFAV57.js.map +0 -1
  55. package/dist/index-BbucFgZi.d.ts +0 -49
  56. package/dist/index-CBlJrKkm.d.cts +0 -49
@@ -1,675 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * reviewkit-emblema — local sync agent pairing a ReviewKit project with the
4
- * Emblema desktop app (contract v2, see docs/EMBLEMA_SYNC_PROTOCOL.md).
5
- *
6
- * Runs in the PROJECT directory (the repo whose site is being reviewed).
7
- * State lives in .reviewkit/emblema-sync.json. Zero dependencies: node http,
8
- * the global WebSocket client (node >= 21) and crypto.
9
- *
10
- * Commands:
11
- * init --origin <https://site> [--name X] [--sync-token T] [--sync-url U]
12
- * [--routes /,/about] [--repo <abs path>] [--port N] [--intake-url U]
13
- * key print the current pairing key (emk_…)
14
- * rotate new secret — the old key stops authenticating immediately
15
- * revoke disable the pairing entirely (until the next rotate/init)
16
- * serve run the HTTP API + realtime bridge (rk-sync room -> Emblema intake)
17
- */
18
-
19
- import { randomBytes, randomUUID } from 'node:crypto';
20
- import { mkdirSync, readFileSync, realpathSync, renameSync, watch, writeFileSync } from 'node:fs';
21
- import { createServer } from 'node:http';
22
- import { dirname, join, resolve } from 'node:path';
23
- import { fileURLToPath } from 'node:url';
24
-
25
- export const KEY_PREFIX = 'emk_';
26
- export const CONTRACT_VERSION = 2;
27
- export const DEFAULT_API_PORT = 48770;
28
- const DEFAULT_SYNC_URL = 'wss://agropolio.fucking.style/rk-sync';
29
- const DEFAULT_INTAKE_URL = 'http://127.0.0.1:48752/intake/review';
30
- const RK_SYNC_PROTOCOL_VERSION = 1;
31
- const STATE_DIR = '.reviewkit';
32
- const STATE_FILE = 'emblema-sync.json';
33
- const PUSH_DEBOUNCE_MS = 300;
34
-
35
- /* ---------------- key + state (pure, tested) ---------------- */
36
-
37
- export function encodeKey({ id, name, url, secret }) {
38
- const json = JSON.stringify({ v: CONTRACT_VERSION, id, name, url, secret });
39
- return KEY_PREFIX + Buffer.from(json, 'utf8').toString('base64url');
40
- }
41
-
42
- export function decodeKey(key) {
43
- if (typeof key !== 'string' || !key.startsWith(KEY_PREFIX)) return null;
44
- try {
45
- const parsed = JSON.parse(Buffer.from(key.slice(KEY_PREFIX.length), 'base64url').toString('utf8'));
46
- if (parsed.v !== CONTRACT_VERSION) return null;
47
- for (const field of ['id', 'name', 'url', 'secret']) {
48
- if (typeof parsed[field] !== 'string' || parsed[field] === '') return null;
49
- }
50
- return parsed;
51
- } catch {
52
- return null;
53
- }
54
- }
55
-
56
- /** ReviewKit item -> Emblema finding (contract v2 shape). */
57
- export function toFinding(item, seq) {
58
- const payload = item.payload ?? {};
59
- const titles = {
60
- copy: () => `Rewrite: "${String(payload.before ?? '').slice(0, 60)}" → "${String(payload.after ?? '').slice(0, 60)}"`,
61
- image: () => `Replace image${payload.description ? `: ${payload.description}` : ''}`,
62
- comment: () => `Comment: ${String(payload.text ?? '').slice(0, 80)}`,
63
- delete: () => `Remove element${payload.reason ? `: ${payload.reason}` : ''}`,
64
- move: () => `Move element ${payload.direction ?? ''}`.trim(),
65
- style: () => `Style: ${String(payload.description ?? '').slice(0, 80)}`,
66
- };
67
- const detailParts = [
68
- `selector: ${item.target?.selector ?? '?'}`,
69
- item.target?.textSnippet ? `text: "${item.target.textSnippet}"` : null,
70
- Object.keys(payload).length > 0 ? `payload: ${JSON.stringify(payload)}` : null,
71
- item.note ? `note: ${item.note}` : null,
72
- item.author?.name ? `by: ${item.author.name}` : null,
73
- ].filter(Boolean);
74
- return {
75
- id: item.id,
76
- severity: item.priority === 'must' ? 'high' : 'low',
77
- file: item.target?.sourceFile ?? item.route ?? '/',
78
- line: item.target?.sourceLine ?? 0,
79
- title: (titles[item.type] ?? (() => `${item.type} change`))(),
80
- detail: detailParts.join('\n'),
81
- status: item.status ?? 'open',
82
- updatedAt: new Date().toISOString(),
83
- _seq: seq,
84
- _route: item.route ?? '/',
85
- _raw: item,
86
- };
87
- }
88
-
89
- /** Emblema status -> ReviewKit ItemStatus. `open` REOPENS a finding (a fix
90
- was abandoned or resolved by mistake) — written into the room, so the
91
- site pin reverts live. */
92
- export const STATUS_MAP = { open: 'open', accepted: 'accepted', dismissed: 'rejected', fixed: 'applied' };
93
-
94
- function statePath(cwd) {
95
- return join(cwd, STATE_DIR, STATE_FILE);
96
- }
97
-
98
- function loadState(cwd) {
99
- try {
100
- return JSON.parse(readFileSync(statePath(cwd), 'utf8'));
101
- } catch {
102
- return null;
103
- }
104
- }
105
-
106
- function saveState(cwd, state) {
107
- const path = statePath(cwd);
108
- mkdirSync(dirname(path), { recursive: true });
109
- const tmp = `${path}.tmp-${process.pid}`;
110
- writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
111
- renameSync(tmp, path);
112
- }
113
-
114
- function newSecret() {
115
- return randomBytes(32).toString('hex');
116
- }
117
-
118
- /**
119
- * Current state for request handling: re-read from disk so `rotate`/`revoke`
120
- * run in another process take effect on the very next request. The directory
121
- * watcher in `serve` is best-effort only (rename-swap writes can slip past
122
- * fs.watch — live-verified: a cached secret kept authenticating after rotate
123
- * AND revoke); authentication must never trust a cached secret. Only lastAck
124
- * belongs to the running agent and is preserved from the cached state.
125
- */
126
- export function freshState(cwd, cached) {
127
- const disk = loadState(cwd);
128
- if (!disk) return cached;
129
- return { ...disk, lastAck: cached?.lastAck ?? disk.lastAck ?? null };
130
- }
131
-
132
- function keyOf(state) {
133
- return encodeKey({ id: state.id, name: state.name, url: state.url, secret: state.secret });
134
- }
135
-
136
- function reviewkitVersion() {
137
- try {
138
- const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
139
- return pkg.version ?? '0.0.0';
140
- } catch {
141
- return '0.0.0';
142
- }
143
- }
144
-
145
- /* ---------------- CLI commands ---------------- */
146
-
147
- function parseFlags(argv) {
148
- const flags = {};
149
- for (let i = 0; i < argv.length; i++) {
150
- if (argv[i].startsWith('--')) flags[argv[i].slice(2)] = argv[i + 1] ?? '';
151
- }
152
- return flags;
153
- }
154
-
155
- function cmdInit(cwd, flags) {
156
- const existing = loadState(cwd);
157
- const origin = flags.origin ?? existing?.settings?.origin;
158
- if (!origin) {
159
- console.error('init needs --origin <https://site-under-review> (the rk-sync room origin)');
160
- process.exit(1);
161
- }
162
- const port = Number(flags.port ?? existing?.settings?.port ?? DEFAULT_API_PORT);
163
- const state = {
164
- v: CONTRACT_VERSION,
165
- id: existing?.id ?? `rkp-${randomUUID()}`,
166
- name: flags.name ?? existing?.name ?? resolve(cwd).split('/').filter(Boolean).pop(),
167
- url: `http://127.0.0.1:${port}`,
168
- secret: newSecret(),
169
- revoked: false,
170
- settings: {
171
- origin,
172
- routes: (flags.routes ?? existing?.settings?.routes?.join(',') ?? '/').split(',').map((r) => r.trim()).filter(Boolean),
173
- syncUrl: flags['sync-url'] ?? existing?.settings?.syncUrl ?? DEFAULT_SYNC_URL,
174
- syncToken: flags['sync-token'] ?? existing?.settings?.syncToken ?? '',
175
- repoPath: flags.repo ?? existing?.settings?.repoPath ?? resolve(cwd),
176
- intakeUrl: flags['intake-url'] ?? existing?.settings?.intakeUrl ?? DEFAULT_INTAKE_URL,
177
- port,
178
- },
179
- lastAck: existing?.lastAck ?? null,
180
- };
181
- saveState(cwd, state);
182
- ensureGitignored(cwd);
183
- console.log(keyOf(state));
184
- }
185
-
186
- /** The state file carries the bearer secret — keep it out of git. Appends
187
- `.reviewkit/` to an existing repo .gitignore (best-effort: no .gitignore
188
- or unwritable tree just skips; init must never fail on this). */
189
- function ensureGitignored(cwd) {
190
- const path = join(cwd, '.gitignore');
191
- let current = '';
192
- try {
193
- current = readFileSync(path, 'utf8');
194
- } catch {
195
- return; // no .gitignore (likely no repo) — nothing to protect against
196
- }
197
- if (current.split(/\r?\n/).some((line) => line.trim().replace(/\/+$/, '') === STATE_DIR)) return;
198
- try {
199
- const sep = current === '' || current.endsWith('\n') ? '' : '\n';
200
- writeFileSync(path, `${current}${sep}${STATE_DIR}/\n`, 'utf8');
201
- log(`added ${STATE_DIR}/ to .gitignore (the sync state holds the pairing secret)`);
202
- } catch {
203
- /* read-only tree — the user manages ignores themselves */
204
- }
205
- }
206
-
207
- function requireState(cwd) {
208
- const state = loadState(cwd);
209
- if (!state) {
210
- console.error(`no ${STATE_DIR}/${STATE_FILE} here — run \`reviewkit-emblema init --origin …\` first`);
211
- process.exit(1);
212
- }
213
- return state;
214
- }
215
-
216
- function cmdKey(cwd) {
217
- const state = requireState(cwd);
218
- if (state.revoked) {
219
- console.error('pairing is revoked — `rotate` to issue a fresh key');
220
- process.exit(1);
221
- }
222
- console.log(keyOf(state));
223
- }
224
-
225
- function cmdRotate(cwd) {
226
- const state = requireState(cwd);
227
- state.secret = newSecret();
228
- state.revoked = false;
229
- saveState(cwd, state);
230
- console.log(keyOf(state));
231
- }
232
-
233
- function cmdRevoke(cwd) {
234
- const state = requireState(cwd);
235
- state.revoked = true;
236
- saveState(cwd, state);
237
- console.log('revoked — the key no longer authenticates (rotate to re-enable)');
238
- }
239
-
240
- /* ---------------- serve: HTTP API + realtime bridge ---------------- */
241
-
242
- export function cmdServe(cwd) {
243
- let state = requireState(cwd);
244
- const port = state.settings.port ?? DEFAULT_API_PORT;
245
-
246
- // External `rotate`/`revoke` (CLI in another terminal) must take effect on
247
- // the running agent. The watcher keeps the realtime push path fresh, but it
248
- // is best-effort — the HTTP handler additionally calls adoptFromDisk() per
249
- // request, because auth against a stale cached secret is a security hole.
250
- const adoptFromDisk = () => {
251
- const next = freshState(cwd, state);
252
- if (next === state) return;
253
- if (next.secret !== state.secret || next.revoked !== state.revoked) {
254
- log(next.revoked ? 'pairing revoked (picked up from disk)' : 'key rotated (picked up from disk)');
255
- }
256
- state = next;
257
- };
258
- let watcher = null;
259
- try {
260
- let reloadTimer = null;
261
- watcher = watch(join(cwd, STATE_DIR), () => {
262
- if (reloadTimer) return;
263
- reloadTimer = setTimeout(() => {
264
- reloadTimer = null;
265
- adoptFromDisk();
266
- }, 150);
267
- });
268
- } catch {
269
- /* watch unsupported — restart the agent after CLI rotate/revoke */
270
- }
271
-
272
- /** id -> finding; seq is a monotonically increasing cursor. */
273
- const findings = new Map();
274
- /** id -> seq at deletion time — tombstones so Emblema's poll learns about
275
- removals instead of showing deleted comments forever. Cleared when the
276
- same id comes back (undo / re-add). */
277
- const deletedIds = new Map();
278
- let seq = 0;
279
- /** route -> live WebSocket (for status write-back). */
280
- const sockets = new Map();
281
- let pushTimer = null;
282
- const pushQueue = new Map(); // id -> raw item
283
-
284
- // Merge-write: only lastAck belongs to the running agent — a full dump
285
- // would clobber a secret rotated by the CLI in another process.
286
- const persistAck = () => {
287
- const disk = loadState(cwd) ?? state;
288
- disk.lastAck = state.lastAck;
289
- saveState(cwd, disk);
290
- };
291
-
292
- const upsertItem = (item) => {
293
- if (!item || typeof item.id !== 'string') return;
294
- seq += 1;
295
- deletedIds.delete(item.id);
296
- findings.set(item.id, toFinding(item, seq));
297
- queuePush(item);
298
- };
299
- // Tombstone unconditionally: a delete for an id this process never saw (a
300
- // ghost row left in the Emblema tile by an older/restarted agent) must still
301
- // be reportable under `deleted`, or the row can never be cleared.
302
- const dropItem = (id) => {
303
- // Already tombstoned and not live: nothing changed — don't churn the
304
- // cursor (rooms replay their `deleted` list on every reconnect).
305
- if (!findings.delete(id) && deletedIds.has(id)) return;
306
- seq += 1;
307
- deletedIds.set(id, seq);
308
- };
309
-
310
- /* -- realtime push to the Emblema intake (headers per contract v2) -- */
311
- function queuePush(item) {
312
- if (state.revoked) return;
313
- pushQueue.set(item.id, item);
314
- if (pushTimer) return;
315
- pushTimer = setTimeout(() => {
316
- pushTimer = null;
317
- void flushPush();
318
- }, PUSH_DEBOUNCE_MS);
319
- }
320
-
321
- async function flushPush() {
322
- const items = [...pushQueue.values()];
323
- pushQueue.clear();
324
- if (items.length === 0 || state.revoked) return;
325
- const body = {
326
- v: 1,
327
- source: 'review-kit',
328
- batchId: `rk-batch-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`,
329
- sentAt: new Date().toISOString(),
330
- repoPath: state.settings.repoPath,
331
- site: { origin: state.settings.origin, reviewUrl: state.settings.origin },
332
- sessionAuthor: '',
333
- items,
334
- };
335
- try {
336
- const res = await fetch(state.settings.intakeUrl, {
337
- method: 'POST',
338
- headers: {
339
- 'content-type': 'application/json',
340
- authorization: `Bearer ${state.secret}`,
341
- 'x-reviewkit-key-id': state.id,
342
- },
343
- body: JSON.stringify(body),
344
- });
345
- if (res.ok) {
346
- state.lastAck = new Date().toISOString();
347
- persistAck();
348
- log(`push: ${items.length} item(s) → intake ok`);
349
- } else {
350
- log(`push: intake answered HTTP ${res.status}`);
351
- }
352
- } catch {
353
- log('push: Emblema intake unreachable (will retry on next change)');
354
- }
355
- }
356
-
357
- /* -- rk-sync rooms: one socket per configured route -- */
358
- function connectRoom(route) {
359
- if (!state.settings.syncToken) {
360
- log('no syncToken configured — realtime bridge disabled (API still serves)');
361
- return;
362
- }
363
- let ws;
364
- let closed = false;
365
- let attempts = 0;
366
- const open = () => {
367
- if (closed) return;
368
- ws = new WebSocket(state.settings.syncUrl);
369
- ws.onopen = () => {
370
- attempts = 0;
371
- ws.send(
372
- JSON.stringify({
373
- t: 'hello',
374
- v: RK_SYNC_PROTOCOL_VERSION,
375
- token: state.settings.syncToken,
376
- room: { origin: state.settings.origin, route },
377
- user: { id: `rk-emblema-${state.id.slice(0, 18)}`, name: 'Emblema sync', color: '#6b7280' },
378
- }),
379
- );
380
- };
381
- ws.onmessage = (event) => {
382
- let msg;
383
- try {
384
- msg = JSON.parse(String(event.data));
385
- } catch {
386
- return;
387
- }
388
- switch (msg.t) {
389
- case 'welcome': {
390
- sockets.set(route, ws);
391
- log(`room ${route}: joined (${(msg.items ?? []).length} items)`);
392
- for (const item of msg.items ?? []) {
393
- seq += 1;
394
- deletedIds.delete(item.id);
395
- findings.set(item.id, toFinding(item, seq));
396
- }
397
- for (const id of msg.deleted ?? []) dropItem(id);
398
- break;
399
- }
400
- case 'item:add':
401
- upsertItem(msg.item);
402
- break;
403
- case 'item:update': {
404
- const existing = findings.get(msg.id)?._raw;
405
- if (existing) upsertItem({ ...existing, ...msg.patch, id: msg.id });
406
- break;
407
- }
408
- case 'item:status': {
409
- const existing = findings.get(msg.id)?._raw;
410
- if (existing) upsertItem({ ...existing, status: msg.status });
411
- break;
412
- }
413
- case 'item:delete':
414
- dropItem(msg.id);
415
- break;
416
- case 'ping':
417
- ws.send(JSON.stringify({ t: 'pong' }));
418
- break;
419
- default:
420
- break;
421
- }
422
- };
423
- ws.onclose = () => {
424
- sockets.delete(route);
425
- if (closed) return;
426
- const delay = Math.min(15000, 500 * 2 ** attempts++);
427
- setTimeout(open, delay);
428
- };
429
- ws.onerror = () => {};
430
- };
431
- open();
432
- return () => {
433
- closed = true;
434
- try {
435
- ws?.close();
436
- } catch {}
437
- };
438
- }
439
-
440
- for (const route of state.settings.routes) connectRoom(route);
441
-
442
- /* -- HTTP API -- */
443
- const CORS = {
444
- 'access-control-allow-origin': '*',
445
- 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
446
- 'access-control-allow-headers': 'content-type, authorization, x-reviewkit-key-id',
447
- };
448
- const send = (res, status, body) => {
449
- const text = JSON.stringify(body);
450
- res.writeHead(status, { 'content-type': 'application/json', ...CORS });
451
- res.end(text);
452
- };
453
-
454
- const server = createServer((req, res) => {
455
- const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
456
- if (req.method === 'OPTIONS') {
457
- res.writeHead(204, CORS);
458
- res.end();
459
- return;
460
- }
461
-
462
- // SECURITY: adopt the current on-disk key material before answering any
463
- // state or auth check — `rotate` and `revoke` from another process must
464
- // apply on the very next request, never only after a watcher event.
465
- adoptFromDisk();
466
-
467
- // Local management (no bearer): same localhost trust model as the Emblema
468
- // intake — these exist so the ReviewKit page UI can drive the pairing.
469
- if (url.pathname.startsWith('/api/emblema/local/')) {
470
- const op = url.pathname.slice('/api/emblema/local/'.length);
471
- if (req.method === 'GET' && op === 'state') {
472
- send(res, 200, {
473
- ok: true,
474
- paired: !state.revoked,
475
- revoked: !!state.revoked,
476
- id: state.id,
477
- name: state.name,
478
- url: state.url,
479
- lastAck: state.lastAck ?? null,
480
- findings: findings.size,
481
- // Additive: realtime-bridge health for status UIs. "off" = no
482
- // syncToken configured (bridge disabled by design), "connected" =
483
- // at least one room socket joined, "connecting" = configured but
484
- // not joined (booting, reconnect backoff, or a bad token).
485
- realtime: !state.settings.syncToken
486
- ? { state: 'off', rooms: 0 }
487
- : { state: sockets.size > 0 ? 'connected' : 'connecting', rooms: sockets.size },
488
- });
489
- return;
490
- }
491
- if (req.method === 'POST' && (op === 'generate' || op === 'rotate')) {
492
- state.secret = newSecret();
493
- state.revoked = false;
494
- saveState(cwd, state);
495
- send(res, 200, { ok: true, key: keyOf(state) });
496
- return;
497
- }
498
- if (req.method === 'GET' && op === 'key') {
499
- if (state.revoked) return send(res, 410, { ok: false, error: 'revoked' });
500
- send(res, 200, { ok: true, key: keyOf(state) });
501
- return;
502
- }
503
- if (req.method === 'POST' && op === 'revoke') {
504
- state.revoked = true;
505
- saveState(cwd, state);
506
- send(res, 200, { ok: true });
507
- return;
508
- }
509
- send(res, 404, { ok: false, error: 'not found' });
510
- return;
511
- }
512
-
513
- // Contract v2 endpoints — Bearer <secret> required.
514
- if (!url.pathname.startsWith('/api/emblema/')) {
515
- send(res, 404, { ok: false, error: 'not found' });
516
- return;
517
- }
518
- const auth = String(req.headers.authorization ?? '');
519
- const authorized = !state.revoked && auth.toLowerCase() === `bearer ${state.secret}`.toLowerCase();
520
- if (!authorized) {
521
- send(res, 401, { ok: false, error: state.revoked ? 'key revoked' : 'bad or missing bearer secret' });
522
- return;
523
- }
524
- state.lastAck = new Date().toISOString();
525
- persistAck();
526
-
527
- if (req.method === 'GET' && url.pathname === '/api/emblema/handshake') {
528
- send(res, 200, { ok: true, id: state.id, name: state.name, version: reviewkitVersion() });
529
- return;
530
- }
531
- if (req.method === 'GET' && url.pathname === '/api/emblema/findings') {
532
- const since = Number(url.searchParams.get('since') ?? 0) || 0;
533
- const list = [...findings.values()]
534
- .filter((f) => f._seq > since)
535
- .sort((a, b) => a._seq - b._seq)
536
- // route + target ride along (additive since contract v2.1): Emblema
537
- // builds its human "what was clicked" label from target.tagName /
538
- // textSnippet, which the flat finding shape does not carry.
539
- .map(({ _seq, _route, _raw, ...pub }) => ({ ...pub, route: _route, target: _raw?.target ?? null }));
540
- const deleted = [...deletedIds.entries()]
541
- .filter(([, at]) => at > since)
542
- .sort((a, b) => a[1] - b[1])
543
- .map(([id]) => id);
544
- send(res, 200, { cursor: String(seq), findings: list, deleted });
545
- return;
546
- }
547
- const statusMatch = url.pathname.match(/^\/api\/emblema\/findings\/([^/]+)\/status$/);
548
- if (req.method === 'POST' && statusMatch) {
549
- let body = '';
550
- req.on('data', (c) => {
551
- body += c;
552
- if (body.length > 4096) req.destroy();
553
- });
554
- req.on('end', () => {
555
- let status;
556
- try {
557
- status = JSON.parse(body).status;
558
- } catch {
559
- return send(res, 400, { ok: false, error: 'invalid JSON' });
560
- }
561
- const mapped = STATUS_MAP[status];
562
- if (!mapped) {
563
- // Echo what arrived: the live 400s on `open` were an OLD installed
564
- // agent, and a bare "must be …" message made that undiagnosable.
565
- return send(res, 400, {
566
- ok: false,
567
- error: `status must be open|accepted|dismissed|fixed (received ${JSON.stringify(status ?? null)})`,
568
- received: status ?? null,
569
- });
570
- }
571
- const finding = findings.get(decodeURIComponent(statusMatch[1]));
572
- if (!finding) return send(res, 404, { ok: false, error: 'unknown finding' });
573
- const ws = sockets.get(finding._route);
574
- if (ws && ws.readyState === 1) {
575
- ws.send(JSON.stringify({ t: 'item:status', id: finding.id, status: mapped }));
576
- }
577
- upsertItem({ ...finding._raw, status: mapped });
578
- send(res, 200, { ok: true });
579
- });
580
- return;
581
- }
582
- // DELETE /findings/{id} (POST …/delete for clients that cannot send DELETE).
583
- // IDEMPOTENT: an unknown id still answers 200 and is tombstoned, so a ghost
584
- // row left in the tile by an older/restarted agent can always be cleared.
585
- // No body is read — the id is entirely in the path.
586
- const deleteMatch =
587
- (req.method === 'DELETE' && url.pathname.match(/^\/api\/emblema\/findings\/([^/]+)$/)) ||
588
- (req.method === 'POST' && url.pathname.match(/^\/api\/emblema\/findings\/([^/]+)\/delete$/));
589
- if (deleteMatch) {
590
- const id = decodeURIComponent(deleteMatch[1]);
591
- const finding = findings.get(id);
592
- // Write-back first, while _route is still known: the site pin must
593
- // disappear for every reviewer, not just in the tile.
594
- const ws = finding && sockets.get(finding._route);
595
- if (ws && ws.readyState === 1) ws.send(JSON.stringify({ t: 'item:delete', id }));
596
- dropItem(id);
597
- send(res, 200, { ok: true, id, known: !!finding });
598
- return;
599
- }
600
- send(res, 404, { ok: false, error: 'not found' });
601
- });
602
-
603
- server.listen(port, '127.0.0.1', () => {
604
- log(`API on ${state.url} (project "${state.name}", id ${state.id})`);
605
- if (state.revoked) log('WARNING: pairing is revoked — rotate to issue a fresh key');
606
- });
607
-
608
- // Handle for tests: the server plus a teardown closing the dir watcher too.
609
- // upsertItem/dropItem are exposed so the /findings tombstone contract can
610
- // be exercised without a live rk-sync room.
611
- return {
612
- server,
613
- upsertItem,
614
- dropItem,
615
- close() {
616
- try {
617
- watcher?.close();
618
- } catch {
619
- /* already closed */
620
- }
621
- server.close();
622
- },
623
- };
624
- }
625
-
626
- function log(message) {
627
- console.log(`[reviewkit-emblema] ${message}`);
628
- }
629
-
630
- /* ---------------- entry ---------------- */
631
-
632
- // Realpath BOTH sides: npm links the bin as a symlink (node_modules/.bin/…)
633
- // and a file: install symlinks the whole package dir, while ESM resolves
634
- // import.meta.url to the real path — a lexical resolve() comparison made
635
- // every npx/bin invocation silently no-op with exit 0 (init "succeeded"
636
- // without writing anything, serve returned immediately).
637
- function realOrSelf(p) {
638
- try {
639
- return realpathSync(p);
640
- } catch {
641
- return resolve(p);
642
- }
643
- }
644
- const isMain = process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url));
645
- if (isMain) {
646
- const [command, ...rest] = process.argv.slice(2);
647
- const cwd = process.cwd();
648
- const flags = parseFlags(rest);
649
- switch (command) {
650
- case 'init':
651
- cmdInit(cwd, flags);
652
- break;
653
- case 'key':
654
- cmdKey(cwd);
655
- break;
656
- case 'rotate':
657
- cmdRotate(cwd);
658
- break;
659
- case 'revoke':
660
- cmdRevoke(cwd);
661
- break;
662
- case 'serve':
663
- cmdServe(cwd);
664
- break;
665
- default:
666
- console.log(`reviewkit-emblema <init|key|rotate|revoke|serve>
667
- init --origin <https://site> [--name X] [--sync-token T] [--sync-url U]
668
- [--routes /,/about] [--repo <abs>] [--port N] [--intake-url U]
669
- key print the pairing key (emk_…) to paste into Emblema
670
- rotate issue a new secret (old key stops working)
671
- revoke disable the pairing
672
- serve run the local API + realtime bridge`);
673
- process.exit(command ? 1 : 0);
674
- }
675
- }