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.
- package/README.md +24 -16
- package/node_modules/@groove-dev/cli/bin/groove.js +32 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/audit.js +60 -0
- package/node_modules/@groove-dev/cli/src/commands/connect.js +279 -0
- package/node_modules/@groove-dev/cli/src/commands/disconnect.js +91 -0
- package/node_modules/@groove-dev/cli/src/commands/federation.js +84 -0
- package/node_modules/@groove-dev/cli/src/commands/start.js +7 -2
- package/node_modules/@groove-dev/cli/src/commands/status.js +4 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +128 -2
- package/node_modules/@groove-dev/daemon/src/audit.js +65 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +352 -0
- package/node_modules/@groove-dev/daemon/src/firstrun.js +27 -2
- package/node_modules/@groove-dev/daemon/src/index.js +64 -6
- package/node_modules/@groove-dev/daemon/src/indexer.js +324 -0
- package/node_modules/@groove-dev/daemon/src/introducer.js +55 -4
- package/node_modules/@groove-dev/daemon/src/journalist.js +140 -51
- package/node_modules/@groove-dev/daemon/src/process.js +3 -2
- package/node_modules/@groove-dev/gui/dist/assets/index-B49YqEXS.js +73 -0
- package/{packages/gui/dist/assets/index-CPzm9ZE9.css → node_modules/@groove-dev/gui/dist/assets/index-Gfb8Zxy9.css} +1 -1
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +24 -1
- package/node_modules/@groove-dev/gui/src/components/AgentNode.jsx +6 -4
- package/node_modules/@groove-dev/gui/src/components/AgentStats.jsx +1 -0
- package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +71 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -2
- package/node_modules/@groove-dev/gui/src/theme.css +2 -2
- package/package.json +1 -1
- package/packages/cli/bin/groove.js +32 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/audit.js +60 -0
- package/packages/cli/src/commands/connect.js +279 -0
- package/packages/cli/src/commands/disconnect.js +91 -0
- package/packages/cli/src/commands/federation.js +84 -0
- package/packages/cli/src/commands/start.js +7 -2
- package/packages/cli/src/commands/status.js +4 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +128 -2
- package/packages/daemon/src/audit.js +65 -0
- package/packages/daemon/src/federation.js +352 -0
- package/packages/daemon/src/firstrun.js +27 -2
- package/packages/daemon/src/index.js +64 -6
- package/packages/daemon/src/indexer.js +324 -0
- package/packages/daemon/src/introducer.js +55 -4
- package/packages/daemon/src/journalist.js +140 -51
- package/packages/daemon/src/process.js +3 -2
- package/packages/gui/dist/assets/index-B49YqEXS.js +73 -0
- package/{node_modules/@groove-dev/gui/dist/assets/index-CPzm9ZE9.css → packages/gui/dist/assets/index-Gfb8Zxy9.css} +1 -1
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +24 -1
- package/packages/gui/src/components/AgentNode.jsx +6 -4
- package/packages/gui/src/components/AgentStats.jsx +1 -0
- package/packages/gui/src/components/SpawnPanel.jsx +71 -1
- package/packages/gui/src/stores/groove.js +19 -2
- package/packages/gui/src/theme.css +2 -2
- package/groove-logo.png +0 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BPVh7Oqk.js +0 -73
- 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
|
|
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
|
|
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
|
+
}
|