@tangle-network/agent-gateway 0.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ export { createAgentGateway } from './middleware'
2
+ export { verifyX402, verifyMpp, defaultVerifyApiKey } from './verify'
3
+ export {
4
+ filterConsumerMessages,
5
+ filterConsumerMessagesStrict,
6
+ detectInjection,
7
+ redactSystemPromptFromOutput,
8
+ } from './filter'
9
+ export {
10
+ checkRateLimit,
11
+ MemoryRateLimitStore,
12
+ type RateLimitConfig,
13
+ type RateLimitResult,
14
+ type RateLimitStore,
15
+ } from './rate-limit'
16
+ export {
17
+ createApiKeyRoutes,
18
+ verifyApiKeyFromStore,
19
+ type ApiKey,
20
+ type ApiKeyCreateRequest,
21
+ type ApiKeyStore,
22
+ type ApiKeyRoutesConfig,
23
+ } from './api-keys'
24
+ export {
25
+ MemoryNonceStore,
26
+ type NonceStore,
27
+ } from './nonce-store'
28
+ export {
29
+ createPublishRoutes,
30
+ type PublishedConfig,
31
+ type PublishRequest,
32
+ type PublishStore,
33
+ type PublishRoutesConfig,
34
+ } from './publish'
35
+ export type {
36
+ AgentMeta,
37
+ PaymentMethod,
38
+ X402Config,
39
+ MppConfig,
40
+ PaymentResult,
41
+ ApiKeyInfo,
42
+ GatewayUsageEvent,
43
+ SandboxStreamEvent,
44
+ SandboxBox,
45
+ GatewayConfig,
46
+ ChatMessage,
47
+ ChatCompletionRequest,
48
+ ChatCompletionChunk,
49
+ } from './types'
@@ -0,0 +1,321 @@
1
+ import { Hono } from 'hono'
2
+ import type {
3
+ GatewayConfig,
4
+ ChatCompletionRequest,
5
+ ChatCompletionChunk,
6
+ PaymentMethod,
7
+ ApiKeyInfo,
8
+ } from './types'
9
+ import { verifyX402, verifyMpp, defaultVerifyApiKey } from './verify'
10
+ import { filterConsumerMessagesStrict, redactSystemPromptFromOutput } from './filter'
11
+ import { checkRateLimit, MemoryRateLimitStore, type RateLimitStore } from './rate-limit'
12
+ import { MemoryNonceStore } from './nonce-store'
13
+
14
+ /**
15
+ * Create a Hono router that serves the agent gateway.
16
+ *
17
+ * Mount at any path:
18
+ * app.route('/v1/agents', createAgentGateway(config))
19
+ *
20
+ * Exposes:
21
+ * GET /:slug/chat/completions — agent discovery metadata
22
+ * POST /:slug/chat/completions — OpenAI-compatible chat endpoint (paid)
23
+ */
24
+ export function createAgentGateway(config: GatewayConfig) {
25
+ const gw = new Hono()
26
+ const maxLen = config.maxMessageLength ?? 8000
27
+ const rateLimitStore: RateLimitStore = config.rateLimitStore ?? new MemoryRateLimitStore()
28
+ const globalRateLimit = config.rateLimit ?? { limit: 60, windowSeconds: 60 }
29
+ const nonceStore = config.nonceStore ?? new MemoryNonceStore()
30
+ const requiredScope = config.requiredScope ?? 'chat'
31
+
32
+ // --- Discovery endpoint (no auth) ---
33
+
34
+ gw.get('/:slug/chat/completions', async (c) => {
35
+ const slug = c.req.param('slug')
36
+ const agent = await config.resolveAgent(slug)
37
+ if (!agent) return c.json({ error: 'Agent not found or not published' }, 404)
38
+
39
+ const paymentMethods: Array<Record<string, unknown>> = [
40
+ {
41
+ type: 'x402',
42
+ operator: config.x402.operatorAddress,
43
+ chain_id: config.x402.chainId,
44
+ credits_contract: config.x402.creditsAddress,
45
+ },
46
+ ]
47
+ if (config.mpp) {
48
+ paymentMethods.push({
49
+ type: 'mpp',
50
+ realm: config.mpp.realm,
51
+ method: config.mpp.method ?? 'blueprintevm',
52
+ })
53
+ }
54
+ paymentMethods.push({ type: 'api_key', prefix: 'sk_agent_' })
55
+
56
+ return c.json({
57
+ slug: agent.slug,
58
+ pricing: {
59
+ per_token_usd: agent.pricePerTokenUsd,
60
+ currency: 'USD',
61
+ platform_fee_percent: agent.platformFeePercent,
62
+ },
63
+ hosting: {
64
+ mode: agent.sandboxEndpoint ? 'sovereign' : 'centralized',
65
+ endpoint: agent.sandboxEndpoint ?? config.baseUrl ?? 'tangle.tools',
66
+ },
67
+ payment_methods: paymentMethods,
68
+ capabilities: ['chat.completions', 'streaming'],
69
+ openai_compatible: true,
70
+ })
71
+ })
72
+
73
+ // --- Chat completions endpoint (paid) ---
74
+
75
+ gw.post('/:slug/chat/completions', async (c) => {
76
+ const slug = c.req.param('slug')
77
+ const startMs = Date.now()
78
+
79
+ // 1. Resolve agent
80
+ const agent = await config.resolveAgent(slug)
81
+ if (!agent) {
82
+ return c.json({ error: { message: 'Agent not found', type: 'not_found' } }, 404)
83
+ }
84
+
85
+ // 2. Body size limit (before parsing — DoS prevention)
86
+ const contentLength = parseInt(c.req.header('Content-Length') ?? '0', 10)
87
+ if (contentLength > 65536) {
88
+ return c.json(
89
+ { error: { message: 'Request body too large (max 64KB)', type: 'invalid_request' } },
90
+ 413,
91
+ )
92
+ }
93
+
94
+ let body: ChatCompletionRequest
95
+ try {
96
+ body = await c.req.json()
97
+ } catch {
98
+ return c.json({ error: { message: 'Invalid JSON', type: 'invalid_request' } }, 400)
99
+ }
100
+ if (!body.messages?.length) {
101
+ return c.json({ error: { message: 'messages array required', type: 'invalid_request' } }, 400)
102
+ }
103
+
104
+ // 3. Authenticate — x402 SpendAuth, MPP, or API key
105
+ const spendAuthHeader = c.req.header('X-Payment-Signature')
106
+ const authHeader = c.req.header('Authorization') ?? ''
107
+ let consumerId: string | null = null
108
+ let paymentMethod: PaymentMethod = 'none'
109
+ let keyInfo: ApiKeyInfo | null = null
110
+
111
+ if (spendAuthHeader) {
112
+ const signer = await verifyX402(spendAuthHeader, config.x402, nonceStore)
113
+ if (!signer) {
114
+ return c.json(
115
+ { error: { message: 'Invalid X-Payment-Signature', type: 'authentication_error', code: 'invalid_spend_auth' } },
116
+ { status: 402, headers: { 'X-Payment-Required': 'spendauth' } },
117
+ )
118
+ }
119
+ consumerId = signer
120
+ paymentMethod = 'x402'
121
+ } else if (config.mpp && authHeader.toLowerCase().startsWith('payment ')) {
122
+ const signer = await verifyMpp(authHeader, config.mpp, config.x402)
123
+ if (!signer) {
124
+ const realm = config.mpp.realm
125
+ const method = config.mpp.method ?? 'blueprintevm'
126
+ return c.json(
127
+ { error: { message: 'Invalid Payment credential', type: 'authentication_error', code: 'invalid_mpp_credential' } },
128
+ { status: 401, headers: { 'WWW-Authenticate': `Payment realm="${realm}", method="${method}"` } },
129
+ )
130
+ }
131
+ consumerId = signer
132
+ paymentMethod = 'mpp'
133
+ } else if (authHeader.startsWith('Bearer ')) {
134
+ const verify = config.verifyApiKey ?? defaultVerifyApiKey
135
+ const key = await verify(authHeader)
136
+ if (!key) {
137
+ return c.json({ error: { message: 'Invalid API key', type: 'authentication_error' } }, 401)
138
+ }
139
+
140
+ // Scope enforcement — API key must include the required scope
141
+ if (key.scopes && key.scopes.length > 0 && !key.scopes.includes(requiredScope)) {
142
+ return c.json(
143
+ { error: { message: `API key missing required scope: ${requiredScope}`, type: 'forbidden', code: 'insufficient_scope' } },
144
+ 403,
145
+ )
146
+ }
147
+
148
+ consumerId = key.consumerId
149
+ paymentMethod = 'apikey'
150
+ keyInfo = key
151
+ } else {
152
+ // No payment — return 402 with instructions
153
+ const methods: string[] = ['x402']
154
+ if (config.mpp) methods.push('mpp')
155
+ methods.push('api_key')
156
+
157
+ const headers: Record<string, string> = { 'X-Payment-Required': methods.join(', ') }
158
+ if (config.mpp) {
159
+ headers['WWW-Authenticate'] = `Payment realm="${config.mpp.realm}", method="${config.mpp.method ?? 'blueprintevm'}"`
160
+ }
161
+
162
+ return c.json({
163
+ error: {
164
+ message: 'Payment required',
165
+ type: 'payment_required',
166
+ payment_methods: methods,
167
+ x402: {
168
+ operator: config.x402.operatorAddress,
169
+ chain_id: config.x402.chainId,
170
+ credits_address: config.x402.creditsAddress,
171
+ estimated_amount_per_request: '20000',
172
+ },
173
+ ...(config.mpp ? {
174
+ mpp: { realm: config.mpp.realm, method: config.mpp.method ?? 'blueprintevm' },
175
+ } : {}),
176
+ api_key: {
177
+ purchase_url: config.baseUrl ? `${config.baseUrl}/agents/${slug}/api-keys` : undefined,
178
+ },
179
+ },
180
+ }, { status: 402, headers })
181
+ }
182
+
183
+ // 4. Rate limit — per-key override or global
184
+ const effectiveRateLimit = keyInfo?.rateLimitPerMinute
185
+ ? { limit: keyInfo.rateLimitPerMinute, windowSeconds: 60 }
186
+ : globalRateLimit
187
+
188
+ const rl = await checkRateLimit(consumerId!, effectiveRateLimit, rateLimitStore)
189
+ if (!rl.allowed) {
190
+ return c.json(
191
+ { error: { message: 'Rate limit exceeded', type: 'rate_limit_error', retry_after: rl.retryAfterSeconds } },
192
+ { status: 429, headers: { 'Retry-After': String(rl.retryAfterSeconds ?? 60) } },
193
+ )
194
+ }
195
+
196
+ // 5. Filter messages — injection detection + sanitization
197
+ const { messages: filtered, injectionWarnings } = filterConsumerMessagesStrict(body.messages, maxLen)
198
+
199
+ if (injectionWarnings.length > 0) {
200
+ // Log injection attempt
201
+ console.warn(`[agent-gateway] injection detected from ${consumerId}: ${injectionWarnings.join(', ')}`)
202
+
203
+ if (config.blockInjection) {
204
+ return c.json(
205
+ { error: { message: 'Request rejected: potential prompt injection detected', type: 'content_policy_violation' } },
206
+ 400,
207
+ )
208
+ }
209
+ // In non-blocking mode, continue but the warning is logged for auditing
210
+ }
211
+
212
+ const userMessage = filtered
213
+ .filter((m) => m.role === 'user')
214
+ .map((m) => m.content)
215
+ .join('\n\n')
216
+
217
+ if (!userMessage) {
218
+ return c.json({ error: { message: 'No user message provided', type: 'invalid_request' } }, 400)
219
+ }
220
+
221
+ // 6. Get sandbox and stream response with output filtering
222
+ let inputTokens = Math.ceil(userMessage.length / 4)
223
+ let outputTokens = 0
224
+
225
+ const stream = new ReadableStream({
226
+ async start(controller) {
227
+ const encoder = new TextEncoder()
228
+ const sendChunk = (rawDelta: string) => {
229
+ // Redact system prompt leakage from output
230
+ const delta = redactSystemPromptFromOutput(rawDelta, agent.systemPrompt)
231
+ outputTokens += Math.ceil(delta.length / 4)
232
+ const chunk: ChatCompletionChunk = {
233
+ id: `chatcmpl-${Date.now()}`,
234
+ object: 'chat.completion.chunk',
235
+ created: Math.floor(Date.now() / 1000),
236
+ model: agent.slug,
237
+ choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
238
+ }
239
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
240
+ }
241
+
242
+ try {
243
+ const box = await config.getSandbox(agent)
244
+ const promptStream = box.streamPrompt(userMessage, {
245
+ sessionId: `consumer:${consumerId}`,
246
+ systemPrompt: agent.systemPrompt,
247
+ })
248
+
249
+ for await (const event of promptStream) {
250
+ if (
251
+ event.type === 'message.part.updated' &&
252
+ event.data?.part?.type === 'text' &&
253
+ event.data.delta
254
+ ) {
255
+ sendChunk(event.data.delta)
256
+ }
257
+ }
258
+
259
+ // Final chunk
260
+ const done: ChatCompletionChunk = {
261
+ id: `chatcmpl-${Date.now()}`,
262
+ object: 'chat.completion.chunk',
263
+ created: Math.floor(Date.now() / 1000),
264
+ model: agent.slug,
265
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
266
+ }
267
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
268
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
269
+
270
+ // 7. Record usage + settle payment
271
+ const totalCost = (inputTokens + outputTokens) * agent.pricePerTokenUsd
272
+ const ownerEarned = totalCost * (1 - agent.platformFeePercent)
273
+ const platformFee = totalCost * agent.platformFeePercent
274
+
275
+ await config.recordUsage({
276
+ agentId: agent.id,
277
+ agentSlug: agent.slug,
278
+ consumerId: consumerId!,
279
+ paymentMethod,
280
+ inputTokens,
281
+ outputTokens,
282
+ totalCostUsd: totalCost,
283
+ ownerEarnedUsd: ownerEarned,
284
+ platformFeeUsd: platformFee,
285
+ durationMs: Date.now() - startMs,
286
+ })
287
+
288
+ if (config.settlePayment) {
289
+ await config.settlePayment({ method: paymentMethod, consumerId: consumerId! }, totalCost).catch(err => {
290
+ console.error(`[agent-gateway] settlement failed for ${consumerId}: ${err instanceof Error ? err.message : err}`)
291
+ })
292
+ }
293
+ } catch (err) {
294
+ // Sanitize error — never expose stack traces or internal paths
295
+ const safeMessage = err instanceof Error
296
+ ? (err.message.includes('/') || err.message.includes('\\') ? 'Internal agent error' : err.message)
297
+ : 'Internal agent error'
298
+ controller.enqueue(
299
+ encoder.encode(`data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`),
300
+ )
301
+ } finally {
302
+ controller.close()
303
+ }
304
+ },
305
+ })
306
+
307
+ return new Response(stream, {
308
+ headers: {
309
+ 'Content-Type': 'text/event-stream',
310
+ 'Cache-Control': 'no-cache',
311
+ 'X-Agent-Slug': agent.slug,
312
+ 'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',
313
+ 'X-Payment-Method': paymentMethod,
314
+ 'X-Payment-Settled': paymentMethod === 'x402' ? 'pending' : 'true',
315
+ ...(rl.remaining !== undefined ? { 'X-RateLimit-Remaining': String(rl.remaining) } : {}),
316
+ },
317
+ })
318
+ })
319
+
320
+ return gw
321
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Nonce replay protection for x402/MPP payments.
3
+ * Tracks seen nonces to prevent the same payment from being used twice.
4
+ */
5
+
6
+ export interface NonceStore {
7
+ /** Check if nonce has been seen. Returns true if already used (reject). */
8
+ hasSeen(nonce: string): Promise<boolean>
9
+ /** Mark nonce as used. TTL = how long to remember it (seconds). */
10
+ markSeen(nonce: string, ttlSeconds: number): Promise<void>
11
+ }
12
+
13
+ /** In-memory nonce store with automatic eviction */
14
+ export class MemoryNonceStore implements NonceStore {
15
+ private seen = new Map<string, number>() // nonce → expiresAt
16
+ private lastEviction = Date.now()
17
+
18
+ async hasSeen(nonce: string): Promise<boolean> {
19
+ this.evictExpired()
20
+ const expiresAt = this.seen.get(nonce)
21
+ if (!expiresAt) return false
22
+ if (expiresAt < Date.now()) {
23
+ this.seen.delete(nonce)
24
+ return false
25
+ }
26
+ return true
27
+ }
28
+
29
+ async markSeen(nonce: string, ttlSeconds: number): Promise<void> {
30
+ this.seen.set(nonce, Date.now() + ttlSeconds * 1000)
31
+ this.evictExpired()
32
+ }
33
+
34
+ private evictExpired() {
35
+ const now = Date.now()
36
+ // Evict at most every 60 seconds to avoid O(n) on every request
37
+ if (now - this.lastEviction < 60_000) return
38
+ this.lastEviction = now
39
+ for (const [nonce, expiresAt] of this.seen) {
40
+ if (expiresAt < now) this.seen.delete(nonce)
41
+ }
42
+ }
43
+ }
package/src/publish.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Publishing routes — let agent owners publish/unpublish their workspaces
3
+ * as paid API endpoints.
4
+ */
5
+
6
+ import { Hono } from 'hono'
7
+
8
+ // --- Types ---
9
+
10
+ export interface PublishedConfig {
11
+ enabled: boolean
12
+ slug: string
13
+ pricePerTokenUsd: number
14
+ platformFeePercent: number
15
+ /** Remote operator endpoint for sovereignty mode */
16
+ sandboxEndpoint?: string | null
17
+ remoteSandboxId?: string | null
18
+ remoteBearerToken?: string | null
19
+ publishedAt: string
20
+ }
21
+
22
+ export interface PublishRequest {
23
+ slug?: string
24
+ pricePerTokenUsd?: number
25
+ platformFeePercent?: number
26
+ sandboxEndpoint?: string | null
27
+ remoteSandboxId?: string | null
28
+ remoteBearerToken?: string | null
29
+ }
30
+
31
+ /** Each agent implements this against their workspace/session model */
32
+ export interface PublishStore {
33
+ /** Get current published config for a workspace/session */
34
+ getPublishedConfig(ownerId: string, resourceId: string): Promise<PublishedConfig | null>
35
+ /** Set published config */
36
+ setPublishedConfig(ownerId: string, resourceId: string, config: PublishedConfig): Promise<void>
37
+ /** Clear published config (unpublish) */
38
+ clearPublishedConfig(ownerId: string, resourceId: string): Promise<void>
39
+ /** Check the resource exists and the user owns it */
40
+ verifyOwnership(ownerId: string, resourceId: string): Promise<boolean>
41
+ }
42
+
43
+ // --- Routes ---
44
+
45
+ export interface PublishRoutesConfig {
46
+ store: PublishStore
47
+ getAuthUserId: (request: Request) => Promise<string | null>
48
+ /** Base URL for gateway endpoint display (e.g. "https://gtm.tangle.tools") */
49
+ baseUrl?: string
50
+ }
51
+
52
+ export function createPublishRoutes(config: PublishRoutesConfig) {
53
+ const router = new Hono()
54
+
55
+ // Get publish status
56
+ router.get('/:resourceId/publish', async (c) => {
57
+ const userId = await config.getAuthUserId(c.req.raw)
58
+ if (!userId) return c.json({ error: 'Unauthorized' }, 401)
59
+
60
+ const resourceId = c.req.param('resourceId')
61
+ const owns = await config.store.verifyOwnership(userId, resourceId)
62
+ if (!owns) return c.json({ error: 'Not found' }, 404)
63
+
64
+ const published = await config.store.getPublishedConfig(userId, resourceId)
65
+ return c.json({ published })
66
+ })
67
+
68
+ // Publish
69
+ router.post('/:resourceId/publish', async (c) => {
70
+ const userId = await config.getAuthUserId(c.req.raw)
71
+ if (!userId) return c.json({ error: 'Unauthorized' }, 401)
72
+
73
+ const resourceId = c.req.param('resourceId')
74
+ const owns = await config.store.verifyOwnership(userId, resourceId)
75
+ if (!owns) return c.json({ error: 'Not found' }, 404)
76
+
77
+ const body = await c.req.json<PublishRequest>()
78
+ const slug = body.slug ?? resourceId
79
+
80
+ const publishedConfig: PublishedConfig = {
81
+ enabled: true,
82
+ slug,
83
+ pricePerTokenUsd: body.pricePerTokenUsd ?? 0.00002,
84
+ platformFeePercent: body.platformFeePercent ?? 0.20,
85
+ sandboxEndpoint: body.sandboxEndpoint ?? null,
86
+ remoteSandboxId: body.remoteSandboxId ?? null,
87
+ remoteBearerToken: body.remoteBearerToken ?? null,
88
+ publishedAt: new Date().toISOString(),
89
+ }
90
+
91
+ await config.store.setPublishedConfig(userId, resourceId, publishedConfig)
92
+
93
+ const base = config.baseUrl ?? ''
94
+ return c.json({
95
+ success: true,
96
+ published: publishedConfig,
97
+ gatewayUrl: `${base}/v1/agents/${slug}/chat/completions`,
98
+ discoveryUrl: `${base}/v1/agents/${slug}/chat/completions`,
99
+ })
100
+ })
101
+
102
+ // Unpublish
103
+ router.post('/:resourceId/unpublish', async (c) => {
104
+ const userId = await config.getAuthUserId(c.req.raw)
105
+ if (!userId) return c.json({ error: 'Unauthorized' }, 401)
106
+
107
+ const resourceId = c.req.param('resourceId')
108
+ const owns = await config.store.verifyOwnership(userId, resourceId)
109
+ if (!owns) return c.json({ error: 'Not found' }, 404)
110
+
111
+ await config.store.clearPublishedConfig(userId, resourceId)
112
+ return c.json({ success: true, published: null })
113
+ })
114
+
115
+ return router
116
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Sliding window rate limiter.
3
+ * In-memory by default. Override with KV-backed store for Workers.
4
+ */
5
+
6
+ export interface RateLimitConfig {
7
+ /** Max requests per window (default: 60) */
8
+ limit: number
9
+ /** Window size in seconds (default: 60) */
10
+ windowSeconds: number
11
+ }
12
+
13
+ export interface RateLimitResult {
14
+ allowed: boolean
15
+ remaining: number
16
+ resetAt: number
17
+ retryAfterSeconds?: number
18
+ }
19
+
20
+ export interface RateLimitStore {
21
+ /** Get timestamps of recent requests for this key */
22
+ get(key: string): Promise<number[]>
23
+ /** Set timestamps for this key (with TTL) */
24
+ set(key: string, timestamps: number[], ttlSeconds: number): Promise<void>
25
+ }
26
+
27
+ /** In-memory rate limit store with periodic eviction */
28
+ export class MemoryRateLimitStore implements RateLimitStore {
29
+ private store = new Map<string, { timestamps: number[]; expiresAt: number }>()
30
+ private lastEviction = Date.now()
31
+
32
+ async get(key: string): Promise<number[]> {
33
+ this.evictExpired()
34
+ const entry = this.store.get(key)
35
+ if (!entry || entry.expiresAt < Date.now()) {
36
+ this.store.delete(key)
37
+ return []
38
+ }
39
+ return entry.timestamps
40
+ }
41
+
42
+ async set(key: string, timestamps: number[], ttlSeconds: number): Promise<void> {
43
+ this.store.set(key, { timestamps, expiresAt: Date.now() + ttlSeconds * 1000 })
44
+ }
45
+
46
+ private evictExpired() {
47
+ const now = Date.now()
48
+ if (now - this.lastEviction < 30_000) return
49
+ this.lastEviction = now
50
+ for (const [key, entry] of this.store) {
51
+ if (entry.expiresAt < now) this.store.delete(key)
52
+ }
53
+ }
54
+ }
55
+
56
+ export async function checkRateLimit(
57
+ consumerId: string,
58
+ config: RateLimitConfig,
59
+ store: RateLimitStore,
60
+ ): Promise<RateLimitResult> {
61
+ const now = Date.now()
62
+ const windowMs = config.windowSeconds * 1000
63
+ const cutoff = now - windowMs
64
+
65
+ const key = `rl:${consumerId}`
66
+ const timestamps = (await store.get(key)).filter(t => t > cutoff)
67
+
68
+ if (timestamps.length >= config.limit) {
69
+ const oldestInWindow = Math.min(...timestamps)
70
+ const resetAt = oldestInWindow + windowMs
71
+ return {
72
+ allowed: false,
73
+ remaining: 0,
74
+ resetAt,
75
+ retryAfterSeconds: Math.ceil((resetAt - now) / 1000),
76
+ }
77
+ }
78
+
79
+ timestamps.push(now)
80
+ await store.set(key, timestamps, config.windowSeconds * 2)
81
+
82
+ return {
83
+ allowed: true,
84
+ remaining: config.limit - timestamps.length,
85
+ resetAt: now + windowMs,
86
+ }
87
+ }