@highway1/core 0.1.47 → 0.1.51

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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Token Bucket Rate Limiter
3
+ *
4
+ * Classic token bucket algorithm for per-sender rate limiting.
5
+ * Tokens refill at a constant rate up to capacity.
6
+ */
7
+
8
+ export interface TokenBucketConfig {
9
+ capacity: number; // Max tokens (burst size)
10
+ refillRate: number; // Tokens per millisecond
11
+ }
12
+
13
+ export class TokenBucket {
14
+ private tokens: number;
15
+ private lastRefill: number;
16
+ private readonly capacity: number;
17
+ private readonly refillRate: number; // tokens per ms
18
+
19
+ constructor(config: TokenBucketConfig, initialTokens?: number, lastRefill?: number) {
20
+ this.capacity = config.capacity;
21
+ this.refillRate = config.refillRate;
22
+ this.tokens = initialTokens ?? config.capacity;
23
+ this.lastRefill = lastRefill ?? Date.now();
24
+ }
25
+
26
+ /** Attempt to consume one token. Returns true if allowed. */
27
+ consume(): boolean {
28
+ this.refill();
29
+ if (this.tokens >= 1) {
30
+ this.tokens -= 1;
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+
36
+ getRemaining(): number {
37
+ this.refill();
38
+ return Math.floor(this.tokens);
39
+ }
40
+
41
+ /** Milliseconds until at least one token is available */
42
+ getResetTime(): number {
43
+ this.refill();
44
+ if (this.tokens >= 1) return 0;
45
+ const needed = 1 - this.tokens;
46
+ return Math.ceil(needed / this.refillRate);
47
+ }
48
+
49
+ /** Serialize state for persistence */
50
+ toState(): { tokens: number; lastRefill: number } {
51
+ return { tokens: this.tokens, lastRefill: this.lastRefill };
52
+ }
53
+
54
+ private refill(): void {
55
+ const now = Date.now();
56
+ const elapsed = now - this.lastRefill;
57
+ const newTokens = elapsed * this.refillRate;
58
+ this.tokens = Math.min(this.capacity, this.tokens + newTokens);
59
+ this.lastRefill = now;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Rate limiter tiers based on trust score
65
+ */
66
+ export interface RateLimitTiers {
67
+ /** Trust < 0.3: new/unknown agents */
68
+ newAgent: TokenBucketConfig;
69
+ /** Trust 0.3–0.6: established agents */
70
+ established: TokenBucketConfig;
71
+ /** Trust > 0.6: trusted agents */
72
+ trusted: TokenBucketConfig;
73
+ }
74
+
75
+ export const DEFAULT_RATE_LIMIT_TIERS: RateLimitTiers = {
76
+ newAgent: { capacity: 10, refillRate: 10 / (60 * 1000) }, // 10/min, burst 10
77
+ established: { capacity: 100, refillRate: 60 / (60 * 1000) }, // 60/min, burst 100
78
+ trusted: { capacity: 1000, refillRate: 600 / (60 * 1000) }, // 600/min, burst 1000
79
+ };
80
+
81
+ export function getTierConfig(trustScore: number, tiers: RateLimitTiers): TokenBucketConfig {
82
+ if (trustScore >= 0.6) return tiers.trusted;
83
+ if (trustScore >= 0.3) return tiers.established;
84
+ return tiers.newAgent;
85
+ }
@@ -1,8 +1,5 @@
1
- import type { Libp2p } from 'libp2p';
2
- import { peerIdFromString } from '@libp2p/peer-id';
3
- import { multiaddr } from '@multiformats/multiaddr';
4
1
  import type { MessageEnvelope } from './envelope.js';
5
- import type { DHTOperations } from '../discovery/dht.js';
2
+ import type { RelayClient } from '../transport/relay-client.js';
6
3
  import { encodeMessage, decodeMessage } from './codec.js';
7
4
  import { validateEnvelope, verifyEnvelope } from './envelope.js';
8
5
  import { createLogger } from '../utils/logger.js';
@@ -12,53 +9,34 @@ import { verify } from '../identity/keys.js';
12
9
 
13
10
  const logger = createLogger('router');
14
11
 
15
- /** Helper to efficiently concatenate Uint8Array chunks */
16
- function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
17
- if (arrays.length === 0) return new Uint8Array(0);
18
- if (arrays.length === 1) return arrays[0];
19
-
20
- const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0);
21
- const result = new Uint8Array(totalLength);
22
- let offset = 0;
23
- for (const arr of arrays) {
24
- result.set(arr, offset);
25
- offset += arr.length;
26
- }
27
- return result;
28
- }
29
-
30
12
  export type MessageHandler = (
31
13
  envelope: MessageEnvelope
32
14
  ) => Promise<MessageEnvelope | void>;
33
15
 
34
- export interface PeerHint {
35
- peerId: string;
36
- multiaddrs: string[];
37
- }
38
-
39
16
  export interface MessageRouter {
40
17
  registerHandler: (protocol: string, handler: MessageHandler) => void;
41
18
  unregisterHandler: (protocol: string) => void;
42
19
  registerCatchAllHandler: (handler: MessageHandler) => void;
43
- sendMessage: (envelope: MessageEnvelope, peerHint?: PeerHint) => Promise<MessageEnvelope | void>;
20
+ sendMessage: (envelope: MessageEnvelope) => Promise<MessageEnvelope | void>;
44
21
  start: () => Promise<void>;
45
22
  stop: () => Promise<void>;
46
23
  }
47
24
 
48
25
  /**
49
- * Create a message router for a libp2p node.
50
- * When dht is provided, sendMessage can resolve DIDs to peer addresses via DHT lookup.
51
- * When relayPeers is provided, sendMessage will attempt relay fallback if direct dial fails.
26
+ * Create a message router using relay client (CVP-0011)
52
27
  */
53
28
  export function createMessageRouter(
54
- libp2p: Libp2p,
55
- verifyFn: (signature: Uint8Array, data: Uint8Array) => Promise<boolean>,
56
- dht?: DHTOperations,
57
- relayPeers?: string[]
29
+ relayClient: RelayClient,
30
+ verifyFn: (signature: Uint8Array, data: Uint8Array) => Promise<boolean>
58
31
  ): MessageRouter {
59
32
  const handlers = new Map<string, MessageHandler>();
60
33
  let catchAllHandler: MessageHandler | undefined;
61
- const PROTOCOL_PREFIX = '/clawiverse/msg/1.0.0';
34
+
35
+ // Map to track pending requests waiting for responses
36
+ const pendingRequests = new Map<string, {
37
+ resolve: (envelope: MessageEnvelope | undefined) => void;
38
+ timeout: NodeJS.Timeout;
39
+ }>();
62
40
 
63
41
  return {
64
42
  registerHandler: (protocol: string, handler: MessageHandler) => {
@@ -76,160 +54,16 @@ export function createMessageRouter(
76
54
  logger.info('Registered catch-all message handler');
77
55
  },
78
56
 
79
- sendMessage: async (envelope: MessageEnvelope, peerHint?: PeerHint) => {
57
+ sendMessage: async (envelope: MessageEnvelope) => {
80
58
  try {
81
59
  if (!validateEnvelope(envelope)) {
82
60
  throw new MessagingError('Invalid message envelope');
83
61
  }
84
62
 
85
- // Resolve peer: try peerHint first, then DHT
86
- let targetPeerIdStr: string | undefined;
87
- let targetMultiaddrs: string[] = [];
88
-
89
- if (peerHint) {
90
- targetPeerIdStr = peerHint.peerId;
91
- targetMultiaddrs = peerHint.multiaddrs;
92
- logger.info('Using peer hint for direct addressing', { peerId: targetPeerIdStr });
93
- } else if (dht) {
94
- const resolved = await dht.resolveDID(envelope.to);
95
- if (resolved) {
96
- targetPeerIdStr = resolved.peerId;
97
- targetMultiaddrs = resolved.multiaddrs;
98
- }
99
- }
100
-
101
- if (!targetPeerIdStr) {
102
- throw new MessagingError(
103
- `Cannot resolve recipient: ${envelope.to} — provide peerHint or ensure agent is in DHT`
104
- );
105
- }
106
-
107
- const targetPeerId = peerIdFromString(targetPeerIdStr);
108
-
109
- if (targetMultiaddrs.length > 0) {
110
- const mas = targetMultiaddrs.map((a) => multiaddr(a));
111
- await libp2p.peerStore.merge(targetPeerId, { multiaddrs: mas });
112
- }
113
-
114
- logger.info('Dialing peer for message delivery', {
115
- peerId: targetPeerIdStr,
116
- multiaddrs: targetMultiaddrs,
117
- });
118
-
119
- let stream: any;
120
- const DIAL_TIMEOUT = 3000; // 3 seconds per attempt
121
-
122
- // Separate relay addrs from direct addrs
123
- const relayMultiaddrs = targetMultiaddrs.filter((a) => a.includes('/p2p-circuit/'));
124
- const directMultiaddrs = targetMultiaddrs.filter((a) => !a.includes('/p2p-circuit/'));
125
-
126
- // Try direct addresses in parallel
127
- if (directMultiaddrs.length > 0) {
128
- const directDialPromises = directMultiaddrs.map(async (addr) => {
129
- try {
130
- const conn = await libp2p.dial(multiaddr(addr), {
131
- signal: AbortSignal.timeout(DIAL_TIMEOUT)
132
- });
133
- const s = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
134
- logger.info('Direct dial succeeded', { addr });
135
- return { conn, stream: s };
136
- } catch {
137
- return null;
138
- }
139
- });
140
-
141
- const winner = await Promise.race(
142
- directDialPromises.map(p => p.then(r => r || Promise.reject()))
143
- ).catch(() => undefined);
144
-
145
- if (winner) {
146
- stream = winner.stream;
147
- // Close connections whose streams were not selected
148
- Promise.allSettled(directDialPromises).then((results) => {
149
- for (const result of results) {
150
- if (result.status === 'fulfilled' && result.value && result.value.stream !== stream) {
151
- result.value.conn.close().catch(() => {});
152
- }
153
- }
154
- });
155
- }
156
- }
157
-
158
- let lastError: unknown;
159
-
160
- // Fall back to relay: try all relay addresses in parallel
161
- if (!stream) {
162
- const allRelayAddrs = [
163
- ...relayMultiaddrs,
164
- ...(relayPeers ?? []).map((r) => buildCircuitRelayAddr(r, targetPeerIdStr!)),
165
- ];
166
- // Deduplicate
167
- const uniqueRelayAddrs = [...new Set(allRelayAddrs)];
168
-
169
- if (uniqueRelayAddrs.length > 0) {
170
- const relayDialPromises = uniqueRelayAddrs.map(async (addr) => {
171
- try {
172
- const conn = await libp2p.dial(multiaddr(addr), {
173
- signal: AbortSignal.timeout(DIAL_TIMEOUT)
174
- });
175
- logger.info('Relay connection established', { addr });
176
- const s = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
177
- logger.info('Relay stream opened', { addr });
178
- return { conn, stream: s };
179
- } catch (relayErr) {
180
- logger.warn('Relay dial failed', { addr, error: (relayErr as Error).message });
181
- lastError = relayErr;
182
- return null;
183
- }
184
- });
185
-
186
- const winner = await Promise.race(
187
- relayDialPromises.map(p => p.then(r => r || Promise.reject()))
188
- ).catch(() => undefined);
189
-
190
- if (winner) {
191
- stream = winner.stream;
192
- // Close connections whose streams were not selected
193
- Promise.allSettled(relayDialPromises).then((results) => {
194
- for (const result of results) {
195
- if (result.status === 'fulfilled' && result.value && result.value.stream !== stream) {
196
- result.value.conn.close().catch(() => {});
197
- }
198
- }
199
- });
200
- }
201
- }
202
- }
203
-
204
- // Last resort: query DHT for relay-capable peers
205
- if (!stream && dht && 'queryRelayPeers' in dht) {
206
- const discoveredRelays = await (dht as any).queryRelayPeers();
207
- for (const relayAddr of discoveredRelays) {
208
- const circuitAddr = buildCircuitRelayAddr(relayAddr, targetPeerIdStr!);
209
- try {
210
- const conn = await libp2p.dial(multiaddr(circuitAddr));
211
- stream = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
212
- logger.info('DHT-discovered relay succeeded', { relayAddr });
213
- break;
214
- } catch (e) {
215
- logger.warn('DHT relay failed', { relayAddr, error: (e as Error).message });
216
- lastError = e;
217
- }
218
- }
219
- }
220
-
221
- if (!stream) {
222
- throw lastError ?? new MessagingError('All dial attempts failed (including DHT-discovered relays)');
223
- }
224
-
225
63
  const encoded = encodeMessage(envelope);
226
- await stream.sink(
227
- (async function* () {
228
- yield encoded;
229
- })()
230
- );
64
+ await relayClient.sendEnvelope(envelope.to, encoded);
231
65
 
232
- logger.info('Message sent over libp2p stream', {
66
+ logger.info('Message sent via relay', {
233
67
  id: envelope.id,
234
68
  from: envelope.from,
235
69
  to: envelope.to,
@@ -240,44 +74,17 @@ export function createMessageRouter(
240
74
  if (envelope.type === 'request') {
241
75
  logger.debug('Waiting for response to request', { id: envelope.id });
242
76
 
243
- try {
244
- // Add timeout to prevent infinite blocking
245
- const RESPONSE_TIMEOUT = 5000; // 5 seconds
77
+ const RESPONSE_TIMEOUT = 30000; // 30 seconds (CVP-0010 §4.2)
246
78
 
247
- const responsePromise = (async () => {
248
- const responseChunks: Uint8Array[] = [];
249
- for await (const chunk of stream.source) {
250
- responseChunks.push(chunk.subarray());
251
- }
79
+ return new Promise<MessageEnvelope | undefined>((resolve) => {
80
+ const timeout = setTimeout(() => {
81
+ pendingRequests.delete(envelope.id);
82
+ logger.warn('Response timeout', { id: envelope.id, timeout: RESPONSE_TIMEOUT });
83
+ resolve(undefined);
84
+ }, RESPONSE_TIMEOUT);
252
85
 
253
- if (responseChunks.length > 0) {
254
- const responseData = concatUint8Arrays(responseChunks);
255
- const responseEnvelope = decodeMessage(responseData);
256
- logger.info('Received response', {
257
- id: responseEnvelope.id,
258
- replyTo: responseEnvelope.replyTo,
259
- });
260
-
261
- return responseEnvelope;
262
- } else {
263
- logger.warn('No response received for request', { id: envelope.id });
264
- return undefined;
265
- }
266
- })();
267
-
268
- const timeoutPromise = new Promise<undefined>((resolve) => {
269
- setTimeout(() => {
270
- logger.warn('Response timeout', { id: envelope.id, timeout: RESPONSE_TIMEOUT });
271
- resolve(undefined);
272
- }, RESPONSE_TIMEOUT);
273
- });
274
-
275
- const response = await Promise.race([responsePromise, timeoutPromise]);
276
- return response;
277
- } catch (error) {
278
- logger.warn('Error reading response', { error });
279
- return undefined;
280
- }
86
+ pendingRequests.set(envelope.id, { resolve, timeout });
87
+ });
281
88
  }
282
89
 
283
90
  return undefined;
@@ -288,128 +95,115 @@ export function createMessageRouter(
288
95
  },
289
96
 
290
97
  start: async () => {
291
- await libp2p.handle(PROTOCOL_PREFIX, async ({ stream }) => {
98
+ // Register handler for incoming DELIVER messages
99
+ relayClient.onDeliver(async (deliverMsg) => {
292
100
  try {
293
- await handleIncomingStream(stream, handlers, catchAllHandler, verifyFn);
101
+ const envelope = decodeMessage(deliverMsg.envelope);
102
+
103
+ if (!validateEnvelope(envelope)) {
104
+ logger.warn('Received invalid message envelope');
105
+ return;
106
+ }
107
+
108
+ // Verify signature against sender DID embedded public key
109
+ const isValidSignature = await verifyEnvelope(envelope, async (signature, data) => {
110
+ const senderPublicKey = extractPublicKey(envelope.from);
111
+ return verify(signature, data, senderPublicKey);
112
+ });
113
+ if (!isValidSignature) {
114
+ logger.warn('Received message with invalid signature', {
115
+ id: envelope.id,
116
+ from: envelope.from,
117
+ });
118
+ return;
119
+ }
120
+
121
+ // Optional caller-supplied verification hook (e.g. policy checks)
122
+ try {
123
+ const { signature, ...envelopeWithoutSig } = envelope;
124
+ const dataBytes = new TextEncoder().encode(JSON.stringify(envelopeWithoutSig));
125
+ const signatureBytes = Buffer.from(signature, 'hex');
126
+ const hookValid = await verifyFn(signatureBytes, dataBytes);
127
+ if (!hookValid) {
128
+ logger.warn('Message rejected by custom verifier', {
129
+ id: envelope.id,
130
+ from: envelope.from,
131
+ });
132
+ return;
133
+ }
134
+ } catch (error) {
135
+ logger.warn('Custom verification hook failed', {
136
+ id: envelope.id,
137
+ from: envelope.from,
138
+ error: (error as Error).message,
139
+ });
140
+ return;
141
+ }
142
+
143
+ logger.info('Received message', {
144
+ id: envelope.id,
145
+ from: envelope.from,
146
+ to: envelope.to,
147
+ protocol: envelope.protocol,
148
+ type: envelope.type,
149
+ });
150
+
151
+ // Check if this is a response to a pending request
152
+ if (envelope.type === 'response' && envelope.replyTo) {
153
+ const pending = pendingRequests.get(envelope.replyTo);
154
+ if (pending) {
155
+ clearTimeout(pending.timeout);
156
+ pendingRequests.delete(envelope.replyTo);
157
+ pending.resolve(envelope);
158
+ logger.info('Matched response to pending request', {
159
+ requestId: envelope.replyTo,
160
+ responseId: envelope.id,
161
+ });
162
+ return;
163
+ }
164
+ }
165
+
166
+ // Dispatch to handler
167
+ const handler = handlers.get(envelope.protocol);
168
+ let response: MessageEnvelope | void = undefined;
169
+
170
+ if (handler) {
171
+ response = await handler(envelope);
172
+ } else if (catchAllHandler) {
173
+ logger.debug('Using catch-all handler for protocol', { protocol: envelope.protocol });
174
+ response = await catchAllHandler(envelope);
175
+ } else {
176
+ logger.warn('No handler for protocol', { protocol: envelope.protocol });
177
+ }
178
+
179
+ // Send response back if handler returned one
180
+ if (response) {
181
+ const encoded = encodeMessage(response);
182
+ await relayClient.sendEnvelope(response.to, encoded);
183
+ logger.info('Sent response back to sender', {
184
+ responseId: response.id,
185
+ replyTo: response.replyTo,
186
+ });
187
+ }
294
188
  } catch (error) {
295
- logger.error('Error handling incoming stream', error);
189
+ logger.error('Error handling incoming message', error);
296
190
  }
297
- }, { runOnLimitedConnection: true });
191
+ });
298
192
 
299
193
  logger.info('Message router started');
300
194
  },
301
195
 
302
196
  stop: async () => {
303
- await libp2p.unhandle(PROTOCOL_PREFIX);
197
+ // Clear all pending requests
198
+ for (const [, pending] of pendingRequests.entries()) {
199
+ clearTimeout(pending.timeout);
200
+ pending.resolve(undefined);
201
+ }
202
+ pendingRequests.clear();
203
+
304
204
  handlers.clear();
305
205
  catchAllHandler = undefined;
306
206
  logger.info('Message router stopped');
307
207
  },
308
208
  };
309
209
  }
310
-
311
- function buildCircuitRelayAddr(relayAddr: string, targetPeerId: string): string {
312
- return `${relayAddr}/p2p-circuit/p2p/${targetPeerId}`;
313
- }
314
-
315
- async function handleIncomingStream(
316
- stream: any,
317
- handlers: Map<string, MessageHandler>,
318
- catchAllHandler: MessageHandler | undefined,
319
- verifyFn: (signature: Uint8Array, data: Uint8Array) => Promise<boolean>
320
- ): Promise<void> {
321
- try {
322
- const chunks: Uint8Array[] = [];
323
- for await (const chunk of stream.source) {
324
- chunks.push(chunk.subarray());
325
- }
326
-
327
- const data = concatUint8Arrays(chunks);
328
-
329
- // Ignore empty streams (from unused parallel dial connections)
330
- if (data.length === 0) {
331
- logger.debug('Received empty stream, ignoring');
332
- return;
333
- }
334
-
335
- const envelope = decodeMessage(data);
336
-
337
- if (!validateEnvelope(envelope)) {
338
- logger.warn('Received invalid message envelope');
339
- return;
340
- }
341
-
342
- // Verify signature against sender DID embedded public key.
343
- const isValidSignature = await verifyEnvelope(envelope, async (signature, data) => {
344
- const senderPublicKey = extractPublicKey(envelope.from);
345
- return verify(signature, data, senderPublicKey);
346
- });
347
- if (!isValidSignature) {
348
- logger.warn('Received message with invalid signature', {
349
- id: envelope.id,
350
- from: envelope.from,
351
- });
352
- return;
353
- }
354
-
355
- // Optional caller-supplied verification hook (e.g. policy checks).
356
- try {
357
- const { signature, ...envelopeWithoutSig } = envelope;
358
- const dataBytes = new TextEncoder().encode(JSON.stringify(envelopeWithoutSig));
359
- const signatureBytes = Buffer.from(signature, 'hex');
360
- const hookValid = await verifyFn(signatureBytes, dataBytes);
361
- if (!hookValid) {
362
- logger.warn('Message rejected by custom verifier', {
363
- id: envelope.id,
364
- from: envelope.from,
365
- });
366
- return;
367
- }
368
- } catch (error) {
369
- logger.warn('Custom verification hook failed', {
370
- id: envelope.id,
371
- from: envelope.from,
372
- error: (error as Error).message,
373
- });
374
- return;
375
- }
376
-
377
- logger.info('Received message', {
378
- id: envelope.id,
379
- from: envelope.from,
380
- to: envelope.to,
381
- protocol: envelope.protocol,
382
- payload: envelope.payload,
383
- });
384
-
385
- const handler = handlers.get(envelope.protocol);
386
- let response: MessageEnvelope | void = undefined;
387
-
388
- if (handler) {
389
- response = await handler(envelope);
390
- } else if (catchAllHandler) {
391
- logger.debug('Using catch-all handler for protocol', { protocol: envelope.protocol });
392
- response = await catchAllHandler(envelope);
393
- } else {
394
- logger.warn('No handler for protocol', { protocol: envelope.protocol });
395
- }
396
-
397
- // Send response back if handler returned one
398
- if (response) {
399
- const encoded = encodeMessage(response);
400
- logger.info('Sending response back to sender', {
401
- responseId: response.id,
402
- replyTo: response.replyTo,
403
- size: encoded.length
404
- });
405
-
406
- await stream.sink(
407
- (async function* () {
408
- yield encoded;
409
- })()
410
- );
411
- }
412
- } catch (error) {
413
- logger.error('Error handling incoming message', error);
414
- }
415
- }