import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { config } from "$lib/server/config"; export const AGENT_PREVIEW_CAPABILITY_TTL_MS = 60 * 60 * 1_000; const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/; const MIN_RESOLVER_SECRET_BYTES = 32; export interface IssuedAgentPreviewCapability { capability: string; hash: string; expiresAt: Date; } export function issueAgentPreviewCapability( now = new Date(), random: (size: number) => Buffer = randomBytes ): IssuedAgentPreviewCapability { const capability = random(32).toString("base64url"); if (!CAPABILITY_PATTERN.test(capability)) { throw new Error("Unable to issue a valid Agent preview capability"); } return { capability, hash: hashAgentPreviewCapability(capability), expiresAt: new Date(now.getTime() + AGENT_PREVIEW_CAPABILITY_TTL_MS), }; } export function hashAgentPreviewCapability(capability: string): string { if (!CAPABILITY_PATTERN.test(capability)) { throw new Error("Invalid Agent preview capability"); } return createHash("sha256").update(capability, "utf8").digest("hex"); } export function agentPreviewOrigin(candidate = config.AGENT_PREVIEW_ORIGIN): string { let url: URL; try { url = new URL(candidate); } catch { throw new Error("AGENT_PREVIEW_ORIGIN must be an absolute HTTPS origin"); } if ( url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash || (candidate !== url.origin && candidate !== `${url.origin}/`) ) { throw new Error("AGENT_PREVIEW_ORIGIN must be a credential-free HTTPS origin"); } if (config.PUBLIC_ORIGIN) { let chatOrigin: string | undefined; try { chatOrigin = new URL(config.PUBLIC_ORIGIN).origin; } catch { // PUBLIC_ORIGIN is validated by the workspace provisioner. Keep this // helper's error focused on the preview runtime configuration. } if (chatOrigin === url.origin) { throw new Error("AGENT_PREVIEW_ORIGIN must be distinct from the Chat UI origin"); } } return url.origin; } export function previewResolverSecret(candidate = config.AGENT_PREVIEW_RESOLVER_SECRET): string { if (Buffer.byteLength(candidate, "utf8") < MIN_RESOLVER_SECRET_BYTES) { throw new Error("AGENT_PREVIEW_RESOLVER_SECRET must contain at least 32 bytes"); } return candidate; } export function verifyPreviewResolverAuthorization( authorization: string | null, expectedSecret: string ): boolean { if (!authorization?.startsWith("Bearer ")) return false; const candidate = authorization.slice("Bearer ".length); const candidateHash = createHash("sha256").update(candidate, "utf8").digest(); const expectedHash = createHash("sha256").update(expectedSecret, "utf8").digest(); return timingSafeEqual(candidateHash, expectedHash); } export function isAgentPreviewCapability(value: unknown): value is string { return typeof value === "string" && CAPABILITY_PATTERN.test(value); }