@tjamescouch/agentchat 0.1.0 → 0.2.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 +91 -3
- package/bin/agentchat.js +140 -0
- package/lib/client.js +298 -1
- package/lib/proposals.js +393 -0
- package/lib/protocol.js +109 -4
- package/lib/server.js +242 -1
- package/package.json +7 -1
- package/.claude/settings.local.json +0 -12
- package/.github/workflows/fly-deploy.yml +0 -18
- package/ROADMAP.md +0 -88
- package/SPEC.md +0 -279
- package/fly.toml +0 -21
- package/quick-test.sh +0 -45
- package/test/integration.test.js +0 -536
package/lib/proposals.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentChat Proposals Module
|
|
3
|
+
* Handles structured negotiation between agents
|
|
4
|
+
*
|
|
5
|
+
* Proposals enable agents to make verifiable, signed commitments
|
|
6
|
+
* for work, services, or payments.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { generateProposalId, ProposalStatus } from './protocol.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* In-memory proposal store
|
|
13
|
+
* In production, this could be backed by persistence
|
|
14
|
+
*/
|
|
15
|
+
export class ProposalStore {
|
|
16
|
+
constructor() {
|
|
17
|
+
// Map of proposal_id -> proposal object
|
|
18
|
+
this.proposals = new Map();
|
|
19
|
+
|
|
20
|
+
// Index by agent for quick lookups
|
|
21
|
+
this.byAgent = new Map(); // agent_id -> Set of proposal_ids
|
|
22
|
+
|
|
23
|
+
// Cleanup expired proposals periodically
|
|
24
|
+
this.cleanupInterval = setInterval(() => this.cleanupExpired(), 60000);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a new proposal
|
|
29
|
+
*/
|
|
30
|
+
create(proposal) {
|
|
31
|
+
const id = proposal.id || generateProposalId();
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
|
|
34
|
+
const stored = {
|
|
35
|
+
id,
|
|
36
|
+
from: proposal.from,
|
|
37
|
+
to: proposal.to,
|
|
38
|
+
task: proposal.task,
|
|
39
|
+
amount: proposal.amount || null,
|
|
40
|
+
currency: proposal.currency || null,
|
|
41
|
+
payment_code: proposal.payment_code || null,
|
|
42
|
+
terms: proposal.terms || null,
|
|
43
|
+
expires: proposal.expires ? now + (proposal.expires * 1000) : null,
|
|
44
|
+
status: ProposalStatus.PENDING,
|
|
45
|
+
created_at: now,
|
|
46
|
+
updated_at: now,
|
|
47
|
+
sig: proposal.sig,
|
|
48
|
+
// Response tracking
|
|
49
|
+
response_sig: null,
|
|
50
|
+
response_payment_code: null,
|
|
51
|
+
completed_at: null,
|
|
52
|
+
completion_proof: null,
|
|
53
|
+
dispute_reason: null
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
this.proposals.set(id, stored);
|
|
57
|
+
|
|
58
|
+
// Index by both agents
|
|
59
|
+
this._indexAgent(proposal.from, id);
|
|
60
|
+
this._indexAgent(proposal.to, id);
|
|
61
|
+
|
|
62
|
+
return stored;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get a proposal by ID
|
|
67
|
+
*/
|
|
68
|
+
get(id) {
|
|
69
|
+
const proposal = this.proposals.get(id);
|
|
70
|
+
if (!proposal) return null;
|
|
71
|
+
|
|
72
|
+
// Check expiration
|
|
73
|
+
if (proposal.expires && Date.now() > proposal.expires) {
|
|
74
|
+
if (proposal.status === ProposalStatus.PENDING) {
|
|
75
|
+
proposal.status = ProposalStatus.EXPIRED;
|
|
76
|
+
proposal.updated_at = Date.now();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return proposal;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Accept a proposal
|
|
85
|
+
*/
|
|
86
|
+
accept(id, acceptorId, sig, payment_code = null) {
|
|
87
|
+
const proposal = this.get(id);
|
|
88
|
+
if (!proposal) {
|
|
89
|
+
return { error: 'PROPOSAL_NOT_FOUND' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (proposal.status !== ProposalStatus.PENDING) {
|
|
93
|
+
return { error: 'PROPOSAL_NOT_PENDING', status: proposal.status };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (proposal.to !== acceptorId) {
|
|
97
|
+
return { error: 'NOT_PROPOSAL_RECIPIENT' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (proposal.expires && Date.now() > proposal.expires) {
|
|
101
|
+
proposal.status = ProposalStatus.EXPIRED;
|
|
102
|
+
proposal.updated_at = Date.now();
|
|
103
|
+
return { error: 'PROPOSAL_EXPIRED' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
proposal.status = ProposalStatus.ACCEPTED;
|
|
107
|
+
proposal.response_sig = sig;
|
|
108
|
+
proposal.response_payment_code = payment_code;
|
|
109
|
+
proposal.updated_at = Date.now();
|
|
110
|
+
|
|
111
|
+
return { proposal };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reject a proposal
|
|
116
|
+
*/
|
|
117
|
+
reject(id, rejectorId, sig, reason = null) {
|
|
118
|
+
const proposal = this.get(id);
|
|
119
|
+
if (!proposal) {
|
|
120
|
+
return { error: 'PROPOSAL_NOT_FOUND' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (proposal.status !== ProposalStatus.PENDING) {
|
|
124
|
+
return { error: 'PROPOSAL_NOT_PENDING', status: proposal.status };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (proposal.to !== rejectorId) {
|
|
128
|
+
return { error: 'NOT_PROPOSAL_RECIPIENT' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
proposal.status = ProposalStatus.REJECTED;
|
|
132
|
+
proposal.response_sig = sig;
|
|
133
|
+
proposal.reject_reason = reason;
|
|
134
|
+
proposal.updated_at = Date.now();
|
|
135
|
+
|
|
136
|
+
return { proposal };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Mark a proposal as complete
|
|
141
|
+
*/
|
|
142
|
+
complete(id, completerId, sig, proof = null) {
|
|
143
|
+
const proposal = this.get(id);
|
|
144
|
+
if (!proposal) {
|
|
145
|
+
return { error: 'PROPOSAL_NOT_FOUND' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (proposal.status !== ProposalStatus.ACCEPTED) {
|
|
149
|
+
return { error: 'PROPOSAL_NOT_ACCEPTED', status: proposal.status };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Either party can mark as complete
|
|
153
|
+
if (proposal.from !== completerId && proposal.to !== completerId) {
|
|
154
|
+
return { error: 'NOT_PROPOSAL_PARTY' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
proposal.status = ProposalStatus.COMPLETED;
|
|
158
|
+
proposal.completed_at = Date.now();
|
|
159
|
+
proposal.completion_proof = proof;
|
|
160
|
+
proposal.completion_sig = sig;
|
|
161
|
+
proposal.completed_by = completerId;
|
|
162
|
+
proposal.updated_at = Date.now();
|
|
163
|
+
|
|
164
|
+
return { proposal };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Dispute a proposal
|
|
169
|
+
*/
|
|
170
|
+
dispute(id, disputerId, sig, reason) {
|
|
171
|
+
const proposal = this.get(id);
|
|
172
|
+
if (!proposal) {
|
|
173
|
+
return { error: 'PROPOSAL_NOT_FOUND' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Can only dispute accepted proposals
|
|
177
|
+
if (proposal.status !== ProposalStatus.ACCEPTED) {
|
|
178
|
+
return { error: 'PROPOSAL_NOT_ACCEPTED', status: proposal.status };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Either party can dispute
|
|
182
|
+
if (proposal.from !== disputerId && proposal.to !== disputerId) {
|
|
183
|
+
return { error: 'NOT_PROPOSAL_PARTY' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
proposal.status = ProposalStatus.DISPUTED;
|
|
187
|
+
proposal.dispute_reason = reason;
|
|
188
|
+
proposal.dispute_sig = sig;
|
|
189
|
+
proposal.disputed_by = disputerId;
|
|
190
|
+
proposal.disputed_at = Date.now();
|
|
191
|
+
proposal.updated_at = Date.now();
|
|
192
|
+
|
|
193
|
+
return { proposal };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* List proposals for an agent
|
|
198
|
+
*/
|
|
199
|
+
listByAgent(agentId, options = {}) {
|
|
200
|
+
const ids = this.byAgent.get(agentId) || new Set();
|
|
201
|
+
let proposals = Array.from(ids).map(id => this.get(id)).filter(Boolean);
|
|
202
|
+
|
|
203
|
+
// Filter by status
|
|
204
|
+
if (options.status) {
|
|
205
|
+
proposals = proposals.filter(p => p.status === options.status);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Filter by role (from/to)
|
|
209
|
+
if (options.role === 'from') {
|
|
210
|
+
proposals = proposals.filter(p => p.from === agentId);
|
|
211
|
+
} else if (options.role === 'to') {
|
|
212
|
+
proposals = proposals.filter(p => p.to === agentId);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Sort by created_at descending
|
|
216
|
+
proposals.sort((a, b) => b.created_at - a.created_at);
|
|
217
|
+
|
|
218
|
+
// Limit
|
|
219
|
+
if (options.limit) {
|
|
220
|
+
proposals = proposals.slice(0, options.limit);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return proposals;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Index a proposal by agent
|
|
228
|
+
*/
|
|
229
|
+
_indexAgent(agentId, proposalId) {
|
|
230
|
+
if (!this.byAgent.has(agentId)) {
|
|
231
|
+
this.byAgent.set(agentId, new Set());
|
|
232
|
+
}
|
|
233
|
+
this.byAgent.get(agentId).add(proposalId);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Clean up expired proposals (older than 24 hours after expiration)
|
|
238
|
+
*/
|
|
239
|
+
cleanupExpired() {
|
|
240
|
+
const cutoff = Date.now() - (24 * 60 * 60 * 1000);
|
|
241
|
+
|
|
242
|
+
for (const [id, proposal] of this.proposals) {
|
|
243
|
+
if (proposal.expires && proposal.expires < cutoff) {
|
|
244
|
+
this.proposals.delete(id);
|
|
245
|
+
|
|
246
|
+
// Remove from agent indices
|
|
247
|
+
const fromSet = this.byAgent.get(proposal.from);
|
|
248
|
+
if (fromSet) fromSet.delete(id);
|
|
249
|
+
|
|
250
|
+
const toSet = this.byAgent.get(proposal.to);
|
|
251
|
+
if (toSet) toSet.delete(id);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Stop the cleanup interval
|
|
258
|
+
*/
|
|
259
|
+
close() {
|
|
260
|
+
if (this.cleanupInterval) {
|
|
261
|
+
clearInterval(this.cleanupInterval);
|
|
262
|
+
this.cleanupInterval = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get stats about the proposal store
|
|
268
|
+
*/
|
|
269
|
+
stats() {
|
|
270
|
+
const byStatus = {};
|
|
271
|
+
for (const proposal of this.proposals.values()) {
|
|
272
|
+
byStatus[proposal.status] = (byStatus[proposal.status] || 0) + 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
total: this.proposals.size,
|
|
277
|
+
byStatus,
|
|
278
|
+
agents: this.byAgent.size
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Format a proposal for display/transmission
|
|
285
|
+
*/
|
|
286
|
+
export function formatProposal(proposal) {
|
|
287
|
+
return {
|
|
288
|
+
id: proposal.id,
|
|
289
|
+
from: proposal.from,
|
|
290
|
+
to: proposal.to,
|
|
291
|
+
task: proposal.task,
|
|
292
|
+
amount: proposal.amount,
|
|
293
|
+
currency: proposal.currency,
|
|
294
|
+
payment_code: proposal.payment_code,
|
|
295
|
+
terms: proposal.terms,
|
|
296
|
+
expires: proposal.expires,
|
|
297
|
+
status: proposal.status,
|
|
298
|
+
created_at: proposal.created_at,
|
|
299
|
+
sig: proposal.sig
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Format a proposal response (accept/reject/complete/dispute)
|
|
305
|
+
*/
|
|
306
|
+
export function formatProposalResponse(proposal, responseType) {
|
|
307
|
+
const base = {
|
|
308
|
+
proposal_id: proposal.id,
|
|
309
|
+
status: proposal.status,
|
|
310
|
+
updated_at: proposal.updated_at
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
switch (responseType) {
|
|
314
|
+
case 'accept':
|
|
315
|
+
return {
|
|
316
|
+
...base,
|
|
317
|
+
payment_code: proposal.response_payment_code,
|
|
318
|
+
sig: proposal.response_sig
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
case 'reject':
|
|
322
|
+
return {
|
|
323
|
+
...base,
|
|
324
|
+
reason: proposal.reject_reason,
|
|
325
|
+
sig: proposal.response_sig
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
case 'complete':
|
|
329
|
+
return {
|
|
330
|
+
...base,
|
|
331
|
+
completed_by: proposal.completed_by,
|
|
332
|
+
completed_at: proposal.completed_at,
|
|
333
|
+
proof: proposal.completion_proof,
|
|
334
|
+
sig: proposal.completion_sig
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
case 'dispute':
|
|
338
|
+
return {
|
|
339
|
+
...base,
|
|
340
|
+
disputed_by: proposal.disputed_by,
|
|
341
|
+
disputed_at: proposal.disputed_at,
|
|
342
|
+
reason: proposal.dispute_reason,
|
|
343
|
+
sig: proposal.dispute_sig
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
default:
|
|
347
|
+
return base;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Create proposal content string for signing
|
|
353
|
+
* This ensures both parties sign the same canonical data
|
|
354
|
+
*/
|
|
355
|
+
export function getProposalSigningContent(proposal) {
|
|
356
|
+
const fields = [
|
|
357
|
+
proposal.to,
|
|
358
|
+
proposal.task,
|
|
359
|
+
proposal.amount || '',
|
|
360
|
+
proposal.currency || '',
|
|
361
|
+
proposal.payment_code || '',
|
|
362
|
+
proposal.expires || ''
|
|
363
|
+
];
|
|
364
|
+
return fields.join('|');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Create accept content string for signing
|
|
369
|
+
*/
|
|
370
|
+
export function getAcceptSigningContent(proposalId, payment_code = '') {
|
|
371
|
+
return `ACCEPT|${proposalId}|${payment_code}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Create reject content string for signing
|
|
376
|
+
*/
|
|
377
|
+
export function getRejectSigningContent(proposalId, reason = '') {
|
|
378
|
+
return `REJECT|${proposalId}|${reason}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Create complete content string for signing
|
|
383
|
+
*/
|
|
384
|
+
export function getCompleteSigningContent(proposalId, proof = '') {
|
|
385
|
+
return `COMPLETE|${proposalId}|${proof}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Create dispute content string for signing
|
|
390
|
+
*/
|
|
391
|
+
export function getDisputeSigningContent(proposalId, reason) {
|
|
392
|
+
return `DISPUTE|${proposalId}|${reason}`;
|
|
393
|
+
}
|
package/lib/protocol.js
CHANGED
|
@@ -15,7 +15,13 @@ export const ClientMessageType = {
|
|
|
15
15
|
LIST_AGENTS: 'LIST_AGENTS',
|
|
16
16
|
CREATE_CHANNEL: 'CREATE_CHANNEL',
|
|
17
17
|
INVITE: 'INVITE',
|
|
18
|
-
PING: 'PING'
|
|
18
|
+
PING: 'PING',
|
|
19
|
+
// Proposal/negotiation message types
|
|
20
|
+
PROPOSAL: 'PROPOSAL',
|
|
21
|
+
ACCEPT: 'ACCEPT',
|
|
22
|
+
REJECT: 'REJECT',
|
|
23
|
+
COMPLETE: 'COMPLETE',
|
|
24
|
+
DISPUTE: 'DISPUTE'
|
|
19
25
|
};
|
|
20
26
|
|
|
21
27
|
// Server -> Client message types
|
|
@@ -29,7 +35,13 @@ export const ServerMessageType = {
|
|
|
29
35
|
CHANNELS: 'CHANNELS',
|
|
30
36
|
AGENTS: 'AGENTS',
|
|
31
37
|
ERROR: 'ERROR',
|
|
32
|
-
PONG: 'PONG'
|
|
38
|
+
PONG: 'PONG',
|
|
39
|
+
// Proposal/negotiation message types (relayed from clients)
|
|
40
|
+
PROPOSAL: 'PROPOSAL',
|
|
41
|
+
ACCEPT: 'ACCEPT',
|
|
42
|
+
REJECT: 'REJECT',
|
|
43
|
+
COMPLETE: 'COMPLETE',
|
|
44
|
+
DISPUTE: 'DISPUTE'
|
|
33
45
|
};
|
|
34
46
|
|
|
35
47
|
// Error codes
|
|
@@ -41,7 +53,23 @@ export const ErrorCode = {
|
|
|
41
53
|
RATE_LIMITED: 'RATE_LIMITED',
|
|
42
54
|
AGENT_NOT_FOUND: 'AGENT_NOT_FOUND',
|
|
43
55
|
CHANNEL_EXISTS: 'CHANNEL_EXISTS',
|
|
44
|
-
INVALID_NAME: 'INVALID_NAME'
|
|
56
|
+
INVALID_NAME: 'INVALID_NAME',
|
|
57
|
+
// Proposal errors
|
|
58
|
+
PROPOSAL_NOT_FOUND: 'PROPOSAL_NOT_FOUND',
|
|
59
|
+
PROPOSAL_EXPIRED: 'PROPOSAL_EXPIRED',
|
|
60
|
+
INVALID_PROPOSAL: 'INVALID_PROPOSAL',
|
|
61
|
+
SIGNATURE_REQUIRED: 'SIGNATURE_REQUIRED',
|
|
62
|
+
NOT_PROPOSAL_PARTY: 'NOT_PROPOSAL_PARTY'
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Proposal status
|
|
66
|
+
export const ProposalStatus = {
|
|
67
|
+
PENDING: 'pending',
|
|
68
|
+
ACCEPTED: 'accepted',
|
|
69
|
+
REJECTED: 'rejected',
|
|
70
|
+
COMPLETED: 'completed',
|
|
71
|
+
DISPUTED: 'disputed',
|
|
72
|
+
EXPIRED: 'expired'
|
|
45
73
|
};
|
|
46
74
|
|
|
47
75
|
/**
|
|
@@ -201,7 +229,61 @@ export function validateClientMessage(raw) {
|
|
|
201
229
|
case ClientMessageType.PING:
|
|
202
230
|
// No additional validation needed
|
|
203
231
|
break;
|
|
204
|
-
|
|
232
|
+
|
|
233
|
+
case ClientMessageType.PROPOSAL:
|
|
234
|
+
// Proposals require: to, task, and signature
|
|
235
|
+
if (!msg.to) {
|
|
236
|
+
return { valid: false, error: 'Missing target (to)' };
|
|
237
|
+
}
|
|
238
|
+
if (!isAgent(msg.to)) {
|
|
239
|
+
return { valid: false, error: 'Proposals must be sent to an agent (@id)' };
|
|
240
|
+
}
|
|
241
|
+
if (!msg.task || typeof msg.task !== 'string') {
|
|
242
|
+
return { valid: false, error: 'Missing or invalid task description' };
|
|
243
|
+
}
|
|
244
|
+
if (!msg.sig) {
|
|
245
|
+
return { valid: false, error: 'Proposals must be signed' };
|
|
246
|
+
}
|
|
247
|
+
// Optional fields: amount, currency, payment_code, expires, terms
|
|
248
|
+
if (msg.expires !== undefined && typeof msg.expires !== 'number') {
|
|
249
|
+
return { valid: false, error: 'expires must be a number (seconds)' };
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
|
|
253
|
+
case ClientMessageType.ACCEPT:
|
|
254
|
+
case ClientMessageType.REJECT:
|
|
255
|
+
// Accept/Reject require: proposal_id and signature
|
|
256
|
+
if (!msg.proposal_id) {
|
|
257
|
+
return { valid: false, error: 'Missing proposal_id' };
|
|
258
|
+
}
|
|
259
|
+
if (!msg.sig) {
|
|
260
|
+
return { valid: false, error: 'Accept/Reject must be signed' };
|
|
261
|
+
}
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
case ClientMessageType.COMPLETE:
|
|
265
|
+
// Complete requires: proposal_id, signature, and optionally proof
|
|
266
|
+
if (!msg.proposal_id) {
|
|
267
|
+
return { valid: false, error: 'Missing proposal_id' };
|
|
268
|
+
}
|
|
269
|
+
if (!msg.sig) {
|
|
270
|
+
return { valid: false, error: 'Complete must be signed' };
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
|
|
274
|
+
case ClientMessageType.DISPUTE:
|
|
275
|
+
// Dispute requires: proposal_id, reason, and signature
|
|
276
|
+
if (!msg.proposal_id) {
|
|
277
|
+
return { valid: false, error: 'Missing proposal_id' };
|
|
278
|
+
}
|
|
279
|
+
if (!msg.reason || typeof msg.reason !== 'string') {
|
|
280
|
+
return { valid: false, error: 'Missing or invalid dispute reason' };
|
|
281
|
+
}
|
|
282
|
+
if (!msg.sig) {
|
|
283
|
+
return { valid: false, error: 'Dispute must be signed' };
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
|
|
205
287
|
default:
|
|
206
288
|
return { valid: false, error: `Unknown message type: ${msg.type}` };
|
|
207
289
|
}
|
|
@@ -234,3 +316,26 @@ export function serialize(msg) {
|
|
|
234
316
|
export function parse(data) {
|
|
235
317
|
return JSON.parse(data);
|
|
236
318
|
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Generate a unique proposal ID
|
|
322
|
+
* Format: prop_<timestamp>_<random>
|
|
323
|
+
*/
|
|
324
|
+
export function generateProposalId() {
|
|
325
|
+
const timestamp = Date.now().toString(36);
|
|
326
|
+
const random = crypto.randomBytes(4).toString('hex');
|
|
327
|
+
return `prop_${timestamp}_${random}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Check if a message type is a proposal-related type
|
|
332
|
+
*/
|
|
333
|
+
export function isProposalMessage(type) {
|
|
334
|
+
return [
|
|
335
|
+
ClientMessageType.PROPOSAL,
|
|
336
|
+
ClientMessageType.ACCEPT,
|
|
337
|
+
ClientMessageType.REJECT,
|
|
338
|
+
ClientMessageType.COMPLETE,
|
|
339
|
+
ClientMessageType.DISPUTE
|
|
340
|
+
].includes(type);
|
|
341
|
+
}
|