import type { FunctionBinding } from '@astrale-os/kernel-api/routed' import type { Node } from '@astrale-os/kernel-core' import type { CorsConfig } from '@astrale-os/kernel-server' import { K } from '@astrale-os/kernel-core' import { Hono } from 'hono' import type { RemoteIdentityConfig } from '../auth/identity.js' import type { AnyRemoteFunctionDef } from '../define/remote-function.js' import type { Fetcher, WorkerEntry } from './worker-entry.js' import { makeFunctionContext } from '../auth/function-context.js' import { buildFunctionSchemas, DEFAULT_FUNCTIONS_FOLDER } from '../domain/extend-functions.js' import { SERVICE_FUNCTIONS_PATH, ServiceFunctionDiscoveryRequestSchema, signServiceFunctionManifest, toServiceFunctionManifestEntries, } from '../service/functions.js' import { mountAuxiliaryRoutes } from './auxiliary-routes.js' import { createAppWorkerEntry } from './worker-entry.js' export interface ServiceWorkerIdentityEnv { IDENTITY_ISS?: string IDENTITY_SUB?: string IDENTITY_PRIVATE_KEY?: string ASTRALE_KERNEL_AUDIENCE?: string } export interface ServiceWorkerEntryConfig { functions?: Record functionsFolder?: string cors?: CorsConfig resolveUrl?: (env: TEnv, requestOrigin: string) => string selfBinding?: (env: TEnv) => Fetcher | null | undefined routeSubrequest?: (url: URL, env: TEnv) => Fetcher | null | undefined } /** A Service worker with zero or more first-class kernel Functions. * * The function map is the only declaration: it drives both HTTP routes and the * signed deploy-time manifest. Function identities are read lazily from the * graph after Services has reconciled them, while every Function reuses the * Service's one private signing key. */ export function serviceWorkerEntry( config: ServiceWorkerEntryConfig, ): WorkerEntry { const functions = config.functions ?? {} const functionsFolder = config.functionsFolder ?? DEFAULT_FUNCTIONS_FOLDER return createAppWorkerEntry({ buildApp: (url, env) => { const app = new Hono() const serviceIdentity = identityFromEnv(env) const { schemas, bindings } = buildFunctionSchemas(functions, url, functionsFolder) assertLocalBindings(url, bindings) const resolveFunctionIdentity = createFunctionIdentityResolver(env, serviceIdentity) app.get('/', (c) => c.json({ ok: true, kind: 'astrale-service' })) app.post(SERVICE_FUNCTIONS_PATH, async (c) => { const parsed = ServiceFunctionDiscoveryRequestSchema.safeParse( await c.req.json().catch(() => null), ) if (!parsed.success) { return c.json({ error: 'Invalid service function discovery request' }, 400) } const response = await signServiceFunctionManifest( { version: 1, service: { issuer: serviceIdentity.issuer, subject: serviceIdentity.subject, }, functions: toServiceFunctionManifestEntries(schemas), }, parsed.data, serviceIdentity, ) return c.json(response) }) mountAuxiliaryRoutes({ app, url, remoteFunctions: functions, remoteFunctionBindings: bindings, deps: env, resolveIdentity: (kind, slug) => { if (kind !== 'remoteFunction') { throw new Error(`serviceWorkerEntry cannot resolve ${kind} identity ${slug}`) } return resolveFunctionIdentity(slug) }, cors: config.cors ?? { origin: '*' }, }) return app }, resolveUrl: config.resolveUrl ?? ((_env, requestOrigin) => requestOrigin), selfBinding: config.selfBinding, routeSubrequest: config.routeSubrequest, }) } function identityFromEnv(env: TEnv): RemoteIdentityConfig { return { issuer: required(env, 'IDENTITY_ISS'), subject: required(env, 'IDENTITY_SUB'), privateKey: parsePrivateKey(required(env, 'IDENTITY_PRIVATE_KEY')), } } function createFunctionIdentityResolver( env: TEnv, serviceIdentity: RemoteIdentityConfig, ): (slug: string) => Promise { const kernelUrl = required(env, 'ASTRALE_KERNEL_AUDIENCE') let cachedAt = 0 let identities = new Map() const ttlMs = 60_000 return async (slug) => { if (Date.now() - cachedAt >= ttlMs || !identities.has(slug)) { const kernel = await makeFunctionContext(serviceIdentity, env, { ref: 'service', defaultKernelUrl: kernelUrl, }).kernel() const service = await kernel.get(`@${serviceIdentity.subject}`) if (!service) throw new Error(`Service @${serviceIdentity.subject} is not readable`) const folder = await kernel.get(`${service.path.raw}/functions`) const nodes = folder ? await ( await kernel.children(folder.path, { classes: [K.$.c('Function').path.class], limit: 500, }) ).all() : [] identities = readFunctionIdentities(nodes, serviceIdentity.issuer) cachedAt = Date.now() } const identity = identities.get(slug) if (!identity) throw new Error(`Function identity function.${slug} is not registered`) return hostedFunctionIdentity(identity, serviceIdentity) } } /** Reuse only the Service's signing key. A hosted Function's inbound audience * defaults to its own issuer; the Service HTTP origin is merely transport. */ export function hostedFunctionIdentity( identity: { issuer: string; subject: string }, serviceIdentity: RemoteIdentityConfig, ): RemoteIdentityConfig { return { ...identity, privateKey: serviceIdentity.privateKey } } function readFunctionIdentities( nodes: readonly Node[], serviceIssuer: string, ): Map { const result = new Map() for (const node of nodes) { const ref = node.props[K.$.i('Function').ref.key] const issuer = node.props[K.Identity.iss.key] const subject = node.props[K.Identity.sub.key] if ( typeof ref !== 'string' || !ref.startsWith('function.') || typeof issuer !== 'string' || typeof subject !== 'string' ) { continue } if (issuer !== serviceIssuer) { throw new Error(`Function ${ref} does not share its hosting Service issuer`) } result.set(ref.slice('function.'.length), { issuer, subject }) } return result } function assertLocalBindings(url: string, bindings: Record): void { const origin = new URL(url).origin for (const [slug, binding] of Object.entries(bindings)) { if (!binding.remoteUrl || new URL(binding.remoteUrl).origin !== origin) { throw new Error(`serviceWorkerEntry function.${slug} must be hosted by this Service origin`) } } } function required(env: TEnv, key: keyof TEnv): string { const value = env[key] if (typeof value !== 'string' || value.length === 0) { throw new Error(`Missing service variable ${String(key)}`) } return value } function parsePrivateKey(value: string): JsonWebKey { let parsed: unknown try { parsed = JSON.parse(value) } catch (error) { throw new Error('IDENTITY_PRIVATE_KEY is not valid JSON', { cause: error }) } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('IDENTITY_PRIVATE_KEY is not a JWK object') } return parsed as JsonWebKey }