import { Hex, Secp256k1 } from 'ox' import { ZoneRpcAuthentication } from 'ox/tempo' import type * as Log from '../../../internal/Log.js' import type * as Viem from '../../../internal/Viem.js' import * as RequestListener from '../../../handlers/internal/requestListener.js' import * as TestApp from '../../../../test/App.js' import * as Relay from '../../../../test/Relay.js' import * as Runtime from '../../../../test/runtime.js' const runtime = Runtime.get() const zone = runtime.zone.chainId const zoneOptions = TestApp.zone({ chainId: zone, rpcUrl: runtime.zone.publicZoneUrl, }) const zoneReader = { id: 'key_zone_reader', orgId: 'org_test', scopes: ['data:read', `zone:${zone}:read`], token: 'secret_zone_reader', } satisfies TestApp.kvStore.Key const zoneReaderOtherWriter = { id: 'key_zone_reader_other_writer', orgId: 'org_test', scopes: ['data:read', `zone:${zone}:read`, `zone:${zone + 1}:write`], token: 'secret_zone_reader_other_writer', } satisfies TestApp.kvStore.Key const zoneWriter = { id: 'key_zone_writer', orgId: 'org_test', scopes: ['data:read', `zone:${zone}:write`], token: 'secret_zone_writer', } satisfies TestApp.kvStore.Key // Prool's default Zone dev key. const zonePrivateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' function zoneToken() { const issuedAt = Math.floor(Date.now() / 1_000) const authentication = ZoneRpcAuthentication.from({ chainId: zone, expiresAt: issuedAt + 300, issuedAt, zoneId: 1, }) return ZoneRpcAuthentication.serialize(authentication, { signature: Secp256k1.sign({ payload: ZoneRpcAuthentication.getSignPayload(authentication), privateKey: zonePrivateKey, }), }) } describe('POST /rpc', () => { test('allows 20 public requests per second', async () => { vi.useFakeTimers({ now: new Date('2026-08-12T00:00:00.000Z') }) try { const app = TestApp.create() const request = () => app.request('/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tempo_blockNumber' }), method: 'POST', }) const responses = await Promise.all( Array.from({ length: 21 }, () => Promise.resolve(request())), ) const limited = responses.find((response) => response.status === 429)! expect(responses.filter((response) => response.status === 401)).toHaveLength(20) expect(limited.headers.get('RateLimit-Limit')).toBe('20') vi.advanceTimersByTime(1_000) expect((await Promise.resolve(request())).status).toBe(401) } finally { vi.useRealTimers() } }) test('routes by principal: a resolver sees the request principal and routes accordingly', async () => { const fetch = mockRpc() // A resolver that routes API-key principals to the private upstream and // everything else (here, the keyless `auth: false` caller) to the public one. const rpc = ({ principal }: Viem.resolveRpc.Context) => principal?.type === 'api_key' ? { auth: 'rpc:secret', url: 'https://internal.rpc.test/json-rpc' } : { url: 'https://public.rpc.test/json-rpc' } try { // Auth disabled ⇒ no API-key principal ⇒ public RPC, no basic auth. const publicApp = TestApp.create({ auth: false, rpc }) await publicApp.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) // Valid API key ⇒ api_key principal ⇒ private profile: internal RPC + basic auth. const authedApp = TestApp.create({ rpc }) await authedApp.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': TestApp.key.token }, method: 'POST', }), ) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://public.rpc.test/json-rpc"`) expect(fetch.calls[0]?.headers['authorization']).toMatchInlineSnapshot(`null`) expect(fetch.calls[1]?.url).toMatchInlineSnapshot(`"https://internal.rpc.test/json-rpc"`) expect(fetch.calls[1]?.headers['authorization']).toMatchInlineSnapshot( `"Basic cnBjOnNlY3JldA=="`, ) } finally { fetch.restore() } }) test('proxies JSON-RPC requests to the configured upstream RPC', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { auth: 'rpc:secret', url: (chainId) => `https://${chainId}.rpc.test/json-rpc`, }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [] }), headers: { authorization: 'Bearer caller-secret', 'content-type': 'application/json', cookie: 'session=secret', 'tempo-api-key': 'caller-key', 'x-api-key': 'legacy-caller-key', 'x-authorization-token': 'zone-secret', 'x-client-trace': 'trace_test', }, method: 'POST', }), ) const body = await response.json() const call = fetch.calls[0]! expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('x-upstream')).toMatchInlineSnapshot(`"rpc"`) expect(body).toMatchInlineSnapshot(` { "id": 1, "jsonrpc": "2.0", "result": "0xa5bf", } `) expect(call.method).toMatchInlineSnapshot(`"POST"`) expect(call.url).toBe(`https://${runtime.chainId}.rpc.test/json-rpc`) expect(call.body).toMatchInlineSnapshot( `"{"id":1,"jsonrpc":"2.0","method":"eth_chainId","params":[]}"`, ) expect(call.headers['authorization']).toMatchInlineSnapshot(`"Basic cnBjOnNlY3JldA=="`) expect(call.headers['cookie']).toMatchInlineSnapshot(`null`) expect(call.headers['tempo-api-key']).toMatchInlineSnapshot(`null`) expect(call.headers['x-api-key']).toBeNull() expect(call.headers['x-authorization-token']).toBeNull() expect(call.headers['x-client-trace']).toMatchInlineSnapshot(`"trace_test"`) } finally { fetch.restore() } }) test('records JSON-RPC server errors returned with HTTP 200', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ error: { code: -32603, message: 'internal error' }, id: 1, jsonrpc: '2.0', }), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(200) expect(await response.json()).toMatchInlineSnapshot(` { "error": { "code": -32603, "message": "internal error", }, "id": 1, "jsonrpc": "2.0", } `) expect(entries[0]).toMatchObject({ level: 'error', route: '/rpc/:chain{(mainnet|testnet|[0-9]+)}?', rpc: { code: -32603, errors: 1, serverErrors: 1 }, status: 200, }) } finally { await server.closeAsync() } }) test('attributes non-success RPC responses to the upstream', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ error: 'unavailable' }, { status: 503 }), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(503) expect(entries[0]).toMatchObject({ provider: { chainId: runtime.chainId, failure: 'http', id: 'rpc', operation: 'request', status: 503, }, }) } finally { await server.closeAsync() } }) test.each([400, 413, 415, 422])( 'keeps caller HTTP %s responses out of upstream alerts', async (status) => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ error: 'invalid request' }, { status }), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: '{', method: 'POST', }), ) expect(response.status).toBe(status) expect(entries[0]?.provider).toBeUndefined() } finally { await server.closeAsync() } }, ) test('keeps RPC method-filter responses out of upstream alerts', async () => { const entries: Log.Entry[] = [] const body = { error: { code: -32_601, message: 'method not allowed: tempo_sponsor' }, id: null, jsonrpc: '2.0', } const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json(body, { status: 403 })), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tempo_sponsor' }), method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toEqual(body) expect(entries[0]?.provider).toBeUndefined() } finally { await server.closeAsync() } }) test('attributes RPC authorization responses to the upstream', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json( { error: { code: -32_001, message: 'unauthorized: invalid credentials' }, id: null, jsonrpc: '2.0', }, { status: 403 }, ), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(403) expect(entries[0]).toMatchObject({ provider: { chainId: runtime.chainId, failure: 'http', id: 'rpc', operation: 'request', status: 403, }, }) } finally { await server.closeAsync() } }) test('attributes RPC transport failures to the upstream', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ id: 1, jsonrpc: '2.0', result: '0x1' }), ), ) const url = server.url await server.closeAsync() const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(502) expect(entries[0]).toMatchObject({ provider: { chainId: runtime.chainId, failure: 'network', id: 'rpc', operation: 'request', }, }) }) test.each([ { body: { id: 1, jsonrpc: '2.0' }, name: 'missing result' }, { body: [], name: 'empty batch' }, ])('attributes malformed JSON-RPC $name payloads to the upstream', async ({ body }) => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json(body)), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(200) expect(entries[0]).toMatchObject({ provider: { chainId: runtime.chainId, failure: 'payload', id: 'rpc', operation: 'request', }, }) } finally { await server.closeAsync() } }) test('accepts empty responses to JSON-RPC notifications', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => new Response(null, { status: 200 })), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), rpc: { url: server.url }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_sendRawTransaction' }), method: 'POST', }), ) expect(response.status).toBe(200) expect(entries[0]?.provider).toBeUndefined() } finally { await server.closeAsync() } }) test('preserves large streaming request bodies during inspection', async () => { const bodies: string[] = [] const requestBody = JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_call', params: [`0x${'ab'.repeat(40_000)}`, 'latest'], }) const server = await Relay.createServer( RequestListener.fromFetchHandler(async (request) => { bodies.push(await request.text()) return Response.json({ id: 1, jsonrpc: '2.0', result: '0x' }) }), ) try { const app = TestApp.create({ auth: false, rpc: { url: server.url } }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: requestBody, method: 'POST' }), ) expect(response.status).toBe(200) expect(bodies).toEqual([requestBody]) } finally { await server.closeAsync() } }) test('preserves RPC responses larger than the inspection bound', async () => { const result = `0x${'ab'.repeat(40_000)}` const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json({ id: 1, jsonrpc: '2.0', result })), ) try { const app = TestApp.create({ auth: false, rpc: { url: server.url } }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_getLogs' }), method: 'POST', }), ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ id: 1, jsonrpc: '2.0', result }) } finally { await server.closeAsync() } }) test('does not attribute Zone caller-auth responses to the RPC provider', async () => { const entries: Log.Entry[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(() => Response.json( { error: { code: -32_000, message: 'invalid zone credential' }, id: 1, jsonrpc: '2.0', }, { status: 401 }, ), ), ) try { const app = TestApp.create({ auth: false, logger: (entry) => void entries.push(entry), zones: [TestApp.zone({ chainId: zone, rpcUrl: server.url })], }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { [ZoneRpcAuthentication.headerName]: 'invalid-zone-credential' }, method: 'POST', }), ) expect(response.status).toBe(401) expect(entries[0]?.provider).toBeUndefined() } finally { await server.closeAsync() } }) test('forwards inferred RPC bearer auth', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { auth: 'rpc-token', url: 'https://internal.rpc.test/json-rpc' }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toBe(200) expect(fetch.calls[0]?.headers['authorization']).toBe('Bearer rpc-token') } finally { fetch.restore() } }) test('allows eth-prefixed methods anonymously', async () => { const fetch = mockRpc() try { const app = TestApp.create({ rpc: { auth: 'rpc:secret', url: (chainId) => `https://${chainId}.rpc.test/json-rpc`, }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/testnet', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_fillTransaction' }), method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://rpc.testnet.tempo.xyz/"`) expect(fetch.calls[0]?.headers['authorization']).toMatchInlineSnapshot(`null`) } finally { fetch.restore() } }) test('requires an API key for non-eth methods', async () => { const fetch = mockRpc() try { const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tempo_fundAddress' }), method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`401`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "api_key_missing", "message": "Missing API key", }, } `) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('requires an API key when a batch includes a private method', async () => { const fetch = mockRpc() try { const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify([ { id: 1, jsonrpc: '2.0', method: 'eth_chainId' }, { id: 2, jsonrpc: '2.0', method: 'tempo_fundAddress' }, ]), method: 'POST', }), ) const body = (await response.json()) as { error?: { code?: unknown } } expect(response.status).toMatchInlineSnapshot(`401`) expect(body.error?.code).toMatchInlineSnapshot(`"api_key_missing"`) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('allows API keys to call private methods', async () => { const fetch = mockRpc() try { const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tempo_fundAddress' }), headers: { 'tempo-api-key': TestApp.key.token }, method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.body).toMatchInlineSnapshot( `"{"id":1,"jsonrpc":"2.0","method":"tempo_fundAddress"}"`, ) } finally { fetch.restore() } }) test('uses the requested chain id for upstream URL resolution', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { url: (chainId) => `https://${chainId}.rpc.test` }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/4217', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://4217.rpc.test/"`) } finally { fetch.restore() } }) test('uses mainnet for the mainnet shortcut', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { url: (chainId) => `https://${chainId}.rpc.test` }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/mainnet', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://4217.rpc.test/"`) } finally { fetch.restore() } }) test('uses testnet for the testnet shortcut', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { url: (chainId) => `https://${chainId}.rpc.test` }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/testnet', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://42431.rpc.test/"`) } finally { fetch.restore() } }) test('proxies zone chain ids and forwards the caller zone auth token', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, rpc: { auth: 'rpc:secret', url: () => 'https://internal.rpc.test' }, zones: [TestApp.zone({ chainId: zone, rpcUrl: `https://${zone}.rpc.test` })], }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_getBalance' }), headers: { authorization: 'Bearer caller-secret', 'x-authorization-token': 'zone-token', }, method: 'POST', }), ) const call = fetch.calls[0]! expect(response.status).toMatchInlineSnapshot(`200`) expect(call.url).toBe(`https://${zone}.rpc.test/`) // Public Zone URLs receive only the caller's token. expect(call.headers['x-authorization-token']).toMatchInlineSnapshot(`"zone-token"`) expect(call.headers['authorization']).toMatchInlineSnapshot(`null`) expect(call.headers['tempo-api-key']).toMatchInlineSnapshot(`null`) } finally { fetch.restore() } }) test('refuses a zone rpc call without a zone scope or auth token', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false, zones: [TestApp.zone({ chainId: zone, rpcUrl: `https://${zone}.rpc.test` })], }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_getBalance' }), method: 'POST', }), ) const body = (await response.json()) as { error: { code: string; message: string } } expect(response.status).toBe(403) expect(body.error.code).toBe('api_key_forbidden') expect(body.error.message).toContain(`zone:${zone}:read`) // Refused before any upstream round-trip. expect(fetch.calls).toHaveLength(0) } finally { fetch.restore() } }) test('routes Zone-scoped reads to the internal RPC', async () => { const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ result: Hex.fromNumber(zone) }) }) test('allows Zone-scoped eth_fillTransaction calls', async () => { const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_fillTransaction' }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(200) }) test('allows Zone-scoped metadata reads with internal RPC auth', async () => { const authorization: (string | null)[] = [] const bodies: string[] = [] const server = await Relay.createServer( RequestListener.fromFetchHandler(async (request) => { authorization.push(request.headers.get('authorization')) bodies.push(await request.text()) return Response.json({ id: 1, jsonrpc: '2.0', result: {} }) }), ) try { const app = TestApp.create({ auth: { keys: [zoneReader] }, rpc: ({ chainId }) => (chainId === zone ? { auth: 'rpc:secret', url: server.url } : {}), zones: [zoneOptions], }) for (const method of ['zone_getEncryptionKey', 'zone_getZoneInfo']) { const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(200) } expect(authorization).toEqual(['Basic cnBjOnNlY3JldA==', 'Basic cnBjOnNlY3JldA==']) expect(bodies).toEqual([ '{"id":1,"jsonrpc":"2.0","method":"zone_getEncryptionKey"}', '{"id":1,"jsonrpc":"2.0","method":"zone_getZoneInfo"}', ]) } finally { await server.closeAsync() } }) test('blocks caller-authenticated Zone methods for Zone read scopes', async () => { const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'zone_getAuthorizationTokenInfo', }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } }) }) test('rejects writes without the matching Zone write scope', async () => { for (const key of [zoneReader, zoneReaderOtherWriter]) { const app = TestApp.create({ auth: { keys: [key] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_sendRawTransaction' }), headers: { 'content-type': 'application/json', 'tempo-api-key': key.token }, method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } }) } }) test('allows Zone-scoped signed transaction broadcasts', async () => { const app = TestApp.create({ auth: { keys: [zoneWriter] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_sendRawTransactionSync', params: ['0x76'], }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneWriter.token }, method: 'POST', }), ) expect(response.status).toBe(200) }) test('allows Zone write scopes to read', async () => { const app = TestApp.create({ auth: { keys: [zoneWriter] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneWriter.token }, method: 'POST', }), ) expect(response.status).toBe(200) }) test('keeps unlocked-account methods blocked for Zone write scopes', async () => { const app = TestApp.create({ auth: { keys: [zoneWriter] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_sendTransaction' }), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneWriter.token }, method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } }) }) test('rejects a Zone-scoped batch containing a write', async () => { const app = TestApp.create({ auth: { keys: [zoneReader] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify([ { id: 1, jsonrpc: '2.0', method: 'eth_chainId' }, { id: 2, jsonrpc: '2.0', method: 'eth_sendRawTransaction' }, ]), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneReader.token }, method: 'POST', }), ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } }) }) test('allows a Zone write scope to batch reads and signed broadcasts', async () => { const app = TestApp.create({ auth: { keys: [zoneWriter] }, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify([ { id: 1, jsonrpc: '2.0', method: 'eth_chainId' }, { id: 2, jsonrpc: '2.0', method: 'eth_sendRawTransaction', params: ['0x76'] }, ]), headers: { 'content-type': 'application/json', 'tempo-api-key': zoneWriter.token }, method: 'POST', }), ) expect(response.status).toBe(200) }) test('routes caller Zone tokens to the private RPC', async () => { const app = TestApp.create({ auth: false, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'content-type': 'application/json', [ZoneRpcAuthentication.headerName]: zoneToken(), }, method: 'POST', }), ) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ result: Hex.fromNumber(zone) }) }) test('rejects invalid Zone tokens at the private RPC', async () => { const app = TestApp.create({ auth: false, zones: [zoneOptions] }) const response = await app.fetch( new Request(`http://tempo-api.test/rpc/${zone}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'content-type': 'application/json', [ZoneRpcAuthentication.headerName]: 'invalid', }, method: 'POST', }), ) expect(response.status).toBe(403) }) test('rejects invalid chain ids before proxying upstream', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: false }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/0', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`400`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "chain_id_invalid", "message": "Invalid chain id", }, } `) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('requires the read scope for API keys', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: { keys: [ { id: 'key_indexer_only', orgId: 'org_test', scopes: ['indexer:query'], token: 'secret_indexer_key', }, ], }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': 'secret_indexer_key' }, method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`403`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "api_key_forbidden", "message": "API key missing required scope", }, } `) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('uses the RPC path for sandbox chain checks', async () => { const app = TestApp.create({ auth: { keys: [ { environment: 'sandbox', id: 'key_sandbox', orgId: 'org_test', scopes: ['data:read'], token: 'secret_sandbox_key', }, ], }, defaultChainId: 4217, }) const request = (path: string) => app.fetch( new Request(`http://tempo-api.test${path}`, { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': 'secret_sandbox_key' }, method: 'POST', }), ) const rejected = await request('/rpc/4217?chainId=42431') const rejectedDefault = await request('/rpc?chainId=42431') const rejectedTrailing = await request('/rpc/?chainId=42431') expect(rejected.status).toMatchInlineSnapshot(`403`) expect(rejectedDefault.status).toMatchInlineSnapshot(`403`) expect(rejectedTrailing.status).toMatchInlineSnapshot(`404`) }) }) describe('POST /rpc/relay', () => { test('rejects anonymous callers', async () => { const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc/relay', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`401`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "api_key_missing", "message": "Missing API key", }, } `) }) test('rejects keys missing the relay scope', async () => { // The shared test key holds the data/indexer scopes only. const app = TestApp.create() const response = await app.fetch( new Request('http://tempo-api.test/rpc/relay', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': TestApp.key.token }, method: 'POST', }), ) const body = (await response.json()) as Record const { requestId, ...stable } = body expect(response.status).toMatchInlineSnapshot(`403`) expect(typeof requestId).toMatchInlineSnapshot(`"string"`) expect(stable).toMatchInlineSnapshot(` { "error": { "code": "api_key_forbidden", "message": "API key missing required scope", }, } `) }) test('routes chain-scoped mounts to the relay pipeline', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: { keys: [ { id: 'key_relay', orgId: 'org_test', scopes: ['rpc-relay:read'], token: 'secret_relay_key', }, ], }, rpc: { url: () => 'https://internal.rpc.test/json-rpc' }, }) // Without a fee payer configured the relay pipeline answers this method // itself; a proxied request would return the mock upstream's result. const response = await app.fetch( new Request('http://tempo-api.test/rpc/relay/42431', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_signRawTransaction', params: ['0x76'] }), // prettier-ignore headers: { 'tempo-api-key': 'secret_relay_key' }, method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) const body = (await response.json()) as { error?: { message: string } | undefined } expect(body.error?.message).toMatchInlineSnapshot( `"eth_signRawTransaction requires a fee payer to be configured on the relay. Set the \`feePayer\` option in \`Handler.relay()\` to enable transaction sponsorship."`, ) expect(fetch.calls).toMatchInlineSnapshot(`[]`) } finally { fetch.restore() } }) test('proxies for keys holding rpc-relay:read', async () => { const fetch = mockRpc() try { const app = TestApp.create({ auth: { keys: [ { id: 'key_relay', orgId: 'org_test', scopes: ['rpc-relay:read'], token: 'secret_relay_key', }, ], }, rpc: { url: () => 'https://internal.rpc.test/json-rpc' }, }) const response = await app.fetch( new Request('http://tempo-api.test/rpc/relay', { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId' }), headers: { 'tempo-api-key': 'secret_relay_key' }, method: 'POST', }), ) expect(response.status).toMatchInlineSnapshot(`200`) expect(await response.json()).toMatchInlineSnapshot(` { "id": 1, "jsonrpc": "2.0", "result": "0xa5bf", } `) expect(fetch.calls[0]?.url).toMatchInlineSnapshot(`"https://internal.rpc.test/json-rpc"`) } finally { fetch.restore() } }) }) function mockRpc() { const original = globalThis.fetch const calls: { body: string headers: Record method: string url: string }[] = [] const spy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { const request = input instanceof Request ? input : new Request(input, init) calls.push({ body: await request.text(), headers: { authorization: request.headers.get('authorization'), cookie: request.headers.get('cookie'), 'tempo-api-key': request.headers.get('tempo-api-key'), 'x-api-key': request.headers.get('x-api-key'), 'x-authorization-token': request.headers.get('x-authorization-token'), 'x-client-trace': request.headers.get('x-client-trace'), }, method: request.method, url: request.url, }) return new Response(JSON.stringify({ id: 1, jsonrpc: '2.0', result: '0xa5bf' }), { headers: { 'content-type': 'application/json', 'x-upstream': 'rpc' }, }) }) return { calls, restore() { spy.mockRestore() globalThis.fetch = original }, } }