/** * `SdkDispatcher` — implements direct KernelAPI calls and hosted invocations * through the same SDK execution pipeline. * * Built once at server startup from the domain's `CompiledDomain` plus a typed * deps container and a private JWK. Per-method identity configs are * pre-resolved into a Map so dispatch is O(1) per call. * * Pipeline per call: * resolve method → authenticate → validate → resolve self → execute */ import type { AuthedKernelAPI, DispatchResult, KernelAPI } from '@astrale-os/kernel-api' import type { AuthPolicy } from '@astrale-os/kernel-api/routed' import type { NodeBinder } from '@astrale-os/kernel-client/schema' import type { CredentialInput, Path } from '@astrale-os/kernel-core' import type { BoundMethod, CompiledDomain } from '@astrale-os/kernel-core/domain' import type { Invocation, Capabilities, Dispatcher } from '@astrale-os/kernel-server' import { createNodeBinder } from '@astrale-os/kernel-client/schema' import { executeInvocation } from '@astrale-os/kernel-server' import type { RemoteIdentityConfig } from '../auth/identity.js' import type { AnyRemoteHandler } from '../method/single.js' import type { MethodIndex } from './resolve.js' import { makeFunctionContext } from '../auth/function-context.js' import { makeDomainAuthority } from '../auth/issuer-mint.js' import { resolveInboundAuth } from '../auth/resolve.js' import { createInlineStep } from '../step/index.js' import { runAuthorize } from './authorize.js' import { MethodNotFoundError, SdkResultValidationError, SdkValidationError } from './errors.js' import { executeHandler } from './execute.js' import { buildIdentityMap } from './identity.js' import { resolveMethod } from './resolve.js' import { resolveSelf, withBoundNode, type ParsedSelf } from './self.js' import { validateParams, validateResult } from './validate.js' export type SdkDispatcherConfig = { /** The compiled domain — source of the method layout and origin-addressed subs. */ compiled: CompiledDomain /** Pre-built method map: ref → BoundMethod. */ methods: MethodIndex /** Dependency container injected into every handler. */ deps: TDeps /** Private key for signing outbound per-function credentials. */ privateKey: JsonWebKey /** * The worker's identity (`iss`) for outbound per-function credentials — its * serving URL (e.g. `https://crm.test.com`), DECOUPLED from the domain's * addressing `origin`. Matches the `iss` the kernel pins on each node at * install (the URL it fetched the domain at). */ issuer: string /** The worker's serving URL (`config.url`) — exposed to handlers as `ctx.env.url`. */ url: string } export class SdkDispatcher implements KernelAPI, Dispatcher { private readonly methods: MethodIndex private readonly deps: TDeps private readonly identities: Map, RemoteIdentityConfig> private readonly issuer: string private readonly url: string private readonly privateKey: JsonWebKey /** Built once from the compiled domain — drives typed self and handler kernel binding. */ private readonly binder: NodeBinder constructor(config: SdkDispatcherConfig) { this.methods = config.methods this.deps = config.deps this.issuer = config.issuer this.url = config.url this.privateKey = config.privateKey this.binder = createNodeBinder(config.compiled.$.schema) const methodList = Array.from(new Set(config.methods.values())) const identityMethods = methodList.filter((bound) => methodAuthPolicy(bound) !== 'public') this.identities = identityMethods.length > 0 ? buildIdentityMap(config.compiled, identityMethods, config.privateKey, config.issuer) : new Map() } async call( path: Path | string, credential: CredentialInput, params: unknown, self?: string, ): Promise { const result = await this.dispatch(path, credential, params, self) switch (result.kind) { case 'value': return result.value case 'stream': return result.generator case 'redirect': throw new Error(`SdkDispatcher cannot redirect "${pathToString(path)}"`) } } async stream( path: Path | string, credential: CredentialInput, params: unknown, self?: string, ): Promise> { const result = await this.dispatch(path, credential, params, self) if (result.kind !== 'stream') { throw new Error(`Method "${pathToString(path)}" did not return an async generator`) } return result.generator } /** * SDK dispatch never redirects — the SDK server IS the remote destination. * Every call resolves locally; we just classify the return as value or stream * and hand it back to the invocation host. */ async dispatch( path: Path | string, credential: CredentialInput, params: unknown, self?: string, ): Promise { return executeInvocation(this, { path, credential, params, ...(self === undefined ? {} : { self }), }) } async invoke(invocation: Invocation, caps: Capabilities): Promise { const result = await this.run(invocation, caps) return isAsyncGenerator(result) ? { kind: 'stream', generator: result } : { kind: 'value', value: result } } as(credential: CredentialInput): AuthedKernelAPI { return { call: (path, params) => this.call(path, credential, params), stream: (path, params) => this.stream(path, credential, params), } } private async run(invocation: Invocation, caps: Capabilities): Promise { const { path, credential, params, self: selfRef } = invocation const method = pathToString(path) const bound = resolveMethod(this.methods, method) if (!bound) throw new MethodNotFoundError(method) const authPolicy: AuthPolicy = (bound.handler as { auth?: AuthPolicy }).auth ?? 'required' const fnIdentity = this.identityFor(bound) const { auth, kernel: rawKernel } = await resolveInboundAuth(credential, authPolicy, fnIdentity) const kernel = rawKernel?.withSchema(this.binder.schema) ?? null if (typeof params !== 'object' || params === null || Array.isArray(params)) { throw new SdkValidationError([{ path: [], message: 'Params must be a plain object' }]) } const validation = validateParams(bound.inputSchema, params) if (!validation.ok) { throw new SdkValidationError(validation.issues as SdkValidationError['issues']) } // `undefined` (not `null`) for static methods — matches the `self` type on // `RemoteContext` / `MethodImpl`, which is `undefined` for static methods. let parsedSelf: ParsedSelf | undefined if (!bound.isStatic) { if (selfRef === undefined || selfRef === null || selfRef === '') { throw new SdkValidationError( [{ path: ['self'], message: 'Required for non-static method' }], `"${bound.owner}.${bound.method}" is an instance method — it needs a target node. ` + `Either call it via instance dispatch (e.g. "@::${bound.method}" ` + `or "/path/to/node::${bound.method}"), or pass "self" as the fourth ` + `dispatch argument (e.g. "@").`, ) } try { parsedSelf = resolveSelf(String(selfRef)) } catch (err) { // A malformed caller-supplied `self` is bad client input, not a server // fault — surface it as VALIDATION_ERROR (422), matching the sibling // missing-self branch above, instead of INTERNAL_ERROR (500). throw new SdkValidationError( [ { path: ['self'], message: err instanceof Error ? err.message : 'Failed to resolve self', }, ], `Invalid "self" target "${selfRef}": ${err instanceof Error ? err.message : 'unparseable path'}`, ) } } const handler = bound.handler as { execute: (...args: unknown[]) => unknown authorize?: (ctx: unknown) => void | Promise } const handlerParams = validation.data // Enrich the parsed self with the lazy `node()` accessor, bound to this // request's kernel (null when the auth policy yields none — `node()` then // rejects). Done here, not in `resolveSelf`, so the parse stays kernel-free. const self = parsedSelf ? withBoundNode(parsedSelf, kernel, this.binder, bound.owner) : undefined 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: handlerParams, auth, self, deps: this.deps, env: { url: this.url }, kernel, ...(caller !== undefined ? { caller } : {}), domain: makeDomainAuthority(fnIdentity), fn: makeFunctionContext(fnIdentity, this.deps, { ref: bound.ref, ...(kernelUrl !== undefined ? { defaultKernelUrl: kernelUrl } : {}), }), } if (handler.authorize) await runAuthorize(handler.authorize, ctx) const result = await executeHandler({ handler, ref: bound.ref, ...ctx, step: createInlineStep({ scope: { kind: 'method', ref: bound.ref, owner: bound.owner, method: bound.method, }, }), defer: caps.defer, sleep: caps.sleep, }) if (isAsyncGenerator(result)) { return validateOutputStream(bound, result) } return validateOutput(bound, result) } private identityFor(bound: BoundMethod): RemoteIdentityConfig { const identity = this.identities.get(bound) if (!identity) { if (methodAuthPolicy(bound) === 'public') { return { issuer: this.issuer, subject: bound.ref, privateKey: this.privateKey } } throw new Error( `SdkDispatcher: no identity config for "${bound.owner}.${bound.method}" — ` + `method is registered in the index but missing from the identity map.`, ) } return identity } } function methodAuthPolicy(bound: BoundMethod): AuthPolicy { return (bound.handler as { auth?: AuthPolicy }).auth ?? 'required' } function pathToString(path: string | { readonly raw: string }): string { return typeof path === 'string' ? path : path.raw } /** * Validate a handler's output against `bound.outputSchema`, mirroring the * kernel's dispatcher (`runtime/.../dispatcher.ts` + `events.ts`): * - `binary` outputs skip validation (no schema applies). * - async-generator outputs (`output: 'stream'`) are wrapped so each chunk * is validated as it is yielded. * - all other (`value`) outputs are validated once and returned parsed. * Returns the synchronous shape (value or generator) so the caller's * `dispatch()` can classify it without awaiting a stream to completion. */ function validateOutput(bound: BoundMethod, result: unknown): unknown { if (bound.output === 'binary') return result return validateChunk(bound, result) } async function* validateOutputStream( bound: BoundMethod, gen: AsyncGenerator, ): AsyncGenerator { for await (const chunk of gen) { yield validateChunk(bound, chunk) } } /** Parse one output value against `bound.outputSchema` or throw a 500-class error. */ function validateChunk(bound: BoundMethod, value: unknown): unknown { const out = validateResult(bound.outputSchema, value) if (!out.ok) { throw new SdkResultValidationError(out.issues as SdkResultValidationError['issues'], bound.ref) } return out.data } function isAsyncGenerator(value: unknown): value is AsyncGenerator { return ( value !== null && value !== undefined && typeof value === 'object' && Symbol.asyncIterator in value ) }