clawmatrix 0.1.22 → 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.
@@ -0,0 +1,628 @@
1
+ import { EventEmitter } from "node:events";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import type { PeerApprovalConfig } from "./config.ts";
5
+ import { debug } from "./debug.ts";
6
+ import type {
7
+ AnyClusterFrame,
8
+ DeviceInfo,
9
+ NodeCapabilities,
10
+ PeerApprovalNotify,
11
+ PeerApprovalRequest,
12
+ PeerApprovalResponse,
13
+ } from "./types.ts";
14
+
15
+ const RATE_LIMIT_WINDOW = 600_000; // 10 min cooldown after deny
16
+ const IP_DENY_THRESHOLD = 3; // auto-block IP after N denied approvals
17
+ const IP_DENY_WINDOW = 1_800_000; // 30 min IP block window
18
+
19
+ /** Who resolved the approval and how. */
20
+ export interface ApprovalResolvedBy {
21
+ /** Source: which node/channel resolved this. e.g. "home-macmini", "telegram:95123182" */
22
+ source: string;
23
+ /** Timestamp of the resolution. */
24
+ at: number;
25
+ }
26
+
27
+ export interface ApprovedPeerRecord {
28
+ nodeId: string;
29
+ approvedAt: number;
30
+ deviceInfo?: DeviceInfo;
31
+ /**
32
+ * Persistent public key (base64) pinned at approval time (TOFU).
33
+ */
34
+ publicKey?: string;
35
+ /** Who approved this peer and when. */
36
+ resolvedBy?: ApprovalResolvedBy;
37
+ }
38
+
39
+ export interface DeniedPeerRecord {
40
+ nodeId: string;
41
+ deniedAt: number;
42
+ deviceInfo?: DeviceInfo;
43
+ /** Who denied this peer and when. */
44
+ resolvedBy?: ApprovalResolvedBy;
45
+ }
46
+
47
+ interface PersistentData {
48
+ approved: Record<string, ApprovedPeerRecord>;
49
+ denied: Record<string, DeniedPeerRecord | number>; // number for legacy compat
50
+ }
51
+
52
+ export interface PendingApproval {
53
+ approvalId: string;
54
+ nodeId: string;
55
+ deviceInfo?: DeviceInfo;
56
+ capabilities: NodeCapabilities;
57
+ /** Peer's persistent public key (base64), to be pinned on approval. */
58
+ publicKey?: string;
59
+ createdAt: number;
60
+ resolve: (decision: "approve" | "deny") => void;
61
+ }
62
+
63
+ export interface PeerApprovalEvents {
64
+ /** Fired when a new peer needs notification/approval. */
65
+ notify: [approval: PendingApproval];
66
+ }
67
+
68
+ /** Channel target for sending notifications. */
69
+ export interface NotifyTarget {
70
+ channel: string;
71
+ to: string;
72
+ accountId?: string;
73
+ threadId?: string | number;
74
+ }
75
+
76
+ /** Minimal interface for sending messages via OpenClaw channel API. */
77
+ export interface ChannelApi {
78
+ [channel: string]: {
79
+ [method: string]: (to: string, text: string, opts?: Record<string, unknown>) => Promise<unknown>;
80
+ } | undefined;
81
+ }
82
+
83
+ /** Gateway send function for channels not available in the runtime channel API (e.g. plugin channels). */
84
+ export type GatewaySendFn = (params: {
85
+ to: string;
86
+ message: string;
87
+ channel: string;
88
+ accountId?: string;
89
+ threadId?: string;
90
+ }) => Promise<unknown>;
91
+
92
+ export class PeerApprovalManager extends EventEmitter<PeerApprovalEvents> {
93
+ private config: PeerApprovalConfig;
94
+ private stateDir: string;
95
+ private data: PersistentData = { approved: {}, denied: {} };
96
+ private pending = new Map<string, PendingApproval>();
97
+ private channelApi: ChannelApi | null = null;
98
+ private gatewaySend: GatewaySendFn | null = null;
99
+ private broadcastFn: ((frame: AnyClusterFrame) => void) | null = null;
100
+ private notifyTargets: NotifyTarget[] = [];
101
+ private loaded = false;
102
+ /** IP → list of deny timestamps (for approval noise suppression). */
103
+ private ipDenyHistory = new Map<string, number[]>();
104
+
105
+ constructor(config: PeerApprovalConfig, stateDir: string) {
106
+ super();
107
+ this.config = config;
108
+ this.stateDir = stateDir;
109
+ }
110
+
111
+ private log(msg: string) {
112
+ debug("approval", msg);
113
+ }
114
+
115
+ /** Set the OpenClaw channel API for sending notifications. */
116
+ setChannelApi(channelApi: ChannelApi | null) {
117
+ this.channelApi = channelApi;
118
+ this.log(`setChannelApi: ${channelApi ? Object.keys(channelApi).join(",") : "null"}`);
119
+ }
120
+
121
+ /** Set a gateway send function for plugin channels not in the runtime API. */
122
+ setGatewaySend(fn: GatewaySendFn | null) {
123
+ this.gatewaySend = fn;
124
+ }
125
+
126
+ /** Set the broadcast function for sending frames to mesh peers. */
127
+ setBroadcastFn(fn: ((frame: AnyClusterFrame) => void) | null) {
128
+ this.broadcastFn = fn;
129
+ }
130
+
131
+ /** Set notification targets (from OpenClaw approvals config). */
132
+ setNotifyTargets(targets: NotifyTarget[]) {
133
+ this.notifyTargets = targets;
134
+ this.log(`setNotifyTargets: ${targets.map(t => `${t.channel}/${t.to}`).join(", ")}`);
135
+ }
136
+
137
+ // ── Persistence ─────────────────────────────────────────────────
138
+
139
+ async load() {
140
+ if (this.loaded) return;
141
+ try {
142
+ const filePath = path.join(this.stateDir, this.config.persistPath);
143
+ if (fs.existsSync(filePath)) {
144
+ const raw = fs.readFileSync(filePath, "utf-8");
145
+ const parsed = JSON.parse(raw);
146
+ this.data = {
147
+ approved: parsed.approved ?? {},
148
+ denied: parsed.denied ?? {},
149
+ };
150
+ }
151
+ } catch {
152
+ // Start fresh
153
+ }
154
+ this.loaded = true;
155
+ }
156
+
157
+ private save() {
158
+ try {
159
+ const filePath = path.join(this.stateDir, this.config.persistPath);
160
+ // Ensure directory exists
161
+ const dir = path.dirname(filePath);
162
+ if (!fs.existsSync(dir)) {
163
+ fs.mkdirSync(dir, { recursive: true });
164
+ }
165
+ fs.writeFileSync(filePath, JSON.stringify(this.data, null, 2), { mode: 0o600 });
166
+ } catch {
167
+ // Best-effort
168
+ }
169
+ }
170
+
171
+ // ── Core logic ──────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Check if a peer should be allowed to join.
175
+ * Returns: "allow" (immediate), "pending" (wait for approval), or "deny" (rate-limited).
176
+ *
177
+ * @param publicKey - The peer's persistent public key (base64) from the E2EE
178
+ * handshake. Used for TOFU identity verification: if the peer was previously
179
+ * approved with a different public key, it is treated as a new device and
180
+ * must be re-approved. This prevents nodeId impersonation — an attacker who
181
+ * obtains the shared secret cannot forge the private key backing this public key.
182
+ */
183
+ checkPeer(
184
+ nodeId: string,
185
+ capabilities: NodeCapabilities,
186
+ publicKey?: string,
187
+ ): "allow" | "pending" | "deny" {
188
+ if (!this.config.enabled) return "allow";
189
+
190
+ // Always allow pre-approved nodeIds from config
191
+ if (this.config.allowList.includes(nodeId)) return "allow";
192
+
193
+ // Already approved (persisted)
194
+ const approved = this.data.approved[nodeId];
195
+ if (approved) {
196
+ // TOFU public key verification: if the approved record has a pinned key
197
+ // and the connecting peer presents a different key, treat as a new device.
198
+ // This is the core defense against nodeId impersonation.
199
+ if (approved.publicKey && publicKey && approved.publicKey !== publicKey) {
200
+ return "pending";
201
+ }
202
+
203
+ // Legacy: deviceInfo hostname change detection (weaker signal, kept
204
+ // for backwards compatibility with records that lack a public key)
205
+ if (!approved.publicKey) {
206
+ const newHostname = capabilities.deviceInfo?.hostname;
207
+ const oldHostname = approved.deviceInfo?.hostname;
208
+ if (oldHostname && newHostname && oldHostname !== newHostname) {
209
+ return "pending";
210
+ }
211
+ }
212
+
213
+ return "allow";
214
+ }
215
+
216
+ // Recently denied — rate limit
217
+ const denied = this.data.denied[nodeId];
218
+ const deniedAt = typeof denied === "number" ? denied : denied?.deniedAt;
219
+ if (deniedAt && Date.now() - deniedAt < RATE_LIMIT_WINDOW) {
220
+ return "deny";
221
+ }
222
+
223
+ return "pending";
224
+ }
225
+
226
+ /**
227
+ * Create a pending approval and return a promise that resolves with the decision.
228
+ * In "notify" mode, the promise resolves immediately with "approve".
229
+ *
230
+ * @param publicKey - The peer's persistent public key (base64), pinned on approval.
231
+ */
232
+ async requestApproval(
233
+ nodeId: string,
234
+ capabilities: NodeCapabilities,
235
+ broadcastFn: (frame: AnyClusterFrame) => void,
236
+ publicKey?: string,
237
+ ip?: string,
238
+ ): Promise<"approve" | "deny" | "timeout"> {
239
+ const approvalId = crypto.randomUUID();
240
+ this.log(`requestApproval: nodeId=${nodeId} mode=${this.config.mode} approvalId=${approvalId}`);
241
+
242
+ if (this.config.mode === "notify") {
243
+ // Auto-approve, but send notifications
244
+ this.addApproved(nodeId, capabilities.deviceInfo, publicKey, { source: "auto:notify-mode", at: Date.now() });
245
+ this.sendNotifications(approvalId, nodeId, capabilities.deviceInfo, "notify", ip);
246
+ this.broadcastNotify(broadcastFn, approvalId, nodeId, capabilities.deviceInfo, "notify", ip);
247
+ return "approve";
248
+ }
249
+
250
+ // Required mode: wait for explicit approval
251
+ return new Promise<"approve" | "deny" | "timeout">((resolve) => {
252
+ const pending: PendingApproval = {
253
+ approvalId,
254
+ nodeId,
255
+ deviceInfo: capabilities.deviceInfo,
256
+ capabilities,
257
+ publicKey,
258
+ createdAt: Date.now(),
259
+ resolve: (decision) => {
260
+ this.pending.delete(approvalId);
261
+ clearTimeout(timer);
262
+ resolve(decision);
263
+ },
264
+ };
265
+
266
+ this.pending.set(approvalId, pending);
267
+
268
+ // Timeout
269
+ const timer = setTimeout(() => {
270
+ if (this.pending.has(approvalId)) {
271
+ this.pending.delete(approvalId);
272
+ resolve("timeout");
273
+ }
274
+ }, this.config.timeout);
275
+
276
+ // Send notifications to IM channels
277
+ this.sendNotifications(approvalId, nodeId, capabilities.deviceInfo, "required", ip);
278
+ this.broadcastNotify(broadcastFn, approvalId, nodeId, capabilities.deviceInfo, "required", ip);
279
+
280
+ // Broadcast approval request to mesh (for iOS etc.)
281
+ broadcastFn({
282
+ type: "peer_approval_req",
283
+ id: approvalId,
284
+ from: "", // will be set by PeerManager
285
+ timestamp: Date.now(),
286
+ payload: {
287
+ approvalId,
288
+ nodeId,
289
+ deviceInfo: capabilities.deviceInfo,
290
+ ip,
291
+ },
292
+ } as PeerApprovalRequest);
293
+
294
+ this.emit("notify", pending);
295
+ });
296
+ }
297
+
298
+ /** Handle an approval response from a connected peer or gateway method. */
299
+ handleResponse(approvalId: string, decision: "approve" | "deny", resolvedBy?: ApprovalResolvedBy) {
300
+ const pending = this.pending.get(approvalId);
301
+ if (!pending) return false;
302
+
303
+ const resolved = resolvedBy ?? { source: "unknown", at: Date.now() };
304
+ this.log(`handleResponse: ${decision} for ${pending.nodeId} by ${resolved.source}`);
305
+
306
+ if (decision === "approve") {
307
+ this.addApproved(pending.nodeId, pending.deviceInfo, pending.publicKey, resolved);
308
+ } else {
309
+ this.data.denied[pending.nodeId] = {
310
+ nodeId: pending.nodeId,
311
+ deniedAt: Date.now(),
312
+ deviceInfo: pending.deviceInfo,
313
+ resolvedBy: resolved,
314
+ };
315
+ this.save();
316
+ }
317
+
318
+ // Send resolution notification to all channels + mesh
319
+ this.sendResolutionNotification(approvalId, pending.nodeId, pending.deviceInfo, decision, resolved);
320
+
321
+ pending.resolve(decision);
322
+ return true;
323
+ }
324
+
325
+ /** Handle a peer_approval_res frame from mesh. */
326
+ handleApprovalFrame(frame: PeerApprovalResponse) {
327
+ const resolvedBy: ApprovalResolvedBy = {
328
+ source: frame.from || "mesh",
329
+ at: Date.now(),
330
+ };
331
+ return this.handleResponse(frame.payload.approvalId, frame.payload.decision, resolvedBy);
332
+ }
333
+
334
+ /** Revoke an approved peer. */
335
+ revoke(nodeId: string, resolvedBy?: ApprovalResolvedBy): boolean {
336
+ const existing = this.data.approved[nodeId];
337
+ if (!existing) return false;
338
+ delete this.data.approved[nodeId];
339
+ this.data.denied[nodeId] = {
340
+ nodeId,
341
+ deniedAt: Date.now(),
342
+ deviceInfo: existing.deviceInfo,
343
+ resolvedBy: resolvedBy ?? { source: "unknown", at: Date.now() },
344
+ };
345
+ this.save();
346
+ return true;
347
+ }
348
+
349
+ /** Get all approved peers. */
350
+ getApprovedPeers(): ApprovedPeerRecord[] {
351
+ return Object.values(this.data.approved);
352
+ }
353
+
354
+ /** Get all pending approvals. */
355
+ getPendingApprovals(): PendingApproval[] {
356
+ return [...this.pending.values()];
357
+ }
358
+
359
+ /** Get denied peer records (excluding legacy number-only entries). */
360
+ getDeniedPeers(): DeniedPeerRecord[] {
361
+ return Object.values(this.data.denied)
362
+ .filter((v): v is DeniedPeerRecord => typeof v === "object" && v !== null)
363
+ .sort((a, b) => b.deniedAt - a.deniedAt);
364
+ }
365
+
366
+ /**
367
+ * Forward a peer approval notification received from mesh to local IM channels.
368
+ * Called on nodes that are NOT the originator of the approval request.
369
+ */
370
+ async forwardApprovalToIM(
371
+ approvalId: string,
372
+ nodeId: string,
373
+ deviceInfo?: DeviceInfo,
374
+ ip?: string,
375
+ ) {
376
+ this.log(`forwardApprovalToIM: approvalId=${approvalId} nodeId=${nodeId}`);
377
+ await this.sendNotifications(approvalId, nodeId, deviceInfo, "required", ip);
378
+ }
379
+
380
+ /**
381
+ * Send resolution notification to all IM channels and broadcast to mesh.
382
+ * Called after a peer approval is resolved (approved or denied).
383
+ */
384
+ private sendResolutionNotification(
385
+ approvalId: string,
386
+ nodeId: string,
387
+ deviceInfo: DeviceInfo | undefined,
388
+ decision: "approve" | "deny",
389
+ resolvedBy: ApprovalResolvedBy,
390
+ ) {
391
+ // Send to local IM channels
392
+ this.sendResolutionToIM(approvalId, nodeId, deviceInfo, decision, resolvedBy);
393
+
394
+ // Broadcast to mesh so other nodes can notify their local channels
395
+ if (this.broadcastFn) {
396
+ this.broadcastFn({
397
+ type: "peer_approval_notify",
398
+ from: "",
399
+ timestamp: Date.now(),
400
+ payload: {
401
+ approvalId,
402
+ nodeId,
403
+ deviceInfo,
404
+ mode: "required",
405
+ resolution: { decision, resolvedBy: resolvedBy.source },
406
+ },
407
+ } as PeerApprovalNotify);
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Send resolution result to local IM channels.
413
+ */
414
+ private async sendResolutionToIM(
415
+ approvalId: string,
416
+ nodeId: string,
417
+ deviceInfo: DeviceInfo | undefined,
418
+ decision: "approve" | "deny",
419
+ resolvedBy: ApprovalResolvedBy,
420
+ ) {
421
+ if (this.notifyTargets.length === 0) {
422
+ return;
423
+ }
424
+ if (!this.channelApi && !this.gatewaySend) {
425
+ return;
426
+ }
427
+
428
+ const deviceLabel = `${deviceInfo?.hostname ?? "unknown"} (${deviceInfo?.os ?? "?"})`;
429
+ const emoji = decision === "approve" ? "\u2705" : "\u274c";
430
+ const verb = decision === "approve" ? "Approved" : "Denied";
431
+ const message = [
432
+ `${emoji} Peer ${verb}`,
433
+ `Node: ${nodeId}`,
434
+ `Device: ${deviceLabel}`,
435
+ `By: ${resolvedBy.source}`,
436
+ ].join("\n");
437
+
438
+ this.log(`sendResolutionToIM: ${decision} for ${nodeId} by ${resolvedBy.source}`);
439
+
440
+ for (const target of this.notifyTargets) {
441
+ try {
442
+ const channelObj = this.channelApi?.[target.channel];
443
+ const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
444
+ const methodName = `sendMessage${capitalize(target.channel)}`;
445
+ const sendFn = channelObj?.[methodName];
446
+
447
+ if (typeof sendFn === "function") {
448
+ await sendFn(target.to, message, {
449
+ accountId: target.accountId,
450
+ messageThreadId: target.threadId,
451
+ });
452
+ } else if (this.gatewaySend) {
453
+ await this.gatewaySend({
454
+ to: target.to,
455
+ message,
456
+ channel: target.channel,
457
+ accountId: target.accountId,
458
+ threadId: target.threadId != null ? String(target.threadId) : undefined,
459
+ });
460
+ }
461
+ } catch (err) {
462
+ this.log(`sendResolutionToIM: failed for ${target.channel}/${target.to}: ${err}`);
463
+ }
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Forward a resolution notification received from mesh to local IM channels.
469
+ */
470
+ async forwardResolutionToIM(
471
+ approvalId: string,
472
+ nodeId: string,
473
+ deviceInfo: DeviceInfo | undefined,
474
+ decision: "approve" | "deny",
475
+ resolvedBySource: string,
476
+ ) {
477
+ this.log(`forwardResolutionToIM: ${decision} for ${nodeId}`);
478
+ await this.sendResolutionToIM(approvalId, nodeId, deviceInfo, decision, {
479
+ source: resolvedBySource,
480
+ at: Date.now(),
481
+ });
482
+ }
483
+
484
+ private addApproved(nodeId: string, deviceInfo?: DeviceInfo, publicKey?: string, resolvedBy?: ApprovalResolvedBy) {
485
+ this.data.approved[nodeId] = {
486
+ nodeId,
487
+ approvedAt: Date.now(),
488
+ deviceInfo,
489
+ publicKey,
490
+ resolvedBy,
491
+ };
492
+ delete this.data.denied[nodeId];
493
+ this.save();
494
+ }
495
+
496
+ // ── Notifications ───────────────────────────────────────────────
497
+
498
+ private broadcastNotify(
499
+ broadcastFn: (frame: AnyClusterFrame) => void,
500
+ approvalId: string,
501
+ nodeId: string,
502
+ deviceInfo: DeviceInfo | undefined,
503
+ mode: "notify" | "required",
504
+ ip?: string,
505
+ ) {
506
+ broadcastFn({
507
+ type: "peer_approval_notify",
508
+ from: "",
509
+ timestamp: Date.now(),
510
+ payload: { approvalId, nodeId, deviceInfo, mode, ip },
511
+ } as PeerApprovalNotify);
512
+ }
513
+
514
+ private async sendNotifications(
515
+ approvalId: string,
516
+ nodeId: string,
517
+ deviceInfo: DeviceInfo | undefined,
518
+ mode: "notify" | "required",
519
+ ip?: string,
520
+ ) {
521
+ if (!this.channelApi || this.notifyTargets.length === 0) {
522
+ this.log(`sendNotifications: skipped (channelApi=${!!this.channelApi} targets=${this.notifyTargets.length})`);
523
+ return;
524
+ }
525
+ this.log(`sendNotifications: sending to ${this.notifyTargets.length} targets`);
526
+
527
+ const deviceLabel = `${deviceInfo?.hostname ?? "unknown"} (${deviceInfo?.os ?? "?"})`;
528
+ const expiresMin = Math.floor(this.config.timeout / 60_000);
529
+ const ipLine = ip ? `IP: ${ip}` : null;
530
+
531
+ const lines = mode === "notify"
532
+ ? [
533
+ `\u{1f4f2} New device joined ClawMatrix cluster`,
534
+ `Node: ${nodeId}`,
535
+ `Device: ${deviceLabel}`,
536
+ ...(ipLine ? [ipLine] : []),
537
+ ``,
538
+ `If this wasn't you: /clawmatrix approval revoke ${nodeId}`,
539
+ ]
540
+ : [
541
+ `\u{1f512} New device requesting to join ClawMatrix cluster`,
542
+ `Node: ${nodeId}`,
543
+ `Device: ${deviceLabel}`,
544
+ ...(ipLine ? [ipLine] : []),
545
+ `Expires in ${expiresMin} minutes`,
546
+ ];
547
+
548
+ const message = lines.join("\n");
549
+
550
+ // Interactive buttons for channels that support them (callback_data processed as command)
551
+ const approveCmd = `/clawmatrix approve ${approvalId}`;
552
+ const denyCmd = `/clawmatrix deny ${approvalId}`;
553
+
554
+ for (const target of this.notifyTargets) {
555
+ try {
556
+ const channelObj = this.channelApi[target.channel];
557
+
558
+ // Convention: sendMessage{Channel} e.g. sendMessageTelegram
559
+ const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
560
+ const methodName = `sendMessage${capitalize(target.channel)}`;
561
+ const sendFn = channelObj?.[methodName];
562
+
563
+ if (typeof sendFn === "function") {
564
+ // Direct channel API (built-in channels like telegram)
565
+ const opts: Record<string, unknown> = {
566
+ accountId: target.accountId,
567
+ messageThreadId: target.threadId,
568
+ };
569
+ if (mode === "required") {
570
+ opts.buttons = [
571
+ [
572
+ { text: "\u2705 Approve", callback_data: approveCmd },
573
+ { text: "\u274c Deny", callback_data: denyCmd },
574
+ ],
575
+ ];
576
+ }
577
+ await sendFn(target.to, message, opts);
578
+ this.log(`sendNotifications: sent to ${target.channel}/${target.to} via channelApi`);
579
+ } else if (this.gatewaySend) {
580
+ // Fallback: gateway send method (works for all channels including plugins like feishu)
581
+ await this.gatewaySend({
582
+ to: target.to,
583
+ message: mode === "required"
584
+ ? `${message}\n\nApprove: ${approveCmd}\nDeny: ${denyCmd}`
585
+ : message,
586
+ channel: target.channel,
587
+ accountId: target.accountId,
588
+ threadId: target.threadId != null ? String(target.threadId) : undefined,
589
+ });
590
+ this.log(`sendNotifications: sent to ${target.channel}/${target.to} via gatewaySend`);
591
+ } else {
592
+ this.log(`sendNotifications: no channelApi or gatewaySend for "${target.channel}"`);
593
+ }
594
+ } catch (err) {
595
+ this.log(`sendNotifications: failed for ${target.channel}/${target.to}: ${err}`);
596
+ }
597
+ }
598
+ }
599
+
600
+ // ── IP-level approval noise suppression ─────────────────────────
601
+
602
+ /** Check if an IP has exceeded the deny threshold (auto-block). */
603
+ isIpBlocked(ip: string): boolean {
604
+ const history = this.ipDenyHistory.get(ip);
605
+ if (!history) return false;
606
+ const cutoff = Date.now() - IP_DENY_WINDOW;
607
+ const recent = history.filter((t) => t > cutoff);
608
+ if (recent.length !== history.length) {
609
+ this.ipDenyHistory.set(ip, recent);
610
+ }
611
+ return recent.length >= IP_DENY_THRESHOLD;
612
+ }
613
+
614
+ /** Record a deny event for an IP. */
615
+ recordIpDeny(ip: string) {
616
+ const history = this.ipDenyHistory.get(ip) ?? [];
617
+ history.push(Date.now());
618
+ this.ipDenyHistory.set(ip, history);
619
+ }
620
+
621
+ destroy() {
622
+ // Reject all pending approvals
623
+ for (const pending of this.pending.values()) {
624
+ pending.resolve("deny");
625
+ }
626
+ this.pending.clear();
627
+ }
628
+ }