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.
Files changed (91) hide show
  1. package/dist/App.d.ts +26 -26
  2. package/dist/admin/App.d.ts +3 -0
  3. package/dist/admin/App.d.ts.map +1 -1
  4. package/dist/admin/apps/api-keys.d.ts +41 -41
  5. package/dist/admin/apps/routes.d.ts +64 -64
  6. package/dist/admin/apps/verified-tokens.d.ts +161 -155
  7. package/dist/admin/apps/verified-tokens.d.ts.map +1 -1
  8. package/dist/admin/apps/verified-tokens.js +24 -4
  9. package/dist/admin/apps/verified-tokens.js.map +1 -1
  10. package/dist/admin/ui.generated.d.ts +1 -1
  11. package/dist/admin/ui.generated.d.ts.map +1 -1
  12. package/dist/admin/ui.generated.js +201 -209
  13. package/dist/admin/ui.generated.js.map +1 -1
  14. package/dist/apps/data/routes/activities.d.ts +357 -357
  15. package/dist/apps/data/routes/blocks.d.ts +125 -125
  16. package/dist/apps/data/routes/fee-amm.d.ts +108 -108
  17. package/dist/apps/data/routes/fee-amm.d.ts.map +1 -1
  18. package/dist/apps/data/routes/fee-amm.js +10 -2
  19. package/dist/apps/data/routes/fee-amm.js.map +1 -1
  20. package/dist/apps/data/routes/indexer.d.ts +43 -43
  21. package/dist/apps/data/routes/receipts.d.ts +235 -235
  22. package/dist/apps/data/routes/rpc.js +45 -11
  23. package/dist/apps/data/routes/rpc.js.map +1 -1
  24. package/dist/apps/data/routes/transfers.d.ts +81 -81
  25. package/dist/apps/data/routes/valuation.d.ts +32 -32
  26. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  27. package/dist/apps/data/routes/webhooks.d.ts +1 -1
  28. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  29. package/dist/apps/data/routes/webhooks.js +17 -10
  30. package/dist/apps/data/routes/webhooks.js.map +1 -1
  31. package/dist/apps/data/routes/zones.d.ts +31 -31
  32. package/dist/apps/management/App.d.ts +26 -26
  33. package/dist/apps/management/routes/api-keys.d.ts +246 -246
  34. package/dist/apps/management/routes/invitations.d.ts +138 -138
  35. package/dist/apps/management/routes/invite-links.d.ts +168 -168
  36. package/dist/apps/management/routes/orgs.d.ts +98 -98
  37. package/dist/apps/management/routes/projects.d.ts +125 -125
  38. package/dist/apps/management/routes/verified-token-requests.d.ts +95 -95
  39. package/dist/apps/management/routes/verified-token-requests.d.ts.map +1 -1
  40. package/dist/apps/management/routes/verified-token-requests.js +7 -9
  41. package/dist/apps/management/routes/verified-token-requests.js.map +1 -1
  42. package/dist/apps/management/routes/webhooks.d.ts +423 -423
  43. package/dist/apps/mcp.d.ts.map +1 -1
  44. package/dist/apps/mcp.js +16 -3
  45. package/dist/apps/mcp.js.map +1 -1
  46. package/dist/apps/mpp/App.d.ts +44 -44
  47. package/dist/apps/routes/routes/chains.d.ts +25 -25
  48. package/dist/apps/routes/routes/quotes.d.ts +87 -87
  49. package/dist/apps/routes/routes/transfers.d.ts +438 -438
  50. package/dist/db/Db.d.ts.map +1 -1
  51. package/dist/db/Db.js +5 -0
  52. package/dist/db/Db.js.map +1 -1
  53. package/dist/db/tables/verifiedTokenRequests.d.ts +21 -4
  54. package/dist/db/tables/verifiedTokenRequests.d.ts.map +1 -1
  55. package/dist/db/tables/verifiedTokenRequests.js +65 -44
  56. package/dist/db/tables/verifiedTokenRequests.js.map +1 -1
  57. package/dist/internal/Auth.d.ts +16 -0
  58. package/dist/internal/Auth.d.ts.map +1 -1
  59. package/dist/internal/Auth.js +31 -8
  60. package/dist/internal/Auth.js.map +1 -1
  61. package/dist/internal/routes/providers/relay.d.ts +1 -1
  62. package/dist/internal/routes/providers/relay.js +1 -1
  63. package/dist/internal/routes/providers/relay.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/admin/apps/verified-tokens.test.ts +32 -0
  66. package/src/admin/apps/verified-tokens.ts +30 -4
  67. package/src/admin/ui/src/lib/verified-tokens.ts +24 -9
  68. package/src/admin/ui/src/routes/__root.tsx +1 -1
  69. package/src/admin/ui/src/routes/organizations.tsx +34 -4
  70. package/src/admin/ui/src/routes/verified-tokens.tsx +64 -20
  71. package/src/admin/ui.generated.ts +202 -263
  72. package/src/apps/data/App.test.ts +26 -0
  73. package/src/apps/data/routes/fee-amm.test.ts +19 -0
  74. package/src/apps/data/routes/fee-amm.ts +10 -2
  75. package/src/apps/data/routes/rpc.test.ts +32 -0
  76. package/src/apps/data/routes/rpc.ts +47 -10
  77. package/src/apps/data/routes/webhooks.scan.test.ts +29 -0
  78. package/src/apps/data/routes/webhooks.test.ts +6 -0
  79. package/src/apps/data/routes/webhooks.ts +25 -12
  80. package/src/apps/management/routes/verified-token-requests.test.ts +16 -2
  81. package/src/apps/management/routes/verified-token-requests.ts +7 -10
  82. package/src/apps/mcp.test.ts +108 -25
  83. package/src/apps/mcp.ts +15 -3
  84. package/src/db/Db.ts +9 -0
  85. package/src/db/tables/routesDeposits.test.ts +72 -0
  86. package/src/db/tables/verifiedTokenRequests.test.ts +39 -20
  87. package/src/db/tables/verifiedTokenRequests.ts +80 -44
  88. package/src/internal/Auth.test.ts +92 -0
  89. package/src/internal/Auth.ts +51 -9
  90. package/src/internal/routes/providers/relay.test.ts +59 -0
  91. 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({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
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.state,
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 inspectRpcBody(c.req.raw) : undefined
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
- const result = (() => {
722
- try {
723
- return { ok: true as const, value: JSON.parse(new TextDecoder().decode(bytes)) as unknown }
724
- } catch {
725
- return { empty: size === 0, ok: false as const }
726
- }
727
- })()
728
- return { body, result }
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('Log topics, starting with the event signature hash (topic 0).'),
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(value: string, target: unknown, kind: 'number' | 'string'): boolean {
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 value.slice(0, 10).toLowerCase() === String(o['selector']).toLowerCase()
2461
+ return value_string.slice(0, 10).toLowerCase() === String(o['selector']).toLowerCase()
2454
2462
  if ('startsWith' in o)
2455
- return value.toLowerCase().startsWith(String(o['startsWith']).toLowerCase())
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.filter((t): t is string => typeof t === 'string')
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
- !topic0.success ||
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: topic0.data },
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
- if (!matchesOperator(asString(value), normalized, numeric ? 'number' : 'string')) return false
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: org.id },
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 organization is at its pending-request limit.', // prettier-ignore
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
  },
@@ -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 response = await host.fetch(
46
- new Request(new URL('/mcp', origin), {
47
- body: JSON.stringify(body),
48
- headers: {
49
- accept: 'application/json, text/event-stream',
50
- 'content-type': 'application/json',
51
- ...headers,
52
- },
53
- method: 'POST',
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(host, {
62
- id: 1,
63
- jsonrpc: '2.0',
64
- method: 'initialize',
65
- params: {
66
- capabilities: {},
67
- clientInfo: { name: 'test-client', version: '1.0.0' },
68
- protocolVersion: '2025-03-26',
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
- return fetch(new Request(new URL(url.pathname + url.search, origin), request))
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
- return cli.fetch(new Request(new URL('/mcp', c.req.url), c.req.raw))
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