@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.
- package/CHANGELOG.md +158 -0
- package/README.md +158 -64
- package/dist/activate.cjs +150 -0
- package/dist/activate.cjs.map +1 -0
- package/dist/activate.d.cts +59 -0
- package/dist/activate.d.ts +59 -0
- package/dist/activate.js +21 -0
- package/dist/activate.js.map +1 -0
- package/dist/{chunk-YWBFAV57.js → chunk-5MAFDRUI.js} +1161 -1555
- package/dist/chunk-5MAFDRUI.js.map +1 -0
- package/dist/{chunk-4YNLMSCK.js → chunk-DG6O5XIC.js} +36 -10
- package/dist/chunk-DG6O5XIC.js.map +1 -0
- package/dist/chunk-EOX3UJNP.js +36 -0
- package/dist/chunk-EOX3UJNP.js.map +1 -0
- package/dist/chunk-ETAGGIDN.js +119 -0
- package/dist/chunk-ETAGGIDN.js.map +1 -0
- package/dist/client/index.cjs +4834 -4244
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.cts +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +49 -3
- package/dist/client/index.js.map +1 -1
- package/dist/effects-EWKHKUDP.js +851 -0
- package/dist/effects-EWKHKUDP.js.map +1 -0
- package/dist/index-CWGkg1-E.d.cts +86 -0
- package/dist/index-DPsjkvaY.d.ts +86 -0
- package/dist/index.cjs +4852 -4267
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +35 -3
- package/dist/index.js.map +1 -1
- package/dist/lazy.cjs +9308 -0
- package/dist/lazy.cjs.map +1 -0
- package/dist/lazy.d.cts +7 -0
- package/dist/lazy.d.ts +7 -0
- package/dist/lazy.js +8 -0
- package/dist/lazy.js.map +1 -0
- package/dist/next.cjs +9309 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +4 -0
- package/dist/next.d.ts +4 -0
- package/dist/next.js +9 -0
- package/dist/next.js.map +1 -0
- package/dist/schema.cjs +53 -16
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +20 -5
- package/dist/schema.d.ts +20 -5
- package/dist/schema.js +30 -2
- package/dist/schema.js.map +1 -1
- package/package.json +23 -8
- package/cli/emblema-sync.mjs +0 -675
- package/dist/chunk-4YNLMSCK.js.map +0 -1
- package/dist/chunk-YWBFAV57.js.map +0 -1
- package/dist/index-BbucFgZi.d.ts +0 -49
- package/dist/index-CBlJrKkm.d.cts +0 -49
package/cli/emblema-sync.mjs
DELETED
|
@@ -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
|
-
}
|