@highway1/core 0.1.46 → 0.1.48
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/dist/index.d.ts +308 -1
- package/dist/index.js +3430 -85
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/messaging/defense.ts +236 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/queue.ts +181 -0
- package/src/messaging/rate-limiter.ts +85 -0
- package/src/messaging/router.ts +38 -7
- package/src/messaging/storage.ts +281 -0
- package/src/messaging/types.ts +149 -0
- package/src/transport/node.ts +3 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -24,6 +24,11 @@ export * from './discovery/semantic-search.js';
|
|
|
24
24
|
export * from './messaging/envelope.js';
|
|
25
25
|
export * from './messaging/codec.js';
|
|
26
26
|
export * from './messaging/router.js';
|
|
27
|
+
export * from './messaging/types.js';
|
|
28
|
+
export * from './messaging/storage.js';
|
|
29
|
+
export * from './messaging/queue.js';
|
|
30
|
+
export * from './messaging/defense.js';
|
|
31
|
+
export * from './messaging/rate-limiter.js';
|
|
27
32
|
|
|
28
33
|
// Trust (Phase 2)
|
|
29
34
|
export * from './trust/index.js';
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defense Middleware
|
|
3
|
+
*
|
|
4
|
+
* Checks incoming messages against:
|
|
5
|
+
* 1. Allowlist bypass
|
|
6
|
+
* 2. Blocklist rejection
|
|
7
|
+
* 3. Deduplication (seen cache)
|
|
8
|
+
* 4. Trust score filtering
|
|
9
|
+
* 5. Rate limiting (token bucket, tiered by trust)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createLogger } from '../utils/logger.js';
|
|
13
|
+
import type { MessageEnvelope } from './envelope.js';
|
|
14
|
+
import type { TrustSystem } from '../trust/index.js';
|
|
15
|
+
import type { MessageStorage } from './storage.js';
|
|
16
|
+
import type { DefenseResult, RateLimitResult } from './types.js';
|
|
17
|
+
import {
|
|
18
|
+
TokenBucket,
|
|
19
|
+
DEFAULT_RATE_LIMIT_TIERS,
|
|
20
|
+
getTierConfig,
|
|
21
|
+
type RateLimitTiers,
|
|
22
|
+
} from './rate-limiter.js';
|
|
23
|
+
|
|
24
|
+
const logger = createLogger('defense');
|
|
25
|
+
|
|
26
|
+
export interface DefenseConfig {
|
|
27
|
+
trustSystem: TrustSystem;
|
|
28
|
+
storage: MessageStorage;
|
|
29
|
+
/** Minimum trust score to accept messages (0 = accept all) */
|
|
30
|
+
minTrustScore?: number;
|
|
31
|
+
/** Auto-block agents below this score */
|
|
32
|
+
autoBlockThreshold?: number;
|
|
33
|
+
rateLimitTiers?: RateLimitTiers;
|
|
34
|
+
/** TTL for seen-cache entries in ms (default: 1 hour) */
|
|
35
|
+
seenTtlMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class DefenseMiddleware {
|
|
39
|
+
private readonly trust: TrustSystem;
|
|
40
|
+
private readonly storage: MessageStorage;
|
|
41
|
+
private readonly minTrustScore: number;
|
|
42
|
+
private readonly autoBlockThreshold: number;
|
|
43
|
+
private readonly tiers: RateLimitTiers;
|
|
44
|
+
private readonly seenTtlMs: number;
|
|
45
|
+
|
|
46
|
+
// In-memory LRU-style seen cache (backed by LevelDB for persistence)
|
|
47
|
+
private readonly seenCache = new Map<string, number>(); // id → seenAt
|
|
48
|
+
private readonly MAX_SEEN_CACHE = 10_000;
|
|
49
|
+
|
|
50
|
+
// In-memory token buckets (backed by LevelDB for persistence)
|
|
51
|
+
private readonly buckets = new Map<string, TokenBucket>();
|
|
52
|
+
|
|
53
|
+
constructor(config: DefenseConfig) {
|
|
54
|
+
this.trust = config.trustSystem;
|
|
55
|
+
this.storage = config.storage;
|
|
56
|
+
this.minTrustScore = config.minTrustScore ?? 0;
|
|
57
|
+
this.autoBlockThreshold = config.autoBlockThreshold ?? 0.1;
|
|
58
|
+
this.tiers = config.rateLimitTiers ?? DEFAULT_RATE_LIMIT_TIERS;
|
|
59
|
+
this.seenTtlMs = config.seenTtlMs ?? 60 * 60 * 1000; // 1 hour
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run all defense checks on an incoming message.
|
|
64
|
+
* Returns { allowed: true } if the message should be processed,
|
|
65
|
+
* or { allowed: false, reason } if it should be dropped.
|
|
66
|
+
*/
|
|
67
|
+
async checkMessage(envelope: MessageEnvelope): Promise<DefenseResult> {
|
|
68
|
+
const did = envelope.from;
|
|
69
|
+
|
|
70
|
+
// 1. Allowlist bypass — skip all other checks
|
|
71
|
+
if (await this.isAllowed(did)) {
|
|
72
|
+
this.markAsSeen(envelope.id);
|
|
73
|
+
return { allowed: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Blocklist check
|
|
77
|
+
if (await this.isBlocked(did)) {
|
|
78
|
+
logger.debug('Message rejected: blocked', { id: envelope.id, from: did });
|
|
79
|
+
return { allowed: false, reason: 'blocked' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Deduplication
|
|
83
|
+
if (this.hasSeen(envelope.id)) {
|
|
84
|
+
logger.debug('Message rejected: duplicate', { id: envelope.id });
|
|
85
|
+
return { allowed: false, reason: 'duplicate' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 4. Trust score check
|
|
89
|
+
let trustScore = 0;
|
|
90
|
+
try {
|
|
91
|
+
const score = await this.trust.getTrustScore(did);
|
|
92
|
+
trustScore = score.interactionScore;
|
|
93
|
+
|
|
94
|
+
// Auto-block very low trust agents
|
|
95
|
+
if (trustScore < this.autoBlockThreshold && trustScore > 0) {
|
|
96
|
+
logger.warn('Auto-blocking low-trust agent', { did, trustScore });
|
|
97
|
+
await this.blockAgent(did, `Auto-blocked: trust score ${trustScore.toFixed(2)} below threshold`);
|
|
98
|
+
return { allowed: false, reason: 'blocked' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (trustScore < this.minTrustScore) {
|
|
102
|
+
logger.debug('Message rejected: trust too low', { id: envelope.id, trustScore });
|
|
103
|
+
return { allowed: false, reason: 'trust_too_low', trustScore };
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
logger.warn('Trust score lookup failed, using 0', { did, error: (err as Error).message });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 5. Rate limiting (tiered by trust)
|
|
110
|
+
const rateLimitResult = await this.checkRateLimit(did, trustScore);
|
|
111
|
+
if (!rateLimitResult.allowed) {
|
|
112
|
+
logger.debug('Message rejected: rate limited', {
|
|
113
|
+
id: envelope.id,
|
|
114
|
+
from: did,
|
|
115
|
+
resetTime: rateLimitResult.resetTime,
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
allowed: false,
|
|
119
|
+
reason: 'rate_limited',
|
|
120
|
+
remainingTokens: rateLimitResult.remaining,
|
|
121
|
+
resetTime: rateLimitResult.resetTime,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// All checks passed
|
|
126
|
+
this.markAsSeen(envelope.id);
|
|
127
|
+
return { allowed: true, trustScore, remainingTokens: rateLimitResult.remaining };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Blocklist ────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
async blockAgent(did: string, reason: string, blockedBy = 'local'): Promise<void> {
|
|
133
|
+
await this.storage.putBlock({ did, reason, blockedAt: Date.now(), blockedBy });
|
|
134
|
+
logger.info('Agent blocked', { did, reason });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async unblockAgent(did: string): Promise<void> {
|
|
138
|
+
await this.storage.deleteBlock(did);
|
|
139
|
+
logger.info('Agent unblocked', { did });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async isBlocked(did: string): Promise<boolean> {
|
|
143
|
+
return (await this.storage.getBlock(did)) !== null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Allowlist ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
async allowAgent(did: string, note?: string): Promise<void> {
|
|
149
|
+
await this.storage.putAllow({ did, addedAt: Date.now(), note });
|
|
150
|
+
logger.info('Agent allowlisted', { did });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async removeFromAllowlist(did: string): Promise<void> {
|
|
154
|
+
await this.storage.deleteAllow(did);
|
|
155
|
+
logger.info('Agent removed from allowlist', { did });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async isAllowed(did: string): Promise<boolean> {
|
|
159
|
+
return (await this.storage.getAllow(did)) !== null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Rate Limiting ────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
async checkRateLimit(did: string, trustScore: number): Promise<RateLimitResult> {
|
|
165
|
+
const tierConfig = getTierConfig(trustScore, this.tiers);
|
|
166
|
+
|
|
167
|
+
// Load or create bucket
|
|
168
|
+
let bucket = this.buckets.get(did);
|
|
169
|
+
if (!bucket) {
|
|
170
|
+
// Try to restore from LevelDB
|
|
171
|
+
const persisted = await this.storage.getRateLimit(did);
|
|
172
|
+
if (persisted) {
|
|
173
|
+
bucket = new TokenBucket(tierConfig, persisted.tokens, persisted.lastRefill);
|
|
174
|
+
} else {
|
|
175
|
+
bucket = new TokenBucket(tierConfig);
|
|
176
|
+
}
|
|
177
|
+
this.buckets.set(did, bucket);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const allowed = bucket.consume();
|
|
181
|
+
const state = bucket.toState();
|
|
182
|
+
|
|
183
|
+
// Persist updated state
|
|
184
|
+
await this.storage.putRateLimit({
|
|
185
|
+
did,
|
|
186
|
+
tokens: state.tokens,
|
|
187
|
+
lastRefill: state.lastRefill,
|
|
188
|
+
totalRequests: 0,
|
|
189
|
+
firstSeen: Date.now(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
allowed,
|
|
194
|
+
remaining: bucket.getRemaining(),
|
|
195
|
+
resetTime: bucket.getResetTime(),
|
|
196
|
+
limit: tierConfig.capacity,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Seen Cache (deduplication) ───────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
hasSeen(messageId: string): boolean {
|
|
203
|
+
return this.seenCache.has(messageId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
markAsSeen(messageId: string): void {
|
|
207
|
+
// Evict oldest entries if at capacity
|
|
208
|
+
if (this.seenCache.size >= this.MAX_SEEN_CACHE) {
|
|
209
|
+
const firstKey = this.seenCache.keys().next().value;
|
|
210
|
+
if (firstKey) this.seenCache.delete(firstKey);
|
|
211
|
+
}
|
|
212
|
+
this.seenCache.set(messageId, Date.now());
|
|
213
|
+
|
|
214
|
+
// Persist to LevelDB (fire-and-forget)
|
|
215
|
+
this.storage.putSeen({ messageId, seenAt: Date.now(), fromDid: '' }).catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Periodic cleanup of expired seen entries */
|
|
219
|
+
async cleanupSeen(): Promise<void> {
|
|
220
|
+
const cutoff = Date.now() - this.seenTtlMs;
|
|
221
|
+
for (const [id, seenAt] of this.seenCache) {
|
|
222
|
+
if (seenAt < cutoff) this.seenCache.delete(id);
|
|
223
|
+
}
|
|
224
|
+
await this.storage.cleanupSeen(this.seenTtlMs);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Periodic cleanup of stale rate limit buckets (24h inactive) */
|
|
228
|
+
async cleanupRateLimits(): Promise<void> {
|
|
229
|
+
const staleMs = 24 * 60 * 60 * 1000;
|
|
230
|
+
const cutoff = Date.now() - staleMs;
|
|
231
|
+
for (const [did, bucket] of this.buckets) {
|
|
232
|
+
if (bucket.toState().lastRefill < cutoff) this.buckets.delete(did);
|
|
233
|
+
}
|
|
234
|
+
await this.storage.cleanupRateLimits(staleMs);
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/messaging/index.ts
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Queue
|
|
3
|
+
*
|
|
4
|
+
* Persistent inbox/outbox backed by LevelDB.
|
|
5
|
+
* Supports real-time subscriptions and pagination.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createLogger } from '../utils/logger.js';
|
|
9
|
+
import type { MessageEnvelope } from './envelope.js';
|
|
10
|
+
import { MessageStorage } from './storage.js';
|
|
11
|
+
import type {
|
|
12
|
+
StoredMessage,
|
|
13
|
+
MessageFilter,
|
|
14
|
+
PaginationOptions,
|
|
15
|
+
MessagePage,
|
|
16
|
+
MessageCallback,
|
|
17
|
+
SubscriptionFilter,
|
|
18
|
+
QueueStats,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
|
|
21
|
+
const logger = createLogger('message-queue');
|
|
22
|
+
|
|
23
|
+
export interface MessageQueueConfig {
|
|
24
|
+
dbPath: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Subscription {
|
|
28
|
+
id: string;
|
|
29
|
+
filter: SubscriptionFilter;
|
|
30
|
+
callback: MessageCallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class MessageQueue {
|
|
34
|
+
private storage: MessageStorage;
|
|
35
|
+
private subscriptions = new Map<string, Subscription>();
|
|
36
|
+
private subCounter = 0;
|
|
37
|
+
|
|
38
|
+
constructor(config: MessageQueueConfig) {
|
|
39
|
+
this.storage = new MessageStorage(config.dbPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get store(): MessageStorage {
|
|
43
|
+
return this.storage;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async start(): Promise<void> {
|
|
47
|
+
await this.storage.open();
|
|
48
|
+
logger.info('Message queue started');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async stop(): Promise<void> {
|
|
52
|
+
await this.storage.close();
|
|
53
|
+
this.subscriptions.clear();
|
|
54
|
+
logger.info('Message queue stopped');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Inbox ────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
async getInbox(filter: MessageFilter = {}, pagination: PaginationOptions = {}): Promise<MessagePage> {
|
|
60
|
+
return this.storage.queryMessages('inbound', filter, pagination);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async getMessage(id: string): Promise<StoredMessage | null> {
|
|
64
|
+
return this.storage.getMessage(id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async markAsRead(id: string): Promise<void> {
|
|
68
|
+
await this.storage.updateMessage(id, { readAt: Date.now() });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async deleteMessage(id: string): Promise<void> {
|
|
72
|
+
await this.storage.deleteMessage(id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Outbox ───────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
async getOutbox(pagination: PaginationOptions = {}): Promise<MessagePage> {
|
|
78
|
+
return this.storage.queryMessages('outbound', {}, pagination);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async retryMessage(id: string): Promise<void> {
|
|
82
|
+
await this.storage.updateMessage(id, { status: 'pending', error: undefined });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Enqueue ──────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async enqueueInbound(envelope: MessageEnvelope, trustScore?: number): Promise<StoredMessage> {
|
|
88
|
+
const msg: StoredMessage = {
|
|
89
|
+
envelope,
|
|
90
|
+
direction: 'inbound',
|
|
91
|
+
status: 'pending',
|
|
92
|
+
receivedAt: Date.now(),
|
|
93
|
+
trustScore,
|
|
94
|
+
};
|
|
95
|
+
await this.storage.putMessage(msg);
|
|
96
|
+
logger.debug('Enqueued inbound message', { id: envelope.id, from: envelope.from });
|
|
97
|
+
this.notifySubscribers(msg);
|
|
98
|
+
return msg;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async enqueueOutbound(envelope: MessageEnvelope): Promise<StoredMessage> {
|
|
102
|
+
const msg: StoredMessage = {
|
|
103
|
+
envelope,
|
|
104
|
+
direction: 'outbound',
|
|
105
|
+
status: 'pending',
|
|
106
|
+
sentAt: Date.now(),
|
|
107
|
+
};
|
|
108
|
+
await this.storage.putMessage(msg);
|
|
109
|
+
logger.debug('Enqueued outbound message', { id: envelope.id, to: envelope.to });
|
|
110
|
+
return msg;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async markOutboundDelivered(id: string): Promise<void> {
|
|
114
|
+
await this.storage.updateMessage(id, { status: 'delivered' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async markOutboundFailed(id: string, error: string): Promise<void> {
|
|
118
|
+
await this.storage.updateMessage(id, { status: 'failed', error });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Subscriptions ────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
subscribe(filter: SubscriptionFilter, callback: MessageCallback): string {
|
|
124
|
+
const id = `sub_${++this.subCounter}`;
|
|
125
|
+
this.subscriptions.set(id, { id, filter, callback });
|
|
126
|
+
logger.debug('Subscription added', { id });
|
|
127
|
+
return id;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
unsubscribe(subscriptionId: string): void {
|
|
131
|
+
this.subscriptions.delete(subscriptionId);
|
|
132
|
+
logger.debug('Subscription removed', { id: subscriptionId });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private notifySubscribers(msg: StoredMessage): void {
|
|
136
|
+
for (const sub of this.subscriptions.values()) {
|
|
137
|
+
if (this.matchesSubscriptionFilter(msg, sub.filter)) {
|
|
138
|
+
Promise.resolve(sub.callback(msg)).catch((err) => {
|
|
139
|
+
logger.warn('Subscription callback error', { id: sub.id, error: (err as Error).message });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private matchesSubscriptionFilter(msg: StoredMessage, filter: SubscriptionFilter): boolean {
|
|
146
|
+
if (filter.fromDid) {
|
|
147
|
+
const froms = Array.isArray(filter.fromDid) ? filter.fromDid : [filter.fromDid];
|
|
148
|
+
if (!froms.includes(msg.envelope.from)) return false;
|
|
149
|
+
}
|
|
150
|
+
if (filter.protocol) {
|
|
151
|
+
const protos = Array.isArray(filter.protocol) ? filter.protocol : [filter.protocol];
|
|
152
|
+
if (!protos.includes(msg.envelope.protocol)) return false;
|
|
153
|
+
}
|
|
154
|
+
if (filter.type && msg.envelope.type !== filter.type) return false;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Stats ────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
async getStats(): Promise<QueueStats> {
|
|
161
|
+
const [inboxTotal, inboxUnread, outboxPending, outboxFailed, blocked, allowed] =
|
|
162
|
+
await Promise.all([
|
|
163
|
+
this.storage.countMessages('inbound'),
|
|
164
|
+
this.storage.countMessages('inbound', { unreadOnly: true }),
|
|
165
|
+
this.storage.countMessages('outbound', { status: 'pending' }),
|
|
166
|
+
this.storage.countMessages('outbound', { status: 'failed' }),
|
|
167
|
+
this.storage.listBlocked().then((l) => l.length),
|
|
168
|
+
this.storage.listAllowed().then((l) => l.length),
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
inboxTotal,
|
|
173
|
+
inboxUnread,
|
|
174
|
+
outboxPending,
|
|
175
|
+
outboxFailed,
|
|
176
|
+
blockedAgents: blocked,
|
|
177
|
+
allowedAgents: allowed,
|
|
178
|
+
rateLimitedAgents: 0,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -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
|
+
}
|
package/src/messaging/router.ts
CHANGED
|
@@ -132,15 +132,27 @@ export function createMessageRouter(
|
|
|
132
132
|
});
|
|
133
133
|
const s = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
|
|
134
134
|
logger.info('Direct dial succeeded', { addr });
|
|
135
|
-
return s;
|
|
135
|
+
return { conn, stream: s };
|
|
136
136
|
} catch {
|
|
137
137
|
return null;
|
|
138
138
|
}
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
directDialPromises.map(p => p.then(
|
|
141
|
+
const winner = await Promise.race(
|
|
142
|
+
directDialPromises.map(p => p.then(r => r || Promise.reject()))
|
|
143
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
|
+
}
|
|
144
156
|
}
|
|
145
157
|
|
|
146
158
|
let lastError: unknown;
|
|
@@ -163,7 +175,7 @@ export function createMessageRouter(
|
|
|
163
175
|
logger.info('Relay connection established', { addr });
|
|
164
176
|
const s = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
|
|
165
177
|
logger.info('Relay stream opened', { addr });
|
|
166
|
-
return s;
|
|
178
|
+
return { conn, stream: s };
|
|
167
179
|
} catch (relayErr) {
|
|
168
180
|
logger.warn('Relay dial failed', { addr, error: (relayErr as Error).message });
|
|
169
181
|
lastError = relayErr;
|
|
@@ -171,9 +183,21 @@ export function createMessageRouter(
|
|
|
171
183
|
}
|
|
172
184
|
});
|
|
173
185
|
|
|
174
|
-
|
|
175
|
-
relayDialPromises.map(p => p.then(
|
|
186
|
+
const winner = await Promise.race(
|
|
187
|
+
relayDialPromises.map(p => p.then(r => r || Promise.reject()))
|
|
176
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
|
+
}
|
|
177
201
|
}
|
|
178
202
|
}
|
|
179
203
|
|
|
@@ -218,7 +242,7 @@ export function createMessageRouter(
|
|
|
218
242
|
|
|
219
243
|
try {
|
|
220
244
|
// Add timeout to prevent infinite blocking
|
|
221
|
-
const RESPONSE_TIMEOUT =
|
|
245
|
+
const RESPONSE_TIMEOUT = 30000; // 30 seconds (CVP-0010 §4.2)
|
|
222
246
|
|
|
223
247
|
const responsePromise = (async () => {
|
|
224
248
|
const responseChunks: Uint8Array[] = [];
|
|
@@ -301,6 +325,13 @@ async function handleIncomingStream(
|
|
|
301
325
|
}
|
|
302
326
|
|
|
303
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
|
+
|
|
304
335
|
const envelope = decodeMessage(data);
|
|
305
336
|
|
|
306
337
|
if (!validateEnvelope(envelope)) {
|