evOo-local/packages/adapters/src/media-settings.ts
2026-10-03 13:53:47 +00:00

161 lines
6.4 KiB
TypeScript

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<PrismaClient, "integrationProviderConfig">;
secrets: Pick<EncryptedSecretStore, "load" | "put">;
}
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<MediaSettings> {
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<T extends { apiKey?: string }>({ 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<MediaSettingsView> {
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);
}