import { type Context, Hono, type Next } from 'hono' import { HTTPException } from 'hono/http-exception' import { requestId, type RequestIdVariables } from 'hono/request-id' import type { Chain } from 'viem' import * as ApiKey from '../ApiKey.js' import type * as Assets from '../Assets.js' import type * as Analytics from '../analytics/Analytics.js' import * as Db from '../db/Db.js' import * as AdminAuditLogs from '../db/tables/adminAuditLogs.js' import type * as Store from '../internal/Store.js' import * as Response from '../internal/Response.js' import * as Viem from '../internal/Viem.js' import * as Zones from '../apps/Zones.js' import { apiKeys } from './apps/api-keys.js' import { earlyAccess } from './apps/early-access.js' import { earnVaults } from './apps/earn-vaults.js' import { organizations } from './apps/organizations.js' import { scopes } from './apps/scopes.js' import { verifiedTokens } from './apps/verified-tokens.js' import * as Ui from './ui.generated.js' /** A verified admin identity, as returned by {@link create.Options.identify}. */ export type Identity = { /** The admin's verified email, recorded as `createdBy` on every minted key. */ email: string } /** Hono environment for the admin app. */ export type Environment = { Variables: RequestIdVariables & { /** ClickHouse analytics source used by operational usage reads. */ analytics: Analytics.Source /** Static-asset writer (e.g. the R2 icon bucket), when configured. */ assets: create.Assets | undefined /** Path where the admin app is mounted. */ basePath: string /** Authoritative Postgres store, or a per-request factory. Resolve at the leaf with `Db.get`. */ db: Db.Source /** Gets a Tempo RPC client for a chain id. */ getClient: Viem.GetClient /** The admin identity, or `null` when unauthenticated (gated routes reject). */ identity: Identity | null /** KV state store backing API-key records (mint/list/revoke). */ kv: { store: Store.State } /** Zone chains available to admin routes. */ zones: readonly Chain[] } } /** The admin portal Hono app. Inferred from {@link create}. */ export type App = ReturnType /** Creates the admin portal Hono app. */ export function create(options: create.Options) { const { identify } = options const zones = options.zones === true ? Zones.chains : (options.zones ?? []) const getClient = Viem.createGetClient({ rpc: options.rpc, zones }) // `false` (disabled) collapses to `undefined` so only a real config surfaces // `loginUrl`/`logoutUrl` in `/config.json`. const ui = options.ui || undefined const app = new Hono() app.use('*', requestId({ headerName: 'tempo-request-id' })) // Public hashed assets (no identity needed); before identify so it's skipped. if (options.ui !== false) app.get('/assets/*', serveAsset) // Resolve identity for every request, but don't reject here; the gate does. app.use('*', async (c, next) => { // Admin data is per-principal and mutating: never cache. c.header('Cache-Control', 'no-store') c.set('analytics', options.analytics) c.set('assets', options.assets) c.set('basePath', c.req.routePath.replace(/\*$/, '').replace(/\/$/, '')) c.set('db', options.db) c.set('getClient', getClient) c.set('kv', options.kv) c.set('zones', zones) c.set('identity', await identify(c.req.raw)) return next() }) // Auth contract the UI reads (identity + sign-in/out URLs). Served at // `/config.json` and inlined into the shell, so the UI reads it synchronously. function authConfig(identity: Identity | null) { return { auth: { identity, ...(ui?.loginUrl ? { loginUrl: ui.loginUrl } : {}), ...(ui?.logoutUrl ? { logoutUrl: ui.logoutUrl } : {}), }, } } // Renders the public SPA shell: stamps its hydration-safe base and auth // placeholders with request values. `<` is escaped to keep the config script. const serveShell = (c: Context) => { const config = JSON.stringify(authConfig(c.get('identity'))).replaceAll('<', '\\u003c') return c.html( Ui.shell .replace( '', ``, ) .replace('window.__TEMPO_API_CONFIG__=undefined', `window.__TEMPO_API_CONFIG__=${config}`), ) } // Browser navigations (`Accept: text/html`) get the public shell before the // data routes match, so SPA routes that share a path with a data endpoint // (e.g. `/api-keys`) still load on hard reload. `fetch`/RPC (`*/*`) fall // through to the JSON API below. if (options.ui !== false) app.get('*', (c, next) => c.req.header('accept')?.includes('text/html') ? serveShell(c) : next(), ) // Gate config + data API (`identity` → `null` ⇒ 401); shell/assets stay public. const requireAuth = async (c: Context, next: Next) => { if (!c.get('identity')) return Response.error(c, { code: 'unauthorized', message: 'Admin authentication required.', status: 401, }) return next() } const protectedPaths = [ '/config.json', '/api-keys', '/api-keys/*', '/early-access', '/early-access/*', '/earn/verified-vaults', '/earn/verified-vaults/*', '/organizations', '/organizations/*', '/scopes', '/scopes/*', '/verified-tokens', '/verified-tokens/*', ] as const for (const path of protectedPaths) app.use(path, requireAuth) for (const path of protectedPaths) app.use(path, async (c, next) => { const identity = c.get('identity') if (!identity) return const search = new URL(c.req.url).searchParams const record = (status: number) => AdminAuditLogs.insert(Db.get(c.get('db')), { actor: identity.email, method: c.req.method, path: c.req.path, query: search.size ? search.toString() : undefined, requestId: c.get('requestId'), status, }) try { await next() } catch (cause) { await record(500) throw cause } await record(c.res.status) }) const routed = app .get('/config.json', (c) => c.json(authConfig(c.get('identity')), 200)) .route('/api-keys', apiKeys()) .route('/early-access', earlyAccess()) .route('/earn/verified-vaults', earnVaults()) .route('/organizations', organizations()) .route('/scopes', scopes()) .route('/verified-tokens', verifiedTokens()) // Any other non-API GET renders the public SPA shell (catch-all last, so data // routes win); the HTML-navigation handler above covers shared-path reloads. if (options.ui !== false) app.get('*', serveShell) app.notFound((c) => Response.error(c, { code: 'not_found', message: 'Route not found', status: 404 }), ) app.onError((cause, c) => { if (cause instanceof HTTPException) return cause.getResponse() // Redact any leaked API-key token from the logged error/stack. console.error( ApiKey.redact(cause instanceof Error ? (cause.stack ?? cause.message) : String(cause)), ) return Response.error(c, { code: 'internal_error', message: 'Internal server error', status: 500, }) }) return routed } // Serves a hashed SPA asset with an immutable cache (override the global // `no-store`). Filename comes from the path, so it resolves under any mount. function serveAsset(c: Context) { const asset = Ui.assets[`assets/${c.req.path.split('/assets/').at(-1)}`] if (!asset) return Response.error(c, { code: 'not_found', message: 'Route not found', status: 404 }) c.header('Cache-Control', 'public, max-age=31536000, immutable') c.header('Content-Type', asset.contentType) return c.body( asset.encoding === 'base64' ? Uint8Array.from(atob(asset.content), (ch) => ch.charCodeAt(0)) : asset.content, ) } export declare namespace create { /** * Static-asset store for the deployment (e.g. `Assets.cloudflareR2(...)`, * shared with the main API). Keyed `/`; `get` enables logo * previews, `put` enables logo uploads. */ type Assets = Assets.Assets /** Options for {@link create}. */ type Options = { /** ClickHouse analytics source used by operational usage reads. */ analytics: Analytics.Source /** * Static-asset writer for logo uploads (shared with the main API's `assets` * loader) to enable `PUT /verified-tokens/:address/logo`; omit for `404`. */ assets?: Assets | undefined /** * Authoritative Postgres store shared with the main API. Pass a long-lived * `Db.postgres(...)` singleton on Node, or a factory * (`() => Db.postgres(...)`) on Workers. */ db: Db.Source /** * Extracts the verified admin identity from a request, or `null` to deny. * Must only return an identity for an already-authenticated request (a * verified Access JWT, or a header set by a trusted proxy). */ identify: (request: Request) => Identity | null | Promise /** * KV state store backing API-key records (shared with the main API's * `kv` option), provisioned against by the `/api-keys` routes. */ kv: { store: Store.State } /** Tempo RPC options used by onchain verification. */ rpc?: Viem.getClient.Rpc | undefined /** * Admin UI options, or `false` to disable the bundled UI (JSON API only). * `loginUrl`/`logoutUrl` are surfaced in `GET /config.json`. */ ui?: Ui | false | undefined /** Zone chains available to admin routes. Pass true to use the hosted Zone list. */ zones?: true | readonly Chain[] | undefined } /** Admin UI options surfaced to the SPA via `GET /config.json`. */ type Ui = { /** Where the deployer's sign-in page lives; shown by the UI on a `401`. */ loginUrl?: string | undefined /** Where to send the user to sign out (e.g. Cloudflare Access logout). */ logoutUrl?: string | undefined } }