palaryn 0.1.0 → 0.3.2
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 +243 -588
- package/dist/sdk/typescript/src/client.js +2 -2
- package/dist/sdk/typescript/src/client.js.map +1 -1
- package/dist/src/anomaly/detector.d.ts +7 -4
- package/dist/src/anomaly/detector.d.ts.map +1 -1
- package/dist/src/anomaly/detector.js +22 -12
- package/dist/src/anomaly/detector.js.map +1 -1
- package/dist/src/audit/logger.d.ts +10 -0
- package/dist/src/audit/logger.d.ts.map +1 -1
- package/dist/src/audit/logger.js +52 -38
- package/dist/src/audit/logger.js.map +1 -1
- package/dist/src/auth/routes.d.ts.map +1 -1
- package/dist/src/auth/routes.js +35 -0
- package/dist/src/auth/routes.js.map +1 -1
- package/dist/src/budget/manager.d.ts +5 -0
- package/dist/src/budget/manager.d.ts.map +1 -1
- package/dist/src/budget/manager.js +32 -0
- package/dist/src/budget/manager.js.map +1 -1
- package/dist/src/budget/model-pricing.d.ts +20 -0
- package/dist/src/budget/model-pricing.d.ts.map +1 -0
- package/dist/src/budget/model-pricing.js +107 -0
- package/dist/src/budget/model-pricing.js.map +1 -0
- package/dist/src/budget/usage-extractor.d.ts +3 -1
- package/dist/src/budget/usage-extractor.d.ts.map +1 -1
- package/dist/src/budget/usage-extractor.js +47 -3
- package/dist/src/budget/usage-extractor.js.map +1 -1
- package/dist/src/config/defaults.d.ts.map +1 -1
- package/dist/src/config/defaults.js +65 -13
- package/dist/src/config/defaults.js.map +1 -1
- package/dist/src/dlp/tool-patterns.d.ts +7 -0
- package/dist/src/dlp/tool-patterns.d.ts.map +1 -0
- package/dist/src/dlp/tool-patterns.js +34 -0
- package/dist/src/dlp/tool-patterns.js.map +1 -0
- package/dist/src/executor/filesystem-executor.d.ts +28 -0
- package/dist/src/executor/filesystem-executor.d.ts.map +1 -0
- package/dist/src/executor/filesystem-executor.js +192 -0
- package/dist/src/executor/filesystem-executor.js.map +1 -0
- package/dist/src/executor/http-executor.d.ts.map +1 -1
- package/dist/src/executor/http-executor.js +22 -2
- package/dist/src/executor/http-executor.js.map +1 -1
- package/dist/src/executor/index.d.ts +4 -0
- package/dist/src/executor/index.d.ts.map +1 -1
- package/dist/src/executor/index.js +9 -1
- package/dist/src/executor/index.js.map +1 -1
- package/dist/src/executor/shell-executor.d.ts +22 -0
- package/dist/src/executor/shell-executor.d.ts.map +1 -0
- package/dist/src/executor/shell-executor.js +119 -0
- package/dist/src/executor/shell-executor.js.map +1 -0
- package/dist/src/executor/sql-executor.d.ts +29 -0
- package/dist/src/executor/sql-executor.d.ts.map +1 -0
- package/dist/src/executor/sql-executor.js +114 -0
- package/dist/src/executor/sql-executor.js.map +1 -0
- package/dist/src/executor/websocket-executor.d.ts +26 -0
- package/dist/src/executor/websocket-executor.d.ts.map +1 -0
- package/dist/src/executor/websocket-executor.js +205 -0
- package/dist/src/executor/websocket-executor.js.map +1 -0
- package/dist/src/interceptor/index.d.ts +2 -0
- package/dist/src/interceptor/index.d.ts.map +1 -0
- package/dist/src/interceptor/index.js +6 -0
- package/dist/src/interceptor/index.js.map +1 -0
- package/dist/src/interceptor/provider-interceptor.d.ts +36 -0
- package/dist/src/interceptor/provider-interceptor.d.ts.map +1 -0
- package/dist/src/interceptor/provider-interceptor.js +302 -0
- package/dist/src/interceptor/provider-interceptor.js.map +1 -0
- package/dist/src/mcp/auth-verifier.d.ts.map +1 -1
- package/dist/src/mcp/auth-verifier.js +3 -2
- package/dist/src/mcp/auth-verifier.js.map +1 -1
- package/dist/src/mcp/bridge.d.ts +14 -10
- package/dist/src/mcp/bridge.d.ts.map +1 -1
- package/dist/src/mcp/bridge.js +51 -227
- package/dist/src/mcp/bridge.js.map +1 -1
- package/dist/src/mcp/http-transport.d.ts +2 -0
- package/dist/src/mcp/http-transport.d.ts.map +1 -1
- package/dist/src/mcp/http-transport.js +117 -66
- package/dist/src/mcp/http-transport.js.map +1 -1
- package/dist/src/mcp/internal-auth.d.ts +13 -0
- package/dist/src/mcp/internal-auth.d.ts.map +1 -0
- package/dist/src/mcp/internal-auth.js +12 -0
- package/dist/src/mcp/internal-auth.js.map +1 -0
- package/dist/src/mcp/tool-definitions.d.ts +41 -0
- package/dist/src/mcp/tool-definitions.d.ts.map +1 -0
- package/dist/src/mcp/tool-definitions.js +491 -0
- package/dist/src/mcp/tool-definitions.js.map +1 -0
- package/dist/src/middleware/auth.js.map +1 -1
- package/dist/src/middleware/session.js.map +1 -1
- package/dist/src/middleware/validate.d.ts +8 -0
- package/dist/src/middleware/validate.d.ts.map +1 -1
- package/dist/src/middleware/validate.js +45 -0
- package/dist/src/middleware/validate.js.map +1 -1
- package/dist/src/policy/engine.d.ts +4 -0
- package/dist/src/policy/engine.d.ts.map +1 -1
- package/dist/src/policy/engine.js +117 -0
- package/dist/src/policy/engine.js.map +1 -1
- package/dist/src/saas/routes.d.ts.map +1 -1
- package/dist/src/saas/routes.js +355 -22
- package/dist/src/saas/routes.js.map +1 -1
- package/dist/src/server/app.d.ts.map +1 -1
- package/dist/src/server/app.js +24 -3
- package/dist/src/server/app.js.map +1 -1
- package/dist/src/server/gateway.d.ts.map +1 -1
- package/dist/src/server/gateway.js +17 -0
- package/dist/src/server/gateway.js.map +1 -1
- package/dist/src/server/index.d.ts.map +1 -1
- package/dist/src/server/index.js +18 -0
- package/dist/src/server/index.js.map +1 -1
- package/dist/src/storage/interfaces.d.ts +14 -3
- package/dist/src/storage/interfaces.d.ts.map +1 -1
- package/dist/src/storage/memory.d.ts +2 -0
- package/dist/src/storage/memory.d.ts.map +1 -1
- package/dist/src/storage/memory.js +6 -0
- package/dist/src/storage/memory.js.map +1 -1
- package/dist/src/storage/postgres.d.ts +5 -0
- package/dist/src/storage/postgres.d.ts.map +1 -1
- package/dist/src/storage/postgres.js +16 -0
- package/dist/src/storage/postgres.js.map +1 -1
- package/dist/src/storage/redis.d.ts +10 -0
- package/dist/src/storage/redis.d.ts.map +1 -1
- package/dist/src/storage/redis.js +65 -0
- package/dist/src/storage/redis.js.map +1 -1
- package/dist/src/types/budget.d.ts +4 -0
- package/dist/src/types/budget.d.ts.map +1 -1
- package/dist/src/types/config.d.ts +58 -0
- package/dist/src/types/config.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +1 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/policy.d.ts +11 -1
- package/dist/src/types/policy.d.ts.map +1 -1
- package/dist/src/types/tool-result.d.ts +11 -0
- package/dist/src/types/tool-result.d.ts.map +1 -1
- package/dist/tests/unit/app-routes.test.d.ts +2 -0
- package/dist/tests/unit/app-routes.test.d.ts.map +1 -0
- package/dist/tests/unit/app-routes.test.js +715 -0
- package/dist/tests/unit/app-routes.test.js.map +1 -0
- package/dist/tests/unit/audit-logger.test.js +105 -0
- package/dist/tests/unit/audit-logger.test.js.map +1 -1
- package/dist/tests/unit/auth-providers.test.d.ts +2 -0
- package/dist/tests/unit/auth-providers.test.d.ts.map +1 -0
- package/dist/tests/unit/auth-providers.test.js +279 -0
- package/dist/tests/unit/auth-providers.test.js.map +1 -0
- package/dist/tests/unit/auth-routes-extended.test.d.ts +2 -0
- package/dist/tests/unit/auth-routes-extended.test.d.ts.map +1 -0
- package/dist/tests/unit/auth-routes-extended.test.js +993 -0
- package/dist/tests/unit/auth-routes-extended.test.js.map +1 -0
- package/dist/tests/unit/auth-verifier.test.d.ts +2 -0
- package/dist/tests/unit/auth-verifier.test.d.ts.map +1 -0
- package/dist/tests/unit/auth-verifier.test.js +505 -0
- package/dist/tests/unit/auth-verifier.test.js.map +1 -0
- package/dist/tests/unit/billing-routes.test.d.ts +2 -0
- package/dist/tests/unit/billing-routes.test.d.ts.map +1 -0
- package/dist/tests/unit/billing-routes.test.js +432 -0
- package/dist/tests/unit/billing-routes.test.js.map +1 -0
- package/dist/tests/unit/config-defaults.test.d.ts +2 -0
- package/dist/tests/unit/config-defaults.test.d.ts.map +1 -0
- package/dist/tests/unit/config-defaults.test.js +119 -0
- package/dist/tests/unit/config-defaults.test.js.map +1 -0
- package/dist/tests/unit/defaults.test.js +0 -10
- package/dist/tests/unit/defaults.test.js.map +1 -1
- package/dist/tests/unit/filesystem-executor.test.d.ts +2 -0
- package/dist/tests/unit/filesystem-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/filesystem-executor.test.js +280 -0
- package/dist/tests/unit/filesystem-executor.test.js.map +1 -0
- package/dist/tests/unit/gateway-branches.test.d.ts +2 -0
- package/dist/tests/unit/gateway-branches.test.d.ts.map +1 -0
- package/dist/tests/unit/gateway-branches.test.js +1039 -0
- package/dist/tests/unit/gateway-branches.test.js.map +1 -0
- package/dist/tests/unit/http-executor-branches.test.d.ts +2 -0
- package/dist/tests/unit/http-executor-branches.test.d.ts.map +1 -0
- package/dist/tests/unit/http-executor-branches.test.js +495 -0
- package/dist/tests/unit/http-executor-branches.test.js.map +1 -0
- package/dist/tests/unit/logger.test.d.ts +2 -0
- package/dist/tests/unit/logger.test.d.ts.map +1 -0
- package/dist/tests/unit/logger.test.js +97 -0
- package/dist/tests/unit/logger.test.js.map +1 -0
- package/dist/tests/unit/mcp-internal-auth.test.d.ts +2 -0
- package/dist/tests/unit/mcp-internal-auth.test.d.ts.map +1 -0
- package/dist/tests/unit/mcp-internal-auth.test.js +445 -0
- package/dist/tests/unit/mcp-internal-auth.test.js.map +1 -0
- package/dist/tests/unit/metrics.test.js +102 -0
- package/dist/tests/unit/metrics.test.js.map +1 -1
- package/dist/tests/unit/model-pricing.test.d.ts +2 -0
- package/dist/tests/unit/model-pricing.test.d.ts.map +1 -0
- package/dist/tests/unit/model-pricing.test.js +87 -0
- package/dist/tests/unit/model-pricing.test.js.map +1 -0
- package/dist/tests/unit/oauth-stores.test.d.ts +2 -0
- package/dist/tests/unit/oauth-stores.test.d.ts.map +1 -0
- package/dist/tests/unit/oauth-stores.test.js +260 -0
- package/dist/tests/unit/oauth-stores.test.js.map +1 -0
- package/dist/tests/unit/policy-engine.test.js +466 -0
- package/dist/tests/unit/policy-engine.test.js.map +1 -1
- package/dist/tests/unit/provider-interceptor.test.d.ts +2 -0
- package/dist/tests/unit/provider-interceptor.test.d.ts.map +1 -0
- package/dist/tests/unit/provider-interceptor.test.js +472 -0
- package/dist/tests/unit/provider-interceptor.test.js.map +1 -0
- package/dist/tests/unit/saas-routes-branches.test.d.ts +2 -0
- package/dist/tests/unit/saas-routes-branches.test.d.ts.map +1 -0
- package/dist/tests/unit/saas-routes-branches.test.js +2165 -0
- package/dist/tests/unit/saas-routes-branches.test.js.map +1 -0
- package/dist/tests/unit/saas-routes-crud.test.d.ts +2 -0
- package/dist/tests/unit/saas-routes-crud.test.d.ts.map +1 -0
- package/dist/tests/unit/saas-routes-crud.test.js +332 -0
- package/dist/tests/unit/saas-routes-crud.test.js.map +1 -0
- package/dist/tests/unit/saas-routes-data.test.d.ts +2 -0
- package/dist/tests/unit/saas-routes-data.test.d.ts.map +1 -0
- package/dist/tests/unit/saas-routes-data.test.js +405 -0
- package/dist/tests/unit/saas-routes-data.test.js.map +1 -0
- package/dist/tests/unit/saas-routes.test.js +3 -3
- package/dist/tests/unit/saas-routes.test.js.map +1 -1
- package/dist/tests/unit/shell-executor.test.d.ts +2 -0
- package/dist/tests/unit/shell-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/shell-executor.test.js +145 -0
- package/dist/tests/unit/shell-executor.test.js.map +1 -0
- package/dist/tests/unit/sql-executor.test.d.ts +2 -0
- package/dist/tests/unit/sql-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/sql-executor.test.js +177 -0
- package/dist/tests/unit/sql-executor.test.js.map +1 -0
- package/dist/tests/unit/stream-proxy.test.d.ts +2 -0
- package/dist/tests/unit/stream-proxy.test.d.ts.map +1 -0
- package/dist/tests/unit/stream-proxy.test.js +147 -0
- package/dist/tests/unit/stream-proxy.test.js.map +1 -0
- package/dist/tests/unit/tool-definitions.test.d.ts +2 -0
- package/dist/tests/unit/tool-definitions.test.d.ts.map +1 -0
- package/dist/tests/unit/tool-definitions.test.js +184 -0
- package/dist/tests/unit/tool-definitions.test.js.map +1 -0
- package/dist/tests/unit/usage-extractor.test.js +140 -0
- package/dist/tests/unit/usage-extractor.test.js.map +1 -1
- package/dist/tests/unit/webhook-handler.test.d.ts +2 -0
- package/dist/tests/unit/webhook-handler.test.d.ts.map +1 -0
- package/dist/tests/unit/webhook-handler.test.js +453 -0
- package/dist/tests/unit/webhook-handler.test.js.map +1 -0
- package/dist/tests/unit/webhook-routes.test.d.ts +2 -0
- package/dist/tests/unit/webhook-routes.test.d.ts.map +1 -0
- package/dist/tests/unit/webhook-routes.test.js +69 -0
- package/dist/tests/unit/webhook-routes.test.js.map +1 -0
- package/dist/tests/unit/websocket-executor.test.d.ts +2 -0
- package/dist/tests/unit/websocket-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/websocket-executor.test.js +121 -0
- package/dist/tests/unit/websocket-executor.test.js.map +1 -0
- package/package.json +8 -2
- package/policy-packs/demo_fail.yaml +41 -0
- package/policy-packs/full_tools.yaml +136 -0
- package/src/admin/index.ts +1 -0
- package/src/admin/routes.ts +509 -0
- package/src/admin/templates.ts +572 -0
- package/src/anomaly/detector.ts +730 -0
- package/src/anomaly/index.ts +1 -0
- package/src/approval/manager.ts +569 -0
- package/src/approval/webhook.ts +133 -0
- package/src/audit/logger.ts +490 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/password.ts +21 -0
- package/src/auth/pkce.ts +22 -0
- package/src/auth/providers.ts +208 -0
- package/src/auth/routes.ts +561 -0
- package/src/auth/session.ts +84 -0
- package/src/billing/index.ts +6 -0
- package/src/billing/plan-enforcer.ts +135 -0
- package/src/billing/routes.ts +229 -0
- package/src/billing/stripe-client.ts +58 -0
- package/src/billing/webhook-handler.ts +182 -0
- package/src/billing/webhook-routes.ts +28 -0
- package/src/budget/manager.ts +679 -0
- package/src/budget/model-pricing.ts +119 -0
- package/src/budget/usage-extractor.ts +214 -0
- package/src/cli.ts +91 -0
- package/src/config/defaults.ts +261 -0
- package/src/config/validate.ts +88 -0
- package/src/dlp/composite-scanner.ts +213 -0
- package/src/dlp/index.ts +9 -0
- package/src/dlp/interfaces.ts +34 -0
- package/src/dlp/patterns.ts +30 -0
- package/src/dlp/prompt-injection-backend.ts +181 -0
- package/src/dlp/prompt-injection-patterns.ts +302 -0
- package/src/dlp/regex-backend.ts +181 -0
- package/src/dlp/scanner.ts +502 -0
- package/src/dlp/text-normalizer.ts +225 -0
- package/src/dlp/tool-patterns.ts +35 -0
- package/src/dlp/trufflehog-backend.ts +190 -0
- package/src/executor/filesystem-executor.ts +196 -0
- package/src/executor/http-executor.ts +349 -0
- package/src/executor/index.ts +9 -0
- package/src/executor/interfaces.ts +11 -0
- package/src/executor/noop-executor.ts +23 -0
- package/src/executor/registry.ts +64 -0
- package/src/executor/shell-executor.ts +148 -0
- package/src/executor/slack-executor.ts +176 -0
- package/src/executor/sql-executor.ts +146 -0
- package/src/executor/websocket-executor.ts +211 -0
- package/src/index.ts +24 -0
- package/src/interceptor/index.ts +1 -0
- package/src/interceptor/provider-interceptor.ts +315 -0
- package/src/mcp/auth-verifier.ts +152 -0
- package/src/mcp/bridge.ts +703 -0
- package/src/mcp/http-transport.ts +698 -0
- package/src/mcp/index.ts +9 -0
- package/src/mcp/internal-auth.ts +14 -0
- package/src/mcp/oauth-pages.ts +139 -0
- package/src/mcp/oauth-postgres-stores.ts +278 -0
- package/src/mcp/oauth-provider.ts +536 -0
- package/src/mcp/oauth-stores.ts +202 -0
- package/src/mcp/server.ts +55 -0
- package/src/mcp/tool-definitions.ts +562 -0
- package/src/metrics/collector.ts +357 -0
- package/src/metrics/index.ts +1 -0
- package/src/middleware/auth.ts +814 -0
- package/src/middleware/session.ts +85 -0
- package/src/middleware/validate.ts +130 -0
- package/src/policy/engine.ts +815 -0
- package/src/policy/index.ts +2 -0
- package/src/policy/opa-engine.ts +829 -0
- package/src/proxy/forward-proxy.ts +649 -0
- package/src/proxy/index.ts +1 -0
- package/src/ratelimit/limiter.ts +196 -0
- package/src/replay/engine.ts +142 -0
- package/src/replay/index.ts +1 -0
- package/src/saas/index.ts +1 -0
- package/src/saas/routes.ts +2178 -0
- package/src/server/app.ts +985 -0
- package/src/server/errors.ts +49 -0
- package/src/server/gateway.ts +1130 -0
- package/src/server/index.ts +307 -0
- package/src/server/logger.ts +255 -0
- package/src/server/stream-proxy.ts +202 -0
- package/src/storage/file-persistence.ts +315 -0
- package/src/storage/index.ts +4 -0
- package/src/storage/interfaces.ts +287 -0
- package/src/storage/memory.ts +686 -0
- package/src/storage/postgres.ts +1831 -0
- package/src/storage/redis.ts +835 -0
- package/src/tracing/index.ts +1 -0
- package/src/tracing/provider.ts +100 -0
- package/src/trust/calculator.ts +141 -0
- package/src/trust/index.ts +7 -0
- package/src/types/budget.ts +36 -0
- package/src/types/config.ts +278 -0
- package/src/types/events.ts +41 -0
- package/src/types/express.d.ts +14 -0
- package/src/types/index.ts +7 -0
- package/src/types/policy.ts +83 -0
- package/src/types/stripe-config.ts +11 -0
- package/src/types/subscription.ts +59 -0
- package/src/types/tool-call.ts +47 -0
- package/src/types/tool-result.ts +82 -0
- package/src/types/user.ts +125 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
import Redis from 'ioredis';
|
|
2
|
+
import { BudgetState } from '../types/budget';
|
|
3
|
+
import { AuditEvent, EventType } from '../types/events';
|
|
4
|
+
import {
|
|
5
|
+
BudgetStore,
|
|
6
|
+
AuditStore,
|
|
7
|
+
ApprovalStore,
|
|
8
|
+
ApprovalRecord,
|
|
9
|
+
RateLimitStore,
|
|
10
|
+
IdempotencyStore,
|
|
11
|
+
} from './interfaces';
|
|
12
|
+
import { ToolResult } from '../types/tool-result';
|
|
13
|
+
|
|
14
|
+
/** Helper to log Redis errors with a descriptive operation name */
|
|
15
|
+
function logRedisError(op: string) {
|
|
16
|
+
return (err: unknown) => console.error(`[redis] ${op} failed:`, err);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Redis-backed rate limit store using sorted sets for sliding window counters.
|
|
21
|
+
*
|
|
22
|
+
* Because the RateLimitStore interface is synchronous, this implementation
|
|
23
|
+
* maintains an in-memory cache for immediate reads and fires off Redis
|
|
24
|
+
* operations asynchronously to persist state. On hit(), the local cache
|
|
25
|
+
* is updated first (synchronous return), then Redis is updated in the
|
|
26
|
+
* background. This gives the sync interface while still benefiting from
|
|
27
|
+
* Redis persistence across process restarts.
|
|
28
|
+
*
|
|
29
|
+
* Redis data structure per key: sorted set where each member is a unique
|
|
30
|
+
* hit ID and the score is the timestamp (ms).
|
|
31
|
+
*/
|
|
32
|
+
export class RedisRateLimitStore implements RateLimitStore {
|
|
33
|
+
private redis: Redis;
|
|
34
|
+
private prefix: string;
|
|
35
|
+
private localWindows = new Map<string, number[]>();
|
|
36
|
+
private hitCounter = 0;
|
|
37
|
+
private _pendingWrites: Promise<void>[] = [];
|
|
38
|
+
|
|
39
|
+
constructor(redis: Redis, prefix: string = 'rl:') {
|
|
40
|
+
this.redis = redis;
|
|
41
|
+
this.prefix = prefix;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
hit(
|
|
45
|
+
key: string,
|
|
46
|
+
windowMs: number,
|
|
47
|
+
maxRequests: number,
|
|
48
|
+
): { allowed: boolean; current: number; limit: number; resetAt: number } {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const cutoff = now - windowMs;
|
|
51
|
+
|
|
52
|
+
// Update local cache (synchronous path)
|
|
53
|
+
let timestamps = this.localWindows.get(key) || [];
|
|
54
|
+
timestamps = timestamps.filter(t => t > cutoff);
|
|
55
|
+
timestamps.push(now);
|
|
56
|
+
this.localWindows.set(key, timestamps);
|
|
57
|
+
|
|
58
|
+
const current = timestamps.length;
|
|
59
|
+
const allowed = current <= maxRequests;
|
|
60
|
+
const resetAt = timestamps.length > 0 ? timestamps[0] + windowMs : now + windowMs;
|
|
61
|
+
|
|
62
|
+
// Track Redis pipeline as a pending write so callers can await consistency
|
|
63
|
+
const redisKey = this.prefix + key;
|
|
64
|
+
const hitId = `${now}:${++this.hitCounter}`;
|
|
65
|
+
const pipeline = this.redis.pipeline();
|
|
66
|
+
pipeline.zremrangebyscore(redisKey, '-inf', cutoff);
|
|
67
|
+
pipeline.zadd(redisKey, now, hitId);
|
|
68
|
+
pipeline.zcard(redisKey);
|
|
69
|
+
// Set a TTL on the key so it auto-expires after the window passes
|
|
70
|
+
pipeline.pexpire(redisKey, windowMs);
|
|
71
|
+
this._pendingWrites.push(
|
|
72
|
+
pipeline.exec().then(() => {}).catch(logRedisError('RateLimitStore.hit pipeline')),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return { allowed, current, limit: maxRequests, resetAt };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Wait for all pending Redis pipeline writes to complete. */
|
|
79
|
+
async flush(): Promise<void> {
|
|
80
|
+
const writes = this._pendingWrites;
|
|
81
|
+
this._pendingWrites = [];
|
|
82
|
+
await Promise.all(writes);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
reset(): void {
|
|
86
|
+
this.localWindows.clear();
|
|
87
|
+
|
|
88
|
+
// Scan and delete all keys matching the prefix pattern in the background
|
|
89
|
+
this._scanAndDelete().catch(logRedisError('RateLimitStore.reset scanAndDelete'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Uses SCAN to iteratively find and delete all keys matching the prefix.
|
|
94
|
+
* SCAN is preferred over KEYS to avoid blocking Redis on large keyspaces.
|
|
95
|
+
*/
|
|
96
|
+
private async _scanAndDelete(): Promise<void> {
|
|
97
|
+
const pattern = this.prefix + '*';
|
|
98
|
+
let cursor = '0';
|
|
99
|
+
|
|
100
|
+
do {
|
|
101
|
+
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
102
|
+
cursor = nextCursor;
|
|
103
|
+
if (keys.length > 0) {
|
|
104
|
+
await this.redis.del(...keys);
|
|
105
|
+
}
|
|
106
|
+
} while (cursor !== '0');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Bulk-hydrate local cache from all Redis sorted set keys matching the prefix.
|
|
111
|
+
* Call once at startup to restore rate limit state after a process restart.
|
|
112
|
+
*/
|
|
113
|
+
async hydrateAll(): Promise<void> {
|
|
114
|
+
try {
|
|
115
|
+
const pattern = this.prefix + '*';
|
|
116
|
+
let cursor = '0';
|
|
117
|
+
do {
|
|
118
|
+
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
119
|
+
cursor = nextCursor;
|
|
120
|
+
for (const redisKey of keys) {
|
|
121
|
+
const localKey = redisKey.slice(this.prefix.length);
|
|
122
|
+
// Read all members with scores; prune nothing (callers apply windowMs on hit)
|
|
123
|
+
const members = await this.redis.zrangebyscore(redisKey, '-inf', '+inf', 'WITHSCORES');
|
|
124
|
+
const timestamps: number[] = [];
|
|
125
|
+
for (let i = 1; i < members.length; i += 2) {
|
|
126
|
+
timestamps.push(Number(members[i]));
|
|
127
|
+
}
|
|
128
|
+
timestamps.sort((a, b) => a - b);
|
|
129
|
+
if (timestamps.length > 0) {
|
|
130
|
+
this.localWindows.set(localKey, timestamps);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} while (cursor !== '0');
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
console.error('[RedisRateLimitStore] hydrateAll failed:', err.message);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Hydrate the local cache from Redis for a given key.
|
|
141
|
+
* Useful after a process restart to restore sliding window state.
|
|
142
|
+
* This is an async helper -- call it during startup if needed.
|
|
143
|
+
*/
|
|
144
|
+
async hydrate(key: string, windowMs: number): Promise<void> {
|
|
145
|
+
const redisKey = this.prefix + key;
|
|
146
|
+
const cutoff = Date.now() - windowMs;
|
|
147
|
+
|
|
148
|
+
// Prune and fetch remaining entries
|
|
149
|
+
await this.redis.zremrangebyscore(redisKey, '-inf', cutoff);
|
|
150
|
+
const members = await this.redis.zrangebyscore(redisKey, cutoff, '+inf', 'WITHSCORES');
|
|
151
|
+
|
|
152
|
+
// members is [member1, score1, member2, score2, ...]
|
|
153
|
+
const timestamps: number[] = [];
|
|
154
|
+
for (let i = 1; i < members.length; i += 2) {
|
|
155
|
+
timestamps.push(Number(members[i]));
|
|
156
|
+
}
|
|
157
|
+
timestamps.sort((a, b) => a - b);
|
|
158
|
+
this.localWindows.set(key, timestamps);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Redis-backed idempotency store for tool call deduplication.
|
|
164
|
+
*
|
|
165
|
+
* Like the rate limit store, this maintains a local in-memory cache for
|
|
166
|
+
* synchronous access (required by the IdempotencyStore interface) and
|
|
167
|
+
* persists to Redis asynchronously. Redis SET with PX expiry handles TTL
|
|
168
|
+
* automatically, while the local cache mirrors the same expiry behavior.
|
|
169
|
+
*
|
|
170
|
+
* On get(), the local cache is checked first. If the entry is missing or
|
|
171
|
+
* expired locally, it will be a cache miss for this call, but a background
|
|
172
|
+
* fetch from Redis populates the local cache for subsequent calls.
|
|
173
|
+
*/
|
|
174
|
+
export class RedisIdempotencyStore implements IdempotencyStore {
|
|
175
|
+
private redis: Redis;
|
|
176
|
+
private prefix: string;
|
|
177
|
+
private localCache = new Map<string, { result: ToolResult; expiresAt: number }>();
|
|
178
|
+
private _pendingWrites: Promise<void>[] = [];
|
|
179
|
+
|
|
180
|
+
constructor(redis: Redis, prefix: string = 'idem:') {
|
|
181
|
+
this.redis = redis;
|
|
182
|
+
this.prefix = prefix;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async get(toolCallId: string): Promise<ToolResult | undefined> {
|
|
186
|
+
// Check local cache first
|
|
187
|
+
const entry = this.localCache.get(toolCallId);
|
|
188
|
+
if (entry) {
|
|
189
|
+
if (Date.now() > entry.expiresAt) {
|
|
190
|
+
this.localCache.delete(toolCallId);
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
return entry.result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Local miss — fetch from Redis synchronously
|
|
197
|
+
try {
|
|
198
|
+
const redisKey = this.prefix + toolCallId;
|
|
199
|
+
const raw = await this.redis.get(redisKey);
|
|
200
|
+
if (raw !== null) {
|
|
201
|
+
const parsed = JSON.parse(raw);
|
|
202
|
+
const ttl = await this.redis.pttl(redisKey);
|
|
203
|
+
if (ttl > 0) {
|
|
204
|
+
this.localCache.set(toolCallId, {
|
|
205
|
+
result: parsed,
|
|
206
|
+
expiresAt: Date.now() + ttl,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return parsed;
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error('[redis] IdempotencyStore.get fetch failed:', err);
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
set(toolCallId: string, result: ToolResult, ttlMs: number): void {
|
|
218
|
+
// Update local cache immediately
|
|
219
|
+
this.localCache.set(toolCallId, {
|
|
220
|
+
result,
|
|
221
|
+
expiresAt: Date.now() + ttlMs,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Evict expired local entries when the cache grows large
|
|
225
|
+
if (this.localCache.size > 10000) {
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
for (const [key, val] of this.localCache) {
|
|
228
|
+
if (now > val.expiresAt) this.localCache.delete(key);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Persist to Redis with millisecond TTL
|
|
233
|
+
const redisKey = this.prefix + toolCallId;
|
|
234
|
+
const serialized = JSON.stringify(result);
|
|
235
|
+
this._pendingWrites.push(
|
|
236
|
+
this.redis.set(redisKey, serialized, 'PX', ttlMs).then(() => {}).catch(logRedisError('IdempotencyStore.set write')),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async has(toolCallId: string): Promise<boolean> {
|
|
241
|
+
return (await this.get(toolCallId)) !== undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async flush(): Promise<void> {
|
|
245
|
+
const writes = this._pendingWrites;
|
|
246
|
+
this._pendingWrites = [];
|
|
247
|
+
await Promise.all(writes);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
clear(): void {
|
|
251
|
+
this.localCache.clear();
|
|
252
|
+
|
|
253
|
+
// Scan and delete all keys matching the prefix pattern in the background
|
|
254
|
+
this._scanAndDelete().catch(logRedisError('IdempotencyStore.clear scanAndDelete'));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Uses SCAN to iteratively find and delete all keys matching the prefix.
|
|
259
|
+
* SCAN is preferred over KEYS to avoid blocking Redis on large keyspaces.
|
|
260
|
+
*/
|
|
261
|
+
private async _scanAndDelete(): Promise<void> {
|
|
262
|
+
const pattern = this.prefix + '*';
|
|
263
|
+
let cursor = '0';
|
|
264
|
+
|
|
265
|
+
do {
|
|
266
|
+
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
267
|
+
cursor = nextCursor;
|
|
268
|
+
if (keys.length > 0) {
|
|
269
|
+
await this.redis.del(...keys);
|
|
270
|
+
}
|
|
271
|
+
} while (cursor !== '0');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Hydrate the local cache from Redis for a given tool call ID.
|
|
276
|
+
* Useful after a process restart to restore cached results.
|
|
277
|
+
* This is an async helper -- call it during startup if needed.
|
|
278
|
+
*/
|
|
279
|
+
async hydrate(toolCallId: string): Promise<void> {
|
|
280
|
+
const redisKey = this.prefix + toolCallId;
|
|
281
|
+
const raw = await this.redis.get(redisKey);
|
|
282
|
+
if (raw !== null) {
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(raw);
|
|
285
|
+
const ttl = await this.redis.pttl(redisKey);
|
|
286
|
+
if (ttl > 0) {
|
|
287
|
+
this.localCache.set(toolCallId, {
|
|
288
|
+
result: parsed,
|
|
289
|
+
expiresAt: Date.now() + ttl,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
// Swallow parse errors
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// RedisBudgetStore
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Redis-backed budget store using the write-through cache pattern.
|
|
305
|
+
*
|
|
306
|
+
* All BudgetStore interface methods are synchronous (in-memory cache),
|
|
307
|
+
* with fire-and-forget Redis persistence for cross-process durability.
|
|
308
|
+
*
|
|
309
|
+
* Redis data structures:
|
|
310
|
+
* - Task states: HSET {prefix}task:{taskId} field value (one hash per task)
|
|
311
|
+
* - Counters: HSET {prefix}counters {key} {value} (single hash)
|
|
312
|
+
* - Retry counts: HSET {prefix}retries {toolCallId} {count} (single hash)
|
|
313
|
+
*/
|
|
314
|
+
export class RedisBudgetStore implements BudgetStore {
|
|
315
|
+
private redis: Redis;
|
|
316
|
+
private prefix: string;
|
|
317
|
+
private taskStates = new Map<string, BudgetState>();
|
|
318
|
+
private counters = new Map<string, number>();
|
|
319
|
+
private retryCounts = new Map<string, number>();
|
|
320
|
+
private _pendingWrites: Promise<void>[] = [];
|
|
321
|
+
|
|
322
|
+
constructor(redis: Redis, prefix: string = 'budget:') {
|
|
323
|
+
this.redis = redis;
|
|
324
|
+
this.prefix = prefix;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// -- Interface implementation -------------------------------------------
|
|
328
|
+
|
|
329
|
+
getTaskState(taskId: string): BudgetState | undefined {
|
|
330
|
+
return this.taskStates.get(taskId);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
setTaskState(taskId: string, state: BudgetState): void {
|
|
334
|
+
this.taskStates.set(taskId, state);
|
|
335
|
+
|
|
336
|
+
const redisKey = this.prefix + 'task:' + taskId;
|
|
337
|
+
this._pendingWrites.push(
|
|
338
|
+
this.redis
|
|
339
|
+
.hset(
|
|
340
|
+
redisKey,
|
|
341
|
+
'task_id', state.task_id,
|
|
342
|
+
'workspace_id', state.workspace_id,
|
|
343
|
+
'actor_id', state.actor_id,
|
|
344
|
+
'spent_usd', String(state.spent_usd),
|
|
345
|
+
'steps', String(state.steps),
|
|
346
|
+
'started_at', state.started_at,
|
|
347
|
+
)
|
|
348
|
+
.then(() => {})
|
|
349
|
+
.catch((err) => {
|
|
350
|
+
console.error(`[redis] BudgetStore.setTaskState hset failed:`, err);
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
getCounter(key: string): number {
|
|
356
|
+
return this.counters.get(key) ?? 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
incrementCounter(key: string, amount: number): void {
|
|
360
|
+
const newVal = (this.counters.get(key) ?? 0) + amount;
|
|
361
|
+
this.counters.set(key, newVal);
|
|
362
|
+
|
|
363
|
+
const redisKey = this.prefix + 'counters';
|
|
364
|
+
this._pendingWrites.push(
|
|
365
|
+
this.redis.hset(redisKey, key, String(newVal)).then(() => {}).catch((err) => {
|
|
366
|
+
console.error(`[redis] BudgetStore.incrementCounter hset failed:`, err);
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
getRetryCount(toolCallId: string): number {
|
|
372
|
+
return this.retryCounts.get(toolCallId) ?? 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
incrementRetryCount(toolCallId: string): number {
|
|
376
|
+
const count = (this.retryCounts.get(toolCallId) ?? 0) + 1;
|
|
377
|
+
this.retryCounts.set(toolCallId, count);
|
|
378
|
+
|
|
379
|
+
const redisKey = this.prefix + 'retries';
|
|
380
|
+
this._pendingWrites.push(
|
|
381
|
+
this.redis.hset(redisKey, toolCallId, String(count)).then(() => {}).catch((err) => {
|
|
382
|
+
console.error(`[redis] BudgetStore.incrementRetryCount hset failed:`, err);
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
return count;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async flush(): Promise<void> {
|
|
390
|
+
const writes = this._pendingWrites;
|
|
391
|
+
this._pendingWrites = [];
|
|
392
|
+
await Promise.all(writes);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
reset(): void {
|
|
396
|
+
this.taskStates.clear();
|
|
397
|
+
this.counters.clear();
|
|
398
|
+
this.retryCounts.clear();
|
|
399
|
+
|
|
400
|
+
// Delete Redis keys in the background
|
|
401
|
+
this._scanAndDelete().catch(logRedisError('BudgetStore.reset scanAndDelete'));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
getAllTaskStates(): Map<string, BudgetState> { return new Map(this.taskStates); }
|
|
405
|
+
getAllCounters(): Map<string, number> { return new Map(this.counters); }
|
|
406
|
+
getAllRetryCounts(): Map<string, number> { return new Map(this.retryCounts); }
|
|
407
|
+
|
|
408
|
+
// -- Hydrate from Redis -------------------------------------------------
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Loads all budget data from Redis into the local cache.
|
|
412
|
+
* Call once at startup to restore state after a process restart.
|
|
413
|
+
*/
|
|
414
|
+
async hydrate(): Promise<void> {
|
|
415
|
+
try {
|
|
416
|
+
// Hydrate counters
|
|
417
|
+
const countersKey = this.prefix + 'counters';
|
|
418
|
+
const countersData = await this.redis.hgetall(countersKey);
|
|
419
|
+
for (const [key, value] of Object.entries(countersData)) {
|
|
420
|
+
this.counters.set(key, Number(value));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Hydrate retry counts
|
|
424
|
+
const retriesKey = this.prefix + 'retries';
|
|
425
|
+
const retriesData = await this.redis.hgetall(retriesKey);
|
|
426
|
+
for (const [key, value] of Object.entries(retriesData)) {
|
|
427
|
+
this.retryCounts.set(key, Number(value));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Hydrate task states by scanning for task keys
|
|
431
|
+
const pattern = this.prefix + 'task:*';
|
|
432
|
+
let cursor = '0';
|
|
433
|
+
do {
|
|
434
|
+
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
435
|
+
cursor = nextCursor;
|
|
436
|
+
for (const key of keys) {
|
|
437
|
+
const data = await this.redis.hgetall(key);
|
|
438
|
+
if (data && data.task_id) {
|
|
439
|
+
const state: BudgetState = {
|
|
440
|
+
task_id: data.task_id,
|
|
441
|
+
workspace_id: data.workspace_id,
|
|
442
|
+
actor_id: data.actor_id,
|
|
443
|
+
spent_usd: Number(data.spent_usd),
|
|
444
|
+
steps: Number(data.steps),
|
|
445
|
+
started_at: data.started_at,
|
|
446
|
+
};
|
|
447
|
+
this.taskStates.set(data.task_id, state);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} while (cursor !== '0');
|
|
451
|
+
} catch (err: any) {
|
|
452
|
+
console.error('[RedisBudgetStore] hydrate failed:', err.message);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// -- Private helpers ----------------------------------------------------
|
|
457
|
+
|
|
458
|
+
private async _scanAndDelete(): Promise<void> {
|
|
459
|
+
const pattern = this.prefix + '*';
|
|
460
|
+
let cursor = '0';
|
|
461
|
+
do {
|
|
462
|
+
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
463
|
+
cursor = nextCursor;
|
|
464
|
+
if (keys.length > 0) {
|
|
465
|
+
await this.redis.del(...keys);
|
|
466
|
+
}
|
|
467
|
+
} while (cursor !== '0');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
// RedisAuditStore
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Redis-backed audit event store using the write-through cache pattern.
|
|
477
|
+
*
|
|
478
|
+
* All AuditStore interface methods are synchronous (in-memory array),
|
|
479
|
+
* with fire-and-forget Redis persistence for cross-process durability.
|
|
480
|
+
*
|
|
481
|
+
* Redis data structures:
|
|
482
|
+
* - Event data: HSET {prefix}data {eventId} {json}
|
|
483
|
+
* - Task index: ZADD {prefix}task:{taskId} {timestamp} {eventId}
|
|
484
|
+
* - Tool call index: ZADD {prefix}toolcall:{toolCallId} {timestamp} {eventId}
|
|
485
|
+
* - Type index: ZADD {prefix}type:{eventType} {timestamp} {eventId}
|
|
486
|
+
*/
|
|
487
|
+
export class RedisAuditStore implements AuditStore {
|
|
488
|
+
private redis: Redis;
|
|
489
|
+
private prefix: string;
|
|
490
|
+
private events: AuditEvent[] = [];
|
|
491
|
+
private _pendingWrites: Promise<void>[] = [];
|
|
492
|
+
|
|
493
|
+
constructor(redis: Redis, prefix: string = 'audit:') {
|
|
494
|
+
this.redis = redis;
|
|
495
|
+
this.prefix = prefix;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// -- Interface implementation -------------------------------------------
|
|
499
|
+
|
|
500
|
+
append(event: AuditEvent): void {
|
|
501
|
+
this.events.push(event);
|
|
502
|
+
|
|
503
|
+
const json = JSON.stringify(event);
|
|
504
|
+
const timestamp = new Date(event.timestamp).getTime();
|
|
505
|
+
|
|
506
|
+
// Store event data + update indices via tracked pending writes
|
|
507
|
+
const dataKey = this.prefix + 'data';
|
|
508
|
+
const taskKey = this.prefix + 'task:' + event.task_id;
|
|
509
|
+
const toolCallKey = this.prefix + 'toolcall:' + event.tool_call_id;
|
|
510
|
+
const typeKey = this.prefix + 'type:' + event.event_type;
|
|
511
|
+
|
|
512
|
+
const pipeline = this.redis.pipeline();
|
|
513
|
+
pipeline.hset(dataKey, event.event_id, json);
|
|
514
|
+
pipeline.zadd(taskKey, timestamp, event.event_id);
|
|
515
|
+
pipeline.zadd(toolCallKey, timestamp, event.event_id);
|
|
516
|
+
pipeline.zadd(typeKey, timestamp, event.event_id);
|
|
517
|
+
|
|
518
|
+
this._pendingWrites.push(
|
|
519
|
+
pipeline.exec().then(() => {}).catch(logRedisError('AuditStore.append pipeline')),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
getByTaskId(taskId: string): AuditEvent[] {
|
|
524
|
+
return this.events
|
|
525
|
+
.filter(e => e.task_id === taskId)
|
|
526
|
+
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
getByToolCallId(toolCallId: string): AuditEvent[] {
|
|
530
|
+
return this.events
|
|
531
|
+
.filter(e => e.tool_call_id === toolCallId)
|
|
532
|
+
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
getByEventType(eventType: EventType): AuditEvent[] {
|
|
536
|
+
return this.events.filter(e => e.event_type === eventType);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
getAll(): AuditEvent[] {
|
|
540
|
+
return [...this.events];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Wait for all pending Redis writes to complete. */
|
|
544
|
+
async flush(): Promise<void> {
|
|
545
|
+
const writes = this._pendingWrites;
|
|
546
|
+
this._pendingWrites = [];
|
|
547
|
+
await Promise.all(writes);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
deleteByTaskId(taskId: string): void {
|
|
551
|
+
// Collect event IDs to remove from Redis data hash
|
|
552
|
+
const removed = this.events.filter(e => e.task_id === taskId);
|
|
553
|
+
this.events = this.events.filter(e => e.task_id !== taskId);
|
|
554
|
+
|
|
555
|
+
if (removed.length > 0) {
|
|
556
|
+
const dataKey = this.prefix + 'data';
|
|
557
|
+
const taskKey = this.prefix + 'task:' + taskId;
|
|
558
|
+
const pipeline = this.redis.pipeline();
|
|
559
|
+
for (const e of removed) {
|
|
560
|
+
pipeline.hdel(dataKey, e.event_id);
|
|
561
|
+
pipeline.zrem(this.prefix + 'toolcall:' + e.tool_call_id, e.event_id);
|
|
562
|
+
pipeline.zrem(this.prefix + 'type:' + e.event_type, e.event_id);
|
|
563
|
+
}
|
|
564
|
+
pipeline.del(taskKey);
|
|
565
|
+
pipeline.exec().catch(logRedisError('AuditStore.deleteByTaskId pipeline'));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
deleteBySessionId(sessionId: string): void {
|
|
570
|
+
const removed = this.events.filter(e => e.session_id === sessionId);
|
|
571
|
+
this.events = this.events.filter(e => e.session_id !== sessionId);
|
|
572
|
+
|
|
573
|
+
if (removed.length > 0) {
|
|
574
|
+
const dataKey = this.prefix + 'data';
|
|
575
|
+
const pipeline = this.redis.pipeline();
|
|
576
|
+
for (const e of removed) {
|
|
577
|
+
pipeline.hdel(dataKey, e.event_id);
|
|
578
|
+
pipeline.zrem(this.prefix + 'task:' + e.task_id, e.event_id);
|
|
579
|
+
pipeline.zrem(this.prefix + 'toolcall:' + e.tool_call_id, e.event_id);
|
|
580
|
+
pipeline.zrem(this.prefix + 'type:' + e.event_type, e.event_id);
|
|
581
|
+
}
|
|
582
|
+
pipeline.exec().catch(logRedisError('AuditStore.deleteBySessionId pipeline'));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
clear(): void {
|
|
587
|
+
this.events = [];
|
|
588
|
+
|
|
589
|
+
// Delete Redis keys in the background
|
|
590
|
+
this._scanAndDelete().catch(logRedisError('AuditStore.clear scanAndDelete'));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// -- Hydrate from Redis -------------------------------------------------
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Loads all audit events from Redis into the local cache.
|
|
597
|
+
* Call once at startup to restore state after a process restart.
|
|
598
|
+
*/
|
|
599
|
+
async hydrate(): Promise<void> {
|
|
600
|
+
try {
|
|
601
|
+
const dataKey = this.prefix + 'data';
|
|
602
|
+
const allData = await this.redis.hgetall(dataKey);
|
|
603
|
+
|
|
604
|
+
const events: AuditEvent[] = [];
|
|
605
|
+
for (const [, json] of Object.entries(allData)) {
|
|
606
|
+
try {
|
|
607
|
+
events.push(JSON.parse(json));
|
|
608
|
+
} catch {
|
|
609
|
+
// Skip unparseable entries
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Sort by timestamp for consistent ordering
|
|
614
|
+
events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
615
|
+
this.events = events;
|
|
616
|
+
} catch (err: any) {
|
|
617
|
+
console.error('[RedisAuditStore] hydrate failed:', err.message);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// -- Private helpers ----------------------------------------------------
|
|
622
|
+
|
|
623
|
+
private async _scanAndDelete(): Promise<void> {
|
|
624
|
+
const pattern = this.prefix + '*';
|
|
625
|
+
let cursor = '0';
|
|
626
|
+
do {
|
|
627
|
+
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
628
|
+
cursor = nextCursor;
|
|
629
|
+
if (keys.length > 0) {
|
|
630
|
+
await this.redis.del(...keys);
|
|
631
|
+
}
|
|
632
|
+
} while (cursor !== '0');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// RedisApprovalStore
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Redis-backed approval store using the write-through cache pattern.
|
|
642
|
+
*
|
|
643
|
+
* All ApprovalStore interface methods are synchronous (in-memory Maps),
|
|
644
|
+
* with fire-and-forget Redis persistence for cross-process durability.
|
|
645
|
+
*
|
|
646
|
+
* Redis data structures:
|
|
647
|
+
* - Approval data: HSET {prefix}data {approvalId} {json}
|
|
648
|
+
* - Token index: HSET {prefix}tokens {token} {approvalId}
|
|
649
|
+
* - Pending set: SADD/SREM {prefix}pending {approvalId}
|
|
650
|
+
* - Workspace index: HSET {prefix}workspace:{wsId} {approvalId} 1
|
|
651
|
+
* - Tool call index: HSET {prefix}toolcall {toolCallId} {approvalId}
|
|
652
|
+
*/
|
|
653
|
+
export class RedisApprovalStore implements ApprovalStore {
|
|
654
|
+
private redis: Redis;
|
|
655
|
+
private prefix: string;
|
|
656
|
+
private approvals = new Map<string, ApprovalRecord>();
|
|
657
|
+
private tokenIndex = new Map<string, string>();
|
|
658
|
+
private _pendingWrites: Promise<void>[] = [];
|
|
659
|
+
|
|
660
|
+
constructor(redis: Redis, prefix: string = 'approvals:') {
|
|
661
|
+
this.redis = redis;
|
|
662
|
+
this.prefix = prefix;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// -- Interface implementation -------------------------------------------
|
|
666
|
+
|
|
667
|
+
save(approvalId: string, approval: ApprovalRecord): void {
|
|
668
|
+
const json = JSON.stringify(approval);
|
|
669
|
+
const dataKey = this.prefix + 'data';
|
|
670
|
+
const pendingKey = this.prefix + 'pending';
|
|
671
|
+
|
|
672
|
+
const pipeline = this.redis.pipeline();
|
|
673
|
+
pipeline.hset(dataKey, approvalId, json);
|
|
674
|
+
|
|
675
|
+
// Manage pending set
|
|
676
|
+
if (approval.status === 'pending') {
|
|
677
|
+
pipeline.sadd(pendingKey, approvalId);
|
|
678
|
+
} else {
|
|
679
|
+
pipeline.srem(pendingKey, approvalId);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Workspace index
|
|
683
|
+
if (approval.workspace_id) {
|
|
684
|
+
const wsKey = this.prefix + 'workspace:' + approval.workspace_id;
|
|
685
|
+
pipeline.hset(wsKey, approvalId, '1');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Tool call index
|
|
689
|
+
if (approval.tool_call_id) {
|
|
690
|
+
const tcKey = this.prefix + 'toolcall';
|
|
691
|
+
pipeline.hset(tcKey, approval.tool_call_id, approvalId);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Critical store: await Redis write, update memory cache only on success
|
|
695
|
+
this._pendingWrites.push(
|
|
696
|
+
pipeline.exec().then(() => {
|
|
697
|
+
this.approvals.set(approvalId, approval);
|
|
698
|
+
}).catch((err) => {
|
|
699
|
+
// Still update memory cache as fallback, but log the error
|
|
700
|
+
this.approvals.set(approvalId, approval);
|
|
701
|
+
console.error(`[redis] ApprovalStore.save pipeline failed:`, err);
|
|
702
|
+
}),
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
// Eagerly set in memory for synchronous reads (will be overwritten by pipeline result)
|
|
706
|
+
this.approvals.set(approvalId, approval);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
getById(approvalId: string): ApprovalRecord | undefined {
|
|
710
|
+
return this.approvals.get(approvalId);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
getByToken(token: string): ApprovalRecord | undefined {
|
|
714
|
+
const id = this.tokenIndex.get(token);
|
|
715
|
+
return id ? this.approvals.get(id) : undefined;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
getByToolCallId(toolCallId: string): ApprovalRecord | undefined {
|
|
719
|
+
for (const approval of this.approvals.values()) {
|
|
720
|
+
if (approval.tool_call_id === toolCallId) return approval;
|
|
721
|
+
}
|
|
722
|
+
return undefined;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
findPending(workspaceId?: string): ApprovalRecord[] {
|
|
726
|
+
const results: ApprovalRecord[] = [];
|
|
727
|
+
for (const approval of this.approvals.values()) {
|
|
728
|
+
if (approval.status !== 'pending') continue;
|
|
729
|
+
if (workspaceId && approval.workspace_id !== workspaceId) continue;
|
|
730
|
+
results.push(approval);
|
|
731
|
+
}
|
|
732
|
+
return results;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
indexToken(token: string, approvalId: string): void {
|
|
736
|
+
this.tokenIndex.set(token, approvalId);
|
|
737
|
+
|
|
738
|
+
const tokensKey = this.prefix + 'tokens';
|
|
739
|
+
this._pendingWrites.push(
|
|
740
|
+
this.redis.hset(tokensKey, token, approvalId).then(() => {}).catch((err) => {
|
|
741
|
+
console.error(`[redis] ApprovalStore.indexToken hset failed:`, err);
|
|
742
|
+
}),
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async flush(): Promise<void> {
|
|
747
|
+
const writes = this._pendingWrites;
|
|
748
|
+
this._pendingWrites = [];
|
|
749
|
+
await Promise.all(writes);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
clear(): void {
|
|
753
|
+
this.approvals.clear();
|
|
754
|
+
this.tokenIndex.clear();
|
|
755
|
+
|
|
756
|
+
// Delete Redis keys in the background
|
|
757
|
+
this._scanAndDelete().catch(logRedisError('ApprovalStore.clear scanAndDelete'));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// -- Hydrate from Redis -------------------------------------------------
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Loads all approval data from Redis into the local cache.
|
|
764
|
+
* Call once at startup to restore state after a process restart.
|
|
765
|
+
*/
|
|
766
|
+
async hydrate(): Promise<void> {
|
|
767
|
+
try {
|
|
768
|
+
// Hydrate approvals
|
|
769
|
+
const dataKey = this.prefix + 'data';
|
|
770
|
+
const allData = await this.redis.hgetall(dataKey);
|
|
771
|
+
for (const [approvalId, json] of Object.entries(allData)) {
|
|
772
|
+
try {
|
|
773
|
+
this.approvals.set(approvalId, JSON.parse(json));
|
|
774
|
+
} catch {
|
|
775
|
+
// Skip unparseable entries
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Hydrate token index
|
|
780
|
+
const tokensKey = this.prefix + 'tokens';
|
|
781
|
+
const tokenData = await this.redis.hgetall(tokensKey);
|
|
782
|
+
for (const [token, approvalId] of Object.entries(tokenData)) {
|
|
783
|
+
this.tokenIndex.set(token, approvalId);
|
|
784
|
+
}
|
|
785
|
+
} catch (err: any) {
|
|
786
|
+
console.error('[RedisApprovalStore] hydrate failed:', err.message);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// -- Private helpers ----------------------------------------------------
|
|
791
|
+
|
|
792
|
+
private async _scanAndDelete(): Promise<void> {
|
|
793
|
+
const pattern = this.prefix + '*';
|
|
794
|
+
let cursor = '0';
|
|
795
|
+
do {
|
|
796
|
+
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
|
797
|
+
cursor = nextCursor;
|
|
798
|
+
if (keys.length > 0) {
|
|
799
|
+
await this.redis.del(...keys);
|
|
800
|
+
}
|
|
801
|
+
} while (cursor !== '0');
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
// Factory function
|
|
807
|
+
// ---------------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Creates all Redis store instances with a shared prefix namespace.
|
|
811
|
+
* Since Redis is schemaless, no initialization (table creation) is needed.
|
|
812
|
+
*
|
|
813
|
+
* Usage:
|
|
814
|
+
* const redis = new Redis('redis://localhost:6379');
|
|
815
|
+
* const stores = createRedisStores(redis, 'myapp:');
|
|
816
|
+
* await stores.budgetStore.hydrate();
|
|
817
|
+
* await stores.auditStore.hydrate();
|
|
818
|
+
* await stores.approvalStore.hydrate();
|
|
819
|
+
*/
|
|
820
|
+
export function createRedisStores(redis: Redis, prefix?: string): {
|
|
821
|
+
budgetStore: RedisBudgetStore;
|
|
822
|
+
auditStore: RedisAuditStore;
|
|
823
|
+
approvalStore: RedisApprovalStore;
|
|
824
|
+
rateLimitStore: RedisRateLimitStore;
|
|
825
|
+
idempotencyStore: RedisIdempotencyStore;
|
|
826
|
+
} {
|
|
827
|
+
const p = prefix || '';
|
|
828
|
+
return {
|
|
829
|
+
budgetStore: new RedisBudgetStore(redis, p + 'budget:'),
|
|
830
|
+
auditStore: new RedisAuditStore(redis, p + 'audit:'),
|
|
831
|
+
approvalStore: new RedisApprovalStore(redis, p + 'approvals:'),
|
|
832
|
+
rateLimitStore: new RedisRateLimitStore(redis, p + 'rl:'),
|
|
833
|
+
idempotencyStore: new RedisIdempotencyStore(redis, p + 'idem:'),
|
|
834
|
+
};
|
|
835
|
+
}
|