import { spawnSync } from 'node:child_process' import { join } from 'node:path' import type { Meta } from './meta.js' import { MetaSchema } from './meta.js' export type DeployCheckOptions = { /** Deployed worker URL (e.g. `https://example.astrale.ai`). */ url: string /** * Expected `meta.schemaHash` — the hash of the domain's install graph (via * `buildInstallGraphHash(domain, url)`). REQUIRED to verify schema drift: when * the worker advertises a `schemaHash` but this is absent, the check fails * loudly rather than silently "passing" an unverified deploy. Per-domain * deploy scripts compute it (`buildInstallGraphHash(...)`) and pass it. */ expectedSchemaHash?: string /** * Path to the SDK repo used to read `sdkCommit`. If omitted and * `workspaceRoot` is provided, defaults to `/sdk`. */ sdkRepoPath?: string /** Astrale workspace root — enables auto-resolution of `sdkRepoPath` (`/sdk`). */ workspaceRoot?: string /** Sink for progress output. Defaults to `console.log`. */ log?: (line: string) => void } /** * Validate a deployed worker against local state by fetching `/meta` and * verifying sdkCommit / schemaHash / JWKS reachability. * * Throws on any mismatch. Returns `Meta` on success. */ export async function deployCheck(opts: DeployCheckOptions): Promise { // oxlint-disable-next-line no-console const log = opts.log ?? ((line: string) => console.log(line)) const base = opts.url.replace(/\/+$/, '') log(`# deploy:check ${base}`) const meta = await fetchMeta(base) log(` meta: ${JSON.stringify(meta)}`) // `meta.iss` is required + non-empty by `MetaSchema`, enforced in `fetchMeta`. await Promise.all([ checkSdkCommit(meta, opts, log), checkSchemaHash(meta, opts, log), checkJwks(meta.iss, log), ]) return meta } async function fetchMeta(base: string): Promise { const res = await fetch(`${base}/meta`) if (!res.ok) throw new Error(`GET ${base}/meta → ${res.status}`) return MetaSchema.parse(await res.json()) } async function checkSdkCommit( meta: Meta, opts: DeployCheckOptions, log: (line: string) => void, ): Promise { if (!meta.sdkCommit) { log(' ! meta.sdkCommit absent — skipping sdkCommit check') return } const sdkRepo = opts.sdkRepoPath ?? (opts.workspaceRoot ? join(opts.workspaceRoot, 'sdk') : undefined) if (!sdkRepo) { log(` ! no sdkRepoPath / workspaceRoot — skipping sdkCommit check`) return } const localSha = gitHead(sdkRepo) if (!localSha) { log(` ! could not read git HEAD from ${sdkRepo} — skipping sdkCommit check`) return } if (!localSha.startsWith(meta.sdkCommit) && !meta.sdkCommit.startsWith(localSha)) { throw new Error(`sdkCommit mismatch: deployed=${meta.sdkCommit} local=${localSha} (${sdkRepo})`) } log(` ✓ sdkCommit ${meta.sdkCommit} matches local HEAD`) } async function checkSchemaHash( meta: Meta, opts: DeployCheckOptions, log: (line: string) => void, ): Promise { if (!meta.schemaHash) return // Fail loud: the worker advertises a schemaHash, so a missing expected value // means we CANNOT verify drift — never silently "pass" an unverified deploy. if (!opts.expectedSchemaHash) { throw new Error( `worker advertises schemaHash=${meta.schemaHash} but no expectedSchemaHash was provided — ` + `cannot verify schema drift (pass expectedSchemaHash, e.g. buildInstallGraphHash(domain, url))`, ) } if (meta.schemaHash !== opts.expectedSchemaHash) { throw new Error( `schemaHash mismatch: deployed=${meta.schemaHash} local=${opts.expectedSchemaHash}`, ) } log(` ✓ schemaHash ${meta.schemaHash} matches local`) } async function checkJwks(iss: string, log: (line: string) => void): Promise { const jwksUrl = `${iss.replace(/\/+$/, '')}/.well-known/jwks.json` const res = await fetch(jwksUrl) if (!res.ok) throw new Error(`GET ${jwksUrl} → ${res.status}`) const body = (await res.json()) as { keys?: Array<{ kid?: string }> } const keys = body.keys ?? [] if (keys.length === 0) throw new Error(`${jwksUrl} returned no keys`) log(` ✓ JWKS resolves (${keys.length} key${keys.length === 1 ? '' : 's'})`) } function gitHead(repoPath: string): string | null { const r = spawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }) if (r.status !== 0) return null return r.stdout.trim() }