/* RFC-0967: Fleet ownership registry client logic. Provides register, verify, and transfer functions that call the fleet-ownership-registry Worker. Reads passport from cache clone and sends it to the registry. Does not implement the Worker — that lives in services/fleet-ownership-registry/. Does not store secrets — only identity claims are transmitted. Does not auto-discover registry URL — out-of-band operator config via FLEET_OWNERSHIP_REGISTRY_URL. RFC-0967: initial client logic — registerOwnership, verifyOwnership, transferOwnership, deriveInstanceId. */ import { createHash } from "node:crypto"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { readPassport } from "../sternsystem/registry-io.ts"; import type { SignedSitePassport } from "../sternsystem/passport.ts"; export interface OwnershipClaim { passportHash: string; systemId: string; creatorIdentity: string; instanceId: string; claimedAt: string; bordbuchHead: string; supersededBy: string | null; } export interface RegisterResult { registered: boolean; claim: OwnershipClaim; conflict: { existingClaim: OwnershipClaim | null } | null; } export interface VerifyResult { registered: boolean; claim: OwnershipClaim | null; conflictsWith: string | null; } export interface TransferResult { transferred: boolean; previousClaim: OwnershipClaim; newClaim: OwnershipClaim; } export class OwnershipError extends Error { constructor( public code: string, message: string, public details?: unknown, ) { super(message); this.name = "OwnershipError"; } } export function deriveInstanceId(publicKey: string): string { return createHash("sha256").update(publicKey, "utf8").digest("hex").slice(0, 16); } function getRegistryUrl(explicit?: string): string { const url = explicit ?? process.env.FLEET_OWNERSHIP_REGISTRY_URL; if (!url) { throw new OwnershipError( "OWNERSHIP-01", "FLEET_OWNERSHIP_REGISTRY_URL is not set — cannot reach fleet ownership registry", ); } return url.replace(/\/+$/, ""); } function getRegistryToken(): string | undefined { return process.env.FLEET_OWNERSHIP_REGISTRY_TOKEN; } function authHeaders(): Record { const token = getRegistryToken(); const headers: Record = { "content-type": "application/json" }; if (token) { headers["authorization"] = `Bearer ${token}`; } return headers; } function mapClaim(raw: Record): OwnershipClaim { return { passportHash: raw.passportHash as string, systemId: raw.systemId as string, creatorIdentity: raw.creatorIdentity as string, instanceId: raw.instanceId as string, claimedAt: raw.claimedAt as string, bordbuchHead: raw.bordbuchHead as string, supersededBy: (raw.supersededBy as string | null) ?? null, }; } export async function registerOwnership(input: { systemId: string; werkstattRoot: string; registryUrl?: string; }): Promise { const { systemId, werkstattRoot } = input; const baseUrl = getRegistryUrl(input.registryUrl); const passport = await readPassport(werkstattRoot, systemId); if (!passport) { throw new OwnershipError( "OWNERSHIP-01", `passport.json not found for ${systemId} — run: pnpm exec werkstatt run sternsystem.passport.generate --id ${systemId}`, ); } let response: Response; try { response = await fetch(`${baseUrl}/register`, { method: "POST", headers: authHeaders(), body: JSON.stringify({ passport }), }); } catch (err) { throw new OwnershipError( "OWNERSHIP-02", `Registry unreachable: ${err instanceof Error ? err.message : String(err)}`, ); } const body = (await response.json().catch(() => ({}))) as Record; if (response.status === 409) { const conflict = body.conflict as { existingClaim?: Record } | undefined; return { registered: false, claim: null as unknown as OwnershipClaim, conflict: { existingClaim: conflict?.existingClaim ? mapClaim(conflict.existingClaim as Record) : null, }, }; } if (response.status === 400 && body.error === "OWNERSHIP-04") { throw new OwnershipError( "OWNERSHIP-04", "Invalid passport signature — Ed25519 verification failed", ); } if (!response.ok) { throw new OwnershipError( "OWNERSHIP-02", `Registry error (${response.status}): ${body.message ?? body.error ?? "unknown"}`, ); } const claim = mapClaim(body.claim as Record); return { registered: true, claim, conflict: null, }; } export async function verifyOwnership(input: { systemId: string; werkstattRoot: string; registryUrl?: string; }): Promise { const { systemId, werkstattRoot } = input; const baseUrl = getRegistryUrl(input.registryUrl); const passport = await readPassport(werkstattRoot, systemId); if (!passport) { return { registered: false, claim: null, conflictsWith: null, }; } let response: Response; try { response = await fetch( `${baseUrl}/verify?passportHash=${encodeURIComponent(passport.passportHash)}`, { method: "GET", headers: authHeaders(), }, ); } catch (err) { throw new OwnershipError( "OWNERSHIP-02", `Registry unreachable: ${err instanceof Error ? err.message : String(err)}`, ); } if (!response.ok) { throw new OwnershipError("OWNERSHIP-02", `Registry error (${response.status})`); } const body = (await response.json().catch(() => ({}))) as Record; if (!body.registered) { return { registered: false, claim: null, conflictsWith: null, }; } const claim = mapClaim(body.claim as Record); return { registered: true, claim, conflictsWith: claim.instanceId !== deriveInstanceId(passport.payload.creator.publicKey) ? claim.instanceId : null, }; } export async function transferOwnership(input: { systemId: string; werkstattRoot: string; registryUrl?: string; authorizationPath: string; io?: WorkspaceIO; }): Promise { const { systemId, werkstattRoot, authorizationPath } = input; const io = input.io ?? createDefaultIO().io; const baseUrl = getRegistryUrl(input.registryUrl); const passport = await readPassport(werkstattRoot, systemId); if (!passport) { throw new OwnershipError("OWNERSHIP-01", `passport.json not found for ${systemId}`); } let authorization: unknown; try { const authRaw = await io.readFile(authorizationPath); authorization = JSON.parse(authRaw); } catch (err) { throw new OwnershipError( "OWNERSHIP-05", `Cannot read authorization file: ${err instanceof Error ? err.message : String(err)}`, ); } const instanceId = deriveInstanceId(passport.payload.creator.publicKey); let response: Response; try { response = await fetch(`${baseUrl}/transfer`, { method: "POST", headers: authHeaders(), body: JSON.stringify({ authorization, newClaim: { passportHash: passport.passportHash, creatorIdentity: passport.payload.creator.identity, instanceId, bordbuchHead: passport.payload.provenance.bordbuchHead, systemId: passport.payload.systemId, }, }), }); } catch (err) { throw new OwnershipError( "OWNERSHIP-02", `Registry unreachable: ${err instanceof Error ? err.message : String(err)}`, ); } const body = (await response.json().catch(() => ({}))) as Record; if (!response.ok) { const code = body.error === "OWNERSHIP-05" ? "OWNERSHIP-05" : "OWNERSHIP-02"; throw new OwnershipError( code, `Transfer failed (${response.status}): ${body.message ?? body.error ?? "unknown"}`, ); } return { transferred: true, previousClaim: mapClaim(body.previousClaim as Record), newClaim: mapClaim(body.newClaim as Record), }; } export type { SignedSitePassport };