/** * Mount worker-side routes for `defineView` and `defineRemoteFunction` entries. * * Each entry's effective `FunctionBinding` is resolved once at boot (host * pattern, path, http verb) and a Hono route is registered that runs the * shared SDK auth pipeline (verify inbound credential, optionally enforce / * optional / public), Zod validation of input AND output (RemoteFunction * only — Views are transport-only), the author's `authorize` hook, and * finally `render` / `execute`. * * Bindings whose host is not a sub-domain of this worker's host are skipped * (the graph node was still materialized; the route lives elsewhere). */ import type { FnMap } from '@astrale-os/kernel-client' import type { BoundClientSessionView } from '@astrale-os/kernel-client/session' import type { AuthContext } from '@astrale-os/kernel-core' import type { Context, Hono } from 'hono' import { decodeKernelRequest, isKernelErrorClassifiable, KERNEL_CONTENT_TYPE_JSON, KERNEL_CONTENT_TYPE_MSGPACK, KERNEL_ERROR_CODES, kernelErrorHttpStatus, resolveCodec, type KernelErrorPayload, type RequestId, } from '@astrale-os/kernel-api' import { isSubdomainOf, matchHost, compileHostMatcher, parseUrlTemplate, type FunctionBinding, type AuthPolicy, } from '@astrale-os/kernel-api/routed' import { buildCorsHeaders, createCapabilityLifecycle, honoCapabilityHost, type CorsConfig, } from '@astrale-os/kernel-server' import type { RemoteIdentityConfig } from '../auth/identity.js' import type { AnyRemoteFunctionDef } from '../define/remote-function.js' import type { ViewDef } from '../define/view.js' import type { AuxIdentityMap } from '../dispatch/identity.js' import { makeFunctionContext } from '../auth/function-context.js' import { makeDomainAuthority } from '../auth/issuer-mint.js' import { resolveInboundAuth } from '../auth/resolve.js' import { runAuthorize } from '../dispatch/authorize.js' import { SdkResultValidationError, SdkValidationError } from '../dispatch/errors.js' import { validateParams, validateResult } from '../dispatch/validate.js' import { createInlineStep } from '../step/index.js' export type AuxiliaryRoutesConfig = { app: Hono /** This worker's serving URL — used to filter bindings that point elsewhere. */ url: string views?: Record> viewBindings?: Record remoteFunctions?: Record remoteFunctionBindings?: Record deps: TDeps /** * Per-route identity configs (keyed by slug) — one entry per View and * one per RemoteFunction. Build via `buildAuxIdentityMap(compiled, key, issuer)` * from `sdk/src/dispatch/identity.ts`. */ identities?: AuxIdentityMap /** Resolve an identity lazily. Service-hosted Functions use this to read the * Function node registered during deploy and reuse the Service signing key. */ resolveIdentity?: ( kind: 'view' | 'remoteFunction', slug: string, ) => RemoteIdentityConfig | Promise /** * CORS policy applied to every mounted route: per-route `app.options(...)` * preflight and `Access-Control-Allow-*` headers on success + error * responses. Required so callers (always `createRemoteServer`) keep the * kernel-envelope and aux-route policies in sync. */ cors: CorsConfig } export function mountAuxiliaryRoutes(config: AuxiliaryRoutesConfig): void { const { app, url, views, viewBindings, remoteFunctions, remoteFunctionBindings, deps, identities, resolveIdentity, cors, } = config const workerHost = parseUrlTemplate(url).hostPattern const corsHeaders = buildCorsHeaders(cors) if (views && viewBindings) { for (const [slug, def] of Object.entries(views)) { const binding = viewBindings[slug] if (!binding || !def.render) continue const identity = identityResolver('view', slug, identities?.views[slug], resolveIdentity) mountEntry({ app, binding, workerHost, defaultMethod: 'GET', auth: def.auth, identity, corsHeaders, // Views are transport-only (iframe HTML/redirect). SERVER-rendered // views that read the graph use `ctx.fn.kernel()`: the view's own // identity, with narrow grants. run: async ({ c, params, auth, kernel, identity }) => { const inboundIss = auth?.credential?.verified?.iss as string | undefined const kernelUrl = kernel?.default ?? inboundIss const ctx = { c, params, auth, deps, env: { url }, fn: makeFunctionContext(identity, deps, { ref: `view.${slug}`, ...(kernelUrl !== undefined ? { defaultKernelUrl: kernelUrl } : {}), }), } if (def.authorize) await runAuthorize(def.authorize, ctx) return def.render!(ctx) }, }) } } if (remoteFunctions && remoteFunctionBindings) { for (const [slug, def] of Object.entries(remoteFunctions)) { const binding = remoteFunctionBindings[slug] if (!binding) continue const identity = identityResolver( 'remoteFunction', slug, identities?.remoteFunctions[slug], resolveIdentity, ) mountEntry({ app, binding, workerHost, defaultMethod: 'POST', auth: def.auth, identity, corsHeaders, run: async ({ c, auth, kernel, identity }) => { const input = await readRemoteFunctionInput(c) const validation = validateParams(def.inputSchema, input.params) if (!validation.ok) { throw new SdkValidationError(validation.issues as SdkValidationError['issues']) } const inboundIss = auth?.credential?.verified?.iss as string | undefined const kernelUrl = kernel?.default ?? inboundIss const caller = auth && inboundIss ? { iss: inboundIss, url: kernelUrl ?? inboundIss, } : undefined const ctx = { params: validation.data, c, auth, deps, env: { url }, kernel, ...(caller !== undefined ? { caller } : {}), domain: makeDomainAuthority(identity), fn: makeFunctionContext(identity, deps, { ref: `function.${slug}`, ...(kernelUrl !== undefined ? { defaultKernelUrl: kernelUrl } : {}), }), } if (def.authorize) await runAuthorize(def.authorize, ctx) const lifecycle = createCapabilityLifecycle(honoCapabilityHost(c)) const result = await (async () => { try { return await def.execute({ ...ctx, step: createInlineStep({ scope: { kind: 'function', ref: `function.${slug}`, slug }, }), defer: lifecycle.capabilities.defer, sleep: lifecycle.capabilities.sleep, }) } finally { await lifecycle.settle() } })() const outValidation = validateResult(def.outputSchema, result) if (!outValidation.ok) { throw new SdkResultValidationError( outValidation.issues as SdkResultValidationError['issues'], `function.${slug}`, ) } return c.json({ result: outValidation.data, ...(input.enveloped ? { id: input.id ?? null } : {}), }) }, }) } } } // ── Internal ─────────────────────────────────────────────────────────────── /** * Every materialized aux callable must have an identity in the install-time * `subs` claim — a missing one means the build pipeline passed a compiled * domain that doesn't include this callable to `buildAuxIdentityMap()`. */ function requireAuxIdentity( kind: 'view' | 'remote function', slug: string, identity: RemoteIdentityConfig | undefined, ): RemoteIdentityConfig { if (identity) return identity throw new Error( `mountAuxiliaryRoutes: no identity registered for ${kind} "${slug}". ` + `Pass a compiled domain that includes this ${kind} to buildAuxIdentityMap().`, ) } function identityResolver( kind: 'view' | 'remoteFunction', slug: string, identity: RemoteIdentityConfig | undefined, resolveIdentity: AuxiliaryRoutesConfig['resolveIdentity'], ): () => Promise { if (resolveIdentity) return async () => resolveIdentity(kind, slug) const label = kind === 'view' ? 'view' : 'remote function' const required = requireAuxIdentity(label, slug, identity) return async () => required } type RunArgs = { c: Context params: Record auth: AuthContext | null kernel: BoundClientSessionView | null identity: RemoteIdentityConfig } type MountEntryArgs = { app: Hono binding: FunctionBinding workerHost: string defaultMethod: 'GET' | 'POST' run: (args: RunArgs) => Promise auth?: AuthPolicy identity: () => Promise corsHeaders: Record } const PLACEHOLDER_RE = /\{(\w+)([+*])?\}/g function mountEntry(args: MountEntryArgs): void { const { app, binding, workerHost, defaultMethod, run, auth, identity, corsHeaders } = args const remoteUrl = binding.remoteUrl if (!remoteUrl) return const parsed = parseUrlTemplate(remoteUrl) if (parsed.hostPattern && !isSubdomainOf(parsed.hostPattern, workerHost)) return const fullPath = joinPath(parsed.basePath, binding.route?.path ?? '') const honoPath = toHonoPath(fullPath) const httpMethod = binding.route?.method ?? defaultMethod // `route.method` can be any `HttpMethod` (PUT/PATCH/DELETE/*), but only GET // and POST are wired below. Fail loudly at mount time rather than silently // registering no handler (which would 404 the real request while the OPTIONS // preflight still reports the route exists). if (httpMethod !== 'GET' && httpMethod !== 'POST') { throw new Error( `mountAuxiliaryRoutes: unsupported HTTP method "${httpMethod}" for route "${honoPath}". ` + `Aux routes (views / remote functions) support only GET and POST.`, ) } // Local-dev requests target literal `localhost`, but bindings reference a // logical host (`example.localhost`) — so only enforce a Host-header match // when the binding has actual placeholders to extract. const hostMatcher = parsed.hostPlaceholders.length > 0 ? compileHostMatcher(parsed.hostPattern) : null const pathParamNames = collectPlaceholderNames(fullPath) const handler = async (c: Context): Promise => { // Apply CORS to every response. `c.json(...)` / `c.body(...)` pick up the // headers via the Hono context; raw `Response` objects — a View's `render` // return, and `errorResponse` in the catch — do NOT, so the final returned // Response is also passed through `applyCorsToResponse`. applyCorsToContext(c, corsHeaders) try { let hostParams: Record = {} if (hostMatcher) { const match = matchHost(hostMatcher, c.req.header('host') ?? '') if (!match) return c.notFound() hostParams = match } const pathParams: Record = {} for (const name of pathParamNames) { const value = c.req.param(name) if (value !== undefined) pathParams[name] = decodeURIComponent(value) } const resolvedIdentity = await identity() const { auth: resolvedAuth, kernel } = await resolveInboundAuth( stripBearerPrefix(c.req.header('authorization') ?? ''), auth, resolvedIdentity, ) const response = await run({ c, params: { ...hostParams, ...pathParams }, auth: resolvedAuth, kernel, identity: resolvedIdentity, }) return applyCorsToResponse(response, corsHeaders) } catch (err) { return applyCorsToResponse(errorResponse(err), corsHeaders) } } if (httpMethod === 'GET') app.get(honoPath, handler) else if (httpMethod === 'POST') app.post(honoPath, handler) // Per-route preflight — mirrors `createKernelApp`'s per-route // `app.options(...)` pattern (kernel/server/app/create.ts:112,145). Avoid // a wildcard `app.options('*', ...)`: it would intercept the kernel // envelope's own preflights mounted later on this same Hono instance. app.options(honoPath, (c) => { applyCorsToContext(c, corsHeaders) return c.body(null, 204) }) } type RemoteFunctionInput = { params: unknown enveloped: boolean id?: RequestId } /** * A Function binding is reached in two legitimate ways: * - kernel-client follows a graph redirect and sends the canonical kernel envelope; * - an external webhook caller POSTs its raw JSON contract directly. * * The vendor content type is the discriminator. Shape-sniffing would break a valid * webhook whose own payload happens to contain `method` and `params` fields. */ async function readRemoteFunctionInput(c: Context): Promise { const contentType = c.req.header('content-type') ?? '' const isJsonEnvelope = contentType.includes(KERNEL_CONTENT_TYPE_JSON) const isMsgpackEnvelope = contentType.includes(KERNEL_CONTENT_TYPE_MSGPACK) if (isJsonEnvelope || isMsgpackEnvelope) { const bytes = new Uint8Array(await c.req.raw.clone().arrayBuffer()) const codec = resolveCodec(isMsgpackEnvelope ? 'msgpack' : 'json') const request = decodeKernelRequest(codec.decode(bytes)) return { params: request.params, enveloped: true, id: request.id } } const params: unknown = await c.req.raw .clone() .json() .catch(() => ({})) return { params, enveloped: false } } function applyCorsToContext(c: Context, headers: Record): void { for (const [name, value] of Object.entries(headers)) c.header(name, value) } function applyCorsToResponse(response: Response, headers: Record): Response { try { for (const [name, value] of Object.entries(headers)) response.headers.set(name, value) return response } catch { // Some Responses have immutable headers — notably `Response.redirect(...)`, // which a View's `render` is documented to return. Rebuild with a mutable // header copy so CORS still applies (status / body / location preserved). const merged = new Headers(response.headers) for (const [name, value] of Object.entries(headers)) merged.set(name, value) return new Response(response.body, { status: response.status, statusText: response.statusText, headers: merged, }) } } function collectPlaceholderNames(path: string): string[] { return [...path.matchAll(PLACEHOLDER_RE)].map((m) => m[1]!) } /** * Serialize an error as the canonical kernel error envelope * `{ error: { code, message, data } }` with the matching HTTP status. This is * the exact shape the routed client (`kernel-client` HttpRoutedTransport. * decodeError) parses — it routes on `error.code` and reads `error.data` for * field-level detail (a flat `{ error: '' }` body silently degrades to * a generic INTERNAL_ERROR client-side). Every SDK error class (AuthMissingError, * SdkValidationError, SdkResultValidationError, AuthorizationDeniedError, …) plus * the kernel-core errors `resolveInboundAuth` rethrows all implement * `toKernelErrorPayload`, so one branch covers them; only a raw non-classifiable * Error falls back to 500. */ function errorResponse(err: unknown): Response { const payload: KernelErrorPayload = isKernelErrorClassifiable(err) ? err.toKernelErrorPayload() : { code: KERNEL_ERROR_CODES.INTERNAL_ERROR, message: err instanceof Error ? err.message : 'Internal error', } return Response.json({ error: payload }, { status: kernelErrorHttpStatus(payload.code) }) } function joinPath(a: string, b: string): string { if (!b) return a if (!a) return b const left = a.endsWith('/') ? a.slice(0, -1) : a const right = b.startsWith('/') ? b : `/${b}` return `${left}${right}` || '/' } /** Convert `/foo/{id}` / `/{name+}` / `/{name*}` to Hono syntax. */ function toHonoPath(path: string): string { return path .replace(/\{(\w+)\+\}/g, ':$1{.+}') .replace(/\{(\w+)\*\}/g, ':$1{.*}') .replace(/\{(\w+)\}/g, ':$1') } function stripBearerPrefix(value: string): string { return value.trim().replace(/^Bearer\s+/i, '') }