/** * Call-back kernel client. * * Every authenticated inbound request produces a `BoundClientSessionView` the * handler uses to call back into the parent kernel. The view is bound to * a composed credential (`union(delegation, self)`) so the kernel * enforces both the caller's scoped identity and the function's own. * * The connection pool and schema registry are cached per kernel URL and reused * across requests. The `ClientSession` itself is per-request: it carries a * delegation mint bound to the calling function's own subject (`config.subject`) * so a remote-bound call (one that redirects to another worker) mints a * worker-scoped credential for the audience the kernel puts on the redirect * (`CallRedirection.iss`) — the worker→worker dance, done reactively instead of * the old proactive `lookupRemoteBinding` resolve-then-dial. */ import type { Delegation } from '@astrale-os/kernel-core' import type { UnresolvedIdentityExpr } from '@astrale-os/kernel-core' import { KernelClient, SchemaRegistry, type FnMap } from '@astrale-os/kernel-client' import { ClientPool } from '@astrale-os/kernel-client/pool' import { ClientSession, allowAllDelegations, delegationMintVia, type BoundClientSessionView, } from '@astrale-os/kernel-client/session' import type { RemoteIdentityConfig } from './identity.js' import { buildComposedExpr, buildComposedGrant, buildSelfExpr } from './compose.js' import { signCredential } from './sign.js' const DELEGATION_TTL_SECONDS = 3600 // Shared per kernel URL — the expensive, identity-agnostic state. Sessions are // NOT shared (each binds a subject-specific delegation mint), but the pool // (connections) and registry (learned schemas) are reused across them. const pools = new Map>() const registries = new Map() function getRegistry(url: string): SchemaRegistry { let registry = registries.get(url) if (!registry) { registry = new SchemaRegistry() registries.set(url, registry) } return registry } function getPool(url: string): ClientPool { const cached = pools.get(url) if (cached) return cached const registry = getRegistry(url) const pool = new ClientPool({ clientFactory: (u) => new KernelClient({ url: u, schema: registry }), }) pools.set(url, pool) return pool } /** * Build a `BoundClientSessionView` that signs outbound calls as the composed * identity (the caller's delegation unioned with this function's own * identity). Remote-bound redirects mint worker-scoped delegations via AuthApi. */ export async function bindKernel( delegation: Delegation, kernelUrl: string, config: RemoteIdentityConfig, ): Promise> { return bindSession(kernelUrl, config, buildComposedGrant(delegation).grant, () => buildComposedExpr(delegation), ) } /** * Shared session construction: sign a credential as this function's identity * carrying `grant`, bind the session to it, and wire the lazy NEXT-HOP mint. */ async function bindSession( kernelUrl: string, config: RemoteIdentityConfig, grant: unknown, nextHopDelegation: () => UnresolvedIdentityExpr, ): Promise> { const credential = await signCredential( { grant }, { issuer: config.issuer, subject: config.subject, audience: kernelUrl, privateKey: config.privateKey, // Long-running handlers (a managed INSTALL saga easily runs minutes on a // cold box) make kernel callbacks throughout — the default 60s wall left // them unable to even write their own failure records (observed live: // install wedged at 'installing' forever). The session credential is // per-request and aud-bound; delegated AUTHORITY still expires with the // inner delegation's own exp. ttl: '30m', }, ) // The standard mint resolves the function identity through `auth.whoami()`. const session: ClientSession = new ClientSession({ default: kernelUrl, schema: getRegistry(kernelUrl), pool: getPool(kernelUrl), delegation: { mint: delegationMintVia( () => session, () => credential, ), ttl: DELEGATION_TTL_SECONDS, }, delegationPolicy: { ...allowAllDelegations({ default: kernelUrl }), delegationFor: () => nextHopDelegation(), }, }) return session.as(credential) } /** * Bind a session to a PRE-SIGNED credential — no delegation mint, no composed * grant; the credential IS the authority. The seam behind * `DomainAuthority.sessionFor` (a worker acting as an identity it issues). Pool + * schema registry are the same per-URL caches the composed sessions use. */ export function bindCredentialSession( kernelUrl: string, credential: string, opts: { delegation?: UnresolvedIdentityExpr } = {}, ): BoundClientSessionView { const session: ClientSession = new ClientSession({ default: kernelUrl, schema: getRegistry(kernelUrl), pool: getPool(kernelUrl), delegation: { mint: delegationMintVia( () => session, () => credential, ), ttl: DELEGATION_TTL_SECONDS, }, delegationPolicy: { ...allowAllDelegations({ default: kernelUrl }), delegationFor: () => opts.delegation ?? buildSelfExpr(), }, }) return session.as(credential) }