tapimo 0.4.4 → 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 (162) hide show
  1. package/dist/App.d.ts +7403 -6127
  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 +115 -115
  10. package/dist/apps/data/App.d.ts +1838 -1534
  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/{FundingRouteProviders.d.ts → FundingQuoteProviders.d.ts} +7 -7
  15. package/dist/apps/data/{FundingRouteProviders.d.ts.map → FundingQuoteProviders.d.ts.map} +1 -1
  16. package/dist/apps/data/{FundingRouteProviders.js → FundingQuoteProviders.js} +36 -91
  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/funding.d.ts +207 -388
  35. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  36. package/dist/apps/data/routes/funding.js +143 -221
  37. package/dist/apps/data/routes/funding.js.map +1 -1
  38. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  39. package/dist/apps/data/routes/rpc.js +86 -16
  40. package/dist/apps/data/routes/rpc.js.map +1 -1
  41. package/dist/apps/data/routes/tokenlist.d.ts +33 -33
  42. package/dist/apps/data/routes/transactions.d.ts +281 -281
  43. package/dist/apps/data/routes/transfers.d.ts +58 -58
  44. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  45. package/dist/apps/data/routes/webhooks.d.ts +340 -0
  46. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  47. package/dist/apps/data/routes/webhooks.js +13 -1
  48. package/dist/apps/data/routes/webhooks.js.map +1 -1
  49. package/dist/apps/index.d.ts +1 -1
  50. package/dist/apps/index.js +1 -1
  51. package/dist/apps/management/App.d.ts +1090 -108
  52. package/dist/apps/management/App.d.ts.map +1 -1
  53. package/dist/apps/management/App.js +4 -0
  54. package/dist/apps/management/App.js.map +1 -1
  55. package/dist/apps/management/routes/api-keys.d.ts +118 -118
  56. package/dist/apps/management/routes/api-keys.js +2 -2
  57. package/dist/apps/management/routes/api-keys.js.map +1 -1
  58. package/dist/apps/management/routes/billing.d.ts +207 -207
  59. package/dist/apps/management/routes/invite-links.d.ts +1100 -0
  60. package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
  61. package/dist/apps/management/routes/invite-links.js +411 -0
  62. package/dist/apps/management/routes/invite-links.js.map +1 -0
  63. package/dist/apps/management/routes/me.d.ts +17 -17
  64. package/dist/apps/management/routes/members.d.ts +71 -71
  65. package/dist/apps/management/routes/orgs.d.ts +12 -12
  66. package/dist/apps/management/routes/projects.d.ts +70 -70
  67. package/dist/apps/management/routes/scopes.d.ts +11 -11
  68. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  69. package/dist/apps/management/routes/scopes.js +2 -2
  70. package/dist/apps/management/routes/scopes.js.map +1 -1
  71. package/dist/apps/management/routes/usage.d.ts +75 -75
  72. package/dist/apps/relay/App.d.ts.map +1 -1
  73. package/dist/apps/relay/App.js +42 -4
  74. package/dist/apps/relay/App.js.map +1 -1
  75. package/dist/db/Db.d.ts +3 -0
  76. package/dist/db/Db.d.ts.map +1 -1
  77. package/dist/db/Db.js +57 -0
  78. package/dist/db/Db.js.map +1 -1
  79. package/dist/db/Schema.d.ts +31 -0
  80. package/dist/db/Schema.d.ts.map +1 -1
  81. package/dist/db/Schema.js +31 -0
  82. package/dist/db/Schema.js.map +1 -1
  83. package/dist/db/tables/inviteLinks.d.ts +42 -0
  84. package/dist/db/tables/inviteLinks.d.ts.map +1 -0
  85. package/dist/db/tables/inviteLinks.js +148 -0
  86. package/dist/db/tables/inviteLinks.js.map +1 -0
  87. package/dist/db/tables/organizations.d.ts.map +1 -1
  88. package/dist/db/tables/organizations.js +2 -0
  89. package/dist/db/tables/organizations.js.map +1 -1
  90. package/dist/handlers/relay.d.ts.map +1 -1
  91. package/dist/handlers/relay.js +13 -3
  92. package/dist/handlers/relay.js.map +1 -1
  93. package/dist/internal/Auth.d.ts +9 -1
  94. package/dist/internal/Auth.d.ts.map +1 -1
  95. package/dist/internal/Auth.js +130 -10
  96. package/dist/internal/Auth.js.map +1 -1
  97. package/dist/internal/EdgeCache.d.ts +3 -1
  98. package/dist/internal/EdgeCache.d.ts.map +1 -1
  99. package/dist/internal/EdgeCache.js +17 -2
  100. package/dist/internal/EdgeCache.js.map +1 -1
  101. package/dist/internal/{FundingRoutes.d.ts → FundingQuotes.d.ts} +185 -428
  102. package/dist/internal/FundingQuotes.d.ts.map +1 -0
  103. package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -218
  104. package/dist/internal/FundingQuotes.js.map +1 -0
  105. package/dist/internal/Viem.d.ts +3 -1
  106. package/dist/internal/Viem.d.ts.map +1 -1
  107. package/dist/internal/Viem.js +11 -2
  108. package/dist/internal/Viem.js.map +1 -1
  109. package/dist/internal/index.d.ts +1 -1
  110. package/dist/internal/index.js +1 -1
  111. package/package.json +1 -1
  112. package/src/ApiKeys.test.ts +23 -0
  113. package/src/App.test-d.ts +2 -0
  114. package/src/App.ts +6 -0
  115. package/src/Client.test-d.ts +20 -11
  116. package/src/Scope.test.ts +1 -0
  117. package/src/Scope.ts +30 -4
  118. package/src/apps/data/App.test.ts +354 -0
  119. package/src/apps/data/App.ts +159 -52
  120. package/src/apps/data/FundingQuoteProviders.test.ts +151 -0
  121. package/src/apps/data/{FundingRouteProviders.ts → FundingQuoteProviders.ts} +40 -101
  122. package/src/apps/data/index.ts +1 -1
  123. package/src/apps/data/routes/balances.test.ts +64 -18
  124. package/src/apps/data/routes/balances.ts +76 -177
  125. package/src/apps/data/routes/blocks.ts +3 -5
  126. package/src/apps/data/routes/coingecko.ts +3 -8
  127. package/src/apps/data/routes/funding.test.ts +151 -206
  128. package/src/apps/data/routes/funding.ts +156 -291
  129. package/src/apps/data/routes/indexer.test.ts +97 -1
  130. package/src/apps/data/routes/rpc.test.ts +219 -0
  131. package/src/apps/data/routes/rpc.ts +92 -17
  132. package/src/apps/data/routes/webhooks.test.ts +21 -0
  133. package/src/apps/data/routes/webhooks.ts +13 -1
  134. package/src/apps/index.ts +1 -1
  135. package/src/apps/management/App.ts +4 -0
  136. package/src/apps/management/routes/api-keys.test.ts +47 -3
  137. package/src/apps/management/routes/api-keys.ts +2 -2
  138. package/src/apps/management/routes/invite-links.test.ts +385 -0
  139. package/src/apps/management/routes/invite-links.ts +570 -0
  140. package/src/apps/management/routes/scopes.test.ts +3 -2
  141. package/src/apps/management/routes/scopes.ts +4 -2
  142. package/src/apps/relay/App.test.ts +324 -4
  143. package/src/apps/relay/App.ts +40 -4
  144. package/src/db/Db.ts +65 -0
  145. package/src/db/Schema.ts +39 -0
  146. package/src/db/tables/inviteLinks.ts +187 -0
  147. package/src/db/tables/organizations.ts +2 -0
  148. package/src/handlers/relay.ts +16 -4
  149. package/src/internal/Auth.test.ts +132 -22
  150. package/src/internal/Auth.ts +171 -14
  151. package/src/internal/EdgeCache.test.ts +24 -3
  152. package/src/internal/EdgeCache.ts +18 -2
  153. package/src/internal/FundingQuotes.test.ts +174 -0
  154. package/src/internal/{FundingRoutes.ts → FundingQuotes.ts} +153 -312
  155. package/src/internal/Viem.ts +14 -5
  156. package/src/internal/index.test.ts +1 -1
  157. package/src/internal/index.ts +1 -1
  158. package/dist/apps/data/FundingRouteProviders.js.map +0 -1
  159. package/dist/internal/FundingRoutes.d.ts.map +0 -1
  160. package/dist/internal/FundingRoutes.js.map +0 -1
  161. package/src/apps/data/FundingRouteProviders.test.ts +0 -225
  162. package/src/internal/FundingRoutes.test.ts +0 -213
@@ -1,12 +1,17 @@
1
+ import { Challenge } from 'mppx'
2
+ import { sessionManager } from 'mppx/client'
1
3
  import { RateLimit, Store } from 'tapimo'
4
+ import { Actions } from 'viem/tempo'
2
5
  import * as TestApp from '../../../../test/App.js'
3
6
  import * as Mppx from '../../../../test/Mppx.js'
4
7
  import * as Runtime from '../../../../test/runtime.js'
5
8
  import * as Tempo from '../../../../test/Tempo.js'
6
9
  import * as Tidx from '../../../internal/Tidx.js'
10
+ import * as Viem from '../../../internal/Viem.js'
7
11
  import * as Indexer from './indexer.js'
8
12
 
9
- const chainId = String(Runtime.get().chainId)
13
+ const runtime = Runtime.get()
14
+ const chainId = String(runtime.chainId)
10
15
 
11
16
  describe('GET /indexer/query', () => {
12
17
  test('proxies a query to the upstream indexer', async () => {
@@ -54,6 +59,42 @@ describe('GET /indexer/query', () => {
54
59
  expect(body.rows[0]?.[0]).toBe(value)
55
60
  })
56
61
 
62
+ test('routes Zone queries to the Zone TIDX with its basic auth', async () => {
63
+ const zone = runtime.zone.chainId
64
+ const zoneReader = {
65
+ id: 'key_zone_indexer',
66
+ orgId: 'org_test',
67
+ scopes: ['data:read', 'indexer:query', `zone:${zone}:read`],
68
+ token: 'secret_zone_indexer',
69
+ } satisfies TestApp.kvStore.Key
70
+ const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
71
+ new Response(JSON.stringify({ columns: [], ok: true, row_count: 0, rows: [] }), {
72
+ headers: { 'content-type': 'application/json' },
73
+ }),
74
+ )
75
+ try {
76
+ const client = TestApp.client({
77
+ auth: { keys: [zoneReader] },
78
+ tidx: ({ chainId }) =>
79
+ chainId === zone
80
+ ? { baseUrl: 'https://zone.tidx.test', basicAuth: 'zone-secret' }
81
+ : { baseUrl: 'https://public.tidx.test', basicAuth: 'public-secret' },
82
+ zones: [{ chainId: zone, forwardRpcUrl: runtime.zone.forwardRpcUrl }],
83
+ })
84
+ const response = await client.v1.indexer.query.$get(
85
+ { query: { chainId: String(zone), sql: 'select 1' } },
86
+ { headers: { authorization: `Bearer ${zoneReader.token}` } },
87
+ )
88
+ const upstream = fetch.mock.calls[0]![0] as Request
89
+
90
+ expect(response.status).toBe(200)
91
+ expect(new URL(upstream.url).origin).toBe('https://zone.tidx.test')
92
+ expect(upstream.headers.get('authorization')).toBe('Basic em9uZS1zZWNyZXQ=')
93
+ } finally {
94
+ fetch.mockRestore()
95
+ }
96
+ })
97
+
57
98
  test('bounds and caches buffered queries', async () => {
58
99
  // Stub the upstream so the injected execution bounds are observable on the
59
100
  // outgoing request; the auth path is in-memory and never touches fetch.
@@ -167,6 +208,61 @@ describe('GET /indexer/query', () => {
167
208
  expect(paid.headers.has('payment-receipt')).toMatchInlineSnapshot(`true`)
168
209
  expect(body.ok).toMatchInlineSnapshot(`true`)
169
210
  })
211
+
212
+ test('accepts session management POSTs', { timeout: 120_000 }, async () => {
213
+ const rateLimitStore = Store.memory()
214
+ const app = TestApp.create({
215
+ rateLimit: { store: rateLimitStore },
216
+ mpp: {
217
+ secretKey: 'secret_test_key_0123456789abcdef',
218
+ session: {
219
+ account: Tempo.accounts[2],
220
+ chainId: Viem.chainId.mainnet,
221
+ currency: Tempo.currency,
222
+ decimals: 6,
223
+ getClient: () => Tempo.client,
224
+ suggestedDeposit: '0.01',
225
+ },
226
+ },
227
+ })
228
+ await exhaustQuota(RateLimit.memory({ store: rateLimitStore }), 'public:anonymous', {
229
+ perMinute: 60,
230
+ })
231
+ await Actions.faucet.fundSync(Tempo.client, {
232
+ account: Tempo.accounts[2],
233
+ timeout: 60_000,
234
+ })
235
+
236
+ const url = `http://tempo-api.test/v1/indexer/query?sql=select%201&chainId=${chainId}`
237
+ const challenged = await app.fetch(new Request(url))
238
+ expect(challenged.status).toMatchInlineSnapshot(`402`)
239
+ expect(Challenge.fromHeaders(challenged.headers).request).toMatchObject({
240
+ amount: '100',
241
+ suggestedDeposit: '10000',
242
+ })
243
+
244
+ const manager = sessionManager({
245
+ account: Tempo.accounts[1],
246
+ client: Tempo.client,
247
+ fetch: async (input, init) => app.fetch(new Request(input, init)),
248
+ })
249
+ const paid = await manager.fetch(url)
250
+ expect(paid.status).toMatchInlineSnapshot(`200`)
251
+
252
+ const toppedUp = await manager.topUp('0.01')
253
+ if (!toppedUp) throw new Error('expected top-up receipt')
254
+ expect(toppedUp.status).toMatchInlineSnapshot(`"success"`)
255
+
256
+ const closed = await manager.close()
257
+ if (!closed) throw new Error('expected close receipt')
258
+ expect(closed.status).toMatchInlineSnapshot(`"success"`)
259
+ expect(closed.channelId).toBe(toppedUp.channelId)
260
+
261
+ const state = await Actions.channel.getStates(Tempo.client, {
262
+ channel: closed.channelId,
263
+ })
264
+ expect(state.deposit).toMatchInlineSnapshot(`0n`)
265
+ })
170
266
  })
171
267
 
172
268
  async function exhaustQuota(rateLimit: RateLimit.Store, key: string, limit: RateLimit.Limit) {
@@ -1,8 +1,43 @@
1
+ import { Hex, Secp256k1 } from 'ox'
2
+ import { ZoneRpcAuthentication } from 'ox/tempo'
3
+
1
4
  import type * as Viem from '../../../internal/Viem.js'
5
+ import * as RequestListener from '../../../handlers/internal/requestListener.js'
2
6
  import * as TestApp from '../../../../test/App.js'
7
+ import * as Relay from '../../../../test/Relay.js'
3
8
  import * as Runtime from '../../../../test/runtime.js'
4
9
 
5
10
  const runtime = Runtime.get()
11
+ const zone = runtime.zone.chainId
12
+ const zoneOptions = {
13
+ chainId: zone,
14
+ forwardRpcUrl: runtime.zone.forwardRpcUrl,
15
+ }
16
+ const zoneReader = {
17
+ id: 'key_zone_reader',
18
+ orgId: 'org_test',
19
+ scopes: ['data:read', `zone:${zone}:read`],
20
+ token: 'secret_zone_reader',
21
+ } satisfies TestApp.kvStore.Key
22
+
23
+ // Prool's default Zone dev key.
24
+ const zonePrivateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
25
+
26
+ function zoneToken() {
27
+ const issuedAt = Math.floor(Date.now() / 1_000)
28
+ const authentication = ZoneRpcAuthentication.from({
29
+ chainId: zone,
30
+ expiresAt: issuedAt + 300,
31
+ issuedAt,
32
+ zoneId: 1,
33
+ })
34
+ return ZoneRpcAuthentication.serialize(authentication, {
35
+ signature: Secp256k1.sign({
36
+ payload: ZoneRpcAuthentication.getSignPayload(authentication),
37
+ privateKey: zonePrivateKey,
38
+ }),
39
+ })
40
+ }
6
41
 
7
42
  describe('POST /rpc', () => {
8
43
  test('routes by principal: a resolver sees the request principal and routes accordingly', async () => {
@@ -65,6 +100,7 @@ describe('POST /rpc', () => {
65
100
  'content-type': 'application/json',
66
101
  cookie: 'session=secret',
67
102
  'tempo-api-key': 'caller-key',
103
+ 'x-authorization-token': 'zone-secret',
68
104
  'x-client-trace': 'trace_test',
69
105
  },
70
106
  method: 'POST',
@@ -90,6 +126,7 @@ describe('POST /rpc', () => {
90
126
  expect(call.headers['authorization']).toMatchInlineSnapshot(`"Basic cnBjLXNlY3JldA=="`)
91
127
  expect(call.headers['cookie']).toMatchInlineSnapshot(`null`)
92
128
  expect(call.headers['tempo-api-key']).toMatchInlineSnapshot(`null`)
129
+ expect(call.headers['x-authorization-token']).toBeNull()
93
130
  expect(call.headers['x-client-trace']).toMatchInlineSnapshot(`"trace_test"`)
94
131
  } finally {
95
132
  fetch.restore()
@@ -267,6 +304,187 @@ describe('POST /rpc', () => {
267
304
  }
268
305
  })
269
306
 
307
+ test('proxies zone chain ids and forwards the caller zone auth token', async () => {
308
+ const fetch = mockRpc()
309
+
310
+ try {
311
+ const app = TestApp.create({
312
+ auth: false,
313
+ zones: [{ chainId: zone, forwardRpcUrl: `https://${zone}.rpc.test` }],
314
+ })
315
+
316
+ const response = await app.fetch(
317
+ new Request(`http://tempo-api.test/rpc/${zone}`, {
318
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_getBalance' }),
319
+ headers: {
320
+ authorization: 'Bearer caller-secret',
321
+ 'x-authorization-token': 'zone-token',
322
+ },
323
+ method: 'POST',
324
+ }),
325
+ )
326
+ const call = fetch.calls[0]!
327
+
328
+ expect(response.status).toMatchInlineSnapshot(`200`)
329
+ expect(call.url).toBe(`https://${zone}.rpc.test/`)
330
+ // Zone RPC auth: the caller's signed token passes through; the API's own
331
+ // credentials do not leak upstream.
332
+ expect(call.headers['x-authorization-token']).toMatchInlineSnapshot(`"zone-token"`)
333
+ expect(call.headers['authorization']).toBeNull()
334
+ expect(call.headers['tempo-api-key']).toMatchInlineSnapshot(`null`)
335
+ } finally {
336
+ fetch.restore()
337
+ }
338
+ })
339
+
340
+ test('refuses a zone rpc call without a zone scope or auth token', async () => {
341
+ const fetch = mockRpc()
342
+
343
+ try {
344
+ const app = TestApp.create({
345
+ auth: false,
346
+ zones: [{ chainId: zone, forwardRpcUrl: `https://${zone}.rpc.test` }],
347
+ })
348
+
349
+ const response = await app.fetch(
350
+ new Request(`http://tempo-api.test/rpc/${zone}`, {
351
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_getBalance' }),
352
+ method: 'POST',
353
+ }),
354
+ )
355
+ const body = (await response.json()) as { error: { code: string; message: string } }
356
+
357
+ expect(response.status).toBe(403)
358
+ expect(body.error.code).toBe('api_key_forbidden')
359
+ expect(body.error.message).toContain(`zone:${zone}:read`)
360
+ // Refused before any upstream round-trip.
361
+ expect(fetch.calls).toHaveLength(0)
362
+ } finally {
363
+ fetch.restore()
364
+ }
365
+ })
366
+
367
+ test('routes Zone-scoped reads to the internal RPC', async () => {
368
+ const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] })
369
+ const response = await app.fetch(
370
+ new Request(`http://tempo-api.test/rpc/${zone}`, {
371
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
372
+ headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token },
373
+ method: 'POST',
374
+ }),
375
+ )
376
+
377
+ expect(response.status).toBe(200)
378
+ expect(await response.json()).toMatchObject({ result: Hex.fromNumber(zone) })
379
+ })
380
+
381
+ test('allows Zone-scoped eth_fillTransaction calls', async () => {
382
+ const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] })
383
+ const response = await app.fetch(
384
+ new Request(`http://tempo-api.test/rpc/${zone}`, {
385
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_fillTransaction' }),
386
+ headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token },
387
+ method: 'POST',
388
+ }),
389
+ )
390
+
391
+ expect(response.status).toBe(200)
392
+ })
393
+
394
+ test('forwards RPC basic auth to the internal Zone RPC', async () => {
395
+ const authorization: (string | null)[] = []
396
+ const server = await Relay.createServer(
397
+ RequestListener.fromFetchHandler(async (request) => {
398
+ authorization.push(request.headers.get('authorization'))
399
+ return Response.json({ id: 1, jsonrpc: '2.0', result: Hex.fromNumber(zone) })
400
+ }),
401
+ )
402
+ try {
403
+ const app = TestApp.create({
404
+ auth: { keys: [zoneReader] },
405
+ rpc: ({ chainId }) =>
406
+ chainId === zone ? { basicAuth: 'rpc-secret', url: server.url } : {},
407
+ zones: [zoneOptions],
408
+ })
409
+ const response = await app.fetch(
410
+ new Request(`http://tempo-api.test/rpc/${zone}`, {
411
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
412
+ headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token },
413
+ method: 'POST',
414
+ }),
415
+ )
416
+
417
+ expect(response.status).toBe(200)
418
+ expect(authorization).toEqual(['Basic cnBjLXNlY3JldA=='])
419
+ } finally {
420
+ await server.closeAsync()
421
+ }
422
+ })
423
+
424
+ test('rejects Zone-scoped writes before the internal RPC', async () => {
425
+ const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] })
426
+ const response = await app.fetch(
427
+ new Request(`http://tempo-api.test/rpc/${zone}`, {
428
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_sendRawTransaction' }),
429
+ headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token },
430
+ method: 'POST',
431
+ }),
432
+ )
433
+
434
+ expect(response.status).toBe(403)
435
+ expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } })
436
+ })
437
+
438
+ test('rejects a Zone-scoped batch containing a write', async () => {
439
+ const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] })
440
+ const response = await app.fetch(
441
+ new Request(`http://tempo-api.test/rpc/${zone}`, {
442
+ body: JSON.stringify([
443
+ { id: 1, jsonrpc: '2.0', method: 'eth_chainId' },
444
+ { id: 2, jsonrpc: '2.0', method: 'eth_sendRawTransaction' },
445
+ ]),
446
+ headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token },
447
+ method: 'POST',
448
+ }),
449
+ )
450
+
451
+ expect(response.status).toBe(403)
452
+ expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } })
453
+ })
454
+
455
+ test('routes caller Zone tokens to the private RPC', async () => {
456
+ const app = TestApp.create({ auth: false, zones: [zoneOptions] })
457
+ const response = await app.fetch(
458
+ new Request(`http://tempo-api.test/rpc/${zone}`, {
459
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
460
+ headers: {
461
+ 'content-type': 'application/json',
462
+ [ZoneRpcAuthentication.headerName]: zoneToken(),
463
+ },
464
+ method: 'POST',
465
+ }),
466
+ )
467
+
468
+ expect(response.status).toBe(200)
469
+ expect(await response.json()).toMatchObject({ result: Hex.fromNumber(zone) })
470
+ })
471
+
472
+ test('rejects invalid Zone tokens at the private RPC', async () => {
473
+ const app = TestApp.create({ auth: false, zones: [zoneOptions] })
474
+ const response = await app.fetch(
475
+ new Request(`http://tempo-api.test/rpc/${zone}`, {
476
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }),
477
+ headers: {
478
+ 'content-type': 'application/json',
479
+ [ZoneRpcAuthentication.headerName]: 'invalid',
480
+ },
481
+ method: 'POST',
482
+ }),
483
+ )
484
+
485
+ expect(response.status).toBe(403)
486
+ })
487
+
270
488
  test('rejects invalid chain ids before proxying upstream', async () => {
271
489
  const fetch = mockRpc()
272
490
 
@@ -520,6 +738,7 @@ function mockRpc() {
520
738
  authorization: request.headers.get('authorization'),
521
739
  cookie: request.headers.get('cookie'),
522
740
  'tempo-api-key': request.headers.get('tempo-api-key'),
741
+ 'x-authorization-token': request.headers.get('x-authorization-token'),
523
742
  'x-client-trace': request.headers.get('x-client-trace'),
524
743
  },
525
744
  method: request.method,
@@ -1,4 +1,5 @@
1
1
  import { Hono, type Context, type TypedResponse } from 'hono'
2
+ import { ZoneRpcAuthentication } from 'ox/tempo'
2
3
  import * as z from 'zod/mini'
3
4
 
4
5
  import type * as App from '../../../App.js'
@@ -19,10 +20,52 @@ const strippedHeaders = new Set([
19
20
  'keep-alive',
20
21
  'proxy-authorization',
21
22
  'te',
23
+ 'tempo-api-key',
22
24
  'trailer',
23
25
  'transfer-encoding',
24
26
  'upgrade',
25
- 'tempo-api-key',
27
+ ZoneRpcAuthentication.headerName.toLowerCase(),
28
+ ])
29
+
30
+ // API-issued Zone read scopes never reach stateful or privileged RPC modules.
31
+ const zoneReadMethods = new Set([
32
+ 'eth_blobBaseFee',
33
+ 'eth_blockNumber',
34
+ 'eth_call',
35
+ 'eth_chainId',
36
+ 'eth_createAccessList',
37
+ 'eth_estimateGas',
38
+ 'eth_feeHistory',
39
+ 'eth_fillTransaction',
40
+ 'eth_gasPrice',
41
+ 'eth_getBalance',
42
+ 'eth_getBlockByHash',
43
+ 'eth_getBlockByNumber',
44
+ 'eth_getBlockReceipts',
45
+ 'eth_getBlockTransactionCountByHash',
46
+ 'eth_getBlockTransactionCountByNumber',
47
+ 'eth_getCode',
48
+ 'eth_getLogs',
49
+ 'eth_getProof',
50
+ 'eth_getRawTransactionByHash',
51
+ 'eth_getStorageAt',
52
+ 'eth_getTransactionByBlockHashAndIndex',
53
+ 'eth_getTransactionByBlockNumberAndIndex',
54
+ 'eth_getTransactionByHash',
55
+ 'eth_getTransactionCount',
56
+ 'eth_getTransactionReceipt',
57
+ 'eth_getUncleByBlockHashAndIndex',
58
+ 'eth_getUncleByBlockNumberAndIndex',
59
+ 'eth_getUncleCountByBlockHash',
60
+ 'eth_getUncleCountByBlockNumber',
61
+ 'eth_maxPriorityFeePerGas',
62
+ 'eth_simulateV1',
63
+ 'eth_syncing',
64
+ 'net_listening',
65
+ 'net_peerCount',
66
+ 'net_version',
67
+ 'web3_clientVersion',
68
+ 'web3_sha3',
26
69
  ])
27
70
 
28
71
  /** Never reached; carries typed passthrough response variants into Hono client inference. */
@@ -105,7 +148,7 @@ export function rpc() {
105
148
  Auth.policy({ apiKey: { scopes: ['data:read'] }, public: true }),
106
149
  OpenApi.describeRoute({
107
150
  description:
108
- 'Sends a single or batch Ethereum JSON-RPC request directly to Tempo. Use `/rpc/testnet`, `/rpc/mainnet`, or `/rpc/:chain` to choose a chain. This passthrough is not part of the stable API contract and is supported on a best-effort basis only. It has no compatibility, latency, availability, or data-freshness guarantees, and breaking changes may happen with limited notice.',
151
+ 'Sends a single or batch Ethereum JSON-RPC request directly to Tempo. Use `/rpc/testnet`, `/rpc/mainnet`, or `/rpc/:chain` to choose a chain. Zone-scoped API keys may call read-only methods. A Zone token in `X-Authorization-Token` is forwarded to the authenticated Zone RPC. This passthrough is not part of the stable API contract and is supported on a best-effort basis only. It has no compatibility, latency, availability, or data-freshness guarantees, and breaking changes may happen with limited notice.',
109
152
  operationId: 'rpcRequest',
110
153
  parameters: [
111
154
  {
@@ -146,19 +189,6 @@ export function rpc() {
146
189
  async (c) => {
147
190
  if (Auth.narrowAccess) return Auth.accessError(c)
148
191
 
149
- if (requiresApiKey(c)) {
150
- const body = await c.req.raw
151
- .clone()
152
- .json()
153
- .catch(() => undefined)
154
- if (!isPublicRequest(body))
155
- return Response.error(c, {
156
- code: 'api_key_missing',
157
- message: 'Missing API key',
158
- status: 401,
159
- })
160
- }
161
-
162
192
  const chainId = resolveChainId(c)
163
193
  if (!chainId)
164
194
  return Response.error(c, {
@@ -167,6 +197,31 @@ export function rpc() {
167
197
  status: 400,
168
198
  })
169
199
 
200
+ const zone = c.get('zones').get(chainId)
201
+ const zoneToken = getZoneToken(c)
202
+ const apiKeyRequired = requiresApiKey(c)
203
+ const inspectBody = apiKeyRequired || Boolean(zone && !zoneToken)
204
+ const body = inspectBody
205
+ ? await c.req.raw
206
+ .clone()
207
+ .json()
208
+ .catch(() => undefined)
209
+ : undefined
210
+
211
+ if (apiKeyRequired && !isPublicRequest(body))
212
+ return Response.error(c, {
213
+ code: 'api_key_missing',
214
+ message: 'Missing API key',
215
+ status: 401,
216
+ })
217
+ if (zone && !zoneToken && !isZoneReadRequest(body))
218
+ return Response.error(c, {
219
+ code: 'api_key_forbidden',
220
+ message:
221
+ 'Zone API keys may only call read-only RPC methods. Pass a Zone token via `X-Authorization-Token` for other methods.',
222
+ status: 403,
223
+ })
224
+
170
225
  if (narrowPassthrough)
171
226
  return c.json(Response.validated(schema.ResponseBody, { jsonrpc: '2.0' }), 200)
172
227
  return passthrough(c, chainId)
@@ -180,6 +235,11 @@ function requiresApiKey(c: Context<App.Environment>) {
180
235
  return principal?.type !== 'api_key' && principal?.type !== 'super_admin'
181
236
  }
182
237
 
238
+ function getZoneToken(c: Context<App.Environment>) {
239
+ const value = c.req.header(ZoneRpcAuthentication.headerName)
240
+ return value?.trim() ? value : undefined
241
+ }
242
+
183
243
  function isPublicRequest(body: unknown): boolean {
184
244
  const requests = Array.isArray(body) ? body : [body]
185
245
  return requests.length > 0 && requests.every((request) => isPublicMethod(request))
@@ -191,13 +251,27 @@ function isPublicMethod(request: unknown): boolean {
191
251
  return typeof method === 'string' && method.startsWith('eth_')
192
252
  }
193
253
 
254
+ function isZoneReadRequest(body: unknown): boolean {
255
+ const requests = Array.isArray(body) ? body : [body]
256
+ return requests.length > 0 && requests.every((request) => isZoneReadMethod(request))
257
+ }
258
+
259
+ function isZoneReadMethod(request: unknown): boolean {
260
+ if (!request || typeof request !== 'object') return false
261
+ const method = (request as Record<string, unknown>)['method']
262
+ return typeof method === 'string' && zoneReadMethods.has(method)
263
+ }
264
+
194
265
  async function passthrough(
195
266
  c: Context<App.Environment>,
196
267
  chainId: z.output<typeof Schema.ChainId>,
197
268
  ): Promise<globalThis.Response & TypedResponse<never, never, never>> {
198
- const rpc = c.get('rpc')
269
+ const zone = c.get('zones').get(chainId)
270
+ const zoneToken = getZoneToken(c)
271
+ const rpc = zone && zoneToken ? { url: () => zone.forwardRpcUrl } : c.get('rpc')
199
272
  const principal = Auth.getPrincipal(c)
200
- const upstream_rpc = principal?.type === 'public' && typeof rpc !== 'function' ? undefined : rpc
273
+ const upstream_rpc =
274
+ !zone && principal?.type === 'public' && typeof rpc !== 'function' ? undefined : rpc
201
275
  // Anonymous callers use the built-in public RPC unless a resolver chooses a
202
276
  // different public upstream.
203
277
  const { basicAuth, url } = Viem.resolve(upstream_rpc, { chainId, principal })
@@ -215,6 +289,7 @@ async function passthrough(
215
289
  for (const [name, value] of c.req.raw.headers)
216
290
  if (!strippedHeaders.has(name.toLowerCase())) headers.set(name, value)
217
291
  if (basicAuth) headers.set('authorization', `Basic ${btoa(basicAuth)}`)
292
+ if (zone && zoneToken) headers.set(ZoneRpcAuthentication.headerName, zoneToken)
218
293
 
219
294
  // `duplex` is required when streaming a request body, but is not yet in
220
295
  // the DOM `RequestInit` type.
@@ -112,6 +112,27 @@ describe('access matrix', () => {
112
112
  expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`)
113
113
  })
114
114
 
115
+ test('rejects Zone webhook subscriptions', async () => {
116
+ const zone = 421_700_001
117
+ const app = TestApp.client({
118
+ auth: { keys: [writer] },
119
+ zones: [
120
+ {
121
+ chainId: zone,
122
+ forwardRpcUrl: 'https://zone.rpc.test',
123
+ },
124
+ ],
125
+ })
126
+ const response = await app.v1.webhooks.$post(
127
+ { json: { ...validBody, chainId: zone } },
128
+ as(writer),
129
+ )
130
+ const body = (await response.json()) as { error?: { code?: string } }
131
+
132
+ expect(response.status).toBe(400)
133
+ expect(body.error?.code).toBe('chain_id_unsupported')
134
+ })
135
+
115
136
  test('lists event types without authentication (public catalog)', async () => {
116
137
  // `GET /webhooks/event-types` is intentionally public: anonymous callers are
117
138
  // served within the public quota, no key or scope required.
@@ -1294,7 +1294,13 @@ export function webhooks(options: webhooks.Options = {}) {
1294
1294
  hide: hidden,
1295
1295
  operationId: 'createWebhook',
1296
1296
  responses: OpenApi.responses({
1297
- errors: { 403: 'You have reached the webhook subscription limit.' },
1297
+ errors: {
1298
+ 400: {
1299
+ codes: ['chain_id_unsupported'],
1300
+ description: 'Webhook subscriptions do not support Zone chains.',
1301
+ },
1302
+ 403: 'You have reached the webhook subscription limit.',
1303
+ },
1298
1304
  success: {
1299
1305
  description:
1300
1306
  'The created webhook subscription, including the one-time signing secret.',
@@ -1320,6 +1326,12 @@ export function webhooks(options: webhooks.Options = {}) {
1320
1326
 
1321
1327
  const body = c.req.valid('json')
1322
1328
  const chainId = body.chainId ?? c.get('chainId')
1329
+ if (c.get('zones').has(chainId))
1330
+ return Response.error(c, {
1331
+ code: 'chain_id_unsupported',
1332
+ message: 'Webhook subscriptions do not support Zone chains.',
1333
+ status: 400,
1334
+ })
1323
1335
 
1324
1336
  try {
1325
1337
  const subscription = await Webhooks.createSubscription(
package/src/apps/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { data, FundingRouteProviders } from './data/index.js'
1
+ export { data, FundingQuoteProviders } from './data/index.js'
2
2
  export { management } from './management/index.js'
3
3
  export { mcp } from './mcp.js'
4
4
  export { relay } from './relay/index.js'
@@ -7,6 +7,7 @@ import * as Metadata from '../metadata.js'
7
7
  import { apiKeys } from './routes/api-keys.js'
8
8
  import { billing } from './routes/billing.js'
9
9
  import { invitations } from './routes/invitations.js'
10
+ import { inviteLinks } from './routes/invite-links.js'
10
11
  import { me } from './routes/me.js'
11
12
  import { members } from './routes/members.js'
12
13
  import { orgs } from './routes/orgs.js'
@@ -105,6 +106,7 @@ export function management(options: management.Options = {}) {
105
106
  .route('/', apiKeys({ enabled: options.apiKeys !== false }))
106
107
  .route('/', billing())
107
108
  .route('/', invitations())
109
+ .route('/', inviteLinks())
108
110
  .route('/', me())
109
111
  .route('/', members())
110
112
  .route('/', orgs())
@@ -144,6 +146,7 @@ const openapi = {
144
146
  { name: 'API Keys', description: 'Credentials for accessing the Tempo API.' },
145
147
  { name: 'Billing', description: 'Organization billing backed by Stripe.' },
146
148
  { name: 'Invitations', description: 'Pending organization invitations.' },
149
+ { name: 'Invite Links', description: 'Reusable organization invite links.' },
147
150
  { name: 'Members', description: 'Organization team membership.' },
148
151
  { name: 'Organizations', description: 'Teams that own application workspaces and members.' },
149
152
  { name: 'Projects', description: 'Application workspaces within an organization.' },
@@ -158,6 +161,7 @@ const openapi = {
158
161
  'Authentication',
159
162
  'Billing',
160
163
  'Invitations',
164
+ 'Invite Links',
161
165
  'Members',
162
166
  'Organizations',
163
167
  'Projects',