kenaz 0.3.19 → 0.3.21

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 (65) hide show
  1. package/core/.claude/commands/Ansuz.md +1 -0
  2. package/core/.claude/commands/architect.md +3 -0
  3. package/core/.claude/commands/incident.md +3 -0
  4. package/core/.claude/commands/review.md +3 -0
  5. package/core/.claude/commands/security.md +3 -0
  6. package/core/.claude/commands/spec.md +3 -0
  7. package/core/.claude/skills/architect_agent/SKILL.md +1 -1
  8. package/core/.claude/skills/assessment_agent/SKILL.md +1 -1
  9. package/core/.claude/skills/code_reviewer/SKILL.md +1 -1
  10. package/core/.claude/skills/incident_response/SKILL.md +1 -1
  11. package/core/.claude/skills/security_agent/SKILL.md +1 -1
  12. package/core/.claude/skills/spec_optimizer/SKILL.md +1 -1
  13. package/core/.formation/bifrost/codex-hooks-repair.js +115 -0
  14. package/core/.formation/bifrost/codex-hooks-repair.test.js +94 -0
  15. package/core/.formation/bifrost/openai/codex-hook-shim.js +5 -3
  16. package/core/.formation/bifrost/openai/codex-hook-shim.test.js +2 -0
  17. package/core/.formation/bifrost/setup-codex.js +22 -12
  18. package/core/.formation/tools/__tests__/arms.test.js +41 -0
  19. package/core/.formation/tools/__tests__/diagnose-bug.test.js +10 -0
  20. package/core/.formation/tools/arms.js +48 -0
  21. package/core/.formation/tools/dispatch.js +23 -14
  22. package/core/.formation/tools/kenaz-stats.js +32 -8
  23. package/core/.formation/tools/kenaz-stats.test.js +15 -2
  24. package/core/.formation/tools/prompt.js +16 -1
  25. package/core/.formation/tools/prompt.test.js +20 -0
  26. package/core/.specialist/Ansuz/RULES.md +7 -0
  27. package/core/.specialist/tools/ansuz-boot-fast.js +7 -4
  28. package/core/.specialist/tools/ansuz-boot-fast.test.js +10 -3
  29. package/core/KENAZ_CORE_VERSION +1 -1
  30. package/core/dev/scripts/peer-identity-e2e.js +402 -0
  31. package/core/dev/scripts/peer-identity-e2e.md +47 -0
  32. package/core/dev/scripts/relay-admin-ui.js +154 -0
  33. package/core/dev/scripts/relay-admin-ui.test.js +112 -0
  34. package/core/dev/scripts/relay-admin.js +103 -0
  35. package/core/dev/scripts/relay-admin.test.js +131 -0
  36. package/core/dev/scripts/skill-routing-replay.js +80 -0
  37. package/core/dev/scripts/skill-routing-replay.test.js +24 -0
  38. package/core/dev/specs/peer/codex-hook-shim-peer.test.js +17 -5
  39. package/core/plugins/kenaz/INVENTORY.md +1 -1
  40. package/core/plugins/kenaz/README.md +10 -0
  41. package/core/plugins/kenaz/commands/Ansuz.md +1 -0
  42. package/core/plugins/kenaz/commands/architect.md +3 -0
  43. package/core/plugins/kenaz/commands/incident.md +3 -0
  44. package/core/plugins/kenaz/commands/review.md +3 -0
  45. package/core/plugins/kenaz/commands/security.md +3 -0
  46. package/core/plugins/kenaz/commands/spec.md +3 -0
  47. package/core/plugins/kenaz/hooks/kenaz-hook.js +2 -1
  48. package/core/plugins/kenaz/hooks/sdk-gate.js +9 -0
  49. package/core/plugins/kenaz/scripts/kenaz-mode.js +5 -0
  50. package/core/plugins/kenaz/scripts/kenaz.test.js +15 -3
  51. package/core/plugins/kenaz/scripts/sync.js +1 -1
  52. package/core/plugins/kenaz/scripts/ygg.js +8 -0
  53. package/core/plugins/kenaz/scripts/ygg.test.js +72 -0
  54. package/core/plugins/kenaz/skills/architect_agent/SKILL.md +1 -1
  55. package/core/plugins/kenaz/skills/assessment_agent/SKILL.md +1 -1
  56. package/core/plugins/kenaz/skills/code_reviewer/SKILL.md +1 -1
  57. package/core/plugins/kenaz/skills/incident_response/SKILL.md +1 -1
  58. package/core/plugins/kenaz/skills/security_agent/SKILL.md +1 -1
  59. package/core/plugins/kenaz/skills/spec_optimizer/SKILL.md +1 -1
  60. package/core/plugins/kenaz-peer/hooks/register.test.ts +32 -0
  61. package/core/plugins/kenaz-peer/hooks/register.ts +9 -0
  62. package/lib/cli.js +4 -0
  63. package/package.json +6 -6
  64. package/core/plugins/kenaz/skills/feature_agent/SKILL.md +0 -289
  65. /package/core/{.claude → dev/archive}/skills/feature_agent/SKILL.md +0 -0
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // TASK_2458: end-to-end usage test of peer identity (TASK_2447-2452, 2456, 2457) against real binaries.
4
+ // Fake registry + local relay (new and old) + kenaz-servers (new and old) in temp homes, ports 18900-18999.
5
+ // Never touches the real ~/.kenaz, ~/.claude, ~/.codex or the user's server on 18080; kills only PIDs it spawned.
6
+ // Usage: node dev/scripts/peer-identity-e2e.js [--control-wrong-fingerprint] [--keep] (see peer-identity-e2e.md)
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const http = require('http');
11
+ const { spawn, execFileSync, spawnSync } = require('child_process');
12
+
13
+ const REPO = path.resolve(__dirname, '..', '..');
14
+ const EXE = process.platform === 'win32' ? '.exe' : '';
15
+ const BIN = {
16
+ relay: process.env.E2E_RELAY || `C:/t/relay-target/debug/yggdrasil-relay${EXE}`,
17
+ relayOld: process.env.E2E_RELAY_OLD || `C:/t/relay-old-target/debug/yggdrasil-relay${EXE}`,
18
+ server: process.env.E2E_SERVER || `C:/t/kenaz-e2e-target/debug/kenaz-server${EXE}`,
19
+ serverOldSrc: process.env.E2E_SERVER_OLD || path.join(os.homedir(), '.kenaz', 'bin', `kenaz-server${EXE}`),
20
+ };
21
+ const ARGS = new Set(process.argv.slice(2));
22
+ const CONTROL = ARGS.has('--control-wrong-fingerprint');
23
+ const KEYS = { t1: 'ygk_e2e_team_one_0001', t2: 'ygk_e2e_team_two_0002' };
24
+ const TEAMS = { [KEYS.t1]: 'e2e-team-1', [KEYS.t2]: 'e2e-team-2' };
25
+ const PORT = { registry: 18900, relay: 18901, relayOld: 18902, A: 18910, B: 18911, fern: 18912, olive: 18913, D: 18914, E: 18915 };
26
+ const ROOM = 'e2e-room';
27
+ const ADMIN_KEY = 'e2e-admin-key-not-a-secret';
28
+
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+ const results = [];
31
+ const procs = [];
32
+ let TMP;
33
+
34
+ // ---------- small helpers ----------
35
+ async function call(method, url, { headers = {}, body } = {}) {
36
+ const res = await fetch(url, { method, headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers }, body: body ? JSON.stringify(body) : undefined });
37
+ const text = await res.text();
38
+ let json; try { json = JSON.parse(text); } catch { /* not json */ }
39
+ return { status: res.status, text, json };
40
+ }
41
+ async function until(what, fn, ms = 60000, every = 1000) {
42
+ const end = Date.now() + ms; let last;
43
+ while (Date.now() < end) { try { last = await fn(); if (last) return last; } catch (e) { last = e; } await sleep(every); }
44
+ throw new Error(`timeout (${ms / 1000}s) waiting for ${what}${last instanceof Error ? ': ' + last.message : ''}`);
45
+ }
46
+ function portFree(port) {
47
+ return new Promise((resolve) => {
48
+ const s = require('net').createServer().once('error', () => resolve(false)).once('listening', () => s.close(() => resolve(true)));
49
+ s.listen(port, '127.0.0.1');
50
+ });
51
+ }
52
+ function start(name, exe, args, env, cwd) {
53
+ const log = fs.openSync(path.join(TMP, `${name}.log`), 'a');
54
+ const child = spawn(exe, args, { env, cwd, stdio: ['ignore', log, log], windowsHide: true });
55
+ const p = { name, child, pid: child.pid, exited: false };
56
+ child.on('exit', () => { p.exited = true; });
57
+ procs.push(p);
58
+ return p;
59
+ }
60
+ async function stop(p) {
61
+ if (!p || p.exited) return;
62
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(p.pid), '/T', '/F'], { stdio: 'ignore' }); // our PID only
63
+ else p.child.kill('SIGKILL');
64
+ await until(`${p.name} exit`, () => p.exited, 15000, 100).catch(() => {});
65
+ }
66
+ function sh(cmd, args, env) {
67
+ const r = spawnSync(cmd, args, { env: { ...process.env, ...env }, encoding: 'utf8' });
68
+ return { code: r.status, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
69
+ }
70
+ function sqlite(file, readOnly = true) {
71
+ const { DatabaseSync } = require('node:sqlite');
72
+ return new DatabaseSync(file, { readOnly });
73
+ }
74
+
75
+ // ---------- fake registry ----------
76
+ function startRegistry() {
77
+ const srv = http.createServer((req, res) => {
78
+ let b = ''; req.on('data', (c) => (b += c));
79
+ req.on('end', () => {
80
+ let key; try { key = JSON.parse(b).p_key; } catch { /* ignore */ }
81
+ res.setHeader('Content-Type', 'application/json');
82
+ if (req.method === 'POST' && req.url === '/rest/v1/rpc/exchange_key' && TEAMS[key]) {
83
+ res.end(JSON.stringify({ team_id: TEAMS[key], team_name: TEAMS[key], max_nodes: 10 }));
84
+ } else res.end('null');
85
+ });
86
+ });
87
+ return new Promise((r) => srv.listen(PORT.registry, '127.0.0.1', () => r(srv)));
88
+ }
89
+
90
+ // ---------- relay ----------
91
+ function startRelay(name, exe, port, db) {
92
+ const env = { ...process.env, RELAY_PORT: String(port), RELAY_DB: db, REGISTRY_URL: `http://127.0.0.1:${PORT.registry}`, REGISTRY_KEY: 'e2e-registry-key',
93
+ RELAY_ADMIN_KEY_FILE: path.join(TMP, 'admin.key'), RUST_LOG: 'yggdrasil_relay=info,warn' };
94
+ delete env.RELAY_ADMIN_KEY; delete env.REGISTRY_SERVICE_KEY;
95
+ return start(name, exe, [], env, TMP);
96
+ }
97
+ const relayUrl = (port) => `http://127.0.0.1:${port}`;
98
+ const bearer = (key) => ({ Authorization: `Bearer ${key}` });
99
+ const admin = (port, ...a) => sh(process.execPath, [path.join(REPO, 'dev/scripts/relay-admin.js'), '--relay', relayUrl(port), ...a], { RELAY_ADMIN_KEY_FILE: path.join(TMP, 'admin.key'), RELAY_ADMIN_KEY: '' });
100
+
101
+ // ---------- kenaz-server ----------
102
+ function sanitizedEnv(dir, port, src) {
103
+ const sys = process.env.SystemRoot || 'C:\\Windows';
104
+ const env = { SystemRoot: sys, ComSpec: process.env.ComSpec || '', PATH: process.platform === 'win32' ? `${sys}\\System32;${sys}` : '/usr/bin:/bin',
105
+ USERPROFILE: path.join(dir, 'home'), HOME: path.join(dir, 'home'), APPDATA: path.join(dir, 'appdata'), LOCALAPPDATA: path.join(dir, 'localappdata'),
106
+ TEMP: path.join(dir, 'tmp'), TMP: path.join(dir, 'tmp'), KENAZ_SOURCE_DIR: src, KENAZ_HTTP_PORT: String(port), RUST_LOG: 'warn' };
107
+ for (const d of ['home', 'appdata', 'localappdata', 'tmp']) fs.mkdirSync(path.join(dir, d), { recursive: true });
108
+ return env;
109
+ }
110
+ class Server {
111
+ constructor(name, exe, port, relayPort, teamKey, node) {
112
+ Object.assign(this, { name, exe, port, relayPort, teamKey, node });
113
+ this.dir = path.join(TMP, name);
114
+ this.project = path.join(this.dir, 'proj').replace(/\\/g, '/');
115
+ this.src = path.join(this.dir, 'src');
116
+ this.url = `http://127.0.0.1:${port}`;
117
+ }
118
+ async setup() {
119
+ fs.mkdirSync(path.join(this.project, '.kenaz'), { recursive: true });
120
+ fs.mkdirSync(path.join(this.dir, 'home', '.kenaz'), { recursive: true });
121
+ fs.mkdirSync(this.src, { recursive: true });
122
+ fs.writeFileSync(path.join(this.project, '.kenaz', 'project.yaml'), `name: e2e-${this.name}\n`);
123
+ fs.writeFileSync(path.join(this.project, '.kenaz', 'yggdrasil.yaml'),
124
+ `relay_url: "http://127.0.0.1:${this.relayPort}"\nnode_name: "${this.node}"\nyggdrasil_project: "${ROOM}"\ncollaboration_projects: "${ROOM}"\n`);
125
+ // Registered before the first start so the presence heartbeat (every 90 s, first tick at boot) already includes this project.
126
+ fs.writeFileSync(path.join(this.dir, 'home', '.kenaz', 'projects.json'), JSON.stringify({ currentProjectId: `e2e-${this.name}`, projects: [
127
+ { id: `e2e-${this.name}`, name: `e2e-${this.name}`, path: this.project, pipelinePath: 'dev/pipeline', createdAt: new Date().toISOString(), is_core: false, core_path: this.project }] }));
128
+ fs.writeFileSync(path.join(this.dir, 'home', '.kenaz', 'credentials.yaml'), `publishable_key: "${this.teamKey}"\n`);
129
+ await this.run();
130
+ const r = await call('POST', `${this.url}/api/projects/register`, { body: { projectPath: this.project } });
131
+ if (!r.json || !r.json.success) throw new Error(`${this.name} register failed: ${r.text}`);
132
+ }
133
+ async run() {
134
+ if (!(await portFree(this.port))) throw new Error(`port ${this.port} is busy; refusing to start ${this.name}`);
135
+ this.proc = start(this.name, this.exe, [], sanitizedEnv(this.dir, this.port, this.src), this.dir);
136
+ await until(`${this.name} http`, async () => (await call('GET', `${this.url}/api/peer/status?projectPath=${encodeURIComponent(this.project)}`)).status === 200, 60000, 500);
137
+ }
138
+ async restart() { await stop(this.proc); await this.run(); }
139
+ status() { return call('GET', `${this.url}/api/peer/status?projectPath=${encodeURIComponent(this.project)}`).then((r) => r.json); }
140
+ async identity() { return ((await this.status()) || {}).node_identity || {}; }
141
+ async send(to, body) {
142
+ const r = await call('POST', `${this.url}/api/mcp`, { body: { jsonrpc: '2.0', id: 1, method: 'tools/call',
143
+ params: { name: 'kenaz_peer_send', arguments: { projectPath: this.project, to, kind: 'notice', body } } } });
144
+ const text = JSON.stringify(r.json || r.text);
145
+ const err = !!(r.json && ((r.json.result && r.json.result.isError) || r.json.error));
146
+ return { ok: r.status === 200 && !err, text };
147
+ }
148
+ async inbox() {
149
+ const r = await call('GET', `${this.url}/api/peer/pending?projectPath=${encodeURIComponent(this.project)}&peek=true`);
150
+ return JSON.stringify(r.json || r.text);
151
+ }
152
+ async waitMail(marker, ms = 75000) {
153
+ let box = '';
154
+ await until(`${this.name} to receive ${marker}`, async () => ((box = await this.inbox()).includes(marker)), ms, 2000);
155
+ // Each message object is {body, created_at, framed: "[Peer message — from X (<label>), thread ...", ...}: the framed text follows the body.
156
+ const at = box.indexOf(marker);
157
+ const m = /\((verified|unverified sender|team-claimed)\), thread/.exec(box.slice(at).split('{"body"')[0]);
158
+ return m ? m[1] : `no label found in ${box.slice(Math.max(0, at - 100), at + 400)}`;
159
+ }
160
+ // Send, retrying while the target node has not shown up in the relay presence table yet.
161
+ async sendWhenKnown(to, body) {
162
+ let r;
163
+ await until(`${this.name} send to ${to} accepted`, async () => ((r = await this.send(to, body)).ok), 90000, 3000).catch((e) => { throw new Error(`${e.message}; last reply ${r && r.text.slice(0, 200)}`); });
164
+ return r;
165
+ }
166
+ }
167
+
168
+ // ---------- scenario runner ----------
169
+ async function scenario(n, title, fn) {
170
+ const t0 = Date.now();
171
+ try {
172
+ const ev = await fn();
173
+ results.push({ n, title, pass: true, ev });
174
+ } catch (e) {
175
+ results.push({ n, title, pass: false, ev: e.message });
176
+ }
177
+ const r = results[results.length - 1];
178
+ console.log(`[${r.pass ? 'PASS' : 'FAIL'}] ${n}. ${title} (${Math.round((Date.now() - t0) / 1000)}s)\n ${r.ev}`);
179
+ }
180
+ const need = (cond, msg) => { if (!cond) throw new Error(msg); };
181
+
182
+ // ---------- isolation checks ----------
183
+ function tree(dir, depth, out = {}) {
184
+ let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
185
+ for (const e of ents) {
186
+ const p = path.join(dir, e.name);
187
+ if (e.isDirectory()) { if (depth > 0) tree(p, depth - 1, out); } else { try { out[p] = fs.statSync(p).mtimeMs; } catch { /* gone */ } }
188
+ }
189
+ return out;
190
+ }
191
+ const realDirs = () => ['.kenaz', '.claude', '.codex'].map((d) => path.join(os.homedir(), d));
192
+ const gitState = () => sh('git', ['-C', REPO, 'status', '--porcelain']).out;
193
+
194
+ async function main() {
195
+ const missing = Object.entries({ relay: BIN.relay, relayOld: BIN.relayOld, server: BIN.server, serverOldSrc: BIN.serverOldSrc }).filter(([, p]) => !fs.existsSync(p));
196
+ if (missing.length) { console.error(`missing binaries: ${missing.map(([k, p]) => `${k}=${p}`).join(', ')}`); return 2; }
197
+ for (const p of Object.values(PORT)) if (!(await portFree(p))) { console.error(`port ${p} busy; aborting`); return 2; }
198
+ TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'kenaz-peer-e2e-'));
199
+ const runId = path.basename(TMP);
200
+ console.log(`peer-identity-e2e: temp dir ${TMP}${CONTROL ? ' (control run: wrong fingerprint in scenario 3)' : ''}`);
201
+ const gitBefore = gitState();
202
+ const realBefore = realDirs().map((d) => tree(d, 3));
203
+ const oldBinStat = fs.statSync(BIN.serverOldSrc).mtimeMs;
204
+ fs.writeFileSync(path.join(TMP, 'admin.key'), ADMIN_KEY);
205
+ const oldExe = path.join(TMP, 'old-server', `kenaz-server${EXE}`);
206
+ fs.mkdirSync(path.dirname(oldExe), { recursive: true });
207
+ fs.copyFileSync(BIN.serverOldSrc, oldExe);
208
+ for (const f of fs.readdirSync(path.dirname(BIN.serverOldSrc))) if (/\.dll$/i.test(f)) fs.copyFileSync(path.join(path.dirname(BIN.serverOldSrc), f), path.join(path.dirname(oldExe), f));
209
+
210
+ let registry;
211
+ try {
212
+ registry = await startRegistry();
213
+ const relayDb = path.join(TMP, 'relay.db');
214
+ let relay = startRelay('relay', BIN.relay, PORT.relay, relayDb);
215
+ const oldRelay = startRelay('relay-old', BIN.relayOld, PORT.relayOld, path.join(TMP, 'relay-old.db'));
216
+ for (const [p, n] of [[PORT.relay, 'relay'], [PORT.relayOld, 'relay-old']]) await until(`${n} health`, async () => (await call('GET', `${relayUrl(p)}/health`)).status === 200, 30000, 300);
217
+ const R = relayUrl(PORT.relay);
218
+ const T1 = TEAMS[KEYS.t1], T2 = TEAMS[KEYS.t2];
219
+
220
+ const A = new Server('A', BIN.server, PORT.A, PORT.relay, KEYS.t1, 'node-a');
221
+ const B = new Server('B', BIN.server, PORT.B, PORT.relay, KEYS.t1, 'node-b');
222
+ const fern = new Server('fern', BIN.server, PORT.fern, PORT.relay, KEYS.t2, 'fern');
223
+ const olive = new Server('olive', oldExe, PORT.olive, PORT.relay, KEYS.t2, 'olive');
224
+ const D = new Server('D', BIN.server, PORT.D, PORT.relayOld, KEYS.t1, 'node-d');
225
+ const E = new Server('E', BIN.server, PORT.E, PORT.relayOld, KEYS.t1, 'node-e');
226
+ for (const s of [A, B, fern, olive, D, E]) await s.setup();
227
+ const fpOf = {};
228
+ const row = (sql, ...p) => sqlite(relayDb).prepare(sql).all(...p);
229
+
230
+ await scenario(1, 'A and B request tokens; pending + fingerprint in GET /api/peer/status', async () => {
231
+ for (const s of [A, B]) {
232
+ const id = await until(`${s.name} pending identity`, async () => { const i = await s.identity(); return i.state === 'pending' && i.fingerprint ? i : null; }, 90000, 2000);
233
+ fpOf[s.name] = id.fingerprint;
234
+ }
235
+ const rows = row("SELECT node_name, fingerprint, status FROM node_credentials WHERE team_id = ?", T1);
236
+ for (const [s, n] of [['A', 'node-a'], ['B', 'node-b']]) need(rows.some((r) => r.node_name === n && r.fingerprint === fpOf[s] && r.status === 'pending'), `no pending DB row for ${n}/${fpOf[s]}`);
237
+ const list = admin(PORT.relay, 'list');
238
+ need(list.out.includes(fpOf.A) && list.out.includes(fpOf.B), `admin list lacks fingerprints: ${list.out}`);
239
+ return `status.node_identity A=pending/${fpOf.A}, B=pending/${fpOf.B}; DB rows pending: ${JSON.stringify(rows)}; admin list shows both`;
240
+ });
241
+
242
+ await scenario(2, 'A -> B before approval: delivered, label (unverified sender)', async () => {
243
+ await A.sendWhenKnown('node-b', 'e2e-marker-s2');
244
+ const label = await B.waitMail('e2e-marker-s2');
245
+ need(label === 'unverified sender', `label was "${label}"`);
246
+ const r = row("SELECT from_node, to_node, sender_verified FROM task_events WHERE team_id = ? ORDER BY id DESC LIMIT 1", T1)[0];
247
+ need(r && r.sender_verified === 0, `DB sender_verified=${r && r.sender_verified}`);
248
+ return `B inbox label "(${label})"; relay row ${JSON.stringify(r)}`;
249
+ });
250
+
251
+ await scenario(3, 'operator approves both with relay-admin.js; servers pick up active via /me', async () => {
252
+ const outs = [];
253
+ outs.push(admin(PORT.relay, 'approve', T1, 'node-a', CONTROL ? 'ffffffffffff' : fpOf.A));
254
+ outs.push(admin(PORT.relay, 'approve', T1, 'node-b', fpOf.B));
255
+ need(outs.every((o) => o.code === 0), `approve failed: ${outs.map((o) => `${o.code} ${o.out}`).join(' | ')}`);
256
+ // The client re-reads /me at most every 10 minutes per process, so a restart is the fast path (token file persists on disk).
257
+ for (const s of [A, B]) await s.restart();
258
+ for (const s of [A, B]) await until(`${s.name} active`, async () => (await s.identity()).state === 'active', 60000, 2000);
259
+ const st = row("SELECT node_name, status FROM node_credentials WHERE team_id = ? AND status = 'active'", T1);
260
+ return `${outs.map((o) => o.out).join(' ; ')}; restart, then status A/B = active; DB active: ${JSON.stringify(st)}`;
261
+ });
262
+
263
+ await scenario(4, 'A -> B after approval: delivered, label (verified)', async () => {
264
+ await A.sendWhenKnown('node-b', 'e2e-marker-s4');
265
+ const label = await B.waitMail('e2e-marker-s4');
266
+ need(label === 'verified', `label was "${label}"`);
267
+ const r = row("SELECT sender_verified FROM task_events WHERE team_id = ? AND from_node = 'node-a' ORDER BY id DESC LIMIT 1", T1)[0];
268
+ need(r.sender_verified === 1, `DB sender_verified=${r.sender_verified}`);
269
+ return `B inbox label "(${label})"; relay row sender_verified=${r.sender_verified}`;
270
+ });
271
+
272
+ await scenario(5, 'protect_bound_names ON: tokenless curl as A / reading or acking B -> 403; A and B still deliver', async () => {
273
+ const on = await call('POST', `${R}/api/admin/team-settings`, { headers: { 'X-Admin-Key': ADMIN_KEY }, body: { team_id: T1, protect_bound_names: true } });
274
+ need(on.status === 200, `team-settings ${on.status}`);
275
+ const project = row("SELECT project FROM nodes WHERE team_id = ? AND node_name = 'node-b' LIMIT 1", T1)[0].project;
276
+ const h = bearer(KEYS.t1);
277
+ const ev = (extra) => ({ event_type: 'peer_message', task_id: 'thread-forged', from_node: 'node-a', to_node: 'node-b', project, payload: { body: 'forged' }, ...extra });
278
+ const forged = await call('POST', `${R}/api/task_events`, { headers: h, body: ev() });
279
+ const mailbox = await call('GET', `${R}/api/task_events/mailbox?to_node=node-b&project=${project}`, { headers: h });
280
+ const bId = row("SELECT id FROM task_events WHERE team_id = ? AND to_node = 'node-b' ORDER BY id DESC LIMIT 1", T1)[0].id;
281
+ const ack1 = await call('PATCH', `${R}/api/task_events/${bId}/ack`, { headers: h, body: { node_name: 'node-b', project } });
282
+ const ackN = await call('PATCH', `${R}/api/task_events/ack`, { headers: h, body: { node_name: 'node-b', ids: [bId], project } });
283
+ // controls: an unbound name is still open to tokenless callers, and the flag is per team
284
+ const ctlUnbound = await call('GET', `${R}/api/task_events/mailbox?to_node=nobody-bound&project=${project}`, { headers: h });
285
+ const ctlTeam2 = await call('GET', `${R}/api/task_events/mailbox?to_node=fern&project=${project}`, { headers: bearer(KEYS.t2) });
286
+ const got = { forged: forged.status, mailbox: mailbox.status, ack: ack1.status, ackBatch: ackN.status, ctlUnbound: ctlUnbound.status, ctlTeam2: ctlTeam2.status };
287
+ need(forged.status === 403 && mailbox.status === 403 && ack1.status === 403 && ackN.status === 403, `expected 403s: ${JSON.stringify(got)}`);
288
+ need(ctlUnbound.status === 200 && ctlTeam2.status === 200, `controls should be open: ${JSON.stringify(got)}`);
289
+ await A.sendWhenKnown('node-b', 'e2e-marker-s5-ab');
290
+ await B.sendWhenKnown('node-a', 'e2e-marker-s5-ba');
291
+ const l1 = await B.waitMail('e2e-marker-s5-ab'), l2 = await A.waitMail('e2e-marker-s5-ba');
292
+ need(l1 === 'verified' && l2 === 'verified', `labels ${l1}/${l2}`);
293
+ return `HTTP ${JSON.stringify(got)}; A->B (${l1}) and B->A (${l2}) delivered with the flag on`;
294
+ });
295
+
296
+ await scenario(6, 'ban A (TASK_2457): A next send refused, B unaffected', async () => {
297
+ const ban = admin(PORT.relay, 'ban', T1, 'node-a', 'e2e');
298
+ need(ban.code === 0, `ban failed: ${ban.out}`);
299
+ const blocked = admin(PORT.relay, 'blocked');
300
+ need(blocked.out.includes('node-a'), `blocked list: ${blocked.out}`);
301
+ const before = row("SELECT COUNT(*) AS n FROM task_events WHERE from_node = 'node-a'")[0].n;
302
+ const a = await A.send('node-b', 'e2e-marker-s6-a');
303
+ need(!a.ok, `A send was not refused: ${a.text.slice(0, 200)}`);
304
+ const after = row("SELECT COUNT(*) AS n FROM task_events WHERE from_node = 'node-a'")[0].n;
305
+ need(after === before, `a row from node-a was created (${before} -> ${after})`);
306
+ await B.sendWhenKnown('node-a', 'e2e-marker-s6-b');
307
+ const r = row("SELECT from_node, sender_verified FROM task_events WHERE from_node = 'node-b' ORDER BY id DESC LIMIT 1")[0];
308
+ need(r.sender_verified === 1, 'B send not verified');
309
+ const stA = row("SELECT status FROM node_credentials WHERE node_name = 'node-a' AND team_id = ?", T1).map((x) => x.status);
310
+ const retry = await call('POST', `${R}/api/node-credentials/request`, { headers: bearer(KEYS.t1), body: { node_name: 'node-a' } });
311
+ need(retry.status === 403, `banned name token request gave ${retry.status}`);
312
+ return `ban: ${ban.out.split('\n')[0]}; A send refused (${a.text.slice(0, 110)}); node-a tokens ${JSON.stringify(stA)}; new token request for node-a HTTP ${retry.status}; B send accepted, relay row sender_verified=${r.sender_verified}; no new node-a row (${before})`;
313
+ });
314
+
315
+ await scenario(7, 'relay cleanup: acked rows past ttl removed, unacked within grace kept, batch ack used', async () => {
316
+ const h = bearer(KEYS.t2);
317
+ const sinkProject = row("SELECT project FROM nodes WHERE team_id = ? AND node_name = 'fern' LIMIT 1", T2)[0].project;
318
+ const mk = async (tag) => {
319
+ const r = await call('POST', `${R}/api/task_events`, { headers: h, body: { event_type: 'peer_message', task_id: `t7-${tag}`, from_node: 'cleanup-src', to_node: 'fern', project: sinkProject, payload: { body: tag } } });
320
+ need(r.status === 201, `create ${tag}: ${r.status} ${r.text}`);
321
+ return r.json.id;
322
+ };
323
+ const ids = { ackedPast: await mk('acked-past'), ackedFuture: await mk('acked-future'), unackedGrace: await mk('unacked-1d-past'), unackedOld: await mk('unacked-31d-past'), ackedPast2: await mk('acked-past-2') };
324
+ const batch = await call('PATCH', `${R}/api/task_events/ack`, { headers: h, body: { node_name: 'fern', ids: [ids.ackedPast, ids.ackedFuture, ids.ackedPast2], project: sinkProject, instance_id: 'e2e' } });
325
+ need(batch.status === 200 && batch.json.acked.length === 3, `batch ack: ${batch.status} ${batch.text}`);
326
+ await stop(relay);
327
+ const db = sqlite(relayDb, false);
328
+ const iso = (days) => new Date(Date.now() - days * 86400000).toISOString();
329
+ const set = db.prepare('UPDATE task_events SET ttl_at = ? WHERE id = ?');
330
+ set.run(iso(1), ids.ackedPast); set.run(iso(2), ids.ackedPast2); set.run(iso(-6), ids.ackedFuture); set.run(iso(1), ids.unackedGrace); set.run(iso(31), ids.unackedOld);
331
+ db.close();
332
+ relay = startRelay('relay-2', BIN.relay, PORT.relay, relayDb); // startup runs one cleanup
333
+ await until('relay health', async () => (await call('GET', `${R}/health`)).status === 200, 30000, 300);
334
+ await until('cleanup ran', () => row('SELECT 1 FROM task_events WHERE id = ?', ids.ackedPast).length === 0, 30000, 500);
335
+ const kept = Object.entries(ids).filter(([, id]) => row('SELECT 1 FROM task_events WHERE id = ?', id).length).map(([k]) => k).sort();
336
+ const want = ['ackedFuture', 'unackedGrace'];
337
+ need(JSON.stringify(kept) === JSON.stringify(want), `kept ${kept}, wanted ${want}`);
338
+ return `batch ack HTTP 200 acked=${JSON.stringify(batch.json.acked)}; after one cleanup run (relay startup) deleted: ackedPast, ackedPast2, unackedOld(31d); kept: ${kept.join(', ')}`;
339
+ });
340
+
341
+ await scenario(8, 'old kenaz-server (copied binary) <-> new server on new relay, flag OFF: both ways, nothing refused', async () => {
342
+ await fern.sendWhenKnown('olive', 'e2e-marker-s8-fo');
343
+ await olive.sendWhenKnown('fern', 'e2e-marker-s8-of');
344
+ const lo = await olive.waitMail('e2e-marker-s8-fo'), lf = await fern.waitMail('e2e-marker-s8-of');
345
+ const flag = row("SELECT protect_bound_names FROM team_settings WHERE team_id = ?", T2);
346
+ need(flag.length === 0 || flag[0].protect_bound_names === 0, 'flag is on for team 2');
347
+ const refused = row("SELECT COUNT(*) AS n FROM task_events WHERE team_id = ? AND from_node IN ('fern','olive')", T2)[0].n;
348
+ const fid = await fern.identity();
349
+ return `fern(new)->olive(old) delivered, olive shows "(${lo})"; olive->fern delivered, fern shows "(${lf})"; flag off for team; relay rows ${refused}; fern identity ${fid.state}; old binary ${path.basename(path.dirname(oldExe))}/${path.basename(oldExe)} (${fs.statSync(oldExe).size} bytes)`;
350
+ });
351
+
352
+ await scenario(9, 'new kenaz-server vs OLD relay (11b8b0b15): token request fails quietly, send/receive work, no token', async () => {
353
+ await D.sendWhenKnown('node-e', 'e2e-marker-s9-de');
354
+ await E.sendWhenKnown('node-d', 'e2e-marker-s9-ed');
355
+ const le = await E.waitMail('e2e-marker-s9-de'), ld = await D.waitMail('e2e-marker-s9-ed');
356
+ await sleep(16000); // at least one more poll tick after the token request attempt
357
+ const ids = [await D.identity(), await E.identity()];
358
+ need(ids.every((i) => i.state === 'none'), `identity: ${JSON.stringify(ids)}`);
359
+ const st = await D.status();
360
+ need(st.last_poll_ok === true, `last_poll_ok=${st.last_poll_ok} ${st.last_poll_error}`);
361
+ for (const s of [D, E]) need(!fs.existsSync(path.join(s.dir, 'home', '.kenaz', 'node_tokens')), `${s.name} wrote a token file`);
362
+ const req = await call('POST', `${relayUrl(PORT.relayOld)}/api/node-credentials/request`, { headers: bearer(KEYS.t1), body: { node_name: 'probe' } });
363
+ need(req.status >= 400, `old relay unexpectedly answered the token request with ${req.status}`);
364
+ return `D->E "(${le})", E->D "(${ld})"; identity D/E = ${ids.map((i) => i.state)}; no node_tokens dir; last_poll_ok=true; old relay answers token request HTTP ${req.status}`;
365
+ });
366
+ } finally {
367
+ for (const p of procs.slice().reverse()) await stop(p);
368
+ if (registry) await new Promise((r) => registry.close(r));
369
+ }
370
+
371
+ // ---------- isolation ----------
372
+ const left = procs.filter((p) => !p.exited).map((p) => `${p.name}:${p.pid}`);
373
+ const gitAfter = gitState();
374
+ const realAfter = realDirs().map((d) => tree(d, 3));
375
+ const touched = [];
376
+ realAfter.forEach((after, i) => {
377
+ for (const [f, m] of Object.entries(after)) {
378
+ if (realBefore[i][f] === m) continue;
379
+ let hit = false; try { if (fs.statSync(f).size < 2e6) hit = fs.readFileSync(f, 'utf8').includes(runId); } catch { /* unreadable */ }
380
+ if (hit) touched.push(f);
381
+ }
382
+ });
383
+ const iso = [
384
+ ['no process left running', left.length === 0, left.join(',') || 'none'],
385
+ ['git status unchanged', gitBefore === gitAfter, gitBefore === gitAfter ? 'same' : `before:\n${gitBefore}\nafter:\n${gitAfter}`],
386
+ ['real ~/.kenaz ~/.claude ~/.codex hold nothing of this run', touched.length === 0, touched.join(', ') || `no changed file mentions ${runId}`],
387
+ ['installed server binary untouched', fs.statSync(BIN.serverOldSrc).mtimeMs === oldBinStat, 'mtime same'],
388
+ ];
389
+ console.log('\nisolation checks');
390
+ for (const [t, ok, ev] of iso) console.log(`[${ok ? 'PASS' : 'FAIL'}] ${t}: ${ev}`);
391
+
392
+ console.log('\n| # | scenario | result | evidence |\n|---|---|---|---|');
393
+ for (const r of results) console.log(`| ${r.n} | ${r.title} | ${r.pass ? 'PASS' : 'FAIL'} | ${r.ev.replace(/\|/g, '/').replace(/\n/g, ' ')} |`);
394
+ const bad = results.filter((r) => !r.pass).length + iso.filter((x) => !x[1]).length + (results.length === 9 ? 0 : 1);
395
+ console.log(`\n${results.filter((r) => r.pass).length}/9 scenarios passed; ${bad ? 'FAILED' : 'OK'}`);
396
+ if (!ARGS.has('--keep') && !bad) fs.rmSync(TMP, { recursive: true, force: true });
397
+ else console.log(`temp dir kept: ${TMP}`);
398
+ return bad ? 1 : 0;
399
+ }
400
+
401
+ process.on('SIGINT', () => { for (const p of procs) if (!p.exited) { if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(p.pid), '/T', '/F'], { stdio: 'ignore' }); else p.child.kill('SIGKILL'); } process.exit(130); });
402
+ main().then((c) => process.exit(c), (e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,47 @@
1
+ # peer-identity-e2e
2
+
3
+ End-to-end usage test of peer identity (TASK_2447-2452, 2456, 2457) against real binaries. Harness: `dev/scripts/peer-identity-e2e.js`.
4
+
5
+ ## What it starts
6
+
7
+ - a fake registry (answers the relay's `POST /rest/v1/rpc/exchange_key` for two team keys), port 18900
8
+ - the new relay (18901) and the old relay built from 11b8b0b15 (18902), each with a temp `RELAY_DB` and an admin key file
9
+ - kenaz-servers on 18910-18915, each with its own temp `USERPROFILE`/`HOME`/`APPDATA`/`LOCALAPPDATA`/`TEMP`, `KENAZ_SOURCE_DIR`, a minimal `PATH` (no `claude`/`git`), and a temp project whose `.kenaz/yggdrasil.yaml` points at the local relay (team key in the temp `credentials.yaml`)
10
+ - A, B (new, team 1); fern (new) + olive (a COPY of the installed `~/.kenaz/bin/kenaz-server`, team 2, flag never turned on); D, E (new, against the old relay)
11
+
12
+ It never contacts 18080, never runs anything from `~/.kenaz/bin`, and stops only the PIDs it spawned. Exit code is 0 only if all 9 scenarios and the 4 isolation checks pass.
13
+
14
+ ## Run
15
+
16
+ ```
17
+ node dev/scripts/peer-identity-e2e.js # ~2 min, deletes its temp dir on success
18
+ node dev/scripts/peer-identity-e2e.js --keep # keep the temp dir (logs: <tmp>/<name>.log)
19
+ node dev/scripts/peer-identity-e2e.js --control-wrong-fingerprint # control: scenario 3 approves A with a wrong fingerprint -> must exit 1
20
+ ```
21
+
22
+ Prebuilt binaries (not built by the script; override with `E2E_RELAY`, `E2E_RELAY_OLD`, `E2E_SERVER`, `E2E_SERVER_OLD`):
23
+ `C:/t/relay-target/debug/yggdrasil-relay.exe` (main), `C:/t/relay-old-target/debug/yggdrasil-relay.exe` (11b8b0b15), `C:/t/kenaz-e2e-target/debug/kenaz-server.exe` (main), and `~/.kenaz/bin/kenaz-server.exe` (the installed old client). Needs Node 22+ (`node:sqlite`).
24
+
25
+ ## Notes learned while building it
26
+
27
+ - A server re-reads `/api/node-credentials/me` at most every 10 minutes per process (`RETRY_AFTER`), so after approval scenario 3 restarts A and B (the token file persists in their home) instead of waiting. A real user waits up to 10 minutes after approval to see `verified`.
28
+ - Presence heartbeats run every 90 s and only cover projects listed in `collaboration_projects`; the harness pre-writes `projects.json` and that key so the first heartbeat (at boot) already registers the node.
29
+ - Isolation check on the real `~/.kenaz`, `~/.claude`, `~/.codex`: the user's own server writes there continuously, so the check is "no changed file mentions this run's temp dir name", plus the installed binary's mtime and `git status` before/after.
30
+ - Scenario 7 backdates `ttl_at` with SQL while the relay is stopped, then restarts it: the relay runs one cleanup at startup. Batch ack is called directly on `PATCH /api/task_events/ack`.
31
+ - The installed old binary is whatever is in `~/.kenaz/bin` at run time (23820288 bytes); its version number was not queried.
32
+
33
+ ## Last run
34
+
35
+ Date 2026-10-13. Relay and server binaries built from main at 0ee647caf (old relay 11b8b0b15). Harness committed on the TASK_2458 worktree branch (commit of this file). Result: 9/9 scenarios PASS, 4/4 isolation checks PASS, exit 0. Control run (`--control-wrong-fingerprint`): scenarios 3, 4, 5 FAIL, exit 1.
36
+
37
+ | # | scenario | result | evidence |
38
+ |---|---|---|---|
39
+ | 1 | A and B request tokens; pending + fingerprint in GET /api/peer/status | PASS | status.node_identity A=pending/749c3b468bea, B=pending/0637ecff09e6; DB rows pending: [{"node_name":"node-a","fingerprint":"749c3b468bea","status":"pending"},{"node_name":"node-b","fingerprint":"0637ecff09e6","status":"pending"}]; admin list shows both |
40
+ | 2 | A -> B before approval: delivered, label (unverified sender) | PASS | B inbox label "(unverified sender)"; relay row {"from_node":"node-a","to_node":"node-b","sender_verified":0} |
41
+ | 3 | operator approves both with relay-admin.js; servers pick up active via /me | PASS | approved e2e-team-1/node-a (749c3b468bea); revoked 0 other token(s) ; approved e2e-team-1/node-b (0637ecff09e6); revoked 0 other token(s); restart, then status A/B = active; DB active: [{"node_name":"node-a","status":"active"},{"node_name":"node-b","status":"active"}] |
42
+ | 4 | A -> B after approval: delivered, label (verified) | PASS | B inbox label "(verified)"; relay row sender_verified=1 |
43
+ | 5 | protect_bound_names ON: tokenless curl as A / reading or acking B -> 403; A and B still deliver | PASS | HTTP {"forged":403,"mailbox":403,"ack":403,"ackBatch":403,"ctlUnbound":200,"ctlTeam2":200}; A->B (verified) and B->A (verified) delivered with the flag on |
44
+ | 6 | ban A (TASK_2457): A next send refused, B unaffected | PASS | ban: banned e2e-team-1/node-a; revoked 1 token(s); new token requests are refused; A send refused ({"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"kenaz_peer_send: failed to reach any target node. Errors); node-a tokens ["revoked"]; new token request for node-a HTTP 403; B send accepted, relay row sender_verified=1; no new node-a row (3) |
45
+ | 7 | relay cleanup: acked rows past ttl removed, unacked within grace kept, batch ack used | PASS | batch ack HTTP 200 acked=[6,7,10]; after one cleanup run (relay startup) deleted: ackedPast, ackedPast2, unackedOld(31d); kept: ackedFuture, unackedGrace |
46
+ | 8 | old kenaz-server (copied binary) <-> new server on new relay, flag OFF: both ways, nothing refused | PASS | fern(new)->olive(old) delivered, olive shows "(team-claimed)"; olive->fern delivered, fern shows "(unverified sender)"; flag off for team; relay rows 2; fern identity pending; old binary old-server/kenaz-server.exe (23820288 bytes) |
47
+ | 9 | new kenaz-server vs OLD relay (11b8b0b15): token request fails quietly, send/receive work, no token | PASS | D->E "(team-claimed)", E->D "(team-claimed)"; identity D/E = none,none; no node_tokens dir; last_poll_ok=true; old relay answers token request HTTP 404 |
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // TASK_2459: local operator page for the relay node-credential admin endpoints.
4
+ // Serves one HTML page on 127.0.0.1 and proxies /api/* to the relay with the admin key added
5
+ // server-side. The key (RELAY_ADMIN_KEY / RELAY_ADMIN_KEY_FILE) never reaches the browser.
6
+ const http = require('http');
7
+ const crypto = require('crypto');
8
+ const { adminCall, loadKey } = require('./relay-admin');
9
+
10
+ const DEFAULT_RELAY = 'https://yggdrasil.kenazai.com';
11
+ // local route -> [method, relay path under /api/admin/]
12
+ const ROUTES = {
13
+ 'GET /api/pending': ['GET', 'node-credentials/pending'],
14
+ 'GET /api/blocked': ['GET', 'node-credentials/blocked'],
15
+ 'GET /api/readiness': ['GET', 'node-credentials/readiness'],
16
+ 'POST /api/approve': ['POST', 'node-credentials/approve'],
17
+ 'POST /api/revoke': ['POST', 'node-credentials/revoke'],
18
+ 'POST /api/ban': ['POST', 'node-credentials/ban'],
19
+ 'POST /api/unban': ['POST', 'node-credentials/unban'],
20
+ 'POST /api/team-settings': ['POST', 'team-settings'],
21
+ };
22
+
23
+ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
24
+ <title>Relay Admin</title><style>
25
+ body{font:14px system-ui,sans-serif;margin:16px;max-width:1100px;background:#fafafa;color:#222}
26
+ h2{margin-top:28px}table{border-collapse:collapse;width:100%;background:#fff}th,td{border:1px solid #ddd;padding:4px 8px;text-align:left;font-size:13px}
27
+ button{margin-right:4px;cursor:pointer}.red{color:#c00;font-weight:bold}.mono{font-family:monospace}#msg{position:sticky;top:0;background:#ffd;padding:6px;display:none}
28
+ </style></head><body>
29
+ <h1>Relay admin</h1><div id="msg"></div>
30
+ <h2>Pending tokens</h2><table id="pending"></table>
31
+ <h2>Team</h2>
32
+ <p>Team id <input id="team" size="30"> <button id="load">Load</button></p>
33
+ <div id="teamview"></div>
34
+ <h2>Blocked</h2><table id="blocked"></table>
35
+ <script>
36
+ const CSRF = '__CSRF__';
37
+ const $ = (id) => document.getElementById(id);
38
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
39
+ function msg(t) { $('msg').textContent = t; $('msg').style.display = t ? 'block' : 'none'; }
40
+ async function api(method, path, body) {
41
+ const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF }, body: body ? JSON.stringify(body) : undefined });
42
+ const j = await r.json().catch(() => ({}));
43
+ if (!r.ok) throw new Error(j.error || 'HTTP ' + r.status);
44
+ return j;
45
+ }
46
+ async function act(fn) { try { msg(''); await fn(); } catch (e) { msg('error: ' + e.message); } await refresh(); }
47
+ function approve(p) {
48
+ const fp = prompt('Type the fingerprint to approve ' + p.team_id + '/' + p.node_name + '\\n(expected ' + p.fingerprint + ')');
49
+ if (fp === null) return;
50
+ if (fp.trim() !== p.fingerprint) { msg('fingerprint does not match; nothing approved'); return; }
51
+ act(() => api('POST', '/api/approve', { team_id: p.team_id, node_name: p.node_name, fingerprint: fp.trim() }));
52
+ }
53
+ function ban(p) {
54
+ if (!confirm('Ban ' + p.team_id + '/' + p.node_name + (p.machine_id_hash ? ' and its machine hash' : '') + '? Tokens are revoked and new requests refused.')) return;
55
+ act(() => api('POST', '/api/ban', { team_id: p.team_id, node_name: p.node_name, reason: 'banned from admin page', machine_id_hash: p.machine_id_hash || undefined }));
56
+ }
57
+ function revoke(t, n) { if (confirm('Revoke every token for ' + t + '/' + n + '?')) act(() => api('POST', '/api/revoke', { team_id: t, node_name: n })); }
58
+ function unban(b) {
59
+ const body = { team_id: b.team_id }; body[b.kind === 'machine' ? 'machine_id_hash' : 'node_name'] = b.value;
60
+ act(() => api('POST', '/api/unban', body));
61
+ }
62
+ function toggle(team, flag, on, lacking) {
63
+ const m = 'Set ' + flag + ' = ' + on + ' for team ' + team + '?\\nActive nodes still lacking a token: ' + lacking;
64
+ if (!confirm(m)) return;
65
+ if (flag === 'require_node_auth' && on && lacking > 0 &&
66
+ !confirm('WARNING: ' + lacking + ' active node(s) have no token and will be locked out (401). Really turn it on?')) return;
67
+ const body = { team_id: team }; body[flag] = on;
68
+ act(() => api('POST', '/api/team-settings', body));
69
+ }
70
+ const rows = (h, list) => '<tr>' + h.map((x) => '<th>' + x + '</th>').join('') + '</tr>' + list.join('');
71
+ let pendingList = [], blockedList = [];
72
+ async function refresh() {
73
+ try {
74
+ pendingList = (await api('GET', '/api/pending')).pending;
75
+ $('pending').innerHTML = rows(['team', 'node', 'fingerprint', 'hostname', 'os', 'machine hash', 'created', ''], pendingList.map((p, i) =>
76
+ '<tr><td>' + esc(p.team_id) + '</td><td>' + esc(p.node_name) + '</td><td class="mono">' + esc(p.fingerprint) + '</td><td>' + esc(p.hostname) + '</td><td>' + esc(p.os) +
77
+ '</td><td class="mono">' + esc((p.machine_id_hash || '').slice(0, 12)) + '</td><td>' + esc(p.created_at) +
78
+ '</td><td><button onclick="approve(pendingList[' + i + '])">Approve</button><button onclick="ban(pendingList[' + i + '])">Reject/Ban</button></td></tr>'));
79
+ blockedList = (await api('GET', '/api/blocked')).blocked;
80
+ $('blocked').innerHTML = rows(['team', 'kind', 'value', 'reason', 'created', ''], blockedList.map((b, i) =>
81
+ '<tr><td>' + esc(b.team_id) + '</td><td>' + esc(b.kind) + '</td><td class="mono">' + esc(b.value) + '</td><td>' + esc(b.reason) + '</td><td>' + esc(b.created_at) +
82
+ '</td><td><button onclick="unban(blockedList[' + i + '])">Unban</button></td></tr>'));
83
+ if ($('team').value.trim()) await loadTeam();
84
+ } catch (e) { msg('error: ' + e.message); }
85
+ }
86
+ async function loadTeam() {
87
+ const team = $('team').value.trim(); if (!team) return;
88
+ const r = await api('GET', '/api/readiness?team_id=' + encodeURIComponent(team));
89
+ const n = r.nodes_without_active_token;
90
+ const cls = n ? 'red' : '';
91
+ $('teamview').innerHTML = '<p>Nodes active in ' + r.window_days + ' days without an active token: <span class="' + cls + '">' + n + '</span></p>' +
92
+ '<table>' + rows(['node', 'token state'], r.nodes.map((x) => '<tr><td>' + esc(x) + '</td><td class="red">no active token</td></tr>')) + '</table>' +
93
+ '<p>Revoke by node name <input id="rv" size="20"> <button id="rvb">Revoke</button></p>' +
94
+ '<p>protect_bound_names <button id="pbn1">on</button><button id="pbn0">off</button> &nbsp; require_node_auth <button id="rna1">on</button><button id="rna0">off</button> ' +
95
+ '<span class="' + cls + '">(' + n + ' lacking a token)</span></p>';
96
+ $('rvb').onclick = () => { const v = $('rv').value.trim(); if (v) revoke(team, v); };
97
+ $('pbn1').onclick = () => toggle(team, 'protect_bound_names', true, n);
98
+ $('pbn0').onclick = () => toggle(team, 'protect_bound_names', false, n);
99
+ $('rna1').onclick = () => toggle(team, 'require_node_auth', true, n);
100
+ $('rna0').onclick = () => toggle(team, 'require_node_auth', false, n);
101
+ }
102
+ $('load').onclick = () => loadTeam().catch((e) => msg('error: ' + e.message));
103
+ refresh();
104
+ </script></body></html>`;
105
+
106
+ // Drive-by protection (same rule as TASK_2442): only our own host names, and a present Origin must be our own.
107
+ function hostOk(req) {
108
+ const host = String(req.headers.host || '');
109
+ if (!/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host)) return false;
110
+ const origin = req.headers.origin;
111
+ return origin === undefined || origin === 'http://' + host;
112
+ }
113
+
114
+ function createServer({ relay, key, csrf }) {
115
+ const page = PAGE.replace('__CSRF__', csrf);
116
+ return http.createServer((req, res) => {
117
+ const send = (s, body, type = 'application/json') => {
118
+ res.writeHead(s, { 'Content-Type': type, 'Cache-Control': 'no-store' });
119
+ res.end(typeof body === 'string' ? body : JSON.stringify(body));
120
+ };
121
+ if (!hostOk(req)) return send(403, { error: 'forbidden host or origin' });
122
+ const u = new URL(req.url, 'http://x');
123
+ if (req.method === 'GET' && u.pathname === '/') return send(200, page, 'text/html; charset=utf-8');
124
+ const route = ROUTES[`${req.method} ${u.pathname}`];
125
+ if (!route) return send(404, { error: 'not found' });
126
+ if (req.method === 'POST') {
127
+ const a = Buffer.from(String(req.headers['x-csrf-token'] || '')), b = Buffer.from(csrf);
128
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return send(403, { error: 'bad csrf token' });
129
+ }
130
+ let raw = '';
131
+ req.on('data', (c) => { raw += c; if (raw.length > 65536) req.destroy(); });
132
+ req.on('end', async () => {
133
+ let body;
134
+ try { body = raw ? JSON.parse(raw) : undefined; } catch { return send(400, { error: 'bad json' }); }
135
+ const path = route[1] + (route[0] === 'GET' ? u.search : '');
136
+ try { send(200, await adminCall(relay, key, route[0], path, body)); }
137
+ catch (e) { send(e.status && e.status !== 401 ? e.status : 502, { error: e.message }); }
138
+ });
139
+ });
140
+ }
141
+
142
+ function main(argv, env) {
143
+ let relay = env.KENAZ_RELAY_URL || DEFAULT_RELAY, port = 18990;
144
+ const g = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
145
+ if (g('--relay')) relay = g('--relay');
146
+ if (g('--port')) port = Number(g('--port'));
147
+ let key;
148
+ try { key = loadKey(env); } catch (e) { console.error(e.message); return process.exit(2); }
149
+ const csrf = crypto.randomBytes(24).toString('hex');
150
+ createServer({ relay, key, csrf }).listen(port, '127.0.0.1', () => console.log(`relay admin page: http://127.0.0.1:${port}/ (relay ${relay})`));
151
+ }
152
+
153
+ module.exports = { createServer, hostOk };
154
+ if (require.main === module) main(process.argv.slice(2), process.env);