import { randomUUID } from "node:crypto"; import type BetterSqlite3 from "better-sqlite3"; import { auditRetentionMilliseconds } from "./audit-policy.js"; type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected"; export class PrivateContentError extends Error { constructor(readonly code: "notice_required" | "notice_version_conflict" | "not_found") { super(code); this.name = "PrivateContentError"; } } function iso(value: number) { return new Date(value).toISOString(); } function isGenerationTablePresent(database: BetterSqlite3.Database) { return Boolean(database.prepare( "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'generation_jobs'", ).get()); } export class PrivateContentService { constructor( readonly database: BetterSqlite3.Database, readonly currentNoticeVersion: string, private readonly clock: () => number = Date.now, ) {} currentNotice() { return { version: this.currentNoticeVersion, messageKey: "admin.private_content.notice", } as const; } readAcknowledgement(adminUserId: string) { const row = this.database.prepare(` SELECT private_content_notice_version, private_content_notice_acknowledged_at FROM user_profiles WHERE user_id = ? `).get(adminUserId) as { private_content_notice_version: string | null; private_content_notice_acknowledged_at: number | null } | undefined; return { version: row?.private_content_notice_version ?? null, acknowledgedAt: row?.private_content_notice_acknowledged_at === null || row?.private_content_notice_acknowledged_at === undefined ? null : iso(row.private_content_notice_acknowledged_at), }; } isAcknowledged(adminUserId: string) { return this.readAcknowledgement(adminUserId).version === this.currentNoticeVersion; } requireAcknowledgement(adminUserId: string) { if (!this.isAcknowledged(adminUserId)) throw new PrivateContentError("notice_required"); } acknowledge(adminUserId: string, expectedNoticeVersion: string) { const now = this.clock(); return this.database.transaction(() => { if (expectedNoticeVersion !== this.currentNoticeVersion) { throw new PrivateContentError("notice_version_conflict"); } this.database.prepare(` INSERT INTO user_profiles ( user_id, creator_name, social_id, private_content_notice_version, private_content_notice_acknowledged_at ) VALUES (?, '', '', ?, ?) ON CONFLICT(user_id) DO UPDATE SET private_content_notice_version = excluded.private_content_notice_version, private_content_notice_acknowledged_at = CASE WHEN user_profiles.private_content_notice_version = excluded.private_content_notice_version THEN user_profiles.private_content_notice_acknowledged_at ELSE excluded.private_content_notice_acknowledged_at END `).run(adminUserId, this.currentNoticeVersion, now); const acknowledged = this.readAcknowledgement(adminUserId); return { noticeVersion: this.currentNoticeVersion, acknowledgedAt: acknowledged.acknowledgedAt ?? iso(now), }; })(); } listGenerations() { const generatedAt = iso(this.clock()); if (!isGenerationTablePresent(this.database)) return { generated_at: generatedAt, items: [] }; const rows = this.database.prepare(` SELECT generation_id, owner_id, project_id, model_id, ratio, status, confirmed_credit_cost, reserved_credits, final_credit_state, error_category, created_at, updated_at FROM generation_jobs WHERE submission_ready = 1 ORDER BY created_at DESC, generation_id DESC LIMIT 100 `).all() as Array<{ generation_id: string; owner_id: string; project_id: string; model_id: string; ratio: "3:4" | "1:1" | "4:3" | "9:16"; status: GenerationStatus; confirmed_credit_cost: number; reserved_credits: number; final_credit_state: "committed" | "released" | null; error_category: string | null; created_at: number; updated_at: number; }>; return { generated_at: generatedAt, items: rows.map((row) => { const terminal = row.status === "succeeded" || row.status === "failed" || row.status === "rejected"; return { generation_id: row.generation_id, owner_ref: row.owner_id, project_id: row.project_id, model_id: row.model_id, ratio: row.ratio, status: row.status, created_at: iso(row.created_at), completed_at: terminal ? iso(row.updated_at) : null, duration_ms: terminal ? Math.max(0, row.updated_at - row.created_at) : null, confirmed_credit_cost: row.confirmed_credit_cost, reserved_credits: row.reserved_credits, final_credit_state: row.final_credit_state, error_category: row.error_category, }; }), }; } private generation(generationId: string) { if (!isGenerationTablePresent(this.database)) throw new PrivateContentError("not_found"); const row = this.database.prepare(` SELECT generation_id, owner_id, project_id FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1 `).get(generationId) as { generation_id: string; owner_id: string; project_id: string } | undefined; if (!row) throw new PrivateContentError("not_found"); return row; } private recordAccess(input: { adminUserId: string; ownerId: string; generationId: string; contentType: "image" | "prompt" }) { const now = this.clock(); // The insert is committed before the caller reads the private value. A failed // constraint therefore cannot accidentally release a private response. this.database.transaction(() => { this.database.prepare(` INSERT INTO private_content_access_logs ( log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( randomUUID(), input.adminUserId, input.ownerId, input.generationId, input.contentType, now, now + auditRetentionMilliseconds, ); })(); } recordPrivateAssetAccess(adminUserId: string, ownerId: string, resourceId: string) { this.recordAccess({ adminUserId, ownerId, generationId: resourceId, contentType: "image" }); } readPrompt(adminUserId: string, generationId: string) { this.requireAcknowledgement(adminUserId); const row = this.generation(generationId); this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "prompt" }); const content = this.database.prepare( "SELECT prompt FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1", ).get(row.generation_id) as { prompt: string } | undefined; if (!content) throw new PrivateContentError("not_found"); return { generationId: row.generation_id, prompt: content.prompt }; } readImageTarget(adminUserId: string, generationId: string) { this.requireAcknowledgement(adminUserId); const row = this.database.prepare(` SELECT g.generation_id, g.owner_id, g.project_id, pi.image_id FROM generation_jobs g JOIN project_images pi ON pi.project_id = g.project_id AND pi.generation_id = g.generation_id WHERE g.generation_id = ? AND g.status = 'succeeded' ORDER BY pi.created_at DESC LIMIT 1 `).get(generationId) as { generation_id: string; owner_id: string; project_id: string; image_id: string } | undefined; if (!row) throw new PrivateContentError("not_found"); this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "image" }); return { projectId: row.project_id, imageId: row.image_id, ownerId: row.owner_id }; } }