import { Challenge } from 'mppx' import { sessionManager } from 'mppx/client' import { ZoneRpcAuthentication } from 'ox/tempo' import { Actions } from 'viem/tempo' import * as TestApp from '../../../../test/App.js' import * as Mppx from '../../../../test/Mppx.js' import * as Runtime from '../../../../test/runtime.js' import * as Tempo from '../../../../test/Tempo.js' import * as Auth from '../../../internal/Auth.js' import * as Tidx from '../../../internal/Tidx.js' import * as Viem from '../../../internal/Viem.js' import * as Indexer from './indexer.js' type JsonSchema = { /** Referenced component path. */ $ref?: string /** Alternative schemas accepted by this value. */ anyOf?: readonly JsonSchema[] /** Human-readable schema description. */ description?: string /** Allowed string values. */ enum?: readonly string[] /** Representative schema values. */ examples?: readonly unknown[] /** Schema for array entries. */ items?: JsonSchema /** Schemas for object properties. */ properties?: Record /** Required object property names. */ required?: readonly string[] /** JSON schema value type. */ type?: string } type OpenApiMediaType = { /** Schema for this media type. */ schema?: JsonSchema } type OpenApiResponse = { /** Referenced response component path. */ $ref?: string /** Response bodies keyed by media type. */ content?: Record } type OpenApiParameter = { /** OpenAPI parameter name. */ name?: string /** Whether callers must provide the parameter. */ required?: boolean /** Parameter value schema. */ schema?: JsonSchema } type OpenApiOperation = { /** Stable generated-client method name. */ operationId?: string /** Parameters accepted by the operation. */ parameters?: readonly OpenApiParameter[] /** Responses keyed by HTTP status. */ responses?: Record } type OpenApiComponents = { /** Reusable schemas keyed by component name. */ schemas: Record } type OpenApiPath = { /** GET operation for this path. */ get?: OpenApiOperation } type OpenApiDocument = { /** Reusable OpenAPI components. */ components: OpenApiComponents /** Operations keyed by request path. */ paths: Record } const runtime = Runtime.get() const chainId = String(runtime.chainId) function component(document: OpenApiDocument, name: string): JsonSchema { const value = document.components.schemas[name] if (!value) throw new Error(`Missing OpenAPI component ${name}`) return value } function resolveSchema(document: OpenApiDocument, value: JsonSchema | undefined): JsonSchema { if (!value) throw new Error('Missing OpenAPI schema') if (!value.$ref) return value const name = value.$ref.split('/').at(-1) if (!name) throw new Error(`Invalid OpenAPI component ref ${value.$ref}`) return component(document, name) } describe('OpenAPI', () => { test('publishes a generator-ready indexer contract', async () => { const app = TestApp.create({ auth: false }) const document = (await (await app.request('/openapi.json')).json()) as OpenApiDocument const operation = document.paths['/v1/indexer/query']?.get if (!operation) throw new Error('Missing indexer OpenAPI operation') const summarizeError = (status: number) => { const response = operation.responses?.[status] if (!response) return undefined if (response.$ref) return { $ref: response.$ref } const value = response.content?.['application/json']?.schema if (status === 400 || status === 422 || status === 502) return value return resolveSchema(document, value).properties?.['error']?.properties?.['code']?.enum } const badRequest = component(document, 'IndexerQueryBadRequest') const eventStream = component(document, 'IndexerQueryEventStream') const requestError = component(document, 'IndexerQueryRequestError') const response = component(document, 'IndexerQueryResponse') const tempoUpstreamError = component(document, 'IndexerQueryTempoUpstreamError') const upstreamError = component(document, 'IndexerQueryError') const upstreamFailure = component(document, 'IndexerQueryUpstreamFailure') expect({ components: { badRequest: badRequest.anyOf, eventStream: { description: eventStream.description, examples: eventStream.examples, type: eventStream.type, }, requestError: requestError.properties?.['error']?.properties?.['code']?.enum, response: { columnsExamples: response.properties?.['columns']?.examples, description: response.description, required: response.required, rowsExamples: response.properties?.['rows']?.examples, }, tempoUpstreamError: tempoUpstreamError.properties?.['error']?.properties?.['code']?.enum, upstreamError: { description: upstreamError.description, required: upstreamError.required, }, upstreamFailure: upstreamFailure.anyOf, }, operation: { errors: Object.fromEntries( [400, 401, 402, 403, 422, 429, 500, 502, 504].map((status) => [ status, summarizeError(status), ]), ), operationId: operation.operationId, parameters: operation.parameters?.map((parameter) => ({ name: parameter.name, required: parameter.required, schema: parameter.schema, })), responses: Object.fromEntries( Object.entries(operation.responses?.['200']?.content ?? {}).map( ([mediaType, content]) => [mediaType, content.schema], ), ), }, }).toMatchInlineSnapshot(` { "components": { "badRequest": [ { "$ref": "#/components/schemas/IndexerQueryError", }, { "$ref": "#/components/schemas/IndexerQueryRequestError", }, ], "eventStream": { "description": "A stream of indexer result, error, and lagged events.", "examples": [ "event: result data: {"ok":true,"columns":["num"],"rows":[[1234567]],"row_count":1} ", ], "type": "string", }, "requestError": [ "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", ], "response": { "columnsExamples": [ [ "num", "hash", "timestamp", ], ], "description": "Structured result from a Tempo indexer SQL query.", "required": [ "ok", "columns", "rows", "row_count", ], "rowsExamples": [ [ [ 1234567, "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 1718668800, ], ], ], }, "tempoUpstreamError": [ "upstream_error", ], "upstreamError": { "description": "Error response returned by the upstream Tempo indexer.", "required": [ "ok", "error", ], }, "upstreamFailure": [ { "$ref": "#/components/schemas/IndexerQueryError", }, { "$ref": "#/components/schemas/IndexerQueryTempoUpstreamError", }, ], }, "operation": { "errors": { "400": { "$ref": "#/components/schemas/IndexerQueryBadRequest", }, "401": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "402": { "$ref": "#/components/responses/PaymentRequired", }, "403": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "422": { "$ref": "#/components/schemas/IndexerQueryError", }, "429": { "$ref": "#/components/responses/RateLimited", }, "500": { "$ref": "#/components/responses/InternalError", }, "502": { "$ref": "#/components/schemas/IndexerQueryUpstreamFailure", }, "504": { "$ref": "#/components/responses/RequestTimeout", }, }, "operationId": "indexerQuery", "parameters": [ { "name": "sql", "required": true, "schema": { "type": "string", }, }, { "name": "chainId", "required": false, "schema": { "anyOf": [ { "enum": [ "mainnet", "testnet", ], "examples": [ "mainnet", ], "type": "string", }, { "minimum": 1, "type": "integer", }, ], "examples": [ "mainnet", ], }, }, { "name": "signature", "required": false, "schema": { "items": { "type": "string", }, "type": "array", }, }, { "name": "engine", "required": false, "schema": { "enum": [ "postgres", "clickhouse", ], "examples": [ "postgres", ], "type": "string", }, }, { "name": "live", "required": false, "schema": { "default": false, "examples": [ false, ], "type": "boolean", }, }, { "name": "limit", "required": false, "schema": { "default": 10000, "examples": [ 10000, ], "maximum": 10000, "minimum": 1, "type": "integer", }, }, { "name": "timeout_ms", "required": false, "schema": { "default": 5000, "examples": [ 5000, ], "maximum": 30000, "minimum": 100, "type": "integer", }, }, ], "responses": { "application/json": { "$ref": "#/components/schemas/IndexerQueryResponse", }, "text/event-stream": { "$ref": "#/components/schemas/IndexerQueryEventStream", }, }, }, } `) }) }) describe('GET /indexer/query', () => { test('rejects malformed API key credentials', async () => { const response = await TestApp.create().request('/v1/indexer/query?sql=select%201', { headers: { authorization: 'Basic wrong' }, }) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'api_key_malformed' } }) }) test('rejects invalid chain ids before proxying', async () => { const response = await TestApp.create().request( '/v1/indexer/query?sql=select%201&chainId=not-a-chain', TestApp.auth, ) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'chain_id_invalid' } }) }) test('requires the indexer query scope', async () => { const reader = { id: 'key_data_reader', orgId: 'org_test', scopes: ['data:read'], token: 'secret_data_reader', } satisfies TestApp.kvStore.Key const client = TestApp.client({ auth: { keys: [reader] } }) const response = await client.v1.indexer.query.$get( { query: { sql: 'select 1' } }, { headers: { authorization: `Bearer ${reader.token}` } }, ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_forbidden' } }) }) test('proxies a query to the upstream indexer', async () => { const client = TestApp.client() const response = await client.v1.indexer.query.$get( // testClient route inputs are query strings, not numbers. { query: { sql: 'select hash from txs limit 1', chainId } }, TestApp.auth, ) const body = await TestApp.json(response, Indexer.schema.Response) expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('content-type')).toMatchInlineSnapshot(`"application/json"`) // A successful response proves the proxy injected TIDX basic auth upstream. expect(body.ok).toMatchInlineSnapshot(`true`) expect(body.columns).toMatchInlineSnapshot(` [ "hash", ] `) expect(body.row_count).toMatchInlineSnapshot(`1`) expect(Array.isArray(body.rows)).toMatchInlineSnapshot(`true`) }) test('escaped string literals round-trip through the real indexer', async () => { // Pins the upstream's accepted escape form: TIDX's ANSI SQL parser only // accepts quote doubling (`''`) inside string literals and rejects // backslash escaping with a parse error, so this fails if `Tidx.escape` // ever regresses to a form the indexer cannot parse. const client = TestApp.client() const value = "a'b\\c" const response = await client.v1.indexer.query.$get( { query: { sql: `select '${Tidx.escape(value)}' as x`, chainId, }, }, TestApp.auth, ) const body = await TestApp.json(response, Indexer.schema.Response) expect(response.status).toMatchInlineSnapshot(`200`) expect(body.ok).toMatchInlineSnapshot(`true`) expect(body.rows[0]?.[0]).toBe(value) }) test('routes Zone queries to the Zone TIDX with its basic auth', async () => { const zone = runtime.zone.chainId const zoneReader = { id: 'key_zone_indexer', orgId: 'org_test', scopes: ['data:read', 'indexer:query', `zone:${zone}:read`], token: 'secret_zone_indexer', } satisfies TestApp.kvStore.Key const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ columns: [], ok: true, row_count: 0, rows: [] }), { headers: { 'content-type': 'application/json' }, }), ) try { const client = TestApp.client({ auth: { keys: [zoneReader] }, tidx: { auth: JSON.stringify({ [zone]: 'zone:secret' }) }, zones: [ TestApp.zone({ chainId: zone, rpcUrl: runtime.zone.publicZoneUrl, tidxUrl: 'https://zone.tidx.test', }), ], }) const response = await client.v1.indexer.query.$get( { query: { chainId: String(zone), sql: 'select 1' } }, { headers: { authorization: `Bearer ${zoneReader.token}`, [ZoneRpcAuthentication.headerName]: 'zone-rpc-secret', }, }, ) const upstream = fetch.mock.calls[0]![0] as Request expect(response.status).toBe(200) expect(new URL(upstream.url).origin).toBe('https://zone.tidx.test') expect(upstream.headers.get('authorization')).toBe('Basic em9uZTpzZWNyZXQ=') expect(upstream.headers.get(ZoneRpcAuthentication.headerName)).toBeNull() } finally { fetch.mockRestore() } }) test('routes Zone queries to the Zone TIDX with its bearer auth', async () => { const zone = runtime.zone.chainId const zoneReader = { id: 'key_zone_bearer_indexer', orgId: 'org_test', scopes: ['data:read', 'indexer:query', `zone:${zone}:read`], token: 'secret_zone_bearer_indexer', } satisfies TestApp.kvStore.Key const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ columns: [], ok: true, row_count: 0, rows: [] }), { headers: { 'content-type': 'application/json' }, }), ) try { const client = TestApp.client({ auth: { keys: [zoneReader] }, tidx: { auth: 'zone-secret', baseUrl: 'https://zone.tidx.test' }, zones: [TestApp.zone({ chainId: zone, rpcUrl: runtime.zone.publicZoneUrl })], }) const response = await client.v1.indexer.query.$get( { query: { chainId: String(zone), sql: 'select 1' } }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) const upstream = fetch.mock.calls[0]![0] as Request expect(response.status).toBe(200) expect(new URL(upstream.url).origin).toBe('https://zone.tidx.test') expect(upstream.headers.get('authorization')).toBe('Bearer zone-secret') } finally { fetch.mockRestore() } }) test('bounds and caches buffered queries', async () => { // Stub the upstream so the injected execution bounds are observable on the // outgoing request; the auth path is in-memory and never touches fetch. const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ columns: [], ok: true, row_count: 0, rows: [] }), { headers: { 'content-type': 'application/json' }, }), ) try { const client = TestApp.client() const response = await client.v1.indexer.query.$get( { query: { sql: 'select 1', chainId } }, { headers: { 'x-api-key': TestApp.key.token } }, ) expect(response.status).toMatchInlineSnapshot(`200`) // Buffered (non-`live`) queries are response-cached on the `feed` tier. expect(response.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=10, stale-while-revalidate=30"`, ) // Tempo API enforces the documented execution bounds upstream even when the // caller omits them. const upstream = new URL((fetch.mock.calls[0]![0] as Request).url) expect((fetch.mock.calls[0]![0] as Request).headers.get('x-api-key')).toBeNull() expect(upstream.searchParams.get('timeout_ms')).toMatchInlineSnapshot(`"5000"`) expect(upstream.searchParams.get('limit')).toMatchInlineSnapshot(`"10000"`) } finally { fetch.mockRestore() } }) test('forwards chain aliases to the upstream as numeric ids', async () => { const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify({ columns: [], ok: true, row_count: 0, rows: [] }), { headers: { 'content-type': 'application/json' }, }), ) try { const client = TestApp.client() const response = await client.v1.indexer.query.$get( { query: { chainId: 'mainnet', sql: 'select 2' } }, TestApp.auth, ) expect(response.status).toMatchInlineSnapshot(`200`) const upstream = new URL((fetch.mock.calls[0]![0] as Request).url) expect(upstream.searchParams.get('chainId')).toMatchInlineSnapshot(`"4217"`) } finally { fetch.mockRestore() } }) test('requires authentication', async () => { const client = TestApp.client() const response = await client.v1.indexer.query.$get( { query: { sql: 'select 1' } }, { headers: { 'tempo-api-key': 'wrong' } }, ) expect(response.status).toMatchInlineSnapshot(`401`) }) test('does not expose anonymous access', () => { expect(Auth.describeAccess(Indexer.indexer())['indexerQuery']).toMatchInlineSnapshot(` { "apiKey": true, "mpp": true, "public": false, "scopes": [ "indexer:query", ], "session": false, } `) }) test('proxies without auth middleware', async () => { const client = TestApp.client({ auth: false }) const response = await client.v1.indexer.query.$get({ query: { sql: 'select hash from txs limit 1', chainId }, }) const body = await TestApp.json(response, Indexer.schema.Response) expect(response.status).toMatchInlineSnapshot(`200`) expect(body.ok).toMatchInlineSnapshot(`true`) }) test('challenges anonymous callers for MPP payment', { retry: 1 }, async () => { const app = TestApp.create({ auth: { mpp: { secretKey: 'secret_test_key_0123456789abcdef', session: { chainId: Tempo.chain.id, currency: Tempo.currency, decimals: 6, getClient: () => Tempo.client, recipient: Tempo.accounts[2].address, }, }, }, }) // MPP challenges and receipts ride on real HTTP headers, so drive the // lifecycle through `app.fetch` rather than the typed route client. const challenge = await app.fetch( new Request('http://tempo-api.test/v1/indexer/query?sql=select%201'), ) expect(challenge.status).toMatchInlineSnapshot(`402`) expect(challenge.headers.get('www-authenticate')?.startsWith('Payment ')).toMatchInlineSnapshot( `true`, ) // Paying the challenge unlocks a real proxied query and returns a receipt. const sql = encodeURIComponent('select hash from txs limit 1') const paid = await Mppx.createClient(app).fetch( `http://tempo-api.test/v1/indexer/query?sql=${sql}&chainId=${chainId}`, ) const body = await TestApp.json(paid, Indexer.schema.Response) expect(paid.status).toMatchInlineSnapshot(`200`) expect(paid.headers.has('payment-receipt')).toMatchInlineSnapshot(`true`) expect(body.ok).toMatchInlineSnapshot(`true`) }) test('rejects an unsupported chain before issuing an MPP challenge', async () => { const app = TestApp.create({ auth: { mpp: { secretKey: 'secret_test_key_0123456789abcdef', session: { chainId: Tempo.chain.id, currency: Tempo.currency, decimals: 6, getClient: () => Tempo.client, recipient: Tempo.accounts[2].address, }, }, }, }) const response = await app.fetch( new Request('http://tempo-api.test/v1/indexer/query?sql=select%201&chainId=31318'), ) const body = (await response.json()) as { error: { code: string } } expect(response.status).toBe(400) expect(response.headers.has('www-authenticate')).toBe(false) expect(body.error.code).toBe('chain_id_unsupported') }) test('accepts session management POSTs', { timeout: 120_000 }, async () => { const app = TestApp.create({ auth: { mpp: { secretKey: 'secret_test_key_0123456789abcdef', session: { account: Tempo.accounts[2], chainId: Viem.chainId.mainnet, currency: Tempo.currency, decimals: 6, getClient: () => Tempo.client, suggestedDeposit: '0.01', }, }, }, }) await Actions.faucet.fundSync(Tempo.client, { account: Tempo.accounts[2], timeout: 60_000, }) const url = `http://tempo-api.test/v1/indexer/query?sql=select%201&chainId=${chainId}` const challenged = await app.fetch(new Request(url)) expect(challenged.status).toMatchInlineSnapshot(`402`) expect(Challenge.fromHeaders(challenged.headers).request).toMatchObject({ amount: '100', suggestedDeposit: '10000', }) const manager = sessionManager({ account: Tempo.accounts[1], client: Tempo.client, fetch: async (input, init) => app.fetch(new Request(input, init)), }) const paid = await manager.fetch(url) expect(paid.status).toMatchInlineSnapshot(`200`) const toppedUp = await manager.topUp('0.01') if (!toppedUp) throw new Error('expected top-up receipt') expect(toppedUp.status).toMatchInlineSnapshot(`"success"`) const closed = await manager.close() if (!closed) throw new Error('expected close receipt') expect(closed.status).toMatchInlineSnapshot(`"success"`) expect(closed.channelId).toBe(toppedUp.channelId) const state = await Actions.channel.getStates(Tempo.client, { channel: closed.channelId, }) expect(state.deposit).toMatchInlineSnapshot(`0n`) }) })