Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
161 lines
6.4 KiB
TypeScript
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);
|
|
}
|