tapimo 0.15.0 → 0.15.1
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/dist/App.d.ts +26 -26
- package/dist/admin/App.d.ts +3 -0
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +41 -41
- package/dist/admin/apps/routes.d.ts +64 -64
- package/dist/admin/apps/verified-tokens.d.ts +161 -155
- package/dist/admin/apps/verified-tokens.d.ts.map +1 -1
- package/dist/admin/apps/verified-tokens.js +24 -4
- package/dist/admin/apps/verified-tokens.js.map +1 -1
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.d.ts.map +1 -1
- package/dist/admin/ui.generated.js +201 -209
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +357 -357
- package/dist/apps/data/routes/blocks.d.ts +125 -125
- package/dist/apps/data/routes/fee-amm.d.ts +108 -108
- package/dist/apps/data/routes/fee-amm.d.ts.map +1 -1
- package/dist/apps/data/routes/fee-amm.js +10 -2
- package/dist/apps/data/routes/fee-amm.js.map +1 -1
- package/dist/apps/data/routes/indexer.d.ts +43 -43
- package/dist/apps/data/routes/receipts.d.ts +235 -235
- package/dist/apps/data/routes/rpc.js +45 -11
- package/dist/apps/data/routes/rpc.js.map +1 -1
- package/dist/apps/data/routes/transfers.d.ts +81 -81
- package/dist/apps/data/routes/valuation.d.ts +32 -32
- package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
- package/dist/apps/data/routes/webhooks.d.ts +1 -1
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +17 -10
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/data/routes/zones.d.ts +31 -31
- package/dist/apps/management/App.d.ts +26 -26
- package/dist/apps/management/routes/api-keys.d.ts +246 -246
- package/dist/apps/management/routes/invitations.d.ts +138 -138
- package/dist/apps/management/routes/invite-links.d.ts +168 -168
- package/dist/apps/management/routes/orgs.d.ts +98 -98
- package/dist/apps/management/routes/projects.d.ts +125 -125
- package/dist/apps/management/routes/verified-token-requests.d.ts +95 -95
- package/dist/apps/management/routes/verified-token-requests.d.ts.map +1 -1
- package/dist/apps/management/routes/verified-token-requests.js +7 -9
- package/dist/apps/management/routes/verified-token-requests.js.map +1 -1
- package/dist/apps/management/routes/webhooks.d.ts +423 -423
- package/dist/apps/mcp.d.ts.map +1 -1
- package/dist/apps/mcp.js +16 -3
- package/dist/apps/mcp.js.map +1 -1
- package/dist/apps/mpp/App.d.ts +44 -44
- package/dist/apps/routes/routes/chains.d.ts +25 -25
- package/dist/apps/routes/routes/quotes.d.ts +87 -87
- package/dist/apps/routes/routes/transfers.d.ts +438 -438
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +5 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/tables/verifiedTokenRequests.d.ts +21 -4
- package/dist/db/tables/verifiedTokenRequests.d.ts.map +1 -1
- package/dist/db/tables/verifiedTokenRequests.js +65 -44
- package/dist/db/tables/verifiedTokenRequests.js.map +1 -1
- package/dist/internal/Auth.d.ts +16 -0
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +31 -8
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/routes/providers/relay.d.ts +1 -1
- package/dist/internal/routes/providers/relay.js +1 -1
- package/dist/internal/routes/providers/relay.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/apps/verified-tokens.test.ts +32 -0
- package/src/admin/apps/verified-tokens.ts +30 -4
- package/src/admin/ui/src/lib/verified-tokens.ts +24 -9
- package/src/admin/ui/src/routes/__root.tsx +1 -1
- package/src/admin/ui/src/routes/organizations.tsx +34 -4
- package/src/admin/ui/src/routes/verified-tokens.tsx +64 -20
- package/src/admin/ui.generated.ts +202 -263
- package/src/apps/data/App.test.ts +26 -0
- package/src/apps/data/routes/fee-amm.test.ts +19 -0
- package/src/apps/data/routes/fee-amm.ts +10 -2
- package/src/apps/data/routes/rpc.test.ts +32 -0
- package/src/apps/data/routes/rpc.ts +47 -10
- package/src/apps/data/routes/webhooks.scan.test.ts +29 -0
- package/src/apps/data/routes/webhooks.test.ts +6 -0
- package/src/apps/data/routes/webhooks.ts +25 -12
- package/src/apps/management/routes/verified-token-requests.test.ts +16 -2
- package/src/apps/management/routes/verified-token-requests.ts +7 -10
- package/src/apps/mcp.test.ts +108 -25
- package/src/apps/mcp.ts +15 -3
- package/src/db/Db.ts +9 -0
- package/src/db/tables/routesDeposits.test.ts +72 -0
- package/src/db/tables/verifiedTokenRequests.test.ts +39 -20
- package/src/db/tables/verifiedTokenRequests.ts +80 -44
- package/src/internal/Auth.test.ts +92 -0
- package/src/internal/Auth.ts +51 -9
- package/src/internal/routes/providers/relay.test.ts +59 -0
- package/src/internal/routes/providers/relay.ts +1 -1
|
@@ -153,6 +153,32 @@ describe('MPP session management', () => {
|
|
|
153
153
|
)
|
|
154
154
|
})
|
|
155
155
|
|
|
156
|
+
test('keeps GET quota controls off generated management routes', async () => {
|
|
157
|
+
const rateLimitStore = Store.memory()
|
|
158
|
+
const limit = { limit: 1, period: 'minute' } satisfies RateLimit.Limit
|
|
159
|
+
const app = TestApp.create({
|
|
160
|
+
auth: {
|
|
161
|
+
mpp,
|
|
162
|
+
overrides: {
|
|
163
|
+
'GET /v1/scopes': { mpp: { bucket: 'scopes', rateLimit: limit } },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
rateLimit: { store: rateLimitStore },
|
|
167
|
+
})
|
|
168
|
+
await RateLimit.memory({ store: rateLimitStore }).consume({
|
|
169
|
+
key: 'mpp:public:anonymous:bucket:scopes',
|
|
170
|
+
limit,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const response = await app.request('/v1/scopes', {
|
|
174
|
+
headers: { authorization: 'Payment not-a-credential' },
|
|
175
|
+
method: 'POST',
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
expect(response.status).toBe(402)
|
|
179
|
+
expect(response.headers.get('RateLimit-Limit')).toBe('100')
|
|
180
|
+
})
|
|
181
|
+
|
|
156
182
|
test('applies POST auth overrides to QUERY aliases', async () => {
|
|
157
183
|
const app = TestApp.create({
|
|
158
184
|
auth: {
|
|
@@ -428,6 +428,25 @@ describe.skip('GET /fee-amm/pools', () => {
|
|
|
428
428
|
})
|
|
429
429
|
|
|
430
430
|
describe('GET /fee-amm/pools query', () => {
|
|
431
|
+
test('applies the protective limit to public and API-key access', async () => {
|
|
432
|
+
const app = TestApp.create()
|
|
433
|
+
const publicResponse = await app.request('/v1/fee-amm/pools?include=token')
|
|
434
|
+
const publicOverflow = await app.request('/v1/fee-amm/pools?include=token')
|
|
435
|
+
const apiKeyResponse = await app.request('/v1/fee-amm/pools?include=token', TestApp.auth)
|
|
436
|
+
const apiKeyOverflow = await app.request('/v1/fee-amm/pools?include=token', TestApp.auth)
|
|
437
|
+
|
|
438
|
+
expect([
|
|
439
|
+
publicResponse.status,
|
|
440
|
+
publicOverflow.status,
|
|
441
|
+
apiKeyResponse.status,
|
|
442
|
+
apiKeyOverflow.status,
|
|
443
|
+
]).toEqual([400, 402, 400, 402])
|
|
444
|
+
expect([
|
|
445
|
+
publicResponse.headers.get('RateLimit-Limit'),
|
|
446
|
+
apiKeyResponse.headers.get('RateLimit-Limit'),
|
|
447
|
+
]).toEqual(['1', '1'])
|
|
448
|
+
})
|
|
449
|
+
|
|
431
450
|
test('rejects the legacy token include', async () => {
|
|
432
451
|
const response = await TestApp.create().request('/v1/fee-amm/pools?include=token', {
|
|
433
452
|
headers: TestApp.auth.headers,
|
|
@@ -319,7 +319,15 @@ export function feeAmm() {
|
|
|
319
319
|
return new Hono<App.Environment>()
|
|
320
320
|
.get(
|
|
321
321
|
'/v1/fee-amm/pools',
|
|
322
|
-
Auth.policy({
|
|
322
|
+
Auth.policy({
|
|
323
|
+
apiKey: {
|
|
324
|
+
bucket: 'fee-amm-pools',
|
|
325
|
+
rateLimit: { limit: 1, period: 'minute' },
|
|
326
|
+
scopes: ['data:read'],
|
|
327
|
+
},
|
|
328
|
+
mpp: { bucket: 'fee-amm-pools', rateLimit: { limit: 1, period: 'minute' } },
|
|
329
|
+
public: { bucket: 'fee-amm-pools', rateLimit: { limit: 1, period: 'minute' } },
|
|
330
|
+
}),
|
|
323
331
|
OpenApi.validate('query', schema.getFeeAmmPools.Query, {
|
|
324
332
|
code: 'query_invalid',
|
|
325
333
|
message: 'Invalid query parameters',
|
|
@@ -344,7 +352,7 @@ export function feeAmm() {
|
|
|
344
352
|
tags: ['Fee AMM'],
|
|
345
353
|
}),
|
|
346
354
|
Cache.response<App.Environment>({
|
|
347
|
-
cacheControl: Cache.policies.
|
|
355
|
+
cacheControl: Cache.policies.noStore,
|
|
348
356
|
name: 'tempo-api:fee-amm-pools:v2',
|
|
349
357
|
key: (c) => Cache.urlKey(c, schema.getFeeAmmPools.Query),
|
|
350
358
|
}),
|
|
@@ -144,6 +144,38 @@ describe('POST /rpc', () => {
|
|
|
144
144
|
}
|
|
145
145
|
})
|
|
146
146
|
|
|
147
|
+
test('rejects oversized anonymous requests before proxying upstream', async () => {
|
|
148
|
+
const requests: Request[] = []
|
|
149
|
+
const server = await Relay.createServer(
|
|
150
|
+
RequestListener.fromFetchHandler((request) => {
|
|
151
|
+
requests.push(request)
|
|
152
|
+
return Response.json({ id: 1, jsonrpc: '2.0', result: '0x1' })
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const app = TestApp.create({ rpc: { url: server.url } })
|
|
158
|
+
const request = new Request('http://tempo-api.test/rpc', {
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
id: 1,
|
|
161
|
+
jsonrpc: '2.0',
|
|
162
|
+
method: 'eth_call',
|
|
163
|
+
params: [`0x${'ab'.repeat(32_768)}`, 'latest'],
|
|
164
|
+
}),
|
|
165
|
+
method: 'POST',
|
|
166
|
+
})
|
|
167
|
+
expect(request.headers.get('content-length')).toBeNull()
|
|
168
|
+
|
|
169
|
+
const response = await app.fetch(request)
|
|
170
|
+
|
|
171
|
+
expect(response.status).toBe(413)
|
|
172
|
+
expect(await response.json()).toMatchObject({ error: { code: 'payload_too_large' } })
|
|
173
|
+
expect(requests).toHaveLength(0)
|
|
174
|
+
} finally {
|
|
175
|
+
await server.closeAsync()
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
147
179
|
test('routes by principal: a resolver sees the request principal and routes accordingly', async () => {
|
|
148
180
|
const fetch = mockRpc()
|
|
149
181
|
// A resolver that routes API-key principals to the private upstream and
|
|
@@ -278,9 +278,8 @@ async function handleRpc(c: Context<App.Environment>) {
|
|
|
278
278
|
const zoneToken = getZoneToken(c)
|
|
279
279
|
const apiKeyRequired = requiresApiKey(c)
|
|
280
280
|
const inspectBody = apiKeyRequired || Boolean(zone)
|
|
281
|
-
const inspection = inspectBody ? await
|
|
281
|
+
const inspection = inspectBody ? await inspectRpcRequest(c.req.raw) : undefined
|
|
282
282
|
if (inspection && inspection.result === undefined) {
|
|
283
|
-
await inspection.body?.cancel()
|
|
284
283
|
return Response.error(c, {
|
|
285
284
|
code: 'payload_too_large',
|
|
286
285
|
message: 'RPC requests requiring method inspection must not exceed 64 KiB.',
|
|
@@ -469,6 +468,38 @@ async function inspectNotificationOnly(request: Request): Promise<boolean> {
|
|
|
469
468
|
}
|
|
470
469
|
}
|
|
471
470
|
|
|
471
|
+
async function inspectRpcRequest(request: Request): Promise<RpcInspection> {
|
|
472
|
+
const contentLength = Number(request.headers.get('content-length'))
|
|
473
|
+
if (Number.isFinite(contentLength) && contentLength > maxRpcInspectionBytes) {
|
|
474
|
+
await request.body?.cancel()
|
|
475
|
+
return { body: null, result: undefined }
|
|
476
|
+
}
|
|
477
|
+
if (!request.body) return { body: null, result: { empty: true, ok: false } }
|
|
478
|
+
|
|
479
|
+
const reader = request.body.getReader()
|
|
480
|
+
const chunks: Uint8Array<ArrayBuffer>[] = []
|
|
481
|
+
let size = 0
|
|
482
|
+
while (size <= maxRpcInspectionBytes) {
|
|
483
|
+
const read = await reader.read()
|
|
484
|
+
if (read.done) break
|
|
485
|
+
size += read.value.byteLength
|
|
486
|
+
if (size > maxRpcInspectionBytes) {
|
|
487
|
+
await reader.cancel()
|
|
488
|
+
return { body: null, result: undefined }
|
|
489
|
+
}
|
|
490
|
+
chunks.push(read.value)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const bytes = joinChunks(chunks, size)
|
|
494
|
+
const body = new ReadableStream<Uint8Array<ArrayBuffer>>({
|
|
495
|
+
start(controller) {
|
|
496
|
+
controller.enqueue(bytes)
|
|
497
|
+
controller.close()
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
return { body, result: parseRpcBody(bytes) }
|
|
501
|
+
}
|
|
502
|
+
|
|
472
503
|
function hasZoneWriteScope(c: Context<App.Environment>, chainId: number): boolean {
|
|
473
504
|
const principal = Auth.getPrincipal(c)
|
|
474
505
|
if (principal?.type !== 'api_key') return false
|
|
@@ -712,20 +743,26 @@ async function inspectRpcBody(response: Request | globalThis.Response): Promise<
|
|
|
712
743
|
})
|
|
713
744
|
if (!complete || size > maxRpcInspectionBytes) return { body, result: undefined }
|
|
714
745
|
|
|
746
|
+
const bytes = joinChunks(chunks, size)
|
|
747
|
+
return { body, result: parseRpcBody(bytes) }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function joinChunks(chunks: readonly Uint8Array<ArrayBuffer>[], size: number) {
|
|
715
751
|
const bytes = new Uint8Array(size)
|
|
716
752
|
let offset = 0
|
|
717
753
|
for (const chunk of chunks) {
|
|
718
754
|
bytes.set(chunk, offset)
|
|
719
755
|
offset += chunk.byteLength
|
|
720
756
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
757
|
+
return bytes
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function parseRpcBody(bytes: Uint8Array<ArrayBuffer>): RpcInspection['result'] {
|
|
761
|
+
try {
|
|
762
|
+
return { ok: true, value: JSON.parse(new TextDecoder().decode(bytes)) as unknown }
|
|
763
|
+
} catch {
|
|
764
|
+
return { empty: bytes.byteLength === 0, ok: false }
|
|
765
|
+
}
|
|
729
766
|
}
|
|
730
767
|
|
|
731
768
|
function rpcProviderFailure(cause: unknown, chainId: number): Log.ProviderFailure {
|
|
@@ -239,6 +239,35 @@ describe('scanBlock', () => {
|
|
|
239
239
|
expect(invalid.invalid).toHaveLength(1)
|
|
240
240
|
})
|
|
241
241
|
|
|
242
|
+
test('delivers an anonymous LOG0 event matched by address', () => {
|
|
243
|
+
const result = AppWebhooks.scanBlock({
|
|
244
|
+
block: block(),
|
|
245
|
+
logs: [customLog({ topics: [] })],
|
|
246
|
+
subscriptions: [subscription('log:emitted', { address: callTarget })],
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
expect(result.invalid).toEqual([])
|
|
250
|
+
expect(result.matches).toHaveLength(1)
|
|
251
|
+
expect(result.matches[0]?.data).toMatchObject({
|
|
252
|
+
address: callTarget,
|
|
253
|
+
data: `0x${'0'.repeat(63)}1`,
|
|
254
|
+
logIndex: 7,
|
|
255
|
+
topics: [],
|
|
256
|
+
transactionHash,
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('rejects raw logs with malformed topics', () => {
|
|
261
|
+
const result = AppWebhooks.scanBlock({
|
|
262
|
+
block: block(),
|
|
263
|
+
logs: [customLog({ topics: ['invalid'] })],
|
|
264
|
+
subscriptions: [subscription('log:emitted', { address: callTarget })],
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
expect(result.matches).toEqual([])
|
|
268
|
+
expect(result.invalid).toEqual([])
|
|
269
|
+
})
|
|
270
|
+
|
|
242
271
|
test('rolls back partial matches when a matcher throws mid-block', () => {
|
|
243
272
|
const signature = 'event Named(string value)'
|
|
244
273
|
const namedTopic = encodeEventTopics({ abi: [parseAbiItem(signature)] })[0]
|
|
@@ -2144,6 +2144,12 @@ describe('matchesOperator', () => {
|
|
|
2144
2144
|
expect(Webhooks.matchesOperator('0x200', op, 'number')).toBe(false)
|
|
2145
2145
|
})
|
|
2146
2146
|
|
|
2147
|
+
test('compare accepts decoded integer values', () => {
|
|
2148
|
+
const op = { gte: '100', lte: '200' }
|
|
2149
|
+
expect(Webhooks.matchesOperator(150n, op, 'number')).toBe(true)
|
|
2150
|
+
expect(Webhooks.matchesOperator(201, op, 'number')).toBe(false)
|
|
2151
|
+
})
|
|
2152
|
+
|
|
2147
2153
|
test('bytes selector matches the first four bytes', () => {
|
|
2148
2154
|
expect(Webhooks.matchesOperator('0xa9059cbbdeadbeef', { selector: '0xa9059cbb' })).toBe(true)
|
|
2149
2155
|
expect(Webhooks.matchesOperator('0x12345678deadbeef', { selector: '0xa9059cbb' })).toBe(false)
|
|
@@ -908,9 +908,12 @@ export namespace schema {
|
|
|
908
908
|
z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }),
|
|
909
909
|
),
|
|
910
910
|
topics: z.array(Schema.Hash).check(
|
|
911
|
-
z.describe(
|
|
911
|
+
z.describe(
|
|
912
|
+
'Zero to four log topics; non-anonymous events start with the event signature hash.',
|
|
913
|
+
),
|
|
912
914
|
z.meta({
|
|
913
915
|
examples: [
|
|
916
|
+
[],
|
|
914
917
|
[
|
|
915
918
|
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
|
|
916
919
|
'0x000000000000000000000000e7687128b0a808c2831ff94d4f7b2fb35c65af38',
|
|
@@ -2421,10 +2424,14 @@ function serializeArgs(value: unknown): unknown {
|
|
|
2421
2424
|
const toBig = (value: unknown) => BigInt(String(value))
|
|
2422
2425
|
|
|
2423
2426
|
/** Case-insensitive equality for string fields; bigint equality for numeric. */
|
|
2424
|
-
function operandEquals(
|
|
2427
|
+
function operandEquals(
|
|
2428
|
+
value: bigint | number | string,
|
|
2429
|
+
target: unknown,
|
|
2430
|
+
kind: 'number' | 'string',
|
|
2431
|
+
): boolean {
|
|
2425
2432
|
return kind === 'number'
|
|
2426
2433
|
? toBig(value) === toBig(target)
|
|
2427
|
-
: value.toLowerCase() === String(target).toLowerCase()
|
|
2434
|
+
: String(value).toLowerCase() === String(target).toLowerCase()
|
|
2428
2435
|
}
|
|
2429
2436
|
|
|
2430
2437
|
/**
|
|
@@ -2442,17 +2449,18 @@ function operandEquals(value: string, target: unknown, kind: 'number' | 'string'
|
|
|
2442
2449
|
* - `{ startsWith }` → hex prefix
|
|
2443
2450
|
*/
|
|
2444
2451
|
export function matchesOperator(
|
|
2445
|
-
value: string | undefined,
|
|
2452
|
+
value: bigint | number | string | undefined,
|
|
2446
2453
|
op: unknown,
|
|
2447
2454
|
kind: 'number' | 'string' = 'string',
|
|
2448
2455
|
): boolean {
|
|
2449
2456
|
if (value === undefined) return false
|
|
2450
2457
|
if (typeof op !== 'object' || op === null) return operandEquals(value, op, kind)
|
|
2451
2458
|
const o = op as Record<string, unknown>
|
|
2459
|
+
const value_string = String(value)
|
|
2452
2460
|
if ('selector' in o)
|
|
2453
|
-
return
|
|
2461
|
+
return value_string.slice(0, 10).toLowerCase() === String(o['selector']).toLowerCase()
|
|
2454
2462
|
if ('startsWith' in o)
|
|
2455
|
-
return
|
|
2463
|
+
return value_string.toLowerCase().startsWith(String(o['startsWith']).toLowerCase())
|
|
2456
2464
|
if ('in' in o) return (o['in'] as readonly unknown[]).some((v) => operandEquals(value, v, kind))
|
|
2457
2465
|
if ('not' in o) return !operandEquals(value, o['not'], kind)
|
|
2458
2466
|
if ('eq' in o && kind === 'string') return operandEquals(value, o['eq'], kind)
|
|
@@ -2535,13 +2543,12 @@ function toLogEvent(
|
|
|
2535
2543
|
const blockNumber = log.blockNumber === null ? undefined : Number(log.blockNumber)
|
|
2536
2544
|
const logIndex = log.logIndex ?? undefined
|
|
2537
2545
|
const transactionIndex = log.transactionIndex ?? undefined
|
|
2538
|
-
const topics = log.topics
|
|
2539
|
-
const topic0 = Schema.Hash.safeParse(topics[0])
|
|
2546
|
+
const topics = z.array(Schema.Hash).safeParse(log.topics)
|
|
2540
2547
|
const data = typeof log.data === 'string' ? log.data : '0x'
|
|
2541
2548
|
if (
|
|
2542
2549
|
!address.success ||
|
|
2543
2550
|
!transactionHash.success ||
|
|
2544
|
-
!
|
|
2551
|
+
!topics.success ||
|
|
2545
2552
|
blockNumber === undefined ||
|
|
2546
2553
|
logIndex === undefined ||
|
|
2547
2554
|
transactionIndex === undefined ||
|
|
@@ -2555,15 +2562,17 @@ function toLogEvent(
|
|
|
2555
2562
|
data,
|
|
2556
2563
|
logIndex,
|
|
2557
2564
|
timestamp,
|
|
2558
|
-
topics,
|
|
2565
|
+
topics: topics.data,
|
|
2559
2566
|
transactionHash: transactionHash.data,
|
|
2560
2567
|
transactionIndex,
|
|
2561
2568
|
}
|
|
2562
2569
|
if (!signature || !event || log.args === undefined) return base
|
|
2570
|
+
const topic0 = topics.data[0]
|
|
2571
|
+
if (!topic0) return undefined
|
|
2563
2572
|
return {
|
|
2564
2573
|
...base,
|
|
2565
2574
|
args: serializeArgs(log.args) as Record<string, unknown>,
|
|
2566
|
-
event: { name: event.name, signature, topic0
|
|
2575
|
+
event: { name: event.name, signature, topic0 },
|
|
2567
2576
|
}
|
|
2568
2577
|
}
|
|
2569
2578
|
|
|
@@ -2736,7 +2745,11 @@ function matchesLog(
|
|
|
2736
2745
|
const numeric = /^u?int/.test(input.type)
|
|
2737
2746
|
const value = args[name]
|
|
2738
2747
|
const normalized = input.type === 'address' ? lowercaseLeaves(op) : op
|
|
2739
|
-
|
|
2748
|
+
const operand =
|
|
2749
|
+
typeof value === 'bigint' || typeof value === 'number' || typeof value === 'string'
|
|
2750
|
+
? value
|
|
2751
|
+
: undefined
|
|
2752
|
+
if (!matchesOperator(operand, normalized, numeric ? 'number' : 'string')) return false
|
|
2740
2753
|
}
|
|
2741
2754
|
}
|
|
2742
2755
|
return true
|
|
@@ -247,10 +247,18 @@ describe('POST /v1/orgs/:orgId/verified-token-requests', () => {
|
|
|
247
247
|
expect(body.error?.code).toBe('request_pending_exists')
|
|
248
248
|
})
|
|
249
249
|
|
|
250
|
-
test('caps pending requests per organization', async () => {
|
|
250
|
+
test('caps pending requests per requester after an organization is deleted', async () => {
|
|
251
251
|
const context = createApp()
|
|
252
252
|
const requests = context.client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['verified-token-requests']
|
|
253
253
|
const { org, owner } = await setup(context)
|
|
254
|
+
const nextResponse = await context.client.v1.orgs.$post(
|
|
255
|
+
{ json: { name: 'Next' } },
|
|
256
|
+
as(owner.cookie),
|
|
257
|
+
)
|
|
258
|
+
expect(nextResponse.status).toBe(200)
|
|
259
|
+
if (nextResponse.status !== 200)
|
|
260
|
+
throw new Error(`Failed to create organization: ${nextResponse.status}`)
|
|
261
|
+
const next = await nextResponse.json()
|
|
254
262
|
|
|
255
263
|
for (let index = 0; index < 10; index++) {
|
|
256
264
|
const created = await requests.$post(
|
|
@@ -267,10 +275,16 @@ describe('POST /v1/orgs/:orgId/verified-token-requests', () => {
|
|
|
267
275
|
expect(created.status).toBe(200)
|
|
268
276
|
}
|
|
269
277
|
|
|
278
|
+
const deleted = await context.client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}'].$delete(
|
|
279
|
+
{ param: { orgId: org.id } },
|
|
280
|
+
as(owner.cookie),
|
|
281
|
+
)
|
|
282
|
+
expect(deleted.status).toBe(200)
|
|
283
|
+
|
|
270
284
|
const overflow = await requests.$post(
|
|
271
285
|
{
|
|
272
286
|
json: { ...token, address: `0x20c${'f'.padStart(37, '0')}` as `0x${string}`, symbol: 'tOVER' }, // prettier-ignore
|
|
273
|
-
param: { orgId:
|
|
287
|
+
param: { orgId: next.id },
|
|
274
288
|
},
|
|
275
289
|
as(owner.cookie),
|
|
276
290
|
)
|
|
@@ -239,9 +239,6 @@ const paramValidation = {
|
|
|
239
239
|
message: 'Check the path parameters and try again.',
|
|
240
240
|
} as const
|
|
241
241
|
|
|
242
|
-
/** Maximum open (pending) requests per organization. */
|
|
243
|
-
const maxPending = 10
|
|
244
|
-
|
|
245
242
|
/** Mounts organization verified-token request operations. */
|
|
246
243
|
export function verifiedTokenRequests() {
|
|
247
244
|
return new Hono<Environment>()
|
|
@@ -265,7 +262,7 @@ export function verifiedTokenRequests() {
|
|
|
265
262
|
},
|
|
266
263
|
409: {
|
|
267
264
|
codes: ['request_limit_reached', 'request_pending_exists', 'verified_symbol_exists', 'verified_token_exists'], // prettier-ignore
|
|
268
|
-
description: 'The token is already verified or requested, or the
|
|
265
|
+
description: 'The token is already verified or requested, or the requester is at the pending-request limit.', // prettier-ignore
|
|
269
266
|
},
|
|
270
267
|
},
|
|
271
268
|
success: { description: 'Created request.', schema: schema.VerifiedTokenRequest },
|
|
@@ -297,12 +294,6 @@ export function verifiedTokenRequests() {
|
|
|
297
294
|
message: 'A verified token already uses this symbol',
|
|
298
295
|
status: 409,
|
|
299
296
|
})
|
|
300
|
-
if ((await VerifiedTokenRequests.countPending(db, orgId)) >= maxPending)
|
|
301
|
-
return Response.error(c, {
|
|
302
|
-
code: 'request_limit_reached',
|
|
303
|
-
message: `Organizations may have at most ${maxPending} pending requests`,
|
|
304
|
-
status: 409,
|
|
305
|
-
})
|
|
306
297
|
const record = await VerifiedTokenRequests.create(db, {
|
|
307
298
|
address: body.address,
|
|
308
299
|
chainId: body.chainId,
|
|
@@ -330,6 +321,12 @@ export function verifiedTokenRequests() {
|
|
|
330
321
|
200,
|
|
331
322
|
)
|
|
332
323
|
} catch (cause) {
|
|
324
|
+
if (cause instanceof VerifiedTokenRequests.PendingLimitError)
|
|
325
|
+
return Response.error(c, {
|
|
326
|
+
code: 'request_limit_reached',
|
|
327
|
+
message: `Requesters may have at most ${VerifiedTokenRequests.maxPendingPerRequester} pending requests`,
|
|
328
|
+
status: 409,
|
|
329
|
+
})
|
|
333
330
|
return Response.upstream(c, cause)
|
|
334
331
|
}
|
|
335
332
|
},
|
package/src/apps/mcp.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Cli as incur_Cli, z } from 'incur'
|
|
2
2
|
import { data } from 'tapimo/apps'
|
|
3
|
-
import { App } from 'tapimo/server'
|
|
3
|
+
import { App, Store } from 'tapimo/server'
|
|
4
4
|
|
|
5
5
|
import * as TestApp from '../../test/App.js'
|
|
6
6
|
import * as Runtime from '../../test/runtime.js'
|
|
@@ -42,32 +42,36 @@ function docsStub() {
|
|
|
42
42
|
type Host = { fetch: (request: Request) => Response | Promise<Response> }
|
|
43
43
|
|
|
44
44
|
async function request(host: Host, body: unknown, headers: Record<string, string> = {}) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
)
|
|
45
|
+
const request = new Request(new URL('/mcp', origin), {
|
|
46
|
+
body: JSON.stringify(body),
|
|
47
|
+
headers: {
|
|
48
|
+
accept: 'application/json, text/event-stream',
|
|
49
|
+
'content-type': 'application/json',
|
|
50
|
+
...headers,
|
|
51
|
+
},
|
|
52
|
+
method: 'POST',
|
|
53
|
+
})
|
|
54
|
+
if (request.headers.has('cf-connecting-ip')) Object.assign(request, { cf: {} })
|
|
55
|
+
const response = await host.fetch(request)
|
|
56
56
|
expect(response.status).toBe(200)
|
|
57
57
|
return (await response.json()) as { result: any }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function initialize(host: Host) {
|
|
61
|
-
return request(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
function initialize(host: Host, headers: Record<string, string> = {}) {
|
|
61
|
+
return request(
|
|
62
|
+
host,
|
|
63
|
+
{
|
|
64
|
+
id: 1,
|
|
65
|
+
jsonrpc: '2.0',
|
|
66
|
+
method: 'initialize',
|
|
67
|
+
params: {
|
|
68
|
+
capabilities: {},
|
|
69
|
+
clientInfo: { name: 'test-client', version: '1.0.0' },
|
|
70
|
+
protocolVersion: '2025-03-26',
|
|
71
|
+
},
|
|
69
72
|
},
|
|
70
|
-
|
|
73
|
+
headers,
|
|
74
|
+
)
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
async function toolsList(host: Host) {
|
|
@@ -109,8 +113,8 @@ async function toolsSearch(host: Host) {
|
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
/** Loads the complete schema and metadata for one catalog tool. */
|
|
112
|
-
async function toolDetails(host: Host, name: string) {
|
|
113
|
-
return resultJson(await toolsCall(host, 'get_tool_details', { name })) as {
|
|
116
|
+
async function toolDetails(host: Host, name: string, headers: Record<string, string> = {}) {
|
|
117
|
+
return resultJson(await toolsCall(host, 'get_tool_details', { name }, headers)) as {
|
|
114
118
|
annotations?: { readOnlyHint?: boolean }
|
|
115
119
|
inputSchema: { properties?: Record<string, { description?: string }> }
|
|
116
120
|
name: string
|
|
@@ -124,7 +128,7 @@ async function generatedToolCall(
|
|
|
124
128
|
args: Record<string, unknown>,
|
|
125
129
|
headers: Record<string, string> = {},
|
|
126
130
|
) {
|
|
127
|
-
const details = await toolDetails(host, name)
|
|
131
|
+
const details = await toolDetails(host, name, headers)
|
|
128
132
|
return toolsCall(
|
|
129
133
|
host,
|
|
130
134
|
details.annotations?.readOnlyHint === true ? 'call_read_tool' : 'call_write_tool',
|
|
@@ -246,6 +250,85 @@ test('forwards the caller Authorization header to authenticated tools', async ()
|
|
|
246
250
|
expect(resultJson(allowed)).toMatchObject({ data: [] })
|
|
247
251
|
})
|
|
248
252
|
|
|
253
|
+
test('preserves Cloudflare client identity across concurrent generated tools', async () => {
|
|
254
|
+
const store = Store.memory()
|
|
255
|
+
const db = TestApp.database()
|
|
256
|
+
await TestApp.verifiedSeed(db, Runtime.get().chainId)
|
|
257
|
+
const app = App.create({
|
|
258
|
+
auth: { rateLimit: { store } },
|
|
259
|
+
cache: { edge: false },
|
|
260
|
+
db,
|
|
261
|
+
}).route('/', data())
|
|
262
|
+
app.route('/', mcp({ docs: false, fetch: (request) => app.fetch(request) }))
|
|
263
|
+
const headers_a = { 'cf-connecting-ip': '203.0.113.7' }
|
|
264
|
+
const headers_b = { 'cf-connecting-ip': '198.51.100.9' }
|
|
265
|
+
await initialize(app, headers_a)
|
|
266
|
+
|
|
267
|
+
const [result_a, result_b] = await Promise.all([
|
|
268
|
+
generatedToolCall(app, 'v1_tokens_get', { verified: true }, headers_a),
|
|
269
|
+
generatedToolCall(app, 'v1_tokens_get', { verified: true }, headers_b),
|
|
270
|
+
])
|
|
271
|
+
|
|
272
|
+
expect(result_a.isError, JSON.stringify(result_a.content)).toBeFalsy()
|
|
273
|
+
expect(result_b.isError, JSON.stringify(result_b.content)).toBeFalsy()
|
|
274
|
+
const keys = (await store.list({ prefix: 'ratelimit:' })).keys.map((key) => key.name)
|
|
275
|
+
expect(keys.some((key) => key.endsWith('public:203.0.113.7'))).toBe(true)
|
|
276
|
+
expect(keys.some((key) => key.endsWith('public:198.51.100.9'))).toBe(true)
|
|
277
|
+
expect(keys.some((key) => key.endsWith('public:anonymous'))).toBe(false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('allows an IP-restricted API key through generated tools', async () => {
|
|
281
|
+
const db = TestApp.database()
|
|
282
|
+
await TestApp.verifiedSeed(db, Runtime.get().chainId)
|
|
283
|
+
const key = {
|
|
284
|
+
...TestApp.key,
|
|
285
|
+
allowedIps: ['203.0.113.0/24'],
|
|
286
|
+
}
|
|
287
|
+
const app = App.create({
|
|
288
|
+
auth: {},
|
|
289
|
+
cache: { edge: false },
|
|
290
|
+
db,
|
|
291
|
+
kv: { store: TestApp.kvStore({ keys: [key] }) },
|
|
292
|
+
}).route('/', data())
|
|
293
|
+
app.route('/', mcp({ docs: false, fetch: (request) => app.fetch(request) }))
|
|
294
|
+
const headers = {
|
|
295
|
+
authorization: `Bearer ${key.token}`,
|
|
296
|
+
'cf-connecting-ip': '203.0.113.7',
|
|
297
|
+
}
|
|
298
|
+
await initialize(app, headers)
|
|
299
|
+
|
|
300
|
+
const result = await generatedToolCall(app, 'v1_tokens_get', { verified: true }, headers)
|
|
301
|
+
|
|
302
|
+
expect(result.isError, JSON.stringify(result.content)).toBeFalsy()
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('preserves a configured trusted client identity through generated tools', async () => {
|
|
306
|
+
const db = TestApp.database()
|
|
307
|
+
await TestApp.verifiedSeed(db, Runtime.get().chainId)
|
|
308
|
+
const key = {
|
|
309
|
+
...TestApp.key,
|
|
310
|
+
allowedIps: ['198.51.100.0/24'],
|
|
311
|
+
}
|
|
312
|
+
const app = App.create({
|
|
313
|
+
auth: { public: { clientIp: (request) => request.headers.get('x-real-ip') ?? undefined } },
|
|
314
|
+
cache: { edge: false },
|
|
315
|
+
db,
|
|
316
|
+
kv: { store: TestApp.kvStore({ keys: [key] }) },
|
|
317
|
+
}).route('/', data())
|
|
318
|
+
app.route('/', mcp({ docs: false, fetch: (request) => app.fetch(request) }))
|
|
319
|
+
const headers = {
|
|
320
|
+
authorization: `Bearer ${key.token}`,
|
|
321
|
+
'cf-connecting-ip': '203.0.113.7',
|
|
322
|
+
'tempo-mcp-client-ip': '192.0.2.1',
|
|
323
|
+
'x-real-ip': '198.51.100.9',
|
|
324
|
+
}
|
|
325
|
+
await initialize(app, headers)
|
|
326
|
+
|
|
327
|
+
const result = await generatedToolCall(app, 'v1_tokens_get', { verified: true }, headers)
|
|
328
|
+
|
|
329
|
+
expect(result.isError, JSON.stringify(result.content)).toBeFalsy()
|
|
330
|
+
})
|
|
331
|
+
|
|
249
332
|
test('proxies docs tool calls to the remote MCP source', async () => {
|
|
250
333
|
const { host } = createHost({ docs: docsStub() })
|
|
251
334
|
await initialize(host)
|
package/src/apps/mcp.ts
CHANGED
|
@@ -16,6 +16,9 @@ const routesTagGroup = 'Routes API'
|
|
|
16
16
|
/** Tags inside the group hidden from MCP: partner-shaped CoinGecko mirrors and the MCP endpoint itself. */
|
|
17
17
|
const excludedTags = ['CoinGecko', 'MCP']
|
|
18
18
|
|
|
19
|
+
/** Internal header carrying the trusted caller identity through incur's generated request. */
|
|
20
|
+
const clientIpHeader = 'tempo-mcp-client-ip'
|
|
21
|
+
|
|
19
22
|
/** Usage guidance surfaced to MCP clients on initialize. */
|
|
20
23
|
const instructions =
|
|
21
24
|
"Tempo MCP provides access to the Tempo API's data and Routes domains, plus documentation. Authenticated tools accept a Tempo API key sent as an `Authorization: Bearer` header on the HTTP request. List endpoints paginate via `cursor`/`nextCursor`. `docs_*` tools search the Tempo documentation."
|
|
@@ -106,7 +109,11 @@ export function mcp(options: mcp.Options) {
|
|
|
106
109
|
// them onto the caller origin and dispatch in-process.
|
|
107
110
|
fetch: (request) => {
|
|
108
111
|
const url = new URL(request.url)
|
|
109
|
-
|
|
112
|
+
const address = request.headers.get(clientIpHeader)
|
|
113
|
+
const forwarded = new Request(new URL(url.pathname + url.search, origin), request)
|
|
114
|
+
forwarded.headers.delete(clientIpHeader)
|
|
115
|
+
if (address) Auth.trustClientIp(forwarded, address)
|
|
116
|
+
return fetch(forwarded)
|
|
110
117
|
},
|
|
111
118
|
mcp: { instructions, title },
|
|
112
119
|
openapi: scope(document),
|
|
@@ -115,7 +122,7 @@ export function mcp(options: mcp.Options) {
|
|
|
115
122
|
// forwarded `Authorization` header).
|
|
116
123
|
openapiConfig: {
|
|
117
124
|
compact: true,
|
|
118
|
-
forwardHeaders: ['authorization'],
|
|
125
|
+
forwardHeaders: ['authorization', clientIpHeader],
|
|
119
126
|
mode: 'namespace',
|
|
120
127
|
security: false,
|
|
121
128
|
},
|
|
@@ -130,7 +137,12 @@ export function mcp(options: mcp.Options) {
|
|
|
130
137
|
const cli = await resolve(new URL(c.req.url).origin)
|
|
131
138
|
// incur routes on the first path segment; pin the path to `/mcp` so a
|
|
132
139
|
// mount prefix never leaks into its router.
|
|
133
|
-
|
|
140
|
+
const request = new Request(new URL('/mcp', c.req.url), c.req.raw)
|
|
141
|
+
// Replace caller input with the identity resolved by the outer auth boundary.
|
|
142
|
+
request.headers.delete(clientIpHeader)
|
|
143
|
+
const address = Auth.getTrustedClientIp(c)
|
|
144
|
+
if (address) request.headers.set(clientIpHeader, address)
|
|
145
|
+
return cli.fetch(request)
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
// The documented POST carries the auth policy (described routes are
|