tapimo 0.4.3 → 0.5.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.
Files changed (192) hide show
  1. package/dist/App.d.ts +7222 -6316
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +4 -0
  4. package/dist/App.js.map +1 -1
  5. package/dist/Scope.d.ts +18 -1
  6. package/dist/Scope.d.ts.map +1 -1
  7. package/dist/Scope.js +24 -3
  8. package/dist/Scope.js.map +1 -1
  9. package/dist/admin/apps/verified-tokens.d.ts +116 -116
  10. package/dist/apps/data/App.d.ts +2541 -2607
  11. package/dist/apps/data/App.d.ts.map +1 -1
  12. package/dist/apps/data/App.js +135 -44
  13. package/dist/apps/data/App.js.map +1 -1
  14. package/dist/apps/data/FundingQuoteProviders.d.ts +36 -0
  15. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -0
  16. package/dist/apps/data/FundingQuoteProviders.js +190 -0
  17. package/dist/apps/data/FundingQuoteProviders.js.map +1 -0
  18. package/dist/apps/data/index.d.ts +1 -1
  19. package/dist/apps/data/index.js +1 -1
  20. package/dist/apps/data/routes/activities.d.ts +242 -242
  21. package/dist/apps/data/routes/addresses.d.ts +227 -227
  22. package/dist/apps/data/routes/balances.d.ts +12 -12
  23. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  24. package/dist/apps/data/routes/balances.js +67 -149
  25. package/dist/apps/data/routes/balances.js.map +1 -1
  26. package/dist/apps/data/routes/blocks.d.ts +125 -126
  27. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  28. package/dist/apps/data/routes/blocks.js +3 -5
  29. package/dist/apps/data/routes/blocks.js.map +1 -1
  30. package/dist/apps/data/routes/coingecko.d.ts +55 -54
  31. package/dist/apps/data/routes/coingecko.d.ts.map +1 -1
  32. package/dist/apps/data/routes/coingecko.js +3 -8
  33. package/dist/apps/data/routes/coingecko.js.map +1 -1
  34. package/dist/apps/data/routes/exchanges.d.ts +455 -455
  35. package/dist/apps/data/routes/fee-amm.d.ts +92 -92
  36. package/dist/apps/data/routes/funding.d.ts +91 -864
  37. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  38. package/dist/apps/data/routes/funding.js +138 -450
  39. package/dist/apps/data/routes/funding.js.map +1 -1
  40. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  41. package/dist/apps/data/routes/rpc.js +86 -16
  42. package/dist/apps/data/routes/rpc.js.map +1 -1
  43. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  44. package/dist/apps/data/routes/webhooks.d.ts +340 -0
  45. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  46. package/dist/apps/data/routes/webhooks.js +13 -1
  47. package/dist/apps/data/routes/webhooks.js.map +1 -1
  48. package/dist/apps/index.d.ts +1 -1
  49. package/dist/apps/index.js +1 -1
  50. package/dist/apps/management/App.d.ts +1843 -861
  51. package/dist/apps/management/App.d.ts.map +1 -1
  52. package/dist/apps/management/App.js +4 -0
  53. package/dist/apps/management/App.js.map +1 -1
  54. package/dist/apps/management/routes/api-keys.d.ts +121 -121
  55. package/dist/apps/management/routes/api-keys.js +2 -2
  56. package/dist/apps/management/routes/api-keys.js.map +1 -1
  57. package/dist/apps/management/routes/billing.d.ts +2 -2
  58. package/dist/apps/management/routes/invite-links.d.ts +1100 -0
  59. package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
  60. package/dist/apps/management/routes/invite-links.js +411 -0
  61. package/dist/apps/management/routes/invite-links.js.map +1 -0
  62. package/dist/apps/management/routes/me.d.ts +13 -13
  63. package/dist/apps/management/routes/members.d.ts +77 -77
  64. package/dist/apps/management/routes/orgs.d.ts +96 -96
  65. package/dist/apps/management/routes/projects.d.ts +62 -62
  66. package/dist/apps/management/routes/scopes.d.ts +11 -11
  67. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  68. package/dist/apps/management/routes/scopes.js +2 -2
  69. package/dist/apps/management/routes/scopes.js.map +1 -1
  70. package/dist/apps/management/routes/usage.d.ts +79 -79
  71. package/dist/apps/mcp.d.ts.map +1 -1
  72. package/dist/apps/mcp.js +3 -1
  73. package/dist/apps/mcp.js.map +1 -1
  74. package/dist/apps/relay/App.d.ts.map +1 -1
  75. package/dist/apps/relay/App.js +42 -4
  76. package/dist/apps/relay/App.js.map +1 -1
  77. package/dist/cloudflare.d.ts +2 -0
  78. package/dist/cloudflare.d.ts.map +1 -1
  79. package/dist/cloudflare.js +4 -0
  80. package/dist/cloudflare.js.map +1 -1
  81. package/dist/db/Db.d.ts +3 -0
  82. package/dist/db/Db.d.ts.map +1 -1
  83. package/dist/db/Db.js +57 -0
  84. package/dist/db/Db.js.map +1 -1
  85. package/dist/db/Schema.d.ts +31 -0
  86. package/dist/db/Schema.d.ts.map +1 -1
  87. package/dist/db/Schema.js +31 -0
  88. package/dist/db/Schema.js.map +1 -1
  89. package/dist/db/tables/inviteLinks.d.ts +42 -0
  90. package/dist/db/tables/inviteLinks.d.ts.map +1 -0
  91. package/dist/db/tables/inviteLinks.js +148 -0
  92. package/dist/db/tables/inviteLinks.js.map +1 -0
  93. package/dist/db/tables/organizations.d.ts.map +1 -1
  94. package/dist/db/tables/organizations.js +2 -0
  95. package/dist/db/tables/organizations.js.map +1 -1
  96. package/dist/handlers/relay.d.ts.map +1 -1
  97. package/dist/handlers/relay.js +13 -3
  98. package/dist/handlers/relay.js.map +1 -1
  99. package/dist/internal/Auth.d.ts +42 -23
  100. package/dist/internal/Auth.d.ts.map +1 -1
  101. package/dist/internal/Auth.js +169 -20
  102. package/dist/internal/Auth.js.map +1 -1
  103. package/dist/internal/EdgeCache.d.ts +3 -1
  104. package/dist/internal/EdgeCache.d.ts.map +1 -1
  105. package/dist/internal/EdgeCache.js +17 -2
  106. package/dist/internal/EdgeCache.js.map +1 -1
  107. package/dist/internal/Errors.d.ts +6 -0
  108. package/dist/internal/Errors.d.ts.map +1 -0
  109. package/dist/internal/Errors.js +16 -0
  110. package/dist/internal/Errors.js.map +1 -0
  111. package/dist/internal/FundingQuotes.d.ts +644 -0
  112. package/dist/internal/FundingQuotes.d.ts.map +1 -0
  113. package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -289
  114. package/dist/internal/FundingQuotes.js.map +1 -0
  115. package/dist/internal/Store.d.ts +61 -16
  116. package/dist/internal/Store.d.ts.map +1 -1
  117. package/dist/internal/Store.js +72 -17
  118. package/dist/internal/Store.js.map +1 -1
  119. package/dist/internal/Viem.d.ts +2 -0
  120. package/dist/internal/Viem.d.ts.map +1 -1
  121. package/dist/internal/Viem.js +11 -2
  122. package/dist/internal/Viem.js.map +1 -1
  123. package/dist/internal/index.d.ts +27 -0
  124. package/dist/internal/index.d.ts.map +1 -0
  125. package/dist/internal/index.js +27 -0
  126. package/dist/internal/index.js.map +1 -0
  127. package/package.json +8 -3
  128. package/src/ApiKeys.test.ts +23 -0
  129. package/src/App.test-d.ts +2 -0
  130. package/src/App.test.ts +60 -4
  131. package/src/App.ts +6 -0
  132. package/src/Client.test-d.ts +16 -54
  133. package/src/Scope.test.ts +1 -0
  134. package/src/Scope.ts +30 -4
  135. package/src/apps/data/App.test.ts +354 -0
  136. package/src/apps/data/App.ts +159 -52
  137. package/src/apps/data/FundingQuoteProviders.test.ts +151 -0
  138. package/src/apps/data/FundingQuoteProviders.ts +251 -0
  139. package/src/apps/data/index.ts +1 -1
  140. package/src/apps/data/routes/balances.test.ts +64 -18
  141. package/src/apps/data/routes/balances.ts +76 -177
  142. package/src/apps/data/routes/blocks.ts +3 -5
  143. package/src/apps/data/routes/coingecko.ts +3 -8
  144. package/src/apps/data/routes/funding.test.ts +149 -306
  145. package/src/apps/data/routes/funding.ts +154 -604
  146. package/src/apps/data/routes/indexer.test.ts +99 -3
  147. package/src/apps/data/routes/rpc.test.ts +219 -0
  148. package/src/apps/data/routes/rpc.ts +92 -17
  149. package/src/apps/data/routes/webhooks.test.ts +23 -2
  150. package/src/apps/data/routes/webhooks.ts +13 -1
  151. package/src/apps/index.ts +1 -1
  152. package/src/apps/management/App.ts +4 -0
  153. package/src/apps/management/routes/api-keys.test.ts +47 -3
  154. package/src/apps/management/routes/api-keys.ts +2 -2
  155. package/src/apps/management/routes/invite-links.test.ts +385 -0
  156. package/src/apps/management/routes/invite-links.ts +570 -0
  157. package/src/apps/management/routes/scopes.test.ts +3 -2
  158. package/src/apps/management/routes/scopes.ts +4 -2
  159. package/src/apps/mcp.test.ts +55 -14
  160. package/src/apps/mcp.ts +3 -1
  161. package/src/apps/relay/App.test.ts +324 -4
  162. package/src/apps/relay/App.ts +40 -4
  163. package/src/cloudflare.ts +5 -0
  164. package/src/db/Db.ts +65 -0
  165. package/src/db/Schema.ts +39 -0
  166. package/src/db/tables/inviteLinks.ts +187 -0
  167. package/src/db/tables/organizations.ts +2 -0
  168. package/src/handlers/relay.ts +16 -4
  169. package/src/internal/Auth.test.ts +528 -37
  170. package/src/internal/Auth.ts +246 -45
  171. package/src/internal/EdgeCache.test.ts +24 -3
  172. package/src/internal/EdgeCache.ts +18 -2
  173. package/src/internal/FundingQuotes.test.ts +174 -0
  174. package/src/internal/FundingQuotes.ts +735 -0
  175. package/src/internal/Log.test.ts +4 -4
  176. package/src/internal/Store.test-d.ts +15 -0
  177. package/src/internal/Store.test.ts +153 -2
  178. package/src/internal/Store.ts +157 -41
  179. package/src/internal/Viem.ts +14 -5
  180. package/src/internal/index.test.ts +34 -0
  181. package/src/internal/index.ts +26 -0
  182. package/dist/apps/data/FundingRouteProviders.d.ts +0 -143
  183. package/dist/apps/data/FundingRouteProviders.d.ts.map +0 -1
  184. package/dist/apps/data/FundingRouteProviders.js +0 -546
  185. package/dist/apps/data/FundingRouteProviders.js.map +0 -1
  186. package/dist/internal/FundingRoutes.d.ts +0 -1085
  187. package/dist/internal/FundingRoutes.d.ts.map +0 -1
  188. package/dist/internal/FundingRoutes.js.map +0 -1
  189. package/src/apps/data/FundingRouteProviders.test.ts +0 -485
  190. package/src/apps/data/FundingRouteProviders.ts +0 -744
  191. package/src/internal/FundingRoutes.test.ts +0 -239
  192. package/src/internal/FundingRoutes.ts +0 -1067
package/src/App.test.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import * as http from 'node:http'
2
+ import { Challenge } from 'mppx'
2
3
  import { ApiKeys, App, Metrics, RateLimit, Store } from 'tapimo'
3
4
  import * as Docs from 'tapimo/docs'
4
5
  import * as Tokens from './apps/data/routes/tokens.js'
5
6
  import type * as Log from './internal/Log.js'
6
7
  import * as TestApp from '../test/App.js'
7
8
  import * as Runtime from '../test/runtime.js'
9
+ import * as Tempo from '../test/Tempo.js'
8
10
 
9
11
  describe('create', () => {
10
12
  test('composes default dependencies', async () => {
@@ -208,6 +210,53 @@ describe('create', () => {
208
210
  `)
209
211
  })
210
212
 
213
+ test('applies the configured global MPP rate limit', async () => {
214
+ const store = Store.memory()
215
+ const limit = { perMinute: 2 }
216
+ const app = TestApp.create({
217
+ mpp: { rateLimit: limit },
218
+ rateLimit: { store },
219
+ })
220
+ await exhaustQuota(RateLimit.memory({ store }), 'mpp:public:203.0.113.10', limit)
221
+
222
+ const response = await app.request(`/v1/tokens/${TestApp.token}`, {
223
+ headers: {
224
+ authorization: 'Payment malformed',
225
+ 'x-forwarded-for': '203.0.113.10',
226
+ },
227
+ })
228
+
229
+ expect(response.status).toBe(429)
230
+ expect(response.headers.get('RateLimit-Limit')).toBe('2')
231
+ expect(response.headers.get('RateLimit-Scope')).toBe('mpp')
232
+ })
233
+
234
+ test('endpoint MPP rate limit overrides the global limit', async () => {
235
+ const store = Store.memory()
236
+ const routeLimit = { perMinute: 1 }
237
+ const app = TestApp.create({
238
+ auth: {
239
+ overrides: {
240
+ 'GET /v1/tokens/:token': { mpp: { rateLimit: routeLimit } },
241
+ },
242
+ },
243
+ mpp: { rateLimit: { perMinute: 2 } },
244
+ rateLimit: { store },
245
+ })
246
+ await exhaustQuota(RateLimit.memory({ store }), 'mpp:public:203.0.113.10', routeLimit)
247
+
248
+ const response = await app.request(`/v1/tokens/${TestApp.token}`, {
249
+ headers: {
250
+ authorization: 'Payment malformed',
251
+ 'x-forwarded-for': '203.0.113.10',
252
+ },
253
+ })
254
+
255
+ expect(response.status).toBe(429)
256
+ expect(response.headers.get('RateLimit-Limit')).toBe('1')
257
+ expect(response.headers.get('RateLimit-Scope')).toBe('mpp')
258
+ })
259
+
211
260
  test('serves Vocs API reference', async () => {
212
261
  const app = TestApp.create({ docs: Docs.docs() })
213
262
  const response = await app.request('/')
@@ -602,11 +651,13 @@ describe('create', () => {
602
651
  auth,
603
652
  cache: { store },
604
653
  mpp: {
605
- charge: {
606
- currency: 'USD',
607
- decimals: 2,
608
- },
609
654
  secretKey: 'secret_test_key_0123456789abcdef',
655
+ session: {
656
+ chainId: Tempo.chain.id,
657
+ decimals: 6,
658
+ getClient: () => Tempo.client,
659
+ recipient: Tempo.accounts[2].address,
660
+ },
610
661
  },
611
662
  rateLimit: {
612
663
  now,
@@ -627,6 +678,11 @@ describe('create', () => {
627
678
  })
628
679
  const overQuota = await app.request('/v1/tokens/not-an-address')
629
680
  expect(overQuota.status).toMatchInlineSnapshot(`402`)
681
+ expect(Challenge.fromHeaders(overQuota.headers).request['currency']).toBe(Tempo.currency)
682
+
683
+ const head = await app.request('/v1/tokens/not-an-address', { method: 'HEAD' })
684
+ expect(head.status).toMatchInlineSnapshot(`402`)
685
+ expect(Challenge.fromHeaders(head.headers).intent).toBe('session')
630
686
  })
631
687
 
632
688
  test('supports bearer API-key fallback', async () => {
package/src/App.ts CHANGED
@@ -72,6 +72,8 @@ export type Environment = {
72
72
  tidx: Tidx.getClient.Tidx | undefined
73
73
  /** Verified-token feature configuration (refresh window), or undefined when the feature is disabled. Token rows live in `db`. */
74
74
  verifiedTokens: { refreshMs?: number | undefined } | undefined
75
+ /** Zone RPC endpoints keyed by chain id; empty when none are configured. */
76
+ zones: ReadonlyMap<number, Data.data.Zone>
75
77
  /** Webhook feature configuration, or undefined when webhooks are disabled. Rows live in `db`. */
76
78
  webhook: { maxPerOwner?: number | undefined } | undefined
77
79
  } & EdgeCache.Variables &
@@ -398,6 +400,10 @@ export function create<const path extends string = '/'>(options: create.Options<
398
400
  let finalized = false
399
401
  const finalize = () => {
400
402
  if (finalized) return
403
+ Auth.installMppManagementRoutes(app, {
404
+ basePath,
405
+ overrides: options.auth === false ? undefined : options.auth?.overrides,
406
+ })
401
407
  finalized = true
402
408
 
403
409
  app.get('/openapi.json', async (c) => c.json(await getSpec()))
@@ -1189,25 +1189,27 @@ describe('narrows responses by status', () => {
1189
1189
  }
1190
1190
  })
1191
1191
 
1192
- test('GET /v1/funding/routes', async () => {
1192
+ test('GET /v1/funding/quotes', async () => {
1193
1193
  const client = Client.create()
1194
- const res = await client.v1.funding.routes.$get({
1194
+ const res = await client.v1.funding.quotes.$get({
1195
1195
  query: {
1196
+ provider: 'relay',
1196
1197
  sourceAmount: '1000000',
1197
1198
  sourceChain: 'base',
1198
1199
  sourceToken: 'USDC',
1199
1200
  },
1200
1201
  })
1201
1202
 
1202
- expectTypeOf(res.status).toEqualTypeOf<200 | 400 | PaidAccessStatus>()
1203
+ expectTypeOf(res.status).toEqualTypeOf<200 | 400 | 502 | PaidAccessStatus>()
1203
1204
 
1204
1205
  switch (res.status) {
1205
1206
  case 200: {
1206
1207
  const json = await res.json()
1207
1208
  expectTypeOf(json.data[0]!.sourceAmount).toEqualTypeOf<
1208
- z.output<typeof Funding.schema.listFundingRoutes.Response>['data'][number]['sourceAmount']
1209
+ z.output<typeof Funding.schema.quoteFunding.Response>['data'][number]['sourceAmount']
1209
1210
  >()
1210
- expectTypeOf(json.data[0]!.provider.id).toEqualTypeOf<string>()
1211
+ expectTypeOf(json.data[0]!.provider.id).toEqualTypeOf<'relay'>()
1212
+ expectTypeOf(json.data[0]!.quote.outputAmount).toEqualTypeOf<string>()
1211
1213
  void json
1212
1214
  return
1213
1215
  }
@@ -1218,51 +1220,11 @@ describe('narrows responses by status', () => {
1218
1220
  void json
1219
1221
  return
1220
1222
  }
1221
- }
1222
- })
1223
-
1224
- test('POST /v1/funding/routes/quote', async () => {
1225
- const client = Client.create()
1226
- const res = await client.v1.funding.routes.quote.$post({
1227
- json: {
1228
- depositor: '0x1111111111111111111111111111111111111111',
1229
- provider: 'across',
1230
- sourceAmount: '1000000',
1231
- sourceChain: 'base',
1232
- sourceToken: 'USDC',
1233
- },
1234
- })
1235
-
1236
- expectTypeOf(res.status).toEqualTypeOf<200 | 400 | 404 | 501 | 502 | PaidAccessStatus>()
1237
-
1238
- switch (res.status) {
1239
- case 200: {
1240
- const json = await res.json()
1241
- expectTypeOf(json).toEqualTypeOf<
1242
- z.output<typeof Funding.schema.quoteFundingRoute.Response>
1243
- >()
1244
- return
1245
- }
1246
- case 400: {
1247
- const json = await res.json()
1248
- expectTypeOf(json.error.code).toEqualTypeOf<Access400<'body_invalid'>>()
1249
- return
1250
- }
1251
- case 404: {
1252
- const json = await res.json()
1253
- expectTypeOf(json.error.code).toEqualTypeOf<'quote_unavailable' | 'route_not_found'>()
1254
- return
1255
- }
1256
- case 501: {
1257
- const json = await res.json()
1258
- expectTypeOf(json.error.code).toEqualTypeOf<'provider_unconfigured'>()
1259
- return
1260
- }
1261
1223
  case 502: {
1262
1224
  const json = await res.json()
1263
- expectTypeOf(json.error.code).toEqualTypeOf<
1264
- 'provider_error' | 'provider_rate_limited' | 'provider_timeout'
1265
- >()
1225
+ expectTypeOf(json.error.code).toEqualTypeOf<'upstream_error'>()
1226
+ expectTypeOf(json.error.message).toEqualTypeOf<string>()
1227
+ void json
1266
1228
  return
1267
1229
  }
1268
1230
  }
@@ -1295,24 +1257,24 @@ describe('narrows responses by status', () => {
1295
1257
  }
1296
1258
  })
1297
1259
 
1298
- test('GET /v1/providers', async () => {
1260
+ test('GET /v1/funding/providers', async () => {
1299
1261
  const client = Client.create()
1300
- const res = await client.v1.providers.$get({ query: { capability: 'fundingRoutes' } })
1262
+ const res = await client.v1.funding.providers.$get()
1301
1263
 
1302
- expectTypeOf(res.status).toEqualTypeOf<200 | 400 | PaidAccessStatus>()
1264
+ expectTypeOf(res.status).toEqualTypeOf<200 | PaidAccessStatus>()
1303
1265
 
1304
1266
  switch (res.status) {
1305
1267
  case 200: {
1306
1268
  const json = await res.json()
1307
- expectTypeOf(json.data[0]!.capabilities).toEqualTypeOf<
1308
- z.output<typeof Funding.schema.listProviders.Response>['data'][number]['capabilities']
1269
+ expectTypeOf(json.data[0]!.status).toEqualTypeOf<
1270
+ z.output<typeof Funding.schema.listProviders.Response>['data'][number]['status']
1309
1271
  >()
1310
1272
  void json
1311
1273
  return
1312
1274
  }
1313
1275
  case 400: {
1314
1276
  const json = await res.json()
1315
- expectTypeOf(json.error.code).toEqualTypeOf<Access400<'query_invalid'>>()
1277
+ expectTypeOf(json.error.code).toEqualTypeOf<'api_key_malformed'>()
1316
1278
  expectTypeOf(json.error.message).toEqualTypeOf<string>()
1317
1279
  void json
1318
1280
  return
package/src/Scope.test.ts CHANGED
@@ -12,6 +12,7 @@ describe('extend', () => {
12
12
  ]),
13
13
  ).toEqual([
14
14
  ...Scope.catalog,
15
+ Scope.zoneEntry,
15
16
  {
16
17
  description: 'Read project accounts.',
17
18
  scope: 'accounts:read',
package/src/Scope.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * API-key validation.
5
5
  */
6
6
 
7
+ import * as z from 'zod/mini'
8
+
7
9
  /**
8
10
  * Wildcard scope. A key whose `scopes` contains this value satisfies every
9
11
  * required scope, so it never needs re-provisioning when new scopes are added.
@@ -69,8 +71,30 @@ export const catalog = [
69
71
  },
70
72
  ] as const satisfies readonly Entry[]
71
73
 
74
+ /**
75
+ * Templated `/v1/scopes` entry for per-zone read scopes. Concrete grants use
76
+ * `zone:<chainId>:read` and are never self-serve.
77
+ */
78
+ export const zoneEntry = {
79
+ description: 'Read RPC and indexed data for one zone chain id.',
80
+ scope: 'zone:<chainId>:read',
81
+ selfServe: false,
82
+ } as const satisfies Entry
83
+
84
+ /** Zod schemas owned by the scope vocabulary. */
85
+ export namespace schema {
86
+ /** A concrete per-zone read scope (`zone:421700001:read`). */
87
+ export const Zone = z
88
+ .templateLiteral(['zone:', z.int(), ':read'])
89
+ // `z.int()` also matches a sign; zone chain ids are positive.
90
+ .check(z.regex(/^zone:\d+:read$/), z.describe('Per-zone read scope (`zone:<chainId>:read`).'))
91
+ }
92
+
93
+ /** Per-zone read scope, granting access to one zone chain id. */
94
+ export type Zone = z.output<typeof schema.Zone>
95
+
72
96
  /** Issuable API-key scope identifier, including host-defined scopes. */
73
- export type Id = (typeof catalog)[number]['scope'] | (string & {})
97
+ export type Id = (typeof catalog)[number]['scope'] | Zone | (string & {})
74
98
 
75
99
  /** Scope catalog used by API-key issuance and discovery. */
76
100
  export type Catalog = readonly Entry[]
@@ -92,7 +116,7 @@ export const selfServe: readonly Id[] = catalog
92
116
  */
93
117
  export function extend(entries: readonly Entry[] = []): Catalog {
94
118
  const identifiers = new Set<string>()
95
- const result = [...catalog, ...entries]
119
+ const result = [...catalog, zoneEntry, ...entries]
96
120
  for (const entry of result) {
97
121
  if (!entry.scope.trim()) throw new Error('Scope identifier must not be empty.')
98
122
  if (identifiers.has(entry.scope)) throw new Error(`Duplicate scope \`${entry.scope}\`.`)
@@ -103,10 +127,12 @@ export function extend(entries: readonly Entry[] = []): Catalog {
103
127
 
104
128
  /** Returns whether a catalog contains a scope identifier. */
105
129
  export function includes(catalog: Catalog, scope: string): scope is Id {
106
- return catalog.some((entry) => entry.scope === scope)
130
+ return schema.Zone.safeParse(scope).success || catalog.some((entry) => entry.scope === scope)
107
131
  }
108
132
 
109
133
  /** Returns the self-issuable identifiers in a scope catalog. */
110
134
  export function selfServeFrom(catalog: Catalog): readonly Id[] {
111
- return catalog.filter((entry) => entry.selfServe).map((entry) => entry.scope)
135
+ return catalog
136
+ .filter((entry) => entry.selfServe && !schema.Zone.safeParse(entry.scope).success)
137
+ .map((entry) => entry.scope)
112
138
  }
@@ -1,4 +1,11 @@
1
+ import { Secp256k1 } from 'ox'
2
+ import { ZoneRpcAuthentication } from 'ox/tempo'
3
+ import { Challenge, PaymentRequest } from 'mppx'
4
+ import { RateLimit } from 'tapimo'
5
+
1
6
  import * as TestApp from '../../../test/App.js'
7
+ import * as Tempo from '../../../test/Tempo.js'
8
+ import * as Store from '../../internal/Store.js'
2
9
  import * as Viem from '../../internal/Viem.js'
3
10
 
4
11
  /** A supported non-mainnet chain id, exercising the sandbox testnet allowance. */
@@ -35,6 +42,346 @@ function mainnetDefaultClient() {
35
42
  })
36
43
  }
37
44
 
45
+ type OpenApiDocument = {
46
+ paths: Record<string, Record<string, unknown>>
47
+ }
48
+
49
+ const mpp = {
50
+ secretKey: 'secret_test_key_0123456789abcdef',
51
+ session: {
52
+ chainId: Tempo.chain.id,
53
+ currency: Tempo.currency,
54
+ decimals: 6,
55
+ getClient: () => Tempo.client,
56
+ recipient: Tempo.accounts[2].address,
57
+ },
58
+ } satisfies NonNullable<TestApp.create.Options['mpp']>
59
+
60
+ describe('MPP session management', () => {
61
+ test('keeps generated POST routes private', async () => {
62
+ const app = TestApp.create({ auth: false })
63
+ const indexer = await app.request('/v1/indexer/query', { method: 'POST' })
64
+ const token = await app.request(`/v1/tokens/${TestApp.token}`, { method: 'POST' })
65
+ const spec = (await (await app.request('/openapi.json')).json()) as OpenApiDocument
66
+
67
+ expect(indexer.status).toBe(404)
68
+ expect(token.status).toBe(404)
69
+ expect(spec.paths['/v1/indexer/query']).not.toHaveProperty('post')
70
+ expect(spec.paths['/v1/tokens/{token}']).not.toHaveProperty('post')
71
+ })
72
+
73
+ test('matches GET scopes for static, parameterized, and hidden resources', async () => {
74
+ const rateLimitStore = Store.memory()
75
+ const app = TestApp.create({ mpp, rateLimit: { store: rateLimitStore } })
76
+ await exhaustPublicQuota(rateLimitStore)
77
+
78
+ for (const resource of [
79
+ {
80
+ path: `/v1/indexer/query?sql=select%201&chainId=${Tempo.chain.id}`,
81
+ scope: 'GET /v1/indexer/query',
82
+ },
83
+ {
84
+ path: `/v1/tokens/${TestApp.token}?chainId=${Tempo.chain.id}`,
85
+ scope: 'GET /v1/tokens/:token',
86
+ },
87
+ {
88
+ path: `/v1/verified-tokens?chainId=${Tempo.chain.id}`,
89
+ scope: 'GET /v1/verified-tokens',
90
+ },
91
+ ]) {
92
+ const url = new URL(resource.path, 'http://tempo-api.test')
93
+ const challenged = await app.fetch(new Request(url))
94
+ url.search = ''
95
+ const management = await app.fetch(new Request(url, { method: 'POST' }))
96
+
97
+ expect(challenged.status).toBe(402)
98
+ expect(management.status).toBe(402)
99
+ const expectedScope = PaymentRequest.serialize({
100
+ _mppx_scope: resource.scope,
101
+ })
102
+ expect(Challenge.fromHeaders(challenged.headers).opaque).toBe(expectedScope)
103
+ expect(Challenge.fromHeaders(management.headers).opaque).toBe(expectedScope)
104
+ }
105
+ })
106
+
107
+ test('preserves the mounted base path in session scope', async () => {
108
+ const rateLimitStore = Store.memory()
109
+ const app = TestApp.create({ mpp, path: '/api', rateLimit: { store: rateLimitStore } })
110
+ await exhaustPublicQuota(rateLimitStore)
111
+
112
+ const resource = new URL(
113
+ `/api/v1/tokens/${TestApp.token}?chainId=${Tempo.chain.id}`,
114
+ 'http://tempo-api.test',
115
+ )
116
+ const challenged = await app.fetch(new Request(resource))
117
+ resource.search = ''
118
+ const management = await app.fetch(new Request(resource, { method: 'POST' }))
119
+
120
+ expect(challenged.status).toBe(402)
121
+ expect(management.status).toBe(402)
122
+ const expectedScope = PaymentRequest.serialize({
123
+ _mppx_scope: 'GET /api/v1/tokens/:token',
124
+ })
125
+ expect(Challenge.fromHeaders(challenged.headers).opaque).toBe(expectedScope)
126
+ expect(Challenge.fromHeaders(management.headers).opaque).toBe(expectedScope)
127
+ })
128
+
129
+ test('honors endpoint MPP overrides', async () => {
130
+ const app = TestApp.create({
131
+ auth: {
132
+ overrides: {
133
+ 'GET /v1/scopes': { mpp: true },
134
+ 'GET /v1/tokens/:token': { mpp: false },
135
+ },
136
+ },
137
+ mpp,
138
+ })
139
+ const enabled = await app.request('/v1/scopes', { method: 'POST' })
140
+ const disabled = await app.request(`/v1/tokens/${TestApp.token}`, { method: 'POST' })
141
+
142
+ expect(enabled.status).toBe(402)
143
+ expect(disabled.status).toBe(404)
144
+ expect(Challenge.fromHeaders(enabled.headers).opaque).toBe(
145
+ PaymentRequest.serialize({ _mppx_scope: 'GET /v1/scopes' }),
146
+ )
147
+ })
148
+ })
149
+
150
+ describe('zone chain gate', () => {
151
+ /** A consumer-supplied zone chain id (the API never derives these). */
152
+ const zone = 421_700_001
153
+ /** A second zone, isolating per-zone grants. */
154
+ const zoneB = 421_700_002
155
+
156
+ function zoneOptions(chainId: number) {
157
+ return {
158
+ chainId,
159
+ forwardRpcUrl: `https://${chainId}.rpc.test`,
160
+ }
161
+ }
162
+
163
+ /** Zone reader: holds the per-zone scope for `zone` (only). */
164
+ const zoneReader = {
165
+ id: 'key_zone',
166
+ orgId: 'org_test',
167
+ scopes: ['data:read', `zone:${zone}:read`],
168
+ token: 'secret_zone',
169
+ } satisfies TestApp.kvStore.Key
170
+
171
+ /** Full-access key: the wildcard covers every zone. */
172
+ const admin = {
173
+ id: 'key_admin',
174
+ orgId: 'org_test',
175
+ scopes: ['*'],
176
+ token: 'secret_admin',
177
+ } satisfies TestApp.kvStore.Key
178
+
179
+ /** Data reader without any zone grant. */
180
+ const outsider = {
181
+ id: 'key_outsider',
182
+ orgId: 'org_test',
183
+ scopes: ['data:read'],
184
+ token: 'secret_outsider',
185
+ } satisfies TestApp.kvStore.Key
186
+
187
+ /** A deployment serving two zones. */
188
+ function zoneApp() {
189
+ return TestApp.create({
190
+ auth: { keys: [admin, outsider, zoneReader] },
191
+ zones: [zoneOptions(zone), zoneOptions(zoneB)],
192
+ })
193
+ }
194
+
195
+ /** Signing key proving that a structurally valid caller token is insufficient for REST. */
196
+ const privateKey = Secp256k1.randomPrivateKey()
197
+
198
+ /** Signs a caller Zone token. */
199
+ function zoneToken(chainId: number) {
200
+ const issuedAt = Math.floor(Date.now() / 1000)
201
+ const authentication = ZoneRpcAuthentication.from({
202
+ chainId,
203
+ expiresAt: issuedAt + 300,
204
+ issuedAt,
205
+ zoneId: 1,
206
+ })
207
+ return ZoneRpcAuthentication.serialize(authentication, {
208
+ signature: Secp256k1.sign({
209
+ payload: ZoneRpcAuthentication.getSignPayload(authentication),
210
+ privateKey,
211
+ }),
212
+ })
213
+ }
214
+
215
+ test('zone chain ids are additive: unsupported ids list them as supported', async () => {
216
+ const app = TestApp.create({ auth: false, zones: [zoneOptions(zone)] })
217
+ const response = await app.request('/v1/transfers?chainId=999999')
218
+ const body = (await response.json()) as { error: { code: string; message: string } }
219
+
220
+ expect(response.status).toBe(400)
221
+ expect(body.error.code).toBe('chain_id_unsupported')
222
+ expect(body.error.message).toContain(String(zone))
223
+ })
224
+
225
+ test('a default zone still applies authorization', async () => {
226
+ const app = TestApp.create({
227
+ auth: false,
228
+ defaultChainId: zone,
229
+ rpc: { url: (chainId) => `https://${chainId}.rpc.test` },
230
+ zones: [zoneOptions(zone)],
231
+ })
232
+
233
+ const rpc = await app.request('/rpc', {
234
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
235
+ method: 'POST',
236
+ })
237
+ const transfers = await app.request('/v1/transfers')
238
+ const tokens = await app.request('/v1/tokens')
239
+ const scopes = await app.request('/v1/scopes')
240
+
241
+ expect(rpc.status).toBe(403)
242
+ expect(transfers.status).toBe(403)
243
+ expect(tokens.status).toBe(403)
244
+ expect(scopes.status).toBe(200)
245
+ })
246
+
247
+ test('a zone-scoped key reads Zone data', async () => {
248
+ const response = await zoneApp().request(`/v1/tokenlist?chainId=${zone}`, as(zoneReader))
249
+ expect(response.status).toBe(200)
250
+ })
251
+
252
+ test('zone authorization applies to every data route', async () => {
253
+ const app = TestApp.create({ auth: false, zones: [zoneOptions(zone)] })
254
+ for (const path of [
255
+ `/v1/tokens?chainId=${zone}`,
256
+ `/v1/addresses/0x0000000000000000000000000000000000000001/activities?chainId=${zone}`,
257
+ `/v1/transfers?chainId=${zone}&include=token`,
258
+ ]) {
259
+ const response = await app.request(path)
260
+ const body = (await response.json()) as { error: { code: string; message: string } }
261
+
262
+ expect(response.status).toBe(403)
263
+ expect(body.error.code).toBe('api_key_forbidden')
264
+ expect(body.error.message).toContain('zone')
265
+ }
266
+ })
267
+
268
+ test('the wildcard scope covers every zone', async () => {
269
+ const app = zoneApp()
270
+ expect((await app.request(`/v1/tokenlist?chainId=${zone}`, as(admin))).status).toBe(200)
271
+ expect((await app.request(`/v1/tokenlist?chainId=${zoneB}`, as(admin))).status).toBe(200)
272
+ })
273
+
274
+ test('a key without the zone scope is refused, naming the scope', async () => {
275
+ const app = zoneApp()
276
+ const response = await app.request(`/v1/transfers?chainId=${zone}`, as(outsider))
277
+ const body = (await response.json()) as { error: { code: string; message: string } }
278
+
279
+ expect(response.status).toBe(403)
280
+ expect(body.error.code).toBe('api_key_forbidden')
281
+ expect(body.error.message).toContain(`zone:${zone}:read`)
282
+ expect(body.error.message).not.toContain('X-Authorization-Token')
283
+ })
284
+
285
+ test('a zone scope does not grant another zone', async () => {
286
+ const app = zoneApp()
287
+ const response = await app.request(`/v1/transfers?chainId=${zoneB}`, as(zoneReader))
288
+ const body = (await response.json()) as { error: { code: string; message: string } }
289
+
290
+ expect(response.status).toBe(403)
291
+ expect(body.error.message).toContain(`zone:${zoneB}:read`)
292
+ })
293
+
294
+ test('a caller-signed Zone token cannot authorize REST data', async () => {
295
+ const response = await zoneApp().request(`/v1/tokenlist?chainId=${zone}`, {
296
+ headers: { [ZoneRpcAuthentication.headerName]: zoneToken(zone) },
297
+ })
298
+ expect(response.status).toBe(403)
299
+ })
300
+
301
+ test('an empty Zone token cannot authorize raw RPC', async () => {
302
+ const response = await zoneApp().request(`/rpc/${zone}`, {
303
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
304
+ headers: { [ZoneRpcAuthentication.headerName]: '' },
305
+ method: 'POST',
306
+ })
307
+ expect(response.status).toBe(403)
308
+ })
309
+
310
+ test('path chain selectors require Zone authorization', async () => {
311
+ const app = TestApp.create({ auth: false, zones: [zoneOptions(zone)] })
312
+ const gecko = await app.request(`/gecko/${zone}/pairs`)
313
+ const block = await app.request(`/chains/${zone}/blocks/by-timestamp?timestamp=1767225600`)
314
+
315
+ expect(gecko.status).toBe(403)
316
+ expect(block.status).toBe(403)
317
+ })
318
+
319
+ test('applies Zone authorization under a base path', async () => {
320
+ const app = TestApp.create({ auth: false, path: '/api', zones: [zoneOptions(zone)] })
321
+
322
+ expect((await app.request(`/api/v1/tokenlist?chainId=${zone}`)).status).toBe(403)
323
+ expect((await app.request(`/api/gecko/${zone}/pairs`)).status).toBe(403)
324
+ })
325
+
326
+ test('rejects conflicting path and query Zone selectors', async () => {
327
+ const response = await zoneApp().request(
328
+ `/gecko/${zoneB}/pairs?chainId=${zone}`,
329
+ as(zoneReader),
330
+ )
331
+ const body = (await response.json()) as { error: { code: string; message: string } }
332
+
333
+ expect(response.status).toBe(400)
334
+ expect(body.error.code).toBe('chain_id_invalid')
335
+ expect(body.error.message).toBe('Conflicting chain ids')
336
+ })
337
+
338
+ test('rejects conflicting query aliases', async () => {
339
+ const response = await zoneApp().request(
340
+ `/v1/tokenlist?chainId=${zone}&chain_id=${zoneB}`,
341
+ as(admin),
342
+ )
343
+ expect(response.status).toBe(400)
344
+ })
345
+
346
+ test('does not apply the data gate to management routes', async () => {
347
+ const response = await zoneApp().request(`/v1/scopes?chainId=${zone}`)
348
+ expect(response.status).toBe(200)
349
+ })
350
+
351
+ test('Zone responses never seed the anonymous edge cache', async () => {
352
+ const app = zoneApp()
353
+ const path = `/v1/tokenlist?chainId=${zone}`
354
+ expect((await app.request(path, as(zoneReader))).status).toBe(200)
355
+ expect((await app.request(path)).status).toBe(403)
356
+ })
357
+
358
+ test('bypasses stale edge entries after a chain becomes a Zone', async () => {
359
+ const store = Store.memory()
360
+ const path = `/v1/tokenlist?chainId=${zone}`
361
+ const publicApp = TestApp.create({
362
+ auth: false,
363
+ cache: { store },
364
+ supportedChainIds: [zone],
365
+ })
366
+ expect((await publicApp.request(path)).status).toBe(200)
367
+
368
+ const privateApp = TestApp.create({ auth: false, cache: { store }, zones: [zoneOptions(zone)] }) // prettier-ignore
369
+ expect((await privateApp.request(path)).status).toBe(403)
370
+ })
371
+
372
+ test('public chains remain edge-cacheable on a Zone deployment', async () => {
373
+ const store = Store.memory()
374
+ const app = TestApp.create({ auth: false, cache: { store }, zones: [zoneOptions(zone)] })
375
+ const path = `/v1/tokenlist?chainId=${Viem.chainId.testnet}`
376
+
377
+ const first = await app.request(path)
378
+ const second = await app.request(path)
379
+ expect(first.status).toBe(200)
380
+ expect(second.status).toBe(200)
381
+ expect(second.headers.get('cache-control')).toContain('public')
382
+ })
383
+ })
384
+
38
385
  describe('sandbox chain guard', () => {
39
386
  test('rejects a sandbox key that omits `chainId` on a mainnet-default deployment', async () => {
40
387
  const response = await mainnetDefaultClient().v1.tokenlist.$get({ query: {} }, as(sandbox))
@@ -70,3 +417,10 @@ describe('sandbox chain guard', () => {
70
417
  expect(response.status).toBe(200)
71
418
  })
72
419
  })
420
+
421
+ async function exhaustPublicQuota(store: Store.Store) {
422
+ const limit = { perMinute: 60 }
423
+ const rateLimit = RateLimit.memory({ store })
424
+ for (let index = 0; index < limit.perMinute; index++)
425
+ await rateLimit.consume({ key: 'public:anonymous', limit })
426
+ }