import * as Accounts from 'accounts/server' import { Hono } from 'hono' import type { Context as hono_Context, MiddlewareHandler } from 'hono' import type { HonoBase } from 'hono/hono-base' import { createMiddleware } from 'hono/factory' import { matchedRoutes } from 'hono/route' import type { MergePath, RouterRoute, Schema as core_Schema } from 'hono/types' import { findTargetHandler } from 'hono/utils/handler' import { Challenge, Credential, type Method } from 'mppx' import { Mppx, tempo } from 'mppx/hono' import { Addresses } from 'viem/tempo' import { tempoMainnet } from 'viem/tempo/chains' import * as z from 'zod/mini' import type * as App from '../App.js' import * as ApiKey from '../ApiKey.js' import * as ApiKeys from '../ApiKeys.js' import * as Db from '../db/Db.js' import type * as Store from './Store.js' import * as Memberships from '../db/tables/memberships.js' import * as IpAllowlist from './IpAllowlist.js' import * as Metadata from '../apps/metadata.js' import * as OpenApi from './OpenApi.js' import * as Organizations from '../db/tables/organizations.js' import * as Projects from '../db/tables/projects.js' import * as RateLimit from './RateLimit.js' import * as Response from './Response.js' import * as Schema from './Schema.js' import * as Scope from '../Scope.js' import * as Users from '../db/tables/users.js' import type * as Viem from './Viem.js' const policySymbol = Symbol('tempo-api.auth.policy') const mppManagementSymbol = Symbol('tempo-api.auth.mpp-management') const defaultApiKeyRateLimit = { limit: 10_000, period: 'minute' } satisfies RateLimit.Limit const defaultMppRateLimit = { limit: 100, period: 'minute' } satisfies RateLimit.Limit /** Auth context consumed by Tempo API handlers. */ export type Context = { /** API key authentication configuration. */ apiKey?: | { /** * API-key quotas keyed by quota scope. The reserved `'*'` entry is the * config default; any other key sets that scope's quota. Mirrors the * per-key {@link ApiKey.ApiKey.rateLimits} shape. */ rateLimits?: Record | undefined /** Resolves a presented token against the app's KV store on `c`. */ resolve: (c: hono_Context, token: string) => Promise } | undefined /** MPP payment configuration. */ mpp?: MppHandler | undefined /** * Configured default anonymous public quota. Used as the throttle ceiling for * sandbox keys without active billing on routes that expose no public lane * (where the per-route `policy.public` is absent). Omit to fall back to the * framework default. */ publicRateLimit?: RateLimit.Limit | undefined /** Resolves a trusted client IP for public quota identity. */ publicClientIp?: ((request: Request) => string | undefined) | undefined /** Rate-limit store used by API handlers. */ rateLimit?: RateLimit.Store | undefined /** Session lane resolution (the session surface's capability), or undefined when sessions are unmounted. */ session?: Session | undefined /** Super-admin machine credential (sha256 of the configured secret), or undefined when disabled. */ superAdmin?: { tokenHash: string } | undefined } /** Hono environment variables set by auth middleware. */ export type Environment = { Variables: Variables } /** * HTTP endpoint key inferred from a Hono app schema. The schema is matched * against `HonoBase` (which carries it as its 2nd generic) rather than `Hono` * (which re-declares only 3 generics and loses the inferred schema when used * as a value type from `ReturnType`). */ export type Endpoint, mount extends string = '/'> = app extends HonoBase ? endpoint.FromSchema : never /** Metadata for an accepted payment. */ export type Payment = { /** Verified MPP payer identifier. */ payer?: string | undefined /** Why payment was required for the request. */ reason: 'api_key_over_quota' | 'public_over_quota' /** Payment protocol kind. */ type: 'mpp' } /** MPP route-policy types. Distinct from the SIWE {@link Session} capability. */ export declare namespace Mpp { /** MPP Session request options used by route access policies. */ type Session = Method.RequestDefaults> & { /** Optional human-readable payment description. */ description?: string | undefined /** Optional challenge expiration timestamp or date. */ expires?: Date | string | undefined /** Optional server-defined correlation data. */ meta?: Record | undefined /** Optional route/resource scope bound to the challenge. */ scope?: string | undefined } } /** MPP payment handler consumed by auth policies. */ type MppHandler = { /** Paid-request quota per access principal. */ rateLimit?: RateLimit.Limit | undefined /** Creates a Hono middleware for a Tempo Session payment. */ session(options: Mpp.Session): MiddlewareHandler /** Resolves the chain from a challenge issued by this server. */ sessionChainId?: ((c: hono_Context) => number | undefined) | undefined /** Chains accepted from request-driven Tempo Session payment overrides. */ sessionChainIds?: ReadonlySet | undefined } /** * Route-level auth policy override. Each lane is independent: `true` enables it * with the middleware defaults, `false` disables it, an object enables it with * the given overrides, and omitting it inherits the resolved policy. All lanes * are default-closed (see {@link middleware}). * * Routes grant API-key, public, or paid access explicitly via {@link policy}; * otherwise only `super_admin` reaches them. */ export type PolicyOverride = { /** API-key lane: pass quota/scopes overrides, or a boolean to enable/disable. */ apiKey?: | { /** API-key quota override for this route (a protective per-route cap). */ rateLimit?: RateLimit.Limit | undefined /** Required API-key scopes override for this route. */ scopes?: readonly Scope.Id[] | undefined } | boolean | undefined /** MPP paid lane: object to override the Session request, or boolean to enable/disable. */ mpp?: | { /** Paid-request quota override for this route (a protective per-route cap). */ rateLimit?: RateLimit.Limit | undefined /** Tempo Session request override for this route. */ session?: Mpp.Session | undefined } | boolean | undefined /** Public lane: object to override the quota, or boolean to enable/disable. */ public?: | { /** Anonymous public quota override for this route. */ rateLimit?: RateLimit.Limit | undefined } | boolean | undefined /** Session lane: `true` allows signed-in wallet sessions, `false` disables. */ session?: boolean | undefined } /** Authenticated or anonymous caller. */ export type Principal = | { /** Resolved API key. */ apiKey: ApiKey.ApiKey /** Key environment (`production` accesses any chain; `sandbox` is non-mainnet only). */ environment: ApiKey.ApiKey['environment'] /** Principal id. */ id: string /** Owning organization id. */ orgId: string /** Payment metadata for paid overflow requests. */ payment?: Payment | undefined /** Attributed project id, when present. */ projectId?: string | undefined /** Principal kind. */ type: 'api_key' } | { /** Public quota identity. */ id: string /** Payment metadata for paid overflow requests. */ payment?: Payment | undefined /** Principal kind. */ type: 'public' } | { /** Verified email bound to the session, when present. */ email?: string | undefined /** Principal id: the user id (`usr_…`). */ id: string /** Identity that established the session, as a provider/subject pair (OIDC `iss`/`sub` semantics). */ identity: { /** Identity provider: `wallet` for SIWE sign-in, an OIDC issuer otherwise. */ provider: string /** Provider-scoped subject — the wallet address for `wallet`. */ subject: string } /** Payment metadata for paid overflow requests. */ payment?: Payment | undefined /** Principal kind. */ type: 'session' } | { /** Attributed super admin identity (`'super_admin'` for the configured machine secret). */ actor: string /** Principal id. */ id: string /** Payment metadata for paid overflow requests. */ payment?: Payment | undefined /** Principal kind. */ type: 'super_admin' } /** Hono variables set by auth middleware. */ export type Variables = { /** Auth context consumed by protected handlers. */ auth: Context /** Caller's membership resolved by {@link ensureOrg} or {@link ensureProject}; absent for API keys and `super_admin`. */ membership?: Memberships.Record | undefined /** Organization resolved by {@link ensureOrg} or {@link ensureProject} for the current request. */ org?: Organizations.Record | undefined /** Authenticated or anonymous caller. */ principal?: Principal | undefined /** Project resolved by {@link ensureProject} for the current request. */ project?: Projects.Record | undefined } /** Reads the authenticated principal from a Hono context. */ export function getPrincipal(c: hono_Context) { return c.get('principal') ?? null } /** * Guards `:orgId` routes and resolves the organization. Sessions require membership, API keys require ownership, and the super admin bypasses ownership. */ export function ensureOrg(options: ensureOrg.Options = {}): MiddlewareHandler { const { role: minimum = 'member' } = options // Typed loosely (like {@link policy}) so route chains keep their inferred // schema; a concrete environment parameter collapses Hono's inference. return createMiddleware(async (c, next) => { const notFound = () => Response.error(c, { code: 'organization_not_found', message: 'Organization not found', status: 404, }) const orgId = c.req.param('orgId' as never) as string | undefined if (!orgId) return notFound() const principal = getPrincipal(c) if ( principal?.type !== 'api_key' && principal?.type !== 'session' && principal?.type !== 'super_admin' ) return notFound() if (principal.type === 'api_key' && principal.orgId !== orgId) return notFound() const db = Db.get(contextDb(c)) const record = await Organizations.get(db, orgId) if (!record) return notFound() if (principal.type === 'session') { const member = await Memberships.get(db, orgId, principal.id) if (!member) return notFound() if (Memberships.rank[member.role] < Memberships.rank[minimum]) return Response.error(c, { code: 'forbidden', message: `Requires the ${minimum} role`, status: 403, }) c.set('membership', member) } c.set('org', record) await next() return undefined }) } export declare namespace ensureOrg { /** Options for the org guard. */ type Options = { /** Minimum session role; defaults to `member`. */ role?: Memberships.Role | undefined } } /** * Resolves `:projectId`, preserving nested organization scope. Sessions require membership; API keys require organization ownership and matching project attribution when present. */ export function ensureProject(): MiddlewareHandler { // Typed loosely (like {@link policy}) so route chains keep their inferred // schema; a concrete environment parameter collapses Hono's inference. return createMiddleware(async (c, next) => { const notFound = () => Response.error(c, { code: 'project_not_found', message: 'Project not found', status: 404, }) const projectId = c.req.param('projectId' as never) as string | undefined if (!projectId) return notFound() const principal = getPrincipal(c) if ( principal?.type !== 'api_key' && principal?.type !== 'session' && principal?.type !== 'super_admin' ) return notFound() const db = Db.get(contextDb(c)) const record = await Projects.get(db, projectId) if (!record) return notFound() if ( principal.type === 'api_key' && principal.projectId !== undefined && principal.projectId !== record.id ) return notFound() const scoped = c.get('org') if (scoped) { if (record.orgId !== scoped.id) return notFound() c.set('project', record) await next() return undefined } if (principal.type === 'api_key' && principal.orgId !== record.orgId) return notFound() const organization = await Organizations.get(db, record.orgId) if (!organization) return notFound() if (principal.type === 'session') { const member = await Memberships.get(db, record.orgId, principal.id) if (!member) return notFound() c.set('membership', member) } c.set('org', organization) c.set('project', record) await next() return undefined }) } /** * Always false at runtime, but typed as `boolean` so handlers can include a * never-reached scope-error branch in Hono's inferred response union (the * ensure middlewares produce these `404`s before the handler runs). */ export const narrowScope = false as boolean /** Always false at runtime; lets handlers include organization role errors in inferred response unions. */ export const narrowOrgRole = false as boolean /** Typed org-scope error response for Hono client inference. Never reached at runtime. */ export function ensureOrgError(c: hono_Context) { return Response.error(c, { code: 'organization_not_found', message: 'Organization not found', status: 404, }) } /** Typed organization-role error response for Hono client inference. Never reached at runtime. */ export function ensureOrgRoleError(c: hono_Context) { return Response.error(c, { code: 'forbidden', message: 'Insufficient organization role', status: 403, }) } /** Typed project-scope error response for Hono client inference. Never reached at runtime. */ export function ensureProjectError(c: hono_Context) { // Nested project routes run the org scope first, so either not-found can surface. const code = c.req.header('tempo-narrow-scope-code') === 'organization_not_found' ? ('organization_not_found' as const) : ('project_not_found' as const) return Response.error(c, { code, message: 'Project not found', status: 404, }) } /** Reads the caller's membership resolved by the organization or project guard; `undefined` for API keys and `super_admin`. */ export function membership( c: hono_Context, ): Memberships.Record | undefined { return c.get('membership') } /** Reads the organization resolved by the organization or project guard; throws when neither middleware ran. */ export function org( c: hono_Context, ): Organizations.Record { const record = c.get('org') if (!record) throw new Error('`ensureOrg` or `ensureProject` must run before reading the organization') return record } /** Reads the project resolved by {@link ensureProject}; throws when the middleware did not run. */ export function project( c: hono_Context, ): Projects.Record { const record = c.get('project') if (!record) throw new Error('`ensureProject` must run before reading the project') return record } // Reads the app's database source from request context (set by `App.create` // from its `db` option). function contextDb(c: hono_Context): Db.Source { return (c.get as (key: string) => unknown)('db') as Db.Source } /** * Always false at runtime, but typed as `boolean` so handlers can include a * never-reached access-error branch in Hono's inferred response union. */ export const narrowAccess = false as boolean /** Typed auth/payment error response for Hono client inference. Never reached at runtime. */ export function accessError(c: hono_Context) { const status = Number(c.req.header('tempo-narrow-access-status')) if (status === 400) return Response.error(c, { code: 'api_key_malformed', message: 'Malformed API key', status: 400, }) if (status === 401) { const code = c.req.header('tempo-narrow-access-code') === 'api_key_missing' ? 'api_key_missing' : 'api_key_invalid' return Response.error(c, { code, message: 'Missing or invalid API key', status: 401, }) } if (status === 403) { const ipForbidden = c.req.header('tempo-narrow-access-code') === 'api_key_ip_forbidden' return Response.error(c, { code: ipForbidden ? 'api_key_ip_forbidden' : 'api_key_forbidden', message: ipForbidden ? 'API key not permitted from this IP address' : 'API key missing required scope', status: 403, }) } const code = c.req.header('tempo-narrow-access-code') === 'rate_limit_exceeded' ? 'rate_limit_exceeded' : 'payment_required' return Response.error(c, { code, message: 'Payment required or rate limit exceeded', status: 429, }) } /** Typed lane-less policy error response for Hono client inference. Never reached at runtime. */ export function superAdminAccessError(c: hono_Context) { const status = Number(c.req.header('tempo-narrow-access-status')) if (status === 400) return Response.error(c, { code: 'api_key_malformed', message: 'Malformed API key', status: 400, }) if (status === 401) { const code = c.req.header('tempo-narrow-access-code') === 'api_key_missing' ? 'api_key_missing' : 'api_key_invalid' return Response.error(c, { code, message: 'Missing or invalid API key', status: 401, }) } return Response.error(c, { code: 'forbidden', message: 'API key not permitted for this route', status: 403, }) } /** Typed auth/payment error response for Hono client inference. Never reached at runtime. */ export function paidAccessError(c: hono_Context) { const status = Number(c.req.header('tempo-narrow-access-status')) if (status !== 402) return accessError(c) // MPP owns the 402 challenge body and headers; it is intentionally not the // API's JSON error envelope. return c.body(null, 402) } /** Creates auth middleware for protected OpenAPI routes. */ export function middleware< endpoint extends string = string, environment extends Environment = Environment, >(options: middleware.Options): MiddlewareHandler { const { rateLimit: mppRateLimit = defaultMppRateLimit, session: session_ = {}, sessionChainIds, ...mpp } = options.mpp === false ? {} : (options.mpp ?? {}) const secretKey = resolveMppSecretKey(mpp.secretKey) const defaults = laneDefaults(options) // Default-deny: no lanes open until a route grants them via `policy`. // Otherwise the `super_admin` principal bypasses lane checks entirely. const base: require.Policy = {} // Keys resolve against the app's KV state store on request context // (`App.create({ kv })`), so the middleware needs no storage wiring; when no // store is configured, every token resolves to `null` (closed). // Resolution is cached per isolate by default (positive 60s / negative 10s, // keyed by token sha256) so hot keys skip the backend read on the auth path // of every request. Pass `apiKey.cache: false` when immediate key updates // matter more than the per-request read. const resolve = (() => { const uncached = (c: hono_Context, token: string) => { const kv = contextKv(c) return kv ? ApiKeys.resolve(kv.store, token, { scopeCatalog: options.scopeCatalog }) : Promise.resolve(null) } if (options.apiKey === false || options.apiKey?.cache === false) return uncached return cachedResolve(uncached, options.apiKey?.cache ?? {}) })() const auth: Context = { ...(options.apiKey === false ? {} : { apiKey: { ...(options.apiKey?.rateLimits ? { rateLimits: options.apiKey.rateLimits } : {}), resolve, }, }), // The throttle ceiling for sandbox keys without active billing, so // API-key-only routes (no per-route `policy.public`) still fall back to the // deployment's configured public quota. publicRateLimit: defaults.public.rateLimit, ...(options.public && options.public.clientIp ? { publicClientIp: options.public.clientIp } : {}), ...(options.mpp === false ? {} : { mpp: { rateLimit: mppRateLimit, session: Mppx.create({ ...mpp, ...(secretKey ? { secretKey } : {}), // Defaults only; global `session` options may override, including // enabling same-route HEAD bootstrap. methods: [ tempo.session({ amount: '0.0001', bootstrap: false, currency: Addresses.pathUsd, unitType: 'request', ...session_, }), ], }).session, sessionChainId: (c) => paymentSessionChainId(c, secretKey), ...(sessionChainIds ? { sessionChainIds: new Set(sessionChainIds) } : {}), }, }), rateLimit: RateLimit.memory(options.rateLimit), ...(options.session ? { session: options.session } : {}), ...(options.superAdmin?.secret ? { superAdmin: { tokenHash: ApiKey.hash(options.superAdmin.secret) } } : {}), } return createMiddleware((c, next) => { const route = openApiRoute(c) if (!route) return next() const { key, description, management, scope } = route const overrides = options.overrides as | Record | undefined const routePolicies = policies(c) const configuredOverride = (() => { const configured = overrides?.[key] if (configured !== undefined) return configured const inheritedMethod = routePolicies.find( (policy) => policy.inheritOverridesFrom, )?.inheritOverridesFrom if (inheritedMethod === undefined) return undefined const inheritedKey = `${inheritedMethod.toUpperCase()} ${key.slice(key.indexOf(' ') + 1)}` return overrides?.[inheritedKey] })() const routeOverride = management && configuredOverride ? { mpp: configuredOverride.mpp } : configuredOverride const endpointPolicy = bindMppScope( resolvePolicy(base, defaults, [...routePolicies, routeOverride], description), scope, ) if (!endpointPolicy) return next() if (management && (!auth.mpp || !endpointPolicy.mpp)) return next() c.set('auth', auth) return require(endpointPolicy)(c, next) }) } export declare namespace middleware { /** Per-route policy overrides keyed by HTTP endpoint. */ type Overrides = Partial< Record, PolicyOverride | false | undefined> > /** Options for creating auth middleware. */ type Options = { /** Default API-key access policy. Pass false to disable API-key access. */ apiKey?: | { /** * Resolve-cache tuning (per-isolate, positive 60s / negative 10s by * default), or `false` to resolve uncached on every request — e.g. * when immediate key updates matter more than the per-request * backend read. */ cache?: Cache | false | undefined /** API-key quotas keyed by quota scope (reserved `'*'` is the config default). */ rateLimits?: Record | undefined /** Required API-key scopes. */ scopes?: readonly Scope.Id[] | undefined } | false | undefined /** MPP payment options. Pass false to disable paid overflow. */ mpp?: MppOptions | false | undefined /** Per-route policy overrides keyed by HTTP endpoint, e.g. `GET /tokens/:token`. */ overrides?: Overrides | undefined /** Default public access policy. Pass false to disable anonymous access. */ public?: | { /** Resolves the client IP from trusted runtime or proxy metadata. Required outside Cloudflare for API-key allowlists. */ clientIp?: ((request: Request) => string | undefined) | undefined /** Anonymous public quota for protected routes. */ rateLimit?: RateLimit.Limit | undefined } | false | undefined /** Rate-limit store options. */ rateLimit?: RateLimit.memory.Options | undefined /** Scope catalog accepted while resolving API-key records. */ scopeCatalog?: Scope.Catalog | undefined /** Session lane resolution — the session surface's capability; omit to leave the session lane closed. */ session?: Session | undefined /** * Super-admin machine credential. A presented API-key token matching this * secret resolves to a `super_admin` principal — bypassing scope and quota * checks — before the key lookup runs. Omit to disable the entry entirely. */ superAdmin?: { secret: string } | undefined } /** Resolve-cache tuning for API-key resolution. */ type Cache = { /** Time-to-live for negative (`null`) results in milliseconds. */ negativeTtl?: number | undefined /** Clock used for expiry. */ now?: (() => number) | undefined /** Time-to-live for positive results in milliseconds. */ ttl?: number | undefined } /** MPP payment options used by auth middleware. */ type MppOptions = Omit< Parameters]>>[0], 'methods' > & { /** Paid-request quota per access principal. Defaults to 100 per minute. */ rateLimit?: RateLimit.Limit | undefined /** Global Tempo Session options passed to the MPP method instantiator. */ session?: NonNullable[0]> | undefined /** Chains accepted from request-driven Tempo Session payment overrides. Omit to preserve the MPP client's chain support. */ sessionChainIds?: readonly number[] | undefined } } /** Zod schemas documenting the session sign-in surface (`/v1/auth/siwe`). */ namespace schema { const siweMessage = 'api.tempo.xyz wants you to sign in with your Ethereum account:\n0x0000000000000000000000000000000000000000\n\nURI: https://api.tempo.xyz\nVersion: 1\nChain ID: 0\nNonce: 3D0sZfnHqTBmc9tKR\nIssued At: 2026-01-01T00:00:00.000Z\nExpiration Time: 2026-01-01T00:10:00.000Z' /** Error body returned by the sign-in handler (SDK shape, not the API error envelope). */ export const HandlerError = z.object({ error: z .string() .check( z.describe('Human-readable failure reason.'), z.meta({ examples: ['domain mismatch'] }), ), issues: z .optional(z.array(z.unknown())) .check(z.describe('Field-level validation issues, present on request-schema failures.')), }) /** Schemas for `POST /v1/auth/siwe/challenge`. */ export namespace challenge { /** Request body. Accepted fields beyond this document pass through to the SDK handler. */ export const Body = z.object({}) /** Response body. */ export const Response = z.object({ message: z .string() .check( z.describe('Single-use EIP-4361 (SIWE) message to sign and submit for verification.'), z.meta({ examples: [siweMessage] }), ), }) } /** Schemas for `POST /v1/auth/siwe` (verify). */ export namespace verify { /** Request body. */ export const Body = z.object({ address: z .string() .check( z.describe('Wallet address that signed the message; becomes the session subject.'), z.meta({ examples: ['0x0000000000000000000000000000000000000001'] }), ), idToken: z.optional(z.string()).check( z.describe('Wallet-minted OIDC identity token (JWT) asserting a verified email, folded onto the session.'), // prettier-ignore z.meta({ examples: ['eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIweDAwMDAifQ.c2lnbmF0dXJl'] }), ), keyAuthorization: z.optional(z.string()).check( z.describe('RLP-serialized signed key authorization (TIP-1053) whose witness binds this message; verification recovers over its digest.'), // prettier-ignore z.meta({ examples: [`0x${'aa'.repeat(60)}`] }), ), message: z .string() .check( z.describe('The exact challenge message issued by `POST /v1/auth/siwe/challenge`.'), z.meta({ examples: [siweMessage] }), ), returnToken: z.optional(z.boolean()).check( z.describe('Return the session token in the body instead of setting the session cookie.'), // prettier-ignore z.meta({ examples: [true] }), ), signature: z .string() .check( z.describe('Signature over the challenge message (or the key-authorization digest).'), z.meta({ examples: [`0x${'aa'.repeat(65)}`] }), ), }) /** Response body. */ export const Response = z.object({ token: z .optional(z.string()) .check( z.describe('Bearer session token; present only when `returnToken` is true.'), z.meta({ examples: ['aa'.repeat(32)] }), ), }) } } /** `{ error }` response for a sign-in handler failure status. */ function handlerError(description: string) { return { content: { 'application/json': { schema: OpenApi.resolver(schema.HandlerError) } }, description, } } /** * Builds the built-in auth pieces from `App.create`'s `auth` option: the * session-surface app to mount (when `session` is configured) and the * enforcement middleware, wired with the surface's resolver. `auth: false` * disables both. */ export function install(options: install.Options) { const { db, kv, scopeCatalog } = options const { session: options_session, ...options_middleware } = options.auth === false ? {} : (options.auth ?? {}) if (options_session && !kv) throw new Error('`auth.session` requires the `kv` state store: sessions and sign-in challenges persist there.') // prettier-ignore // Session sign-in surface (challenge, verify, logout — SIWE wallet sign-in // today) plus the capability the session lane resolves through. Public but // policy-gated: the hidden describeRoute engages the middleware for the // mounted sub-app, and the public lane rate-limits challenge-store writes. const app = (() => { if (!options_session || !kv) return undefined const { issuer, origin, requireEmail, ttl } = (typeof options_session === 'object' ? options_session.wallet : undefined) ?? {} const handler = Accounts.Handler.auth({ getClient: options.getClient, ...(issuer || requireEmail ? { identity: { ...(issuer ? { issuer } : {}), ...(requireEmail ? { required: true } : {}) } } // prettier-ignore : {}), // Sign-in upserts the `users` row keyed by address. onAuthenticate: async ({ address }) => { await Users.upsertByAddress(Db.get(db), { address }) }, // No pinned origin: on Workers the edge proxy headers are trustworthy, so // opt in explicitly (the SDK's guard ignores its own Workers default). // Off-Workers the SDK keeps throwing until `wallet.origin` is set. ...(origin ? { origin } : globalThis.navigator?.userAgent === 'Cloudflare-Workers' ? { trustProxy: true } : {}), // The app's KV store under the SDK's Kv contract: JSON values, // second-based TTLs, `auth:`-prefixed keys disjoint from API-key // records. `take` is omitted; the SDK falls back to get+delete. store: { async delete(key) { await kv.store.delete(`auth:${key}`) }, async get(key) { const value = await kv.store.get(`auth:${key}`) return value === null ? undefined : (JSON.parse(value) as never) }, async set(key, value, options) { await kv.store.put( `auth:${key}`, JSON.stringify(value), options?.ttl ? { ttl: options.ttl * 1_000 } : {}, ) }, }, ...(ttl ? { ttl } : {}), }) const session: Session = { resolve: async (c) => { const payload = await handler.getSession(c.req.raw) if (!payload) return null const database = Db.get(db) const user = await Users.getByAddress(database, payload.address) if (!user) return null // Fold a newly verified email onto the user row: identity tokens are // optional per sign-in, so the column fills lazily. if (payload.email && user.email !== payload.email) await Users.setEmail(database, user.id, payload.email) const email = payload.email ?? user.email ?? undefined return { ...(email ? { email } : {}), id: user.id, identity: { provider: 'wallet', subject: payload.address }, type: 'session', } }, } const policy_public = policy({ public: true }) const policy_session = policy({ public: true, session: true }) const describe = OpenApi.describeRoute({ hide: true }) const app = new Hono() .use('/v1/auth/siwe', policy_public, describe) .use('/v1/auth/siwe/*', policy_public, describe) // Documented operations: pre-routes carrying the OpenAPI metadata and the // envelope-shaped body validation; the mounted handler owns behavior. // Statement form keeps them off the app type (`hc` surface). app.post( '/v1/auth/siwe/challenge', policy_public, OpenApi.validate('json', schema.challenge.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ description: 'Issues a single-use SIWE challenge message to sign. The challenge expires after a short TTL.', // Overrides the surface-wide hidden mount guard for this documented route. hide: false, operationId: 'createSiweChallenge', responses: { 200: { content: { 'application/json': { schema: OpenApi.resolver(schema.challenge.Response) } }, // prettier-ignore description: 'The challenge message to sign.', headers: { ...OpenApi.successHeaders }, }, 400: OpenApi.standardError(400, 'Malformed request body.', ['body_invalid']), 429: OpenApi.standardError(429, 'Rate limited.'), 500: OpenApi.standardError(500, 'Internal server error.'), }, summary: 'Create challenge (SIWE)', tags: ['Authentication'], }), ) app.post( '/v1/auth/siwe', policy_public, OpenApi.validate('json', schema.verify.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ description: 'Verifies the signed challenge and establishes a session. Sets a session cookie by default; pass `returnToken` to receive a bearer token instead.', hide: false, operationId: 'verifySiwe', responses: { 200: { content: { 'application/json': { schema: OpenApi.resolver(schema.verify.Response) } }, description: 'Session established. Cookie mode sets `Set-Cookie`; token mode returns `token`.', // prettier-ignore headers: { ...OpenApi.successHeaders, 'Set-Cookie': { description: 'Session cookie (`accounts_auth`); omitted when `returnToken` is true.', // prettier-ignore schema: { type: 'string' }, }, }, }, 400: handlerError('Invalid, expired, or mismatched challenge message; or a required identity token is missing.'), // prettier-ignore 401: handlerError('Signature or identity verification failed.'), 409: handlerError('Challenge nonce already used or unknown.'), 429: OpenApi.standardError(429, 'Rate limited.'), 500: OpenApi.standardError(500, 'Internal server error.'), 502: handlerError('The Tempo RPC could not verify the signature.'), }, summary: 'Authenticate (SIWE)', tags: ['Authentication'], }), ) app.post( '/v1/auth/siwe/logout', policy_session, OpenApi.describeRoute({ description: 'Revokes the current session and clears the session cookie.', hide: false, operationId: 'siweLogout', responses: { 204: { description: 'Session revoked and cookie cleared.' }, 429: OpenApi.standardError(429, 'Rate limited.'), 500: OpenApi.standardError(500, 'Internal server error.'), }, summary: 'Sign out', tags: ['Authentication'], }), ) return Object.assign( // The tag joins the `Management API` group at `management()`'s chosen slot; // group listings filter to defined tags, so it drops out when unmounted. Metadata.attach(app.route('/v1/auth/siwe', handler), { tags: [{ name: 'Authentication', description: 'Authenticate into the Tempo Platform.' }], }), { session }, ) })() const middleware_ = options.auth === false ? undefined : middleware({ ...options_middleware, ...(app ? { session: app.session } : {}), scopeCatalog, }) return { app, middleware: middleware_ } } export declare namespace install { /** Options for building the built-in auth pieces. */ type Options = { auth: | (Omit & { /** Session sign-in surface: mounts `/v1/auth/siwe` (challenge/verify/logout) and enables the session lane. Pass true for defaults or a config object. Requires `kv`. */ session?: Session | undefined }) | false | undefined /** Authoritative database holding user rows. */ db: Db.Source /** Resolves the configured Tempo RPC client used for SIWE signature verification. */ getClient: (chainId: number) => Viem.getClient.ReturnType /** KV state store, or undefined when key auth is closed. */ kv: { store: Store.State } | undefined /** Scope catalog accepted while resolving API-key records. */ scopeCatalog: Scope.Catalog } /** Session sign-in surface configuration: `true` for defaults, or a config object. */ type Session = | boolean | { /** SIWE wallet sign-in configuration. */ wallet?: Wallet | undefined } /** SIWE wallet sign-in configuration. */ type Wallet = { /** Identity (verified email) issuer override; defaults to the Tempo wallet's production OIDC mount. */ issuer?: string | undefined /** Pinned absolute origin for SIWE domain binding. Required off-Workers; on Workers the edge proxy headers are trusted by default. */ origin?: string | undefined /** Reject sign-ins that carry no valid identity token, so every session holds a verified email. */ requireEmail?: boolean | undefined /** Session and challenge TTL overrides, in seconds. */ ttl?: { challenge?: number | undefined; session?: number | undefined } | undefined } } /** Creates route middleware that contributes an auth policy override. */ export function policy(options: policy.Options): MiddlewareHandler { const middleware = createMiddleware((_, next) => next()) as MiddlewareHandler & PolicyHandler middleware[policySymbol] = options return middleware } export declare namespace policy { /** Route policy plus metadata for compatibility aliases. */ type Options = PolicyOverride & { /** HTTP method whose configured override this route inherits when its own is absent. */ inheritOverridesFrom?: string | undefined } } /** Adds management POSTs for OpenAPI-registered MPP GET resources. */ export function installMppManagementRoutes( app: Hono, options: MppManagementOptions = {}, ) { const routes = [...app.routes] const postPaths = new Set( routes.filter((route) => route.method === 'POST').map((route) => route.path), ) const targets = new Map() for (const route of routes) { if (route.method !== 'GET') continue const target = targets.get(route.path) ?? { path: route.path } const override = policyFromRoute(route) if (override?.mpp !== undefined) target.mpp = resolveMppLane(target.mpp, override.mpp, { session: {} }) const spec = openApiSpec(route) as MppManagementSpec | undefined if (spec) { target.registered = true const description = typeof spec.summary === 'string' ? spec.summary : spec.description if (typeof description === 'string') target.description = description } targets.set(route.path, target) } for (const target of targets.values()) { const key = `GET ${stripBasePath(normalizeRoutePath(target.path), options.basePath)}` const override = options.overrides?.[key] if (override === false) continue const mpp = override?.mpp === undefined ? target.mpp : resolveMppLane(target.mpp, override.mpp, { session: {} }) if (!target.registered || !mpp) continue if (postPaths.has(target.path)) throw new Error(`MPP management POST conflicts with an existing route: ${target.path}`) const management = policy({ mpp }) as MiddlewareHandler & MppManagementHandler management[mppManagementSymbol] = { description: target.description } app.post( stripBasePath(target.path, options.basePath), management, OpenApi.describeRoute({ hide: true }), ) } } export declare namespace endpoint { /** * HTTP endpoint key inferred from a Hono app schema. Distributes over the * schema union so the `BlankSchema | MergeSchemaPath<...>` shape Hono * produces from `.basePath(...).route(...)` chains contributes its typed * routes (a non-distributing mapped type would collapse `BlankSchema` to * `never` and infect the whole union). */ type FromSchema = schema extends infer s ? { [path in keyof s & string]: { [method in keyof s[path] & `$${string}`]: `${Method} ${Path>}` }[keyof s[path] & `$${string}`] }[keyof s & string] : never /** HTTP method string extracted from Hono's schema method key. */ type Method = method extends `$${infer value}` ? Uppercase : never /** Public endpoint path with Hono route regex constraints removed. */ type Path = path extends `${infer head}/${infer tail}` ? `${Segment}/${Path}` : Segment /** Public endpoint path segment with any regex constraint removed. */ type Segment = segment extends `:${infer parameter}{${string}` ? `:${parameter}` : segment } /** Per-lane default config a route inherits when it enables a lane. */ type LaneDefaults = { apiKey: NonNullable mpp: NonNullable public: NonNullable session: NonNullable } type OpenApiHandler = { [OpenApi.uniqueSymbol]?: OpenApi.HandlerUniqueProperty | undefined } type OpenApiRoute = { key: string description: string | undefined management: boolean scope: string } type PolicyHandler = { [policySymbol]?: policy.Options | undefined } type MppManagementHandler = { [mppManagementSymbol]?: { description?: string | undefined } | undefined } type MppManagementOptions = { basePath?: string | undefined overrides?: Record | undefined } type MppManagementSpec = { description?: unknown summary?: unknown } type MppManagementTarget = { description?: string | undefined mpp?: NonNullable | undefined path: string registered?: boolean | undefined } function laneDefaults(options: middleware.Options): LaneDefaults { const apiKey = options.apiKey === false ? undefined : options.apiKey const public_ = options.public === false ? undefined : options.public return { apiKey: { // `rateLimit` is intentionally omitted: `policy.apiKey.rateLimit` means an // explicit per-route cap (precedence tier 2), not the config default. The // config default (tier 5) lives on `auth.apiKey.rateLimits['*']` and is // applied by `resolveApiKeyLimit` at consume time. // // Default-closed to a full-access key: a route that declares no scopes // requires the `'*'` wildcard, so a narrowly-scoped key only reaches // routes that explicitly grant its scope. Routes opt into specific // catalog scopes via `Auth.policy`. scopes: apiKey?.scopes ?? [Scope.wildcard], }, mpp: { session: {} }, public: { rateLimit: public_?.rateLimit ?? { limit: 60, period: 'minute' } }, session: true, } } function resolvePolicy( base: require.Policy, defaults: LaneDefaults, overrides: readonly (PolicyOverride | false | undefined)[], description: string | undefined, ): require.Policy | undefined { let apiKey = base.apiKey let mpp = base.mpp let public_ = base.public let session = base.session for (const override of overrides) { if (override === false) return undefined if (!override) continue apiKey = resolveApiKeyLane(apiKey, override.apiKey, defaults.apiKey) mpp = resolveMppLane(mpp, override.mpp, defaults.mpp) public_ = resolvePublicLane(public_, override.public, defaults.public) session = resolveSessionLane(session, override.session, defaults.session) } // The OpenAPI summary/description rides along as the payment-challenge // description. It is metadata, not a policy override, so it only applies when // the paid lane is actually enabled and has no explicit description already. if (mpp && description !== undefined && mpp.session.description === undefined) mpp = { ...mpp, session: { ...mpp.session, description } } // A lane-less policy stays enforced for super admin; only an explicit // `false` override opts a route out of auth entirely. return { ...(apiKey ? { apiKey } : {}), ...(mpp ? { mpp } : {}), ...(public_ ? { public: public_ } : {}), ...(session ? { session } : {}), } } function bindMppScope(policy: require.Policy | undefined, scope: string) { if (!policy?.mpp || policy.mpp.session.scope !== undefined) return policy return { ...policy, mpp: { ...policy.mpp, session: { ...policy.mpp.session, scope }, }, } } function resolveApiKeyLane( current: require.Policy['apiKey'], override: PolicyOverride['apiKey'], default_: LaneDefaults['apiKey'], ): require.Policy['apiKey'] { if (override === undefined) return current if (override === false) return undefined if (override === true) return current ?? default_ const base = current ?? default_ return { rateLimit: override.rateLimit ?? base.rateLimit, scopes: override.scopes ?? base.scopes, } } function resolveMppLane( current: require.Policy['mpp'], override: PolicyOverride['mpp'], default_: LaneDefaults['mpp'], ): require.Policy['mpp'] { if (override === undefined) return current if (override === false) return undefined if (override === true) return current ?? default_ const base = current ?? default_ return { rateLimit: override.rateLimit ?? base.rateLimit, session: { ...base.session, ...override.session }, } } function resolvePublicLane( current: require.Policy['public'], override: PolicyOverride['public'], default_: LaneDefaults['public'], ): require.Policy['public'] { if (override === undefined) return current if (override === false) return undefined if (override === true) return current ?? default_ const base = current ?? default_ return { rateLimit: override.rateLimit ?? base.rateLimit } } function resolveSessionLane( current: require.Policy['session'], override: PolicyOverride['session'], default_: LaneDefaults['session'], ): require.Policy['session'] { if (override === undefined) return current if (override === false) return undefined return current ?? default_ } function openApiRoute(c: hono_Context): OpenApiRoute | undefined { const routes = matchedRoutes(c).slice(c.req.routeIndex + 1) const managementRoute = routes.find((route) => !!mppManagementFromRoute(route)) if (managementRoute) { const management = mppManagementFromRoute(managementRoute)! return { key: endpointKey(c, managementRoute, 'GET'), description: management.description, management: true, scope: `GET ${managementRoute.path}`, } } const route = routes.find((route) => !!openApiSpec(route)) if (!route) return undefined const spec = openApiSpec(route) if (!spec) return undefined const description = typeof spec.summary === 'string' ? spec.summary : spec.description return { key: endpointKey(c, route), description: typeof description === 'string' ? description : undefined, management: false, scope: `${route.method.toUpperCase()} ${route.path}`, } } function policies(c: hono_Context) { return matchedRoutes(c) .slice(c.req.routeIndex + 1) .map(policyFromRoute) .filter((routePolicy): routePolicy is policy.Options => routePolicy !== undefined) } function openApiSpec(route: RouterRoute) { const handler = findTargetHandler(route.handler) as OpenApiHandler const metadata = handler[OpenApi.uniqueSymbol] if (!metadata || !('spec' in metadata)) return undefined return metadata.spec } function policyFromRoute(route: RouterRoute) { const handler = findTargetHandler(route.handler) as PolicyHandler return handler[policySymbol] } function mppManagementFromRoute(route: RouterRoute) { const handler = findTargetHandler(route.handler) as MppManagementHandler return handler[mppManagementSymbol] } /** Resolved access lanes for one documented operation. */ export type OperationAccess = { /** Whether API-key access is allowed. */ apiKey: boolean /** Whether MPP payment is accepted (enables the `402` payment-challenge response). */ mpp: boolean /** Whether anonymous public-quota access is allowed (keeps the anonymous security option). */ public: boolean /** Required API-key scopes (`[]` accepts any valid key); empty when the API-key lane is disabled. */ scopes: readonly Scope.Id[] /** Whether signed-in sessions are allowed. */ session: boolean } /** * Maps each documented operation id to its resolved access lanes by reading the * `policy` overrides co-located on the app's routes. This is the single source * of truth for per-operation `security` and the `402` response in the generated * OpenAPI document (see `App.create`): all lanes are default-closed, and an * operation surfaces only the lanes its route opts into via {@link policy}. */ export function describeAccess(app: Hono): Record { const groups = new Map() for (const route of app.routes) { const key = `${route.method} ${route.path}` const group = groups.get(key) ?? { overrides: [] } const spec = openApiSpec(route) as { operationId?: unknown } | undefined if (typeof spec?.operationId === 'string') group.operationId = spec.operationId const override = policyFromRoute(route) if (override) group.overrides.push(override) groups.set(key, group) } const access: Record = {} for (const { operationId, overrides } of groups.values()) { if (!operationId) continue let apiKey = false let scopes: readonly Scope.Id[] = [] let public_ = false let mpp = false let session = false for (const override of overrides) { if (override.apiKey !== undefined) apiKey = override.apiKey !== false if (override.apiKey && override.apiKey !== true && override.apiKey.scopes) scopes = override.apiKey.scopes if (override.public !== undefined) public_ = override.public !== false if (override.mpp !== undefined) mpp = override.mpp !== false if (override.session !== undefined) session = override.session !== false } access[operationId] = { apiKey, mpp, public: public_, scopes: apiKey ? scopes : [], session } } return access } function endpointKey(c: hono_Context, route: RouterRoute, method = route.method) { return `${method.toUpperCase()} ${stripBasePath(normalizeRoutePath(route.path), basePath(c))}` } function normalizeRoutePath(path: string) { // Hono stores regex-constrained params as `:name{...}`. Auth overrides use // the stable public route shape, e.g. `GET /tokens/:symbol`. return path .split('/') .map((segment) => { if (!segment.startsWith(':')) return segment const pattern = segment.indexOf('{') return pattern === -1 ? segment : segment.slice(0, pattern) }) .join('/') } function stripBasePath(path: string, basePath: string | undefined) { if (!basePath || basePath === '/') return path if (path === basePath) return '/' if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length) return path } function basePath(c: hono_Context) { const variables = c.var as Record return typeof variables['basePath'] === 'string' ? variables['basePath'] : undefined } /** Requires route access through API key, public quota, or MPP payment. */ export function require( policy: require.Policy, ): MiddlewareHandler { return createMiddleware(async (c, next) => { const auth = c.get('auth') const credential = getApiKeyCredential(c) const hasQueryApiKey = new URL(c.req.url).searchParams.has('key') // Auth owns `key`; strict route schemas must not validate it. stripApiKeyQuery(c) // Reject unknown chains before MPP builds a chain-bound challenge. Route // groups publish their supported chains into the shared app context. const chainId = explicitChainId(c) const supportedChainIds = (c.var as Record)['supportedChainIds'] if ( chainId !== undefined && supportedChainIds instanceof Set && !supportedChainIds.has(chainId) ) return Response.unsupportedChainId(c, chainId, supportedChainIds) const proceed = async () => { await next() // Query credential responses must not enter shared caches. const cacheControl = c.res.headers.get('Cache-Control')?.toLowerCase() if ( hasQueryApiKey && !cacheControl?.includes('no-store') && !cacheControl?.includes('private') ) c.res.headers.set('Cache-Control', 'private') } if (credential === false) return jsonError(c, { code: 'api_key_malformed', message: 'Malformed API key', status: 400, }) const hasPayment = hasPaymentCredential(c) && isPaymentEnabled(auth, policy) if (credential) { // The super admin secret precedes key lookup: it is not a stored key. // It must never reach KV resolution or scope/quota checks. if (auth.superAdmin && ApiKey.hash(credential.token) === auth.superAdmin.tokenHash) { c.set('principal', { actor: 'super_admin', id: 'super_admin', type: 'super_admin' }) return proceed() } const apiKey_resolved = await auth.apiKey?.resolve(c, credential.token) if (!apiKey_resolved) { // A bearer session token is indistinguishable from an API key at the // header, so a failed key lookup falls through to the session lane // before rejecting. if (policy.session) { const session = await resolveSession(c, auth) if (session) { c.set('principal', session) return proceed() } } return jsonError(c, { code: 'api_key_invalid', message: 'Invalid API key', status: 401, }) } const scopes = Scope.expandAliases(apiKey_resolved.scopes) const apiKey = scopes === apiKey_resolved.scopes ? apiKey_resolved : { ...apiKey_resolved, scopes } // Sandbox keys are testnet-only. A sandbox key that explicitly targets // mainnet (`?chainId`) is rejected with a clear message rather than // steered or downgraded to anonymous, so the caller learns their key does // not cover mainnet. The no-`chainId` default (mainnet) is enforced by // the data group's chain guard, which knows the route is chain-scoped; // the relay resolves its chain from the request body and enforces the // same rule in its sponsor validation. const explicitChain = explicitChainId(c) const principal = principalFromApiKey(apiKey) c.set('principal', principal) // Default-deny: routes with no API-key lane allow only the super admin. // A valid key is authenticated but forbidden. if (!policy.apiKey) return jsonError(c, { code: 'forbidden', message: 'API key not permitted for this route', status: 403, }) if ( apiKey.allowedIps.length && !(await IpAllowlist.allows(c, { address: trustedClientIp(c, auth), rules: apiKey.allowedIps, })) ) return jsonError(c, { code: 'api_key_ip_forbidden', message: 'API key not permitted from this IP address', status: 403, }) if ( apiKey.environment === 'sandbox' && explicitChain !== undefined && explicitChain === tempoMainnet.id ) return jsonError(c, { code: 'api_key_forbidden', message: 'Sandbox API keys only support testnet. Pass a testnet `chainId`.', status: 403, }) const missing = apiKey.scopes.includes(Scope.wildcard) ? [] : (policy.apiKey?.scopes.filter((scope) => !apiKey.scopes.includes(scope)) ?? []) if (missing.length > 0) return jsonError(c, { code: 'api_key_forbidden', message: 'API key missing required scope', status: 403, }) // Sandbox keys without active billing are throttled to the public quota. // `billingActive` is snapshotted on the key record (stamped at mint, // re-synced when billing changes), so this needs no billing read on the // request path. Resolved before the paid lane so a payment credential // can't buy past the throttle: sandbox is non-billable testnet. const throttled = apiKey.environment === 'sandbox' && apiKey.billingActive !== true if (hasPayment && !throttled) return requirePayment(c, proceed, { auth, payment: { reason: 'api_key_over_quota', type: 'mpp' }, policy, principal, }) if (policy.apiKey) { // A route consumes exactly one quota bucket: its single required scope, // else `'*'` (zero scopes). Routes today require at most one scope; a // future multi-scope route buckets against its first scope. const scope = policy.apiKey.scopes[0] ?? Scope.wildcard // A throttled sandbox key shares the key's bucket but at the public // ceiling, so activating billing lifts the limit on the same counter. const limit = throttled ? (policy.public?.rateLimit ?? auth.publicRateLimit ?? { limit: 60, period: 'minute' }) : resolveApiKeyLimit({ apiKey, auth, policy: policy.apiKey, scope }) const result = await consumeRateLimit(auth, { key: `api_key:${apiKey.id}:scope:${scope}`, limit, }) if (result) { setRateLimitHeaders(c, result) // Surface the bucket so callers can correlate a 429 with the exhausted // quota. Set before the over-quota branch so it rides the 429 (an MPP // 402 challenge is owned by mppx and drops these headers — by design). c.header('RateLimit-Scope', scope) } if (result && !result.allowed) { // A throttled sandbox key gets a plain 429, never an MPP challenge — // it must activate billing to lift the ceiling, not pay per request. if (throttled) return rateLimitExceeded(c, result) return requirePaymentOrRateLimit(c, proceed, { auth, payment: { reason: 'api_key_over_quota', type: 'mpp' }, policy, principal, rateLimit: result, }) } } return proceed() } // Cookie sessions carry no Authorization credential; resolve the session // lane before the public/paid lanes so signed-in browsers skip anonymous // quotas. if (policy.session) { const session = await resolveSession(c, auth) if (session) { c.set('principal', session) return proceed() } } const publicPrincipal = principalFromPublic(c, auth) if (hasPayment) { c.set('principal', publicPrincipal) return requirePayment(c, proceed, { auth, payment: { reason: 'public_over_quota', type: 'mpp' }, policy, principal: publicPrincipal, }) } const limit = policy.public?.rateLimit if (limit) { const result = await consumeRateLimit(auth, { key: `public:${publicPrincipal.id}`, limit, }) if (result) setRateLimitHeaders(c, result) if (!result || result.allowed) { c.set('principal', publicPrincipal) return proceed() } c.set('principal', publicPrincipal) return requirePaymentOrRateLimit(c, proceed, { auth, payment: { reason: 'public_over_quota', type: 'mpp' }, policy, principal: publicPrincipal, rateLimit: result, }) } if (isPaymentEnabled(auth, policy)) { c.set('principal', publicPrincipal) return requirePayment(c, proceed, { auth, payment: { reason: 'public_over_quota', type: 'mpp' }, policy, principal: publicPrincipal, }) } return jsonError(c, { code: 'api_key_missing', message: 'Missing API key', status: 401, }) }) } export declare namespace require { /** Route access policy. */ type Policy = { /** API-key access policy. */ apiKey?: | { /** API-key quota override for this route (a protective per-route cap). */ rateLimit?: RateLimit.Limit | undefined /** * Required API-key scopes. Defaults to `['*']`, so a route that does * not declare scopes requires a full-access key; narrow it by listing * catalog scopes via {@link policy}. */ scopes: readonly (Scope.Id | typeof Scope.wildcard)[] } | undefined /** MPP payment access policy. */ mpp?: | { /** Paid-request quota override for this route (a protective per-route cap). */ rateLimit?: RateLimit.Limit | undefined /** Tempo Session request policy. */ session: Mpp.Session } | undefined /** Public access policy. */ public?: | { /** Anonymous public quota for this route. */ rateLimit: RateLimit.Limit } | undefined /** Session access policy: `true` allows signed-in wallet sessions. */ session?: boolean | undefined } } type ApiKeyCredential = { token: string } type PaymentAccess = { auth: Context payment: Omit policy: require.Policy principal: Principal } type RateLimitAccess = PaymentAccess & { rateLimit: RateLimit.Result } /** * How long to wait for the rate-limit counter before failing open. A hot * counter backend (e.g. a single Durable Object serving one busy quota bucket) * can stall for tens of seconds during a storage-latency blip; without this cap * every request for that bucket blocks on it, turning a localized backend * hiccup into an API-wide latency spike. The abandoned increment still applies * server-side — we just stop waiting for it. */ const rateLimitConsumeTimeout = 300 function consumeRateLimit( auth: Context, options: RateLimit.Store.ConsumeOptions, ): Promise { if (!auth.rateLimit) return Promise.resolve(null) // Fail open: a rate-limit store error (e.g. a lost Durable Object connection) // or a stall past `rateLimitConsumeTimeout` degrades to unenforced quota // instead of failing — or blocking — the request. return withTimeout(auth.rateLimit.consume(options), rateLimitConsumeTimeout).catch((error) => { // A timeout or transient Durable Object fault is an expected degradation, // logged at warn to stay out of error dashboards; an unexpected store // throw is a genuine error. if (error instanceof RateLimitTimeoutError || isDurableObjectFault(error)) console.warn('[auth] rate-limit consume degraded; failing open', error) else console.error('[auth] rate-limit consume failed; failing open', error) return null }) } function isDurableObjectFault(error: unknown): boolean { const { message, overloaded, retryable } = (error ?? {}) as { message?: string | undefined overloaded?: boolean | undefined retryable?: boolean | undefined } if (overloaded === true || retryable === true) return true // Cloudflare redacts internal Durable Object faults to "internal error; // reference = ", with no marker property to detect them by. return typeof message === 'string' && message.startsWith('internal error') } /** * Rejects with a {@link RateLimitTimeoutError} if `promise` does not settle * within `ms`, clearing the timer on either outcome so it never keeps the * isolate alive. */ function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new RateLimitTimeoutError(ms)), ms) promise.then( (value) => { clearTimeout(timer) resolve(value) }, (error) => { clearTimeout(timer) reject(error) }, ) }) } /** * Resolves the rate limit for an API-key request against a quota bucket. * Most-specific wins: a per-key per-scope grant is the only thing that pierces a * route's protective cap; the per-key `'*'` default does not (so a premium key * can't silently blow past an expensive endpoint's ceiling). Per-key values live * in the optional {@link ApiKey.ApiKey.rateLimits} map and stay `undefined` until a * record source populates them, so until then only tiers 2/4/5/6 apply. */ function resolveApiKeyLimit(options: resolveApiKeyLimit.Options): RateLimit.Limit { const { apiKey, auth, policy, scope } = options return ( apiKey.rateLimits?.[scope] ?? // 1. per-key, per-scope policy?.rateLimit ?? // 2. route override (protective cap) apiKey.rateLimits?.[Scope.wildcard] ?? // 3. per-key default auth.apiKey?.rateLimits?.[scope] ?? // 4. config per-scope auth.apiKey?.rateLimits?.[Scope.wildcard] ?? // 5. config default defaultApiKeyRateLimit // 6. hardcoded fallback ) } declare namespace resolveApiKeyLimit { type Options = { /** Resolved API key (carries per-key overrides). */ apiKey: ApiKey.ApiKey /** Auth context (carries config defaults). */ auth: Context /** Route API-key policy (carries the per-route cap). */ policy: require.Policy['apiKey'] /** Quota bucket the request consumes. */ scope: string } } function getApiKeyCredential(c: hono_Context): ApiKeyCredential | false | null { const header = c.req.header('tempo-api-key') if (header) return { token: header } // Bearer supports clients unable to set the canonical header. Payment // credentials belong to MPP and must pass through untouched. const authorization = c.req.header('authorization') if (authorization && !Credential.extractPaymentScheme(authorization)) { const match = /^Bearer\s+(.+)$/i.exec(authorization) if (!match?.[1]) return false return { token: match[1] } } // `x-api-key` remains a deprecated compatibility header. const legacyHeader = c.req.header('x-api-key') if (legacyHeader) return { token: legacyHeader } const apiKey = c.req.query('key') if (apiKey) return { token: apiKey } return null } function clientIp(c: hono_Context, auth: Context): string | undefined { return auth.publicClientIp ? auth.publicClientIp(c.req.raw) : c.req.header('cf-connecting-ip') } function trustedClientIp(c: hono_Context, auth: Context): string | undefined { if (auth.publicClientIp) return auth.publicClientIp(c.req.raw) // Cloudflare supplies both the request metadata and this header. if (!('cf' in c.req.raw)) return undefined return c.req.header('cf-connecting-ip') } /** Removes the reserved auth query parameter before route validation. */ function stripApiKeyQuery(c: hono_Context) { const url = new URL(c.req.url) if (!url.searchParams.has('key')) return url.searchParams.delete('key') c.req.raw = new Request(url, c.req.raw) } /** * Reads an explicit `chainId`/`chain_id` query parameter, normalized to a * numeric chain id. Returns `undefined` when absent or malformed (a malformed * value falls through to the route's own validation). Mirrors the resolution in * `App.create`'s unsupported-chain guard. */ function explicitChainId(c: hono_Context): number | undefined { const raw = c.req.query('chainId') ?? c.req.query('chain_id') if (raw === undefined) return undefined const parsed = Schema.ChainId.safeParse(raw) return parsed.success ? parsed.data : undefined } function getRequestId(c: hono_Context) { const variables = c.var as Record return typeof variables['requestId'] === 'string' ? variables['requestId'] : undefined } /** True when the request carries an `Authorization: Payment` MPP credential. */ export function hasPaymentCredential(c: hono_Context) { const authorization = c.req.header('authorization') if (!authorization) return false return !!Credential.extractPaymentScheme(authorization) } function paymentSessionChainId(c: hono_Context, secretKey: string | undefined): number | undefined { if (!secretKey) return undefined try { const credential = Credential.fromRequest(c.req.raw) if (!Challenge.verify(credential.challenge, { secretKey })) return undefined if (credential.challenge.method !== 'tempo' || credential.challenge.intent !== 'session') return undefined const details = credential.challenge.request['methodDetails'] if (!details || typeof details !== 'object') return undefined const chainId = (details as Record)['chainId'] return typeof chainId === 'number' ? chainId : undefined } catch { return undefined } } function resolveMppSecretKey(explicit: string | undefined): string | undefined { if (explicit) return explicit try { const value = typeof process === 'undefined' ? undefined : process.env['MPP_SECRET_KEY'] if (value) return value } catch {} try { const deno = ( globalThis as typeof globalThis & { Deno?: { env?: { get?: (name: string) => string | undefined } | undefined } | undefined } ).Deno return deno?.env?.get?.('MPP_SECRET_KEY') || undefined } catch { return undefined } } function isPaymentEnabled(auth: Context, policy: require.Policy) { return !!auth.mpp && !!policy.mpp } function jsonError(c: hono_Context, options: jsonError.Options) { const requestId = getRequestId(c) // Auth error envelopes embed per-request state and must not be cached. c.header('Cache-Control', 'no-store') c.set('errorCode', options.code) return c.json( { error: { code: options.code, message: options.message, }, ...(requestId === undefined ? {} : { requestId }), }, options.status, ) } declare namespace jsonError { type Options = { code: string message: string status: 400 | 401 | 403 | 429 } } function payer(c: hono_Context) { try { return Credential.fromRequest(c.req.raw).source } catch { return undefined } } function principalFromApiKey(apiKey: ApiKey.ApiKey): Principal { return { apiKey, environment: apiKey.environment, id: apiKey.id, orgId: apiKey.orgId, ...(apiKey.projectId === undefined ? {} : { projectId: apiKey.projectId }), type: 'api_key', } } function principalFromPublic(c: hono_Context, auth: Context): Principal { const ip = clientIp(c, auth) return { id: ip ? RateLimit.normalizeIp(ip) : 'anonymous', type: 'public', } } async function requirePayment( c: hono_Context, next: () => Promise, options: PaymentAccess, ) { if (hasPaymentCredential(c)) { const result = await consumeRateLimit(options.auth, { key: `mpp:${options.principal.type}:${options.principal.id}`, limit: options.policy.mpp?.rateLimit ?? options.auth.mpp?.rateLimit ?? defaultMppRateLimit, }) if (result) { setRateLimitHeaders(c, result) c.header('RateLimit-Scope', 'mpp') if (!result.allowed) return rateLimitExceeded(c, result) } } const session = options.policy.mpp?.session ?? {} const explicit = explicitChainId(c) const sessionChainIds = options.auth.mpp?.sessionChainIds if (explicit !== undefined && sessionChainIds && !sessionChainIds.has(explicit)) return jsonError(c, { code: 'payment_required', message: 'Payment is not available for the requested chain', status: 429, }) const chainId = explicit ?? (c.req.method === 'POST' && session.scope?.startsWith('GET ') ? options.auth.mpp?.sessionChainId?.(c) : undefined) const middleware = options.auth.mpp?.session({ ...session, ...(chainId === undefined ? {} : { chainId }), }) if (!middleware) return jsonError(c, { code: 'payment_required', message: 'Payment required', status: 429, }) return middleware(c, async () => { const payer_ = payer(c) c.set('principal', { ...options.principal, payment: { ...options.payment, ...(payer_ === undefined ? {} : { payer: payer_ }), }, }) await next() }) } function requirePaymentOrRateLimit( c: hono_Context, next: () => Promise, options: RateLimitAccess, ) { if (isPaymentEnabled(options.auth, options.policy)) return requirePayment(c, next, options) return rateLimitExceeded(c, options.rateLimit) } /** Emits a `429` with a `Retry-After` derived from the window reset. */ function rateLimitExceeded(c: hono_Context, result: RateLimit.Result) { c.header('Retry-After', String(Math.max(result.reset - Math.ceil(Date.now() / 1_000), 1))) return jsonError(c, { code: 'rate_limit_exceeded', message: 'Rate limit exceeded', status: 429, }) } function setRateLimitHeaders(c: hono_Context, result: RateLimit.Result) { c.header('RateLimit-Limit', String(result.limit)) c.header('RateLimit-Remaining', String(result.remaining)) c.header('RateLimit-Reset', String(result.reset)) } // Reads the app's KV state store from request context (set by `App.create` // from its `kv` option); `undefined` leaves API-key auth closed. function contextKv(c: hono_Context): { store: Store.State } | undefined { return (c.get as (key: string) => unknown)('kv') as { store: Store.State } | undefined } /** Session principal resolved by the session lane. */ export type SessionPrincipal = Extract /** * Session resolution capability installed on app context (`session`) by * `App.create` when session auth is configured; `undefined` leaves the session * lane closed. */ export type Session = { /** Resolves the request's session (cookie or bearer token) to a principal. */ resolve: (c: hono_Context) => Promise } // Resolve the session lane from the auth context's capability. Failures // resolve to `null` so the remaining lanes and the final `401` still apply. async function resolveSession(c: hono_Context, auth: Context): Promise { const session = auth.session if (!session) return null try { return await session.resolve(c) } catch (error) { console.error('[auth] session resolve failed; treating as no session', error) return null } } /** Maximum resolve-cache entries held per isolate before eviction. */ const cachedMaxEntries = 10_000 /** * Wraps token resolution with a short-TTL per-isolate cache so hot keys skip * the backend read on every request. Positive results cache for `ttl` * (default 60s), negative for `negativeTtl` (default 10s) so a freshly minted * key is usable quickly; concurrent misses for one token are single-flighted. * Entries are keyed by the token's sha256 (`ApiKey.hash`) — never the * plaintext. Key updates may remain stale for up to `ttl`, the accepted * trade-off for removing a backend read from the auth path. */ function cachedResolve( resolve: (c: hono_Context, token: string) => Promise, options: middleware.Cache, ): (c: hono_Context, token: string) => Promise { const { negativeTtl = 10_000, now = Date.now, ttl = 60_000 } = options type Cell = { expiresAt: number; value: ApiKey.ApiKey | null } const cells = new Map() const flights = new Map>() return async (c, token) => { // Versioned so a future record-shape change can't serve stale shapes. const key = `v1:${ApiKey.hash(token)}` const cell = cells.get(key) if (cell && cell.expiresAt > now()) return cell.value if (cell) cells.delete(key) const existing = flights.get(key) if (existing) return existing const flight = (async () => { const value = await resolve(c, token) // Bound the cache: random invalid tokens each add a (negative) entry, // so sweep expired cells at the cap and then drop the oldest. if (cells.size >= cachedMaxEntries) { const time = now() for (const [name, entry] of cells) if (entry.expiresAt <= time) cells.delete(name) while (cells.size >= cachedMaxEntries) cells.delete(cells.keys().next().value as string) } cells.set(key, { expiresAt: now() + (value ? ttl : negativeTtl), value }) return value })() flights.set(key, flight) try { return await flight } finally { flights.delete(key) } } } /** Thrown when a rate-limit consume exceeds {@link rateLimitConsumeTimeout}. */ export class RateLimitTimeoutError extends Error { override name = 'Auth.RateLimitTimeoutError' constructor(ms: number) { super(`Rate-limit consume timed out after ${ms}ms; failing open.`) } }