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.
- package/dist/App.d.ts +7403 -6127
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +4 -0
- package/dist/App.js.map +1 -1
- package/dist/Scope.d.ts +18 -1
- package/dist/Scope.d.ts.map +1 -1
- package/dist/Scope.js +24 -3
- package/dist/Scope.js.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +115 -115
- package/dist/apps/data/App.d.ts +1838 -1534
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +135 -44
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/{FundingRouteProviders.d.ts → FundingQuoteProviders.d.ts} +7 -7
- package/dist/apps/data/{FundingRouteProviders.d.ts.map → FundingQuoteProviders.d.ts.map} +1 -1
- package/dist/apps/data/{FundingRouteProviders.js → FundingQuoteProviders.js} +36 -91
- package/dist/apps/data/FundingQuoteProviders.js.map +1 -0
- package/dist/apps/data/index.d.ts +1 -1
- package/dist/apps/data/index.js +1 -1
- package/dist/apps/data/routes/activities.d.ts +242 -242
- package/dist/apps/data/routes/addresses.d.ts +227 -227
- package/dist/apps/data/routes/balances.d.ts +12 -12
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +67 -149
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +125 -126
- package/dist/apps/data/routes/blocks.d.ts.map +1 -1
- package/dist/apps/data/routes/blocks.js +3 -5
- package/dist/apps/data/routes/blocks.js.map +1 -1
- package/dist/apps/data/routes/coingecko.d.ts +55 -54
- package/dist/apps/data/routes/coingecko.d.ts.map +1 -1
- package/dist/apps/data/routes/coingecko.js +3 -8
- package/dist/apps/data/routes/coingecko.js.map +1 -1
- package/dist/apps/data/routes/funding.d.ts +207 -388
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +143 -221
- package/dist/apps/data/routes/funding.js.map +1 -1
- package/dist/apps/data/routes/rpc.d.ts.map +1 -1
- package/dist/apps/data/routes/rpc.js +86 -16
- package/dist/apps/data/routes/rpc.js.map +1 -1
- package/dist/apps/data/routes/tokenlist.d.ts +33 -33
- package/dist/apps/data/routes/transactions.d.ts +281 -281
- package/dist/apps/data/routes/transfers.d.ts +58 -58
- package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
- package/dist/apps/data/routes/webhooks.d.ts +340 -0
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +13 -1
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/index.d.ts +1 -1
- package/dist/apps/index.js +1 -1
- package/dist/apps/management/App.d.ts +1090 -108
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/App.js +4 -0
- package/dist/apps/management/App.js.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +118 -118
- package/dist/apps/management/routes/api-keys.js +2 -2
- package/dist/apps/management/routes/api-keys.js.map +1 -1
- package/dist/apps/management/routes/billing.d.ts +207 -207
- package/dist/apps/management/routes/invite-links.d.ts +1100 -0
- package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
- package/dist/apps/management/routes/invite-links.js +411 -0
- package/dist/apps/management/routes/invite-links.js.map +1 -0
- package/dist/apps/management/routes/me.d.ts +17 -17
- package/dist/apps/management/routes/members.d.ts +71 -71
- package/dist/apps/management/routes/orgs.d.ts +12 -12
- package/dist/apps/management/routes/projects.d.ts +70 -70
- package/dist/apps/management/routes/scopes.d.ts +11 -11
- package/dist/apps/management/routes/scopes.d.ts.map +1 -1
- package/dist/apps/management/routes/scopes.js +2 -2
- package/dist/apps/management/routes/scopes.js.map +1 -1
- package/dist/apps/management/routes/usage.d.ts +75 -75
- package/dist/apps/relay/App.d.ts.map +1 -1
- package/dist/apps/relay/App.js +42 -4
- package/dist/apps/relay/App.js.map +1 -1
- package/dist/db/Db.d.ts +3 -0
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +57 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +31 -0
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +31 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/inviteLinks.d.ts +42 -0
- package/dist/db/tables/inviteLinks.d.ts.map +1 -0
- package/dist/db/tables/inviteLinks.js +148 -0
- package/dist/db/tables/inviteLinks.js.map +1 -0
- package/dist/db/tables/organizations.d.ts.map +1 -1
- package/dist/db/tables/organizations.js +2 -0
- package/dist/db/tables/organizations.js.map +1 -1
- package/dist/handlers/relay.d.ts.map +1 -1
- package/dist/handlers/relay.js +13 -3
- package/dist/handlers/relay.js.map +1 -1
- package/dist/internal/Auth.d.ts +9 -1
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +130 -10
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/EdgeCache.d.ts +3 -1
- package/dist/internal/EdgeCache.d.ts.map +1 -1
- package/dist/internal/EdgeCache.js +17 -2
- package/dist/internal/EdgeCache.js.map +1 -1
- package/dist/internal/{FundingRoutes.d.ts → FundingQuotes.d.ts} +185 -428
- package/dist/internal/FundingQuotes.d.ts.map +1 -0
- package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -218
- package/dist/internal/FundingQuotes.js.map +1 -0
- package/dist/internal/Viem.d.ts +3 -1
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +11 -2
- package/dist/internal/Viem.js.map +1 -1
- package/dist/internal/index.d.ts +1 -1
- package/dist/internal/index.js +1 -1
- package/package.json +1 -1
- package/src/ApiKeys.test.ts +23 -0
- package/src/App.test-d.ts +2 -0
- package/src/App.ts +6 -0
- package/src/Client.test-d.ts +20 -11
- package/src/Scope.test.ts +1 -0
- package/src/Scope.ts +30 -4
- package/src/apps/data/App.test.ts +354 -0
- package/src/apps/data/App.ts +159 -52
- package/src/apps/data/FundingQuoteProviders.test.ts +151 -0
- package/src/apps/data/{FundingRouteProviders.ts → FundingQuoteProviders.ts} +40 -101
- package/src/apps/data/index.ts +1 -1
- package/src/apps/data/routes/balances.test.ts +64 -18
- package/src/apps/data/routes/balances.ts +76 -177
- package/src/apps/data/routes/blocks.ts +3 -5
- package/src/apps/data/routes/coingecko.ts +3 -8
- package/src/apps/data/routes/funding.test.ts +151 -206
- package/src/apps/data/routes/funding.ts +156 -291
- package/src/apps/data/routes/indexer.test.ts +97 -1
- package/src/apps/data/routes/rpc.test.ts +219 -0
- package/src/apps/data/routes/rpc.ts +92 -17
- package/src/apps/data/routes/webhooks.test.ts +21 -0
- package/src/apps/data/routes/webhooks.ts +13 -1
- package/src/apps/index.ts +1 -1
- package/src/apps/management/App.ts +4 -0
- package/src/apps/management/routes/api-keys.test.ts +47 -3
- package/src/apps/management/routes/api-keys.ts +2 -2
- package/src/apps/management/routes/invite-links.test.ts +385 -0
- package/src/apps/management/routes/invite-links.ts +570 -0
- package/src/apps/management/routes/scopes.test.ts +3 -2
- package/src/apps/management/routes/scopes.ts +4 -2
- package/src/apps/relay/App.test.ts +324 -4
- package/src/apps/relay/App.ts +40 -4
- package/src/db/Db.ts +65 -0
- package/src/db/Schema.ts +39 -0
- package/src/db/tables/inviteLinks.ts +187 -0
- package/src/db/tables/organizations.ts +2 -0
- package/src/handlers/relay.ts +16 -4
- package/src/internal/Auth.test.ts +132 -22
- package/src/internal/Auth.ts +171 -14
- package/src/internal/EdgeCache.test.ts +24 -3
- package/src/internal/EdgeCache.ts +18 -2
- package/src/internal/FundingQuotes.test.ts +174 -0
- package/src/internal/{FundingRoutes.ts → FundingQuotes.ts} +153 -312
- package/src/internal/Viem.ts +14 -5
- package/src/internal/index.test.ts +1 -1
- package/src/internal/index.ts +1 -1
- package/dist/apps/data/FundingRouteProviders.js.map +0 -1
- package/dist/internal/FundingRoutes.d.ts.map +0 -1
- package/dist/internal/FundingRoutes.js.map +0 -1
- package/src/apps/data/FundingRouteProviders.test.ts +0 -225
- 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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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: {
|
|
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
|
@@ -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',
|