Media tools and card actions send prompts, images, video and audio to the
deployment owner's ComfyUI and transcription servers. Like the embeddings
server behind built-in memory, those self-hosted endpoints are recipients in
the AI data-sharing disclosure: they appear with the model uses ("ComfyUI
(<origin>)", "Transcription (<origin>)", keyed by the full endpoint so a new
server asks again), and starting a card job asks for model consent on clients
that run the foreground check.
Also adds unit tests for the media settings store: URL normalization and
refusals, encryption, the key-free view, one decryption per saved version, key
retention per server and removing empty settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
66 lines
2.3 KiB
TypeScript
66 lines
2.3 KiB
TypeScript
import type { AiConsentStatus, AiDataUse, AiRecipient } from "@rakazo/contracts";
|
|
import { AI_CONSENT_REQUIRED } from "@rakazo/contracts";
|
|
|
|
/** A foreground check failed before the requested mutation was dispatched. */
|
|
export class AiConsentBlocked extends Error {
|
|
constructor(message = AI_CONSENT_REQUIRED) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
/** Only user actions that can start AI processing need a foreground disclosure. */
|
|
export function aiDataUsesForProcedure(procedure: string, input?: unknown): AiDataUse[] {
|
|
const path = procedure.replaceAll(".", "/");
|
|
if (
|
|
path === "routines/update" &&
|
|
input &&
|
|
typeof input === "object" &&
|
|
"active" in input &&
|
|
input.active === false
|
|
)
|
|
return [];
|
|
if (
|
|
[
|
|
"threads/send",
|
|
"artifacts/create",
|
|
"threads/followUp",
|
|
"threads/react",
|
|
"threads/answer",
|
|
"routines/create",
|
|
"routines/update",
|
|
"routines/testRun",
|
|
].includes(path)
|
|
)
|
|
return ["model", "memory"];
|
|
if (path === "media/jobs/start") return ["model"];
|
|
if (["voice/prepare", "voice/speak", "voice/transcribe"].includes(path)) return ["voice"];
|
|
return [];
|
|
}
|
|
|
|
export async function ensureAiDataConsent(options: {
|
|
uses: AiDataUse[];
|
|
status(): Promise<AiConsentStatus>;
|
|
prompt(recipient: AiRecipient, privacyUrl?: string): Promise<boolean>;
|
|
allow(input: { scope: string; version: string; keys: string[] }): Promise<unknown>;
|
|
}) {
|
|
if (options.uses.length === 0) return;
|
|
try {
|
|
const status = await options.status();
|
|
for (const recipient of status.recipients) {
|
|
if (recipient.allowed || !options.uses.includes(recipient.use)) continue;
|
|
if (!(await options.prompt(recipient, status.privacyUrl)))
|
|
throw new Error(AI_CONSENT_REQUIRED);
|
|
await options.allow({ scope: status.scope, version: status.version, keys: [recipient.key] });
|
|
}
|
|
} catch (error) {
|
|
throw new AiConsentBlocked(error instanceof Error ? error.message : AI_CONSENT_REQUIRED);
|
|
}
|
|
}
|
|
|
|
export function aiConsentTarget(input: unknown): { botId?: string; groupId?: string } {
|
|
if (!input || typeof input !== "object") return {};
|
|
const target = input as { botId?: unknown; groupId?: unknown };
|
|
if (typeof target.groupId === "string") return { groupId: target.groupId };
|
|
return typeof target.botId === "string" ? { botId: target.botId } : {};
|
|
}
|