@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/README.md +37 -0
- package/dist/api-keys.d.ts +63 -0
- package/dist/api-keys.js +9 -0
- package/dist/api-keys.js.map +1 -0
- package/dist/chunk-4FULF5LW.js +55 -0
- package/dist/chunk-4FULF5LW.js.map +1 -0
- package/dist/chunk-5O75YDQP.js +89 -0
- package/dist/chunk-5O75YDQP.js.map +1 -0
- package/dist/chunk-5ZJPIIIV.js +56 -0
- package/dist/chunk-5ZJPIIIV.js.map +1 -0
- package/dist/chunk-EMGS63QE.js +398 -0
- package/dist/chunk-EMGS63QE.js.map +1 -0
- package/dist/chunk-Z22ALGHW.js +33 -0
- package/dist/chunk-Z22ALGHW.js.map +1 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +19 -0
- package/dist/middleware.js +9 -0
- package/dist/middleware.js.map +1 -0
- package/dist/nonce-store.d.ts +20 -0
- package/dist/nonce-store.js +7 -0
- package/dist/nonce-store.js.map +1 -0
- package/dist/publish.d.ts +42 -0
- package/dist/publish.js +7 -0
- package/dist/publish.js.map +1 -0
- package/dist/rate-limit.d.ts +33 -0
- package/dist/rate-limit.js +9 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
- package/src/api-keys.ts +187 -0
- package/src/filter.ts +138 -0
- package/src/index.ts +49 -0
- package/src/middleware.ts +321 -0
- package/src/nonce-store.ts +43 -0
- package/src/publish.ts +116 -0
- package/src/rate-limit.ts +87 -0
- package/src/types.ts +177 -0
- package/src/verify.ts +112 -0
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
|
+
}
|