import type { MediaSettings, MediaSettingsUpdate, MediaSettingsView } from "@rakazo/contracts"; import { MediaSettingsSchema, MediaSettingsUpdateSchema } from "@rakazo/contracts"; import { parseWorkflowTemplate } from "@rakazo/core/media/workflows"; import type { PrismaClient } from "@rakazo/db"; import { assertAllowedOpenAiCompatibleRequestUrl, assertAllowedOpenAiCompatibleUrl, assertHttpsForKeyedOpenAiCompatibleUrl, } from "./openai-compatible-url.js"; import type { EncryptedSecretStore } from "./secrets.js"; /** Recipient ids for AI data-sharing disclosures of the media servers. */ export const MEDIA_COMFYUI_PROVIDER_ID = "comfyui"; export const MEDIA_TRANSCRIPTION_PROVIDER_ID = "media-transcription"; /** The IntegrationProviderConfig row that stores the media backends, encrypted as one record. */ export const MEDIA_SETTINGS_ID = "media"; const RECORD_ID = `integration-provider:${MEDIA_SETTINGS_ID}`; export interface MediaSettingsDeps { prisma: Pick; secrets: Pick; } const NOT_CONFIGURED: MediaSettings = { comfyui: null, transcription: null }; // Decrypting derives a scrypt key; tool selection reads the settings on every run. let cached: { ciphertext: string; settings: MediaSettings } | undefined; /** Deployment-wide media backends, read fresh from the database so every process sees edits. */ export async function loadMediaSettings(deps: MediaSettingsDeps): Promise { const row = await deps.prisma.integrationProviderConfig.findUnique({ where: { id: MEDIA_SETTINGS_ID }, }); if (!row) return NOT_CONFIGURED; if (cached?.ciphertext === row.ciphertext) return cached.settings; const settings = MediaSettingsSchema.parse( JSON.parse(deps.secrets.load(row.ciphertext, RECORD_ID)), ); cached = { ciphertext: row.ciphertext, settings }; return settings; } export function mediaSettingsView(settings: MediaSettings): MediaSettingsView { return { comfyui: settings.comfyui ? withoutKey(settings.comfyui) : null, transcription: settings.transcription ? withoutKey(settings.transcription) : null, }; } function withoutKey({ apiKey, ...rest }: T) { return { ...rest, hasApiKey: Boolean(apiKey) }; } /** A ComfyUI root such as `http://127.0.0.1:8188`, without credentials, query or fragment. */ export function normalizeComfyUiBaseUrl(raw: string): string { let url: URL; try { url = new URL(raw.trim()); } catch { throw new Error("Server URL must be an absolute HTTP(S) URL"); } if (url.username || url.password) throw new Error("Server URL must not contain credentials"); const normalized = `${url.origin}${url.pathname.replace(/\/+$/, "")}`; // Same address policy as OpenAI-compatible servers: private hosts pass, metadata hosts never. return assertAllowedOpenAiCompatibleRequestUrl(normalized).href.replace(/\/+$/, ""); } /** An OpenAI-compatible API root ending in a version segment, such as `…/v1`. */ export function normalizeTranscriptionBaseUrl(raw: string): string { return assertAllowedOpenAiCompatibleUrl(raw).href.replace(/\/+$/, ""); } function keptKey( next: string | undefined, previous: { baseUrl: string; apiKey?: string } | null, baseUrl: string, ): string | undefined { if (next !== undefined) return next || undefined; // A saved key never follows the settings to another server. if (!previous?.apiKey) return undefined; return new URL(previous.baseUrl).origin === new URL(baseUrl).origin ? previous.apiKey : undefined; } /** A media setting the deployment owner has to correct; the message says what is wrong. */ export class MediaSettingsInvalidError extends Error { constructor(message: string) { super(message); this.name = "MediaSettingsInvalidError"; } } function assertValidWorkflow(label: string, workflow: string | undefined): void { if (!workflow) return; try { parseWorkflowTemplate(workflow); } catch (error) { throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}`); } } function resolveSettings(previous: MediaSettings, update: MediaSettingsUpdate): MediaSettings { const parsed = MediaSettingsUpdateSchema.parse(update); try { assertValidWorkflow("Image workflow", parsed.comfyui?.imageWorkflow); assertValidWorkflow("Video workflow", parsed.comfyui?.videoWorkflow); let comfyui: MediaSettings["comfyui"] = null; if (parsed.comfyui) { const baseUrl = normalizeComfyUiBaseUrl(parsed.comfyui.baseUrl); const apiKey = keptKey(parsed.comfyui.apiKey, previous.comfyui, baseUrl); assertHttpsForKeyedOpenAiCompatibleUrl(new URL(baseUrl), apiKey); comfyui = { ...parsed.comfyui, baseUrl, apiKey }; } let transcription: MediaSettings["transcription"] = null; if (parsed.transcription) { const baseUrl = normalizeTranscriptionBaseUrl(parsed.transcription.baseUrl); const apiKey = keptKey(parsed.transcription.apiKey, previous.transcription, baseUrl); assertHttpsForKeyedOpenAiCompatibleUrl(new URL(baseUrl), apiKey); transcription = { ...parsed.transcription, baseUrl, apiKey }; } return MediaSettingsSchema.parse({ comfyui, transcription }); } catch (error) { throw new MediaSettingsInvalidError( error instanceof Error ? error.message : "These media settings are not valid", ); } } /** * Validates and stores the media backends (deployment owner only; the caller authorizes). * Throws MediaSettingsInvalidError when a URL is not allowed. */ export async function saveMediaSettings( deps: MediaSettingsDeps, update: MediaSettingsUpdate, ): Promise { const settings = resolveSettings(await loadMediaSettings(deps), update); if (!settings.comfyui && !settings.transcription) { await deps.prisma.integrationProviderConfig.deleteMany({ where: { id: MEDIA_SETTINGS_ID } }); return mediaSettingsView(settings); } const stored = await deps.secrets.put( JSON.stringify(settings), { operationId: "media-settings", traceId: "media-settings", spaceId: "", userId: "", signal: new AbortController().signal, }, RECORD_ID, ); await deps.prisma.integrationProviderConfig.upsert({ where: { id: MEDIA_SETTINGS_ID }, create: { id: MEDIA_SETTINGS_ID, ciphertext: stored.ciphertext }, update: { ciphertext: stored.ciphertext }, }); cached = { ciphertext: stored.ciphertext, settings }; return mediaSettingsView(settings); }