import type { Context } from 'hono' import { Hono } from 'hono' import { matchedRoutes } from 'hono/route' import { compress as hono_compress } from 'hono/compress' import { cors as hono_cors } from 'hono/cors' import { HTTPException } from 'hono/http-exception' import { requestId, type RequestIdVariables } from 'hono/request-id' import { timeout as hono_timeout } from 'hono/timeout' import { generateSpecs } from 'hono-openapi' import type { Chain } from 'viem' import type * as Analytics from './analytics/Analytics.js' import * as ApiKey from './ApiKey.js' import * as Assets from './Assets.js' import * as Auth from './internal/Auth.js' import * as Db from './db/Db.js' import * as EdgeCache from './internal/EdgeCache.js' import * as Log from './internal/Log.js' import * as MetricSink from './internal/MetricSink.js' import type * as Metrics from './Metrics.js' import * as OpenApi from './internal/OpenApi.js' import * as Store from './internal/Store.js' import * as Path from './internal/Path.js' import type * as Provider from './internal/Provider.js' import * as Response from './internal/Response.js' import * as Scope from './Scope.js' import type * as Tidx from './internal/Tidx.js' import * as Timing from './internal/Timing.js' import * as Viem from './internal/Viem.js' import type * as Webhooks from './internal/Webhooks.js' import openrpcSpec from './openrpc.json' with { type: 'json' } import * as Metadata from './apps/metadata.js' import * as Zones from './apps/Zones.js' import * as Data from './apps/data/App.js' import * as Funding from './apps/funding/App.js' import * as Management from './apps/management/App.js' import * as Mpp from './apps/mpp/App.js' import * as Relay from './apps/relay/App.js' export import data = Data.data export import funding = Funding.funding export import management = Management.management export import mpp = Mpp.mpp export import relay = Relay.relay /** The chain-data route group; see {@link data}. */ export type DataApp = ReturnType /** The inbound funding route group; see {@link funding}. */ export type FundingApp = ReturnType /** The management route group; see {@link management}. */ export type ManagementApp = ReturnType /** The MPP credential relay route group; see {@link mpp}. */ export type MppApp = ReturnType /** The wallet relay route group; see {@link relay}. */ export type RelayApp = ReturnType /** Hono environment variables set by the Tempo API app. */ export type Environment = { Variables: Auth.Variables & RequestIdVariables & { /** ClickHouse-backed analytics store, or a per-request factory; undefined when usage analytics is unconfigured. Resolve at the leaf with `Analytics.get`. */ analytics: Analytics.Source | undefined /** Base path for this API app. */ basePath: string /** Default Tempo chain id for this API app. */ chainId: Viem.ChainId /** Authoritative Postgres store, or a per-request factory. Resolve at the leaf with `Db.get`. */ db: Db.Source /** Store for staleness-tolerant reads (a caching Hyperdrive config when configured); falls back to `db`. */ dbCached: Db.Source /** KV state store backing API-key records, or undefined when key auth is closed. */ kv: { store: Store.State } | undefined /** Providers registered by the host application. */ providers: readonly Provider.Provider[] /** Issuable API-key scope catalog, including host-defined entries. */ scopeCatalog: Scope.Catalog /** Gets one static API asset. */ getAsset: GetAsset /** Gets a Tempo RPC client for a chain id. */ getClient: Viem.GetClient /** Gets a TIDX query client for a chain id. */ getTidx: Tidx.GetClient /** Configured RPC client options used by the raw RPC passthrough. */ rpc: Viem.getClient.Rpc | undefined /** Cache store: handler `Store.memoize` and the edge response cache. */ store: Store.Store /** Chain ids this deployment serves; a request outside this set is rejected with a clear 400. */ supportedChainIds: ReadonlySet /** Configured TIDX client options used by the raw indexer passthrough. */ tidx: Tidx.getClient.Tidx | undefined /** Verified-token feature configuration (refresh window), or undefined when the feature is disabled. Token rows live in `db`. */ verifiedTokens: { refreshMs?: number | undefined } | undefined /** Zone chain ids inferred from the current API key, or undefined for single-chain requests. */ zoneChainIds: readonly Viem.ChainId[] | undefined /** Zone chains keyed by chain id; empty when none are configured. */ zones: ReadonlyMap /** Webhook feature configuration, or undefined when webhooks are disabled. Rows live in `db`. */ webhook: Webhook | undefined } & EdgeCache.Variables & Log.Variables & Timing.Variables } /** * The canonical fully-composed Tempo API app ({@link create} with all route * groups). {@link Client} and the reference worker target this type; a subset * consumer infers its own narrower type from its own composition chain. */ export type App = ReturnType /** Defines an external route group with metadata collected by {@link create}. */ export function from>(app: app, metadata: create.Metadata): app { return Metadata.attach(app, metadata) } // Type anchor only — never invoked. Expresses the canonical composition so // `App` (and thus `Client`) reflect the full route surface; real consumers // compose explicitly. function compose(options: create.Options) { return create(options) .route('/', data()) .route('/', funding()) .route('/', relay()) .route('/', mpp({ state: Store.memory() })) .route('/', management()) } /** Node.js request listener for `node:http` and compatible interfaces. */ export type Listener = (incoming: unknown, outgoing: unknown) => Promise /** HTTP endpoints owned by Tempo API handlers, used for typed auth overrides. */ export type Endpoint = Auth.Endpoint /** Stripe billing capability for management billing routes; owned by {@link management}. */ export type Billing = Management.Billing /** Transactional email sender for management emails; owned by {@link management}. */ export type Email = Management.Email /** Webhook capability shared by subscription routes and the host poller. */ export type Webhook = { /** Application events produced by the host outside the chain poller. */ applicationEventTypes?: readonly Webhooks.EventType[] | undefined /** Maximum live (unexpired) subscriptions per owner. Defaults to 100. */ maxPerOwner?: number | undefined /** Chain ids the host actively polls for webhook events. */ supportedChainIds: readonly number[] } /** One static API asset resolved from packaged data. */ export type Asset = { /** Loaded asset response. */ response: Response /** Absolute public API URL for the asset (scheme + host + path). */ uri: string } /** Gets one static API asset for a chain, or undefined when it is missing. */ export type GetAsset = (( chainId: Viem.ChainId, path: string, ) => Asset | undefined | Promise) & { /** Lists public asset locations under a path prefix, when supported. */ list?: GetAsset.List | undefined } export declare namespace GetAsset { /** Lists public asset locations for a chain and path prefix. */ type List = ( chainId: Viem.ChainId, path: string, ) => Promise< readonly { /** Internal asset-store key. */ key: string /** Absolute public API URL for the asset. */ uri: string }[] > } /** Top-level OpenAPI `info.description`, rendered above the reference. */ const infoDescription = 'REST API for reading and interacting with Tempo' // Comfortably above the slowest legitimate time-to-response observed in // production (~40s tidx reads), far below the 17-100 minute stalls it bounds. const requestTimeoutMs = 60_000 /** Creates the Tempo API Hono app. */ export function create(options: create.Options) { const { path = '/' as path } = options // Capture as the literal generic so `Hono.basePath(...)` preserves the // typed prefix. testClient's `Client` inference collapses to a // `Record` index signature when the BasePath generic is the // unconstrained `string`, which strips per-route typing. const basePath = Path.normalize(path) as path // The cache store backs both caching layers: handler `memoize` (upstream // RPC/indexer reads, keyed by data identity) and the edge response cache // (whole responses, keyed by URL). Both are caches, not durable storage — a // miss simply re-fetches — so it defaults to in-memory. Point it at a // colo-local store (`Store.cache(caches.default)`) in production; never the // Durable Object, or edge-cache hits re-incur the round-trip they skip. // Authoritative state lives elsewhere: webhooks + verified tokens in // `options.db`, API-key records in `options.kv`. const store = options.cache?.store ?? Store.memory() const scopeCatalog = Scope.extend(options.scopes) const defaultChainId = options.defaultChainId ?? Viem.defaultChainId const zones = new Map( (options.zones === true ? Zones.chains : (options.zones ?? [])).map((zone) => [zone.id, zone]), ) const getClient = Viem.createGetClient({ defaultChainId, rpc: options.rpc, zones: [...zones.values()], }) const configuredRpcChainIds = typeof options.rpc === 'function' ? [] : Viem.configuredChainIds(options.rpc?.url) const supportedChainIds = new Set([ ...Object.keys(Viem.url).map(Number), defaultChainId, ...configuredRpcChainIds, ...(options.supportedChainIds ?? []), ...zones.keys(), ]) const defaultChainRouteHandlers = new Set() // Build the app in two phases: register middleware imperatively, then chain // route mounts so the merged Hono schema flows through the return type. The // chained type is what makes `testClient(create())` produce typed path // proxies in tests. `basePath` is typed as the literal `path` generic above, // which keeps the merged schema keys typed (vs. the unconstrained `string` // basePath that would collapse them to an index signature). const base = new Hono().basePath(basePath) if (options.cors !== false) base.use('*', hono_cors(typeof options.cors === 'object' ? options.cors : undefined)) if (options.compress) base.use( '*', hono_compress(typeof options.compress === 'object' ? options.compress : undefined), ) base.use('*', requestId()) base.use('*', async (c, next) => { await next() c.header('tempo-request-id', c.get('requestId')) }) // One canonical structured log line per request, plus request metrics. // Registered after `requestId` (the entry embeds it) and before the timing and // edge-cache layers so the entry covers edge-cache hits and includes the // Server-Timing metrics collected inside the request. // // Logging and metrics are separate concerns sharing one entry: `logger` emits // the structured log line, the request metrics sink records HTTP series, and // the optional `metrics.analytics` hook ships the full wide-event entry. The // webhook poller/queue run outside this app, so they wire their own // `MetricSink.webhooks` sink over the same backend. // Wire the request (HTTP) metrics sink over the provided backend. const recordRequest = options.metrics ? MetricSink.requests(options.metrics) : undefined // Per-request analytics hook (e.g. ClickHouse wide events), if the backend // provides one. Its promise is returned from `emit` below so `Log.middleware` // keeps it alive via `waitUntil` without holding the response open. const recordAnalytics = options.metrics?.analytics // Resolve `logger` to a single emit sink: a custom function as-is, `true` to // the level-matched console emit, a level string to that emit filtered to the // level and above (e.g. `'warn'` drops successful-request info lines). const log: Log.Emit | undefined = (() => { if (typeof options.logger === 'function') return options.logger if (options.logger === true) return Log.emit if (typeof options.logger === 'string') return Log.withMinLevel(options.logger) return undefined })() if (log || recordRequest || recordAnalytics) base.use( '*', Log.middleware({ emit: async (entry, cause) => { await log?.(entry, cause) recordRequest?.(entry) // Return the analytics promise so `Log.middleware` extends the // response with `waitUntil`; otherwise the async sink's `queue.send` // is a floating promise the Workers runtime cancels once the response // is returned, and no analytics ever reach the sink. return recordAnalytics?.(entry) }, }), ) // Bounds time-to-response only; streaming bodies are unaffected. Without a // deadline, a request stalled on an unbounded upstream wait pins its // invocation (and isolate memory) until the client disconnects. const deadline = hono_timeout( requestTimeoutMs, (c) => new HTTPException(504, { message: 'Request timed out', res: Response.error(c, { code: 'request_timeout', message: 'Request timed out', status: 504, }), }), ) // Safe methods only: the expired handler is abandoned, not aborted, so a // mutation could still commit after the 504. Paid requests are also exempt: // MPP consumes payment before the handler, and the Payment-Receipt attaches // only when the handler returns through the payment wrapper. base.use('*', (c, next) => { if (c.req.method !== 'GET' && c.req.method !== 'HEAD') return next() if (Auth.hasPaymentCredential(c)) return next() return deadline(c, next) }) // Normalize the `db` option: the plain form is `{ source }` with no cached // side; `cached` falls back to `source` so consumers never branch. const db = 'source' in options.db ? options.db : { source: options.db } const providers = (() => { const values = options.providers ?? [] const ids = new Set() for (const provider of values) { if (provider.id.length === 0 || provider.id !== provider.id.trim().toLowerCase()) throw new InvalidProviderIdError(provider.id) if (ids.has(provider.id)) throw new DuplicateProviderIdError(provider.id) ids.add(provider.id) } return values })() // Normalize the `analytics` option: accept the bare source or `{ source }`, // mirroring `db`. Undefined leaves usage analytics unconfigured (501). const analytics = options.analytics === undefined ? undefined : 'source' in options.analytics ? options.analytics.source : options.analytics // Built-in auth: the session surface (mounted last) plus the enforcement // middleware (installed below), wired together in `Auth.install`. const auth = Auth.install({ auth: options.auth, db: db.source, getClient, kv: options.kv, scopeCatalog, }) base.use('*', Timing.middleware()) base.use('*', async (c, next) => { c.set('analytics', analytics) c.set('basePath', basePath) c.set('chainId', defaultChainId) c.set('db', db.source) c.set('dbCached', db.cached ?? db.source) c.set('getClient', (chainId) => getClient(chainId, Auth.getPrincipal(c))) c.set('kv', options.kv) c.set('providers', providers) c.set('rpc', options.rpc) c.set('scopeCatalog', scopeCatalog) c.set('store', Store.withRequest(store, c.req, { waitUntil: getWaitUntil(c) })) c.set('supportedChainIds', supportedChainIds) c.set('zones', zones) await next() }) // Safety net: any non-2xx response (auth/validation errors, MPP `402` // challenges, rate-limit `429`s, upstream `502`s, etc.) must never be // cached. This wraps endpoint middleware and handlers registered below. base.use('*', async (c, next) => { await next() if (c.res.status >= 200 && c.res.status < 300) return if (c.res.headers.has('Cache-Control')) return c.res.headers.set('Cache-Control', 'no-store') }) // Serve GETs from the cache store before user middleware (auth, rate limiting) // runs, so cache hits skip the per-request store round-trips that dominate // latency. if (options.cache?.edge !== false) base.use('*', EdgeCache.middleware({ store })) const app = base.get('/health', OpenApi.describeRoute({ hide: true }), (c) => c.json({ status: 'ok' }, 200), ) // Built-in enforcement: default-closed policies over OpenAPI-registered // routes. `auth: false` leaves policies unenforced (tests only). if (auth.middleware) app.use('*', auth.middleware) // Groups marked as default-chain consumers share the root Zone and sandbox // policy without inheriting another group's chain selectors. app.use('*', async (c, next) => { const matched = matchedRoutes(c).some((route) => defaultChainRouteHandlers.has(route.handler)) if (!matched) return next() const chainId = c.get('chainId') const principal = Auth.getPrincipal(c) if (c.get('zones').has(chainId)) { const scopes = principal?.type === 'api_key' ? principal.apiKey.scopes : undefined const allowed = scopes?.includes(Scope.wildcard) || scopes?.includes(`zone:${chainId}:read`) || scopes?.includes(`zone:${chainId}:write`) if (!allowed) return Response.error(c, { code: 'api_key_forbidden', message: `Chain id ${chainId} is a zone. Use an API key granting \`zone:${chainId}:read\` or \`zone:${chainId}:write\`.`, status: 403, }) } if ( principal?.type === 'api_key' && principal.environment === 'sandbox' && Viem.isMainnet(chainId) ) return Response.error(c, { code: 'api_key_forbidden', message: 'Sandbox API keys only support testnet. Pass a testnet `chainId`.', status: 403, }) await next() if (c.get('zones').has(chainId)) c.set('edgeCache', undefined) }) // Collect each mounted group's OpenAPI metadata: Hono's `.route()` drops // sub-app metadata, so a group attaches it to its instance (a WeakMap) and we // read it back here. Patching the runtime method, not the type, keeps `hc`. const metadata: create.Metadata[] = [] const route = app.route.bind(app) ;(app as unknown as { route: typeof route }).route = ((path, sub) => { const entry = Metadata.read(sub) if (entry) { metadata.push(entry) for (const chainId of entry.supportedChainIds ?? []) supportedChainIds.add(chainId) } if (Metadata.usesDefaultChain(sub)) for (const route of sub.routes) { if (route.method === 'ALL') continue defaultChainRouteHandlers.add(route.handler) EdgeCache.setEligibility( route.handler, (c: Context) => !c.get('zones').has(c.get('chainId')), ) } return route(path, sub) }) as typeof route // Register the JSON error envelopes eagerly (they don't depend on mounted // routes) so error handling is live before the first request and a consumer's // `withSentry(app)` sees them already in place. app.notFound((c) => Response.error(c, { code: 'not_found', message: 'Route not found', status: 404 }), ) app.onError((cause, c) => { if (cause instanceof HTTPException) return errorFromException(c, cause) // Redact any leaked API-key token from the logged error/stack. console.error( ApiKey.redact(cause instanceof Error ? (cause.stack ?? cause.message) : String(cause)), ) return Response.error(c, { code: 'internal_error', message: 'Internal server error', status: 500, }) }) // Mount the OpenAPI/OpenRPC documents and docs reference lazily on the first // request, so the document sees the full route surface without a `seal()` // step. The patched `fetch` runs this before Hono builds its matcher. // // Spec generation stays lazy: ordinary API traffic only installs the routes, // while `/openapi.json` and the Vocs handler start the memoized build. let spec: ReturnType | undefined const getSpec = () => (spec ??= buildSpec()) const buildSpec = async () => { // Merge each mounted group's contributed OpenAPI metadata (tags, security // schemes, webhooks block). Unused tags/schemes don't render, so a subset // app's document carries only what its groups contributed. // The built-in session surface's metadata appends after every mounted // group's, so consumer-mounted groups lead the document order. const metadata_all = (() => { const entry = auth.app ? Metadata.read(auth.app) : undefined return entry ? [...metadata, entry] : metadata })() const contributedSchemes = Object.assign({}, ...metadata_all.map((m) => m.securitySchemes ?? {})) // prettier-ignore const tags = [ ...new Map( metadata_all.flatMap((m) => (m.tags ?? []).map((tag) => [tag.name, tag] as const)), ).values(), ] // Tag groups contributed under the same name merge into one sidebar group, // deduped and filtered to defined tags (a group may list a tag another // surface defines, e.g. `Auth` from the built-in session surface). const tagGroups = (() => { const defined = new Set(tags.map((tag) => tag.name)) const groups = new Map() for (const { name, tags: members } of metadata_all.flatMap((m) => m['x-tagGroups'] ?? [])) groups.set(name, { name, tags: [...(groups.get(name)?.tags ?? []), ...members] }) return [...groups.values()] .map((group) => ({ ...group, tags: [...new Set(group.tags)].filter((tag) => defined.has(tag)), })) // prettier-ignore .filter((group) => group.tags.length > 0) })() const generated = await generateSpecs(app, { documentation: { components: { headers: OpenApi.headerComponents, responses: OpenApi.responseComponents(), securitySchemes: { apiKey: { description: 'Your Tempo API key, sent in the `tempo-api-key` request header. This is the canonical and recommended API-key header.', in: 'header', name: 'tempo-api-key', type: 'apiKey', }, bearerAuth: { description: 'Compatibility alternative for API keys: send `Authorization: Bearer `. Prefer the `tempo-api-key` header when your HTTP client supports custom headers.', // prettier-ignore scheme: 'bearer', type: 'http', }, queryApiKey: { description: 'Supported query-string alternative for API keys. Prefer `tempo-api-key` because URLs may be logged by clients and infrastructure.', in: 'query', name: 'key', type: 'apiKey', }, ...contributedSchemes, }, }, info: { description: infoDescription, title: 'Tempo API', version: '1.0.0', }, security: [{ apiKey: [] }, { queryApiKey: [] }, { bearerAuth: [] }], servers: [ ...(options.servers ?? [ { url: basePath || '/', description: 'Relative to the host serving this document.' }, ]), ], tags, ...(tagGroups.length ? { 'x-tagGroups': tagGroups } : {}), }, }) // Document the outbound `webhooks` block only when the webhook routes are // actually mounted; a disabled feature hides its routes, so it must not // advertise deliveries. const hasWebhookRoutes = Object.keys(generated.paths).some((path) => /\/webhooks(\/|$)/.test(path), ) const webhookBlocks = hasWebhookRoutes ? metadata_all.flatMap((m) => (m.webhooks ? [m.webhooks()] : [])) : [] // prettier-ignore const webhooksDoc = webhookBlocks.length ? { webhooks: Object.assign({}, ...webhookBlocks) } : {} // Set per-operation `security` and the `402` payment-challenge response // from each route's resolved auth policy (the single source of truth), so // the anonymous option and `402` appear only where the public/MPP lanes // are enabled, not on every operation via the document default. OpenApiDocument.applyAccess(generated.paths, Auth.describeAccess(app)) // Point the `/rpc` operation at the OpenRPC document via `x-openrpc`. OpenApiDocument.applyOpenrpcLink(generated.paths, basePath) // Re-order paths so list endpoints appear before get endpoints inside each // tag group in the sidebar. We classify by operation summary, which every // handler sets as either "List ..." or "Get ...". The reference groups by // tag and preserves document path order within a tag, so reordering here // drives the sidebar sub-item order. return { ...generated, ...webhooksDoc, paths: OpenApiDocument.sortPaths(generated.paths) } } let finalized = false const finalize = () => { if (finalized) return Auth.installMppManagementRoutes(app, { basePath, overrides: options.auth === false ? undefined : options.auth?.overrides, }) finalized = true app.get('/openapi.json', async (c) => c.json(await getSpec())) // The OpenRPC document is a committed build artifact (no runtime generator); // see scripts/openrpc/build.ts and `pnpm openrpc:build`. app.get('/openrpc.json', (c) => c.json(openrpcSpec)) // Mount each docs group's reference factory (see `docs()` in `tapimo/docs`) // last, so its catch-all sits behind every API route and the documents // above — regardless of where the group sat in the composition chain. for (const entry of metadata) if (entry.docs) app.route('/', entry.docs({ spec: getSpec })) } // Patch `fetch` to finalize once before dispatch. `request()` reads // `this.fetch` dynamically so it picks up the patch, and the captured // `originalFetch` still dispatches as a bare value (used by `App.listener`). const originalFetch = app.fetch ;(app as unknown as { fetch: typeof originalFetch }).fetch = ((request, ...rest) => { finalize() return originalFetch(request, ...rest) }) as typeof originalFetch return route('/', auth.app ?? new Hono()) } /** * Wraps a Tempo API app with a `node:http`-compatible request listener. * Lifted out of `create()` so the returned `app` keeps its precise chained * Hono type (which `testClient` needs); intersecting the listener onto the * app degrades the typed proxy inference. */ export function listener(app: { fetch: NodeServer.Fetch }): Listener { return NodeServer.getRequestListener(app.fetch) } export declare namespace create { /** * Options for creating the Tempo API Hono app: shared chain, RPC, database, * cache, authentication, and cross-cutting configuration. Email belongs to * {@link management}. */ type Options = { /** Analytics store for usage reads (e.g. `Analytics.clickhouse(...)`), or a per-request factory. Accepts `{ source }` to mirror `db`. Omit to leave usage analytics unconfigured — usage routes answer 501. Ingest happens outside this app (queue consumer via `Analytics.handleQueue`). */ analytics?: Analytics.Source | { source: Analytics.Source } | undefined /** Authentication: lane defaults, quotas, and credentials for the built-in enforcement middleware, plus the `session` sign-in surface. Omit for default-closed policies with in-memory rate limiting; pass false to leave policies unenforced (tests only). */ auth?: Auth.install.Options['auth'] | false | undefined /** Caching configuration: the cache store plus the edge response cache toggle. */ cache?: Cache | undefined /** Response compression for API handlers. Disabled by default; pass true for defaults or a config object to customize. Leave disabled when fronted by a proxy/CDN that already compresses. */ compress?: boolean | Compress | undefined /** CORS for API handlers. Enabled by default (wildcard origin, no credentials); pass a config object to customize, true for defaults, or false to disable (e.g. when CORS is handled by a fronting gateway/CDN). */ cors?: boolean | Cors | undefined /** Authoritative Postgres store backing stateful features (webhooks, verified tokens). Pass a long-lived `Db.postgres(...)` singleton on Node, or a factory (`() => Db.postgres(...)`) on Workers so each request builds a fresh Hyperdrive-pooled connection. Pass `{ source, cached }` to route staleness-tolerant reads (verified tokens) through a second, query-caching Hyperdrive config; `source` stays uncached for read-after-write correctness. Apply migrations out of band via `tapimo admin migrate`. */ db: Db.Source | { cached?: Db.Source | undefined; source: Db.Source } /** Default Tempo chain id for requests that omit `chainId`. */ defaultChainId?: Viem.ChainId | undefined /** KV state store backing API-key records (e.g. `Store.cloudflareKv(...)`). Omit to leave API-key auth closed — every token resolves to `null`. */ kv?: Kv | undefined /** Request logging. Disabled by default. Pass `true` to emit one canonical structured log entry per request through the level-matched console method (indexed by Workers Logs); a level (e.g. `'warn'`) to emit only that level and above (dropping successful-request info lines); or an emit function for a custom sink. */ logger?: boolean | Log.Level | Logger | undefined /** Metrics backend. Disabled by default; pass a backend (e.g. `Metrics.cloudflare()`) to emit request (HTTP) metrics per response. Pass the same backend to the webhook poller/queue handlers so they emit over it too. */ metrics?: Metrics.Metrics | undefined /** Base path to mount API handlers under. Provided as a literal via the `path` generic so the resulting Hono schema keeps typed route paths for `testClient`. */ path?: path | undefined /** Providers available to mounted route groups. */ providers?: readonly Provider.Provider[] | undefined /** Tempo RPC options, including per-request routing by request principal. */ rpc?: Viem.getClient.Rpc | undefined /** OpenAPI `servers` for the generated document. Defaults to a single relative server (the host serving the doc), so the reference targets the deployment it is served from. Set the canonical public URL(s) here, e.g. `[{ url: 'https://api.tempo.xyz' }]`. */ servers?: readonly Server[] | undefined /** Host-defined API-key scopes appended to the built-in {@link Scope.catalog}. */ scopes?: readonly Scope.Entry[] | undefined /** Extra chain ids served beyond the built-in chains and `defaultChainId`. */ supportedChainIds?: readonly number[] | undefined /** Zone chains served by this deployment. Pass true to use the hosted Zone list. */ zones?: true | readonly Chain[] | undefined } /** KV state-store configuration. */ type Kv = { /** * State store backing API-key records. Must be a {@link Store.State} * (e.g. `Store.cloudflareKv(...)` or `Store.memory()`); the Web Cache * adapter (`Store.cache`) is rejected at the type level since a miss there * would silently drop authoritative state. */ store: Store.State } /** Caching configuration. */ type Cache = { /** Edge response caching for public GET endpoints, serving cache hits from `store` before auth/rate-limiting for all callers (hits bypass origin metering). Enabled by default; set false to disable. */ edge?: boolean | undefined /** Cache store backing both the handler read cache and the edge response cache. This is a cache, not durable storage — a miss simply re-fetches — and defaults to in-memory. Use `Store.cache(caches.default)` in production for a colo-local store; never the Durable Object. */ store?: Store.Store | undefined } /** Binary asset store for static API assets (e.g. `Assets.cloudflareR2(...)`). */ type Assets = Assets.Assets /** Response compression middleware options. */ type Compress = NonNullable[0]> /** CORS middleware options. */ type Cors = NonNullable[0]> /** Reference-UI factory a docs group carries via {@link Metadata} (see `docs()` in `tapimo/docs`): given the app's OpenAPI document builder, returns an app that {@link create} mounts at the root on finalize. */ type Docs = (context: DocsContext) => Hono /** Context the app provides to a docs group's reference factory. */ type DocsContext = { /** Memoized OpenAPI document builder — the same document served at `/openapi.json`. */ spec: () => Promise<{ paths: Record }> } /** Custom request-log sink, receiving one canonical entry per request. */ type Logger = Log.Emit /** OpenAPI server entry for the generated document. */ type Server = { /** Server URL (absolute, or relative to the requesting host). */ url: string /** Optional human-readable description. */ description?: string } /** * What a route group contributes to the composed app beyond its routes: * OpenAPI document metadata (tags, security schemes, outbound webhooks block) * and an optional docs-UI factory. `create` collects these off each mounted * group (via {@link Metadata}); unused entries don't render. */ type Metadata = { /** Reference-UI factory mounted at the root on finalize (see `docs()` in `tapimo/docs`). */ docs?: Docs | undefined /** Security scheme definitions this group's routes reference (e.g. the session cookie). */ securitySchemes?: Record | undefined /** Chain ids served by this route group, merged before auth handles the first request. */ supportedChainIds?: readonly number[] | undefined /** Root-level OpenAPI tag definitions for this group's operations. */ tags?: readonly Tag[] | undefined /** Builds the OAS 3.1 `webhooks` block documenting outbound deliveries, emitted only when the group's webhook routes are mounted. Called during spec generation only, keeping schema conversion off isolate startup. */ webhooks?: (() => Record) | undefined /** Vocs tag-group metadata for grouping root tags in the reference. */ 'x-tagGroups'?: readonly TagGroup[] | undefined } /** One root-level OpenAPI tag definition. */ type Tag = { /** Human-readable summary shown in the reference sidebar. */ description: string /** Tag name referenced by operations. */ name: string /** Optional human-readable label for references that support `x-displayName`. */ 'x-displayName'?: string | undefined /** Optional page path below the reference mount. */ 'x-pagePath'?: string | undefined } /** One Vocs OpenAPI tag group. */ type TagGroup = { /** Section name shown by tag-group-aware references. */ name: string /** Root tag names in this group. */ tags: readonly string[] } } function getRequestId(c: Context) { return c.get('requestId') } async function errorFromException(c: Context, cause: HTTPException) { const requestId = getRequestId(c) const response = cause.getResponse() response.headers.set('tempo-request-id', requestId) if (!ResponseBody.isJson(response)) return response const body = await response .clone() .json() .catch(() => null) if (!body || typeof body !== 'object' || Array.isArray(body)) return response return c.json({ ...body, requestId }, cause.status, Object.fromEntries(response.headers)) } namespace OpenApiDocument { // Reorder OpenAPI `paths` so reads come before writes within each tag group, // and shallower paths come before deeper paths inside each bucket so root-level // routes ("List tokens" — `/tokens`) win over nested ones ("List address // tokens" — `/addresses/{address}/tokens`). Tie-break by original document // order to keep registered grouping. // // A path is ranked as a unit because REST groups verbs onto shared paths (and // the reference renders all methods of a path adjacently, in the fixed canonical // method order GET→POST→PATCH→DELETE), so two operations on the same path can // never be separated or reordered relative to each other. // // Read-only tags keep lists-before-gets (tiers 0/2). The `Webhooks` tag instead // groups all reads (lists, then the item GET) ahead of the item writes, scoped // to that tag so the read tags are untouched. The shared-path constraint means // the desired "Create, all GETs (lists first), Update, Delete" can only be // approximated: `Create webhook` shares `/webhooks` with `List webhooks`, and // vocs renders GET before POST, so "List webhooks" unavoidably precedes // "Create". `Get`/`Update`/`Delete` all share `/webhooks/{id}`, so they render // adjacently as Get→Update→Delete. The `{id}` item is tiered right after the // collection root, so `Get/Update/Delete` sit just below `List webhooks/Create`, // ahead of the remaining lists and action POSTs. Tiers: // 0 collection root (`GET`+`POST /webhooks`) → "List webhooks, Create webhook" // 1 item (`GET`+`PATCH`+`DELETE /webhooks/{id}`) → "Get, Update, Delete" // 2 other list-only paths (`/event-types`, `/deliveries`) // 3 other writes/actions (`POST .../ping`, `.../retry`) // Funding deposit paths preserve their resource-specific sidebar order. const fundingDepositOperationOrder = [ 'createFundingDepositAddress', 'reconcileFundingDepositAddress', 'getFundingDepositAddress', 'getFundingDeposit', 'listFundingDeposits', ] as const export function sortPaths(paths: PathsObject): PathsObject { const entries = Object.entries(paths) const rank = (entry: [string, PathItem]) => { const operations = Object.values(entry[1]) const summaries = operations.flatMap((op) => isOperation(op) && op.summary ? [op.summary] : [], ) const has = (prefix: string) => summaries.some((summary) => summary.startsWith(prefix)) if (operations.some((op) => isOperation(op) && op.tags?.includes('Webhooks'))) { if (has('Create')) return 0 if (has('Get')) return 1 if (has('List')) return 2 return 3 } if (operations.some((op) => isOperation(op) && op.tags?.includes('Deposit Addresses'))) { const index = fundingDepositOperationOrder.findIndex((operationId) => operations.some((op) => isOperation(op) && op.operationId === operationId), ) if (index >= 0) return index } if (/\/v1\/funding(?:\/|$)/.test(entry[0])) { if (has('Create')) return 1 if (has('Get transfer')) return 2 return 0 } if (has('List')) return has('Create') ? 1 : 0 if (has('Get')) return 2 if (has('Create')) return 3 return 4 } const depth = (path: string) => path.endsWith('/rpc') ? 2 : path.split('/').filter(Boolean).length const indexed = entries.map( (entry, index) => [entry, index, rank(entry), depth(entry[0])] as const, ) indexed.sort(([, ai, ar, ad], [, bi, br, bd]) => ar - br || ad - bd || ai - bi) return Object.fromEntries(indexed.map(([entry]) => entry)) } /** * Applies route security, required scopes, and payment responses from resolved access lanes. * API keys support header, query, and bearer authentication. */ export function applyAccess(paths: PathsObject, access: Record) { for (const item of Object.values(paths)) { for (const op of Object.values(item as Record)) { if (!isMutableOperation(op)) continue const lanes = access[op.operationId] if (!lanes) continue const security = [ ...(lanes.apiKey ? [{ apiKey: [] }, { queryApiKey: [] }, { bearerAuth: [] }] : []), ...(lanes.session ? [{ session: [] }, ...(lanes.apiKey ? [] : [{ bearerAuth: [] }])] : []), ...(lanes.public ? [{}] : []), ] if (security.length > 0) op.security = security if (lanes.scopes.length > 0) op['x-required-scopes'] = [...lanes.scopes] if (lanes.mpp) op.responses = { ...op.responses, 402: OpenApi.paymentChallengeRef } else if (op.responses) delete op.responses['402'] } } } /** * Links the JSON-RPC passthrough operation to the OpenRPC document (served at * `{basePath}/openrpc.json`) via an `x-openrpc` vendor extension. The raw `/rpc` * request body is opaque to OpenAPI, so this lets consumers discover the * method/param/return schemas it accepts. * * The public `/openapi.json` keeps a URL reference (small, conventional). The * docs handler instead inlines the document — see `inlineOpenrpc` in `tapimo/docs`. */ export function applyOpenrpcLink(paths: PathsObject, basePath: string) { // `basePath` is `/` at the root mount and `/api` etc. otherwise; strip any // trailing slash so the join never doubles up (`//openrpc.json`). const url = `${basePath.replace(/\/$/, '')}/openrpc.json` for (const item of Object.values(paths)) for (const op of Object.values(item as Record)) if (isMutableOperation(op) && isRpcOperation(op)) op['x-openrpc'] = url } type PathsObject = Awaited>['paths'] type PathItem = PathsObject[string] type Operation = { operationId?: string | undefined; summary?: string | undefined } type MutableOperation = { operationId: string responses?: Record | undefined security?: unknown 'x-openrpc'?: unknown 'x-required-scopes'?: unknown } function isRpcOperation(op: MutableOperation) { return op.operationId === 'rpcRequest' } function isOperation(value: unknown): value is Operation { return !!value && typeof value === 'object' && 'summary' in value } function isMutableOperation(value: unknown): value is MutableOperation { return ( !!value && typeof value === 'object' && !Array.isArray(value) && 'operationId' in value && typeof value.operationId === 'string' ) } } namespace ResponseBody { export function isJson(response: globalThis.Response) { return response.headers.get('content-type')?.includes('application/json') ?? false } } function getWaitUntil(c: { executionCtx?: { waitUntil(promise: Promise): void } }) { // `c.executionCtx` throws in runtimes without one, so cache persistence falls back to an awaited write. try { const context = c.executionCtx return context ? context.waitUntil.bind(context) : undefined } catch { return undefined } } namespace NodeServer { type GetRequestListener = (typeof import('@hono/node-server'))['getRequestListener'] export type Fetch = Parameters[0] type NodeRequestListener = ReturnType export function getRequestListener(fetch: Fetch): Listener { let listener: NodeRequestListener | undefined return async (incoming_, outgoing_) => { // Resolve the Node-only adapter at call time. The split specifier keeps // Worker bundlers from eagerly including a server adapter they never run. if (listener === undefined) { const nodeServer = (await import( /* @vite-ignore */ ['@hono', 'node-server'].join('/') )) as typeof import('@hono/node-server') listener = nodeServer.getRequestListener(fetch) } return listener(incoming_ as never, outgoing_ as never) } } } /** Error thrown when an app registers the same provider identifier more than once. */ export class DuplicateProviderIdError extends Error { override name = 'App.DuplicateProviderIdError' constructor(id: string) { super(`Duplicate provider id "${id}".`) } } /** Error thrown when an app registers a provider with a non-normalized identifier. */ export class InvalidProviderIdError extends Error { override name = 'App.InvalidProviderIdError' constructor(id: string) { super(`Invalid provider id "${id}". Provider ids must be lowercase and trimmed.`) } }