@johpaz/hive-core 1.0.7 → 1.0.10
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/package.json +10 -9
- package/src/agent/ethics.ts +70 -68
- package/src/agent/index.ts +48 -17
- package/src/agent/providers/index.ts +11 -5
- package/src/agent/soul.ts +19 -15
- package/src/agent/user.ts +19 -15
- package/src/agent/workspace.ts +6 -6
- package/src/agents/index.ts +4 -0
- package/src/agents/inter-agent-bus.test.ts +264 -0
- package/src/agents/inter-agent-bus.ts +279 -0
- package/src/agents/registry.test.ts +275 -0
- package/src/agents/registry.ts +273 -0
- package/src/agents/router.test.ts +229 -0
- package/src/agents/router.ts +251 -0
- package/src/agents/team-coordinator.test.ts +401 -0
- package/src/agents/team-coordinator.ts +480 -0
- package/src/canvas/canvas-manager.test.ts +159 -0
- package/src/canvas/canvas-manager.ts +219 -0
- package/src/canvas/canvas-tools.ts +189 -0
- package/src/canvas/index.ts +2 -0
- package/src/channels/whatsapp.ts +12 -12
- package/src/config/loader.ts +12 -9
- package/src/events/event-bus.test.ts +98 -0
- package/src/events/event-bus.ts +171 -0
- package/src/gateway/server.ts +131 -35
- package/src/index.ts +9 -1
- package/src/multi-agent/manager.ts +12 -12
- package/src/plugins/api.ts +129 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/loader.test.ts +285 -0
- package/src/plugins/loader.ts +363 -0
- package/src/resilience/circuit-breaker.test.ts +129 -0
- package/src/resilience/circuit-breaker.ts +223 -0
- package/src/security/google-chat.test.ts +219 -0
- package/src/security/google-chat.ts +269 -0
- package/src/security/index.ts +5 -0
- package/src/security/pairing.test.ts +302 -0
- package/src/security/pairing.ts +250 -0
- package/src/security/rate-limit.test.ts +239 -0
- package/src/security/rate-limit.ts +270 -0
- package/src/security/signal.test.ts +92 -0
- package/src/security/signal.ts +321 -0
- package/src/state/store.test.ts +190 -0
- package/src/state/store.ts +310 -0
- package/src/storage/sqlite.ts +3 -3
- package/src/tools/cron.ts +42 -2
- package/src/tools/dynamic-registry.test.ts +226 -0
- package/src/tools/dynamic-registry.ts +258 -0
- package/src/tools/fs.test.ts +127 -0
- package/src/tools/fs.ts +364 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/read.ts +23 -19
- package/src/utils/logger.ts +112 -33
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { TokenBucketRateLimiter, SlidingWindowRateLimiter } from "./rate-limit.ts";
|
|
3
|
+
|
|
4
|
+
describe("TokenBucketRateLimiter", () => {
|
|
5
|
+
let limiter: TokenBucketRateLimiter;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
limiter = new TokenBucketRateLimiter({
|
|
9
|
+
maxTokens: 10,
|
|
10
|
+
refillRate: 2,
|
|
11
|
+
refillIntervalMs: 1000,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("canProceed", () => {
|
|
16
|
+
it("should allow requests when tokens available", () => {
|
|
17
|
+
const result = limiter.canProceed("user1");
|
|
18
|
+
|
|
19
|
+
expect(result.allowed).toBe(true);
|
|
20
|
+
expect(result.remaining).toBe(9);
|
|
21
|
+
expect(result.retryAfterMs).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should deny requests when bucket empty", () => {
|
|
25
|
+
for (let i = 0; i < 10; i++) {
|
|
26
|
+
limiter.canProceed("user1");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const result = limiter.canProceed("user1");
|
|
30
|
+
|
|
31
|
+
expect(result.allowed).toBe(false);
|
|
32
|
+
expect(result.remaining).toBe(0);
|
|
33
|
+
expect(result.retryAfterMs).toBeGreaterThan(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should track separate buckets per key", () => {
|
|
37
|
+
for (let i = 0; i < 10; i++) {
|
|
38
|
+
limiter.canProceed("user1");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
expect(limiter.canProceed("user1").allowed).toBe(false);
|
|
42
|
+
expect(limiter.canProceed("user2").allowed).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should refill tokens over time", async () => {
|
|
46
|
+
for (let i = 0; i < 10; i++) {
|
|
47
|
+
limiter.canProceed("user1");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expect(limiter.canProceed("user1").allowed).toBe(false);
|
|
51
|
+
|
|
52
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
53
|
+
|
|
54
|
+
const result = limiter.canProceed("user1");
|
|
55
|
+
expect(result.allowed).toBe(true);
|
|
56
|
+
expect(result.remaining).toBeGreaterThanOrEqual(1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("consume", () => {
|
|
61
|
+
it("should consume multiple tokens", () => {
|
|
62
|
+
const result = limiter.consume("user1", 5);
|
|
63
|
+
|
|
64
|
+
expect(result.allowed).toBe(true);
|
|
65
|
+
expect(result.remaining).toBe(5);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should deny if not enough tokens", () => {
|
|
69
|
+
limiter.consume("user1", 8);
|
|
70
|
+
|
|
71
|
+
const result = limiter.consume("user1", 5);
|
|
72
|
+
|
|
73
|
+
expect(result.allowed).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("peek", () => {
|
|
78
|
+
it("should return available tokens without consuming", () => {
|
|
79
|
+
limiter.canProceed("user1");
|
|
80
|
+
limiter.canProceed("user1");
|
|
81
|
+
|
|
82
|
+
const tokens = limiter.peek("user1");
|
|
83
|
+
|
|
84
|
+
expect(tokens).toBe(8);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should return max tokens for unknown key", () => {
|
|
88
|
+
expect(limiter.peek("unknown")).toBe(10);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("refill", () => {
|
|
93
|
+
it("should refill tokens", () => {
|
|
94
|
+
for (let i = 0; i < 10; i++) {
|
|
95
|
+
limiter.canProceed("user1");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
limiter.refill("user1", 5);
|
|
99
|
+
|
|
100
|
+
expect(limiter.peek("user1")).toBe(5);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should not exceed max tokens", () => {
|
|
104
|
+
limiter.refill("user1", 100);
|
|
105
|
+
|
|
106
|
+
expect(limiter.peek("user1")).toBe(10);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("reset", () => {
|
|
111
|
+
it("should reset specific key", () => {
|
|
112
|
+
for (let i = 0; i < 5; i++) {
|
|
113
|
+
limiter.canProceed("user1");
|
|
114
|
+
}
|
|
115
|
+
for (let i = 0; i < 3; i++) {
|
|
116
|
+
limiter.canProceed("user2");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
limiter.reset("user1");
|
|
120
|
+
|
|
121
|
+
expect(limiter.peek("user1")).toBe(10);
|
|
122
|
+
expect(limiter.peek("user2")).toBeCloseTo(7, 0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("resetAll", () => {
|
|
127
|
+
it("should reset all buckets", () => {
|
|
128
|
+
for (let i = 0; i < 5; i++) {
|
|
129
|
+
limiter.canProceed("user1");
|
|
130
|
+
limiter.canProceed("user2");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
limiter.resetAll();
|
|
134
|
+
|
|
135
|
+
expect(limiter.peek("user1")).toBe(10);
|
|
136
|
+
expect(limiter.peek("user2")).toBe(10);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("getStats", () => {
|
|
141
|
+
it("should return correct stats", () => {
|
|
142
|
+
limiter.canProceed("user1");
|
|
143
|
+
limiter.canProceed("user1");
|
|
144
|
+
limiter.canProceed("user2");
|
|
145
|
+
|
|
146
|
+
const stats = limiter.getStats();
|
|
147
|
+
|
|
148
|
+
expect(stats.totalBuckets).toBe(2);
|
|
149
|
+
expect(stats.activeBuckets).toBe(2);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("SlidingWindowRateLimiter", () => {
|
|
155
|
+
let limiter: SlidingWindowRateLimiter;
|
|
156
|
+
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
limiter = new SlidingWindowRateLimiter({
|
|
159
|
+
windowMs: 1000,
|
|
160
|
+
maxRequests: 5,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("check", () => {
|
|
165
|
+
it("should allow requests within limit", () => {
|
|
166
|
+
for (let i = 0; i < 5; i++) {
|
|
167
|
+
const result = limiter.check("user1");
|
|
168
|
+
expect(result.allowed).toBe(true);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should deny requests over limit", () => {
|
|
173
|
+
for (let i = 0; i < 5; i++) {
|
|
174
|
+
limiter.check("user1");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const result = limiter.check("user1");
|
|
178
|
+
|
|
179
|
+
expect(result.allowed).toBe(false);
|
|
180
|
+
expect(result.retryAfterMs).toBeGreaterThan(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("should allow after window expires", async () => {
|
|
184
|
+
for (let i = 0; i < 5; i++) {
|
|
185
|
+
limiter.check("user1");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
expect(limiter.check("user1").allowed).toBe(false);
|
|
189
|
+
|
|
190
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
191
|
+
|
|
192
|
+
expect(limiter.check("user1").allowed).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should track remaining count", () => {
|
|
196
|
+
limiter.check("user1");
|
|
197
|
+
limiter.check("user1");
|
|
198
|
+
|
|
199
|
+
const result = limiter.check("user1");
|
|
200
|
+
|
|
201
|
+
expect(result.remaining).toBe(2);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should track separate windows per key", () => {
|
|
205
|
+
for (let i = 0; i < 5; i++) {
|
|
206
|
+
limiter.check("user1");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
expect(limiter.check("user1").allowed).toBe(false);
|
|
210
|
+
expect(limiter.check("user2").allowed).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("reset", () => {
|
|
215
|
+
it("should reset specific key", () => {
|
|
216
|
+
for (let i = 0; i < 5; i++) {
|
|
217
|
+
limiter.check("user1");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
limiter.reset("user1");
|
|
221
|
+
|
|
222
|
+
expect(limiter.check("user1").allowed).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("resetAll", () => {
|
|
227
|
+
it("should reset all windows", () => {
|
|
228
|
+
for (let i = 0; i < 5; i++) {
|
|
229
|
+
limiter.check("user1");
|
|
230
|
+
limiter.check("user2");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
limiter.resetAll();
|
|
234
|
+
|
|
235
|
+
expect(limiter.check("user1").allowed).toBe(true);
|
|
236
|
+
expect(limiter.check("user2").allowed).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.ts";
|
|
2
|
+
|
|
3
|
+
export interface TokenBucketConfig {
|
|
4
|
+
maxTokens: number;
|
|
5
|
+
refillRate: number;
|
|
6
|
+
refillIntervalMs?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TokenBucket {
|
|
10
|
+
tokens: number;
|
|
11
|
+
lastUpdate: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RateLimitResult {
|
|
15
|
+
allowed: boolean;
|
|
16
|
+
remaining: number;
|
|
17
|
+
retryAfterMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TokenBucketStats {
|
|
21
|
+
totalBuckets: number;
|
|
22
|
+
activeBuckets: number;
|
|
23
|
+
totalTokensAvailable: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TokenBucketRateLimiter {
|
|
27
|
+
private buckets: Map<string, TokenBucket> = new Map();
|
|
28
|
+
private config: Required<TokenBucketConfig>;
|
|
29
|
+
private log = logger.child("token-bucket-limiter");
|
|
30
|
+
|
|
31
|
+
constructor(config: TokenBucketConfig) {
|
|
32
|
+
this.config = {
|
|
33
|
+
maxTokens: config.maxTokens,
|
|
34
|
+
refillRate: config.refillRate,
|
|
35
|
+
refillIntervalMs: config.refillIntervalMs ?? 1000,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
this.startCleanup();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
canProceed(key: string): RateLimitResult {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
let bucket = this.buckets.get(key);
|
|
44
|
+
|
|
45
|
+
if (!bucket) {
|
|
46
|
+
bucket = {
|
|
47
|
+
tokens: this.config.maxTokens,
|
|
48
|
+
lastUpdate: now,
|
|
49
|
+
};
|
|
50
|
+
this.buckets.set(key, bucket);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const elapsed = now - bucket.lastUpdate;
|
|
54
|
+
const tokensToAdd = (elapsed / this.config.refillIntervalMs) * this.config.refillRate;
|
|
55
|
+
bucket.tokens = Math.min(this.config.maxTokens, bucket.tokens + tokensToAdd);
|
|
56
|
+
bucket.lastUpdate = now;
|
|
57
|
+
|
|
58
|
+
if (bucket.tokens >= 1) {
|
|
59
|
+
bucket.tokens--;
|
|
60
|
+
return {
|
|
61
|
+
allowed: true,
|
|
62
|
+
remaining: Math.floor(bucket.tokens),
|
|
63
|
+
retryAfterMs: 0,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const retryAfterMs = Math.ceil(
|
|
68
|
+
((1 - bucket.tokens) / this.config.refillRate) * this.config.refillIntervalMs
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
this.log.debug(`Rate limit hit for ${key}, retry after ${retryAfterMs}ms`);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
allowed: false,
|
|
75
|
+
remaining: 0,
|
|
76
|
+
retryAfterMs,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
consume(key: string, tokens: number = 1): RateLimitResult {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
let bucket = this.buckets.get(key);
|
|
83
|
+
|
|
84
|
+
if (!bucket) {
|
|
85
|
+
bucket = {
|
|
86
|
+
tokens: this.config.maxTokens,
|
|
87
|
+
lastUpdate: now,
|
|
88
|
+
};
|
|
89
|
+
this.buckets.set(key, bucket);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const elapsed = now - bucket.lastUpdate;
|
|
93
|
+
const tokensToAdd = (elapsed / this.config.refillIntervalMs) * this.config.refillRate;
|
|
94
|
+
bucket.tokens = Math.min(this.config.maxTokens, bucket.tokens + tokensToAdd);
|
|
95
|
+
bucket.lastUpdate = now;
|
|
96
|
+
|
|
97
|
+
if (bucket.tokens >= tokens) {
|
|
98
|
+
bucket.tokens -= tokens;
|
|
99
|
+
return {
|
|
100
|
+
allowed: true,
|
|
101
|
+
remaining: Math.floor(bucket.tokens),
|
|
102
|
+
retryAfterMs: 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const retryAfterMs = Math.ceil(
|
|
107
|
+
((tokens - bucket.tokens) / this.config.refillRate) * this.config.refillIntervalMs
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
allowed: false,
|
|
112
|
+
remaining: 0,
|
|
113
|
+
retryAfterMs,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
peek(key: string): number {
|
|
118
|
+
const bucket = this.buckets.get(key);
|
|
119
|
+
if (!bucket) return this.config.maxTokens;
|
|
120
|
+
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const elapsed = now - bucket.lastUpdate;
|
|
123
|
+
const tokensToAdd = (elapsed / this.config.refillIntervalMs) * this.config.refillRate;
|
|
124
|
+
|
|
125
|
+
return Math.min(this.config.maxTokens, bucket.tokens + tokensToAdd);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
refill(key: string, tokens?: number): void {
|
|
129
|
+
const bucket = this.buckets.get(key);
|
|
130
|
+
if (!bucket) return;
|
|
131
|
+
|
|
132
|
+
bucket.tokens = Math.min(
|
|
133
|
+
this.config.maxTokens,
|
|
134
|
+
bucket.tokens + (tokens ?? this.config.maxTokens)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
reset(key: string): void {
|
|
139
|
+
this.buckets.delete(key);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
resetAll(): void {
|
|
143
|
+
this.buckets.clear();
|
|
144
|
+
this.log.info("All rate limit buckets cleared");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getStats(): TokenBucketStats {
|
|
148
|
+
let totalTokens = 0;
|
|
149
|
+
let activeBuckets = 0;
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
|
|
152
|
+
for (const bucket of this.buckets.values()) {
|
|
153
|
+
const elapsed = now - bucket.lastUpdate;
|
|
154
|
+
const tokens = Math.min(
|
|
155
|
+
this.config.maxTokens,
|
|
156
|
+
bucket.tokens + (elapsed / this.config.refillIntervalMs) * this.config.refillRate
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (tokens < this.config.maxTokens) {
|
|
160
|
+
activeBuckets++;
|
|
161
|
+
}
|
|
162
|
+
totalTokens += tokens;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
totalBuckets: this.buckets.size,
|
|
167
|
+
activeBuckets,
|
|
168
|
+
totalTokensAvailable: Math.floor(totalTokens),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private startCleanup(): void {
|
|
173
|
+
setInterval(() => {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
for (const [key, bucket] of this.buckets) {
|
|
176
|
+
const elapsed = now - bucket.lastUpdate;
|
|
177
|
+
const fullTokens =
|
|
178
|
+
bucket.tokens + (elapsed / this.config.refillIntervalMs) * this.config.refillRate;
|
|
179
|
+
|
|
180
|
+
if (fullTokens >= this.config.maxTokens) {
|
|
181
|
+
this.buckets.delete(key);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}, 5 * 60 * 1000);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface SlidingWindowConfig {
|
|
189
|
+
windowMs: number;
|
|
190
|
+
maxRequests: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface SlidingWindowEntry {
|
|
194
|
+
timestamps: number[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export class SlidingWindowRateLimiter {
|
|
198
|
+
private windows: Map<string, SlidingWindowEntry> = new Map();
|
|
199
|
+
private config: SlidingWindowConfig;
|
|
200
|
+
private log = logger.child("sliding-window-limiter");
|
|
201
|
+
|
|
202
|
+
constructor(config: SlidingWindowConfig) {
|
|
203
|
+
this.config = config;
|
|
204
|
+
this.startCleanup();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
check(key: string): RateLimitResult {
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
const windowStart = now - this.config.windowMs;
|
|
210
|
+
|
|
211
|
+
let entry = this.windows.get(key);
|
|
212
|
+
if (!entry) {
|
|
213
|
+
entry = { timestamps: [] };
|
|
214
|
+
this.windows.set(key, entry);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
|
|
218
|
+
|
|
219
|
+
if (entry.timestamps.length >= this.config.maxRequests) {
|
|
220
|
+
const oldestInWindow = entry.timestamps[0];
|
|
221
|
+
const retryAfterMs = oldestInWindow + this.config.windowMs - now;
|
|
222
|
+
|
|
223
|
+
this.log.debug(`Sliding window limit hit for ${key}`);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
allowed: false,
|
|
227
|
+
remaining: 0,
|
|
228
|
+
retryAfterMs: Math.max(0, retryAfterMs),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
entry.timestamps.push(now);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
allowed: true,
|
|
236
|
+
remaining: this.config.maxRequests - entry.timestamps.length,
|
|
237
|
+
retryAfterMs: 0,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
reset(key: string): void {
|
|
242
|
+
this.windows.delete(key);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
resetAll(): void {
|
|
246
|
+
this.windows.clear();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private startCleanup(): void {
|
|
250
|
+
setInterval(() => {
|
|
251
|
+
const now = Date.now();
|
|
252
|
+
const windowStart = now - this.config.windowMs;
|
|
253
|
+
|
|
254
|
+
for (const [key, entry] of this.windows) {
|
|
255
|
+
entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
|
|
256
|
+
if (entry.timestamps.length === 0) {
|
|
257
|
+
this.windows.delete(key);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}, 60 * 1000);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createTokenBucketLimiter(config: TokenBucketConfig): TokenBucketRateLimiter {
|
|
265
|
+
return new TokenBucketRateLimiter(config);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function createSlidingWindowLimiter(config: SlidingWindowConfig): SlidingWindowRateLimiter {
|
|
269
|
+
return new SlidingWindowRateLimiter(config);
|
|
270
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from "bun:test";
|
|
2
|
+
import { SignalChannel, type SignalConfig } from "./signal.ts";
|
|
3
|
+
import { pairingService } from "./pairing.ts";
|
|
4
|
+
import { eventBus } from "../events/event-bus.ts";
|
|
5
|
+
|
|
6
|
+
describe("SignalChannel", () => {
|
|
7
|
+
let channel: SignalChannel;
|
|
8
|
+
const config: SignalConfig = {
|
|
9
|
+
phoneNumber: "+1234567890",
|
|
10
|
+
enabled: true,
|
|
11
|
+
dmPolicy: "pairing",
|
|
12
|
+
allowFrom: [],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
channel = new SignalChannel("default", config);
|
|
17
|
+
pairingService.clear();
|
|
18
|
+
eventBus.removeAllListeners();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
channel.stop?.();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("constructor", () => {
|
|
26
|
+
it("should set default dmPolicy to pairing", () => {
|
|
27
|
+
const ch = new SignalChannel("default", {
|
|
28
|
+
phoneNumber: "+123",
|
|
29
|
+
enabled: true,
|
|
30
|
+
} as SignalConfig);
|
|
31
|
+
expect(ch.config.dmPolicy).toBe("pairing");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("parseSignalMessage", () => {
|
|
36
|
+
it("should parse valid JSON message", () => {
|
|
37
|
+
const raw = JSON.stringify({
|
|
38
|
+
envelope: {
|
|
39
|
+
source: "+1234567890",
|
|
40
|
+
sourceName: "Test User",
|
|
41
|
+
timestamp: 1234567890000,
|
|
42
|
+
dataMessage: {
|
|
43
|
+
message: "Hello World",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = JSON.parse(raw);
|
|
49
|
+
|
|
50
|
+
expect(result.envelope.source).toBe("+1234567890");
|
|
51
|
+
expect(result.envelope.dataMessage.message).toBe("Hello World");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should ignore non-JSON output", () => {
|
|
55
|
+
expect(() => JSON.parse("Some random output")).toThrow();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("chunkMessage", () => {
|
|
60
|
+
it("should split long messages", () => {
|
|
61
|
+
const chunkMessage = (channel as unknown as { chunkMessage: (s: string, n: number) => string[] })
|
|
62
|
+
.chunkMessage;
|
|
63
|
+
|
|
64
|
+
const longMessage = "A".repeat(3000);
|
|
65
|
+
const chunks = chunkMessage.call(channel, longMessage, 2000);
|
|
66
|
+
|
|
67
|
+
expect(chunks.length).toBe(2);
|
|
68
|
+
expect(chunks[0]!.length).toBeLessThanOrEqual(2000);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should prefer splitting at newlines", () => {
|
|
72
|
+
const chunkMessage = (channel as unknown as { chunkMessage: (s: string, n: number) => string[] })
|
|
73
|
+
.chunkMessage;
|
|
74
|
+
|
|
75
|
+
const message = "Line 1\n\nLine 2\n\nLine 3";
|
|
76
|
+
const chunks = chunkMessage.call(channel, message, 10);
|
|
77
|
+
|
|
78
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("extractPhoneFromSession", () => {
|
|
83
|
+
it("should extract phone from session ID", () => {
|
|
84
|
+
const extract = (channel as unknown as { extractPhoneFromSession: (s: string) => string | undefined })
|
|
85
|
+
.extractPhoneFromSession;
|
|
86
|
+
|
|
87
|
+
const phone = extract.call(channel, "agent:main:signal:direct:+1234567890");
|
|
88
|
+
|
|
89
|
+
expect(phone).toBe("+1234567890");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|