import * as Accounts from 'accounts/server' import { splitSetCookieHeader } from 'better-auth/cookies' import { Hono } from 'hono' import type { Context as hono_Context, MiddlewareHandler } from 'hono' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' 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 * as jose from 'jose' import { sql } from 'kysely' import { Challenge, Credential, type Method } from 'mppx' import { Mppx, tempo } from 'mppx/hono' import { Address, Hex } from 'ox' 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 ApiKeyAdmissions from '../db/tables/apiKeyAdmissions.js' import * as ApiKeyOwnerTombstones from '../db/tables/apiKeyOwnerTombstones.js' import * as AuthAccounts from '../db/tables/authAccounts.js' import * as BetterAuth from './BetterAuth.js' import * as Db from '../db/Db.js' import type * as Email from './Email.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 Path from './Path.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 type * as Store from './Store.js' import * as Timing from './Timing.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 trustedClientIps = new WeakMap() const defaultApiKeyRateLimit = { limit: 10_000, period: 'minute' } satisfies RateLimit.Limit const defaultIdentitySessionTtl = 24 * 60 * 60 const defaultMppRateLimit = { limit: 100, period: 'minute' } satisfies RateLimit.Limit const defaultPublicRateLimit = { limit: 60, period: 'minute' } satisfies RateLimit.Limit const identityCookieName = 'tempo_identity' const walletIdentityIssuer = 'https://wallet.tempo.xyz/api/oidc' type IdentitySession = { /** Wallet address asserted by the identity token. */ address: string /** Verified email asserted by the identity token. */ email?: string | undefined /** Unix timestamp when the session expires. */ expiresAt: number /** Unix timestamp when the session was issued. */ issuedAt: number } type WalletSession = { /** Wallet address used to resolve the canonical user. */ address: string /** Verified email used to reconcile the canonical user. */ email?: string | undefined } function isIdentityTokenError(error: unknown) { return ( error instanceof jose.errors.JOSEAlgNotAllowed || error instanceof jose.errors.JOSENotSupported || error instanceof jose.errors.JWSInvalid || error instanceof jose.errors.JWKSNoMatchingKey || error instanceof jose.errors.JWSSignatureVerificationFailed || error instanceof jose.errors.JWTClaimValidationFailed || error instanceof jose.errors.JWTExpired || error instanceof jose.errors.JWTInvalid || (error instanceof Error && error.message === 'email not verified') ) } /** 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' } /** Host-provided request backstop that returns a rejection response when over quota. */ export type EdgeRateLimit = (request: Request) => Promise /** MPP route-policy types. Distinct from the authentication {@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?: | { /** Optional counter bucket that isolates this route's quota. */ bucket?: string | undefined /** 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?: | { /** Optional counter bucket that isolates this route's quota. */ bucket?: string | undefined /** 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?: | { /** Optional counter bucket that isolates this route's quota. */ bucket?: string | undefined /** Anonymous public quota override for this route. */ rateLimit?: RateLimit.Limit | undefined } | boolean | undefined /** Session lane: `true` allows signed-in 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: `email`, `wallet`, or an OIDC issuer. */ 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 } /** Resolves the caller IP from configured trusted metadata. */ export function getTrustedClientIp(c: hono_Context) { const auth = c.get('auth') return auth ? trustedClientIp(c, auth) : resolveTrustedClientIp(c.req.raw, undefined) } /** Marks a synthesized in-process request with its trusted caller IP address. */ export function trustClientIp(request: Request, address: string) { trustedClientIps.set(request, address) } /** * 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 resolve = createApiKeyResolver({ apiKey: options.apiKey, scopeCatalog: options.scopeCatalog, }) return createAuthMiddleware(options, resolve) } function createAuthMiddleware< endpoint extends string = string, environment extends Environment = Environment, >( options: middleware.Options, resolve: ReturnType, ): 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 auth: Context = { ...(options.apiKey === false ? {} : { apiKey: { ...(options.apiKey?.rateLimits ? { rateLimits: options.apiKey.rateLimits } : {}), resolve: resolve.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 resolved = resolveRequestPolicy(c, { base, defaults, options }) if (!resolved) return next() c.set('auth', auth) if (!resolved.policy) return next() if (resolved.management && (!auth.mpp || !resolved.policy.mpp)) return next() return require(resolved.policy)(c, next) }) } function resolveRequestPolicy(c: hono_Context, options: resolveRequestPolicy.Options) { const route = openApiRoute(c) if (!route) return undefined const { key, description, management, scope } = route const overrides = options.options.overrides as | Record | undefined const routePolicies = policies(c) const configuredOverride = (() => { const configured = overrides?.[key] if (configured !== undefined) return configured const inheritedMethod = routePolicies.find( (routePolicy): routePolicy is policy.Options => routePolicy !== false && routePolicy.inheritOverridesFrom !== undefined, )?.inheritOverridesFrom if (inheritedMethod === undefined) return undefined const inheritedKey = `${inheritedMethod.toUpperCase()} ${key.slice(key.indexOf(' ') + 1)}` return overrides?.[inheritedKey] })() // Generated management routes already materialize the GET's MPP contract. // Reapplying its override would also copy GET-only quota controls. const routeOverride = management ? undefined : configuredOverride return { management, policy: bindMppScope( resolvePolicy(options.base, options.defaults, [...routePolicies, routeOverride], description), scope, ), } } declare namespace resolveRequestPolicy { /** Inputs used to resolve the current route's authentication policy. */ type Options = { /** Default-closed base policy. */ base: require.Policy /** Configured lane defaults. */ defaults: LaneDefaults /** Authentication middleware options and route overrides. */ options: middleware.Options } } type ApiKeyResolverOptions = { /** API-key authentication and cache configuration. */ apiKey: middleware.Options['apiKey'] /** Authoritative owner store used to reject orphaned API keys. */ db?: Db.Source | undefined /** Scope catalog accepted while resolving records. */ scopeCatalog: Scope.Catalog | undefined } type RequestApiKeyResolution = { /** Resolved API key, or null when the token is invalid. */ apiKey: ApiKey.ApiKey | null /** Hash of the presented token associated with the resolution. */ tokenHash: string } function createApiKeyResolver(options: ApiKeyResolverOptions) { const requests = new WeakMap() const uncached = (c: hono_Context, token: string) => { const kv = contextKv(c) return kv ? ApiKeys.resolve(kv.store, token, { scopeCatalog: options.scopeCatalog }) : Promise.resolve(null) } const cache = options.apiKey === false || options.apiKey?.cache === false ? { invalidate: () => {}, peek: () => undefined, resolve: uncached } : cachedResolve(uncached, options.apiKey?.cache ?? {}) return { peek: (c: hono_Context, token: string) => { const tokenHash = ApiKey.hash(token) const existing = requests.get(c) if (existing?.tokenHash === tokenHash) return existing.apiKey return cache.peek(token) }, resolve: async (c: hono_Context, token: string) => { const tokenHash = ApiKey.hash(token) const existing = requests.get(c) if (existing?.tokenHash === tokenHash) return existing.apiKey const cached = await cache.resolve(c, token) const kv = contextKv(c) const apiKey = cached && ((kv && (await ApiKeys.isOwnerDeleted(kv.store, cached))) || !(await apiKeyOwnerExists(cached, options.db))) ? null : cached if (cached && !apiKey) cache.invalidate(token) requests.set(c, { apiKey, tokenHash }) return apiKey }, } } /** Rejects attributed keys fenced by a committed owner deletion. */ async function apiKeyOwnerExists(apiKey: ApiKey.ApiKey, source: Db.Source | undefined) { if (!source) return true const db = Db.get(source) if (!(await ApiKeyAdmissions.matches(db, apiKey))) return false if (await ApiKeyOwnerTombstones.isDeleted(db, apiKey)) return false return true } 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. */ 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 = OpenApi.component(z.object({}), 'SiweChallengeRequest') /** Response body. */ export const Response = OpenApi.component( z.object({ message: z .string() .check( z.describe('Single-use EIP-4361 (SIWE) message to sign and submit for verification.'), z.meta({ examples: [siweMessage] }), ), }), 'SiweChallengeResponse', ) } /** Schemas for `POST /v1/auth/siwe` (verify). */ export namespace verify { /** Request body. */ export const Body = OpenApi.component( 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)}`] }), ), }), 'SiweVerifyRequest', ) /** Response body. */ export const Response = OpenApi.component( 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)] }), ), }), 'SiweVerifyResponse', ) } /** Schemas for `POST /v1/auth/identity`. */ export namespace identity { /** Request body. */ export const Body = OpenApi.component( z.object({ idToken: z .string() .check( z.describe('Wallet-issued OpenID Connect identity token.'), z.meta({ examples: ['eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiIweDAwMDAifQ.c2lnbmF0dXJl'] }), ), }), 'IdentitySignInRequest', ) /** Response body. */ export const Response = OpenApi.component(z.object({}), 'IdentitySignInResponse') } } /** `{ 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 { edgeRateLimit: options_edgeRateLimit, session: options_session, ...options_middleware } = options.auth === false ? {} : (options.auth ?? {}) const publicClientIp = typeof options_middleware.public === 'object' ? options_middleware.public.clientIp : undefined if (options_session && !kv) throw new Error('`auth.session` requires the `kv` state store: sessions and sign-in challenges persist there.') // prettier-ignore const options_betterAuth = options_session ? { basePath: Path.join(options.basePath, 'v1/auth'), clientIp: (request: Request) => resolveTrustedClientIp(request, publicClientIp), db, get email() { return options.email() }, rateLimit: RateLimit.memory(options_middleware.rateLimit), secret: options_session.secret, ...(options_session.google ? { google: options_session.google } : {}), ...(options_session.trustedOrigins ? { trustedOrigins: options_session.trustedOrigins } : {}), } : undefined // 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_betterAuth || !options_session || !kv) return undefined const { issuer, origin, requireEmail, ttl } = options_session.wallet ?? {} 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 identity = options_session.identity const identitySessionTtl = identity?.ttl ?? defaultIdentitySessionTtl const identitySessionKey = (token: string) => `identity:session:${token}` async function resolveWallet(payload: WalletSession) { const database = Db.get(db) const verifiedEmail = payload.email?.trim().toLowerCase() return database.kysely.transaction().execute(async (kysely) => { const tx = { ...database, kysely } // Organization creation locks the same address row before resolving // the wallet owner, serializing resource writes with reconciliation. const addressUser = await Users.getByAddressForUpdate(tx, payload.address) if (!addressUser) return null if (verifiedEmail) await sql`SELECT pg_advisory_xact_lock(hashtextextended(${verifiedEmail}, 0))`.execute( kysely, ) const linkedUserId = await AuthAccounts.getWalletUserId(tx, payload.address) const linkedUser = linkedUserId ? await Users.get(tx, linkedUserId) : undefined const emailUser = verifiedEmail ? await Users.getByEmail(tx, verifiedEmail) : undefined const walletUser = linkedUser ?? addressUser const walletHasResources = emailUser && walletUser.id !== emailUser.id ? await Memberships.existsForUser(tx, walletUser.id) : false // A verified email reconciles a provisional email-less wallet link. // Keep a provisional owner when changing it would orphan its access. const user = walletHasResources ? walletUser : (emailUser ?? walletUser) if (linkedUser?.id !== user.id) await AuthAccounts.setWalletUser(tx, { address: payload.address, userId: user.id }) if (verifiedEmail && (user.email !== verifiedEmail || !user.emailVerified)) await Users.setEmail(tx, user.id, verifiedEmail) return { email: verifiedEmail ?? user.email ?? undefined, user } }) } const session: Session = { resolve: async (c) => { const [betterAuth, identitySession, wallet] = await Promise.all([ (async () => { const auth = BetterAuth.fromContext(c, options_betterAuth) const request = BetterAuth.request(c, options_betterAuth) const result = await auth.api.getSession({ headers: request.headers }) if (!result) return null const provider = 'provider' in result.session && typeof result.session.provider === 'string' ? result.session.provider : undefined if ( !provider || !result.user.email || (provider === 'email' && !options_betterAuth.email) ) return null return { email: result.user.email, id: result.user.id, identity: { provider, subject: result.user.email }, type: 'session', } satisfies SessionPrincipal })(), (async () => { if (!identity) return null const token = getCookie(c, identityCookieName) if (!token) return null const value = await kv.store.get(identitySessionKey(token)) if (value === null) return null const payload = JSON.parse(value) as IdentitySession if (payload.expiresAt <= Math.floor(Date.now() / 1_000)) return null const user = await resolveWallet(payload) if (!user) return null return { ...(user.email ? { email: user.email } : {}), id: user.user.id, identity: { provider: 'wallet', subject: payload.address }, type: 'session', } satisfies SessionPrincipal })(), (async () => { const payload = await handler.getSession(c.req.raw) if (!payload) return null const user = await resolveWallet(payload) if (!user) return null return { ...(user.email ? { email: user.email } : {}), id: user.user.id, identity: { provider: 'wallet', subject: payload.address }, type: 'session', } satisfies SessionPrincipal })(), ]) if (betterAuth && identitySession && betterAuth.id !== identitySession.id) return null if (betterAuth && wallet && betterAuth.id !== wallet.id) return null if (identitySession && wallet && identitySession.id !== wallet.id) return null return betterAuth ?? identitySession ?? wallet }, } const policy_public = policy({ public: 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.documentJsonRequest(schema.challenge.Body), validateJsonRequest(schema.challenge.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.documentJsonRequest(schema.verify.Body), validateJsonRequest(schema.verify.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'], }), ) if (identity) app.post( '/v1/auth/identity', policy_public, OpenApi.documentJsonRequest(schema.identity.Body), validateJsonRequest(schema.identity.Body), OpenApi.describeRoute({ description: 'Verifies a Wallet-issued OpenID Connect identity token and establishes a session.', hide: false, operationId: 'verifyIdentity', responses: { 200: { content: { 'application/json': { schema: OpenApi.resolver(schema.identity.Response) }, }, description: 'Session established.', headers: { ...OpenApi.successHeaders, 'Set-Cookie': { description: `Session cookie (${identityCookieName}).`, schema: { type: 'string' }, }, }, }, 400: OpenApi.standardError(400, 'Malformed request body.', ['body_invalid']), 401: OpenApi.standardError(401, 'Identity verification failed.', ['identity_invalid']), 429: OpenApi.standardError(429, 'Rate limited.'), 500: OpenApi.standardError(500, 'Internal server error.'), }, summary: 'Authenticate identity', tags: ['Authentication'], }), async (c) => { const { idToken } = schema.identity.Body.parse(await c.req.json()) const claims = await Accounts.Identity.verify(idToken, { audience: identity.audience, issuer: identity.issuer ?? walletIdentityIssuer, }).catch((error) => { // Issuer and network failures must reach the request error signal. if (isIdentityTokenError(error)) return undefined throw error }) if (!claims || !Address.validate(claims.subject, { strict: false })) return Response.error(c, { code: 'identity_invalid', message: 'Invalid identity token', status: 401, }) const address = Address.checksum(claims.subject) await Users.upsertByAddress(Db.get(db), { address }) const issuedAt = Math.floor(Date.now() / 1_000) const token = Hex.fromBytes(crypto.getRandomValues(new Uint8Array(32))).slice(2) await kv.store.put( identitySessionKey(token), JSON.stringify({ address, ...(claims.email ? { email: claims.email } : {}), expiresAt: issuedAt + identitySessionTtl, issuedAt, } satisfies IdentitySession), { ttl: identitySessionTtl * 1_000 }, ) setCookie(c, identityCookieName, token, { httpOnly: true, maxAge: identitySessionTtl, path: '/', sameSite: 'Lax', secure: new URL(c.req.url).protocol === 'https:', }) return c.json({}, 200) }, ) app.post( '/v1/auth/logout', policy(false), createMiddleware(async (c, next) => { const auth = c.get('auth') const principal = principalFromPublic(c, auth) const result = await consumeRateLimit(auth, { key: `public:${principal.id}`, limit: auth.publicRateLimit ?? defaultPublicRateLimit, }) if (result) { setRateLimitHeaders(c, result) if (!result.allowed) return rateLimitExceeded(c, result) } c.set('principal', principal) await next() return undefined }), OpenApi.describeRoute({ description: 'Revokes auth sessions and clears their cookies.', hide: false, operationId: 'logout', responses: { 204: { description: 'Sessions revoked and cookies cleared.' }, 429: OpenApi.standardError(429, 'Rate limited.'), 500: OpenApi.standardError(500, 'Session logout failed.', ['session_logout_failed']), }, summary: 'Sign out', tags: ['Authentication'], }), async (c) => { const walletHeaders = (() => { const authorization = c.req.header('authorization') const cookie = c.req.header('cookie') if (!authorization?.match(/^Bearer\s+.+$/i) || !cookie?.match(/(?:^|;\s*)accounts_auth=/)) return [c.req.raw.headers] const bearer = new Headers(c.req.raw.headers) bearer.delete('cookie') const cookie_ = new Headers(c.req.raw.headers) cookie_.delete('authorization') return [bearer, cookie_] })() const [betterAuth, identityLogout, ...accounts] = await Promise.allSettled([ BetterAuth.fromContext(c, options_betterAuth).api.signOut({ headers: c.req.raw.headers, returnHeaders: true, }), (async () => { const token = getCookie(c, identityCookieName) deleteCookie(c, identityCookieName, { path: '/' }) if (token) await kv.store.delete(identitySessionKey(token)) })(), ...walletHeaders.map((headers) => handler.request( new Request(new URL('/logout', c.req.url), { headers, method: 'POST', }), ), ), ]) if (betterAuth.status === 'fulfilled') for (const cookie of splitSetCookieHeader( betterAuth.value.headers.get('Set-Cookie') ?? '', )) c.header('Set-Cookie', cookie, { append: true }) for (const result of accounts) if (result.status === 'fulfilled') for (const cookie of splitSetCookieHeader(result.value.headers.get('Set-Cookie') ?? '')) c.header('Set-Cookie', cookie, { append: true }) const error = (() => { const rejected = [betterAuth, identityLogout, ...accounts].find( (result) => result.status === 'rejected', ) if (rejected?.status === 'rejected') return rejected.reason instanceof Error ? rejected.reason : new Error('Session logout failed', { cause: rejected.reason }) const failed = accounts.find( (result) => result.status === 'fulfilled' && !result.value.ok, ) if (failed?.status === 'fulfilled') return new Error(`Accounts session logout failed with status ${failed.value.status}`) return undefined })() if (error) { c.error = error return Response.error(c, { code: 'session_logout_failed', message: 'Session logout failed', status: 500, }) } return c.body(null, 204) }, ) app.route('/v1/auth/siwe', handler) app.on(['GET', 'POST'], '/v1/auth/*', policy_public, describe, async (c, next) => { if ( c.req.method === 'POST' && stripBasePath(c.req.path, basePath(c)) === '/v1/auth/email-otp/send-verification-otp' ) { const body: unknown = await c.req.raw .clone() .json() .catch(() => undefined) if (body && typeof body === 'object' && 'type' in body && body.type !== 'sign-in') return Response.error(c, { code: 'body_invalid', message: 'Only sign-in OTPs are supported', status: 400, }) } const response = await BetterAuth.fromContext(c, options_betterAuth).handler( BetterAuth.request(c, options_betterAuth), ) // Leave unmatched paths available to auth providers mounted by the host app. return response.status === 404 ? next() : response }) 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, { securitySchemes: { session: { description: 'Session cookie established by email OTP, Wallet identity, or SIWE sign-in. Browsers send `tempo_auth.session_token`, `tempo_identity`, or `accounts_auth`; Accounts sessions may also use `Authorization: Bearer `.', // prettier-ignore in: 'header', name: 'Cookie', type: 'apiKey', }, }, tags: [{ name: 'Authentication', description: 'Authenticate into the Tempo Platform.' }], }), { session }, ) })() const resolve = createApiKeyResolver({ apiKey: options_middleware.apiKey, db: options.db, scopeCatalog, }) const defaults = laneDefaults(options_middleware) const base: require.Policy = {} const edgeRateLimit = (() => { if (options.auth === false || !options_edgeRateLimit) return undefined const superAdminTokenHash = options_middleware.superAdmin?.secret ? ApiKey.hash(options_middleware.superAdmin.secret) : undefined return createMiddleware(async (c, next) => { const credential = getApiKeyCredential(c) const resolved = resolveRequestPolicy(c, { base, defaults, options: options_middleware }) const isSuperAdmin = credential && superAdminTokenHash !== undefined && ApiKey.hash(credential.token) === superAdminTokenHash const canUseApiKeyQuota = credential && !isSuperAdmin && !hasPaymentCredential(c) && options_middleware.apiKey !== false && resolved?.policy?.apiKey && (!resolved.management || (options_middleware.mpp !== false && resolved.policy.mpp)) const cached = canUseApiKeyQuota ? resolve.peek(c, credential.token) : undefined if (cached) return next() const response = await Timing.time(c, 'edge_rate_limit', () => options_edgeRateLimit(c.req.raw), ) if (response) return response if (canUseApiKeyQuota && cached === undefined) await resolve.resolve(c, credential.token) return next() }) })() const middleware_ = options.auth === false ? undefined : createAuthMiddleware( { ...options_middleware, ...(app ? { session: app.session } : {}), scopeCatalog, }, resolve, ) return { app, edgeRateLimit, middleware: middleware_ } } export declare namespace install { /** Options for building the built-in auth pieces. */ type Options = { auth: | (Omit & { /** Request backstop skipped for cached API keys on routes with per-key quotas. */ edgeRateLimit?: EdgeRateLimit | undefined /** Session sign-in surface: mounts Better Auth, Wallet identity, and SIWE routes and enables the session lane. Requires `kv`. */ session?: Session | undefined }) | false | undefined /** Normalized path prefix that contains the auth surface. */ basePath: string /** Authoritative database holding user rows. */ db: Db.Source /** Resolves the transactional email sender shared by authentication and route groups. */ email: () => Email.Sender | undefined /** 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. */ type Session = { /** Google OpenID Connect configuration. */ google?: | { /** OAuth client identifier. */ clientId: string /** OAuth client secret. */ clientSecret: string } | undefined /** Wallet-issued OpenID Connect identity sign-in configuration. */ identity?: Identity | undefined /** High-entropy secret used to protect Better Auth credentials. */ secret: string /** Additional browser origins accepted by Better Auth. */ trustedOrigins?: readonly string[] | undefined /** SIWE wallet sign-in configuration. */ wallet?: Wallet | undefined } /** Wallet-issued OpenID Connect identity sign-in configuration. */ type Identity = { /** Expected token audience, usually the Console origin. */ audience: string /** Identity issuer override; defaults to Tempo Wallet. */ issuer?: string | undefined /** Session lifetime in seconds. Defaults to 24 hours. */ ttl?: number | 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 without a valid identity token. Defaults to false. */ requireEmail?: boolean | undefined /** Session and challenge TTL overrides, in seconds. */ ttl?: { challenge?: number | undefined; session?: number | undefined } | undefined } } function validateJsonRequest( schema: schema, ): MiddlewareHandler { return async (c, next) => { if (!c.req.header('content-type')?.toLowerCase().startsWith('application/json')) return Response.error(c, { code: 'body_invalid', message: 'Invalid request body', status: 400, }) const value = await (async () => { try { return await c.req.raw.clone().json() } catch { return undefined } })() const result = schema.safeParse(value) if (!result.success) return Response.error(c, { code: 'body_invalid', details: Response.validationDetails(result.error.issues), message: 'Invalid request body', status: 400, }) return next() } } /** Creates route middleware that contributes an auth policy override, or bypasses auth when passed `false`. */ export function policy(options: policy.Options | false): 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 && 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: { session: mpp.session }, }) 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 | false | 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 ?? defaultPublicRateLimit }, 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 { bucket: override.bucket ?? base.bucket, 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 { bucket: override.bucket ?? base.bucket, 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 { bucket: override.bucket ?? base.bucket, 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 | false => 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 this operation explicitly bypasses auth instead of inheriting document security. */ bypass?: true | undefined /** 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 !== undefined) 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 let bypass = false for (const override of overrides) { if (override === false) { bypass = true break } 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, ...(bypass ? { bypass: true } : {}), 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) const chainId = explicitChainId(c) const supportedChainIds = (c.var as Record)['supportedChainIds'] const unsupportedChain = (() => { if ( chainId === undefined || !(supportedChainIds instanceof Set) || supportedChainIds.has(chainId) ) return undefined return { chainId, supportedChainIds } })() if (unsupportedChain && !credential) return Response.unsupportedChainId( c, unsupportedChain.chainId, unsupportedChain.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) { if (unsupportedChain) return Response.unsupportedChainId( c, unsupportedChain.chainId, unsupportedChain.supportedChainIds, ) 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) { if (unsupportedChain) return Response.unsupportedChainId( c, unsupportedChain.chainId, unsupportedChain.supportedChainIds, ) // 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, }) // Non-paid requests consume API-key quota before rejection. Paid // requests use the MPP quota after access checks. const scope = policy.apiKey.scopes[0] ?? Scope.wildcard const throttled = apiKey.environment === 'sandbox' && apiKey.billingActive !== true const limit = throttled ? (policy.public?.rateLimit ?? auth.publicRateLimit ?? { limit: 60, period: 'minute' }) : resolveApiKeyLimit({ apiKey, auth, policy: policy.apiKey, scope }) const paid = hasPayment && !throttled const result = paid ? null : await consumeRateLimit(auth, { key: rateLimitKey(`api_key:${apiKey.id}:scope:${scope}`, policy.apiKey.bucket), limit, }) if (result) { setRateLimitHeaders(c, result) // Surface the bucket so callers can correlate a 429 with the exhausted // quota. MPP challenges own their headers and drop these by design. c.header('RateLimit-Scope', scope) } if (unsupportedChain) return Response.unsupportedChainId( c, unsupportedChain.chainId, unsupportedChain.supportedChainIds, ) 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, }) if (paid) return requirePayment(c, proceed, { auth, payment: { reason: 'api_key_over_quota', type: 'mpp' }, policy, principal, }) 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: rateLimitKey(`public:${publicPrincipal.id}`, policy.public?.bucket), 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?: | { /** Optional counter bucket that isolates this route's quota. */ bucket?: string | undefined /** 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?: | { /** Optional counter bucket that isolates this route's quota. */ bucket?: string | undefined /** 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?: | { /** Optional counter bucket that isolates this route's quota. */ bucket?: string | undefined /** Anonymous public quota for this route. */ rateLimit: RateLimit.Limit } | undefined /** Session access policy: `true` allows signed-in 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 /** Consumes one quota and returns null when its counter is unavailable, allowing callers to fail open. */ export 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 ( trustedClientIps.get(c.req.raw) ?? (auth.publicClientIp ? auth.publicClientIp(c.req.raw) : c.req.header('cf-connecting-ip')) ) } function trustedClientIp(c: hono_Context, auth: Context): string | undefined { return trustedClientIps.get(c.req.raw) ?? resolveTrustedClientIp(c.req.raw, auth.publicClientIp) } function resolveTrustedClientIp( request: Request, resolve: ((request: Request) => string | undefined) | undefined, ) { if (resolve) return resolve(request) // Cloudflare supplies both the request metadata and this header. if (!('cf' in request)) return undefined return request.headers.get('cf-connecting-ip') ?? undefined } /** 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: rateLimitKey( `mpp:${options.principal.type}:${options.principal.id}`, options.policy.mpp?.bucket, ), 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) } function rateLimitKey(key: string, bucket: string | undefined) { return bucket === undefined ? key : `${key}:bucket:${bucket}` } /** 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. Dependency // failures propagate to the app error boundary instead of becoming a `401`. async function resolveSession(c: hono_Context, auth: Context): Promise { const session = auth.session if (!session) return null return session.resolve(c) } /** 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, ) { 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 { invalidate(token: string) { cells.delete(`v1:${ApiKey.hash(token)}`) }, peek(token: string) { const key = `v1:${ApiKey.hash(token)}` const cell = cells.get(key) if (!cell) return undefined if (cell.expiresAt > now()) return cell.value cells.delete(key) return undefined }, async resolve(c: hono_Context, token: string) { // 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.`) } }