groove-dev 0.27.15 → 0.27.18

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 (172) hide show
  1. package/CLAUDE.md +0 -10
  2. package/README.md +37 -1
  3. package/developerID_application.cer +0 -0
  4. package/node_modules/@groove-dev/daemon/src/api.js +586 -67
  5. package/node_modules/@groove-dev/daemon/src/classifier.js +24 -0
  6. package/node_modules/@groove-dev/daemon/src/credentials.js +12 -2
  7. package/node_modules/@groove-dev/daemon/src/federation/ambassador.js +204 -0
  8. package/node_modules/@groove-dev/daemon/src/federation/connection.js +359 -0
  9. package/node_modules/@groove-dev/daemon/src/federation/contracts.js +112 -0
  10. package/node_modules/@groove-dev/daemon/src/federation/whitelist.js +190 -0
  11. package/node_modules/@groove-dev/daemon/src/federation.js +166 -7
  12. package/node_modules/@groove-dev/daemon/src/index.js +172 -19
  13. package/node_modules/@groove-dev/daemon/src/introducer.js +52 -7
  14. package/node_modules/@groove-dev/daemon/src/journalist.js +46 -1
  15. package/node_modules/@groove-dev/daemon/src/memory.js +36 -16
  16. package/node_modules/@groove-dev/daemon/src/process.js +140 -23
  17. package/node_modules/@groove-dev/daemon/src/providers/base.js +1 -0
  18. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +14 -0
  19. package/node_modules/@groove-dev/daemon/src/providers/codex.js +124 -28
  20. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +104 -17
  21. package/node_modules/@groove-dev/daemon/src/providers/index.js +17 -0
  22. package/node_modules/@groove-dev/daemon/src/registry.js +10 -1
  23. package/node_modules/@groove-dev/daemon/src/rotator.js +93 -30
  24. package/node_modules/@groove-dev/daemon/src/skills.js +33 -3
  25. package/node_modules/@groove-dev/daemon/src/terminal-pty.js +9 -1
  26. package/node_modules/@groove-dev/daemon/src/tool-executor.js +11 -5
  27. package/node_modules/@groove-dev/daemon/src/toys.js +69 -0
  28. package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +24 -5
  29. package/node_modules/@groove-dev/daemon/templates/toys-catalog.json +242 -0
  30. package/node_modules/@groove-dev/daemon/test/classifier.test.js +98 -0
  31. package/node_modules/@groove-dev/daemon/test/introducer.test.js +72 -1
  32. package/node_modules/@groove-dev/daemon/test/journalist.test.js +117 -0
  33. package/node_modules/@groove-dev/daemon/test/memory.test.js +37 -1
  34. package/node_modules/@groove-dev/daemon/test/rotator.test.js +183 -4
  35. package/node_modules/@groove-dev/gui/dist/assets/index-Bg6_D2xK.css +1 -0
  36. package/node_modules/@groove-dev/gui/dist/assets/index-D3rvwTHD.js +8607 -0
  37. package/node_modules/@groove-dev/gui/dist/index.html +3 -2
  38. package/node_modules/@groove-dev/gui/index.html +1 -0
  39. package/node_modules/@groove-dev/gui/src/app.css +7 -0
  40. package/node_modules/@groove-dev/gui/src/app.jsx +37 -10
  41. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +21 -31
  42. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +11 -6
  43. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +2 -2
  44. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +42 -1
  45. package/node_modules/@groove-dev/gui/src/components/editor/breadcrumbs.jsx +30 -0
  46. package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +33 -2
  47. package/node_modules/@groove-dev/gui/src/components/editor/editor-status-bar.jsx +26 -0
  48. package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +113 -34
  49. package/node_modules/@groove-dev/gui/src/components/editor/goto-line.jsx +35 -0
  50. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +12 -6
  51. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +15 -3
  52. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +0 -1
  53. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  54. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +6 -2
  55. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +11 -1
  56. package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +10 -9
  57. package/node_modules/@groove-dev/gui/src/components/marketplace/repo-import.jsx +9 -1
  58. package/node_modules/@groove-dev/gui/src/components/onboarding/provider-card.jsx +134 -0
  59. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  60. package/node_modules/@groove-dev/gui/src/components/pro/pro-gate.jsx +12 -5
  61. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-card.jsx +15 -8
  62. package/node_modules/@groove-dev/gui/src/components/pro/upgrade-modal.jsx +151 -0
  63. package/node_modules/@groove-dev/gui/src/components/settings/federation-activity.jsx +98 -0
  64. package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +290 -0
  65. package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +126 -0
  66. package/node_modules/@groove-dev/gui/src/components/settings/federation-wizard.jsx +293 -0
  67. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +110 -67
  68. package/node_modules/@groove-dev/gui/src/components/settings/remote-server-card.jsx +3 -3
  69. package/node_modules/@groove-dev/gui/src/components/settings/server-detail.jsx +310 -0
  70. package/node_modules/@groove-dev/gui/src/components/settings/server-dialog.jsx +4 -1
  71. package/node_modules/@groove-dev/gui/src/components/settings/server-list.jsx +59 -0
  72. package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +549 -0
  73. package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +78 -0
  74. package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +144 -0
  75. package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +187 -0
  76. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +2 -2
  77. package/node_modules/@groove-dev/gui/src/lib/electron.js +15 -0
  78. package/node_modules/@groove-dev/gui/src/lib/format.js +1 -0
  79. package/node_modules/@groove-dev/gui/src/stores/groove.js +388 -63
  80. package/node_modules/@groove-dev/gui/src/views/agents.jsx +148 -42
  81. package/node_modules/@groove-dev/gui/src/views/editor.jsx +92 -2
  82. package/node_modules/@groove-dev/gui/src/views/federation.jsx +37 -0
  83. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +2 -42
  84. package/node_modules/@groove-dev/gui/src/views/settings.jsx +35 -134
  85. package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +327 -0
  86. package/node_modules/@groove-dev/gui/src/views/teams.jsx +3 -3
  87. package/node_modules/@groove-dev/gui/src/views/toys.jsx +162 -0
  88. package/package.json +1 -1
  89. package/packages/daemon/src/api.js +586 -67
  90. package/packages/daemon/src/classifier.js +24 -0
  91. package/packages/daemon/src/credentials.js +12 -2
  92. package/packages/daemon/src/federation/ambassador.js +204 -0
  93. package/packages/daemon/src/federation/connection.js +359 -0
  94. package/packages/daemon/src/federation/contracts.js +112 -0
  95. package/packages/daemon/src/federation/whitelist.js +190 -0
  96. package/packages/daemon/src/federation.js +166 -7
  97. package/packages/daemon/src/index.js +172 -19
  98. package/packages/daemon/src/introducer.js +52 -7
  99. package/packages/daemon/src/journalist.js +46 -1
  100. package/packages/daemon/src/memory.js +36 -16
  101. package/packages/daemon/src/process.js +140 -23
  102. package/packages/daemon/src/providers/base.js +1 -0
  103. package/packages/daemon/src/providers/claude-code.js +14 -0
  104. package/packages/daemon/src/providers/codex.js +124 -28
  105. package/packages/daemon/src/providers/gemini.js +104 -17
  106. package/packages/daemon/src/providers/index.js +17 -0
  107. package/packages/daemon/src/registry.js +10 -1
  108. package/packages/daemon/src/rotator.js +93 -30
  109. package/packages/daemon/src/skills.js +33 -3
  110. package/packages/daemon/src/terminal-pty.js +9 -1
  111. package/packages/daemon/src/tool-executor.js +11 -5
  112. package/packages/daemon/src/toys.js +69 -0
  113. package/packages/daemon/src/tunnel-manager.js +24 -5
  114. package/packages/daemon/templates/toys-catalog.json +242 -0
  115. package/packages/gui/dist/assets/index-Bg6_D2xK.css +1 -0
  116. package/packages/gui/dist/assets/index-D3rvwTHD.js +8607 -0
  117. package/packages/gui/dist/index.html +3 -2
  118. package/packages/gui/index.html +1 -0
  119. package/packages/gui/src/app.css +7 -0
  120. package/packages/gui/src/app.jsx +37 -10
  121. package/packages/gui/src/components/agents/agent-chat.jsx +21 -31
  122. package/packages/gui/src/components/agents/agent-config.jsx +11 -6
  123. package/packages/gui/src/components/agents/agent-feed.jsx +2 -2
  124. package/packages/gui/src/components/agents/spawn-wizard.jsx +42 -1
  125. package/packages/gui/src/components/editor/breadcrumbs.jsx +30 -0
  126. package/packages/gui/src/components/editor/code-editor.jsx +33 -2
  127. package/packages/gui/src/components/editor/editor-status-bar.jsx +26 -0
  128. package/packages/gui/src/components/editor/editor-tabs.jsx +113 -34
  129. package/packages/gui/src/components/editor/goto-line.jsx +35 -0
  130. package/packages/gui/src/components/editor/terminal.jsx +12 -6
  131. package/packages/gui/src/components/layout/activity-bar.jsx +15 -3
  132. package/packages/gui/src/components/layout/app-shell.jsx +0 -1
  133. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
  134. package/packages/gui/src/components/layout/command-palette.jsx +6 -2
  135. package/packages/gui/src/components/layout/status-bar.jsx +11 -1
  136. package/packages/gui/src/components/layout/terminal-panel.jsx +10 -9
  137. package/packages/gui/src/components/marketplace/repo-import.jsx +9 -1
  138. package/packages/gui/src/components/onboarding/provider-card.jsx +134 -0
  139. package/packages/gui/src/components/onboarding/setup-wizard.jsx +819 -0
  140. package/packages/gui/src/components/pro/pro-gate.jsx +12 -5
  141. package/packages/gui/src/components/pro/upgrade-card.jsx +15 -8
  142. package/packages/gui/src/components/pro/upgrade-modal.jsx +151 -0
  143. package/packages/gui/src/components/settings/federation-activity.jsx +98 -0
  144. package/packages/gui/src/components/settings/federation-panel.jsx +290 -0
  145. package/packages/gui/src/components/settings/federation-peers.jsx +126 -0
  146. package/packages/gui/src/components/settings/federation-wizard.jsx +293 -0
  147. package/packages/gui/src/components/settings/quick-connect.jsx +110 -67
  148. package/packages/gui/src/components/settings/remote-server-card.jsx +3 -3
  149. package/packages/gui/src/components/settings/server-detail.jsx +310 -0
  150. package/packages/gui/src/components/settings/server-dialog.jsx +4 -1
  151. package/packages/gui/src/components/settings/server-list.jsx +59 -0
  152. package/packages/gui/src/components/settings/ssh-wizard.jsx +549 -0
  153. package/packages/gui/src/components/toys/toy-card.jsx +78 -0
  154. package/packages/gui/src/components/toys/toy-creator.jsx +144 -0
  155. package/packages/gui/src/components/toys/toy-launcher.jsx +187 -0
  156. package/packages/gui/src/components/ui/toast.jsx +2 -2
  157. package/packages/gui/src/lib/electron.js +15 -0
  158. package/packages/gui/src/lib/format.js +1 -0
  159. package/packages/gui/src/stores/groove.js +388 -63
  160. package/packages/gui/src/views/agents.jsx +148 -42
  161. package/packages/gui/src/views/editor.jsx +92 -2
  162. package/packages/gui/src/views/federation.jsx +37 -0
  163. package/packages/gui/src/views/marketplace.jsx +2 -42
  164. package/packages/gui/src/views/settings.jsx +35 -134
  165. package/packages/gui/src/views/subscription-panel.jsx +327 -0
  166. package/packages/gui/src/views/teams.jsx +3 -3
  167. package/packages/gui/src/views/toys.jsx +162 -0
  168. package/plans/chat-persistence-refactor.md +154 -0
  169. package/node_modules/@groove-dev/gui/dist/assets/index-BE6lYcd7.css +0 -1
  170. package/node_modules/@groove-dev/gui/dist/assets/index-zdzOLAZM.js +0 -677
  171. package/packages/gui/dist/assets/index-BE6lYcd7.css +0 -1
  172. package/packages/gui/dist/assets/index-zdzOLAZM.js +0 -677
@@ -0,0 +1,112 @@
1
+ // GROOVE — Federation Diplomatic Pouch Contracts
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ const CONTRACT_SCHEMAS = {
5
+ 'task-request': {
6
+ required: ['taskId', 'description', 'requesterRole'],
7
+ optional: ['priority', 'suggestedRole', 'context', 'deadline'],
8
+ },
9
+ 'task-accepted': {
10
+ required: ['taskId', 'assignedAgentId', 'assignedRole'],
11
+ optional: ['estimatedCompletion'],
12
+ },
13
+ 'task-rejected': {
14
+ required: ['taskId', 'reason'],
15
+ optional: ['suggestion'],
16
+ },
17
+ 'status-update': {
18
+ required: ['taskId', 'status'],
19
+ optional: ['progress', 'details', 'blockers'],
20
+ },
21
+ 'delivery': {
22
+ required: ['taskId', 'result'],
23
+ optional: ['artifacts', 'summary', 'followUp'],
24
+ },
25
+ 'question': {
26
+ required: ['questionId', 'text'],
27
+ optional: ['taskId', 'context', 'urgency'],
28
+ },
29
+ 'capability-query': {
30
+ required: [],
31
+ optional: ['filter'],
32
+ },
33
+ 'capability-response': {
34
+ required: ['roles', 'providerCount', 'agentCount'],
35
+ optional: ['availableRoles', 'busyAgents'],
36
+ },
37
+ };
38
+
39
+ const VALID_TYPES = new Set(Object.keys(CONTRACT_SCHEMAS));
40
+
41
+ export function validateContract(contract) {
42
+ if (!contract || typeof contract !== 'object') {
43
+ return { valid: false, error: 'Contract must be an object' };
44
+ }
45
+ if (!contract.type || !VALID_TYPES.has(contract.type)) {
46
+ return { valid: false, error: `Invalid contract type: ${contract.type}` };
47
+ }
48
+ if (!contract.spec || typeof contract.spec !== 'object') {
49
+ return { valid: false, error: 'Contract must have a spec object' };
50
+ }
51
+
52
+ const schema = CONTRACT_SCHEMAS[contract.type];
53
+ for (const field of schema.required) {
54
+ if (contract.spec[field] === undefined || contract.spec[field] === null) {
55
+ return { valid: false, error: `Missing required field: spec.${field}` };
56
+ }
57
+ }
58
+
59
+ return { valid: true };
60
+ }
61
+
62
+ export function getContractTypes() {
63
+ return Object.keys(CONTRACT_SCHEMAS);
64
+ }
65
+
66
+ export class ContractHandlerRegistry {
67
+ constructor() {
68
+ this.handlers = new Map();
69
+ }
70
+
71
+ register(type, handler) {
72
+ if (!VALID_TYPES.has(type)) {
73
+ throw new Error(`Cannot register handler for unknown type: ${type}`);
74
+ }
75
+ this.handlers.set(type, handler);
76
+ }
77
+
78
+ async handle(contract, context) {
79
+ const validation = validateContract(contract);
80
+ if (!validation.valid) {
81
+ throw new Error(validation.error);
82
+ }
83
+
84
+ const handler = this.handlers.get(contract.type);
85
+ if (!handler) {
86
+ throw new Error(`No handler registered for type: ${contract.type}`);
87
+ }
88
+
89
+ return handler(contract, context);
90
+ }
91
+
92
+ has(type) {
93
+ return this.handlers.has(type);
94
+ }
95
+ }
96
+
97
+ export function createCapabilityResponse(daemon) {
98
+ const agents = daemon.registry.getAll();
99
+ const roles = new Set(agents.map(a => a.role));
100
+ const busy = agents.filter(a => a.status === 'running');
101
+
102
+ return {
103
+ type: 'capability-response',
104
+ spec: {
105
+ roles: Array.from(roles),
106
+ providerCount: agents.reduce((s, a) => { s.add(a.provider); return s; }, new Set()).size,
107
+ agentCount: agents.length,
108
+ availableRoles: Array.from(roles),
109
+ busyAgents: busy.length,
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,190 @@
1
+ // GROOVE — Federation IP Whitelist Manager
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import { EventEmitter } from 'events';
7
+
8
+ const PROBE_INTERVAL = 15_000;
9
+ const PROBE_TIMEOUT = 5_000;
10
+
11
+ const PRIVATE_PATTERNS = [
12
+ /^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./,
13
+ /^0\./, /^169\.254\./, /^localhost$/i, /^::1$/, /^\[::1\]$/,
14
+ /^fc/i, /^fd/i, /^fe80/i,
15
+ ];
16
+
17
+ function isPrivateIp(host) {
18
+ return PRIVATE_PATTERNS.some(p => p.test(host));
19
+ }
20
+
21
+ function validateIp(ip) {
22
+ if (!ip || typeof ip !== 'string') throw new Error('IP is required');
23
+ const trimmed = ip.trim();
24
+ if (trimmed.length > 253) throw new Error('IP too long');
25
+ if (isPrivateIp(trimmed)) throw new Error('Cannot whitelist private/local addresses');
26
+ if (/[;&|`$(){}]/.test(trimmed)) throw new Error('Invalid characters in IP');
27
+ return trimmed;
28
+ }
29
+
30
+ export class WhitelistManager extends EventEmitter {
31
+ constructor(federation) {
32
+ super();
33
+ this.federation = federation;
34
+ this.daemon = federation.daemon;
35
+ this.filePath = resolve(federation.fedDir, 'whitelist.json');
36
+ this.entries = this._load();
37
+ this._probeTimer = null;
38
+ }
39
+
40
+ _load() {
41
+ if (!existsSync(this.filePath)) return new Map();
42
+ try {
43
+ const data = JSON.parse(readFileSync(this.filePath, 'utf8'));
44
+ const map = new Map();
45
+ for (const entry of data) {
46
+ map.set(entry.ip, entry);
47
+ }
48
+ return map;
49
+ } catch {
50
+ return new Map();
51
+ }
52
+ }
53
+
54
+ _save() {
55
+ const dir = resolve(this.federation.fedDir);
56
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
57
+ writeFileSync(this.filePath, JSON.stringify(Array.from(this.entries.values()), null, 2), { mode: 0o600 });
58
+ }
59
+
60
+ add(ip, port = 31415, name = '') {
61
+ const validated = validateIp(ip);
62
+ if (this.entries.has(validated)) {
63
+ throw new Error(`IP already whitelisted: ${validated}`);
64
+ }
65
+
66
+ const entry = {
67
+ ip: validated,
68
+ port: typeof port === 'number' ? port : 31415,
69
+ name: typeof name === 'string' ? name.trim().slice(0, 128) : '',
70
+ status: 'waiting',
71
+ addedAt: new Date().toISOString(),
72
+ lastProbe: null,
73
+ remoteDaemonId: null,
74
+ };
75
+
76
+ this.entries.set(validated, entry);
77
+ this._save();
78
+ this.daemon.audit.log('federation.whitelist.add', { ip: validated, port: entry.port });
79
+ this.emit('added', entry);
80
+ return entry;
81
+ }
82
+
83
+ remove(ip) {
84
+ const trimmed = ip?.trim();
85
+ if (!this.entries.has(trimmed)) {
86
+ throw new Error(`IP not in whitelist: ${trimmed}`);
87
+ }
88
+ const entry = this.entries.get(trimmed);
89
+ this.entries.delete(trimmed);
90
+ this._save();
91
+ this.daemon.audit.log('federation.whitelist.remove', { ip: trimmed });
92
+ this.emit('removed', { ip: trimmed, previousStatus: entry.status });
93
+ return true;
94
+ }
95
+
96
+ list() {
97
+ return Array.from(this.entries.values());
98
+ }
99
+
100
+ isWhitelisted(ip) {
101
+ return this.entries.has(ip?.trim());
102
+ }
103
+
104
+ getEntry(ip) {
105
+ return this.entries.get(ip?.trim()) || null;
106
+ }
107
+
108
+ startProbing() {
109
+ if (this._probeTimer) return;
110
+ this._probeTimer = setInterval(() => this._probeAll(), PROBE_INTERVAL);
111
+ this._probeAll();
112
+ }
113
+
114
+ stopProbing() {
115
+ if (this._probeTimer) {
116
+ clearInterval(this._probeTimer);
117
+ this._probeTimer = null;
118
+ }
119
+ }
120
+
121
+ async _probeAll() {
122
+ for (const entry of this.entries.values()) {
123
+ if (entry.status === 'connected') continue;
124
+ try {
125
+ await this._probePeer(entry);
126
+ } catch {
127
+ // Individual probe failures are expected
128
+ }
129
+ }
130
+ }
131
+
132
+ async _probePeer(entry) {
133
+ const url = `http://${entry.ip}:${entry.port}/api/federation/whitelist-check`;
134
+ const controller = new AbortController();
135
+ const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT);
136
+
137
+ try {
138
+ const res = await fetch(url, {
139
+ signal: controller.signal,
140
+ headers: { 'X-Groove-DaemonId': this.federation._daemonId() },
141
+ });
142
+ clearTimeout(timeout);
143
+
144
+ if (!res.ok) {
145
+ this._updateStatus(entry, 'waiting');
146
+ return;
147
+ }
148
+
149
+ const data = await res.json();
150
+ entry.lastProbe = new Date().toISOString();
151
+ entry.remoteDaemonId = data.daemonId || null;
152
+
153
+ if (data.whitelisted) {
154
+ this._updateStatus(entry, 'mutual');
155
+ } else {
156
+ this._updateStatus(entry, 'waiting');
157
+ }
158
+ } catch {
159
+ clearTimeout(timeout);
160
+ entry.lastProbe = new Date().toISOString();
161
+ }
162
+ }
163
+
164
+ _updateStatus(entry, newStatus) {
165
+ const oldStatus = entry.status;
166
+ if (oldStatus === newStatus) return;
167
+ entry.status = newStatus;
168
+ this._save();
169
+ this.emit('status-change', { ip: entry.ip, oldStatus, newStatus, entry });
170
+ }
171
+
172
+ setConnected(ip) {
173
+ const entry = this.entries.get(ip?.trim());
174
+ if (entry) {
175
+ this._updateStatus(entry, 'connected');
176
+ }
177
+ }
178
+
179
+ setDisconnected(ip) {
180
+ const entry = this.entries.get(ip?.trim());
181
+ if (entry && entry.status === 'connected') {
182
+ this._updateStatus(entry, 'mutual');
183
+ }
184
+ }
185
+
186
+ destroy() {
187
+ this.stopProbing();
188
+ this.removeAllListeners();
189
+ }
190
+ }
@@ -1,9 +1,13 @@
1
- // GROOVE — Federation (Ed25519 key exchange + contract signing)
1
+ // GROOVE — Federation (Ed25519 key exchange + contract signing + v1 protocol)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey, createHash } from 'crypto';
4
+ import { generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey, createHash, randomBytes } from 'crypto';
5
5
  import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, readdirSync } from 'fs';
6
6
  import { resolve } from 'path';
7
+ import { WhitelistManager } from './federation/whitelist.js';
8
+ import { ConnectionManager } from './federation/connection.js';
9
+ import { AmbassadorManager } from './federation/ambassador.js';
10
+ import { ContractHandlerRegistry, createCapabilityResponse } from './federation/contracts.js';
7
11
 
8
12
  // Peer IDs must be safe for filenames — hex only (from SHA-256 fingerprint)
9
13
  const PEER_ID_PATTERN = /^[a-f0-9]{1,64}$/;
@@ -66,10 +70,11 @@ export class Federation {
66
70
  * @returns {{ payload: object, signature: string }}
67
71
  */
68
72
  sign(payload) {
69
- const data = Buffer.from(JSON.stringify(payload), 'utf8');
73
+ const enriched = { ...payload, timestamp: Date.now(), nonce: randomBytes(16).toString('hex') };
74
+ const data = Buffer.from(JSON.stringify(enriched), 'utf8');
70
75
  const sig = sign(null, data, this.privateKey);
71
76
  return {
72
- payload,
77
+ payload: enriched,
73
78
  signature: sig.toString('base64'),
74
79
  };
75
80
  }
@@ -86,6 +91,12 @@ export class Federation {
86
91
  if (!peer) return false;
87
92
 
88
93
  try {
94
+ // Replay protection: reject messages older than 5 minutes
95
+ if (payload.timestamp && (Date.now() - payload.timestamp > 5 * 60 * 1000)) {
96
+ console.warn(`[federation] Rejected stale message from ${peerId}`);
97
+ return false;
98
+ }
99
+
89
100
  const data = Buffer.from(JSON.stringify(payload), 'utf8');
90
101
  const sig = Buffer.from(signature, 'base64');
91
102
  const pubKey = createPublicKey(peer.publicKey);
@@ -132,6 +143,26 @@ export class Federation {
132
143
  * @returns {object} pairing result
133
144
  */
134
145
  async initiatePairing(remoteUrl) {
146
+ // SSRF protection: block private/reserved IPs
147
+ try {
148
+ const parsed = new URL(remoteUrl);
149
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
150
+ throw new Error('Only HTTP(S) URLs allowed');
151
+ }
152
+ const host = parsed.hostname;
153
+ const privatePatterns = [
154
+ /^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./,
155
+ /^0\./, /^169\.254\./, /^localhost$/i, /^::1$/, /^\[::1\]$/,
156
+ /^fc/i, /^fd/i, /^fe80/i,
157
+ ];
158
+ if (privatePatterns.some(p => p.test(host))) {
159
+ throw new Error('Cannot pair with private/local addresses');
160
+ }
161
+ } catch (err) {
162
+ if (err.message.includes('Cannot pair') || err.message.includes('Only HTTP')) throw err;
163
+ throw new Error(`Invalid remote URL: ${err.message}`);
164
+ }
165
+
135
166
  const localInfo = this._localInfo();
136
167
 
137
168
  // Send our public key to the remote daemon's pairing endpoint
@@ -189,7 +220,12 @@ export class Federation {
189
220
  * @param {object} remoteInfo — { id, name, host, port, publicKey }
190
221
  * @returns {object} our info + public key for the remote to store
191
222
  */
192
- acceptPairing(remoteInfo) {
223
+ acceptPairing(remoteInfo, callerIp) {
224
+ // Gate: caller IP must be whitelisted (bi-directional requirement)
225
+ if (!callerIp || !this.whitelist?.isWhitelisted(callerIp)) {
226
+ throw new Error('Pairing rejected: caller IP not whitelisted');
227
+ }
228
+
193
229
  if (!remoteInfo.id || !remoteInfo.publicKey) {
194
230
  throw new Error('Invalid pairing request: missing id or publicKey');
195
231
  }
@@ -207,13 +243,13 @@ export class Federation {
207
243
  this._savePeer({
208
244
  id: remoteInfo.id,
209
245
  name: remoteInfo.name || remoteInfo.id,
210
- host: remoteInfo.host,
246
+ host: callerIp,
211
247
  port: remoteInfo.port,
212
248
  publicKey: remoteInfo.publicKey,
213
249
  pairedAt: new Date().toISOString(),
214
250
  });
215
251
 
216
- this.daemon.audit.log('federation.pair', { peerId: remoteInfo.id, peerHost: remoteInfo.host });
252
+ this.daemon.audit.log('federation.pair', { peerId: remoteInfo.id, peerHost: callerIp });
217
253
 
218
254
  // Return our info so the remote can store us
219
255
  const localInfo = this._localInfo();
@@ -341,12 +377,135 @@ export class Federation {
341
377
  }));
342
378
  }
343
379
 
380
+ // --- v1 Protocol ---
381
+
382
+ initialize() {
383
+ this.whitelist = new WhitelistManager(this);
384
+ this.connections = new ConnectionManager(this);
385
+ this.ambassadors = new AmbassadorManager(this);
386
+ this.contractHandlers = new ContractHandlerRegistry();
387
+
388
+ this.contractHandlers.register('capability-query', (_contract, _ctx) => {
389
+ return createCapabilityResponse(this.daemon);
390
+ });
391
+
392
+ this.whitelist.on('status-change', ({ ip, newStatus, entry }) => {
393
+ this.daemon.broadcast({ type: 'federation:whitelist', data: this.whitelist.list() });
394
+ if (newStatus === 'mutual') {
395
+ this.connections.onMutual(ip, entry.port, entry.remoteDaemonId);
396
+ }
397
+ });
398
+
399
+ this.connections.on('message', ({ message, peerId, daemonId, inbound }) => {
400
+ const senderId = inbound ? daemonId : peerId;
401
+ if (message.type === 'pouch' && message.senderId && message.payload && message.signature) {
402
+ try {
403
+ this.ambassadors.receivePouch(message.senderId, message.payload, message.signature);
404
+ } catch (err) {
405
+ console.warn(`[federation] Pouch error from ${senderId}: ${err.message}`);
406
+ }
407
+ }
408
+ });
409
+
410
+ this.whitelist.startProbing();
411
+ console.log(`[federation] v1 initialized — daemon ${this._daemonId()}`);
412
+ }
413
+
414
+ handleKnock(senderId, publicKey, payload, signature, callerIp) {
415
+ if (!senderId || !publicKey || !payload || !signature) {
416
+ throw new Error('Missing knock fields');
417
+ }
418
+
419
+ // Gate: caller IP must be in our whitelist (bi-directional requirement)
420
+ if (!callerIp || !this.whitelist?.isWhitelisted(callerIp)) {
421
+ throw new Error('Knock rejected: caller IP not whitelisted');
422
+ }
423
+
424
+ validatePeerId(senderId);
425
+
426
+ try {
427
+ createPublicKey(publicKey);
428
+ } catch {
429
+ throw new Error('Invalid public key in knock');
430
+ }
431
+
432
+ // Register peer key only after whitelist gate passes
433
+ if (!this.peers.has(senderId)) {
434
+ this._savePeer({
435
+ id: senderId,
436
+ name: senderId,
437
+ host: callerIp,
438
+ port: null,
439
+ publicKey,
440
+ pairedAt: new Date().toISOString(),
441
+ });
442
+ }
443
+
444
+ if (!this.verify(senderId, payload, signature)) {
445
+ throw new Error('Knock signature verification failed');
446
+ }
447
+
448
+ this.daemon.audit.log('federation.knock', { senderId, callerIp });
449
+
450
+ return {
451
+ accepted: true,
452
+ peerId: this._daemonId(),
453
+ peerName: this._daemonId(),
454
+ publicKey: this.getPublicKeyPem(),
455
+ };
456
+ }
457
+
458
+ handleWsUpgrade(ws, daemonId, callerIp, signatureHeader) {
459
+ // Gate: caller IP must be whitelisted (bi-directional requirement)
460
+ if (!callerIp || !this.whitelist?.isWhitelisted(callerIp)) {
461
+ ws.close(4001, 'IP not whitelisted');
462
+ return;
463
+ }
464
+
465
+ if (!daemonId || !this.peers.has(daemonId)) {
466
+ ws.close(4001, 'Unknown daemon');
467
+ return;
468
+ }
469
+
470
+ // Verify the cryptographic signature header
471
+ if (!signatureHeader) {
472
+ ws.close(4003, 'Missing signature');
473
+ return;
474
+ }
475
+ try {
476
+ const decoded = JSON.parse(Buffer.from(signatureHeader, 'base64').toString());
477
+ if (!decoded.payload || !decoded.signature || !this.verify(daemonId, decoded.payload, decoded.signature)) {
478
+ ws.close(4003, 'Signature verification failed');
479
+ return;
480
+ }
481
+ } catch {
482
+ ws.close(4003, 'Malformed signature header');
483
+ return;
484
+ }
485
+
486
+ this.connections.handleInboundConnection(ws, daemonId);
487
+ this.daemon.broadcast({ type: 'federation:whitelist', data: this.whitelist?.list() || [] });
488
+ }
489
+
490
+ isWhitelisted(ip) {
491
+ return this.whitelist?.isWhitelisted(ip) || false;
492
+ }
493
+
344
494
  getStatus() {
345
495
  return {
346
496
  id: this._daemonId(),
347
497
  peers: this.getPeers(),
348
498
  peerCount: this.peers.size,
349
499
  hasKeypair: existsSync(this.keyPath),
500
+ whitelist: this.whitelist?.list() || [],
501
+ connections: this.connections?.getStatus() || [],
502
+ ambassadors: this.ambassadors?.getStatus() || { ambassadors: [], totalQueued: 0 },
350
503
  };
351
504
  }
505
+
506
+ destroy() {
507
+ this.whitelist?.destroy();
508
+ this.connections?.destroy();
509
+ this.ambassadors?.destroy();
510
+ }
352
511
  }