/*
RFC-1188: resolve the platform's Web Bot Auth outbound signer for leitstand's
fleet fetches. PASSPORT_SIGNING_KEY (identity site's .env first, ambient env as
fallback) + the active passport kid + the identity origin → createWbaSigner +
createOutboundAgentClient. The signer identity is the platform's canonical
site: WBA_IDENTITY_SITE overrides, otherwise the probed systemId signs (the
fleet-internal loop verifies end-to-end against our own directories).
Unconfigured resolves to a signer-less client — the call still carries the
identifying User-Agent and is never broken by missing key material.
- Does not decide WHICH call sites sign — callers opt in per fetch.
- Does not verify inbound signatures — inbound WBA stays edge-delegated (ADR-0098).
- Does not manage key rotation — passport.key.rotate owns that.
- RFC-1188: initial outbound signer resolver for leitstand fleet fetches.
- RFC-1188: Web Bot Auth outbound identity: wba-sign primitives, directory emit+sweep, AGS-WBA-01, leitstand signed fetches
- RFC-1188: review hardening — AGS-WBA-01 validates every published key, wba-signer fail-soft on unresolvable appDir
- RFC-1188: fo-review fix — IO-01 cleared (WorkspaceIO/fs helpers instead of node:fs), resolvePassportSigningKey + selectActivePassportKey dedup, onSigningFailure channel for signing-failure logging
*/
import { createRequire } from "node:module";
import { join } from "node:path";
import {
createOutboundAgentClient,
createWbaSigner,
WBA_DIRECTORY_PATH,
type OutboundAgentClient,
type WbaSigner,
} from "@warpgogol/werkstatt-shared/agent/wba-sign";
import {
PassportPublicKeyFileSchema,
selectActivePassportKey,
} from "@warpgogol/werkstatt-shared/passport/schema";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
import { resolvePassportSigningKey } from "../integrity/signing.ts";
import { pathExists, readText } from "../integrity/fs.ts";
import {
readSystemConfigSmart,
resolveActiveWorkpieceDir,
resolveCacheClonePath,
} from "../sternsystem/registry-io.ts";
const ENGINE_VERSION = (createRequire(import.meta.url)("../../package.json") as { version: string })
.version;
const PASSPORT_PUBLIC_KEY_FILE = "public/.well-known/cosmic-passport-key.json";
export interface OutboundSignerResolution {
client: OutboundAgentClient;
/** "signed" when key material + kid + origin all resolved, else "degraded". */
mode: "signed" | "degraded";
/** Why degraded — for operator-visible logging. Absent when signed. */
reason?: string;
}
export interface OutboundSignerOptions {
/**
* Called when signing a request throws (bad key material, crypto failure) —
* the client falls back to unsigned either way. Defaults to console.warn so
* the "logs a warning" contract in OutboundAgentClient holds; command
* callers SHOULD pass their own logger.warn to route it into the report.
*/
onSigningFailure?: (error: unknown) => void;
/** IO port for the passport-key read — defaults to the ambient adapter. */
io?: WorkspaceIO;
}
/**
* Resolve the outbound agent client for fleet fetches originating from the
* platform toward system `systemId`. Env: WBA_IDENTITY_SITE (identity system,
* defaults to the probed system), PASSPORT_SIGNING_KEY (hex private key,
* identity site's .env authoritative).
*/
export async function resolveOutboundAgentClient(
workspaceRoot: string,
systemId?: string,
fetchImpl?: typeof fetch,
opts?: OutboundSignerOptions,
): Promise {
const io = opts?.io ?? createDefaultIO().io;
const onSigningFailure =
opts?.onSigningFailure ??
((error: unknown) =>
console.warn(
`[leitstand.wba-signer] outbound signing failed, request sent unsigned: ${
error instanceof Error ? error.message : String(error)
}`,
));
const identityId = process.env.WBA_IDENTITY_SITE?.trim() || systemId;
// The active mission workpiece carries the freshest .env + key material —
// mirror readSystemConfigSmart's preference so the signer tracks mission
// state rather than the (possibly older) cache clone.
let appDir: string;
try {
appDir =
(identityId && (await resolveActiveWorkpieceDir(workspaceRoot, identityId))) ??
resolveCacheClonePath(workspaceRoot, identityId ?? "");
} catch {
appDir = resolveCacheClonePath(workspaceRoot, identityId ?? "");
}
let origin: string | null = null;
if (identityId) {
try {
const config = await readSystemConfigSmart(workspaceRoot, identityId);
origin = config.deployment?.channels?.main?.url ?? null;
} catch {
origin = null;
}
}
const directoryUrl = origin ? `${origin.replace(/\/+$/, "")}${WBA_DIRECTORY_PATH}` : null;
const userAgent = directoryUrl
? `WarpgogolBot/${ENGINE_VERSION} (+${directoryUrl})`
: `WarpgogolBot/${ENGINE_VERSION}`;
const fail = (reason: string): OutboundSignerResolution => ({
client: createOutboundAgentClient({ userAgent, fetchImpl, onSigningFailure }),
mode: "degraded",
reason,
});
let privateKeyHex: string | undefined;
try {
privateKeyHex = await resolvePassportSigningKey(appDir);
} catch {
privateKeyHex = process.env["PASSPORT_SIGNING_KEY"];
}
if (!privateKeyHex) return fail("PASSPORT_SIGNING_KEY not configured");
if (!origin) return fail(`no main channel URL for identity site '${identityId}'`);
const keyFilePath = appDir ? join(appDir, PASSPORT_PUBLIC_KEY_FILE) : "";
if (!keyFilePath || !(await pathExists(keyFilePath, io))) {
return fail(`no cosmic-passport-key.json for identity site '${identityId}'`);
}
let kid: string;
try {
const keyFile = PassportPublicKeyFileSchema.parse(JSON.parse(await readText(keyFilePath, io)));
const active = selectActivePassportKey(keyFile);
if (!active) return fail(`no active key for identity site '${identityId}'`);
kid = active.version;
} catch {
return fail(`unparseable cosmic-passport-key.json for '${identityId}'`);
}
const signer: WbaSigner = createWbaSigner({
privateKeyHex,
keyId: kid,
directoryUrl: directoryUrl!,
userAgent,
});
return {
client: createOutboundAgentClient({
signer,
userAgent,
fetchImpl,
onSigningFailure,
}),
mode: "signed",
};
}