/** * 0G Router headless auth: SIWE → wallet JWT → management key (mk-) → api key * (sk-). The agent signs an EIP-4361 message with its OWN privkey (no browser, * no operator, no dashboard), so the Router brain stays as sovereign as the * Direct path: the agent's privkey is still the only durable secret. * * Verified live end-to-end Jun 16 2026 (see `reference-0g-router-payment-layer` * memory): POST /auth/nonce → sign → POST /auth/signin (HS256 JWT, 24h) → POST * /management-keys {name,scopes} → POST /api-keys {name}. * * The HTTP surface is kept thin and base-URL-injectable so a `Bun.serve` * mock can exercise the whole handshake offline (router-api.integration.test.ts). */ import { privateKeyToAccount } from 'viem/accounts' import { createSiweMessage } from 'viem/siwe' /** Default OpenAI-compatible Router base URL (no trailing slash). */ export const DEFAULT_ROUTER_BASE_URL = 'https://router-api.0g.ai/v1' /** SIWE domain + URI the Router backend expects (the pc.0g.ai product). */ export const ROUTER_SIWE_DOMAIN = 'pc.0g.ai' export const ROUTER_SIWE_URI = 'https://pc.0g.ai' /** The management-key scope set the Router enumerates; mk- needs a non-empty subset. */ export const MANAGEMENT_KEY_SCOPES = [ 'account:read', 'keys:create', 'keys:manage', 'keys:read', ] as const /** Thrown when any step of the headless auth handshake fails. */ export class RouterAuthError extends Error { readonly step: string readonly status?: number constructor(step: string, message: string, status?: number) { super(`Router auth failed at ${step}: ${message}`) this.name = 'RouterAuthError' this.step = step this.status = status } } interface JsonResponse { status: number ok: boolean body: unknown } async function postJson( url: string, init: { token?: string; body?: unknown }, ): Promise { const headers: Record = { 'content-type': 'application/json' } if (init.token) headers.authorization = `Bearer ${init.token}` const res = await fetch(url, { method: 'POST', headers, body: init.body !== undefined ? JSON.stringify(init.body) : '{}', }) const text = await res.text() let parsed: unknown = text try { parsed = JSON.parse(text) } catch { /* leave as text */ } return { status: res.status, ok: res.ok, body: parsed } } function trimBase(baseUrl: string): string { return baseUrl.replace(/\/+$/, '') } function readField(body: unknown, ...keys: string[]): string | null { if (!body || typeof body !== 'object') return null const o = body as Record for (const k of keys) { const v = o[k] if (typeof v === 'string' && v.length > 0) return v } return null } function errMessage(body: unknown): string { if (body && typeof body === 'object') { const e = (body as { error?: { message?: string } }).error if (e?.message) return e.message } return typeof body === 'string' ? body.slice(0, 200) : JSON.stringify(body).slice(0, 200) } /** Fetch the public Router model catalog (`GET /v1/models`, no auth). */ export async function listRouterModels( baseUrl: string = DEFAULT_ROUTER_BASE_URL, ): Promise { try { const res = await fetch(`${trimBase(baseUrl)}/models`) if (!res.ok) return [] const json = (await res.json()) as { data?: Array<{ id?: string }> } return (json.data ?? []) .map(m => m.id) .filter((id): id is string => typeof id === 'string' && id.length > 0) } catch { return [] } } /** Step 1: fetch a login nonce. */ export async function requestNonce(baseUrl: string): Promise { const r = await postJson(`${trimBase(baseUrl)}/auth/nonce`, { body: {} }) if (!r.ok) throw new RouterAuthError('nonce', errMessage(r.body), r.status) const nonce = readField(r.body, 'nonce') ?? readField((r.body as { data?: unknown })?.data, 'nonce') if (!nonce) throw new RouterAuthError('nonce', 'no nonce in response', r.status) return nonce } /** * Build the EIP-4361 SIWE message + sign it with the agent privkey. * `issuedAt` is injectable so the message is deterministic in tests. */ export async function buildAndSignSiwe(opts: { privkeyHex: `0x${string}` chainId: number nonce: string issuedAt?: Date domain?: string uri?: string }): Promise<{ message: string; signature: `0x${string}`; address: `0x${string}` }> { const account = privateKeyToAccount(opts.privkeyHex) const message = createSiweMessage({ address: account.address, chainId: opts.chainId, domain: opts.domain ?? ROUTER_SIWE_DOMAIN, uri: opts.uri ?? ROUTER_SIWE_URI, version: '1', nonce: opts.nonce, statement: 'Sign in to 0G Private Computer', ...(opts.issuedAt ? { issuedAt: opts.issuedAt } : {}), }) const signature = await account.signMessage({ message }) return { message, signature, address: account.address } } /** Step 2: exchange a signed SIWE message for a wallet JWT. */ export async function signin( baseUrl: string, message: string, signature: string, ): Promise<{ token: string; expiresAt?: string }> { const r = await postJson(`${trimBase(baseUrl)}/auth/signin`, { body: { message, signature } }) if (!r.ok) throw new RouterAuthError('signin', errMessage(r.body), r.status) const token = readField(r.body, 'token', 'jwt', 'access_token') ?? readField((r.body as { data?: unknown })?.data, 'token') if (!token) throw new RouterAuthError('signin', 'no token in response', r.status) return { token, expiresAt: readField(r.body, 'expires_at') ?? undefined } } /** Step 3: mint a management key (mk-) from a wallet JWT. */ export async function mintManagementKey( baseUrl: string, jwt: string, opts: { name: string; scopes?: readonly string[] }, ): Promise<{ key: string; keyId?: string }> { const r = await postJson(`${trimBase(baseUrl)}/management-keys`, { token: jwt, body: { name: opts.name, scopes: opts.scopes ?? MANAGEMENT_KEY_SCOPES }, }) if (!r.ok) throw new RouterAuthError('management-keys', errMessage(r.body), r.status) const key = readField(r.body, 'key', 'management_key', 'mk', 'secret') if (!key) throw new RouterAuthError('management-keys', 'no key in response', r.status) return { key, keyId: readField(r.body, 'key_id') ?? undefined } } /** Step 4: mint an inference api key (sk-) from a management key. */ export async function mintApiKey( baseUrl: string, mk: string, opts: { name: string }, ): Promise<{ key: string; keyId?: string }> { const r = await postJson(`${trimBase(baseUrl)}/api-keys`, { token: mk, body: { name: opts.name }, }) if (!r.ok) throw new RouterAuthError('api-keys', errMessage(r.body), r.status) const key = readField(r.body, 'key', 'api_key', 'sk', 'secret') if (!key) throw new RouterAuthError('api-keys', 'no key in response', r.status) return { key, keyId: readField(r.body, 'key_id') ?? undefined } } export interface HeadlessMintResult { /** Inference key (`sk-...`), used as the completion Bearer. */ sk: string /** Management key (`mk-...`), used to rotate sk- without re-SIWE. */ mk: string /** Wallet JWT (HS256, ~24h); short-lived, not persisted. */ jwt: string } /** * Run the full headless chain: nonce → SIWE sign → signin → mk- → sk-. * Zero browser, zero operator. The agent privkey is the only input secret. */ export async function headlessMint(opts: { baseUrl?: string privkeyHex: `0x${string}` chainId: number /** Label prefix for the minted keys (e.g. the agent subname). */ keyName?: string /** Injectable for deterministic tests; defaults to now at call time. */ issuedAt?: Date }): Promise { const baseUrl = opts.baseUrl ?? DEFAULT_ROUTER_BASE_URL const name = opts.keyName ?? 'anima' const nonce = await requestNonce(baseUrl) const { message, signature } = await buildAndSignSiwe({ privkeyHex: opts.privkeyHex, chainId: opts.chainId, nonce, issuedAt: opts.issuedAt, }) const { token: jwt } = await signin(baseUrl, message, signature) const { key: mk } = await mintManagementKey(baseUrl, jwt, { name: `${name}-mgmt` }) const { key: sk } = await mintApiKey(baseUrl, mk, { name: `${name}-sk` }) return { sk, mk, jwt } }