import type { Edl, GateIssue, PlatformProfile } from "@rakazo/contracts"; import type { MediaActor, MediaServiceDeps } from "./media-context.js"; import { clipWindows, overlapsWindows } from "./media-privacy-plan.js"; /* * Consent and age proof (spec 0 and 6.4). A paid-video profile always needs * documented consent and age verification of every recognised participant, * whatever its own flags say; other profiles follow their consent flags. * Faces that belong to no person can never show consent and block. */ export interface ConsentRequirements { consent: boolean; age: boolean; } export function consentRequirements(profile: PlatformProfile): ConsentRequirements { const paid = profile.kind === "paid_video"; return { consent: paid || profile.consent.requirePerformerConsent, age: paid || profile.consent.requireAgeVerification, }; } /** The profile with its consent flags raised to what the hard rules demand. */ export function enforceConsentFlags(profile: PlatformProfile): PlatformProfile { const required = consentRequirements(profile); return { ...profile, consent: { requirePerformerConsent: required.consent, requireAgeVerification: required.age }, }; } export interface PersonConsentState { id: string; consentRecordedAt: Date | string | null; ageVerifiedAt: Date | string | null; ageVerificationMethod: string; } /** Valid proof: a recorded date that is not in the future. */ function proven(value: Date | string | null, now: Date): boolean { if (!value) return false; const at = value instanceof Date ? value : new Date(value); return !Number.isNaN(at.getTime()) && at.getTime() <= now.getTime(); } export function hasConsent(person: PersonConsentState, now: Date): boolean { return proven(person.consentRecordedAt, now); } export function hasAgeProof(person: PersonConsentState, now: Date): boolean { return person.ageVerificationMethod !== "none" && proven(person.ageVerifiedAt, now); } /** * Consent issues for the participants of an export. `unassigned` counts faces * no person could be matched to; each blocks because nobody can consent for it. */ export function consentIssues( profile: PlatformProfile, people: PersonConsentState[], unassigned: Array<{ atMs?: number; endMs?: number }>, now: Date, ): GateIssue[] { const required = consentRequirements(profile); if (!required.consent && !required.age) return []; const issues: GateIssue[] = []; for (const person of [...people].sort((a, b) => a.id.localeCompare(b.id))) { if (required.consent && !hasConsent(person, now)) { issues.push({ check: "consent", severity: "block", code: "consent_missing", message: "no recorded consent", personId: person.id, }); } if (required.age && !hasAgeProof(person, now)) { issues.push({ check: "consent", severity: "block", code: "age_unverified", message: "age is not verified", personId: person.id, }); } } for (const face of unassigned) { issues.push({ check: "consent", severity: "block", code: "unknown_performer", message: "a visible face is not assigned to a person with consent", ...(face.atMs !== undefined ? { atMs: face.atMs } : {}), ...(face.endMs !== undefined ? { endMs: face.endMs } : {}), }); } return issues; } /** * People and unassigned faces in the clip ranges of an EDL, from the stored * face tracks of its assets. */ export function participantsOf( edl: Edl, tracks: Array<{ assetId: string; personId: string | null; startMs: number; endMs: number }>, ): { personIds: string[]; unassigned: Array<{ assetId: string; startMs: number; endMs: number }>; } { const personIds = new Set(); const unassigned: Array<{ assetId: string; startMs: number; endMs: number }> = []; for (const track of tracks) { const windows = clipWindows(edl.tracks.video.filter((clip) => clip.assetId === track.assetId)); if (!overlapsWindows(track, windows)) continue; if (track.personId) personIds.add(track.personId); else unassigned.push({ assetId: track.assetId, startMs: track.startMs, endMs: track.endMs }); } return { personIds: [...personIds].sort(), unassigned }; } export class ConsentRequiredError extends Error { constructor(readonly issues: GateIssue[]) { super( `export blocked: ${issues.length} participant${issues.length === 1 ? "" : "s"} without documented consent or age proof`, ); this.name = "ConsentRequiredError"; } } /** * Consent issues for exporting a render to a profile, re-read at export time * so a withdrawn consent stops a bundle that passed the gate earlier. */ export async function exportConsentIssues( deps: Pick, actor: MediaActor, input: { edl: Edl; profile: PlatformProfile }, ): Promise { const required = consentRequirements(input.profile); if (!required.consent && !required.age) return []; const assetIds = [...new Set(input.edl.tracks.video.map((clip) => clip.assetId))]; const tracks = await deps.repos.faceTrackRecords(actor.spaceId, assetIds); const { personIds, unassigned } = participantsOf(input.edl, tracks); const people = personIds.length ? await deps.repos.listPersonRecords(actor.spaceId, personIds) : []; return consentIssues(input.profile, people, unassigned, deps.now()); } /** Throws ConsentRequiredError when the export may not happen. */ export async function assertExportConsent( deps: Pick, actor: MediaActor, input: { edl: Edl; profile: PlatformProfile }, ): Promise { const issues = await exportConsentIssues(deps, actor, input); if (issues.length > 0) throw new ConsentRequiredError(issues); }