diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 705d190..b84ea47 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,8 @@ import { AccountProfileUpdateResponseSchema, AccountSettingsResponseSchema, AdminAuthenticatedUserSchema, + AdminGenerationRecordSchema, + AdminGenerationListResponseSchema, AdminOverviewResponseSchema, AdminCreditParamsSchema, AdminLoginCompleteRequestSchema, @@ -61,6 +63,10 @@ import { ModelConfigUpdateRequestSchema, ModelParamsSchema, ModelConfigUpdateHeadersSchema, + PrivateContentGenerationParamsSchema, + PrivateContentNoticeAckRequestSchema, + PrivateContentNoticeAckResponseSchema, + PrivateContentPromptResponseSchema, FailedEmptyTrashRequestSchema, FailedEmptyTrashResponseSchema, ExportFormatSchema, @@ -180,6 +186,7 @@ import type { RecentAssetService } from "./recent-assets.js"; import type { AmapAdapter } from "./amap-adapter.js"; import { ModelConfigurationError } from "./model-configuration.js"; import type { ModelConfigurationService } from "./model-configuration.js"; +import { PrivateContentError, PrivateContentService } from "./private-content.js"; const defaultBootstrap: BootstrapResponse = { app_version: "0.0.0", @@ -221,6 +228,7 @@ export interface CreateAppOptions { releaseVersion: string; resourceId: string; }) => boolean | Promise; + privateContent?: PrivateContentService; registration?: RegistrationService; } @@ -640,6 +648,13 @@ function sendBrowserUnsupported( export async function createApp(options: CreateAppOptions = {}) { const eventHub = options.eventHub ?? new EventHub(); const bootstrap = options.bootstrap ?? (() => defaultBootstrap); + const privateContent = options.privateContent ?? (options.registration + ? new PrivateContentService( + options.registration.database, + options.registration.options.currentPrivacyNoticeVersion, + options.registration.options.clock, + ) + : undefined); const browserGate = options.browserGate ?? true; const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32); const browserSupportRelease = options.browserSupportRelease; @@ -691,6 +706,12 @@ export async function createApp(options: CreateAppOptions = {}) { AdminLoginCompleteRequestSchema, AdminLoginCompleteResponseSchema, AdminSessionResponseSchema, + AdminGenerationRecordSchema, + AdminGenerationListResponseSchema, + PrivateContentNoticeAckRequestSchema, + PrivateContentNoticeAckResponseSchema, + PrivateContentPromptResponseSchema, + PrivateContentGenerationParamsSchema, AdminOverviewResponseSchema, CreditSummarySchema, CreditEntryTypeSchema, @@ -833,6 +854,167 @@ export async function createApp(options: CreateAppOptions = {}) { status: "ready", })); + const readAdminRequestSession = (request: { headers: Record }) => { + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + return token && options.registration ? options.registration.readAdminSession(token) : undefined; + }; + const privateContentNoticeRequired = (reply: FastifyReply, correlationId: string) => { + const notice = privateContent?.currentNotice(); + return reply.code(428).send(createErrorEnvelope({ + code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", + correlationId, + details: { latest_version: notice?.version ?? "" }, + })); + }; + + app.post( + "/api/v1/admin/private-content-notice/ack", + { + attachValidation: true, + schema: { + body: Type.Ref(PrivateContentNoticeAckRequestSchema), + headers: Type.Intersect([Type.Ref(CsrfHeadersSchema), Type.Ref(RegistrationCompleteHeadersSchema)]), + operationId: "ackPrivateContentNotice", + response: { + 200: Type.Ref(PrivateContentNoticeAckResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 428: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Private Content"], + }, + }, + async (request, reply) => { + if (request.validationError || !headerValue(request.headers["idempotency-key"])) { + return reply.code(400).send(createErrorEnvelope({ + code: "REGISTRATION_REQUEST_INVALID", + correlationId: request.id, + details: { field_errors: [{ field: "headers", message_key: "request.headers.invalid" }] }, + })); + } + if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const admin = options.registration.authorizeAdminMutation({ + csrfToken: headerValue(request.headers["x-csrf-token"]) ?? "", + sessionToken: token, + }); + const result = privateContent.acknowledge(admin.userId, (request.body as { expected_notice_version: string }).expected_notice_version); + return { acknowledged_at: result.acknowledgedAt, notice_version: result.noticeVersion, status: "acknowledged" as const }; + } catch (error) { + if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error); + if (error instanceof PrivateContentError && error.code === "notice_version_conflict") return privateContentNoticeRequired(reply, request.id); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + + app.get( + "/api/v1/admin/generations", + { + schema: { + operationId: "listAdminGenerations", + response: { + 200: Type.Ref(AdminGenerationListResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 428: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Private Content"], + }, + }, + async (request, reply) => { + if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const session = readAdminRequestSession(request); + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + privateContent.requireAcknowledgement(session.user_id); + return privateContent.listGenerations(); + } catch (error) { + if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + + app.get( + "/api/v1/admin/private-content/generations/:generationId/prompt", + { + attachValidation: true, + schema: { + params: Type.Ref(PrivateContentGenerationParamsSchema), + operationId: "openAdminGenerationPrompt", + response: { + 200: Type.Ref(PrivateContentPromptResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 428: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Private Content"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(404).send(null); + if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const session = readAdminRequestSession(request); + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const generationId = (request.params as { generationId: string }).generationId; + const value = privateContent.readPrompt(session.user_id, generationId); + reply.header("Cache-Control", "private, no-store"); + return { content_type: "prompt" as const, generation_id: value.generationId, prompt: value.prompt }; + } catch (error) { + if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id); + if (error instanceof PrivateContentError && error.code === "not_found") return reply.code(404).send(null); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + + app.get( + "/api/v1/admin/private-content/generations/:generationId/image", + { + attachValidation: true, + schema: { + params: Type.Ref(PrivateContentGenerationParamsSchema), + operationId: "openAdminGenerationImage", + produces: ["application/octet-stream"], + response: { + 200: Type.String({ format: "binary" }), + 401: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 428: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Private Content"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(404).send(null); + if (!privateContent || !options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const session = readAdminRequestSession(request); + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const generationId = (request.params as { generationId: string }).generationId; + const target = privateContent.readImageTarget(session.user_id, generationId); + const item = options.latestExports.getOriginal(target.ownerId, target.projectId, target.imageId); + const extension = item.mime_type === "image/jpeg" ? "jpg" : item.mime_type === "image/webp" ? "webp" : "png"; + reply.header("Cache-Control", "private, no-store"); + reply.header("Content-Disposition", `inline; filename="dada-generation.${extension}"`); + reply.type(item.mime_type); + return reply.send(createReadStream(item.path)); + } catch (error) { + if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id); + if (error instanceof PrivateContentError && error.code === "not_found") return reply.code(404).send(null); + return latestExportFailure(reply, request.id, error); + } + }, + ); + app.get( "/api/v1/assets/public/:resourceVersion/manifest", { schema: { hide: true } }, @@ -961,6 +1143,13 @@ export async function createApp(options: CreateAppOptions = {}) { }) : false; if (userSession?.userId !== resource.ownerId && !controlledAdmin) return reply.code(404).send(); + if (adminSession && controlledAdmin && privateContent) { + try { + privateContent.recordPrivateAssetAccess(adminSession.user_id, resource.ownerId, resource.resourceId); + } catch { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + } reply.type(resource.mimeType); reply.header("Cache-Control", "private, no-store"); reply.header("Content-Disposition", "inline"); @@ -1194,15 +1383,18 @@ export async function createApp(options: CreateAppOptions = {}) { if (!session) { return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); } + const acknowledgement = privateContent?.readAcknowledgement(session.user_id); + const notice = privateContent?.currentNotice(); return { - acknowledged_private_content_notice_version: null, + acknowledged_private_content_notice_version: acknowledgement?.version ?? null, admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id }, audience: "admin" as const, authenticated: true as const, csrf_token: options.registration.issueAdminCsrfToken(token!), - current_private_content_notice_version: null, + ...(notice ? { current_private_content_notice_message_key: notice.messageKey } : {}), + current_private_content_notice_version: notice?.version ?? null, expires_at: new Date(session.expires_at).toISOString(), - notice_acknowledged: false, + notice_acknowledged: notice ? acknowledgement?.version === notice.version : false, }; }, ); diff --git a/apps/api/src/private-content.ts b/apps/api/src/private-content.ts new file mode 100644 index 0000000..9d5c1dd --- /dev/null +++ b/apps/api/src/private-content.ts @@ -0,0 +1,186 @@ +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 }; + } +} diff --git a/apps/web/src/admin-generations.css b/apps/web/src/admin-generations.css new file mode 100644 index 0000000..c5f3c1f --- /dev/null +++ b/apps/web/src/admin-generations.css @@ -0,0 +1,24 @@ +.admin-generations { display: grid; gap: 20px; } +.admin-generations-refresh { align-self: start; } +.admin-generations-notice-panel { display: grid; gap: 14px; max-width: 760px; padding: 24px; border: 1px solid #d5b36a; background: #fffaf0; } +.admin-generations-notice-panel p { margin: 0; } +.admin-generations-notice-panel button { justify-self: start; } +.admin-generations-error, .admin-generations-notice { padding: 12px 16px; border: 1px solid #d46a6a; background: #fff4f4; } +.admin-generations-error button { margin-left: 12px; } +.admin-generations-table-wrap { overflow-x: auto; border: 1px solid #d9dde5; background: #fff; } +.admin-generations-table-wrap table { width: 100%; min-width: 1050px; border-collapse: collapse; } +.admin-generations-table-wrap th, .admin-generations-table-wrap td { padding: 12px 14px; border-bottom: 1px solid #e9ebef; text-align: left; vertical-align: top; } +.admin-generations-table-wrap th { background: #f5f6f8; color: #4d5664; font-size: 12px; } +.admin-generations-table-wrap small { color: #6c7481; } +.admin-generations-status { display: inline-block; padding: 3px 7px; border-radius: 4px; background: #edf0f4; } +.admin-generations-status.is-succeeded { color: #23623d; background: #e6f4ea; } +.admin-generations-status.is-failed, .admin-generations-status.is-rejected { color: #8b2b2b; background: #fff0f0; } +.admin-generations-status.is-running { color: #7a5a10; background: #fff5d8; } +.admin-generations-actions { display: grid; gap: 8px; min-width: 190px; } +.admin-generations-actions button { white-space: normal; } +.admin-generations-empty { margin: 0; padding: 28px; color: #6c7481; } +.admin-generations-opened { display: grid; gap: 10px; padding: 18px; border: 1px solid #cbd2dd; background: #fff; } +.admin-generations-opened header { display: flex; align-items: center; justify-content: space-between; } +.admin-generations-opened h3 { margin: 0; } +.admin-generations-opened pre { max-height: 360px; overflow: auto; margin: 0; padding: 14px; white-space: pre-wrap; background: #f6f7f9; } +.admin-generations-opened img { max-width: 100%; max-height: 620px; object-fit: contain; } diff --git a/apps/web/src/admin-generations.tsx b/apps/web/src/admin-generations.tsx new file mode 100644 index 0000000..4fb8753 --- /dev/null +++ b/apps/web/src/admin-generations.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState } from "react"; + +import "./admin-generations.css"; + +interface AdminSession { + acknowledged_private_content_notice_version: string | null; + current_private_content_notice_version: string | null; + csrf_token: string; + notice_acknowledged: boolean; +} + +interface GenerationRecord { + generation_id: string; + owner_ref: string; + project_id: string; + model_id: string; + ratio: string; + status: "queued" | "running" | "succeeded" | "failed" | "rejected"; + created_at: string; + completed_at: string | null; + duration_ms: number | null; + confirmed_credit_cost: number; + reserved_credits: number; + final_credit_state: "committed" | "released" | null; + error_category: string | null; +} + +interface GenerationResponse { generated_at: string; items: GenerationRecord[] } +interface OpenedPrompt { generation_id: string; prompt: string } + +function idempotencyKey() { + return `${crypto.randomUUID().replaceAll("-", "")}${crypto.randomUUID().replaceAll("-", "")}`; +} + +function compactId(value: string) { return `${value.slice(0, 8)}...${value.slice(-4)}`; } +function formatTime(value: string | null) { return value ? new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "medium" }).format(new Date(value)) : "未完成"; } +function statusLabel(value: GenerationRecord["status"]) { return { queued: "排队", running: "运行中", succeeded: "成功", failed: "失败", rejected: "已拒绝" }[value]; } + +export function AdminGenerationsPage() { + const [session, setSession] = useState(); + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [acknowledging, setAcknowledging] = useState(false); + const [notice, setNotice] = useState(""); + const [openedPrompt, setOpenedPrompt] = useState(); + const [openedImage, setOpenedImage] = useState<{ generationId: string; url: string }>(); + + async function load() { + setLoading(true); + setFailed(false); + try { + const sessionResponse = await fetch("/api/v1/admin-auth/session", { credentials: "same-origin" }); + if (sessionResponse.status === 401) throw new Error("session_invalid"); + if (!sessionResponse.ok) throw new Error("session_unavailable"); + const current = await sessionResponse.json() as AdminSession; + setSession(current); + setNotice(""); + if (!current.notice_acknowledged) { + setRecords([]); + return; + } + const listResponse = await fetch("/api/v1/admin/generations", { credentials: "same-origin" }); + if (!listResponse.ok) throw new Error("generation_list_unavailable"); + setRecords((await listResponse.json() as GenerationResponse).items); + } catch { + setFailed(true); + } finally { + setLoading(false); + } + } + + useEffect(() => { void load(); }, []); + useEffect(() => () => { if (openedImage) URL.revokeObjectURL(openedImage.url); }, [openedImage]); + + async function acknowledge() { + if (!session?.current_private_content_notice_version || acknowledging) return; + setAcknowledging(true); + setNotice(""); + try { + const response = await fetch("/api/v1/admin/private-content-notice/ack", { + body: JSON.stringify({ expected_notice_version: session.current_private_content_notice_version }), + credentials: "same-origin", + headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey(), "X-CSRF-Token": session.csrf_token }, + method: "POST", + }); + if (!response.ok) throw new Error("notice_ack_failed"); + await load(); + } catch { + setNotice("告知版本已变化或确认未完成,请重新读取。 "); + } finally { + setAcknowledging(false); + } + } + + async function openPrompt(generationId: string) { + setNotice(""); + try { + const response = await fetch(`/api/v1/admin/private-content/generations/${generationId}/prompt`, { credentials: "same-origin" }); + if (!response.ok) throw new Error("prompt_unavailable"); + setOpenedPrompt(await response.json() as OpenedPrompt); + } catch { + setNotice("内容读取未完成,访问审计未成功时不会返回内容。 "); + } + } + + async function openImage(generationId: string) { + setNotice(""); + try { + const response = await fetch(`/api/v1/admin/private-content/generations/${generationId}/image`, { credentials: "same-origin" }); + if (!response.ok) throw new Error("image_unavailable"); + const url = URL.createObjectURL(await response.blob()); + setOpenedImage((previous) => { + if (previous) URL.revokeObjectURL(previous.url); + return { generationId, url }; + }); + } catch { + setNotice("内容读取未完成,访问审计未成功时不会返回内容。 "); + } + } + + return ( +
+

OPERATIONS / GENERATION RECORDS

生成记录

+ {loading ?

正在读取生成记录

: null} + {failed ?
后台生成记录暂时无法读取。
: null} + {notice ?

{notice}

: null} + {session && !session.notice_acknowledged ? ( +
+

PRIVATE CONTENT ACCESS

+

查看私有内容前,请确认当前规则告知

+

生成记录默认只显示安全元数据。打开图片或完整提示词时,系统会自动记录本次管理员、目标和内容类型访问审计。

+ +
+ ) : null} + {session?.notice_acknowledged ? ( +
+ + {records.map((record) => + + + + + + + + )} +
任务用户标识模型 / 比例状态创建 / 完成点数私有内容
{compactId(record.generation_id)}{compactId(record.owner_ref)}{record.model_id}
{record.ratio}
{statusLabel(record.status)}{record.error_category ? {record.error_category} : null}
{formatTime(record.completed_at)}
{record.confirmed_credit_cost} / {record.final_credit_state ?? "冻结"}
+ {!records.length && !loading ?

当前无生成记录

: null} +
+ ) : null} + {openedPrompt ?

已记录审计的完整提示词

{compactId(openedPrompt.generation_id)}

{openedPrompt.prompt}
: null} + {openedImage ?

已记录审计的生成图片

已记录审计的生成图片
: null} +
+ ); +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 0cc508a..c44d56e 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,9 +1,18 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminOverviewResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; +import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminOverviewResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } +export async function ackPrivateContentNotice(body: PrivateContentNoticeAckRequest, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content-notice/ack`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -175,6 +184,13 @@ export async function getUserSession(options: ClientOptions = {}): Promise; } +export async function listAdminGenerations(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/generations`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function listProjects(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} }); @@ -196,6 +212,20 @@ export async function logoutUser(options: ClientOptions = {}): Promise; } +export async function openAdminGenerationImage(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/image`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.blob() as Promise; +} + +export async function openAdminGenerationPrompt(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/prompt`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function purgeProject(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} }); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 4387041..72e2fac 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -60,6 +60,27 @@ export type AdminCreditParams = { "userId": string; }; +export type AdminGenerationListResponse = { + "generated_at": string; + "items": Array; +}; + +export type AdminGenerationRecord = { + "completed_at": string | null; + "confirmed_credit_cost": number; + "created_at": string; + "duration_ms": number | null; + "error_category": "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable" | null; + "final_credit_state": "committed" | "released" | null; + "generation_id": string; + "model_id": string; + "owner_ref": string; + "project_id": string; + "ratio": "3:4" | "1:1" | "4:3" | "9:16"; + "reserved_credits": number; + "status": "queued" | "running" | "succeeded" | "failed" | "rejected"; +}; + export type AdminLoginCompleteRequest = { "registration_id": string; "verification_code": string; @@ -123,6 +144,7 @@ export type AdminSessionResponse = { "audience": "admin"; "authenticated": true; "csrf_token": string; + "current_private_content_notice_message_key"?: string; "current_private_content_notice_version": string | null; "expires_at": string; "notice_acknowledged": boolean; @@ -582,6 +604,26 @@ export type ModelRuntimeSseEvent = { "runtime_availability_version": number; }; +export type PrivateContentGenerationParams = { + "generationId": string; +}; + +export type PrivateContentNoticeAckRequest = { + "expected_notice_version": string; +}; + +export type PrivateContentNoticeAckResponse = { + "acknowledged_at": string; + "notice_version": string; + "status": "acknowledged"; +}; + +export type PrivateContentPromptResponse = { + "content_type": "prompt"; + "generation_id": string; + "prompt": string; +}; + export type ProjectDetailResponse = { "canvas_state": CanvasState; "created_at": string; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 544b206..b5c6226 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,6 +7,7 @@ import { UserAuthPage } from "./user-auth.js"; import { AccountSettingsPage } from "./account-settings.js"; import { AdminUsersPage } from "./admin-users.js"; import { AdminModelsPage } from "./admin-models.js"; +import { AdminGenerationsPage } from "./admin-generations.js"; import { CreditsPage } from "./credits-page.js"; import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js"; import { EditorPage } from "./editor-page.js"; @@ -41,7 +42,7 @@ function renderAuthenticationEntry() { "/admin": { content: , title: "运营总览" }, "/admin/assets": { content: , title: "素材" }, "/admin/audit": { content: , title: "审计" }, - "/admin/generations": { content: , title: "生成记录" }, + "/admin/generations": { content: , title: "生成记录" }, "/admin/invites": { content: , title: "邀请码" }, "/admin/models": { content: , title: "模型" }, "/admin/preview": { content: , title: "内部预览" }, diff --git a/openapi/openapi.json b/openapi/openapi.json index 39835dc..0fd0b54 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -300,6 +300,241 @@ ], "type": "object" }, + "AdminGenerationListResponse": { + "additionalProperties": false, + "properties": { + "generated_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "items": { + "items": { + "$ref": "#/components/schemas/AdminGenerationRecord" + }, + "maxItems": 100, + "type": "array" + } + }, + "required": [ + "generated_at", + "items" + ], + "type": "object" + }, + "AdminGenerationRecord": { + "additionalProperties": false, + "properties": { + "completed_at": { + "anyOf": [ + { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "confirmed_credit_cost": { + "minimum": 0, + "type": "integer" + }, + "created_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "duration_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "error_category": { + "anyOf": [ + { + "enum": [ + "upstream_timeout" + ], + "type": "string" + }, + { + "enum": [ + "upstream_failed" + ], + "type": "string" + }, + { + "enum": [ + "safety_rejected" + ], + "type": "string" + }, + { + "enum": [ + "model_disabled" + ], + "type": "string" + }, + { + "enum": [ + "gateway_balance_insufficient" + ], + "type": "string" + }, + { + "enum": [ + "gateway_contract_invalid" + ], + "type": "string" + }, + { + "enum": [ + "reference_invalid" + ], + "type": "string" + }, + { + "enum": [ + "unknown_retryable" + ], + "type": "string" + }, + { + "enum": [ + "unknown_non_retryable" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "final_credit_state": { + "anyOf": [ + { + "enum": [ + "committed" + ], + "type": "string" + }, + { + "enum": [ + "released" + ], + "type": "string" + }, + { + "type": "null" + } + ] + }, + "generation_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "model_id": { + "maxLength": 80, + "pattern": "^[a-z0-9][a-z0-9.-]+$", + "type": "string" + }, + "owner_ref": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "project_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "ratio": { + "anyOf": [ + { + "enum": [ + "3:4" + ], + "type": "string" + }, + { + "enum": [ + "1:1" + ], + "type": "string" + }, + { + "enum": [ + "4:3" + ], + "type": "string" + }, + { + "enum": [ + "9:16" + ], + "type": "string" + } + ] + }, + "reserved_credits": { + "minimum": 0, + "type": "integer" + }, + "status": { + "anyOf": [ + { + "enum": [ + "queued" + ], + "type": "string" + }, + { + "enum": [ + "running" + ], + "type": "string" + }, + { + "enum": [ + "succeeded" + ], + "type": "string" + }, + { + "enum": [ + "failed" + ], + "type": "string" + }, + { + "enum": [ + "rejected" + ], + "type": "string" + } + ] + } + }, + "required": [ + "generation_id", + "owner_ref", + "project_id", + "model_id", + "ratio", + "status", + "created_at", + "completed_at", + "duration_ms", + "confirmed_credit_cost", + "reserved_credits", + "final_credit_state", + "error_category" + ], + "type": "object" + }, "AdminLoginCompleteRequest": { "additionalProperties": false, "properties": { @@ -730,6 +965,11 @@ "pattern": "^[A-Za-z0-9_-]+$", "type": "string" }, + "current_private_content_notice_message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + }, "current_private_content_notice_version": { "anyOf": [ { @@ -3625,6 +3865,87 @@ ], "type": "object" }, + "PrivateContentGenerationParams": { + "additionalProperties": false, + "properties": { + "generationId": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + } + }, + "required": [ + "generationId" + ], + "type": "object" + }, + "PrivateContentNoticeAckRequest": { + "additionalProperties": false, + "properties": { + "expected_notice_version": { + "maxLength": 80, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + }, + "required": [ + "expected_notice_version" + ], + "type": "object" + }, + "PrivateContentNoticeAckResponse": { + "additionalProperties": false, + "properties": { + "acknowledged_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$", + "type": "string" + }, + "notice_version": { + "maxLength": 80, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + }, + "status": { + "enum": [ + "acknowledged" + ], + "type": "string" + } + }, + "required": [ + "notice_version", + "acknowledged_at", + "status" + ], + "type": "object" + }, + "PrivateContentPromptResponse": { + "additionalProperties": false, + "properties": { + "content_type": { + "enum": [ + "prompt" + ], + "type": "string" + }, + "generation_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "prompt": { + "maxLength": 4000, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "generation_id", + "content_type", + "prompt" + ], + "type": "object" + }, "ProjectDetailResponse": { "additionalProperties": false, "properties": { @@ -5287,6 +5608,56 @@ ] } }, + "/api/v1/admin/generations": { + "get": { + "operationId": "listAdminGenerations", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminGenerationListResponse" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Admin Private Content" + ] + } + }, "/api/v1/admin/models/configuration": { "put": { "operationId": "replaceModelConfiguration", @@ -8331,6 +8702,239 @@ ] } }, + "/api/v1/admin/private-content-notice/ack": { + "post": { + "operationId": "ackPrivateContentNotice", + "parameters": [ + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivateContentNoticeAckRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivateContentNoticeAckResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Admin Private Content" + ] + } + }, + "/api/v1/admin/private-content/generations/{generationId}/image": { + "get": { + "operationId": "openAdminGenerationImage", + "parameters": [ + { + "in": "path", + "name": "generationId", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "404": { + "description": "Default Response" + }, + "428": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Admin Private Content" + ] + } + }, + "/api/v1/admin/private-content/generations/{generationId}/prompt": { + "get": { + "operationId": "openAdminGenerationPrompt", + "parameters": [ + { + "in": "path", + "name": "generationId", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrivateContentPromptResponse" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "404": { + "description": "Default Response" + }, + "428": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Admin Private Content" + ] + } + }, "/api/v1/admin/users/{userId}/credit-adjustments": { "post": { "operationId": "adjustAdminUserCredits", diff --git a/packages/shared-contracts/src/admin.ts b/packages/shared-contracts/src/admin.ts index 0f73546..54a571d 100644 --- a/packages/shared-contracts/src/admin.ts +++ b/packages/shared-contracts/src/admin.ts @@ -3,6 +3,72 @@ import { Type, type Static } from "@sinclair/typebox"; const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$"; const modelIdPattern = "^[a-z0-9][a-z0-9.-]+$"; const safeReferencePattern = "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$"; +const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"; + +export const AdminGenerationRecordSchema = Type.Object( + { + generation_id: Type.String({ pattern: uuidPattern }), + owner_ref: Type.String({ pattern: uuidPattern }), + project_id: Type.String({ pattern: uuidPattern }), + model_id: Type.String({ maxLength: 80, pattern: modelIdPattern }), + ratio: Type.Union([Type.Literal("3:4"), Type.Literal("1:1"), Type.Literal("4:3"), Type.Literal("9:16")]), + status: Type.Union([Type.Literal("queued"), Type.Literal("running"), Type.Literal("succeeded"), Type.Literal("failed"), Type.Literal("rejected")]), + created_at: Type.String({ pattern: isoTimestampPattern }), + completed_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]), + duration_ms: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]), + confirmed_credit_cost: Type.Integer({ minimum: 0 }), + reserved_credits: Type.Integer({ minimum: 0 }), + final_credit_state: Type.Union([Type.Literal("committed"), Type.Literal("released"), Type.Null()]), + error_category: Type.Union([ + Type.Literal("upstream_timeout"), Type.Literal("upstream_failed"), Type.Literal("safety_rejected"), + Type.Literal("model_disabled"), Type.Literal("gateway_balance_insufficient"), Type.Literal("gateway_contract_invalid"), + Type.Literal("reference_invalid"), Type.Literal("unknown_retryable"), Type.Literal("unknown_non_retryable"), Type.Null(), + ]), + }, + { additionalProperties: false, $id: "AdminGenerationRecord" }, +); + +export const AdminGenerationListResponseSchema = Type.Object( + { + generated_at: Type.String({ pattern: isoTimestampPattern }), + items: Type.Array(Type.Ref(AdminGenerationRecordSchema), { maxItems: 100 }), + }, + { additionalProperties: false, $id: "AdminGenerationListResponse" }, +); + +export const PrivateContentNoticeAckRequestSchema = Type.Object( + { expected_notice_version: Type.String({ minLength: 1, maxLength: 80, pattern: "^[A-Za-z0-9_.:-]+$" }) }, + { additionalProperties: false, $id: "PrivateContentNoticeAckRequest" }, +); + +export const PrivateContentNoticeAckResponseSchema = Type.Object( + { + notice_version: Type.String({ minLength: 1, maxLength: 80, pattern: "^[A-Za-z0-9_.:-]+$" }), + acknowledged_at: Type.String({ pattern: isoTimestampPattern }), + status: Type.Literal("acknowledged"), + }, + { additionalProperties: false, $id: "PrivateContentNoticeAckResponse" }, +); + +export const PrivateContentPromptResponseSchema = Type.Object( + { + generation_id: Type.String({ pattern: uuidPattern }), + content_type: Type.Literal("prompt"), + prompt: Type.String({ minLength: 1, maxLength: 4000 }), + }, + { additionalProperties: false, $id: "PrivateContentPromptResponse" }, +); + +export const PrivateContentGenerationParamsSchema = Type.Object( + { generationId: Type.String({ pattern: uuidPattern }) }, + { additionalProperties: false, $id: "PrivateContentGenerationParams" }, +); + +export type AdminGenerationRecord = Static; +export type AdminGenerationListResponse = Static; +export type PrivateContentNoticeAckRequest = Static; +export type PrivateContentNoticeAckResponse = Static; +export type PrivateContentPromptResponse = Static; export const AdminOverviewResponseSchema = Type.Object( { diff --git a/packages/shared-contracts/src/auth.ts b/packages/shared-contracts/src/auth.ts index 4bfb1b6..fe71dfb 100644 --- a/packages/shared-contracts/src/auth.ts +++ b/packages/shared-contracts/src/auth.ts @@ -149,6 +149,7 @@ export const AdminSessionResponseSchema = Type.Object( audience: Type.Literal("admin"), authenticated: Type.Literal(true), csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }), + current_private_content_notice_message_key: Type.Optional(Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.-]+$" })), current_private_content_notice_version: Type.Union([Type.String(), Type.Null()]), expires_at: Type.String({ pattern: isoTimestampPattern }), notice_acknowledged: Type.Boolean(), diff --git a/tests/api/wp6-02-private-content.test.ts b/tests/api/wp6-02-private-content.test.ts new file mode 100644 index 0000000..df21cbc --- /dev/null +++ b/tests/api/wp6-02-private-content.test.ts @@ -0,0 +1,105 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; + +const roots: string[] = []; +const services: RegistrationService[] = []; +const now = Date.parse("2026-08-04T09:30:00.000Z"); +const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; + +function harness() { + const root = mkdtempSync(join(tmpdir(), "dada-wp6-02-api-")); + roots.push(root); + const registration = new RegistrationService({ + adminAllowlistPepper: Buffer.alloc(32, 0xc1), + challengePepper: Buffer.alloc(32, 0xc2), + clock: () => now, + currentPrivacyNoticeVersion: "p0a-private-content-v1", + databasePath: join(root, "dada.sqlite3"), + invitePepper: Buffer.alloc(32, 0xc3), + resend: new MockResendAdapter(), + sessionPepper: Buffer.alloc(32, 0xc4), + }); + services.push(registration); + const adminId = randomUUID(); + const ownerId = randomUUID(); + const projectId = randomUUID(); + const generationId = randomUUID(); + registration.database.prepare(` + INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at) + VALUES (?, ?, 'super_admin', 'active', 0, ?, ?), (?, ?, 'user', 'active', 1, ?, ?) + `).run(adminId, `admin-${adminId}@example.invalid`, randomUUID(), now, ownerId, `user-${ownerId}@example.invalid`, randomUUID(), now); + registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId); + registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Admin', '@admin')").run(adminId); + registration.database.exec(` + CREATE TABLE generation_jobs ( + generation_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, project_id TEXT NOT NULL, + prompt TEXT NOT NULL, ratio TEXT NOT NULL, status TEXT NOT NULL, model_id TEXT NOT NULL, + model_config_version INTEGER NOT NULL, confirmed_credit_cost INTEGER NOT NULL, + reserved_credits INTEGER NOT NULL, final_credit_state TEXT, error_category TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, submission_ready INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE project_images (image_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, generation_id TEXT NOT NULL, created_at INTEGER NOT NULL); + `); + registration.database.prepare(` + INSERT INTO generation_jobs ( + generation_id, owner_id, project_id, prompt, ratio, status, model_id, model_config_version, + confirmed_credit_cost, reserved_credits, final_credit_state, error_category, created_at, updated_at, submission_ready + ) VALUES (?, ?, ?, ?, '1:1', 'succeeded', 'demo.model', 1, 2, 0, 'committed', NULL, ?, ?, 1) + `).run(generationId, ownerId, projectId, "secret prompt must never be listed", now - 1000, now); + return { registration, adminId, generationId, adminSession: registration.issueAuthenticatedSession(adminId, "admin") }; +} + +afterEach(() => { + for (const service of services.splice(0)) service.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TDD-WP6-PRIV-001/TDD-WP6-PRIV-002", () => { + it("persists notice acknowledgement, returns metadata only, and audits every prompt open", async () => { + const fixture = harness(); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration: fixture.registration }); + const cookie = `dada_admin_session=${fixture.adminSession.sessionToken}`; + const denied = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/admin/generations" }); + expect(denied.statusCode).toBe(428); + expect(denied.json()).toMatchObject({ error: { code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", details: { latest_version: "p0a-private-content-v1" } } }); + expect(JSON.stringify(denied.json())).not.toContain("secret prompt"); + + const csrf = fixture.registration.issueAdminCsrfToken(fixture.adminSession.sessionToken); + const ack = await app.inject({ + headers: { ...headers, cookie, "x-csrf-token": csrf, "idempotency-key": "wp6-02-ack-000000000000000000000000000000" }, + method: "POST", payload: { expected_notice_version: "p0a-private-content-v1" }, url: "/api/v1/admin/private-content-notice/ack", + }); + expect(ack.statusCode).toBe(200); + expect(ack.json()).toMatchObject({ notice_version: "p0a-private-content-v1", status: "acknowledged" }); + + const stale = await app.inject({ + headers: { ...headers, cookie, "x-csrf-token": fixture.registration.issueAdminCsrfToken(fixture.adminSession.sessionToken), "idempotency-key": "wp6-02-ack-stale-000000000000000000000000000" }, + method: "POST", payload: { expected_notice_version: "old-private-content-v0" }, url: "/api/v1/admin/private-content-notice/ack", + }); + expect(stale.statusCode).toBe(428); + expect(stale.json()).toMatchObject({ error: { code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", details: { latest_version: "p0a-private-content-v1" } } }); + + const list = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/admin/generations" }); + expect(list.statusCode).toBe(200); + expect(list.json().items[0]).toMatchObject({ generation_id: fixture.generationId, owner_ref: expect.any(String), status: "succeeded" }); + expect(JSON.stringify(list.json())).not.toContain("secret prompt"); + + const opened = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: `/api/v1/admin/private-content/generations/${fixture.generationId}/prompt` }); + expect(opened.statusCode).toBe(200); + expect(opened.json()).toEqual({ content_type: "prompt", generation_id: fixture.generationId, prompt: "secret prompt must never be listed" }); + expect((fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get() as { count: number }).count).toBe(1); + + const reopened = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: `/api/v1/admin/private-content/generations/${fixture.generationId}/prompt` }); + expect(reopened.statusCode).toBe(200); + expect((fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get() as { count: number }).count).toBe(2); + await app.close(); + }); +});