groove-dev 0.10.10 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +24 -16
  2. package/node_modules/@groove-dev/cli/bin/groove.js +32 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/cli/src/commands/audit.js +60 -0
  5. package/node_modules/@groove-dev/cli/src/commands/connect.js +279 -0
  6. package/node_modules/@groove-dev/cli/src/commands/disconnect.js +91 -0
  7. package/node_modules/@groove-dev/cli/src/commands/federation.js +84 -0
  8. package/node_modules/@groove-dev/cli/src/commands/start.js +7 -2
  9. package/node_modules/@groove-dev/cli/src/commands/status.js +4 -1
  10. package/node_modules/@groove-dev/daemon/package.json +1 -1
  11. package/node_modules/@groove-dev/daemon/src/api.js +128 -2
  12. package/node_modules/@groove-dev/daemon/src/audit.js +65 -0
  13. package/node_modules/@groove-dev/daemon/src/federation.js +352 -0
  14. package/node_modules/@groove-dev/daemon/src/firstrun.js +27 -2
  15. package/node_modules/@groove-dev/daemon/src/index.js +64 -6
  16. package/node_modules/@groove-dev/daemon/src/indexer.js +324 -0
  17. package/node_modules/@groove-dev/daemon/src/introducer.js +55 -4
  18. package/node_modules/@groove-dev/daemon/src/journalist.js +140 -51
  19. package/node_modules/@groove-dev/daemon/src/process.js +3 -2
  20. package/node_modules/@groove-dev/gui/dist/assets/index-B49YqEXS.js +73 -0
  21. package/{packages/gui/dist/assets/index-CPzm9ZE9.css → node_modules/@groove-dev/gui/dist/assets/index-Gfb8Zxy9.css} +1 -1
  22. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  23. package/node_modules/@groove-dev/gui/package.json +1 -1
  24. package/node_modules/@groove-dev/gui/src/App.jsx +24 -1
  25. package/node_modules/@groove-dev/gui/src/components/AgentNode.jsx +6 -4
  26. package/node_modules/@groove-dev/gui/src/components/AgentStats.jsx +1 -0
  27. package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +71 -1
  28. package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -2
  29. package/node_modules/@groove-dev/gui/src/theme.css +2 -2
  30. package/package.json +1 -1
  31. package/packages/cli/bin/groove.js +32 -0
  32. package/packages/cli/package.json +1 -1
  33. package/packages/cli/src/commands/audit.js +60 -0
  34. package/packages/cli/src/commands/connect.js +279 -0
  35. package/packages/cli/src/commands/disconnect.js +91 -0
  36. package/packages/cli/src/commands/federation.js +84 -0
  37. package/packages/cli/src/commands/start.js +7 -2
  38. package/packages/cli/src/commands/status.js +4 -1
  39. package/packages/daemon/package.json +1 -1
  40. package/packages/daemon/src/api.js +128 -2
  41. package/packages/daemon/src/audit.js +65 -0
  42. package/packages/daemon/src/federation.js +352 -0
  43. package/packages/daemon/src/firstrun.js +27 -2
  44. package/packages/daemon/src/index.js +64 -6
  45. package/packages/daemon/src/indexer.js +324 -0
  46. package/packages/daemon/src/introducer.js +55 -4
  47. package/packages/daemon/src/journalist.js +140 -51
  48. package/packages/daemon/src/process.js +3 -2
  49. package/packages/gui/dist/assets/index-B49YqEXS.js +73 -0
  50. package/{node_modules/@groove-dev/gui/dist/assets/index-CPzm9ZE9.css → packages/gui/dist/assets/index-Gfb8Zxy9.css} +1 -1
  51. package/packages/gui/dist/index.html +2 -2
  52. package/packages/gui/package.json +1 -1
  53. package/packages/gui/src/App.jsx +24 -1
  54. package/packages/gui/src/components/AgentNode.jsx +6 -4
  55. package/packages/gui/src/components/AgentStats.jsx +1 -0
  56. package/packages/gui/src/components/SpawnPanel.jsx +71 -1
  57. package/packages/gui/src/stores/groove.js +19 -2
  58. package/packages/gui/src/theme.css +2 -2
  59. package/groove-logo.png +0 -0
  60. package/node_modules/@groove-dev/gui/dist/assets/index-BPVh7Oqk.js +0 -73
  61. package/packages/gui/dist/assets/index-BPVh7Oqk.js +0 -73
@@ -11,7 +11,7 @@ import { validateAgentConfig } from './validate.js';
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
 
13
13
  export function createApi(app, daemon) {
14
- // CORS — restrict to localhost origins only
14
+ // CORS — restrict to localhost + bound interface origins
15
15
  app.use((req, res, next) => {
16
16
  const origin = req.headers.origin;
17
17
  const allowed = [
@@ -19,6 +19,10 @@ export function createApi(app, daemon) {
19
19
  `http://127.0.0.1:${daemon.port}`,
20
20
  'http://localhost:3142', // Vite dev server
21
21
  ];
22
+ // Allow the bound interface (for Tailscale/LAN access)
23
+ if (daemon.host && daemon.host !== '127.0.0.1') {
24
+ allowed.push(`http://${daemon.host}:${daemon.port}`);
25
+ }
22
26
  if (!origin || allowed.includes(origin)) {
23
27
  res.header('Access-Control-Allow-Origin', origin || '*');
24
28
  }
@@ -52,6 +56,7 @@ export function createApi(app, daemon) {
52
56
  try {
53
57
  const config = validateAgentConfig(req.body);
54
58
  const agent = await daemon.processes.spawn(config);
59
+ daemon.audit.log('agent.spawn', { id: agent.id, role: agent.role, provider: agent.provider });
55
60
  res.status(201).json(agent);
56
61
  } catch (err) {
57
62
  res.status(400).json({ error: err.message });
@@ -81,6 +86,7 @@ export function createApi(app, daemon) {
81
86
  daemon.registry.remove(req.params.id);
82
87
  }
83
88
 
89
+ daemon.audit.log('agent.kill', { id: agent.id, role: agent.role, purged: req.query.purge === 'true' || !isAlive });
84
90
  res.json({ ok: true, purged: req.query.purge === 'true' || !isAlive });
85
91
  } catch (err) {
86
92
  res.status(400).json({ error: err.message });
@@ -89,7 +95,9 @@ export function createApi(app, daemon) {
89
95
 
90
96
  // Kill all agents
91
97
  app.delete('/api/agents', async (req, res) => {
98
+ const count = daemon.processes.getRunningCount();
92
99
  await daemon.processes.killAll();
100
+ daemon.audit.log('agent.kill_all', { count });
93
101
  res.json({ ok: true });
94
102
  });
95
103
 
@@ -122,11 +130,13 @@ export function createApi(app, daemon) {
122
130
  app.post('/api/credentials/:provider', (req, res) => {
123
131
  if (!req.body.key) return res.status(400).json({ error: 'key is required' });
124
132
  daemon.credentials.setKey(req.params.provider, req.body.key);
133
+ daemon.audit.log('credential.set', { provider: req.params.provider });
125
134
  res.json({ ok: true, masked: daemon.credentials.mask(req.body.key) });
126
135
  });
127
136
 
128
137
  app.delete('/api/credentials/:provider', (req, res) => {
129
138
  daemon.credentials.deleteKey(req.params.provider);
139
+ daemon.audit.log('credential.delete', { provider: req.params.provider });
130
140
  res.json({ ok: true });
131
141
  });
132
142
 
@@ -157,6 +167,7 @@ export function createApi(app, daemon) {
157
167
  uptime: process.uptime(),
158
168
  agents: daemon.registry.getAll().length,
159
169
  running: daemon.processes.getRunningCount(),
170
+ host: daemon.host,
160
171
  port: daemon.port,
161
172
  projectDir: daemon.projectDir,
162
173
  });
@@ -174,6 +185,7 @@ export function createApi(app, daemon) {
174
185
  app.post('/api/teams', (req, res) => {
175
186
  try {
176
187
  const team = daemon.teams.save(req.body.name);
188
+ daemon.audit.log('team.save', { name: req.body.name });
177
189
  res.status(201).json(team);
178
190
  } catch (err) {
179
191
  res.status(400).json({ error: err.message });
@@ -183,6 +195,7 @@ export function createApi(app, daemon) {
183
195
  app.post('/api/teams/:name/load', async (req, res) => {
184
196
  try {
185
197
  const result = await daemon.teams.load(req.params.name);
198
+ daemon.audit.log('team.load', { name: req.params.name });
186
199
  res.json(result);
187
200
  } catch (err) {
188
201
  res.status(400).json({ error: err.message });
@@ -192,6 +205,7 @@ export function createApi(app, daemon) {
192
205
  app.delete('/api/teams/:name', (req, res) => {
193
206
  try {
194
207
  daemon.teams.delete(req.params.name);
208
+ daemon.audit.log('team.delete', { name: req.params.name });
195
209
  res.json({ ok: true });
196
210
  } catch (err) {
197
211
  res.status(400).json({ error: err.message });
@@ -210,6 +224,7 @@ export function createApi(app, daemon) {
210
224
  app.post('/api/teams/import', (req, res) => {
211
225
  try {
212
226
  const team = daemon.teams.import(JSON.stringify(req.body));
227
+ daemon.audit.log('team.import', { name: team.name });
213
228
  res.status(201).json(team);
214
229
  } catch (err) {
215
230
  res.status(400).json({ error: err.message });
@@ -229,12 +244,14 @@ export function createApi(app, daemon) {
229
244
  app.post('/api/approvals/:id/approve', (req, res) => {
230
245
  const result = daemon.supervisor.approve(req.params.id);
231
246
  if (!result) return res.status(404).json({ error: 'Approval not found' });
247
+ daemon.audit.log('approval.approve', { id: req.params.id });
232
248
  res.json(result);
233
249
  });
234
250
 
235
251
  app.post('/api/approvals/:id/reject', (req, res) => {
236
252
  const result = daemon.supervisor.reject(req.params.id, req.body.reason);
237
253
  if (!result) return res.status(404).json({ error: 'Approval not found' });
254
+ daemon.audit.log('approval.reject', { id: req.params.id, reason: req.body.reason });
238
255
  res.json(result);
239
256
  });
240
257
 
@@ -247,7 +264,9 @@ export function createApi(app, daemon) {
247
264
  // Rotate an agent
248
265
  app.post('/api/agents/:id/rotate', async (req, res) => {
249
266
  try {
267
+ const oldAgent = daemon.registry.get(req.params.id);
250
268
  const newAgent = await daemon.rotator.rotate(req.params.id);
269
+ daemon.audit.log('agent.rotate', { oldId: req.params.id, newId: newAgent.id, role: oldAgent?.role });
251
270
  res.json(newAgent);
252
271
  } catch (err) {
253
272
  res.status(400).json({ error: err.message });
@@ -268,10 +287,12 @@ export function createApi(app, daemon) {
268
287
 
269
288
  // Try session resume first (zero cold-start)
270
289
  // Falls back to rotation if no session ID or provider doesn't support resume
271
- const newAgent = agent.sessionId
290
+ const resumed = !!agent.sessionId;
291
+ const newAgent = resumed
272
292
  ? await daemon.processes.resume(req.params.id, message.trim())
273
293
  : await daemon.rotator.rotate(req.params.id, { additionalPrompt: message.trim() });
274
294
 
295
+ daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed });
275
296
  res.json(newAgent);
276
297
  } catch (err) {
277
298
  res.status(400).json({ error: err.message });
@@ -341,6 +362,27 @@ export function createApi(app, daemon) {
341
362
  res.json(daemon.adaptive.getAllProfiles());
342
363
  });
343
364
 
365
+ // --- Codebase Indexer ---
366
+
367
+ app.get('/api/indexer', (req, res) => {
368
+ res.json(daemon.indexer.getStatus());
369
+ });
370
+
371
+ app.get('/api/indexer/workspaces', (req, res) => {
372
+ res.json({
373
+ workspaces: daemon.indexer.getWorkspaces(),
374
+ });
375
+ });
376
+
377
+ app.post('/api/indexer/rescan', (req, res) => {
378
+ try {
379
+ daemon.indexer.scan();
380
+ res.json({ ok: true, ...daemon.indexer.getStatus() });
381
+ } catch (err) {
382
+ res.status(500).json({ error: err.message });
383
+ }
384
+ });
385
+
344
386
  // --- Project Manager (AI Review Gate) ---
345
387
 
346
388
  // Agent knocks on PM before risky operations (Auto permission mode)
@@ -401,11 +443,13 @@ export function createApi(app, daemon) {
401
443
  provider: config.provider || 'claude-code',
402
444
  model: config.model || 'auto',
403
445
  permission: config.permission || 'auto',
446
+ workingDir: config.workingDir || undefined,
404
447
  });
405
448
  const agent = await daemon.processes.spawn(validated);
406
449
  spawned.push({ id: agent.id, name: agent.name, role: agent.role });
407
450
  }
408
451
 
452
+ daemon.audit.log('team.launch', { count: spawned.length, agents: spawned.map((a) => a.role) });
409
453
  res.json({ launched: spawned.length, agents: spawned });
410
454
  } catch (err) {
411
455
  res.status(500).json({ error: err.message });
@@ -502,6 +546,87 @@ export function createApi(app, daemon) {
502
546
  });
503
547
  });
504
548
 
549
+ // --- Federation ---
550
+
551
+ // Federation status
552
+ app.get('/api/federation', (req, res) => {
553
+ res.json(daemon.federation.getStatus());
554
+ });
555
+
556
+ // List peers
557
+ app.get('/api/federation/peers', (req, res) => {
558
+ res.json(daemon.federation.getPeers());
559
+ });
560
+
561
+ // Initiate pairing (local CLI calls this with the remote URL)
562
+ app.post('/api/federation/initiate', async (req, res) => {
563
+ try {
564
+ const { remoteUrl } = req.body;
565
+ if (!remoteUrl || typeof remoteUrl !== 'string') {
566
+ return res.status(400).json({ error: 'remoteUrl is required' });
567
+ }
568
+ const result = await daemon.federation.initiatePairing(remoteUrl);
569
+ res.json(result);
570
+ } catch (err) {
571
+ res.status(400).json({ error: err.message });
572
+ }
573
+ });
574
+
575
+ // Accept pairing (remote daemon calls this during key exchange)
576
+ app.post('/api/federation/pair', (req, res) => {
577
+ try {
578
+ const result = daemon.federation.acceptPairing(req.body);
579
+ res.json(result);
580
+ } catch (err) {
581
+ res.status(400).json({ error: err.message });
582
+ }
583
+ });
584
+
585
+ // Unpair a peer
586
+ app.delete('/api/federation/peers/:id', (req, res) => {
587
+ try {
588
+ daemon.federation.unpair(req.params.id);
589
+ res.json({ ok: true });
590
+ } catch (err) {
591
+ res.status(400).json({ error: err.message });
592
+ }
593
+ });
594
+
595
+ // Receive a signed contract from a peer
596
+ app.post('/api/federation/contract', (req, res) => {
597
+ try {
598
+ const { senderId, payload, signature } = req.body;
599
+ if (!senderId || !payload || !signature) {
600
+ return res.status(400).json({ error: 'senderId, payload, and signature are required' });
601
+ }
602
+ const result = daemon.federation.receiveContract(senderId, payload, signature);
603
+ res.json(result);
604
+ } catch (err) {
605
+ res.status(403).json({ error: err.message });
606
+ }
607
+ });
608
+
609
+ // Send a contract to a peer (local agents call this)
610
+ app.post('/api/federation/contract/send', async (req, res) => {
611
+ try {
612
+ const { peerId, contract } = req.body;
613
+ if (!peerId || !contract) {
614
+ return res.status(400).json({ error: 'peerId and contract are required' });
615
+ }
616
+ const result = await daemon.federation.sendContract(peerId, contract);
617
+ res.json(result);
618
+ } catch (err) {
619
+ res.status(400).json({ error: err.message });
620
+ }
621
+ });
622
+
623
+ // --- Audit Log ---
624
+
625
+ app.get('/api/audit', (req, res) => {
626
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 500);
627
+ res.json(daemon.audit.recent(limit));
628
+ });
629
+
505
630
  // --- Config ---
506
631
 
507
632
  app.get('/api/config', (req, res) => {
@@ -521,6 +646,7 @@ export function createApi(app, daemon) {
521
646
  }
522
647
  const { saveConfig } = await import('./firstrun.js');
523
648
  saveConfig(daemon.grooveDir, daemon.config);
649
+ daemon.audit.log('config.set', { keys: Object.keys(req.body) });
524
650
  res.json(daemon.config);
525
651
  });
526
652
 
@@ -0,0 +1,65 @@
1
+ // GROOVE — Audit Logger
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { appendFileSync, readFileSync, existsSync, renameSync, statSync } from 'fs';
5
+ import { resolve } from 'path';
6
+
7
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
8
+
9
+ export class AuditLogger {
10
+ constructor(grooveDir) {
11
+ this.logPath = resolve(grooveDir, 'audit.log');
12
+ }
13
+
14
+ /**
15
+ * Append an audit entry.
16
+ * @param {string} action — e.g. 'agent.spawn', 'config.set', 'team.load'
17
+ * @param {object} detail — action-specific metadata (keep it small)
18
+ */
19
+ log(action, detail = {}) {
20
+ const entry = JSON.stringify({
21
+ t: new Date().toISOString(),
22
+ action,
23
+ ...detail,
24
+ });
25
+ try {
26
+ // Rotate if log exceeds 5MB
27
+ if (existsSync(this.logPath)) {
28
+ try {
29
+ const size = statSync(this.logPath).size;
30
+ if (size > MAX_LOG_SIZE) {
31
+ const rotated = this.logPath + '.1';
32
+ renameSync(this.logPath, rotated);
33
+ }
34
+ } catch { /* ignore rotation errors */ }
35
+ }
36
+ appendFileSync(this.logPath, entry + '\n', { mode: 0o600 });
37
+ } catch {
38
+ // Audit must never crash the daemon
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Read recent entries (newest first).
44
+ * @param {number} limit — max entries to return
45
+ * @returns {object[]}
46
+ */
47
+ recent(limit = 50) {
48
+ if (!existsSync(this.logPath)) return [];
49
+ try {
50
+ const lines = readFileSync(this.logPath, 'utf8')
51
+ .trim()
52
+ .split('\n')
53
+ .filter(Boolean);
54
+ return lines
55
+ .slice(-limit)
56
+ .reverse()
57
+ .map((line) => {
58
+ try { return JSON.parse(line); } catch { return null; }
59
+ })
60
+ .filter(Boolean);
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,352 @@
1
+ // GROOVE — Federation (Ed25519 key exchange + contract signing)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey, createHash } from 'crypto';
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, readdirSync } from 'fs';
6
+ import { resolve } from 'path';
7
+
8
+ // Peer IDs must be safe for filenames — hex only (from SHA-256 fingerprint)
9
+ const PEER_ID_PATTERN = /^[a-f0-9]{1,64}$/;
10
+
11
+ function validatePeerId(id) {
12
+ if (!id || typeof id !== 'string' || !PEER_ID_PATTERN.test(id)) {
13
+ throw new Error(`Invalid peer ID: must be lowercase hex (got: ${String(id).slice(0, 20)})`);
14
+ }
15
+ }
16
+
17
+ export class Federation {
18
+ constructor(daemon) {
19
+ this.daemon = daemon;
20
+ this.fedDir = resolve(daemon.grooveDir, 'federation');
21
+ this.peersDir = resolve(this.fedDir, 'peers');
22
+ mkdirSync(this.peersDir, { recursive: true });
23
+
24
+ // Load or generate this daemon's keypair
25
+ this.keyPath = resolve(this.fedDir, 'identity.key');
26
+ this.pubPath = resolve(this.fedDir, 'identity.pub');
27
+ this._ensureKeypair();
28
+
29
+ // In-memory peer cache
30
+ this.peers = this._loadPeers();
31
+
32
+ // Pending pairing requests (in-memory, short-lived)
33
+ this.pendingPairs = new Map();
34
+ }
35
+
36
+ // --- Key Management ---
37
+
38
+ _ensureKeypair() {
39
+ if (existsSync(this.keyPath) && existsSync(this.pubPath)) {
40
+ this.privateKey = createPrivateKey(readFileSync(this.keyPath));
41
+ this.publicKey = createPublicKey(readFileSync(this.pubPath));
42
+ return;
43
+ }
44
+
45
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
46
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
47
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
48
+ });
49
+
50
+ writeFileSync(this.keyPath, privateKey, { mode: 0o600 });
51
+ writeFileSync(this.pubPath, publicKey, { mode: 0o644 });
52
+
53
+ this.privateKey = createPrivateKey(privateKey);
54
+ this.publicKey = createPublicKey(publicKey);
55
+ }
56
+
57
+ getPublicKeyPem() {
58
+ return readFileSync(this.pubPath, 'utf8');
59
+ }
60
+
61
+ // --- Signing / Verification ---
62
+
63
+ /**
64
+ * Sign a payload (contract or message).
65
+ * @param {object} payload — JSON-serializable data
66
+ * @returns {{ payload: object, signature: string }}
67
+ */
68
+ sign(payload) {
69
+ const data = Buffer.from(JSON.stringify(payload), 'utf8');
70
+ const sig = sign(null, data, this.privateKey);
71
+ return {
72
+ payload,
73
+ signature: sig.toString('base64'),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Verify a signed message from a peer.
79
+ * @param {string} peerId — the peer's ID
80
+ * @param {object} payload — the original payload
81
+ * @param {string} signature — base64 signature
82
+ * @returns {boolean}
83
+ */
84
+ verify(peerId, payload, signature) {
85
+ const peer = this.peers.get(peerId);
86
+ if (!peer) return false;
87
+
88
+ try {
89
+ const data = Buffer.from(JSON.stringify(payload), 'utf8');
90
+ const sig = Buffer.from(signature, 'base64');
91
+ const pubKey = createPublicKey(peer.publicKey);
92
+ return verify(null, data, pubKey, sig);
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ // --- Peer Management ---
99
+
100
+ _loadPeers() {
101
+ const peers = new Map();
102
+ if (!existsSync(this.peersDir)) return peers;
103
+
104
+ for (const file of readdirSync(this.peersDir)) {
105
+ if (!file.endsWith('.json')) continue;
106
+ try {
107
+ const data = JSON.parse(readFileSync(resolve(this.peersDir, file), 'utf8'));
108
+ peers.set(data.id, data);
109
+ } catch { /* skip corrupt files */ }
110
+ }
111
+ return peers;
112
+ }
113
+
114
+ _savePeer(peer) {
115
+ validatePeerId(peer.id);
116
+ const file = resolve(this.peersDir, `${peer.id}.json`);
117
+ writeFileSync(file, JSON.stringify(peer, null, 2), { mode: 0o600 });
118
+ this.peers.set(peer.id, peer);
119
+ }
120
+
121
+ _removePeer(peerId) {
122
+ validatePeerId(peerId);
123
+ const file = resolve(this.peersDir, `${peerId}.json`);
124
+ if (existsSync(file)) unlinkSync(file);
125
+ this.peers.delete(peerId);
126
+ }
127
+
128
+ /**
129
+ * Initiate pairing with a remote daemon.
130
+ * Sends our public key + daemon info to the remote.
131
+ * @param {string} remoteUrl — http://<ip>:<port> of remote daemon
132
+ * @returns {object} pairing result
133
+ */
134
+ async initiatePairing(remoteUrl) {
135
+ const localInfo = this._localInfo();
136
+
137
+ // Send our public key to the remote daemon's pairing endpoint
138
+ const res = await fetch(`${remoteUrl}/api/federation/pair`, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({
142
+ id: localInfo.id,
143
+ name: localInfo.name,
144
+ host: localInfo.host,
145
+ port: localInfo.port,
146
+ publicKey: this.getPublicKeyPem(),
147
+ }),
148
+ });
149
+
150
+ if (!res.ok) {
151
+ const err = await res.json().catch(() => ({}));
152
+ throw new Error(err.error || `Pairing failed: HTTP ${res.status}`);
153
+ }
154
+
155
+ const remote = await res.json();
156
+
157
+ // Validate remote response before trusting it
158
+ if (!remote.id || !remote.publicKey) {
159
+ throw new Error('Remote returned invalid pairing response');
160
+ }
161
+ validatePeerId(remote.id);
162
+ try {
163
+ createPublicKey(remote.publicKey);
164
+ } catch {
165
+ throw new Error('Remote returned invalid public key');
166
+ }
167
+
168
+ // Store the remote's public key as a trusted peer
169
+ this._savePeer({
170
+ id: remote.id,
171
+ name: remote.name,
172
+ host: remote.host,
173
+ port: remote.port,
174
+ publicKey: remote.publicKey,
175
+ pairedAt: new Date().toISOString(),
176
+ });
177
+
178
+ this.daemon.audit.log('federation.pair', { peerId: remote.id, peerHost: remote.host });
179
+
180
+ return {
181
+ peerId: remote.id,
182
+ peerName: remote.name,
183
+ peerHost: remote.host,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Handle incoming pairing request from a remote daemon.
189
+ * @param {object} remoteInfo — { id, name, host, port, publicKey }
190
+ * @returns {object} our info + public key for the remote to store
191
+ */
192
+ acceptPairing(remoteInfo) {
193
+ if (!remoteInfo.id || !remoteInfo.publicKey) {
194
+ throw new Error('Invalid pairing request: missing id or publicKey');
195
+ }
196
+
197
+ validatePeerId(remoteInfo.id);
198
+
199
+ // Validate the public key is parseable PEM
200
+ try {
201
+ createPublicKey(remoteInfo.publicKey);
202
+ } catch {
203
+ throw new Error('Invalid pairing request: publicKey is not valid PEM');
204
+ }
205
+
206
+ // Store the remote peer
207
+ this._savePeer({
208
+ id: remoteInfo.id,
209
+ name: remoteInfo.name || remoteInfo.id,
210
+ host: remoteInfo.host,
211
+ port: remoteInfo.port,
212
+ publicKey: remoteInfo.publicKey,
213
+ pairedAt: new Date().toISOString(),
214
+ });
215
+
216
+ this.daemon.audit.log('federation.pair', { peerId: remoteInfo.id, peerHost: remoteInfo.host });
217
+
218
+ // Return our info so the remote can store us
219
+ const localInfo = this._localInfo();
220
+ return {
221
+ id: localInfo.id,
222
+ name: localInfo.name,
223
+ host: localInfo.host,
224
+ port: localInfo.port,
225
+ publicKey: this.getPublicKeyPem(),
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Remove a peer.
231
+ */
232
+ unpair(peerId) {
233
+ if (!this.peers.has(peerId)) {
234
+ throw new Error(`Peer not found: ${peerId}`);
235
+ }
236
+ const peer = this.peers.get(peerId);
237
+ this._removePeer(peerId);
238
+ this.daemon.audit.log('federation.unpair', { peerId, peerHost: peer.host });
239
+ }
240
+
241
+ // --- Contracts ---
242
+
243
+ /**
244
+ * Send a signed contract to a peer daemon.
245
+ * @param {string} peerId — target peer
246
+ * @param {object} contract — { type, spec, from, to }
247
+ * @returns {object} remote response
248
+ */
249
+ async sendContract(peerId, contract) {
250
+ const peer = this.peers.get(peerId);
251
+ if (!peer) throw new Error(`Unknown peer: ${peerId}`);
252
+
253
+ const envelope = this.sign({
254
+ ...contract,
255
+ from: this._localInfo().id,
256
+ timestamp: new Date().toISOString(),
257
+ });
258
+
259
+ const url = `http://${peer.host}:${peer.port}/api/federation/contract`;
260
+ const res = await fetch(url, {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({ senderId: this._localInfo().id, ...envelope }),
264
+ });
265
+
266
+ if (!res.ok) {
267
+ const err = await res.json().catch(() => ({}));
268
+ throw new Error(err.error || `Contract delivery failed: HTTP ${res.status}`);
269
+ }
270
+
271
+ this.daemon.audit.log('federation.contract.send', { peerId, type: contract.type });
272
+
273
+ return res.json();
274
+ }
275
+
276
+ /**
277
+ * Receive and verify a contract from a peer.
278
+ * @param {string} senderId — claimed sender
279
+ * @param {object} payload — the contract data
280
+ * @param {string} signature — base64 Ed25519 signature
281
+ * @returns {object} verified contract
282
+ */
283
+ receiveContract(senderId, payload, signature) {
284
+ if (!this.peers.has(senderId)) {
285
+ throw new Error(`Unknown sender: ${senderId}. Not a paired peer.`);
286
+ }
287
+
288
+ if (!this.verify(senderId, payload, signature)) {
289
+ throw new Error(`Signature verification failed for sender: ${senderId}`);
290
+ }
291
+
292
+ // Replay protection — reject contracts older than 5 minutes
293
+ if (payload.timestamp) {
294
+ const age = Date.now() - new Date(payload.timestamp).getTime();
295
+ if (age > 5 * 60 * 1000) {
296
+ throw new Error(`Contract too old (${Math.round(age / 1000)}s). Possible replay.`);
297
+ }
298
+ if (age < -60 * 1000) {
299
+ throw new Error('Contract timestamp is in the future. Clock skew?');
300
+ }
301
+ }
302
+
303
+ this.daemon.audit.log('federation.contract.recv', {
304
+ senderId,
305
+ type: payload.type,
306
+ });
307
+
308
+ return { verified: true, contract: payload };
309
+ }
310
+
311
+ // --- Info / Status ---
312
+
313
+ _localInfo() {
314
+ return {
315
+ id: this._daemonId(),
316
+ name: this._daemonId(),
317
+ host: this.daemon.host,
318
+ port: this.daemon.port,
319
+ };
320
+ }
321
+
322
+ _daemonId() {
323
+ // Stable ID derived from keypair — fingerprint of public key
324
+ const idPath = resolve(this.fedDir, 'daemon.id');
325
+ if (existsSync(idPath)) {
326
+ return readFileSync(idPath, 'utf8').trim();
327
+ }
328
+ const pubPem = this.getPublicKeyPem();
329
+ const id = createHash('sha256').update(pubPem).digest('hex').slice(0, 12);
330
+ writeFileSync(idPath, id, { mode: 0o600 });
331
+ return id;
332
+ }
333
+
334
+ getPeers() {
335
+ return Array.from(this.peers.values()).map((p) => ({
336
+ id: p.id,
337
+ name: p.name,
338
+ host: p.host,
339
+ port: p.port,
340
+ pairedAt: p.pairedAt,
341
+ }));
342
+ }
343
+
344
+ getStatus() {
345
+ return {
346
+ id: this._daemonId(),
347
+ peers: this.getPeers(),
348
+ peerCount: this.peers.size,
349
+ hasKeypair: existsSync(this.keyPath),
350
+ };
351
+ }
352
+ }