diff --git a/DEVELOPMENT_CONVENTIONS.md b/DEVELOPMENT_CONVENTIONS.md new file mode 100644 index 0000000..fecd001 --- /dev/null +++ b/DEVELOPMENT_CONVENTIONS.md @@ -0,0 +1,13 @@ +# Dada 开发约定 + +## 适用范围 + +本约定自当前“Dada P0-A Windows 单机内测版”第一版开发起立即生效,适用于任务计划中的每个 `TASK-*`,也适用于第一版完成后的问题修改和相关工单。 + +## 开发约定 + +- 每个 `TASK-*` 或后续工单都是独立交付单元。完成测试与回归后,必须形成范围明确的本地 Git 提交,推送到 `intelligrow` 组织仓库的同名 Gitea 远端分支,并记录任务号、完整提交 SHA、远端分支和远端哈希核对结果。不得等第一版全部开发完成后再统一提交;本地未提交、推送失败、仅推送个人命名空间或远端哈希不一致时,不得宣告当前任务完成,也不得开始下一任务。 +- 修复任何工单必须在独立的 git worktree 中进行;开工前先用 `git worktree add` 创建专属工作目录,避免污染主工作区、便于多工单并行。修 CI 配置(Dockerfile、Drone 流水线等)可以直接在主仓库改,因为 CI 改动要打 tag 才能触发构建。 +- 处理任何工单必须先检查并使用适用的 Superpowers Skill;在分析、提问、制定计划或改代码前,至少先启用 `using-superpowers`,并按任务性质继续使用 `systematic-debugging`、`test-driven-development`、`using-git-worktrees`、`verification-before-completion` 等相关技能。若判断没有适用技能,必须简短说明原因后再继续。 +- 新功能需求类工单必须先使用 Superpowers 的 `brainstorming` 技能帮助澄清目标、约束和方案,再进入计划或实现;缺陷类工单必须先使用 `systematic-debugging` 技能复现问题并分析 root cause,再开始修复,禁止在根因未明确时直接改代码。 +- 实现或修复工单完成后,必须继续按 Superpowers 收尾流程执行验证、代码审查、PR/合并准备和工作区清理;通常应依次使用 `verification-before-completion`、`requesting-code-review`、`finishing-a-development-branch` 等适用技能,在完成这些流程前不得声称工单已结束。 diff --git a/DevelopmentPlan.md b/DevelopmentPlan.md index 4213511..c3b8bc3 100644 --- a/DevelopmentPlan.md +++ b/DevelopmentPlan.md @@ -2,7 +2,7 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 1.1 | +| 文档版本 | 1.2 | | 适用产品版本 | PRD v1.10,P0-A 本机测试需求冻结稿 | | 功能索引版本 | FeatureSummary 1.1 | | 当前交付 | 仅 P0-A Windows 本机测试版 | @@ -55,6 +55,12 @@ P1 的本地底图上传、空白画布、普通贴纸关键词搜索、公开 Gitea Actions 是已确定的工程交付组件,不是 PRD 产品依赖,也不计入上述三项外部发布阻塞。当前电脑的 Windows 自托管 Runner 在隔离工作目录注册并跑通无真实凭据流水线之前,本地工程初始化和测试脚本可继续,但正式 CI 和 WP-0 工程完成不得宣告。 +### 1.6 任务级 Git/Gitea 交付协议 + +`tasks.md` 中每个 `TASK-*` 是最小工程交付单元。每个任务在开始实现前建立独立的 `codex/` 分支;完成 Red、Green、所属门禁、回归和脱敏扫描后,必须形成只包含该任务及其必要文档同步的本地 Git 提交,并推送到项目指定的 Gitea 组织仓库同名远端分支。当前项目的目标组织命名空间固定为 `intelligrow`,个人命名空间中的仓库或分支不作为任务交付位置。 + +任务交付必须记录 `task_id`、完整提交 SHA、Gitea 仓库、远端分支和远端哈希核对结果;记录不得包含凭据。推送后必须通过远端引用确认 Gitea 分支哈希与本地提交完全一致。只有本地测试 Green、必要真实/人工门禁齐全且远端核对通过,任务才可从 `green` 或 `externally_blocked` 进入 `passed`。仅有工作区改动、本地提交、推送失败、推送到个人命名空间或远端哈希不一致时,均不得宣告任务完成、工作包完成,也不得开始下一个 `TASK-*`。 + ## 2. 技术架构与选型 ### 2.1 固定技术基线 diff --git a/RELEASE.json b/RELEASE.json new file mode 100644 index 0000000..0116eee --- /dev/null +++ b/RELEASE.json @@ -0,0 +1,29 @@ +{ + "appVersion": "0.0.0", + "browsers": [ + { + "brand": "Google Chrome", + "fullVersion": "150.0.7871.187" + }, + { + "brand": "Microsoft Edge", + "fullVersion": "151.0.4129.59" + } + ], + "buildCommit": "08f3cccae4a1e75e2f2292eef14611313523916d", + "deferredExternalTasks": [ + "TASK-WP7-03", + "TASK-WP7-04" + ], + "finalRelease": true, + "fixedPort": 43121, + "frozenFromCommit": "08e9c39e49d68f8642d5acfe22b0fdb40a3a08fa", + "recordedAt": "2026-08-04T15:20:54.271Z", + "releaseStatus": "first_version_internal", + "schemaVersion": "1.0", + "windows": { + "arch": "x64", + "build": "26200.8875", + "displayVersion": "25H2" + } +} diff --git a/UIDesign.md b/UIDesign.md index 2ce7050..6dee058 100644 --- a/UIDesign.md +++ b/UIDesign.md @@ -2,13 +2,13 @@ | 文档项 | 内容 | | --- | --- | -| 文档版本 | 1.0 | +| 文档版本 | 1.1 | | 产品范围 | P0-A Windows 单机版 | | 设计状态 | 低保真交互与界面规范 | | 主要画布 | Windows 当前稳定版 Chrome / Edge,1920 × 1080 优先验收 | | 产品需求权威 | PRD.md v1.10 | | 功能与状态导航 | FeatureSummary.md 1.1 | -| 工程与接口约束 | DevelopmentPlan.md 1.1 | +| 工程与接口约束 | DevelopmentPlan.md 1.2 | | 本阶段产出 | 页面、布局、组件、状态、文案、焦点顺序和可访问性规范 | | 本阶段不产出 | 高保真视觉稿、前端代码、移动端、P0-B、部署人员 CLI | @@ -18,7 +18,7 @@ | --- | --- | | PRD.md | 31F93674DF1A90B557FEE3AA9E74FB084E246CA8FE09F6BD4B1DC9D56D606565 | | FeatureSummary.md | 6F80E272AAB08A5525B54501D83F16A4F6A7A170596947BBC25F1DA54F2FE844 | -| DevelopmentPlan.md | 7C579E9DF8D00DA9333F587D05FC966123437EE68575D87D73C903C1487FA9D6 | +| DevelopmentPlan.md | 76CCC786E910F3E503921AEF5B9BD22976E364184BA8C0062CDCF1C5F376AC0A | ## 文档使用规则 diff --git a/apps/api/package.json b/apps/api/package.json index 208b01b..7a67db5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,12 +9,16 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@dada/asset-release-manifest": "workspace:*", "@dada/shared-contracts": "workspace:*", + "@dada/static-sticker-catalog": "workspace:*", + "@fastify/multipart": "10.1.0", "@fastify/swagger": "9.8.1", "@sinclair/typebox": "0.34.52", "better-sqlite3": "13.0.1", "drizzle-orm": "0.45.2", - "fastify": "5.10.0" + "fastify": "5.10.0", + "sharp": "0.35.3" }, "devDependencies": { "@types/better-sqlite3": "7.6.13", diff --git a/apps/api/src/admin-audit.ts b/apps/api/src/admin-audit.ts new file mode 100644 index 0000000..1d05e22 --- /dev/null +++ b/apps/api/src/admin-audit.ts @@ -0,0 +1,148 @@ +import type BetterSqlite3 from "better-sqlite3"; + +import type { + AdminAuditQuery, + AdminOperationAuditItem, + AdminOperationAuditResponse, + PrivateContentAccessAuditItem, + PrivateContentAccessAuditResponse, +} from "@dada/shared-contracts"; + +interface AuditCursor { + logId: string; + occurredAt: number; +} + +interface AdminOperationRow { + actor_ref: string; + actor_type: "system" | "super_admin"; + after_summary: string | null; + before_summary: string | null; + expires_at: number; + log_id: string; + occurred_at: number; + operation_type: string; + result: "failed" | "succeeded"; + target_ref: string; + target_type: string; +} + +interface PrivateContentAccessRow { + actor_ref: string; + content_type: "image" | "prompt"; + expires_at: number; + log_id: string; + occurred_at: number; + target_ref: string; +} + +export class AdminAuditQueryError extends Error { + constructor() { + super("admin_audit_query_invalid"); + this.name = "AdminAuditQueryError"; + } +} + +function encodeCursor(row: { log_id: string; occurred_at: number }) { + return Buffer.from(JSON.stringify([row.occurred_at, row.log_id]), "utf8").toString("base64url"); +} + +function decodeCursor(cursor: string | undefined): AuditCursor | undefined { + if (!cursor) return undefined; + try { + const parsed: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + if (!Array.isArray(parsed) || parsed.length !== 2 || !Number.isSafeInteger(parsed[0]) + || typeof parsed[1] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(parsed[1])) { + throw new AdminAuditQueryError(); + } + return { occurredAt: parsed[0] as number, logId: parsed[1] }; + } catch (error) { + if (error instanceof AdminAuditQueryError) throw error; + throw new AdminAuditQueryError(); + } +} + +function normalizeLimit(limit: number | undefined) { + if (limit === undefined) return 50; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new AdminAuditQueryError(); + return limit; +} + +function pageRows(rows: Row[], limit: number) { + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + return { items, nextCursor: hasMore ? encodeCursor(items[items.length - 1]!) : null }; +} + +function iso(value: number) { + return new Date(value).toISOString(); +} + +export function listAdminOperationAudit( + database: BetterSqlite3.Database, + query: AdminAuditQuery, + clock: () => number = Date.now, +): AdminOperationAuditResponse { + const cursor = decodeCursor(query.cursor); + const limit = normalizeLimit(query.limit); + const rows = (cursor + ? database.prepare(` + SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id, + occurred_at, operation_type, result, target_ref, target_type + FROM admin_operation_logs + WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?) + ORDER BY occurred_at DESC, log_id DESC LIMIT ? + `).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1) + : database.prepare(` + SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id, + occurred_at, operation_type, result, target_ref, target_type + FROM admin_operation_logs + ORDER BY occurred_at DESC, log_id DESC LIMIT ? + `).all(limit + 1)) as AdminOperationRow[]; + const page = pageRows(rows, limit); + const items: AdminOperationAuditItem[] = page.items.map((row) => ({ + actor_ref: row.actor_ref, + actor_type: row.actor_type, + after_summary: row.after_summary, + before_summary: row.before_summary, + expires_at: iso(row.expires_at), + log_id: row.log_id, + occurred_at: iso(row.occurred_at), + operation_type: row.operation_type, + result: row.result, + target_ref: row.target_ref, + target_type: row.target_type, + })); + return { generated_at: iso(clock()), items, next_cursor: page.nextCursor }; +} + +export function listPrivateContentAccessAudit( + database: BetterSqlite3.Database, + query: AdminAuditQuery, + clock: () => number = Date.now, +): PrivateContentAccessAuditResponse { + const cursor = decodeCursor(query.cursor); + const limit = normalizeLimit(query.limit); + const rows = (cursor + ? database.prepare(` + SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref + FROM private_content_access_logs + WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?) + ORDER BY occurred_at DESC, log_id DESC LIMIT ? + `).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1) + : database.prepare(` + SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref + FROM private_content_access_logs + ORDER BY occurred_at DESC, log_id DESC LIMIT ? + `).all(limit + 1)) as PrivateContentAccessRow[]; + const page = pageRows(rows, limit); + const items: PrivateContentAccessAuditItem[] = page.items.map((row) => ({ + actor_ref: row.actor_ref, + content_type: row.content_type, + expires_at: iso(row.expires_at), + log_id: row.log_id, + occurred_at: iso(row.occurred_at), + target_ref: row.target_ref, + })); + return { generated_at: iso(clock()), items, next_cursor: page.nextCursor }; +} diff --git a/apps/api/src/admin-state.ts b/apps/api/src/admin-state.ts new file mode 100644 index 0000000..e3a1f92 --- /dev/null +++ b/apps/api/src/admin-state.ts @@ -0,0 +1,194 @@ +import type BetterSqlite3 from "better-sqlite3"; + +import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts"; + +import type { BrowserSupportRelease } from "./browser-support.js"; +import type { ManagedStorage } from "./managed-storage.js"; +import type { ModelConfigurationService } from "./model-configuration.js"; + +type AdminService = AdminServicesStorageResponse["services"][number]; +const adminServiceIds = ["resend", "amap", "ai_gateway", "worker", "api", "asset_root"] as const; +const forbiddenDiagnosticPatterns = [ + /\b(?:api[_ -]?key|secret|password|credential|authorization|bearer|session[_ -]?token|cookie|prompt|email|token)\b/i, + /[A-Z]:[\\/](?:Users|Documents|ProgramData|Windows)[\\/]/i, + /\\\\[^\\\s]+\\[^\s]+/, + /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i, + /https?:\/\//i, +]; +const safePauseReasons = new Set([ + "asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked", + "contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed", + "model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown", + "worker_degraded", "worker_state_missing", "worker_stopped", +]); + +function iso(value: number | string | null | undefined) { + if (value === null || value === undefined) return null; + return typeof value === "number" ? new Date(value).toISOString() : value; +} + +function tableExists(database: BetterSqlite3.Database, table: string) { + return Boolean(database.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)); +} + +function safeService( + service_id: AdminService["service_id"], + status: AdminService["status"], + impact_scope: AdminService["impact_scope"], + configured: boolean, + checked_at: string | null, + pause_reason: string | null = null, +): AdminService { + return { checked_at, configured, impact_scope, pause_reason, service_id, status }; +} + +function safeReason(value: unknown) { + return typeof value === "string" && safePauseReasons.has(value) ? value : null; +} + +function serviceUsage(database: BetterSqlite3.Database, serviceName: string) { + if (!tableExists(database, "external_service_usage")) return undefined; + const columns = new Set((database.prepare("PRAGMA table_info(external_service_usage)").all() as Array<{ name: string }>).map((column) => column.name)); + const serviceColumn = columns.has("service_name") ? "service_name" : columns.has("service_id") ? "service_id" : undefined; + if (!serviceColumn || !columns.has("service_status")) return undefined; + const row = database.prepare(`SELECT service_status, ${columns.has("checked_at") ? "checked_at" : "NULL AS checked_at"}, ${columns.has("pause_reason") ? "pause_reason" : "NULL AS pause_reason"} FROM external_service_usage WHERE ${serviceColumn} = ? ORDER BY rowid DESC LIMIT 1`).get(serviceName) as { service_status: string; checked_at: number | string | null; pause_reason: string | null } | undefined; + if (!row) return undefined; + const status = new Set(["active", "paused_quota", "paused_provider", "disabled"]).has(row.service_status as AdminService["status"]) + ? row.service_status as AdminService["status"] + : "degraded"; + return { status, checked_at: iso(row.checked_at), pause_reason: safeReason(row.pause_reason) }; +} + +function workerStatus(database: BetterSqlite3.Database) { + if (!tableExists(database, "worker_runtime_state")) return { status: "unavailable" as const, checked_at: null, pause_reason: "worker_state_missing" }; + const row = database.prepare("SELECT status, reason, updated_at FROM worker_runtime_state WHERE singleton = 1").get() as { status: string; reason: string | null; updated_at: number | string | null } | undefined; + if (!row) return { status: "unavailable" as const, checked_at: null, pause_reason: "worker_state_missing" }; + return { + status: row.status === "ready" ? "active" as const : row.status === "degraded" ? "degraded" as const : "unavailable" as const, + checked_at: iso(row.updated_at), + pause_reason: safeReason(row.reason), + }; +} + +export function createAdminServicesStorageProvider(input: { + database: BetterSqlite3.Database; + models?: ModelConfigurationService; + storage?: ManagedStorage; + assetRoot?: Pick; + clock?: () => number; +}): () => AdminServicesStorageResponse { + const clock = input.clock ?? Date.now; + return () => { + const generatedAt = new Date(clock()).toISOString(); + const resend = serviceUsage(input.database, "resend"); + const amap = serviceUsage(input.database, "amap"); + const worker = workerStatus(input.database); + const modelRuntime = input.models?.read().models ?? []; + const unavailableModels = modelRuntime.filter((model) => !model.runtime_availability.available_for_new_jobs); + const gatewayReason = unavailableModels[0]?.runtime_availability.reason ?? null; + const storageState = input.storage?.getState(); + const cleanupPendingCount = tableExists(input.database, "file_cleanup_queue") + ? (input.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status IN ('pending', 'failed')").get() as { count: number }).count + : 0; + const services: AdminService[] = [ + safeService("resend", resend?.status ?? "unavailable", "authentication", Boolean(resend), resend?.checked_at ?? null, resend?.pause_reason ?? "service_state_missing"), + safeService("amap", amap?.status ?? "unavailable", "location", Boolean(amap), amap?.checked_at ?? null, amap?.pause_reason ?? "service_state_missing"), + safeService("ai_gateway", unavailableModels.length > 0 ? "degraded" : modelRuntime.length > 0 ? "active" : "unavailable", "generation", modelRuntime.length > 0, generatedAt, safeReason(gatewayReason)), + safeService("worker", worker.status, "generation", worker.status !== "unavailable", worker.checked_at, worker.pause_reason), + safeService("api", "active", "api", true, generatedAt), + safeService( + "asset_root", + input.assetRoot?.status ?? "unavailable", + "storage", + input.assetRoot?.configured ?? false, + input.assetRoot?.checked_at ?? null, + input.assetRoot?.pause_reason ?? "asset_root_state_missing", + ), + ]; + return assertSafeAdminServicesStorage({ + generated_at: generatedAt, + services, + storage: { + capacity_notice_level: storageState?.capacity_notice_level ?? "normal", + cleanup_pending_count: cleanupPendingCount, + data_root_ref: "configured_local_data_root", + hard_limit_bytes: storageState?.hard_limit_bytes ?? 5_368_709_120, + last_measured_at: storageState?.measured_at ?? null, + managed_content_bytes: storageState?.managed_content_bytes ?? 0, + remeasurement_required: storageState?.storage_status === "unavailable", + status: storageState?.storage_status ?? "unavailable", + storage_backend: "local_filesystem", + }, + }); + }; +} + +function diagnosticText(input: AdminServicesStorageResponse, system: AdminDiagnosticsResponse["system"]) { + const lines = [ + "Dada P0-A diagnostics", + `app_version=${system.app_version}`, + `api_status=${system.api_status}`, + `worker_status=${system.worker_status}`, + `storage_status=${input.storage.status}`, + `capacity_notice_level=${input.storage.capacity_notice_level}`, + `managed_content_bytes=${input.storage.managed_content_bytes}`, + `hard_limit_bytes=${input.storage.hard_limit_bytes}`, + `cleanup_pending_count=${input.storage.cleanup_pending_count}`, + ]; + for (const service of input.services) lines.push(`service.${service.service_id}=${service.status}`); + return lines.join("\n"); +} + +export function createAdminDiagnosticsProvider(input: { + servicesStorage: () => AdminServicesStorageResponse; + browserSupportRelease?: BrowserSupportRelease; + appVersion?: string; + clock?: () => number; +}): () => AdminDiagnosticsResponse { + const clock = input.clock ?? Date.now; + return () => { + const services = input.servicesStorage(); + const system: AdminDiagnosticsResponse["system"] = { + api_status: "ready", + app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0", + browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({ + brand: browser.brand, + major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10), + })).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0), + worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active" + ? "ready" + : services.services.find((service) => service.service_id === "worker")?.status === "unavailable" + ? "unavailable" + : "degraded", + }; + return assertSafeAdminDiagnostics({ + generated_at: new Date(clock()).toISOString(), + diagnostic_text: diagnosticText(services, system), + services, + system, + }); + }; +} + +export function assertSafeAdminServicesStorage(input: AdminServicesStorageResponse) { + const ids = input.services.map((service) => service.service_id); + if (ids.length !== adminServiceIds.length || new Set(ids).size !== adminServiceIds.length + || adminServiceIds.some((serviceId) => !ids.includes(serviceId))) { + throw new Error("admin_service_state_incomplete"); + } + if (input.services.some((service) => service.pause_reason !== null && !safePauseReasons.has(service.pause_reason))) { + throw new Error("admin_services_redaction_failed"); + } + if (forbiddenDiagnosticPatterns.some((pattern) => pattern.test(JSON.stringify(input)))) { + throw new Error("admin_services_redaction_failed"); + } + return input; +} + +export function assertSafeAdminDiagnostics(input: AdminDiagnosticsResponse) { + assertSafeAdminServicesStorage(input.services); + if (forbiddenDiagnosticPatterns.some((pattern) => pattern.test(input.diagnostic_text))) { + throw new Error("admin_diagnostics_redaction_failed"); + } + return input; +} diff --git a/apps/api/src/amap-adapter.ts b/apps/api/src/amap-adapter.ts new file mode 100644 index 0000000..97ba7f1 --- /dev/null +++ b/apps/api/src/amap-adapter.ts @@ -0,0 +1,164 @@ +import { request as httpsRequest } from "node:https"; + +const amapHostname = "restapi.amap.com" as const; +const amapMaxResponseBytes = 65_536; +const amapTimeoutMs = 15_000; + +export interface AmapHttpRequest { + allowRedirects: false; + hostname: typeof amapHostname; + maxResponseBytes: number; + method: "GET"; + path: string; + protocol: "https:"; + rejectUnauthorized: true; + timeoutMs: number; +} + +type AmapRequester = (request: AmapHttpRequest) => Promise; + +export class AmapAdapterError extends Error { + constructor(readonly code: "amap_adapter_disposed" | "amap_invalid_request" | "amap_invalid_response" | "amap_provider_rejected" | "amap_provider_unavailable" | "amap_redirect_rejected" | "amap_request_timeout" | "amap_response_too_large") { + super(code); + } +} + +export interface AmapAdapter { + dispose?(): void; + reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" | "real" }>; +} + +export class MockAmapAdapter implements AmapAdapter { + readonly calls: Array<{ latitude: number; longitude: number }> = []; + + async reverseGeocode(coordinates: { latitude: number; longitude: number }) { + this.calls.push({ ...coordinates }); + return { + formattedValue: `模拟地点 ${coordinates.latitude.toFixed(4)}, ${coordinates.longitude.toFixed(4)}`, + serviceMode: "mock" as const, + }; + } +} + +function requestAmapJson(input: AmapHttpRequest) { + return new Promise((resolve, reject) => { + if (input.protocol !== "https:" || input.hostname !== amapHostname || input.allowRedirects || !input.rejectUnauthorized) { + reject(new AmapAdapterError("amap_invalid_request")); + return; + } + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + callback(); + }; + const request = httpsRequest({ + headers: { Accept: "application/json" }, + hostname: input.hostname, + method: input.method, + path: input.path, + port: 443, + protocol: input.protocol, + rejectUnauthorized: input.rejectUnauthorized, + servername: input.hostname, + }, (response) => { + const statusCode = response.statusCode ?? 0; + if (statusCode >= 300 && statusCode < 400) { + response.resume(); + finish(() => reject(new AmapAdapterError("amap_redirect_rejected"))); + return; + } + if (statusCode !== 200) { + response.resume(); + finish(() => reject(new AmapAdapterError("amap_provider_unavailable"))); + return; + } + const declaredLength = Number(response.headers["content-length"] ?? 0); + if (Number.isFinite(declaredLength) && declaredLength > input.maxResponseBytes) { + response.destroy(); + finish(() => reject(new AmapAdapterError("amap_response_too_large"))); + return; + } + const chunks: Buffer[] = []; + let receivedBytes = 0; + response.on("data", (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + receivedBytes += bytes.length; + if (receivedBytes > input.maxResponseBytes) { + response.destroy(); + finish(() => reject(new AmapAdapterError("amap_response_too_large"))); + return; + } + chunks.push(bytes); + }); + response.on("end", () => { + finish(() => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch { + reject(new AmapAdapterError("amap_invalid_response")); + } finally { + for (const chunk of chunks) chunk.fill(0); + chunks.length = 0; + } + }); + }); + }); + request.setTimeout(input.timeoutMs, () => request.destroy(new AmapAdapterError("amap_request_timeout"))); + request.on("error", (error) => finish(() => reject(error instanceof AmapAdapterError ? error : new AmapAdapterError("amap_provider_unavailable")))); + request.end(); + }); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export class RealAmapAdapter implements AmapAdapter { + private readonly credential: Buffer; + private readonly requester: AmapRequester; + private disposed = false; + + constructor(value: string, options: { request?: AmapRequester } = {}) { + if (!value.trim()) throw new AmapAdapterError("amap_invalid_request"); + this.credential = Buffer.from(value, "utf8"); + this.requester = options.request ?? requestAmapJson; + } + + async reverseGeocode(coordinates: { latitude: number; longitude: number }) { + if (this.disposed) throw new AmapAdapterError("amap_adapter_disposed"); + if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90 + || !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) { + throw new AmapAdapterError("amap_invalid_request"); + } + const query = new URLSearchParams({ + extensions: "base", + key: this.credential.toString("utf8"), + location: `${coordinates.longitude},${coordinates.latitude}`, + }); + const response = await this.requester({ + allowRedirects: false, + hostname: amapHostname, + maxResponseBytes: amapMaxResponseBytes, + method: "GET", + path: `/v3/geocode/regeo?${query.toString()}`, + protocol: "https:", + rejectUnauthorized: true, + timeoutMs: amapTimeoutMs, + }); + if (!isRecord(response) || response.status !== "1" || !isRecord(response.regeocode)) { + throw new AmapAdapterError("amap_provider_rejected"); + } + const formattedValue = typeof response.regeocode.formatted_address === "string" + ? response.regeocode.formatted_address.trim() + : ""; + if (!formattedValue || formattedValue.length > 200) throw new AmapAdapterError("amap_invalid_response"); + return { formattedValue, serviceMode: "real" as const }; + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.credential.fill(0); + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a62f488..7623b3a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,23 +1,169 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { createReadStream, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { + AccountDeletionCompleteRequestSchema, + AccountDeletionResponseSchema, + AccountDeletionSendResponseSchema, + AccountProfileUpdateRequestSchema, + AccountProfileUpdateResponseSchema, + AccountSettingsResponseSchema, + AdminAuditQuerySchema, + AdminAuthenticatedUserSchema, + AdminGenerationRecordSchema, + AdminGenerationListResponseSchema, + AdminOverviewResponseSchema, + AdminOperationAuditItemSchema, + AdminOperationAuditResponseSchema, + AdminServicesResponseSchema, + AdminServiceHealthCheckRequestSchema, + AdminServiceLimitRequestSchema, + AdminServiceParamsSchema, + AdminServiceRecoveryRequestSchema, + ExternalServiceIdSchema, + ExternalServicePeriodTypeSchema, + ExternalServiceStatusSchema, + ExternalServiceUsageSchema, + AdminDiagnosticsResponseSchema, + AdminServicesStorageResponseSchema, + AdminCreditParamsSchema, + AdminLoginCompleteRequestSchema, + AdminLoginCompleteResponseSchema, + AdminLoginSendRequestSchema, + AdminSessionResponseSchema, BootstrapResponseSchema, + CanvasBackgroundAdjustmentsSchema, + CanvasElementSchema, + CanvasStateSchema, CorrelationIdSchema, + AuthenticatedUserSchema, + CreditSummarySchema, + CreditAdjustmentHeadersSchema, + CreditAdjustmentRequestSchema, + CreditAdjustmentResponseSchema, + CreditBalanceResponseSchema, + CreditEntryStatusSchema, + CreditEntryTypeSchema, + CreditLedgerEntrySchema, + CreditLedgerQuerySchema, + CreditLedgerResponseSchema, + CreditReferenceTypeSchema, + CsrfHeadersSchema, ErrorDetailsSchema, ErrorEnvelopeSchema, GenerationErrorCategorySchema, + GenerationCreateHeadersSchema, + GenerationCreateResponseSchema, + GenerationParamsSchema, + GenerationMultipartBodySchema, + GenerationTaskResponseSchema, + GenerationTaskStatusSchema, + LoginCompleteRequestSchema, + LoginCompleteResponseSchema, + LoginSendRequestSchema, + LogoutHeadersSchema, + LogoutResponseSchema, ModelConfigSseEventSchema, ModelRuntimeSseEventSchema, + ModelIdSchema, + ModelContractValidationStatusSchema, + ModelRuntimeReasonSchema, + ModelReferenceLimitsSchema, + ModelRuntimeAvailabilitySchema, + ModelConfigurationResponseSchema, + ModelConfigSchema, + ModelConfigCandidateSchema, + ModelConfigUpdateRequestSchema, + ModelParamsSchema, + ModelConfigUpdateHeadersSchema, + PrivateContentGenerationParamsSchema, + PrivateContentNoticeAckRequestSchema, + PrivateContentNoticeAckResponseSchema, + PrivateContentPromptResponseSchema, + PrivateContentAccessAuditItemSchema, + PrivateContentAccessAuditResponseSchema, + FailedEmptyTrashRequestSchema, + FailedEmptyTrashResponseSchema, + ExportFormatSchema, + GenerationProjectItemSchema, + LatestExportItemSchema, + LatestExportMultipartBodySchema, + LatestExportParamsSchema, + LatestExportSaveResponseSchema, + ProjectDetailResponseSchema, + ProjectEditableStateSchema, + ProjectIdSchema, + ProjectImageItemSchema, + ProjectImageParamsSchema, + ProjectListQuerySchema, + ProjectListResponseSchema, + ProjectParamsSchema, + ProjectRatioSchema, + ProjectRenameRequestSchema, + ProjectRenameResponseSchema, + ProjectRestoreResponseSchema, + ProjectPurgeResponseSchema, + ProjectTrashResponseSchema, + ProjectStateConflictResponseSchema, + ProjectStateSaveHeadersSchema, + ProjectStateSaveResponseSchema, + ProjectSummarySchema, + ProjectViewStatusSchema, + RecentAssetItemSchema, + RecentAssetKindSchema, + RecentAssetListResponseSchema, + RecentAssetQuerySchema, + RecentAssetRecordRequestSchema, + RecentAssetRecordResponseSchema, + ReverseGeocodeRequestSchema, + ReverseGeocodeResponseSchema, + RegistrationCompleteHeadersSchema, + RegistrationCompleteRequestSchema, + RegistrationCompleteResponseSchema, + RegistrationSendRequestSchema, + RegistrationSendResponseSchema, SseEventSchema, StableEngineeringErrorCodeSchema, StateSseEventSchema, + Type, + UserSessionResponseSchema, createErrorEnvelope, isCorrelationId, type BootstrapResponse, + type AdminLoginCompleteRequest, + type AdminLoginSendRequest, + type AdminOverviewResponse, + type AdminAuditQuery, + type AdminDiagnosticsResponse, + type AdminServicesStorageResponse, + type AccountDeletionCompleteRequest, + type AccountProfileUpdateRequest, + type AdminCreditParams, + type CreditAdjustmentHeaders, + type CreditAdjustmentRequest, + type CreditLedgerQuery, + type LoginCompleteRequest, + type LoginSendRequest, + type FailedEmptyTrashRequest, + type LatestExportParams, + type ProjectImageParams, + type GenerationCreateHeaders, + type GenerationParams, + type ProjectListQuery, + type ProjectParams, + type ProjectRenameRequest, + type ProjectEditableState, + type ProjectStateSaveHeaders, + type RecentAssetQuery, + type RecentAssetRecordRequest, + type ReverseGeocodeRequest, + type RegistrationCompleteRequest, + type RegistrationSendRequest, } from "@dada/shared-contracts"; +import type { AssetReleaseReader } from "@dada/asset-release-manifest"; import swagger from "@fastify/swagger"; +import multipart from "@fastify/multipart"; import Fastify, { type FastifyReply } from "fastify"; import { @@ -34,8 +180,43 @@ import { type BrowserUnsupportedReason, } from "./browser-support.js"; import { EventHub } from "./event-hub.js"; +import { CreditError } from "./credit-errors.js"; +import type { CreditService } from "./credits.js"; import type { PublicAssetResolver } from "./local-data-root.js"; +import { GenerationSubmissionError } from "./generation-submission-errors.js"; +import type { + GenerationSubmissionFields, + GenerationSubmissionService, + GenerationTaskView, + GenerationUploadSession, + NewGenerationReference, +} from "./generation-submission.js"; import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js"; +import { ProjectError } from "./project-errors.js"; +import type { ProjectService } from "./projects.js"; +import { LatestExportError } from "./latest-export-errors.js"; +import type { LatestExportService } from "./latest-exports.js"; +import { + RegistrationError, + registrationFieldError, +} from "./registration-errors.js"; +import type { RegistrationService } from "./registration.js"; +import { + AdminAuditQueryError, + listAdminOperationAudit, + listPrivateContentAccessAudit, +} from "./admin-audit.js"; +import type { AssetPreviewGrantService } from "./preview-grants.js"; +import type { RecentAssetService } from "./recent-assets.js"; +import type { AmapAdapter } from "./amap-adapter.js"; +import { ExternalServiceUsageError, type ExternalServiceUsage } from "./external-service-usage.js"; +import { ModelConfigurationError } from "./model-configuration.js"; +import type { ModelConfigurationService } from "./model-configuration.js"; +import { StickerReleaseError } from "./sticker-release-errors.js"; +import type { StickerReleaseService } from "./sticker-releases.js"; +import type { ManagedStorage } from "./managed-storage.js"; +import { PrivateContentError, PrivateContentService } from "./private-content.js"; +import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js"; const defaultBootstrap: BootstrapResponse = { app_version: "0.0.0", @@ -50,13 +231,41 @@ const defaultBootstrap: BootstrapResponse = { }; export interface CreateAppOptions { + adminDiagnostics?: () => AdminDiagnosticsResponse | Promise; + adminOverview?: () => AdminOverviewResponse | Promise; + adminServicesStorage?: () => AdminServicesStorageResponse | Promise; + amap?: AmapAdapter; + assetReleases?: AssetReleaseReader; bootstrap?: () => BootstrapResponse | Promise; browserGate?: boolean; browserSupportRelease?: BrowserSupportRelease; browserSupportSecret?: Buffer; + credits?: CreditService; eventHub?: EventHub; + generations?: GenerationSubmissionService; + latestExports?: LatestExportService; + models?: ModelConfigurationService; networkBoundary?: NetworkBoundaryOptions; publicAssets?: PublicAssetResolver; + recentAssets?: RecentAssetService; + projects?: ProjectService; + storage?: ManagedStorage; + previewAssetAuthorizer?: (input: { + releaseVersion: string; + resourceId: string; + userId: string; + }) => boolean | Promise; + previewGrants?: AssetPreviewGrantService; + privateAssetAdminAuthorizer?: (input: { + adminUserId: string; + ownerId: string; + releaseVersion: string; + resourceId: string; + }) => boolean | Promise; + privateContent?: PrivateContentService; + registration?: RegistrationService; + stickers?: StickerReleaseService; + serviceUsage?: ExternalServiceUsage; } const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate"); @@ -75,6 +284,10 @@ const contentSecurityPolicy = [ "base-uri 'none'", "frame-ancestors 'none'", ].join("; "); +const authFlowCookieName = "dada_auth_flow"; +const userSessionCookieName = "dada_session"; +const adminAuthFlowCookieName = "dada_admin_auth_flow"; +const adminSessionCookieName = "dada_admin_session"; function requestCorrelationId(headers: Record) { const header = headers["x-correlation-id"]; @@ -86,6 +299,372 @@ function headerValue(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value; } +function cookieValue(cookieHeader: string | undefined, name: string) { + if (!cookieHeader) return undefined; + for (const item of cookieHeader.split(";")) { + const separator = item.indexOf("="); + if (separator < 0) continue; + if (item.slice(0, separator).trim() === name) return item.slice(separator + 1).trim(); + } + return undefined; +} + +function registrationFailure(reply: FastifyReply, correlationId: string, error: unknown) { + if (error instanceof RegistrationError) { + return reply.code(error.httpStatus).send( + createErrorEnvelope({ + code: error.code, + correlationId, + details: { field_errors: [registrationFieldError(error.reason)] }, + }), + ); + } + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); +} + +function registrationValidationFailure(reply: FastifyReply, correlationId: string) { + return reply.code(400).send( + createErrorEnvelope({ + code: "REGISTRATION_REQUEST_INVALID", + correlationId, + details: { field_errors: [{ field: "request", message_key: "auth.registration.request_invalid" }] }, + }), + ); +} + +function projectFailure(reply: FastifyReply, correlationId: string, error: unknown) { + if (!(error instanceof ProjectError)) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); + } + const mapping = { + generation_state_invalid: 400, + project_active_limit: 409, + project_history_limit: 409, + project_name_invalid: 400, + project_not_found: 404, + project_ratio_fixed: 409, + project_retry_not_allowed: 409, + project_state_conflict: 412, + project_state_idempotency_conflict: 409, + project_state_invalid: 400, + } as const; + return reply.code(mapping[error.code]).send(null); +} + +function latestExportResponse(item: ReturnType | Awaited>) { + return { + byte_size: item.byteSize, + created_at: item.createdAt, + download_url: item.downloadUrl, + export_id: item.exportId, + format: item.format, + pixel_height: item.pixelHeight, + pixel_width: item.pixelWidth, + sha256: item.sha256, + state_version: item.stateVersion, + status: "saved" as const, + }; +} + +function latestExportFailure(reply: FastifyReply, correlationId: string, error: unknown) { + if (error instanceof LatestExportError) { + return reply.code(error.code === "not_found" ? 404 : error.code === "conflict" ? 409 : 400).send(null); + } + if (error && typeof error === "object" && "code" in error && error.code === "STORAGE_CAPACITY_EXCEEDED") { + const details = "details" in error && error.details && typeof error.details === "object" + ? error.details as { capacity_status?: "normal" | "warning" | "critical" | "full" | "unavailable"; remaining_bytes?: number } + : undefined; + return reply.code(507).send(createErrorEnvelope({ + code: "STORAGE_CAPACITY_EXCEEDED", + correlationId, + details: { capacity_status: details?.capacity_status ?? "full", remaining_bytes: details?.remaining_bytes ?? 0 }, + })); + } + if (error && typeof error === "object" && "code" in error && error.code === "storage_unavailable") { + return reply.code(507).send(createErrorEnvelope({ + code: "STORAGE_CAPACITY_EXCEEDED", + correlationId, + details: { capacity_status: "unavailable", remaining_bytes: 0 }, + })); + } + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); +} + +function creditFailure(reply: FastifyReply, correlationId: string, error: unknown) { + if (!(error instanceof CreditError)) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); + } + if (error.code === "credit_operation_conflict") { + return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId })); + } + const status = error.code === "credit_request_invalid" + ? 400 + : error.code === "credit_insufficient" || error.code === "credit_invariant_failed" + ? 409 + : 404; + return reply.code(status).send(null); +} + +function modelConfigurationFailure(reply: FastifyReply, correlationId: string, error: unknown) { + if (!(error instanceof ModelConfigurationError)) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); + } + const status = error.code === "MODEL_CONFIG_VERSION_CONFLICT" ? 412 + : error.code === "MODEL_RECOMMENDATION_PRIORITY_INVALID" ? 400 + : error.code === "IDEMPOTENCY_KEY_CONFLICT" ? 409 : 409; + const details = { + ...(error.code === "MODEL_CONFIG_VERSION_CONFLICT" && typeof error.details.latest_version === "number" + ? { latest_version: error.details.latest_version } : {}), + ...(Array.isArray(error.details.conflict_model_ids) ? { conflict_model_ids: error.details.conflict_model_ids as string[] } : {}), + ...(Array.isArray(error.details.field_errors) ? { field_errors: error.details.field_errors as Array<{ field: string; message_key: string }> } : {}), + }; + return reply.code(status).send(createErrorEnvelope({ code: error.code, correlationId, details })); +} + +function stickerReleaseFailure(reply: FastifyReply, correlationId: string, error: unknown) { + if (error instanceof StickerReleaseError) return reply.code(error.httpStatus).send(null); + return latestExportFailure(reply, correlationId, error); +} + +function assetCleanupFailure(reply: FastifyReply, correlationId: string, error: unknown) { + const code = error instanceof Error ? error.message : ""; + if (code === "ASSET_HISTORY_REFERENCE_CONFLICT" || code === "ASSET_CLEANUP_CANDIDATE_STALE") { + return reply.code(409).send(createErrorEnvelope({ code, correlationId })); + } + if (code === "IDEMPOTENCY_KEY_CONFLICT") { + return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId })); + } + if (code === "cleanup_candidates_invalid") return reply.code(400).send(null); + if (code === "cleanup_uncommitted") return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); +} + +function generationTaskResponse(task: GenerationTaskView) { + return { + confirmed_credit_cost: task.confirmedCreditCost, + created_at: task.createdAt, + error_category: task.errorCategory, + generation_id: task.generationId, + model_config_version: task.modelConfigVersion, + model_id: task.modelId, + project_id: task.projectId, + prompt: task.prompt, + ratio: task.ratio, + reference_asset_ids: task.referenceAssetIds, + reference_count: task.referenceCount, + reserved_credits: task.reservedCredits, + status: task.status, + updated_at: task.updatedAt, + }; +} + +function generationFailure(reply: FastifyReply, correlationId: string, error: unknown) { + if (error instanceof GenerationSubmissionError) { + if (error.code === "model_config_stale") { + return reply.code(412).send(createErrorEnvelope({ + code: "MODEL_CONFIG_VERSION_CONFLICT", + correlationId, + details: { latest_version: error.latest?.configVersion ?? 0 }, + })); + } + if (error.code === "generation_idempotency_conflict") { + return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId })); + } + if (error.code === "generation_blocked") { + return reply.code(503).send(createErrorEnvelope({ + code: "AUTH_SERVICE_UNAVAILABLE", + correlationId, + ...(error.errorCategory ? { errorCategory: error.errorCategory } : {}), + })); + } + if (error.code === "generation_storage_unavailable") { + return reply.code(507).send(createErrorEnvelope({ + code: "STORAGE_CAPACITY_EXCEEDED", + correlationId, + details: { + capacity_status: error.storage?.capacityStatus ?? "unavailable", + remaining_bytes: error.storage?.remainingBytes ?? 0, + }, + })); + } + return reply.code(error.code === "generation_not_found" ? 404 : 400).send(null); + } + if (error && typeof error === "object" && "code" in error) { + if (typeof error.code === "string" && error.code.startsWith("FST_")) { + return reply.code(400).send(null); + } + if (error.code === "STORAGE_CAPACITY_EXCEEDED") { + const details = "details" in error && error.details && typeof error.details === "object" + ? error.details as { capacity_status?: "normal" | "warning" | "critical" | "full" | "unavailable"; remaining_bytes?: number } + : {}; + return reply.code(507).send(createErrorEnvelope({ + code: "STORAGE_CAPACITY_EXCEEDED", + correlationId, + details: { + capacity_status: details.capacity_status ?? "full", + remaining_bytes: details.remaining_bytes ?? 0, + }, + })); + } + if (error.code === "storage_unavailable") { + return reply.code(507).send(createErrorEnvelope({ + code: "STORAGE_CAPACITY_EXCEEDED", + correlationId, + details: { capacity_status: "unavailable", remaining_bytes: 0 }, + })); + } + } + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); +} + +const generationFieldNames = new Set([ + "client_submission_id", + "confirmed_credit_cost", + "creation_mode", + "existing_reference_asset_ids", + "model_config_version", + "model_id", + "project_id", + "prompt", + "ratio", + "reference_manifest", +]); + +interface ReferenceManifestEntry { + fileName: string; + mimeType: NewGenerationReference["mimeType"]; + projectedBytes: number; +} + +function positiveIntegerField(value: string | undefined) { + if (!value || !/^[1-9][0-9]*$/.test(value)) throw new GenerationSubmissionError("generation_request_invalid"); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) throw new GenerationSubmissionError("generation_request_invalid"); + return parsed; +} + +function jsonStringArray(value: string | undefined) { + if (value === undefined) return []; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new GenerationSubmissionError("generation_request_invalid"); + } + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) { + throw new GenerationSubmissionError("generation_request_invalid"); + } + return parsed; +} + +function referenceManifest(value: string | undefined): ReferenceManifestEntry[] { + if (value === undefined) return []; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new GenerationSubmissionError("generation_request_invalid"); + } + if (!Array.isArray(parsed)) throw new GenerationSubmissionError("generation_request_invalid"); + return parsed.map((entry) => { + if (!entry || typeof entry !== "object") throw new GenerationSubmissionError("generation_request_invalid"); + const item = entry as Record; + const keys = Object.keys(item).toSorted(); + if (keys.join(",") !== "file_name,mime_type,size" || typeof item.file_name !== "string" + || !["image/jpeg", "image/png", "image/webp"].includes(String(item.mime_type)) + || !Number.isSafeInteger(item.size) || Number(item.size) <= 0) { + throw new GenerationSubmissionError("generation_request_invalid"); + } + return { + fileName: item.file_name, + mimeType: item.mime_type as NewGenerationReference["mimeType"], + projectedBytes: Number(item.size), + }; + }); +} + +function generationFields( + values: Map, + input: { idempotencyKey: string; userId: string }, +): { fields: GenerationSubmissionFields; manifest: ReferenceManifestEntry[] } { + for (const name of values.keys()) { + if (!generationFieldNames.has(name)) throw new GenerationSubmissionError("generation_request_invalid"); + } + const creationMode = values.get("creation_mode"); + if (creationMode !== "new_project" && creationMode !== "existing_project") { + throw new GenerationSubmissionError("generation_request_invalid"); + } + const fields: GenerationSubmissionFields = { + clientSubmissionId: values.get("client_submission_id") ?? "", + confirmedCreditCost: positiveIntegerField(values.get("confirmed_credit_cost")), + existingReferenceAssetIds: jsonStringArray(values.get("existing_reference_asset_ids")), + idempotencyKey: input.idempotencyKey, + mode: creationMode, + modelConfigVersion: positiveIntegerField(values.get("model_config_version")), + modelId: values.get("model_id") ?? "", + ...(values.has("project_id") ? { projectId: values.get("project_id")! } : {}), + prompt: values.get("prompt") ?? "", + ratio: values.get("ratio") as GenerationSubmissionFields["ratio"], + userId: input.userId, + }; + return { fields, manifest: referenceManifest(values.get("reference_manifest")) }; +} + +type ProjectSummaryView = ReturnType[number]; +type ProjectDetailView = ReturnType; + +function projectSummaryResponse(project: ProjectSummaryView) { + return { + current_image_id: project.currentImageId, + deleted_at: project.deletedAt, + name: project.name, + project_id: project.projectId, + purge_at: project.purgeAt, + ratio: project.ratio, + state_version: project.stateVersion, + status: project.status, + successful_image_count: project.successfulImageCount, + updated_at: project.updatedAt, + }; +} + +function projectDetailResponse(project: ProjectDetailView) { + return { + ...projectSummaryResponse(project), + canvas_state: project.canvasState, + created_at: project.createdAt, + draft_prompt: project.draftPrompt, + generations: project.generations.map((generation) => ({ + created_at: generation.createdAt, + error_category: generation.errorCategory, + generation_id: generation.generationId, + prompt: generation.prompt, + ratio: generation.ratio, + status: generation.status, + updated_at: generation.updatedAt, + })), + images: project.images.map((image) => ({ + created_at: image.createdAt, + generation_id: image.generationId, + image_id: image.imageId, + })), + latest_exports: project.latestExports.map((item) => ({ + byte_size: item.byteSize, + created_at: item.createdAt, + download_url: item.downloadUrl, + export_id: item.exportId, + format: item.format, + pixel_height: item.pixelHeight, + pixel_width: item.pixelWidth, + sha256: item.sha256, + state_version: item.stateVersion, + })), + pixel_height: project.pixelHeight, + pixel_width: project.pixelWidth, + save_status: project.saveStatus, + }; +} + function isSupportGateRequest(method: string, path: string) { if (method === "POST" && path === "/api/v1/support/check") return true; if (method !== "GET" && method !== "HEAD") return false; @@ -123,6 +702,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; @@ -145,20 +731,145 @@ export async function createApp(options: CreateAppOptions = {}) { }, }); + await app.register(multipart, { + limits: { + fieldNameSize: 120, + fieldSize: 16 * 1024, + fields: 20, + fileSize: 100 * 1024 * 1024, + files: 16, + parts: 36, + }, + }); + for (const schema of [ CorrelationIdSchema, GenerationErrorCategorySchema, + GenerationTaskStatusSchema, + GenerationTaskResponseSchema, + GenerationCreateResponseSchema, + GenerationParamsSchema, + GenerationCreateHeadersSchema, + GenerationMultipartBodySchema, StableEngineeringErrorCodeSchema, ErrorDetailsSchema, ErrorEnvelopeSchema, + AuthenticatedUserSchema, + AdminAuthenticatedUserSchema, + AdminLoginSendRequestSchema, + AdminLoginCompleteRequestSchema, + AdminLoginCompleteResponseSchema, + AdminSessionResponseSchema, + AdminAuditQuerySchema, + AdminOperationAuditItemSchema, + AdminOperationAuditResponseSchema, + AdminGenerationRecordSchema, + AdminGenerationListResponseSchema, + PrivateContentNoticeAckRequestSchema, + PrivateContentNoticeAckResponseSchema, + PrivateContentPromptResponseSchema, + PrivateContentAccessAuditItemSchema, + PrivateContentAccessAuditResponseSchema, + PrivateContentGenerationParamsSchema, + AdminOverviewResponseSchema, + AdminServicesResponseSchema, + AdminServiceHealthCheckRequestSchema, + AdminServiceLimitRequestSchema, + AdminServiceParamsSchema, + AdminServiceRecoveryRequestSchema, + ExternalServiceIdSchema, + ExternalServicePeriodTypeSchema, + ExternalServiceStatusSchema, + ExternalServiceUsageSchema, + AdminServicesStorageResponseSchema, + AdminDiagnosticsResponseSchema, + CreditSummarySchema, + CreditEntryTypeSchema, + CreditEntryStatusSchema, + CreditReferenceTypeSchema, + CreditBalanceResponseSchema, + CreditLedgerEntrySchema, + CreditLedgerQuerySchema, + CreditLedgerResponseSchema, + AdminCreditParamsSchema, + CreditAdjustmentHeadersSchema, + CreditAdjustmentRequestSchema, + CreditAdjustmentResponseSchema, + CsrfHeadersSchema, + AccountSettingsResponseSchema, + AccountProfileUpdateRequestSchema, + AccountProfileUpdateResponseSchema, + AccountDeletionSendResponseSchema, + AccountDeletionCompleteRequestSchema, + AccountDeletionResponseSchema, + RegistrationSendRequestSchema, + RegistrationSendResponseSchema, + RegistrationCompleteRequestSchema, + RegistrationCompleteHeadersSchema, + RegistrationCompleteResponseSchema, + LoginSendRequestSchema, + LoginCompleteRequestSchema, + LoginCompleteResponseSchema, + LogoutHeadersSchema, + LogoutResponseSchema, + UserSessionResponseSchema, BrowserUnsupportedReasonSchema, BrowserSupportRequestSchema, BrowserSupportSuccessSchema, BootstrapResponseSchema, + CanvasBackgroundAdjustmentsSchema, + CanvasElementSchema, + CanvasStateSchema, StateSseEventSchema, ModelConfigSseEventSchema, ModelRuntimeSseEventSchema, SseEventSchema, + ProjectIdSchema, + ProjectRatioSchema, + ProjectViewStatusSchema, + ProjectSummarySchema, + ProjectListQuerySchema, + ProjectListResponseSchema, + ProjectParamsSchema, + GenerationProjectItemSchema, + ProjectImageItemSchema, + ExportFormatSchema, + LatestExportItemSchema, + LatestExportMultipartBodySchema, + LatestExportParamsSchema, + LatestExportSaveResponseSchema, + ProjectImageParamsSchema, + ProjectDetailResponseSchema, + ProjectEditableStateSchema, + ProjectRenameRequestSchema, + ProjectRenameResponseSchema, + ProjectTrashResponseSchema, + ProjectRestoreResponseSchema, + ProjectPurgeResponseSchema, + ProjectStateSaveHeadersSchema, + ProjectStateSaveResponseSchema, + ProjectStateConflictResponseSchema, + RecentAssetKindSchema, + RecentAssetItemSchema, + RecentAssetQuerySchema, + RecentAssetListResponseSchema, + RecentAssetRecordRequestSchema, + RecentAssetRecordResponseSchema, + ReverseGeocodeRequestSchema, + ReverseGeocodeResponseSchema, + FailedEmptyTrashRequestSchema, + FailedEmptyTrashResponseSchema, + ModelIdSchema, + ModelContractValidationStatusSchema, + ModelRuntimeReasonSchema, + ModelReferenceLimitsSchema, + ModelRuntimeAvailabilitySchema, + ModelConfigSchema, + ModelConfigCandidateSchema, + ModelConfigurationResponseSchema, + ModelParamsSchema, + ModelConfigUpdateRequestSchema, + ModelConfigUpdateHeadersSchema, ]) { app.addSchema(schema); } @@ -213,13 +924,393 @@ 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/static-stickers/current", + { schema: { hide: true } }, + async (_request, reply) => { + if (!options.stickers) return reply.code(503).send(); + reply.header("Cache-Control", "no-cache"); + return options.stickers.listPublic(); + }, + ); + + app.get( + "/api/v1/static-stickers/:resourceVersion", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.stickers) return reply.code(404).send(); + const { resourceVersion } = request.params as { resourceVersion: string }; + const catalog = options.stickers.listPublic(resourceVersion); + if (!catalog.release_version) return reply.code(404).send(); + reply.header("Cache-Control", "public, max-age=31536000, immutable"); + return catalog; + }, + ); + + app.get( + "/api/v1/admin/assets/static-stickers", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.registration || !options.stickers) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + reply.header("Cache-Control", "private, no-store"); + return options.stickers.adminView(); + }, + ); + + app.post( + "/api/v1/admin/assets/static-stickers", + { schema: { hide: true } }, + async (request, reply) => { + if (!request.isMultipart()) return reply.code(400).send(null); + if (!options.registration || !options.stickers) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + if (!idempotencyKey || !/^[A-Za-z0-9_-]{32,200}$/.test(idempotencyKey) + || !csrfToken || !/^[A-Za-z0-9_-]{43,64}$/.test(csrfToken)) return reply.code(400).send(null); + try { + const admin = options.registration.authorizeAdminMutation({ csrfToken, sessionToken: token }); + const values = new Map(); + const allowedFields = new Set(["enabled", "order", "original_byte_size", "original_sha256", "part", "stable_id"]); + let result: Awaited> | undefined; + for await (const part of request.parts({ limits: { fileSize: 20 * 1024 * 1024, files: 1, fields: 8, parts: 9 } })) { + if (part.type === "field") { + if (result || !allowedFields.has(part.fieldname) || values.has(part.fieldname) || typeof part.value !== "string") throw new StickerReleaseError("sticker_upload_invalid"); + values.set(part.fieldname, part.value); + continue; + } + if (result || part.fieldname !== "sticker_file" || !part.filename + || !new Set(["image/png", "image/webp"]).has(part.mimetype)) throw new StickerReleaseError("sticker_upload_invalid"); + const stableId = values.get("stable_id"); + const partValue = Number(values.get("part")); + const order = Number(values.get("order")); + const enabled = values.get("enabled"); + const expectedByteSize = Number(values.get("original_byte_size")); + const expectedSha256 = values.get("original_sha256"); + if (!stableId || !expectedSha256 || !new Set(["true", "false"]).has(enabled ?? "")) throw new StickerReleaseError("sticker_upload_invalid"); + result = await options.stickers.upload({ + actorId: admin.userId, + content: part.file, + enabled: enabled === "true", + expectedByteSize, + expectedMimeType: part.mimetype as "image/png" | "image/webp", + expectedSha256, + fileName: part.filename, + idempotencyKey, + order, + part: partValue, + stableId, + }); + if (part.file.truncated) throw new StickerReleaseError("sticker_upload_invalid"); + } + if (!result) throw new StickerReleaseError("sticker_upload_invalid"); + return reply.code(result.created ? 201 : 200).send(result); + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : stickerReleaseFailure(reply, request.id, error); + } + }, + ); + + app.patch( + "/api/v1/admin/assets/static-stickers/:stableId", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.registration || !options.stickers) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const admin = options.registration.authorizeAdminMutation({ csrfToken, sessionToken: token }); + const body = request.body as { enabled?: boolean; order?: number; part?: number } | undefined; + if (!body || Object.keys(body).length === 0 || Object.keys(body).some((key) => !new Set(["enabled", "order", "part"]).has(key))) { + throw new StickerReleaseError("sticker_update_invalid"); + } + return options.stickers.update({ + actorId: admin.userId, + ...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}), + ...(typeof body.order === "number" ? { order: body.order } : {}), + ...(typeof body.part === "number" ? { part: body.part } : {}), + stableId: (request.params as { stableId: string }).stableId, + }); + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : stickerReleaseFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/admin/assets/static-stickers/cleanup/candidates", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.registration || !options.storage) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + reply.header("Cache-Control", "private, no-store"); + return options.storage.listAssetCleanupCandidates(); + } catch (error) { + return assetCleanupFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/admin/assets/static-stickers/cleanup/intents", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.registration || !options.storage) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + if (!idempotencyKey || !/^[A-Za-z0-9_-]{32,200}$/.test(idempotencyKey) || !/^[A-Za-z0-9_-]{43,64}$/.test(csrfToken)) return reply.code(400).send(null); + try { + const admin = options.registration.authorizeAdminMutation({ csrfToken, sessionToken: token }); + const body = request.body as { file_ids?: string[]; snapshot_version?: string } | undefined; + return reply.code(201).send(options.storage.createAssetCleanupIntent({ + actorId: admin.userId, + fileIds: body?.file_ids ?? [], + idempotencyKey, + snapshotVersion: body?.snapshot_version ?? "", + })); + } catch (error) { + return assetCleanupFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/admin/assets/static-stickers/cleanup/intents/:requestId/confirm", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.registration || !options.storage) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + if (!/^[A-Za-z0-9_-]{43,64}$/.test(csrfToken)) return reply.code(400).send(null); + try { + const admin = options.registration.authorizeAdminMutation({ csrfToken, sessionToken: token }); + const body = request.body as { confirmation_token?: string } | undefined; + const requestId = (request.params as { requestId: string }).requestId; + return reply.send(options.storage.confirmAssetCleanupIntent({ + actorId: admin.userId, + confirmationToken: body?.confirmation_token ?? "", + requestId, + })); + } catch (error) { + return assetCleanupFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/assets/public/:resourceVersion/manifest", + { schema: { hide: true } }, + async (request, reply) => { + const { resourceVersion } = request.params as { resourceVersion: string }; + const manifest = options.assetReleases?.project("public_release_asset", resourceVersion); + if (!manifest) return reply.code(404).send(); + reply.header("Cache-Control", "public, max-age=31536000, immutable"); + reply.header("ETag", `"sha256-${manifest.manifest_sha256}"`); + return manifest; + }, + ); + app.get( "/api/v1/assets/public/:resourceVersion/:assetId", { schema: { hide: true } }, async (request, reply) => { const { assetId, resourceVersion } = request.params as { assetId?: string; resourceVersion?: string }; const resource = assetId && resourceVersion - ? options.publicAssets?.read(resourceVersion, assetId) + ? options.assetReleases?.read("public_release_asset", resourceVersion, assetId) + ?? options.publicAssets?.read(resourceVersion, assetId) + ?? options.stickers?.readPublicAsset( + resourceVersion, + assetId, + (request.query as { variant?: string }).variant === "thumbnail" ? "thumbnail" : "original", + ) : undefined; if (!resource) return reply.code(404).send(); reply.type(resource.mimeType); @@ -230,6 +1321,1886 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.get( + "/api/v1/assets/preview/:resourceVersion/manifest", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + const { resourceVersion } = request.params as { resourceVersion: string }; + if (options.previewGrants) { + const manifest = options.previewGrants.projectManifest({ releaseVersion: resourceVersion, userId: session.userId }); + if (!manifest) return reply.code(404).send(); + reply.header("Cache-Control", "private, no-store"); + reply.header("Vary", "Cookie"); + return manifest; + } + const available = options.assetReleases?.project("internal_preview_asset", resourceVersion); + if (!available || !options.previewAssetAuthorizer) return reply.code(404).send(); + const authorizedIds: string[] = []; + for (const item of available.items) { + if (await options.previewAssetAuthorizer({ + releaseVersion: resourceVersion, + resourceId: item.resource_id, + userId: session.userId, + })) authorizedIds.push(item.resource_id); + } + if (authorizedIds.length === 0) return reply.code(404).send(); + const manifest = options.assetReleases?.project("internal_preview_asset", resourceVersion, { resourceIds: authorizedIds }); + if (!manifest) return reply.code(404).send(); + reply.header("Cache-Control", "private, no-store"); + reply.header("Vary", "Cookie"); + return manifest; + }, + ); + + app.get( + "/api/v1/assets/preview/:resourceVersion/:assetId", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + const { assetId, resourceVersion } = request.params as { assetId: string; resourceVersion: string }; + if (options.previewGrants) { + const resource = options.previewGrants.readManifestItem({ + manifestItemId: assetId, + releaseVersion: resourceVersion, + userId: session.userId, + }); + if (!resource) return reply.code(404).send(); + reply.type(resource.mimeType); + reply.header("Cache-Control", "private, no-store"); + reply.header("Content-Disposition", "inline"); + reply.header("Vary", "Cookie"); + return resource.bytes; + } + const authorized = await options.previewAssetAuthorizer?.({ resourceId: assetId, releaseVersion: resourceVersion, userId: session.userId }); + const resource = authorized ? options.assetReleases?.read("internal_preview_asset", resourceVersion, assetId) : undefined; + if (!resource) return reply.code(404).send(); + reply.type(resource.mimeType); + reply.header("Cache-Control", "private, no-store"); + reply.header("Content-Disposition", "inline"); + reply.header("Vary", "Cookie"); + return resource.bytes; + }, + ); + + app.get( + "/api/v1/private-assets/:resourceVersion/manifest", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + const { resourceVersion } = request.params as { resourceVersion: string }; + const manifest = options.assetReleases?.project("private_user_asset", resourceVersion, { ownerId: session.userId }); + if (!manifest) return reply.code(404).send(); + reply.header("Cache-Control", "private, no-store"); + reply.header("Vary", "Cookie"); + return manifest; + }, + ); + + app.get( + "/api/v1/private-assets/:resourceVersion/:assetId", + { schema: { hide: true } }, + async (request, reply) => { + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const userToken = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const userSession = userToken ? options.registration.readUserSession(userToken) : undefined; + const adminToken = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const adminSession = adminToken ? options.registration.readAdminSession(adminToken) : undefined; + if (!userSession && !adminSession) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + const { assetId, resourceVersion } = request.params as { assetId: string; resourceVersion: string }; + const resource = options.assetReleases?.read("private_user_asset", resourceVersion, assetId); + if (!resource?.ownerId) return reply.code(404).send(); + const controlledAdmin = adminSession + ? await options.privateAssetAdminAuthorizer?.({ + adminUserId: adminSession.user_id, + ownerId: resource.ownerId, + releaseVersion: resource.releaseVersion, + resourceId: resource.resourceId, + }) + : 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"); + reply.header("Vary", "Cookie"); + return resource.bytes; + }, + ); + + app.get( + "/api/v1/assets/recent", + { + attachValidation: true, + schema: { + operationId: "listRecentAssets", + querystring: Type.Ref(RecentAssetQuerySchema), + response: { + 200: Type.Ref(RecentAssetListResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Assets"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.recentAssets) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + const query = request.query as RecentAssetQuery; + return { items: options.recentAssets.list(session.userId, query.asset_kind) }; + }, + ); + + app.post( + "/api/v1/assets/recent", + { + attachValidation: true, + schema: { + body: Type.Ref(RecentAssetRecordRequestSchema), + headers: Type.Ref(CsrfHeadersSchema), + operationId: "recordRecentAsset", + response: { + 200: Type.Ref(RecentAssetRecordResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Assets"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.recentAssets) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const body = request.body as RecentAssetRecordRequest; + options.recentAssets.recordSuccessfulUse({ + assetId: body.asset_id, + assetKind: body.asset_kind, + resourceVersion: body.resource_version, + userId: owner.userId, + }); + return { status: "recorded" as const }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/location/reverse-geocode", + { + attachValidation: true, + schema: { + body: Type.Ref(ReverseGeocodeRequestSchema), + headers: Type.Ref(CsrfHeadersSchema), + operationId: "reverseGeocodeLocation", + response: { + 200: Type.Ref(ReverseGeocodeResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Location"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.amap) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const usage = options.serviceUsage ?? options.registration.serviceUsage; + usage.claimAmap(); + const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest); + return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const }; + } catch (error) { + if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error); + if (error instanceof ExternalServiceUsageError) return reply.code(503).send(null); + try { + (options.serviceUsage ?? options.registration.serviceUsage).markProviderFailure({ serviceId: "amap_web_service", reason: "provider_unavailable" }); + } catch { /* preserve the provider failure response */ } + return reply.code(503).send(null); + } + }, + ); + + app.post( + "/api/v1/admin-auth/login/send", + { + attachValidation: true, + schema: { + body: Type.Ref(AdminLoginSendRequestSchema), + operationId: "sendAdminLoginCode", + response: { + 200: Type.Ref(RegistrationSendResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const existingFlow = cookieValue(headerValue(request.headers.cookie), adminAuthFlowCookieName); + const clientKey = existingFlow ?? randomBytes(32).toString("base64url"); + try { + const body = request.body as AdminLoginSendRequest; + const result = await options.registration.sendAdminLoginCode({ clientKey, email: body.email }); + if (!existingFlow) { + reply.header( + "Set-Cookie", + `${adminAuthFlowCookieName}=${clientKey}; Max-Age=${10 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + } + return { + challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(), + registration_id: result.registrationId, + resend_available_at: new Date(result.resendAvailableAt).toISOString(), + status: result.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/admin-auth/login/complete", + { + attachValidation: true, + schema: { + body: Type.Ref(AdminLoginCompleteRequestSchema), + headers: Type.Ref(RegistrationCompleteHeadersSchema), + operationId: "completeAdminLogin", + response: { + 200: Type.Ref(AdminLoginCompleteResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const clientKey = cookieValue(headerValue(request.headers.cookie), adminAuthFlowCookieName); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id); + try { + const body = request.body as AdminLoginCompleteRequest; + const result = options.registration.completeAdminLogin({ + clientKey, + code: body.verification_code, + idempotencyKey, + registrationId: body.registration_id, + }); + reply.header( + "Set-Cookie", + `${adminSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + return { + admin: { + role: result.admin.role, + status: result.admin.status, + user_id: result.admin.userId, + }, + audience: result.audience, + session_expires_at: new Date(result.sessionExpiresAt).toISOString(), + status: result.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/admin-auth/session", + { + schema: { + operationId: "getAdminSession", + response: { + 200: Type.Ref(AdminSessionResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Authentication"], + }, + }, + async (request, reply) => { + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + 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: 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!), + ...(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: notice ? acknowledgement?.version === notice.version : false, + }; + }, + ); + + app.get( + "/api/v1/admin/audit/operations", + { + schema: { + operationId: "getAdminOperationAudit", + querystring: Type.Ref(AdminAuditQuerySchema), + response: { + 200: Type.Ref(AdminOperationAuditResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + reply.header("Cache-Control", "private, no-store"); + return listAdminOperationAudit(options.registration.database, request.query as AdminAuditQuery); + } catch (error) { + if (error instanceof AdminAuditQueryError) return reply.code(400).send(null); + throw error; + } + }, + ); + + app.get( + "/api/v1/admin/audit/private-content", + { + schema: { + operationId: "getPrivateContentAccessAudit", + querystring: Type.Ref(AdminAuditQuerySchema), + response: { + 200: Type.Ref(PrivateContentAccessAuditResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + reply.header("Cache-Control", "private, no-store"); + return listPrivateContentAccessAudit(options.registration.database, request.query as AdminAuditQuery); + } catch (error) { + if (error instanceof AdminAuditQueryError) return reply.code(400).send(null); + throw error; + } + }, + ); + + app.get( + "/api/v1/admin/overview", + { + schema: { + operationId: "getAdminOverview", + response: { + 200: Type.Ref(AdminOverviewResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + if (!options.adminOverview) return reply.code(503).send(null); + try { + return await options.adminOverview(); + } catch { + return reply.code(503).send(null); + } + }, + ); + + app.get( + "/api/v1/admin/services", + { + schema: { + operationId: "getAdminServices", + response: { 200: Type.Ref(AdminServicesResponseSchema), 401: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Admin Services"], + }, + }, + async (request, reply) => { + const registration = options.registration; + const usage = options.serviceUsage ?? registration?.serviceUsage; + if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? registration.readAdminSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + return { + services: usage.readCurrent().map((row) => ({ + hard_limit: row.hardLimit, + pause_reason: row.pauseReason, + period_start: new Date(row.periodStart).toISOString(), + period_type: row.periodType, + service_id: row.serviceId, + service_status: row.status, + updated_at: new Date(row.updatedAt).toISOString(), + used_count: row.usedCount, + })), + }; + }, + ); + + app.post( + "/api/v1/admin/services/:service_id/health-check", + { + attachValidation: true, + schema: { + params: Type.Ref(AdminServiceParamsSchema), + body: Type.Ref(AdminServiceHealthCheckRequestSchema), + headers: Type.Ref(ModelConfigUpdateHeadersSchema), + operationId: "checkAdminServiceHealth", + response: { 200: Type.Object({ check_id: Type.String(), available: Type.Boolean(), checked_at: Type.String() }, { additionalProperties: false }), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Admin Services"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id })); + const registration = options.registration; + const usage = options.serviceUsage ?? registration?.serviceUsage; + if (!registration || !usage) 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 headers = request.headers as { "x-csrf-token": string }; + registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token }); + const body = request.body as { available: boolean; reason?: string }; + const params = request.params as { service_id: "resend_email" | "amap_web_service" }; + const check = usage.recordHealthCheck({ serviceId: params.service_id, available: body.available, ...(body.reason ? { reason: body.reason } : {}) }); + return { check_id: check.checkId, available: check.available, checked_at: new Date(check.checkedAt).toISOString() }; + } catch (error) { + if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id })); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + + app.get( + "/api/v1/admin/services-storage", + { + schema: { + operationId: "getAdminServicesStorage", + response: { + 200: Type.Ref(AdminServicesStorageResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + if (!options.adminServicesStorage) return reply.code(503).send(null); + try { + return assertSafeAdminServicesStorage(await options.adminServicesStorage()); + } catch { + return reply.code(503).send(null); + } + }, + ); + + app.patch( + "/api/v1/admin/services/:service_id/limits", + { + attachValidation: true, + schema: { + params: Type.Ref(AdminServiceParamsSchema), + body: Type.Ref(AdminServiceLimitRequestSchema), + headers: Type.Ref(ModelConfigUpdateHeadersSchema), + operationId: "updateAdminServiceHardLimit", + response: { 200: Type.Ref(AdminServicesResponseSchema), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Admin Services"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id })); + const registration = options.registration; + const usage = options.serviceUsage ?? registration?.serviceUsage; + if (!registration || !usage) 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 headers = request.headers as { "x-csrf-token": string }; + const admin = registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token }); + const body = request.body as { hard_limit: number; period_type: "daily" | "monthly" }; + const params = request.params as { service_id: "resend_email" | "amap_web_service" }; + usage.setHardLimit({ actorId: admin.userId, hardLimit: body.hard_limit, periodType: body.period_type, serviceId: params.service_id }); + return { + services: usage.readCurrent().map((row) => ({ + hard_limit: row.hardLimit, + pause_reason: row.pauseReason, + period_start: new Date(row.periodStart).toISOString(), + period_type: row.periodType, + service_id: row.serviceId, + service_status: row.status, + updated_at: new Date(row.updatedAt).toISOString(), + used_count: row.usedCount, + })), + }; + } catch (error) { + if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id })); + if (error instanceof ExternalServiceUsageError) return reply.code(409).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + + app.post( + "/api/v1/admin/services/:service_id/recover", + { + attachValidation: true, + schema: { + params: Type.Ref(AdminServiceParamsSchema), + body: Type.Ref(AdminServiceRecoveryRequestSchema), + headers: Type.Ref(ModelConfigUpdateHeadersSchema), + operationId: "recoverAdminService", + response: { 200: Type.Object({ status: Type.Literal("active") }, { additionalProperties: false }), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Admin Services"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id })); + const registration = options.registration; + const usage = options.serviceUsage ?? registration?.serviceUsage; + if (!registration || !usage) 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 headers = request.headers as { "x-csrf-token": string }; + const admin = registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token }); + const body = request.body as { check_id: string }; + const params = request.params as { service_id: "resend_email" | "amap_web_service" }; + return usage.recover({ actorId: admin.userId, checkId: body.check_id, serviceId: params.service_id }); + } catch (error) { + if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id })); + if (error instanceof ExternalServiceUsageError) return reply.code(409).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + + app.get( + "/api/v1/admin/diagnostics", + { + schema: { + operationId: "getAdminDiagnostics", + response: { + 200: Type.Ref(AdminDiagnosticsResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + if (!options.adminDiagnostics) return reply.code(503).send(null); + try { + return assertSafeAdminDiagnostics(await options.adminDiagnostics()); + } catch { + return reply.code(503).send(null); + } + }, + ); + + app.post( + "/api/v1/auth/login/send", + { + attachValidation: true, + schema: { + body: Type.Ref(LoginSendRequestSchema), + operationId: "sendLoginCode", + response: { + 200: Type.Ref(RegistrationSendResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const existingFlow = cookieValue(headerValue(request.headers.cookie), authFlowCookieName); + const clientKey = existingFlow ?? randomBytes(32).toString("base64url"); + try { + const body = request.body as LoginSendRequest; + const result = await options.registration.sendLoginCode({ clientKey, email: body.email }); + if (!existingFlow) { + reply.header( + "Set-Cookie", + `${authFlowCookieName}=${clientKey}; Max-Age=${10 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + } + return { + challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(), + registration_id: result.registrationId, + resend_available_at: new Date(result.resendAvailableAt).toISOString(), + status: result.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/auth/login/complete", + { + attachValidation: true, + schema: { + body: Type.Ref(LoginCompleteRequestSchema), + headers: Type.Ref(RegistrationCompleteHeadersSchema), + operationId: "completeLogin", + response: { + 200: Type.Ref(LoginCompleteResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const clientKey = cookieValue(headerValue(request.headers.cookie), authFlowCookieName); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id); + try { + const body = request.body as LoginCompleteRequest; + const result = options.registration.completeLogin({ + clientKey, + code: body.verification_code, + idempotencyKey, + registrationId: body.registration_id, + }); + reply.header( + "Set-Cookie", + `${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + return { + audience: result.audience, + credits: { + available_balance: result.credits.availableBalance, + reserved_balance: result.credits.reservedBalance, + }, + session_expires_at: new Date(result.sessionExpiresAt).toISOString(), + status: result.status, + user: { + creator_name: result.user.creatorName, + role: result.user.role, + social_id: result.user.socialId, + status: result.user.status, + user_id: result.user.userId, + }, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/auth/register/send", + { + attachValidation: true, + schema: { + body: Type.Ref(RegistrationSendRequestSchema), + operationId: "sendRegistrationCode", + response: { + 200: Type.Ref(RegistrationSendResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + try { + const body = request.body as RegistrationSendRequest; + const result = await options.registration.sendRegistrationCode({ email: body.email, inviteCode: body.invite_code }); + return { + challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(), + registration_id: result.registrationId, + resend_available_at: new Date(result.resendAvailableAt).toISOString(), + status: result.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/auth/register/complete", + { + attachValidation: true, + schema: { + body: Type.Ref(RegistrationCompleteRequestSchema), + headers: Type.Ref(RegistrationCompleteHeadersSchema), + operationId: "completeRegistration", + response: { + 200: Type.Ref(RegistrationCompleteResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + try { + const body = request.body as RegistrationCompleteRequest; + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!idempotencyKey) return registrationValidationFailure(reply, request.id); + const result = options.registration.completeRegistration({ + code: body.verification_code, + creatorName: body.creator_name, + idempotencyKey, + privacyConsentAccepted: body.privacy_consent_accepted, + privacyNoticeVersion: body.privacy_notice_version, + registrationId: body.registration_id, + socialId: body.social_id, + }); + reply.header( + "Set-Cookie", + `${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + return { + credits: { + available_balance: result.credits.availableBalance, + reserved_balance: result.credits.reservedBalance, + }, + session_expires_at: new Date(result.sessionExpiresAt).toISOString(), + status: result.status, + user: { + creator_name: result.user.creatorName, + role: result.user.role, + social_id: result.user.socialId, + status: result.user.status, + user_id: result.user.userId, + }, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/auth/session", + { + schema: { + operationId: "getUserSession", + response: { + 200: Type.Ref(UserSessionResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Authentication"], + }, + }, + async (request, reply) => { + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + return { + audience: session.audience, + authenticated: true as const, + credits: { + available_balance: session.credits.availableBalance, + reserved_balance: session.credits.reservedBalance, + }, + csrf_token: options.registration.issueUserCsrfToken(token!), + expires_at: new Date(session.expiresAt).toISOString(), + user: { + creator_name: session.user.creatorName, + role: session.user.role, + social_id: session.user.socialId, + status: session.user.status, + user_id: session.user.userId, + }, + }; + }, + ); + + app.post( + "/api/v1/auth/logout", + { + attachValidation: true, + schema: { + headers: Type.Ref(LogoutHeadersSchema), + operationId: "logoutUser", + response: { + 200: Type.Ref(LogoutResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + options.registration.logoutUser({ csrfToken, sessionToken: token }); + reply.header("Set-Cookie", `${userSessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`); + return { status: "logged_out" as const }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/account/settings", + { + schema: { + operationId: "getAccountSettings", + response: { + 200: Type.Ref(AccountSettingsResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Account"], + }, + }, + async (request, reply) => { + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const settings = options.registration.readAccountSettings(token); + const csrfToken = options.registration.issueUserCsrfToken(token); + return { + account: settings.account, + csrf_token: csrfToken, + local_data: { + backup_enabled: settings.localData.backupEnabled, + capacity_status: settings.localData.capacityStatus, + hard_limit_bytes: settings.localData.hardLimitBytes, + location: settings.localData.location, + managed_content_bytes: settings.localData.managedContentBytes, + migration_supported: settings.localData.migrationSupported, + }, + profile: { + creator_name: settings.profile.creatorName, + social_id: settings.profile.socialId, + }, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.put( + "/api/v1/account/settings/profile", + { + attachValidation: true, + schema: { + body: Type.Ref(AccountProfileUpdateRequestSchema), + headers: Type.Ref(CsrfHeadersSchema), + operationId: "updateAccountProfile", + response: { + 200: Type.Ref(AccountProfileUpdateResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Account"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const body = request.body as AccountProfileUpdateRequest; + const saved = options.registration.updateAccountProfile({ + creatorName: body.creator_name, + csrfToken, + sessionToken: token, + socialId: body.social_id, + }); + return { profile: { creator_name: saved.creatorName, social_id: saved.socialId }, status: saved.status }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/me/credits", + { + schema: { + operationId: "getMyCredits", + response: { + 200: Type.Ref(CreditBalanceResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Credits"], + }, + }, + async (request, reply) => { + if (!options.registration || !options.credits) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const account = options.credits.readAccount(session.userId); + return { + available_balance: account.availableBalance, + reserved_balance: account.reservedBalance, + updated_at: account.updatedAt, + }; + } catch (error) { + return creditFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/me/credit-ledger", + { + attachValidation: true, + schema: { + operationId: "getMyCreditLedger", + querystring: Type.Ref(CreditLedgerQuerySchema), + response: { + 200: Type.Ref(CreditLedgerResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Credits"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.credits) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const query = request.query as CreditLedgerQuery; + const ledger = options.credits.listLedger({ + ...(query.cursor ? { cursor: query.cursor } : {}), + ...(query.event_type ? { eventType: query.event_type } : {}), + ...(query.from ? { from: query.from } : {}), + ...(query.limit ? { limit: query.limit } : {}), + ...(query.to ? { to: query.to } : {}), + userId: session.userId, + }); + return { + credits: { + available_balance: ledger.account.availableBalance, + reserved_balance: ledger.account.reservedBalance, + }, + entries: ledger.entries.map((entry) => ({ + amount: entry.amount, + available_after: entry.availableAfter, + available_before: entry.availableBefore, + created_at: entry.createdAt, + entry_id: entry.entryId, + entry_type: entry.entryType, + model_id: entry.modelId, + reason: entry.reason, + reference_id: entry.referenceId, + reference_type: entry.referenceType, + reserved_after: entry.reservedAfter, + reserved_before: entry.reservedBefore, + status: entry.status, + })), + next_cursor: ledger.nextCursor, + updated_at: ledger.account.updatedAt, + }; + } catch (error) { + return creditFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/admin/users/:userId/credits", + { + attachValidation: true, + schema: { + operationId: "getAdminUserCredits", + params: Type.Ref(AdminCreditParamsSchema), + response: { + 200: Type.Ref(CreditBalanceResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Credits"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.credits) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const account = options.credits.readAccount((request.params as AdminCreditParams).userId); + return { + available_balance: account.availableBalance, + reserved_balance: account.reservedBalance, + updated_at: account.updatedAt, + }; + } catch (error) { + return creditFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/admin/users/:userId/credit-adjustments", + { + attachValidation: true, + schema: { + body: Type.Ref(CreditAdjustmentRequestSchema), + headers: Type.Ref(CreditAdjustmentHeadersSchema), + operationId: "adjustAdminUserCredits", + params: Type.Ref(AdminCreditParamsSchema), + response: { + 200: Type.Ref(CreditAdjustmentResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 409: Type.Union([Type.Ref(ErrorEnvelopeSchema), Type.Null()]), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Credits"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.credits) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const headers = request.headers as CreditAdjustmentHeaders; + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const admin = options.registration.authorizeAdminMutation({ + csrfToken: headers["x-csrf-token"], + sessionToken: token, + }); + const body = request.body as CreditAdjustmentRequest; + const adjusted = options.credits.adjustAvailable({ + adjustmentId: body.adjustment_id, + adminId: admin.userId, + amount: body.amount, + idempotencyKey: headers["idempotency-key"], + reason: body.reason, + userId: (request.params as AdminCreditParams).userId, + }); + return { + adjustment_id: adjusted.adjustmentId, + available_balance: adjusted.availableBalance, + reserved_balance: adjusted.reservedBalance, + status: adjusted.status, + }; + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : creditFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/account/deletion/send", + { + attachValidation: true, + schema: { + headers: Type.Ref(CsrfHeadersSchema), + operationId: "sendAccountDeletionCode", + response: { + 200: Type.Ref(AccountDeletionSendResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Account"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const sent = await options.registration.sendAccountDeletionCode({ csrfToken, sessionToken: token }); + return { + challenge_expires_at: new Date(sent.challengeExpiresAt).toISOString(), + deletion_id: sent.deletionId, + resend_available_at: new Date(sent.resendAvailableAt).toISOString(), + status: sent.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/account/deletion/complete", + { + attachValidation: true, + schema: { + body: Type.Ref(AccountDeletionCompleteRequestSchema), + headers: Type.Ref(LogoutHeadersSchema), + operationId: "completeAccountDeletion", + response: { + 200: Type.Ref(AccountDeletionResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Account"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!token || !csrfToken || !idempotencyKey) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + const body = request.body as AccountDeletionCompleteRequest; + const deleted = options.registration.completeAccountDeletion({ + code: body.verification_code, + confirmation: body.confirmation, + csrfToken, + deletionId: body.deletion_id, + idempotencyKey, + sessionToken: token, + }); + reply.header("Set-Cookie", `${userSessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`); + return deleted; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/generations/current", + { + schema: { + operationId: "getCurrentGeneration", + response: { + 200: Type.Ref(GenerationTaskResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Generations"], + }, + }, + async (request, reply) => { + if (!options.registration || !options.generations) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + const task = options.generations.readCurrentTask(session.userId); + return task ? generationTaskResponse(task) : reply.code(404).send(null); + }, + ); + + app.get( + "/api/v1/generations/:generationId", + { + attachValidation: true, + schema: { + operationId: "getGeneration", + params: Type.Ref(GenerationParamsSchema), + response: { + 200: Type.Ref(GenerationTaskResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Generations"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.generations) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + return generationTaskResponse(options.generations.readTask(session.userId, (request.params as GenerationParams).generationId)); + } catch (error) { + return generationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/generations", + { + attachValidation: true, + schema: { + consumes: ["multipart/form-data"], + body: Type.Optional(Type.Ref(GenerationMultipartBodySchema)), + headers: Type.Ref(GenerationCreateHeadersSchema), + operationId: "createGeneration", + response: { + 200: Type.Ref(GenerationCreateResponseSchema), + 201: Type.Ref(GenerationCreateResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 412: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + 507: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Generations"], + }, + validatorCompiler: () => (data) => ({ value: data }), + }, + async (request, reply) => { + if (request.validationError || !request.isMultipart()) return reply.code(400).send(null); + if (!options.registration || !options.generations) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!idempotencyKey || !/^[A-Za-z0-9_-]{32,200}$/.test(idempotencyKey) + || !csrfToken || !/^[A-Za-z0-9_-]{43,64}$/.test(csrfToken)) return reply.code(400).send(null); + const headers = { "idempotency-key": idempotencyKey, "x-csrf-token": csrfToken } satisfies GenerationCreateHeaders; + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + let upload: GenerationUploadSession | undefined; + try { + const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token }); + const current = options.generations.readCurrentTask(owner.userId); + if (current) return { created: false, task: generationTaskResponse(current) }; + + const values = new Map(); + let manifest: ReferenceManifestEntry[] | undefined; + let fileIndex = 0; + for await (const part of request.parts()) { + if (part.type === "field") { + if (upload || values.has(part.fieldname) || typeof part.value !== "string") { + throw new GenerationSubmissionError("generation_request_invalid"); + } + values.set(part.fieldname, part.value); + continue; + } + if (part.fieldname !== "reference_files" || !part.filename) { + throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + } + if (!upload) { + const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId }); + manifest = parsed.manifest; + upload = options.generations.beginUpload(parsed.fields); + } + const expected = manifest?.[fileIndex]; + if (!expected || expected.fileName !== part.filename || expected.mimeType !== part.mimetype) { + throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + } + await upload.stageReference({ + content: part.file, + fileName: part.filename, + mimeType: expected.mimeType, + projectedBytes: expected.projectedBytes, + }); + if (part.file.truncated) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + fileIndex += 1; + } + if (!upload) { + const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId }); + manifest = parsed.manifest; + upload = options.generations.beginUpload(parsed.fields); + } + if (fileIndex !== (manifest?.length ?? 0)) { + throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + } + const result = await upload.commit(); + return reply.code(result.created ? 201 : 200).send({ created: result.created, task: generationTaskResponse(result.task) }); + } catch (error) { + upload?.abort(); + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : error instanceof CreditError + ? creditFailure(reply, request.id, error) + : generationFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/projects", + { + attachValidation: true, + schema: { + operationId: "listProjects", + querystring: Type.Ref(ProjectListQuerySchema), + response: { + 200: Type.Ref(ProjectListResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) { + return reply.code(400).send(null); + } + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + const query = request.query as ProjectListQuery; + const status = query.status ?? "active"; + return { + active_count: options.projects.activeProjectCount(session.userId), + active_limit: 20 as const, + projects: options.projects.listProjects(session.userId, status).slice(0, 20).map(projectSummaryResponse), + }; + }, + ); + + app.get( + "/api/v1/projects/:projectId", + { + attachValidation: true, + schema: { + operationId: "getProject", + params: Type.Ref(ProjectParamsSchema), + response: { + 200: Type.Ref(ProjectDetailResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) { + return reply.code(400).send(null); + } + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + return projectDetailResponse(options.projects.getProject(session.userId, (request.params as ProjectParams).projectId)); + } catch (error) { + return projectFailure(reply, request.id, error); + } + }, + ); + + app.put( + "/api/v1/projects/:projectId/latest-exports/:format", + { + attachValidation: true, + schema: { + body: Type.Optional(Type.Ref(LatestExportMultipartBodySchema)), + consumes: ["multipart/form-data"], + headers: Type.Ref(CsrfHeadersSchema), + operationId: "saveLatestExport", + params: Type.Ref(LatestExportParamsSchema), + response: { + 200: Type.Ref(LatestExportSaveResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 409: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + 507: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + validatorCompiler: () => (data) => ({ value: data }), + }, + async (request, reply) => { + if (request.validationError || !request.isMultipart()) return reply.code(400).send(null); + if (!options.registration || !options.latestExports) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const params = request.params as LatestExportParams; + const fields = new Map(); + let saved: Awaited> | undefined; + for await (const part of request.parts()) { + if (part.type === "field") { + if (saved || fields.has(part.fieldname) || typeof part.value !== "string") throw new LatestExportError("invalid"); + fields.set(part.fieldname, part.value); + continue; + } + if (saved || part.fieldname !== "export_file" || !part.filename) throw new LatestExportError("invalid"); + const format = fields.get("format"); + const expectedMime = params.format === "png" ? "image/png" : "image/jpeg"; + if (format !== params.format || part.mimetype !== expectedMime) throw new LatestExportError("invalid"); + saved = await options.latestExports.saveLatest({ + byteSize: Number(fields.get("byte_size")), + content: part.file, + exportId: fields.get("export_id") ?? "", + format: params.format, + ownerId: owner.userId, + pixelHeight: Number(fields.get("pixel_height")), + pixelWidth: Number(fields.get("pixel_width")), + projectId: params.projectId, + sha256: fields.get("sha256") ?? "", + stateVersion: Number(fields.get("state_version")), + }); + if (part.file.truncated) throw new LatestExportError("invalid"); + } + if (!saved) throw new LatestExportError("invalid"); + return latestExportResponse(saved); + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : latestExportFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/projects/:projectId/latest-exports/:format", + { + attachValidation: true, + schema: { + operationId: "downloadLatestExport", + params: Type.Ref(LatestExportParamsSchema), + produces: ["application/octet-stream"], + response: { 200: Type.String({ format: "binary" }), 400: Type.Null(), 401: Type.Ref(ErrorEnvelopeSchema), 404: Type.Null(), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const params = request.params as LatestExportParams; + const item = options.latestExports.getLatest(session.userId, params.projectId, params.format); + reply.header("Cache-Control", "private, no-store"); + reply.header("Content-Disposition", `attachment; filename="dada-latest.${params.format === "jpg" ? "jpg" : "png"}"`); + reply.type(item.mimeType); + return reply.send(createReadStream(item.path)); + } catch (error) { + return latestExportFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/private-assets/projects/:projectId/images/:imageId", + { + attachValidation: true, + schema: { + operationId: "downloadOriginalGeneration", + params: Type.Ref(ProjectImageParamsSchema), + produces: ["application/octet-stream"], + response: { 200: Type.String({ format: "binary" }), 400: Type.Null(), 401: Type.Ref(ErrorEnvelopeSchema), 404: Type.Null(), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const params = request.params as ProjectImageParams; + const item = options.latestExports.getOriginal(session.userId, params.projectId, params.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", `attachment; filename="dada-original.${extension}"`); + reply.type(item.mime_type); + return reply.send(createReadStream(item.path)); + } catch (error) { + return latestExportFailure(reply, request.id, error); + } + }, + ); + + app.put( + "/api/v1/projects/:projectId/state", + { + attachValidation: true, + schema: { + body: Type.Ref(ProjectEditableStateSchema), + headers: Type.Ref(ProjectStateSaveHeadersSchema), + operationId: "saveProjectState", + params: Type.Ref(ProjectParamsSchema), + response: { + 200: Type.Ref(ProjectStateSaveResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 409: Type.Ref(ErrorEnvelopeSchema), + 412: Type.Ref(ProjectStateConflictResponseSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const headers = request.headers as ProjectStateSaveHeaders; + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token }); + const saved = options.projects.saveProjectState({ + expectedStateVersion: Number(headers["if-match"]), + idempotencyKey: headers["idempotency-key"], + ownerId: owner.userId, + projectId: (request.params as ProjectParams).projectId, + state: request.body as ProjectEditableState, + }); + return { save_status: "saved" as const, state_version: saved.stateVersion }; + } catch (error) { + if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error); + if (error instanceof ProjectError && error.code === "project_state_conflict") { + return reply.code(412).send({ + latest_state_version: error.latestStateVersion ?? 1, + save_status: "conflicted" as const, + }); + } + if (error instanceof ProjectError && error.code === "project_state_idempotency_conflict") { + return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId: request.id })); + } + return projectFailure(reply, request.id, error); + } + }, + ); + + app.patch( + "/api/v1/projects/:projectId", + { + attachValidation: true, + schema: { + body: Type.Ref(ProjectRenameRequestSchema), + headers: Type.Ref(CsrfHeadersSchema), + operationId: "renameProject", + params: Type.Ref(ProjectParamsSchema), + response: { + 200: Type.Ref(ProjectRenameResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) { + return reply.code(400).send(null); + } + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const renamed = options.projects.renameProject( + owner.userId, + (request.params as ProjectParams).projectId, + (request.body as ProjectRenameRequest).name, + ); + return { name: renamed.name, state_version: renamed.stateVersion, status: "renamed" as const }; + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : projectFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/projects/failed-empty/trash", + { + attachValidation: true, + schema: { + body: Type.Ref(FailedEmptyTrashRequestSchema), + headers: Type.Ref(CsrfHeadersSchema), + operationId: "trashFailedEmptyProjects", + response: { + 200: Type.Ref(FailedEmptyTrashResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) { + return reply.code(400).send(null); + } + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const result = options.projects.trashFailedEmpty(owner.userId, (request.body as FailedEmptyTrashRequest).project_ids); + return { ignored_project_ids: result.ignoredProjectIds, trashed_project_ids: result.trashedProjectIds }; + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : projectFailure(reply, request.id, error); + } + }, + ); + + const projectLifecycle = ( + action: "trash" | "restore" | "purge", + operationId: string, + responseSchema: typeof ProjectTrashResponseSchema | typeof ProjectRestoreResponseSchema | typeof ProjectPurgeResponseSchema, + ) => { + app.post( + `/api/v1/projects/:projectId/${action}`, + { + attachValidation: true, + schema: { + headers: Type.Ref(CsrfHeadersSchema), + operationId, + params: Type.Ref(ProjectParamsSchema), + response: { + 200: Type.Ref(responseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 409: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.projects) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const projectId = (request.params as ProjectParams).projectId; + if (action === "trash") { + const project = options.projects.trashProject(owner.userId, projectId); + return { deleted_at: project.deletedAt, project_id: projectId, purge_at: project.purgeAt, status: "trashed" as const }; + } + if (action === "restore") { + options.projects.restoreProject(owner.userId, projectId); + return { deleted_at: null, project_id: projectId, purge_at: null, status: "active" as const }; + } + options.projects.purgeProject(owner.userId, projectId); + return { project_id: projectId, status: "purged" as const }; + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : projectFailure(reply, request.id, error); + } + }, + ); + }; + + projectLifecycle("trash", "trashProject", ProjectTrashResponseSchema); + projectLifecycle("restore", "restoreProject", ProjectRestoreResponseSchema); + projectLifecycle("purge", "purgeProject", ProjectPurgeResponseSchema); + app.post( "/api/v1/support/check", { @@ -281,6 +3252,107 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.get( + "/api/v1/models", + { + schema: { + operationId: "getModels", + response: { 200: ModelConfigurationResponseSchema, 503: ErrorEnvelopeSchema, 426: ErrorEnvelopeSchema }, + tags: ["Models"], + }, + }, + async (request, reply) => { + if (!options.models) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + try { + return options.models.read(); + } catch { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + + app.get( + "/api/v1/models/:model_id", + { + schema: { + operationId: "getModel", + params: ModelParamsSchema, + response: { 200: ModelConfigSchema, 404: Type.Null(), 503: ErrorEnvelopeSchema, 426: ErrorEnvelopeSchema }, + tags: ["Models"], + }, + }, + async (request, reply) => { + if (!options.models) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const model = options.models.readModel((request.params as { model_id: string }).model_id); + return model ?? reply.code(404).send(null); + }, + ); + + app.put( + "/api/v1/admin/models/configuration", + { + attachValidation: true, + schema: { + operationId: "replaceModelConfiguration", + body: ModelConfigUpdateRequestSchema, + headers: ModelConfigUpdateHeadersSchema, + response: { + 200: ModelConfigurationResponseSchema, + 400: ErrorEnvelopeSchema, + 401: ErrorEnvelopeSchema, + 403: ErrorEnvelopeSchema, + 409: ErrorEnvelopeSchema, + 412: ErrorEnvelopeSchema, + 429: ErrorEnvelopeSchema, + 503: ErrorEnvelopeSchema, + 426: ErrorEnvelopeSchema, + }, + tags: ["Admin Models"], + }, + }, + async (request, reply) => { + if (request.validationError) { + const validation = JSON.stringify(request.validationError.validation ?? []); + const priority = validation.includes("recommendation_priority"); + return reply.code(priority ? 400 : 409).send(createErrorEnvelope({ + code: priority ? "MODEL_RECOMMENDATION_PRIORITY_INVALID" : "MODEL_DEFAULT_REPLACEMENT_INVALID", + correlationId: request.id, + ...(priority ? { details: { field_errors: [{ field: "models.recommendation_priority", message_key: "model.priority.invalid" }] } } : {}), + })); + } + if (!options.models || !options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!token || !session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const headers = request.headers as { "idempotency-key": string; "x-csrf-token": string }; + const admin = options.registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token }); + const body = request.body as { + expected_config_set_version: number; + models: Array>; + }; + const result = options.models.replace({ + actorId: admin.userId, + expectedConfigSetVersion: body.expected_config_set_version, + idempotencyKey: headers["idempotency-key"], + models: body.models as never, + }); + return result; + } catch (error) { + if (error instanceof RegistrationError) { + return reply.code(error.httpStatus).send(createErrorEnvelope({ + code: error.code, + correlationId: request.id, + details: { field_errors: [registrationFieldError(error.reason)] }, + })); + } + return modelConfigurationFailure(reply, request.id, error); + } + }, + ); + app.get( "/api/v1/bootstrap", { diff --git a/apps/api/src/audit-policy.ts b/apps/api/src/audit-policy.ts new file mode 100644 index 0000000..3a64c74 --- /dev/null +++ b/apps/api/src/audit-policy.ts @@ -0,0 +1,224 @@ +import type BetterSqlite3 from "better-sqlite3"; + +export const auditRetentionMilliseconds = 180 * 24 * 60 * 60 * 1_000; + +const auditRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/; +const forbiddenSummaryKeys = new Set([ + "absolute_path", "api_key", "body", "code_hmac", "content", "credential", + "email", "image", "image_content", "password", "path", "prompt", "secret", + "session_token", "verification_code", "whitelist", +]); +const forbiddenSummaryKeyFragments = [ + "content", "credential", "email", "image", "password", "path", "prompt", "secret", "token", +]; +const safeStringPattern = /^[A-Za-z0-9_.:@-]{1,160}$/; + +function isSafeSummaryValue(value: unknown, depth: number): boolean { + if (depth > 3) return false; + if (value === null || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isSafeInteger(value); + if (typeof value === "string") return safeStringPattern.test(value) && !value.includes("@"); + if (Array.isArray(value)) return value.length <= 20 && value.every((entry) => isSafeSummaryValue(entry, depth + 1)); + if (!value || typeof value !== "object") return false; + const entries = Object.entries(value); + return entries.length <= 32 && entries.every(([key, entry]) => ( + auditRefPattern.test(key) + && !forbiddenSummaryKeys.has(key.toLowerCase()) + && !forbiddenSummaryKeyFragments.some((fragment) => key.toLowerCase().includes(fragment)) + && isSafeSummaryValue(entry, depth + 1) + )); +} + +export function isSafeAuditRef(value: unknown) { + return typeof value === "string" && auditRefPattern.test(value) ? 1 : 0; +} + +export function isSafeAuditSummaryJson(value: unknown) { + if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 2_048) return 0; + try { + return isSafeSummaryValue(JSON.parse(value), 0) ? 1 : 0; + } catch { + return 0; + } +} + +export function serializeAuditSummary(value: Record | null) { + if (value === null) return null; + const serialized = JSON.stringify(value); + if (isSafeAuditSummaryJson(serialized) !== 1) throw new Error("audit_summary_sensitive_or_invalid"); + return serialized; +} + +function toMilliseconds(value: string | number | null | undefined, fallback: number) { + if (typeof value === "number" && Number.isSafeInteger(value)) return value; + const parsed = Date.parse(String(value)); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function createAdminOperationTable(database: BetterSqlite3.Database) { + database.exec(` + CREATE TABLE admin_operation_logs ( + log_id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(actor_ref) = 1), + operation_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(operation_type) = 1), + target_type TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_type) = 1), + target_ref TEXT NOT NULL CHECK (dada_audit_ref_is_safe(target_ref) = 1), + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT CHECK (before_summary IS NULL OR dada_audit_summary_is_safe(before_summary) = 1), + after_summary TEXT CHECK (after_summary IS NULL OR dada_audit_summary_is_safe(after_summary) = 1), + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds}) + ); + `); +} + +function installAdminOperationTriggers(database: BetterSqlite3.Database) { + database.exec(` + DROP TRIGGER IF EXISTS admin_operation_logs_no_update; + DROP TRIGGER IF EXISTS admin_operation_logs_no_delete; + CREATE TRIGGER admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs + BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs + WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now() + BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + `); +} + +export function ensureAdminOperationAuditSchema(database: BetterSqlite3.Database, fallbackNow: number) { + const table = database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'admin_operation_logs' + `).get() as { sql: string } | undefined; + if (!table) { + createAdminOperationTable(database); + installAdminOperationTriggers(database); + return; + } + if (table.sql.includes("dada_audit_summary_is_safe") && table.sql.includes(String(auditRetentionMilliseconds))) { + installAdminOperationTriggers(database); + return; + } + + const columns = database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>; + const current = columns.some((column) => column.name === "actor_type"); + const rows = current + ? database.prepare("SELECT * FROM admin_operation_logs").all() as Array> + : database.prepare("SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs").all() as Array>; + const migrate = database.transaction(() => { + database.exec(` + DROP TRIGGER IF EXISTS admin_operation_logs_no_update; + DROP TRIGGER IF EXISTS admin_operation_logs_no_delete; + ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_before_wp1_06; + `); + createAdminOperationTable(database); + const insert = database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + const occurredAt = toMilliseconds(current ? row.occurred_at! : row.created_at!, fallbackNow); + const expiresAt = current ? toMilliseconds(row.expires_at!, fallbackNow) : occurredAt + auditRetentionMilliseconds; + if (expiresAt !== occurredAt + auditRetentionMilliseconds) throw new Error("audit_retention_invalid"); + const beforeSummary = current && typeof row.before_summary === "string" + ? serializeAuditSummary(JSON.parse(row.before_summary) as Record) + : null; + const afterSummary = current + ? typeof row.after_summary === "string" ? serializeAuditSummary(JSON.parse(row.after_summary) as Record) : null + : serializeAuditSummary({ legacy_outcome: String(row.outcome) }); + insert.run( + row.log_id, + current ? row.actor_type : "system", + current ? row.actor_ref : "managed_storage_migration", + current ? row.operation_type : row.operation, + current ? row.target_type : "legacy_operation", + row.target_ref, + current ? row.result : String(row.outcome).startsWith("denied") ? "failed" : "succeeded", + beforeSummary, + afterSummary, + occurredAt, + expiresAt, + ); + } + database.exec("DROP TABLE admin_operation_logs_before_wp1_06"); + installAdminOperationTriggers(database); + }); + migrate(); +} + +function createPrivateAccessTable(database: BetterSqlite3.Database) { + database.exec(` + CREATE TABLE private_content_access_logs ( + log_id TEXT PRIMARY KEY, + actor_ref TEXT NOT NULL CHECK (length(actor_ref) = 36 AND dada_audit_ref_is_safe(actor_ref) = 1), + subject_ref TEXT NOT NULL CHECK (length(subject_ref) = 36 AND dada_audit_ref_is_safe(subject_ref) = 1), + target_ref TEXT NOT NULL CHECK (length(target_ref) = 36 AND dada_audit_ref_is_safe(target_ref) = 1), + content_type TEXT NOT NULL CHECK (content_type IN ('image', 'prompt')), + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL CHECK (expires_at = occurred_at + ${auditRetentionMilliseconds}) + ); + `); +} + +function installPrivateAccessTriggers(database: BetterSqlite3.Database) { + database.exec(` + DROP TRIGGER IF EXISTS private_content_access_logs_no_update; + DROP TRIGGER IF EXISTS private_content_access_logs_no_delete; + CREATE TRIGGER private_content_access_logs_no_update + BEFORE UPDATE ON private_content_access_logs + WHEN dada_allow_privacy_purge() <> 1 + OR OLD.subject_ref <> dada_privacy_purge_subject() + OR NEW.actor_ref <> OLD.actor_ref + OR NEW.content_type <> OLD.content_type + OR NEW.occurred_at <> OLD.occurred_at + OR NEW.expires_at <> OLD.expires_at + OR NEW.subject_ref = OLD.subject_ref + OR NEW.target_ref = OLD.target_ref + BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END; + CREATE TRIGGER private_content_access_logs_no_delete + BEFORE DELETE ON private_content_access_logs + WHEN dada_allow_retention_purge() <> 1 OR OLD.expires_at > dada_retention_purge_now() + BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END; + `); +} + +export function ensurePrivateAccessAuditSchema(database: BetterSqlite3.Database) { + const table = database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'private_content_access_logs' + `).get() as { sql: string } | undefined; + if (!table) { + createPrivateAccessTable(database); + installPrivateAccessTriggers(database); + return; + } + if (table.sql.includes(String(auditRetentionMilliseconds))) { + installPrivateAccessTriggers(database); + return; + } + const rows = database.prepare("SELECT * FROM private_content_access_logs").all() as Array>; + const migrate = database.transaction(() => { + database.exec(` + DROP TRIGGER IF EXISTS private_content_access_logs_no_update; + DROP TRIGGER IF EXISTS private_content_access_logs_no_delete; + ALTER TABLE private_content_access_logs RENAME TO private_content_access_logs_before_wp1_06; + `); + createPrivateAccessTable(database); + const insert = database.prepare(` + INSERT INTO private_content_access_logs ( + log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + const occurredAt = toMilliseconds(row.occurred_at, 0); + const expiresAt = toMilliseconds(row.expires_at, 0); + if (expiresAt !== occurredAt + auditRetentionMilliseconds) throw new Error("audit_retention_invalid"); + insert.run(row.log_id, row.actor_ref, row.subject_ref, row.target_ref, row.content_type, occurredAt, expiresAt); + } + database.exec("DROP TABLE private_content_access_logs_before_wp1_06"); + installPrivateAccessTriggers(database); + }); + migrate(); +} diff --git a/apps/api/src/credit-errors.ts b/apps/api/src/credit-errors.ts new file mode 100644 index 0000000..8ae3f39 --- /dev/null +++ b/apps/api/src/credit-errors.ts @@ -0,0 +1,16 @@ +export type CreditErrorCode = + | "credit_account_not_found" + | "credit_generation_not_found" + | "credit_insufficient" + | "credit_invariant_failed" + | "credit_operation_conflict" + | "credit_request_invalid"; + +export class CreditError extends Error { + readonly code: CreditErrorCode; + + constructor(code: CreditErrorCode) { + super(code); + this.code = code; + } +} diff --git a/apps/api/src/credits.ts b/apps/api/src/credits.ts new file mode 100644 index 0000000..d42c6ca --- /dev/null +++ b/apps/api/src/credits.ts @@ -0,0 +1,549 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; + +import type BetterSqlite3 from "better-sqlite3"; + +import { + auditRetentionMilliseconds, + ensureAdminOperationAuditSchema, + isSafeAuditRef, + isSafeAuditSummaryJson, + serializeAuditSummary, +} from "./audit-policy.js"; +import { CreditError } from "./credit-errors.js"; + +export { CreditError } from "./credit-errors.js"; +export type { CreditErrorCode } from "./credit-errors.js"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; + +interface CreditAccountRow { + available_balance: number; + reserved_balance: number; + updated_at: number; + user_id: string; +} + +interface LedgerRow { + amount: number; + available_after: number; + available_before: number; + created_at: number; + entry_status: "succeeded" | "frozen" | "committed" | "released"; + entry_type: "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment"; + ledger_id: string; + model_id: string | null; + reason: string | null; + reference_id: string | null; + reference_type: "registration" | "generation" | "admin_adjustment" | null; + reserved_after: number; + reserved_before: number; + user_id: string; +} + +interface ReservationRow { + amount: number; + generation_id: string; + model_id: string; + status: "reserved" | "committed" | "released"; + user_id: string; +} + +function iso(timestamp: number) { + return new Date(timestamp).toISOString(); +} + +function encodeCursor(row: Pick) { + return Buffer.from(JSON.stringify([row.created_at, row.ledger_id]), "utf8").toString("base64url"); +} + +function decodeCursor(cursor: string | undefined) { + if (!cursor) return undefined; + try { + const value: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + if (!Array.isArray(value) || value.length !== 2 || !Number.isSafeInteger(value[0]) || typeof value[1] !== "string") { + throw new Error("cursor_invalid"); + } + return { createdAt: value[0] as number, ledgerId: value[1] }; + } catch { + throw new CreditError("credit_request_invalid"); + } +} + +export class CreditService { + readonly database: BetterSqlite3.Database; + private readonly clock: () => number; + + constructor(input: { clock?: () => number; databasePath: string }) { + this.clock = input.clock ?? Date.now; + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined); + this.database.pragma("journal_mode = WAL"); + this.database.pragma("foreign_keys = ON"); + this.database.pragma("synchronous = FULL"); + this.database.pragma("busy_timeout = 5000"); + this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef); + this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson); + this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0); + this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0); + this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0); + this.migrate(); + } + + close() { + this.database.close(); + } + + readAccount(userId: string) { + const row = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(userId) as CreditAccountRow | undefined; + if (!row) throw new CreditError("credit_account_not_found"); + return { + availableBalance: row.available_balance, + reservedBalance: row.reserved_balance, + updatedAt: iso(row.updated_at), + }; + } + + listLedger(input: { + cursor?: string; + eventType?: LedgerRow["entry_type"]; + from?: string; + limit?: number; + to?: string; + userId: string; + }) { + const account = this.readAccount(input.userId); + const limit = input.limit ?? 20; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new CreditError("credit_request_invalid"); + const cursor = decodeCursor(input.cursor); + const conditions = ["user_id = ?"]; + const values: Array = [input.userId]; + if (input.eventType) { + conditions.push("entry_type = ?"); + values.push(input.eventType); + } + if (input.from) { + const from = Date.parse(input.from); + if (!Number.isFinite(from)) throw new CreditError("credit_request_invalid"); + conditions.push("created_at >= ?"); + values.push(from); + } + if (input.to) { + const to = Date.parse(input.to); + if (!Number.isFinite(to)) throw new CreditError("credit_request_invalid"); + conditions.push("created_at <= ?"); + values.push(to); + } + if (cursor) { + conditions.push("(created_at < ? OR (created_at = ? AND ledger_id < ?))"); + values.push(cursor.createdAt, cursor.createdAt, cursor.ledgerId); + } + const rows = this.database.prepare(` + SELECT * FROM credit_ledger WHERE ${conditions.join(" AND ")} + ORDER BY created_at DESC, ledger_id DESC LIMIT ? + `).all(...values, limit + 1) as LedgerRow[]; + const hasMore = rows.length > limit; + const page = rows.slice(0, limit); + return { + account, + entries: page.map((row) => ({ + amount: row.amount, + availableAfter: row.available_after, + availableBefore: row.available_before, + createdAt: iso(row.created_at), + entryId: row.ledger_id, + entryType: row.entry_type, + modelId: row.model_id, + reason: row.reason, + referenceId: row.reference_id, + referenceType: row.reference_type, + reservedAfter: row.reserved_after, + reservedBefore: row.reserved_before, + status: row.entry_status, + })), + nextCursor: hasMore && page.length > 0 ? encodeCursor(page.at(-1)!) : null, + }; + } + + reserveGeneration(input: { + creditCost: number; + generationId: string; + modelId: string; + operationKey: string; + userId: string; + }) { + if (!Number.isSafeInteger(input.creditCost) || input.creditCost <= 0 || !input.modelId || !input.operationKey) { + throw new CreditError("credit_request_invalid"); + } + return this.immediate(() => { + const replay = this.database.prepare("SELECT * FROM credit_ledger WHERE operation_key = ?") + .get(input.operationKey) as LedgerRow | undefined; + if (replay) { + if (replay.user_id !== input.userId || replay.reference_id !== input.generationId + || replay.entry_type !== "generation_reserve" || replay.amount !== -input.creditCost + || replay.model_id !== input.modelId) { + throw new CreditError("credit_operation_conflict"); + } + return { availableBalance: replay.available_after, reservedBalance: replay.reserved_after, status: "reserved" as const }; + } + const account = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(input.userId) as CreditAccountRow | undefined; + if (!account) throw new CreditError("credit_account_not_found"); + if (account.available_balance < input.creditCost) throw new CreditError("credit_insufficient"); + if (!this.tableExists("generation_jobs")) throw new CreditError("credit_generation_not_found"); + const generation = this.database.prepare(` + SELECT generation_id FROM generation_jobs + WHERE generation_id = ? AND owner_id = ? AND status IN ('queued', 'running') + `).get(input.generationId, input.userId); + if (!generation) throw new CreditError("credit_generation_not_found"); + const existing = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?") + .get(input.generationId) as ReservationRow | undefined; + if (existing) throw new CreditError("credit_operation_conflict"); + const availableAfter = account.available_balance - input.creditCost; + const reservedAfter = account.reserved_balance + input.creditCost; + const now = this.clock(); + const changed = this.database.prepare(` + UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ? + WHERE user_id = ? AND available_balance = ? AND reserved_balance = ? + `).run(availableAfter, reservedAfter, now, input.userId, account.available_balance, account.reserved_balance); + if (changed.changes !== 1) throw new CreditError("credit_invariant_failed"); + this.database.prepare(` + INSERT INTO credit_reservations (generation_id, user_id, model_id, amount, status, created_at, finalized_at) + VALUES (?, ?, ?, ?, 'reserved', ?, NULL) + `).run(input.generationId, input.userId, input.modelId, input.creditCost, now); + this.database.prepare(` + UPDATE generation_jobs SET model_id = ?, confirmed_credit_cost = ?, reserved_credits = ?, final_credit_state = NULL + WHERE generation_id = ? + `).run(input.modelId, input.creditCost, input.creditCost, input.generationId); + this.insertLedger({ + amount: -input.creditCost, + availableAfter, + availableBefore: account.available_balance, + createdAt: now, + entryStatus: "frozen", + entryType: "generation_reserve", + modelId: input.modelId, + operationKey: input.operationKey, + reason: null, + referenceId: input.generationId, + referenceType: "generation", + reservedAfter, + reservedBefore: account.reserved_balance, + userId: input.userId, + }); + this.insertOutbox("generation_credit_reserved", input.generationId, input.operationKey, { amount: input.creditCost, user_id: input.userId }, now); + return { availableBalance: availableAfter, reservedBalance: reservedAfter, status: "reserved" as const }; + }); + } + + finalizeGeneration(input: { + generationId: string; + operationKey: string; + outcome: "succeeded" | "failed" | "rejected"; + }) { + if (!input.operationKey) throw new CreditError("credit_request_invalid"); + return this.immediate(() => { + const reservation = this.database.prepare("SELECT * FROM credit_reservations WHERE generation_id = ?") + .get(input.generationId) as ReservationRow | undefined; + if (!reservation) throw new CreditError("credit_generation_not_found"); + if (reservation.status !== "reserved") { + const replay = this.database.prepare(` + SELECT * FROM credit_ledger WHERE reference_id = ? AND entry_type IN ('generation_commit', 'generation_release') + ORDER BY created_at DESC, ledger_id DESC LIMIT 1 + `).get(input.generationId) as LedgerRow | undefined; + if (!replay) throw new CreditError("credit_invariant_failed"); + return { + availableBalance: replay.available_after, + creditState: reservation.status, + reservedBalance: replay.reserved_after, + status: "finalized" as const, + }; + } + const operation = this.database.prepare("SELECT * FROM credit_ledger WHERE operation_key = ?").get(input.operationKey) as LedgerRow | undefined; + if (operation) throw new CreditError("credit_operation_conflict"); + const account = this.database.prepare("SELECT * FROM credit_accounts WHERE user_id = ?").get(reservation.user_id) as CreditAccountRow | undefined; + if (!account || account.reserved_balance < reservation.amount) throw new CreditError("credit_invariant_failed"); + const committed = input.outcome === "succeeded"; + const creditState = committed ? "committed" as const : "released" as const; + const availableAfter = committed ? account.available_balance : account.available_balance + reservation.amount; + const reservedAfter = account.reserved_balance - reservation.amount; + if (!Number.isSafeInteger(availableAfter)) throw new CreditError("credit_invariant_failed"); + const now = this.clock(); + this.database.prepare(` + UPDATE credit_accounts SET available_balance = ?, reserved_balance = ?, updated_at = ? WHERE user_id = ? + `).run(availableAfter, reservedAfter, now, reservation.user_id); + this.database.prepare(` + UPDATE credit_reservations SET status = ?, finalized_at = ? WHERE generation_id = ? AND status = 'reserved' + `).run(creditState, now, input.generationId); + this.database.prepare("UPDATE generation_jobs SET final_credit_state = ? WHERE generation_id = ?") + .run(creditState, input.generationId); + this.insertLedger({ + amount: committed ? -reservation.amount : reservation.amount, + availableAfter, + availableBefore: account.available_balance, + createdAt: now, + entryStatus: committed ? "committed" : "released", + entryType: committed ? "generation_commit" : "generation_release", + modelId: reservation.model_id, + operationKey: input.operationKey, + reason: null, + referenceId: input.generationId, + referenceType: "generation", + reservedAfter, + reservedBefore: account.reserved_balance, + userId: reservation.user_id, + }); + this.insertOutbox(committed ? "generation_credit_committed" : "generation_credit_released", input.generationId, input.operationKey, { outcome: input.outcome }, now); + return { availableBalance: availableAfter, creditState, reservedBalance: reservedAfter, status: "finalized" as const }; + }); + } + + adjustAvailable(input: { + adjustmentId: string; + adminId: string; + amount: number; + idempotencyKey: string; + reason: string; + userId: string; + }) { + const reason = input.reason.trim(); + if (!Number.isSafeInteger(input.amount) || input.amount === 0 || !reason || reason.length > 500 + || input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 + || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { + throw new CreditError("credit_request_invalid"); + } + const idempotencyKeyDigest = createHash("sha256").update(input.idempotencyKey, "utf8").digest("hex"); + const requestHash = createHash("sha256") + .update(JSON.stringify([input.adjustmentId, input.adminId, input.userId, input.amount, reason]), "utf8") + .digest("hex"); + return this.immediate(() => { + type Receipt = { + adjustment_id: string; + admin_id: string | null; + available_after: number; + idempotency_key_digest: string | null; + request_hash: string; + reserved_after: number; + user_id: string | null; + }; + const receiptByKey = this.database.prepare(` + SELECT * FROM credit_adjustment_receipts + WHERE admin_id = ? AND user_id = ? AND idempotency_key_digest = ? + `).get(input.adminId, input.userId, idempotencyKeyDigest) as Receipt | undefined; + if (receiptByKey) { + if (receiptByKey.request_hash !== requestHash) throw new CreditError("credit_operation_conflict"); + return { + adjustmentId: receiptByKey.adjustment_id, + availableBalance: receiptByKey.available_after, + reservedBalance: receiptByKey.reserved_after, + status: "adjusted" as const, + }; + } + const receipt = this.database.prepare("SELECT * FROM credit_adjustment_receipts WHERE adjustment_id = ?") + .get(input.adjustmentId) as Receipt | undefined; + if (receipt) { + if (receipt.request_hash !== requestHash || receipt.admin_id !== input.adminId || receipt.user_id !== input.userId + || receipt.idempotency_key_digest !== idempotencyKeyDigest) { + throw new CreditError("credit_operation_conflict"); + } + return { + adjustmentId: input.adjustmentId, + availableBalance: receipt.available_after, + reservedBalance: receipt.reserved_after, + status: "adjusted" as const, + }; + } + const admin = this.database.prepare(` + SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(input.adminId); + if (!admin) throw new CreditError("credit_account_not_found"); + const target = this.database.prepare(` + SELECT c.* FROM credit_accounts c JOIN users u ON u.user_id = c.user_id + WHERE c.user_id = ? AND u.role = 'user' AND u.status <> 'deleted' + `).get(input.userId) as CreditAccountRow | undefined; + if (!target) throw new CreditError("credit_account_not_found"); + const availableAfter = target.available_balance + input.amount; + if (!Number.isSafeInteger(availableAfter)) throw new CreditError("credit_request_invalid"); + const now = this.clock(); + this.database.prepare("UPDATE credit_accounts SET available_balance = ?, updated_at = ? WHERE user_id = ?") + .run(availableAfter, now, input.userId); + const ledgerId = this.insertLedger({ + amount: input.amount, + availableAfter, + availableBefore: target.available_balance, + createdAt: now, + entryStatus: "succeeded", + entryType: "admin_adjustment", + modelId: null, + operationKey: `admin_adjustment:${createHash("sha256").update(`${input.adminId}\0${input.userId}\0${idempotencyKeyDigest}`, "utf8").digest("hex")}`, + reason, + referenceId: input.adjustmentId, + referenceType: "admin_adjustment", + reservedAfter: target.reserved_balance, + reservedBefore: target.reserved_balance, + userId: input.userId, + }); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'super_admin', ?, 'credit_adjustment', 'user_credit_account', ?, 'succeeded', ?, ?, ?, ?) + `).run( + randomUUID(), input.adminId, input.userId, + serializeAuditSummary({ available_balance: target.available_balance, reserved_balance: target.reserved_balance }), + serializeAuditSummary({ adjustment_amount: input.amount, available_balance: availableAfter, reserved_balance: target.reserved_balance }), + now, now + auditRetentionMilliseconds, + ); + this.database.prepare(` + INSERT INTO credit_adjustment_receipts ( + adjustment_id, admin_id, user_id, idempotency_key_digest, request_hash, + ledger_id, available_after, reserved_after, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.adjustmentId, input.adminId, input.userId, idempotencyKeyDigest, requestHash, + ledgerId, availableAfter, target.reserved_balance, now, + ); + return { + adjustmentId: input.adjustmentId, + availableBalance: availableAfter, + reservedBalance: target.reserved_balance, + status: "adjusted" as const, + }; + }); + } + + private immediate(action: () => T): T { + if (this.database.inTransaction) return action(); + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = action(); + this.database.exec("COMMIT"); + return result; + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + throw error; + } + } + + private insertLedger(input: { + amount: number; + availableAfter: number; + availableBefore: number; + createdAt: number; + entryStatus: LedgerRow["entry_status"]; + entryType: LedgerRow["entry_type"]; + modelId: string | null; + operationKey: string; + reason: string | null; + referenceId: string | null; + referenceType: LedgerRow["reference_type"]; + reservedAfter: number; + reservedBefore: number; + userId: string; + }) { + const ledgerId = randomUUID(); + this.database.prepare(` + INSERT INTO credit_ledger ( + ledger_id, user_id, operation_key, entry_type, amount, + available_before, available_after, reserved_before, reserved_after, created_at, + reference_type, reference_id, model_id, reason, entry_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + ledgerId, input.userId, input.operationKey, input.entryType, input.amount, + input.availableBefore, input.availableAfter, input.reservedBefore, input.reservedAfter, input.createdAt, + input.referenceType, input.referenceId, input.modelId, input.reason, input.entryStatus, + ); + return ledgerId; + } + + private insertOutbox(topic: string, aggregateId: string, operationKey: string, payload: Record, now: number) { + this.database.prepare(` + INSERT INTO outbox_events ( + event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at + ) VALUES (?, ?, ?, 'generation', ?, ?, 'pending', ?, NULL) + `).run(randomUUID(), operationKey, topic, aggregateId, JSON.stringify(payload), now); + } + + private tableExists(name: string) { + return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + } + + private ensureColumn(table: string, column: string, definition: string) { + const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + if (!columns.some((value) => value.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } + + private migrate() { + if (!this.tableExists("credit_accounts") || !this.tableExists("credit_ledger")) { + throw new Error("credit_schema_unavailable"); + } + this.ensureColumn("credit_ledger", "reference_type", "TEXT"); + this.ensureColumn("credit_ledger", "reference_id", "TEXT"); + this.ensureColumn("credit_ledger", "model_id", "TEXT"); + this.ensureColumn("credit_ledger", "reason", "TEXT"); + this.ensureColumn("credit_ledger", "entry_status", "TEXT NOT NULL DEFAULT 'succeeded'"); + if (this.tableExists("generation_jobs")) { + this.ensureColumn("generation_jobs", "model_id", "TEXT"); + this.ensureColumn("generation_jobs", "model_config_version", "INTEGER"); + this.ensureColumn("generation_jobs", "confirmed_credit_cost", "INTEGER"); + this.ensureColumn("generation_jobs", "reserved_credits", "INTEGER NOT NULL DEFAULT 0"); + this.ensureColumn("generation_jobs", "final_credit_state", "TEXT"); + this.ensureColumn("generation_jobs", "finished_at", "INTEGER"); + } + this.database.exec(` + CREATE TABLE IF NOT EXISTS credit_reservations ( + generation_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id), + model_id TEXT NOT NULL, + amount INTEGER NOT NULL CHECK (amount > 0), + status TEXT NOT NULL CHECK (status IN ('reserved', 'committed', 'released')), + created_at INTEGER NOT NULL, + finalized_at INTEGER + ); + CREATE INDEX IF NOT EXISTS credit_reservations_user_status ON credit_reservations(user_id, status, created_at); + CREATE TABLE IF NOT EXISTS credit_adjustment_receipts ( + adjustment_id TEXT PRIMARY KEY, + admin_id TEXT REFERENCES users(user_id), + user_id TEXT REFERENCES users(user_id), + idempotency_key_digest TEXT CHECK (idempotency_key_digest IS NULL OR length(idempotency_key_digest) = 64), + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + ledger_id TEXT NOT NULL UNIQUE REFERENCES credit_ledger(ledger_id), + available_after INTEGER NOT NULL, + reserved_after INTEGER NOT NULL CHECK (reserved_after >= 0), + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS outbox_events ( + event_id TEXT PRIMARY KEY, + operation_key TEXT NOT NULL UNIQUE, + topic TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + payload_json TEXT NOT NULL CHECK (json_valid(payload_json)), + status TEXT NOT NULL CHECK (status IN ('pending', 'published')), + created_at INTEGER NOT NULL, + published_at INTEGER + ); + DROP TRIGGER IF EXISTS credit_ledger_shape_guard; + CREATE TRIGGER credit_ledger_shape_guard BEFORE INSERT ON credit_ledger + WHEN + NEW.entry_status NOT IN ('succeeded', 'frozen', 'committed', 'released') + OR (NEW.entry_type = 'admin_adjustment' AND ( + NEW.reference_type <> 'admin_adjustment' OR NEW.reference_id IS NULL OR trim(COALESCE(NEW.reason, '')) = '' + )) + OR (NEW.entry_type IN ('generation_reserve', 'generation_commit', 'generation_release') AND ( + NEW.reference_type <> 'generation' OR NEW.reference_id IS NULL OR NEW.model_id IS NULL + )) + BEGIN SELECT RAISE(ABORT, 'credit_ledger_shape_invalid'); END; + `); + this.ensureColumn("credit_adjustment_receipts", "admin_id", "TEXT REFERENCES users(user_id)"); + this.ensureColumn("credit_adjustment_receipts", "user_id", "TEXT REFERENCES users(user_id)"); + this.ensureColumn("credit_adjustment_receipts", "idempotency_key_digest", "TEXT"); + this.database.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS credit_adjustment_idempotency + ON credit_adjustment_receipts(admin_id, user_id, idempotency_key_digest) + WHERE admin_id IS NOT NULL AND user_id IS NOT NULL AND idempotency_key_digest IS NOT NULL; + `); + ensureAdminOperationAuditSchema(this.database, this.clock()); + } +} diff --git a/apps/api/src/external-service-usage.ts b/apps/api/src/external-service-usage.ts new file mode 100644 index 0000000..f2f4b45 --- /dev/null +++ b/apps/api/src/external-service-usage.ts @@ -0,0 +1,459 @@ +import { randomUUID } from "node:crypto"; + +import type BetterSqlite3 from "better-sqlite3"; + +import { serializeAuditSummary } from "./audit-policy.js"; + +export type ExternalServiceId = "resend_email" | "amap_web_service"; +export type ExternalServicePeriodType = "daily" | "monthly"; +export type ExternalServiceStatus = "active" | "paused_quota" | "paused_provider" | "disabled"; + +const retentionMilliseconds = 180 * 24 * 60 * 60 * 1_000; +const recoveryCheckLifetimeMilliseconds = 15 * 60 * 1_000; +const maximumHardLimits: Record>> = { + resend_email: { daily: 80, monthly: 2_400 }, + amap_web_service: { monthly: 1_000 }, +}; + +export interface ExternalServiceUsageRow { + serviceId: ExternalServiceId; + periodType: ExternalServicePeriodType; + periodStart: number; + hardLimit: number; + usedCount: number; + status: ExternalServiceStatus; + pauseReason: string | null; + updatedAt: number; +} + +export class ExternalServiceUsageError extends Error { + constructor( + readonly code: + | "service_paused_quota" + | "service_paused_provider" + | "service_disabled" + | "hard_limit_increase_forbidden" + | "hard_limit_invalid" + | "health_check_required" + | "quota_exhausted" + | "service_not_found", + message = code, + ) { + super(message); + this.name = "ExternalServiceUsageError"; + } +} + +interface ExternalServiceUsageOptions { + clock?: () => number; + database: BetterSqlite3.Database; +} + +interface RawUsageRow { + service_id: ExternalServiceId; + period_type: ExternalServicePeriodType; + period_start: number; + hard_limit: number; + used_count: number; + service_status: ExternalServiceStatus; + pause_reason: string | null; + updated_at: number; +} + +function periodStart(periodType: ExternalServicePeriodType, now: number) { + const date = new Date(now); + if (periodType === "daily") return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1); +} + +function requiredPeriods(serviceId: ExternalServiceId): ExternalServicePeriodType[] { + return serviceId === "resend_email" ? ["daily", "monthly"] : ["monthly"]; +} + +function toPublic(row: RawUsageRow): ExternalServiceUsageRow { + return { + hardLimit: row.hard_limit, + pauseReason: row.pause_reason, + periodStart: row.period_start, + periodType: row.period_type, + serviceId: row.service_id, + status: row.service_status, + updatedAt: row.updated_at, + usedCount: row.used_count, + }; +} + +export class ExternalServiceUsage { + readonly database: BetterSqlite3.Database; + readonly clock: () => number; + + constructor(options: ExternalServiceUsageOptions) { + this.database = options.database; + this.clock = options.clock ?? Date.now; + this.ensureSchema(); + this.runImmediate(() => { + this.ensureCurrentRows(this.clock(), "resend_email"); + this.ensureCurrentRows(this.clock(), "amap_web_service"); + }); + } + + claimResend(now = this.clock()) { + return this.runImmediate(() => this.claimWithinTransaction("resend_email", now)); + } + + claimResendWithinTransaction(now = this.clock()) { + return this.claimWithinTransaction("resend_email", now); + } + + claimAmap(now = this.clock()) { + return this.runImmediate(() => this.claimWithinTransaction("amap_web_service", now)); + } + + claimAmapWithinTransaction(now = this.clock()) { + return this.claimWithinTransaction("amap_web_service", now); + } + + markProviderFailure(input: { serviceId: ExternalServiceId; reason: string; now?: number }) { + return this.runImmediate(() => this.markProviderFailureWithinTransaction(input)); + } + + markProviderFailureWithinTransaction(input: { serviceId: ExternalServiceId; reason: string; now?: number }) { + const now = input.now ?? this.clock(); + const rows = this.ensureCurrentRows(now, input.serviceId); + const reason = normalizeReason(input.reason); + for (const row of rows) { + if (row.service_status === "disabled") continue; + this.database.prepare(` + UPDATE external_service_usage + SET service_status = 'paused_provider', pause_reason = ?, updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(reason, now, row.service_id, row.period_type, row.period_start); + } + this.recordAudit({ + actorRef: "external_service_runtime", + actorType: "system", + afterSummary: { pause_reason: reason, status: "paused_provider" }, + beforeSummary: { status: rows[0]?.service_status ?? "active" }, + operationType: "service_provider_pause", + result: "succeeded", + targetRef: input.serviceId, + targetType: "external_service", + }, now); + return this.read(input.serviceId); + } + + recordHealthCheck(input: { serviceId: ExternalServiceId; available: boolean; reason?: string; now?: number }) { + const now = input.now ?? this.clock(); + const checkId = randomUUID(); + const currentPeriod = periodStart(requiredPeriods(input.serviceId)[0]!, now); + const result = this.runImmediate(() => { + this.database.prepare(` + INSERT INTO service_recovery_checks ( + check_id, service_name, target_ref, status, checked_at, expires_at, details_json, + service_id, period_start, available, check_reason + ) VALUES (?, 'external_service', ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + checkId, + input.serviceId, + input.available ? "passed" : "failed", + now, + now + recoveryCheckLifetimeMilliseconds, + JSON.stringify({ non_sensitive: true }), + input.serviceId, + currentPeriod, + input.available ? 1 : 0, + input.reason ? normalizeReason(input.reason) : null, + ); + return { checkId, available: input.available, checkedAt: now }; + }); + return result; + } + + recover(input: { serviceId: ExternalServiceId; actorId: string; checkId: string; now?: number }) { + const now = input.now ?? this.clock(); + const result = this.runImmediate(() => { + const check = this.database.prepare(` + SELECT check_id, period_start, available, expires_at + FROM service_recovery_checks + WHERE check_id = ? AND service_id = ? AND service_name = 'external_service' + `).get(input.checkId, input.serviceId) as { available: number; check_id: string; expires_at: number; period_start: number } | undefined; + const currentPeriod = periodStart(requiredPeriods(input.serviceId)[0]!, now); + if (!check?.available || check.period_start !== currentPeriod || check.expires_at <= now) { + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { reason: "health_check_required" }, + beforeSummary: null, + operationType: "service_recovery", + result: "failed", + targetRef: input.serviceId, + targetType: "external_service", + }, now); + return { error: new ExternalServiceUsageError("health_check_required") }; + } + const rows = this.ensureCurrentRows(now, input.serviceId); + if (rows.some((row) => row.used_count >= row.hard_limit)) { + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { reason: "quota_exhausted" }, + beforeSummary: { status: rows[0]?.service_status ?? "paused_quota" }, + operationType: "service_recovery", + result: "failed", + targetRef: input.serviceId, + targetType: "external_service", + }, now); + return { error: new ExternalServiceUsageError("quota_exhausted") }; + } + for (const row of rows) { + this.database.prepare(` + UPDATE external_service_usage + SET service_status = 'active', pause_reason = NULL, updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(now, row.service_id, row.period_type, row.period_start); + } + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { check_id: input.checkId, status: "active" }, + beforeSummary: { status: rows[0]?.service_status ?? "paused_provider" }, + operationType: "service_recovery", + result: "succeeded", + targetRef: input.serviceId, + targetType: "external_service", + }, now); + return { status: "active" as const }; + }); + if ("error" in result && result.error) throw result.error; + return result; + } + + setHardLimit(input: { + serviceId: ExternalServiceId; + periodType: ExternalServicePeriodType; + hardLimit: number; + actorId: string; + now?: number; + }) { + const now = input.now ?? this.clock(); + const result = this.runImmediate(() => { + const maximum = maximumHardLimits[input.serviceId][input.periodType]; + const current = this.ensureCurrentRows(now, input.serviceId).find((row) => row.period_type === input.periodType); + if (current && input.hardLimit > current.hard_limit) { + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { reason: "hard_limit_increase_forbidden" }, + beforeSummary: { hard_limit: current.hard_limit }, + operationType: "service_hard_limit_update", + result: "failed", + targetRef: `${input.serviceId}:${input.periodType}`, + targetType: "external_service_limit", + }, now); + return { error: new ExternalServiceUsageError("hard_limit_increase_forbidden") }; + } + const valid = maximum !== undefined && Number.isSafeInteger(input.hardLimit) && input.hardLimit >= 1 && input.hardLimit <= maximum; + if (!valid || !current) { + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { reason: "hard_limit_invalid" }, + beforeSummary: current ? { hard_limit: current.hard_limit } : null, + operationType: "service_hard_limit_update", + result: "failed", + targetRef: `${input.serviceId}:${input.periodType}`, + targetType: "external_service_limit", + }, now); + return { error: new ExternalServiceUsageError("hard_limit_invalid") }; + } + this.database.prepare(` + UPDATE external_service_usage + SET hard_limit = ?, service_status = CASE + WHEN used_count >= ? THEN 'paused_quota' + ELSE service_status + END, pause_reason = CASE + WHEN used_count >= ? THEN 'hard_limit_reached' + ELSE pause_reason + END, updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(input.hardLimit, input.hardLimit, input.hardLimit, now, current.service_id, current.period_type, current.period_start); + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { hard_limit: input.hardLimit }, + beforeSummary: { hard_limit: current.hard_limit }, + operationType: "service_hard_limit_update", + result: "succeeded", + targetRef: `${input.serviceId}:${input.periodType}`, + targetType: "external_service_limit", + }, now); + return this.read(input.serviceId); + }); + if ("error" in result && result.error) throw result.error; + return result; + } + + read(serviceId?: ExternalServiceId) { + const rows = (serviceId + ? this.database.prepare("SELECT * FROM external_service_usage WHERE service_id = ? ORDER BY period_type").all(serviceId) + : this.database.prepare("SELECT * FROM external_service_usage ORDER BY service_id, period_type").all()) as RawUsageRow[]; + return rows.map(toPublic); + } + + readCurrent() { + const now = this.clock(); + return this.read().filter((row) => row.periodStart === periodStart(row.periodType, now)); + } + + readStatus(serviceId: ExternalServiceId) { + const rows = this.read(serviceId).filter((row) => row.periodStart >= periodStart(row.periodType, this.clock())); + const status = rows.some((row) => row.status === "disabled") + ? "disabled" + : rows.some((row) => row.status === "paused_provider") + ? "paused_provider" + : rows.some((row) => row.status === "paused_quota") + ? "paused_quota" + : "active"; + return { serviceId, status, rows } as const; + } + + private claimWithinTransaction(serviceId: ExternalServiceId, now: number) { + const rows = this.ensureCurrentRows(now, serviceId); + for (const row of rows) { + if (row.service_status === "paused_quota") throw new ExternalServiceUsageError("service_paused_quota"); + if (row.service_status === "paused_provider") throw new ExternalServiceUsageError("service_paused_provider"); + if (row.service_status === "disabled") throw new ExternalServiceUsageError("service_disabled"); + if (row.used_count >= row.hard_limit) { + this.database.prepare(` + UPDATE external_service_usage + SET service_status = 'paused_quota', pause_reason = 'hard_limit_reached', updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(now, row.service_id, row.period_type, row.period_start); + throw new ExternalServiceUsageError("service_paused_quota"); + } + } + const updated = rows.map((row) => { + const usedCount = row.used_count + 1; + const status: ExternalServiceStatus = usedCount >= row.hard_limit ? "paused_quota" : "active"; + this.database.prepare(` + UPDATE external_service_usage + SET used_count = ?, service_status = ?, pause_reason = CASE WHEN ? = 'active' THEN NULL ELSE 'hard_limit_reached' END, updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(usedCount, status, status, now, row.service_id, row.period_type, row.period_start); + return { periodType: row.period_type, remaining: Math.max(0, row.hard_limit - usedCount), usedCount }; + }); + return { allowed: true as const, serviceId, allocations: updated, remaining: Math.min(...updated.map((item) => item.remaining)) }; + } + + private ensureCurrentRows(now: number, serviceId: ExternalServiceId) { + const periods = requiredPeriods(serviceId); + for (const periodType of periods) { + const start = periodStart(periodType, now); + const current = this.database.prepare(` + SELECT * FROM external_service_usage WHERE service_id = ? AND period_type = ? AND period_start = ? + `).get(serviceId, periodType, start) as RawUsageRow | undefined; + if (current) continue; + const previous = this.database.prepare(` + SELECT hard_limit FROM external_service_usage + WHERE service_id = ? AND period_type = ? ORDER BY period_start DESC LIMIT 1 + `).get(serviceId, periodType) as { hard_limit: number } | undefined; + const maximum = maximumHardLimits[serviceId][periodType]; + if (maximum === undefined) throw new ExternalServiceUsageError("service_not_found"); + this.database.prepare(` + INSERT INTO external_service_usage ( + service_id, period_type, period_start, hard_limit, used_count, + service_status, pause_reason, updated_at + ) VALUES (?, ?, ?, ?, 0, ?, ?, ?) + `).run(serviceId, periodType, start, previous?.hard_limit ?? maximum, previous ? "paused_quota" : "active", previous ? "period_confirmation_required" : null, now); + } + return this.database.prepare(` + SELECT * FROM external_service_usage + WHERE service_id = ? AND period_start IN (${periods.map(() => "?").join(",")}) + ORDER BY period_type + `).all(serviceId, ...periods.map((period) => periodStart(period, now))) as RawUsageRow[]; + } + + private ensureSchema() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS external_service_usage ( + service_id TEXT NOT NULL CHECK (service_id IN ('resend_email', 'amap_web_service')), + period_type TEXT NOT NULL CHECK (period_type IN ('daily', 'monthly')), + period_start INTEGER NOT NULL, + hard_limit INTEGER NOT NULL CHECK (hard_limit >= 1), + used_count INTEGER NOT NULL CHECK (used_count >= 0 AND used_count <= hard_limit), + service_status TEXT NOT NULL CHECK (service_status IN ('active', 'paused_quota', 'paused_provider', 'disabled')), + pause_reason TEXT, + updated_at INTEGER NOT NULL, + PRIMARY KEY (service_id, period_type, period_start) + ); + CREATE TABLE IF NOT EXISTS service_recovery_checks ( + check_id TEXT PRIMARY KEY, + service_name TEXT NOT NULL DEFAULT 'external_service', + target_ref TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'passed' CHECK (status IN ('passed', 'failed')), + checked_at INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL DEFAULT 0, + details_json TEXT NOT NULL DEFAULT '{}', + service_id TEXT CHECK (service_id IS NULL OR service_id IN ('resend_email', 'amap_web_service')), + period_start INTEGER, + available INTEGER CHECK (available IS NULL OR available IN (0, 1)), + check_reason TEXT + ); + `); + const columns = new Set((this.database.prepare("PRAGMA table_info(service_recovery_checks)").all() as Array<{ name: string }>).map((column) => column.name)); + const additions: Array<[string, string]> = [ + ["service_name", "TEXT NOT NULL DEFAULT 'external_service'"], + ["target_ref", "TEXT NOT NULL DEFAULT ''"], + ["status", "TEXT NOT NULL DEFAULT 'passed'"], + ["expires_at", "INTEGER NOT NULL DEFAULT 0"], + ["details_json", "TEXT NOT NULL DEFAULT '{}'"], + ["service_id", "TEXT"], + ["period_start", "INTEGER"], + ["available", "INTEGER"], + ["check_reason", "TEXT"], + ]; + for (const [name, definition] of additions) { + if (!columns.has(name)) this.database.exec(`ALTER TABLE service_recovery_checks ADD COLUMN ${name} ${definition}`); + } + } + + private runImmediate(action: () => T): T { + const nested = this.database.inTransaction; + if (!nested) this.database.exec("BEGIN IMMEDIATE"); + try { + const result = action(); + if (!nested) this.database.exec("COMMIT"); + return result; + } catch (error) { + if (!nested && this.database.inTransaction) this.database.exec("ROLLBACK"); + throw error; + } + } + + private recordAudit(input: { + actorRef: string; + actorType: "system" | "super_admin"; + afterSummary: Record | null; + beforeSummary: Record | null; + operationType: string; + result: "succeeded" | "failed"; + targetRef: string; + targetType: string; + }, now: number) { + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + randomUUID(), input.actorType, input.actorRef, input.operationType, input.targetType, input.targetRef, + input.result, serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), now, + now + retentionMilliseconds, + ); + } +} + +function normalizeReason(reason: string) { + const normalized = reason.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "_").slice(0, 120); + return normalized || "provider_unavailable"; +} diff --git a/apps/api/src/generation-submission-errors.ts b/apps/api/src/generation-submission-errors.ts new file mode 100644 index 0000000..e92c147 --- /dev/null +++ b/apps/api/src/generation-submission-errors.ts @@ -0,0 +1,30 @@ +export type GenerationSubmissionErrorCode = + | "generation_blocked" + | "generation_idempotency_conflict" + | "generation_not_found" + | "generation_request_invalid" + | "generation_storage_unavailable" + | "model_config_stale" + | "reference_invalid"; + +export class GenerationSubmissionError extends Error { + readonly code: GenerationSubmissionErrorCode; + readonly errorCategory: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | undefined; + readonly latest: { configVersion: number; creditCost: number; modelId: string } | undefined; + readonly storage: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number } | undefined; + + constructor( + code: GenerationSubmissionErrorCode, + options: { + errorCategory?: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid"; + latest?: { configVersion: number; creditCost: number; modelId: string }; + storage?: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number }; + } = {}, + ) { + super(code); + this.code = code; + this.errorCategory = options.errorCategory; + this.latest = options.latest; + this.storage = options.storage; + } +} diff --git a/apps/api/src/generation-submission.ts b/apps/api/src/generation-submission.ts new file mode 100644 index 0000000..8b3c292 --- /dev/null +++ b/apps/api/src/generation-submission.ts @@ -0,0 +1,547 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { Readable } from "node:stream"; + +import type BetterSqlite3 from "better-sqlite3"; + +import type { CreditService } from "./credits.js"; +import { GenerationSubmissionError } from "./generation-submission-errors.js"; +import type { ManagedStorage, StagedManagedFile } from "./managed-storage.js"; +import { + defaultCanvasState, + defaultProjectName, + historyLimit, + normalizePrompt, + projectLimit, + projectRatios, + ratioPixels, + stableJson, + type ProjectRatio, +} from "./projects.js"; +import { classifyCapacity } from "./storage-policy.js"; + +export { GenerationSubmissionError } from "./generation-submission-errors.js"; + +const idempotencyPattern = /^[A-Za-z0-9_-]{32,200}$/; +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export interface GenerationModelSnapshot { + configSetVersion: number; + configVersion: number; + contractValidationStatus: "verified" | "unverified"; + creditCost: number; + enabled: boolean; + modelId: string; + promptMaxLength: number; + referenceLimits: { maxFileBytes: number; maxFiles: number; maxTotalBytes: number }; + runtimeAvailability: { + availableForNewJobs: boolean; + reason: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | null; + }; + supportedRatios: readonly ProjectRatio[]; +} + +export interface GenerationModelCatalog { + readModel(modelId: string): GenerationModelSnapshot | undefined; +} + +export class StaticGenerationModelCatalog implements GenerationModelCatalog { + private readonly models = new Map(); + + constructor(models: GenerationModelSnapshot[]) { + for (const model of models) this.models.set(model.modelId, structuredClone(model)); + } + + readModel(modelId: string) { + const model = this.models.get(modelId); + return model ? structuredClone(model) : undefined; + } + + replace(model: GenerationModelSnapshot) { + this.models.set(model.modelId, structuredClone(model)); + } +} + +export interface NewGenerationReference { + content: Readable; + fileName: string; + mimeType: "image/jpeg" | "image/png" | "image/webp"; + projectedBytes: number; +} + +export interface GenerationSubmissionInput { + clientSubmissionId: string; + confirmedCreditCost: number; + existingReferenceAssetIds: string[]; + idempotencyKey: string; + mode: "new_project" | "existing_project"; + modelConfigVersion: number; + modelId: string; + newReferences: NewGenerationReference[]; + projectId?: string; + prompt: string; + ratio: ProjectRatio; + userId: string; +} + +export type GenerationSubmissionFields = Omit; + +export interface GenerationTaskView { + confirmedCreditCost: number; + createdAt: string; + errorCategory: "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" + | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" + | "unknown_retryable" | "unknown_non_retryable" | null; + generationId: string; + modelConfigVersion: number; + modelId: string; + projectId: string; + prompt: string; + ratio: ProjectRatio; + referenceAssetIds: string[]; + referenceCount: number; + reservedCredits: number; + status: "queued" | "running" | "succeeded" | "failed" | "rejected"; + updatedAt: string; +} + +export type GenerationSubmissionResult = { created: boolean; task: GenerationTaskView }; + +export interface GenerationUploadSession { + abort(): void; + commit(): Promise; + stageReference(reference: NewGenerationReference): Promise; +} + +interface GenerationRow { + confirmed_credit_cost: number; + created_at: number; + error_category: GenerationTaskView["errorCategory"]; + generation_id: string; + model_config_version: number; + model_id: string; + owner_id: string; + project_id: string; + prompt: string; + ratio: ProjectRatio; + reserved_credits: number; + status: "queued" | "running" | "succeeded" | "failed" | "rejected"; + updated_at: number; +} + +function iso(timestamp: number) { + return new Date(timestamp).toISOString(); +} + +function digest(value: string) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function safeInteger(value: number) { + return Number.isSafeInteger(value) && value > 0; +} + +export class GenerationSubmissionService { + readonly database: BetterSqlite3.Database; + private readonly beforeTransaction: (() => Promise) | undefined; + private readonly clock: () => number; + private readonly credits: CreditService; + private readonly models: GenerationModelCatalog; + private readonly storage: ManagedStorage; + + constructor(input: { + beforeTransaction?: () => Promise; + clock?: () => number; + credits: CreditService; + models: GenerationModelCatalog; + storage: ManagedStorage; + }) { + this.beforeTransaction = input.beforeTransaction; + this.clock = input.clock ?? Date.now; + this.credits = input.credits; + this.database = input.credits.database; + this.models = input.models; + this.storage = input.storage; + this.migrate(); + } + + close() { + // The database connection is owned by CreditService. + } + + readCurrentTask(userId: string) { + const row = this.database.prepare(` + SELECT * FROM generation_jobs + WHERE owner_id = ? AND status IN ('queued', 'running') AND submission_ready = 1 + ORDER BY created_at DESC, generation_id DESC LIMIT 1 + `).get(userId) as GenerationRow | undefined; + return row ? this.taskView(row) : undefined; + } + + readTask(userId: string, generationId: string) { + const row = this.database.prepare(` + SELECT * FROM generation_jobs WHERE generation_id = ? AND owner_id = ? AND submission_ready = 1 + `).get(generationId, userId) as GenerationRow | undefined; + if (!row) throw new GenerationSubmissionError("generation_not_found"); + return this.taskView(row); + } + + async submit(input: GenerationSubmissionInput): Promise { + const current = this.readCurrentTask(input.userId); + if (current) return { created: false as const, task: current }; + const { newReferences, ...fields } = input; + const upload = this.beginUpload(fields); + try { + for (const reference of newReferences) await upload.stageReference(reference); + return await upload.commit(); + } catch (error) { + upload.abort(); + throw error; + } + } + + beginUpload(input: GenerationSubmissionFields): GenerationUploadSession { + const preflightModel = this.validate({ ...input, newReferences: [] }); + const staged: StagedManagedFile[] = []; + let finished = false; + const abort = () => { + if (finished) return; + finished = true; + for (const file of staged) this.storage.abandonStagedFile(file); + }; + return { + abort, + commit: async () => { + if (finished) throw new GenerationSubmissionError("generation_request_invalid"); + if (this.beforeTransaction) await this.beforeTransaction(); + try { + const result = this.immediate(() => this.commitSubmission({ ...input, newReferences: [] }, staged)); + if (result.created) finished = true; + else abort(); + return result; + } catch (error) { + abort(); + throw error; + } + }, + stageReference: async (reference) => { + if (finished) throw new GenerationSubmissionError("generation_request_invalid"); + const nextCount = input.existingReferenceAssetIds.length + staged.length + 1; + const nextBytes = staged.reduce((sum, file) => sum + file.bytes, 0) + reference.projectedBytes; + if (nextCount > preflightModel.referenceLimits.maxFiles || nextBytes > preflightModel.referenceLimits.maxTotalBytes + || !safeInteger(reference.projectedBytes) || reference.projectedBytes > preflightModel.referenceLimits.maxFileBytes) { + throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + } + try { + staged.push(await this.storage.stagePrivateImage({ + content: reference.content, + expectedMimeType: reference.mimeType, + fileName: reference.fileName, + maximumBytes: preflightModel.referenceLimits.maxFileBytes, + operationId: randomUUID(), + ownerRef: input.userId, + projectedWriteBytes: reference.projectedBytes, + })); + } catch (error) { + if (error instanceof GenerationSubmissionError || (error && typeof error === "object" && "code" in error)) throw error; + throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + } + }, + }; + } + + private validate(input: GenerationSubmissionInput) { + if (!uuidPattern.test(input.userId) || !uuidPattern.test(input.clientSubmissionId) + || !idempotencyPattern.test(input.idempotencyKey) || !projectRatios.includes(input.ratio) + || !safeInteger(input.modelConfigVersion) || !safeInteger(input.confirmedCreditCost) + || (input.mode === "existing_project" && (!input.projectId || !uuidPattern.test(input.projectId))) + || (input.mode === "new_project" && input.projectId !== undefined)) { + throw new GenerationSubmissionError("generation_request_invalid"); + } + const prompt = normalizePrompt(input.prompt); + const model = this.models.readModel(input.modelId); + if (!model) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" }); + const workerState = this.database.prepare("SELECT status FROM worker_runtime_state WHERE singleton = 1").get() as { status: "ready" | "degraded" } | undefined; + if (workerState?.status === "degraded") throw new GenerationSubmissionError("generation_blocked", { errorCategory: "gateway_contract_invalid" }); + if (model.configVersion !== input.modelConfigVersion || model.creditCost !== input.confirmedCreditCost) { + throw new GenerationSubmissionError("model_config_stale", { + latest: { configVersion: model.configVersion, creditCost: model.creditCost, modelId: model.modelId }, + }); + } + if (!model.enabled) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" }); + if (model.contractValidationStatus !== "verified") { + throw new GenerationSubmissionError("generation_blocked", { errorCategory: "gateway_contract_invalid" }); + } + if (!model.runtimeAvailability.availableForNewJobs) { + throw new GenerationSubmissionError("generation_blocked", { errorCategory: model.runtimeAvailability.reason ?? "model_disabled" }); + } + if (prompt.length > model.promptMaxLength || !model.supportedRatios.includes(input.ratio)) { + throw new GenerationSubmissionError("generation_request_invalid"); + } + const referenceCount = input.newReferences.length + input.existingReferenceAssetIds.length; + const projectedBytes = input.newReferences.reduce((sum, reference) => sum + reference.projectedBytes, 0); + if (referenceCount > model.referenceLimits.maxFiles || projectedBytes > model.referenceLimits.maxTotalBytes + || input.newReferences.some((reference) => !safeInteger(reference.projectedBytes) || reference.projectedBytes > model.referenceLimits.maxFileBytes) + || new Set(input.existingReferenceAssetIds).size !== input.existingReferenceAssetIds.length + || input.existingReferenceAssetIds.some((id) => !uuidPattern.test(id)) + || (input.mode === "new_project" && input.existingReferenceAssetIds.length > 0)) { + throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + } + const storageState = this.storage.getState(); + if (storageState.storage_status !== "active") { + throw new GenerationSubmissionError("generation_storage_unavailable", { + storage: { + capacityStatus: storageState.storage_status, + remainingBytes: Math.max( + 0, + storageState.hard_limit_bytes - storageState.managed_content_bytes - storageState.active_storage_reservations_bytes, + ), + }, + }); + } + return model; + } + + private commitSubmission(input: GenerationSubmissionInput, staged: StagedManagedFile[]) { + const model = this.validate(input); + const requestHash = digest(stableJson({ + client_submission_id: input.clientSubmissionId, + confirmed_credit_cost: input.confirmedCreditCost, + existing_reference_asset_ids: input.existingReferenceAssetIds, + mode: input.mode, + model_config_version: input.modelConfigVersion, + model_id: input.modelId, + new_references: staged.map((file) => ({ bytes: file.bytes, mime_type: file.mimeType, sha256: file.sha256 })), + project_id: input.projectId ?? null, + prompt: normalizePrompt(input.prompt), + ratio: input.ratio, + })); + const keyDigest = digest(input.idempotencyKey); + const receipt = this.database.prepare(` + SELECT r.request_hash, g.* FROM generation_submission_receipts r + JOIN generation_jobs g ON g.generation_id = r.generation_id + WHERE r.owner_id = ? AND r.idempotency_key_digest = ? + `).get(input.userId, keyDigest) as (GenerationRow & { request_hash: string }) | undefined; + if (receipt) { + if (receipt.request_hash !== requestHash) throw new GenerationSubmissionError("generation_idempotency_conflict"); + return { created: false as const, task: this.taskView(receipt) }; + } + const bySubmission = this.database.prepare("SELECT * FROM generation_jobs WHERE client_submission_id = ?") + .get(input.clientSubmissionId) as (GenerationRow & { submission_request_hash: string | null }) | undefined; + if (bySubmission) { + if (bySubmission.submission_request_hash !== requestHash || bySubmission.owner_id !== input.userId) { + throw new GenerationSubmissionError("generation_idempotency_conflict"); + } + return { created: false as const, task: this.taskView(bySubmission) }; + } + const current = this.readCurrentTask(input.userId); + if (current) return { created: false as const, task: current }; + + const now = this.clock(); + const prompt = normalizePrompt(input.prompt); + const generationId = randomUUID(); + const projectId = input.mode === "new_project" + ? this.insertProject(input.userId, prompt, input.ratio, now) + : this.validateExistingProject(input.userId, input.projectId!, input.ratio, prompt, now); + this.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, + client_submission_id, submission_request_hash, submission_ready, config_snapshot_json + ) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?, 0, NULL, NULL, ?, ?, ?, ?, 0, ?) + `).run( + generationId, input.userId, projectId, prompt, input.ratio, input.modelId, input.modelConfigVersion, + input.confirmedCreditCost, now, now, input.clientSubmissionId, requestHash, + stableJson({ + config_set_version: model.configSetVersion, + config_version: model.configVersion, + credit_cost: model.creditCost, + model_id: model.modelId, + prompt_max_length: model.promptMaxLength, + reference_limits: model.referenceLimits, + supported_ratios: model.supportedRatios, + }), + ); + + const referenceIds: string[] = []; + for (const file of staged) { + this.storage.moveStagedFile(file); + this.database.prepare(` + INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at) + VALUES (?, 'reference', ?, ?, ?, ?, ?, 'committed', ?) + `).run(file.fileId, input.userId, file.relativePath, file.bytes, file.mimeType, file.sha256, iso(now)); + this.database.prepare(`INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) VALUES (?, ?, 'reference', ?)`) + .run(projectId, file.fileId, now); + this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, 'project', ?)`) + .run(`project:${projectId}:${file.fileId}`, file.fileId, iso(now)); + referenceIds.push(file.fileId); + } + for (const referenceId of input.existingReferenceAssetIds) { + const allowed = this.database.prepare(` + SELECT mf.file_id FROM managed_files mf + JOIN project_resource_files prf ON prf.managed_file_id = mf.file_id + JOIN projects p ON p.project_id = prf.project_id + WHERE mf.file_id = ? AND mf.file_kind = 'reference' AND mf.status = 'committed' + AND mf.owner_ref = ? AND prf.project_id = ? AND p.owner_id = ? AND p.status = 'active' + `).get(referenceId, input.userId, projectId, input.userId); + if (!allowed) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + referenceIds.push(referenceId); + } + referenceIds.forEach((referenceId, index) => this.database.prepare(` + INSERT INTO generation_reference_snapshots (generation_id, position, managed_file_id, source_kind, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(generationId, index, referenceId, index < staged.length ? "uploaded" : "existing", now)); + + this.credits.reserveGeneration({ + creditCost: input.confirmedCreditCost, + generationId, + modelId: input.modelId, + operationKey: `generation:${generationId}:reserve`, + userId: input.userId, + }); + this.database.prepare("UPDATE generation_jobs SET submission_ready = 1 WHERE generation_id = ?").run(generationId); + this.database.prepare(` + INSERT INTO generation_submission_receipts (owner_id, idempotency_key_digest, request_hash, generation_id, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(input.userId, keyDigest, requestHash, generationId, now); + this.consumeStagedStorage(staged, now); + return { created: true as const, task: this.readTask(input.userId, generationId) }; + } + + private insertProject(ownerId: string, prompt: string, ratio: ProjectRatio, now: number) { + const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") + .get(ownerId) as { count: number }; + if (active.count >= projectLimit) throw new GenerationSubmissionError("generation_request_invalid"); + const projectId = randomUUID(); + const pixels = ratioPixels[ratio]; + const name = defaultProjectName(prompt, now); + this.database.prepare(` + INSERT INTO projects ( + project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height, + status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL) + `).run(projectId, ownerId, name, prompt, ratio, pixels.width, pixels.height, now, now); + this.database.prepare(` + INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) VALUES (?, 1, ?, ?, ?) + `).run(projectId, name, stableJson(defaultCanvasState(ratio, pixels, null)), now); + return projectId; + } + + private validateExistingProject(ownerId: string, projectId: string, ratio: ProjectRatio, prompt: string, now: number) { + const project = this.database.prepare("SELECT ratio FROM projects WHERE project_id = ? AND owner_id = ? AND status = 'active'") + .get(projectId, ownerId) as { ratio: ProjectRatio } | undefined; + if (!project || project.ratio !== ratio) throw new GenerationSubmissionError("generation_request_invalid"); + const history = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?").get(projectId) as { count: number }; + if (history.count >= historyLimit) throw new GenerationSubmissionError("generation_request_invalid"); + this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?").run(prompt, now, projectId); + return projectId; + } + + private consumeStagedStorage(staged: StagedManagedFile[], now: number) { + if (staged.length === 0) return; + const total = staged.reduce((sum, file) => sum + file.bytes, 0); + for (const file of staged) { + this.database.prepare(` + UPDATE storage_reservations SET status = 'consumed', resolved_at = ? + WHERE operation_id = ? AND status = 'active' + `).run(iso(now), file.operationId); + } + const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1") + .get() as { managed_content_bytes: number }; + const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'") + .get() as { bytes: number }; + const nextBytes = state.managed_content_bytes + total; + const classification = classifyCapacity(nextBytes, active.bytes); + this.database.prepare(` + UPDATE local_backend_storage_state + SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1 + WHERE singleton = 1 + `).run(nextBytes, classification.capacity_notice_level, classification.storage_status, iso(now)); + } + + private taskView(row: GenerationRow): GenerationTaskView { + const referenceAssetIds = (this.database.prepare(` + SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position + `).all(row.generation_id) as Array<{ managed_file_id: string }>).map((entry) => entry.managed_file_id); + return { + confirmedCreditCost: row.confirmed_credit_cost, + createdAt: iso(row.created_at), + errorCategory: row.error_category, + generationId: row.generation_id, + modelConfigVersion: row.model_config_version, + modelId: row.model_id, + projectId: row.project_id, + prompt: row.prompt, + ratio: row.ratio, + referenceAssetIds, + referenceCount: referenceAssetIds.length, + reservedCredits: row.reserved_credits, + status: row.status, + updatedAt: iso(row.updated_at), + }; + } + + private immediate(action: () => T): T { + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = action(); + this.database.exec("COMMIT"); + return result; + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + throw error; + } + } + + private ensureColumn(table: string, column: string, definition: string) { + const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + if (!columns.some((value) => value.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } + + private migrate() { + this.ensureColumn("generation_jobs", "client_submission_id", "TEXT"); + this.ensureColumn("generation_jobs", "submission_request_hash", "TEXT"); + this.ensureColumn("generation_jobs", "submission_ready", "INTEGER NOT NULL DEFAULT 0"); + this.ensureColumn("generation_jobs", "config_snapshot_json", "TEXT"); + this.database.exec(` + CREATE TABLE IF NOT EXISTS worker_runtime_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + status TEXT NOT NULL CHECK (status IN ('ready', 'degraded')), + reason TEXT, + owner_id TEXT, + lock_expires_at INTEGER, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_active_owner + ON generation_jobs(owner_id) WHERE status IN ('queued', 'running'); + CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_client_submission + ON generation_jobs(client_submission_id) WHERE client_submission_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS generation_submission_receipts ( + owner_id TEXT NOT NULL, + idempotency_key_digest TEXT NOT NULL CHECK (length(idempotency_key_digest) = 64), + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + generation_id TEXT NOT NULL UNIQUE REFERENCES generation_jobs(generation_id), + created_at INTEGER NOT NULL, + PRIMARY KEY (owner_id, idempotency_key_digest) + ); + CREATE TABLE IF NOT EXISTS generation_reference_snapshots ( + generation_id TEXT NOT NULL REFERENCES generation_jobs(generation_id) ON DELETE CASCADE, + position INTEGER NOT NULL CHECK (position >= 0), + managed_file_id TEXT NOT NULL REFERENCES managed_files(file_id), + source_kind TEXT NOT NULL CHECK (source_kind IN ('uploaded', 'existing')), + created_at INTEGER NOT NULL, + PRIMARY KEY (generation_id, position), + UNIQUE (generation_id, managed_file_id) + ); + CREATE TRIGGER IF NOT EXISTS generation_reference_snapshots_no_update + BEFORE UPDATE ON generation_reference_snapshots BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END; + DROP TRIGGER IF EXISTS generation_reference_snapshots_no_delete; + CREATE TRIGGER generation_reference_snapshots_no_delete + BEFORE DELETE ON generation_reference_snapshots + WHEN dada_allow_privacy_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END; + `); + this.ensureColumn("worker_runtime_state", "owner_id", "TEXT"); + this.ensureColumn("worker_runtime_state", "lock_expires_at", "INTEGER"); + this.database.prepare("INSERT OR IGNORE INTO worker_runtime_state (singleton, status, reason, owner_id, lock_expires_at, updated_at) VALUES (1, 'ready', NULL, NULL, NULL, ?)").run(this.clock()); + } +} diff --git a/apps/api/src/latest-export-errors.ts b/apps/api/src/latest-export-errors.ts new file mode 100644 index 0000000..f76a8a1 --- /dev/null +++ b/apps/api/src/latest-export-errors.ts @@ -0,0 +1,5 @@ +export class LatestExportError extends Error { + constructor(readonly code: "invalid" | "not_found" | "conflict") { + super(code); + } +} diff --git a/apps/api/src/latest-exports.ts b/apps/api/src/latest-exports.ts new file mode 100644 index 0000000..9ab3bb2 --- /dev/null +++ b/apps/api/src/latest-exports.ts @@ -0,0 +1,263 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; +import type { Readable } from "node:stream"; + +import type BetterSqlite3 from "better-sqlite3"; + +import { ManagedStorage } from "./managed-storage.js"; +import { stableJson } from "./projects.js"; +import { LatestExportError } from "./latest-export-errors.js"; +export { LatestExportError } from "./latest-export-errors.js"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; + +export type ExportFormat = "jpg" | "png"; + +interface ExportRow { + byte_size: number; + created_at: number; + export_id: string; + format: ExportFormat; + managed_file_id: string; + pixel_height: number; + pixel_width: number; + project_id: string; + sha256: string; + state_version: number; +} + +interface ManagedFileRow { + byte_size: number; + file_id: string; + mime_type: string; + relative_path: string; + sha256: string; +} + +type LatestExportView = ReturnType; + +function iso(timestamp: number) { + return new Date(timestamp).toISOString(); +} + +function validSha256(value: string) { + return /^[0-9a-f]{64}$/.test(value); +} + +export class LatestExportService { + readonly database: BetterSqlite3.Database; + private readonly clock: () => number; + private readonly storage: ManagedStorage; + + constructor(input: { clock?: () => number; databasePath: string; storage: ManagedStorage }) { + this.clock = input.clock ?? Date.now; + this.storage = input.storage; + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined); + this.database.pragma("journal_mode = WAL"); + this.database.pragma("foreign_keys = ON"); + this.database.pragma("busy_timeout = 5000"); + this.migrate(); + } + + close() { + this.database.close(); + } + + async saveLatest(input: { + byteSize: number; + content: Readable; + exportId: string; + format: ExportFormat; + ownerId: string; + pixelHeight: number; + pixelWidth: number; + projectId: string; + sha256: string; + stateVersion: number; + }) { + this.validateInput(input); + const requestHash = createHash("sha256").update(stableJson({ + byte_size: input.byteSize, export_id: input.exportId, format: input.format, + owner_id: input.ownerId, + pixel_height: input.pixelHeight, pixel_width: input.pixelWidth, project_id: input.projectId, + sha256: input.sha256, state_version: input.stateVersion, + })).digest("hex"); + const replay = this.database.prepare("SELECT owner_id, request_hash, response_json FROM latest_export_receipts WHERE export_id = ?") + .get(input.exportId) as { owner_id: string; request_hash: string; response_json: string | null } | undefined; + if (replay) { + if (replay.owner_id !== input.ownerId || replay.request_hash !== requestHash) throw new LatestExportError("conflict"); + const replayHash = createHash("sha256"); + let replayBytes = 0; + for await (const chunk of input.content) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + replayBytes += bytes.byteLength; + replayHash.update(bytes); + } + if (replayBytes !== input.byteSize || replayHash.digest("hex") !== input.sha256) throw new LatestExportError("conflict"); + return replay.response_json ? JSON.parse(replay.response_json) as LatestExportView : this.readByExportId(input.ownerId, input.exportId); + } + this.assertWritableProject(input); + const stored = await this.storage.commitStream({ + content: input.content, + expectedMimeType: input.format === "png" ? "image/png" : "image/jpeg", + expectedSha256: input.sha256, + fileKind: "export", + fileName: `latest.${input.format === "jpg" ? "jpg" : "png"}`, + operationId: input.exportId, + ownerRef: input.ownerId, + projectedWriteBytes: input.byteSize, + }); + try { + if (stored.bytes !== input.byteSize) throw new LatestExportError("invalid"); + const createdAt = this.clock(); + const response = this.view({ + byte_size: stored.bytes, + created_at: createdAt, + export_id: input.exportId, + format: input.format, + managed_file_id: stored.file_id, + pixel_height: input.pixelHeight, + pixel_width: input.pixelWidth, + project_id: input.projectId, + sha256: stored.sha256, + state_version: input.stateVersion, + }); + const transaction = this.database.transaction(() => { + this.assertWritableProject(input); + const previous = this.database.prepare("SELECT managed_file_id FROM latest_exports WHERE project_id = ? AND format = ?") + .get(input.projectId, input.format) as { managed_file_id: string } | undefined; + this.database.prepare(` + INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) + VALUES (?, ?, 'export', ?) + `).run(input.projectId, stored.file_id, createdAt); + this.database.prepare(` + INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) + VALUES (?, ?, 'project', ?) + `).run(`project:${input.projectId}:${stored.file_id}`, stored.file_id, iso(createdAt)); + this.database.prepare(` + INSERT INTO latest_exports ( + project_id, format, export_id, managed_file_id, state_version, sha256, + byte_size, pixel_width, pixel_height, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, format) DO UPDATE SET + export_id = excluded.export_id, managed_file_id = excluded.managed_file_id, + state_version = excluded.state_version, sha256 = excluded.sha256, + byte_size = excluded.byte_size, pixel_width = excluded.pixel_width, + pixel_height = excluded.pixel_height, created_at = excluded.created_at + `).run( + input.projectId, input.format, input.exportId, stored.file_id, input.stateVersion, + stored.sha256, stored.bytes, input.pixelWidth, input.pixelHeight, createdAt, + ); + this.database.prepare("INSERT INTO latest_export_receipts (export_id, owner_id, request_hash, response_json, created_at) VALUES (?, ?, ?, ?, ?)") + .run(input.exportId, input.ownerId, requestHash, stableJson(response), createdAt); + if (previous && previous.managed_file_id !== stored.file_id) this.retireReplacedFile(previous.managed_file_id, createdAt); + }); + transaction.immediate(); + return response; + } catch (error) { + this.storage.retireManagedFile(stored.file_id, "compensation"); + throw error; + } + } + + getLatest(ownerId: string, projectId: string, format: ExportFormat) { + const row = this.database.prepare(` + SELECT le.*, mf.mime_type, mf.relative_path + FROM latest_exports le + JOIN projects p ON p.project_id = le.project_id + JOIN managed_files mf ON mf.file_id = le.managed_file_id AND mf.status = 'committed' + WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND le.format = ? + `).get(ownerId, projectId, format) as (ExportRow & ManagedFileRow) | undefined; + if (!row) throw new LatestExportError("not_found"); + const path = this.storage.resolveManagedFile(row.managed_file_id); + if (!path) throw new LatestExportError("not_found"); + return { ...this.view(row), mimeType: row.mime_type, path }; + } + + getOriginal(ownerId: string, projectId: string, imageId: string) { + const row = this.database.prepare(` + SELECT mf.file_id, mf.relative_path, mf.byte_size, mf.mime_type, mf.sha256 + FROM project_images pi + JOIN projects p ON p.project_id = pi.project_id + JOIN managed_files mf ON mf.file_id = pi.image_id AND mf.status = 'committed' + WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND pi.image_id = ? + `).get(ownerId, projectId, imageId) as ManagedFileRow | undefined; + if (!row) throw new LatestExportError("not_found"); + const path = this.storage.resolveManagedFile(row.file_id); + if (!path) throw new LatestExportError("not_found"); + return { ...row, path }; + } + + private assertWritableProject(input: Pick[0], "ownerId" | "pixelHeight" | "pixelWidth" | "projectId" | "stateVersion">) { + const project = this.database.prepare(` + SELECT state_version, pixel_width, pixel_height FROM projects + WHERE owner_id = ? AND project_id = ? AND status = 'active' + `).get(input.ownerId, input.projectId) as { pixel_height: number; pixel_width: number; state_version: number } | undefined; + if (!project) throw new LatestExportError("not_found"); + if (project.state_version !== input.stateVersion) throw new LatestExportError("conflict"); + if (project.pixel_width !== input.pixelWidth || project.pixel_height !== input.pixelHeight) throw new LatestExportError("invalid"); + } + + private readByExportId(ownerId: string, exportId: string) { + const row = this.database.prepare(` + SELECT le.* FROM latest_exports le JOIN projects p ON p.project_id = le.project_id + WHERE p.owner_id = ? AND le.export_id = ? + `).get(ownerId, exportId) as ExportRow | undefined; + if (!row) throw new LatestExportError("not_found"); + return this.view(row); + } + + private retireReplacedFile(fileId: string, timestamp: number) { + const file = this.database.prepare("SELECT relative_path, byte_size FROM managed_files WHERE file_id = ? AND status = 'committed'") + .get(fileId) as { byte_size: number; relative_path: string } | undefined; + if (!file) return; + this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId); + this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(iso(timestamp), fileId); + this.database.prepare(` + INSERT OR IGNORE INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL) + `).run(randomUUID(), fileId, file.relative_path, file.byte_size, iso(timestamp)); + } + + private validateInput(input: Parameters[0]) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.exportId) || !["jpg", "png"].includes(input.format) + || !validSha256(input.sha256) || ![input.byteSize, input.pixelHeight, input.pixelWidth, input.stateVersion].every((value) => Number.isSafeInteger(value) && value > 0)) { + throw new LatestExportError("invalid"); + } + } + + private view(row: ExportRow) { + return { + byteSize: row.byte_size, + createdAt: iso(row.created_at), + downloadUrl: `/api/v1/projects/${row.project_id}/latest-exports/${row.format}`, + exportId: row.export_id, + format: row.format, + pixelHeight: row.pixel_height, + pixelWidth: row.pixel_width, + projectId: row.project_id, + sha256: row.sha256, + stateVersion: row.state_version, + }; + } + + private migrate() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS latest_export_receipts ( + export_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + response_json TEXT CHECK (response_json IS NULL OR json_valid(response_json)), + created_at INTEGER NOT NULL + ); + `); + const columns = this.database.prepare("PRAGMA table_info(latest_export_receipts)").all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "response_json")) { + this.database.exec("ALTER TABLE latest_export_receipts ADD COLUMN response_json TEXT"); + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 416f9bc..f7034b4 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,19 +1,112 @@ +import { createHmac } from "node:crypto"; import { join, resolve } from "node:path"; +import { registrationNotice } from "@dada/shared-contracts"; + import { createApp } from "./app.js"; import { readBrowserSupportRelease } from "./browser-support.js"; -import { readConfiguredLocalDataRoot } from "./local-data-root.js"; +import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js"; import { ManagedStorage } from "./managed-storage.js"; +import { LatestExportService } from "./latest-exports.js"; +import { CreditService } from "./credits.js"; +import { ProjectService } from "./projects.js"; +import { RegistrationService } from "./registration.js"; +import { RecentAssetService } from "./recent-assets.js"; +import { MockResendAdapter } from "./resend-adapter.js"; +import { readSecureConfigCandidate } from "./secure-config.js"; import { StructuredJsonlLogger } from "./structured-log.js"; import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js"; +import { ModelConfigurationService } from "./model-configuration.js"; +import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js"; +import { StickerReleaseService } from "./sticker-releases.js"; +import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js"; const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); +let registration: RegistrationService | undefined; +let projects: ProjectService | undefined; +let credits: CreditService | undefined; +let storage: ManagedStorage | undefined; +let latestExports: LatestExportService | undefined; +let models: ModelConfigurationService | undefined; +let recentAssets: RecentAssetService | undefined; +let stickers: StickerReleaseService | undefined; +let amap: AmapAdapter = new MockAmapAdapter(); +const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); if (credentialChannelEnabled) { - initializeApiCredentialClients(await receiveApiCredentials()); + const clients = initializeApiCredentialClients(await receiveApiCredentials()); + try { + amap = clients.amap; + const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper) + .update(`Dada/P0A/${purpose}/v1`, "utf8") + .digest(); + const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); + const databasePath = join(dataRoot, "db", "dada.sqlite3"); + registration = new RegistrationService({ + adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper), + challengePepper: derivePepper("challenge-pepper"), + currentPrivacyNoticeVersion: registrationNotice.version, + databasePath, + invitePepper: derivePepper("invite-pepper"), + resend: new MockResendAdapter(), + sessionPepper: derivePepper("session-pepper"), + }); + projects = new ProjectService({ databasePath }); + credits = new CreditService({ databasePath }); + storage = new ManagedStorage({ dataRoot, databasePath }); + stickers = new StickerReleaseService({ databasePath, storage }); + latestExports = new LatestExportService({ databasePath, storage }); + models = new ModelConfigurationService({ database: registration.database }); + recentAssets = new RecentAssetService({ database: registration.database }); + registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); + } catch (error) { + amap.dispose?.(); + amap = new MockAmapAdapter(); + stickers?.close(); + stickers = undefined; + latestExports?.close(); + latestExports = undefined; + storage?.close(); + storage = undefined; + credits?.close(); + credits = undefined; + projects?.close(); + projects = undefined; + registration?.close(); + registration = undefined; + throw error; + } finally { + clients.adminAllowlistPepper.fill(0); + } } const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); -const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {}); +const adminServicesStorage = registration + ? createAdminServicesStorageProvider({ + database: registration.database, + ...(models ? { models } : {}), + ...(storage ? { storage } : {}), + }) + : undefined; +const adminDiagnostics = adminServicesStorage + ? createAdminDiagnosticsProvider({ + ...(browserSupportRelease ? { browserSupportRelease, appVersion: browserSupportRelease.appVersion } : {}), + servicesStorage: adminServicesStorage, + }) + : undefined; +const app = await createApp({ + ...(adminServicesStorage ? { adminServicesStorage } : {}), + ...(adminDiagnostics ? { adminDiagnostics } : {}), + amap, + ...(browserSupportRelease ? { browserSupportRelease } : {}), + ...(credits ? { credits } : {}), + ...(latestExports ? { latestExports } : {}), + ...(models ? { models } : {}), + ...(projects ? { projects } : {}), + ...(registration ? { registration } : {}), + ...(recentAssets ? { recentAssets } : {}), + ...(stickers ? { stickers } : {}), + ...(storage ? { storage } : {}), +}); await app.listen({ host: "127.0.0.1", @@ -24,14 +117,19 @@ const controlPipeIndex = process.argv.indexOf("--dada-control-pipe"); if (controlPipeIndex >= 0) { const controlPipe = process.argv[controlPipeIndex + 1]; if (!controlPipe) throw new Error("Supervisor control pipe name is required."); - let storage: ManagedStorage | undefined; const control = attachApiSupervisorControl(controlPipe, async () => { await app.close(); + amap.dispose?.(); + latestExports?.close(); + credits?.close(); + projects?.close(); + registration?.close(); storage?.close(); + stickers?.close(); }); try { - const dataRoot = readConfiguredLocalDataRoot(); - storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") }); + const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); + if (!storage) storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") }); const logger = new StructuredJsonlLogger({ component: "api", directory: join(dataRoot, "logs", "api"), diff --git a/apps/api/src/managed-storage.ts b/apps/api/src/managed-storage.ts index 4b19087..923bfeb 100644 --- a/apps/api/src/managed-storage.ts +++ b/apps/api/src/managed-storage.ts @@ -15,6 +15,13 @@ import { pipeline } from "node:stream/promises"; import type BetterSqlite3 from "better-sqlite3"; +import { + auditRetentionMilliseconds, + ensureAdminOperationAuditSchema, + isSafeAuditRef, + isSafeAuditSummaryJson, + serializeAuditSummary, +} from "./audit-policy.js"; import { resolvePathWithinRoot } from "./local-data-root.js"; import { HARD_LIMIT_BYTES, @@ -68,6 +75,31 @@ interface CleanupQueueRow { relative_path: string; } +export interface AssetCleanupCandidateView { + byte_size: number; + file_id: string; + file_kind: "original" | "thumbnail"; + hash_prefix: string; + reference_count: 0; + resource_version: string; + stable_id: string; +} + +export interface AssetCleanupCandidatesView { + candidate_snapshot_version: string; + expires_at: string; + items: AssetCleanupCandidateView[]; +} + +export interface AssetCleanupIntentView { + confirmation_token: string; + expires_at: string; + file_count: number; + request_id: string; + status: "pending_confirmation" | "denied" | "queued" | "completed"; + total_bytes: number; +} + export class StorageCapacityError extends Error { readonly code = "STORAGE_CAPACITY_EXCEEDED"; readonly httpStatus = 507; @@ -93,6 +125,14 @@ function now() { return new Date().toISOString(); } +function auditExpiry(occurredAt: number) { + return occurredAt + auditRetentionMilliseconds; +} + +function digest(value: string) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + function validatePositiveBytes(value: number, name: string) { if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`); } @@ -102,6 +142,9 @@ function sniffMime(prefix: Buffer) { return "image/png"; } if (prefix.length >= 3 && prefix[0] === 0xff && prefix[1] === 0xd8 && prefix[2] === 0xff) return "image/jpeg"; + if (prefix.length >= 12 && prefix.subarray(0, 4).toString("ascii") === "RIFF" && prefix.subarray(8, 12).toString("ascii") === "WEBP") { + return "image/webp"; + } return "application/octet-stream"; } @@ -118,7 +161,7 @@ function listFiles(root: string): string[] { export interface CommitStreamInput { content: Readable; - expectedMimeType: "image/png" | "image/jpeg" | "application/octet-stream"; + expectedMimeType: "image/png" | "image/jpeg" | "image/webp" | "application/octet-stream"; expectedSha256?: string; failurePoint?: CommitFailurePoint; fileKind: ManagedFileKind; @@ -128,6 +171,20 @@ export interface CommitStreamInput { projectedWriteBytes: number; } +export interface StagedManagedFile { + bytes: number; + destinationPath: string; + fileId: string; + fileKind: ManagedFileKind; + mimeType: "image/png" | "image/jpeg" | "image/webp"; + operationId: string; + ownerRef: string; + relativePath: string; + sha256: string; + stagingDirectory: string; + stagingPath: string; +} + export class ManagedStorage { readonly dataRoot: string; readonly databasePath: string; @@ -151,6 +208,12 @@ export class ManagedStorage { this.database.pragma("journal_mode = WAL"); this.database.pragma("foreign_keys = ON"); this.database.pragma("busy_timeout = 5000"); + this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef); + this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson); + this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0); + this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => ""); + this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0); + this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0); this.migrate(); const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1").get() as { managed_content_bytes: number }; this.measurementBaselineBytes = Math.max(0, state.managed_content_bytes - this.physicalManagedBytes()); @@ -180,6 +243,7 @@ export class ManagedStorage { CREATE TABLE IF NOT EXISTS managed_files ( file_id TEXT PRIMARY KEY, file_kind TEXT NOT NULL CHECK (file_kind IN ('reference', 'generated', 'export', 'derived', 'sticker_original', 'sticker_thumbnail')), + owner_ref TEXT, relative_path TEXT NOT NULL UNIQUE, byte_size INTEGER NOT NULL CHECK (byte_size > 0), mime_type TEXT NOT NULL, @@ -220,14 +284,94 @@ export class ManagedStorage { FOREIGN KEY (request_id) REFERENCES asset_cleanup_requests(request_id), FOREIGN KEY (managed_file_id) REFERENCES managed_files(file_id) ); + CREATE TABLE IF NOT EXISTS asset_cleanup_candidate_snapshots ( + snapshot_version TEXT PRIMARY KEY, + items_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS sticker_managed_file_history ( + managed_file_id TEXT NOT NULL, + stable_id TEXT NOT NULL, + resource_version TEXT NOT NULL, + file_kind TEXT NOT NULL CHECK (file_kind IN ('original', 'thumbnail')), + created_at INTEGER NOT NULL, + PRIMARY KEY (managed_file_id, file_kind), + FOREIGN KEY (managed_file_id) REFERENCES managed_files(file_id) + ); + CREATE TABLE IF NOT EXISTS project_sticker_asset_refs ( + reference_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + stable_id TEXT NOT NULL, + resource_version TEXT NOT NULL, + created_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS admin_operation_logs ( log_id TEXT PRIMARY KEY, - operation TEXT NOT NULL, - outcome TEXT NOT NULL, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, target_ref TEXT NOT NULL, - created_at TEXT NOT NULL + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at TEXT NOT NULL, + expires_at TEXT NOT NULL ); + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; `); + const managedFileColumns = this.database.prepare("PRAGMA table_info(managed_files)").all() as Array<{ name: string }>; + if (!managedFileColumns.some((column) => column.name === "owner_ref")) { + this.database.exec("ALTER TABLE managed_files ADD COLUMN owner_ref TEXT"); + } + if (!managedFileColumns.some((column) => column.name === "cleanup_status")) { + this.database.exec("ALTER TABLE managed_files ADD COLUMN cleanup_status TEXT"); + } + const cleanupRequestColumns = this.database.prepare("PRAGMA table_info(asset_cleanup_requests)").all() as Array<{ name: string }>; + const cleanupRequestAdditions: Array<[string, string]> = [ + ["created_by", "TEXT"], + ["confirmed_by", "TEXT"], + ["snapshot_version", "TEXT"], + ["expires_at", "INTEGER"], + ["confirmation_token_digest", "TEXT"], + ["idempotency_key_digest", "TEXT"], + ["request_hash", "TEXT"], + ["file_count", "INTEGER"], + ["total_bytes", "INTEGER"], + ["denied_reason", "TEXT"], + ]; + for (const [column, type] of cleanupRequestAdditions) { + if (!cleanupRequestColumns.some((item) => item.name === column)) { + this.database.exec(`ALTER TABLE asset_cleanup_requests ADD COLUMN ${column} ${type}`); + } + } + const cleanupItemColumns = this.database.prepare("PRAGMA table_info(asset_cleanup_request_items)").all() as Array<{ name: string }>; + const cleanupItemAdditions: Array<[string, string]> = [ + ["stable_id", "TEXT"], + ["resource_version", "TEXT"], + ["file_kind", "TEXT"], + ["byte_size", "INTEGER"], + ["sha256_prefix", "TEXT"], + ]; + for (const [column, type] of cleanupItemAdditions) { + if (!cleanupItemColumns.some((item) => item.name === column)) { + this.database.exec(`ALTER TABLE asset_cleanup_request_items ADD COLUMN ${column} ${type}`); + } + } + this.database.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS asset_cleanup_requests_actor_idempotency + ON asset_cleanup_requests (created_by, idempotency_key_digest) + WHERE created_by IS NOT NULL AND idempotency_key_digest IS NOT NULL; + CREATE INDEX IF NOT EXISTS sticker_managed_file_history_lookup + ON sticker_managed_file_history (stable_id, resource_version, file_kind); + CREATE INDEX IF NOT EXISTS asset_cleanup_candidate_snapshots_expiry + ON asset_cleanup_candidate_snapshots (expires_at); + `); + ensureAdminOperationAuditSchema(this.database, Date.now()); const initial = classifyCapacity(0, 0); this.database.prepare(` INSERT OR IGNORE INTO local_backend_storage_state @@ -249,6 +393,106 @@ export class ManagedStorage { return withReservations; } + private readAssetCleanupCandidates(): AssetCleanupCandidateView[] { + const releaseReferenceClause = this.tableExists("sticker_release_items") ? ` + AND NOT EXISTS ( + SELECT 1 FROM sticker_release_items release_items + WHERE release_items.original_file_id = mf.file_id OR release_items.thumbnail_file_id = mf.file_id + )` : ""; + return this.database.prepare(` + SELECT + mf.file_id, + mf.byte_size, + history.stable_id, + history.resource_version, + history.file_kind, + substr(mf.sha256, 1, 12) AS hash_prefix, + 0 AS reference_count + FROM sticker_managed_file_history history + JOIN managed_files mf ON mf.file_id = history.managed_file_id + WHERE mf.status = 'committed' + AND mf.cleanup_status IS NULL + AND mf.file_kind IN ('sticker_original', 'sticker_thumbnail') + AND NOT EXISTS ( + SELECT 1 FROM project_asset_refs refs WHERE refs.managed_file_id = mf.file_id + ) + AND NOT EXISTS ( + SELECT 1 FROM project_sticker_asset_refs project_refs + WHERE project_refs.stable_id = history.stable_id + AND project_refs.resource_version = history.resource_version + ) + ${releaseReferenceClause} + AND NOT EXISTS ( + SELECT 1 FROM asset_cleanup_request_items request_items + JOIN asset_cleanup_requests requests ON requests.request_id = request_items.request_id + WHERE request_items.managed_file_id = mf.file_id + AND requests.status IN ('pending_confirmation', 'queued') + ) + ORDER BY history.stable_id, history.resource_version, history.file_kind, mf.file_id + `).all() as AssetCleanupCandidateView[]; + } + + private assertActiveAdmin(actorId: string) { + const admin = this.database.prepare(` + SELECT 1 AS allowed FROM users u + JOIN admin_access access ON access.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND access.allowed = 1 + `).get(actorId); + if (!admin) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE"); + } + + private assetReferenceCount(fileId: string, requestId: string) { + const projectOrRelease = (this.database.prepare(` + SELECT COUNT(*) AS count FROM project_asset_refs WHERE managed_file_id = ? + `).get(fileId) as { count: number }).count; + const releaseItems = this.tableExists("sticker_release_items") + ? (this.database.prepare(` + SELECT COUNT(*) AS count FROM sticker_release_items + WHERE original_file_id = ? OR thumbnail_file_id = ? + `).get(fileId, fileId) as { count: number }).count + : 0; + const projectStickerRefs = (this.database.prepare(` + SELECT COUNT(*) AS count + FROM sticker_managed_file_history history + JOIN project_sticker_asset_refs refs + ON refs.stable_id = history.stable_id AND refs.resource_version = history.resource_version + WHERE history.managed_file_id = ? + `).get(fileId) as { count: number }).count; + const otherCleanup = (this.database.prepare(` + SELECT COUNT(*) AS count FROM asset_cleanup_request_items items + JOIN asset_cleanup_requests requests ON requests.request_id = items.request_id + WHERE items.managed_file_id = ? AND items.request_id <> ? + AND requests.status IN ('pending_confirmation', 'queued') + `).get(fileId, requestId) as { count: number }).count; + return projectOrRelease + releaseItems + projectStickerRefs + otherCleanup; + } + + private cleanupConfirmationToken(requestId: string, actorId: string, keyDigest: string) { + return digest(`Dada/P0A/asset-cleanup-confirm/v1:${requestId}:${actorId}:${keyDigest}`); + } + + private tableExists(name: string) { + return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + } + + private insertCleanupAudit(input: { + actorRef: string; + afterSummary: Record; + operationType: string; + requestId: string; + result: "failed" | "succeeded"; + }, occurredAt: number) { + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'super_admin', ?, ?, 'asset_cleanup_request', ?, ?, NULL, ?, ?, ?) + `).run( + randomUUID(), input.actorRef, input.operationType, input.requestId, input.result, + serializeAuditSummary(input.afterSummary), occurredAt, auditExpiry(occurredAt), + ); + } + private activeReservationBytes(excludingOperationId?: string) { const row = this.database.prepare(` SELECT COALESCE(SUM(projected_bytes), 0) AS bytes @@ -416,9 +660,9 @@ export class ManagedStorage { const commit = this.database.transaction(() => { this.database.prepare(` - INSERT INTO managed_files (file_id, file_kind, relative_path, byte_size, mime_type, sha256, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, 'committed', ?) - `).run(fileId, input.fileKind, destination.relativePath, byteSize, input.expectedMimeType, sha256, now()); + INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'committed', ?) + `).run(fileId, input.fileKind, input.ownerRef, destination.relativePath, byteSize, input.expectedMimeType, sha256, now()); this.database.prepare(` UPDATE local_backend_storage_state SET managed_content_bytes = managed_content_bytes + ? WHERE singleton = 1 `).run(byteSize); @@ -446,6 +690,103 @@ export class ManagedStorage { } } + async stageManagedImage(input: { + content: Readable; + expectedMimeType: "image/png" | "image/jpeg" | "image/webp"; + expectedSha256?: string; + fileKind: ManagedFileKind; + fileName: string; + maximumBytes: number; + operationId: string; + ownerRef: string; + projectedWriteBytes: number; + }): Promise { + const fileId = randomUUID(); + const destination = this.destination({ + content: input.content, + expectedMimeType: input.expectedMimeType, + fileKind: input.fileKind, + fileName: input.fileName, + operationId: input.operationId, + ownerRef: input.ownerRef, + projectedWriteBytes: input.projectedWriteBytes, + }, fileId); + if (!Number.isSafeInteger(input.maximumBytes) || input.maximumBytes <= 0) throw new Error("maximum_bytes_invalid"); + this.reserve(input.operationId, input.projectedWriteBytes); + const stagingDirectory = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}`); + const stagingPath = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}/payload.tmp`); + try { + mkdirSync(stagingDirectory, { recursive: true }); + const hash = createHash("sha256"); + let byteSize = 0; + let prefix = Buffer.alloc(0); + const inspect = new Transform({ + transform(chunk: Buffer | string, encoding, callback) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding); + byteSize += bytes.byteLength; + if (byteSize > input.maximumBytes) return callback(new Error("content_size_invalid")); + hash.update(bytes); + if (prefix.byteLength < 16) prefix = Buffer.concat([prefix, bytes.subarray(0, 16 - prefix.byteLength)]); + callback(null, bytes); + }, + }); + await pipeline(input.content, inspect, createWriteStream(stagingPath, { flags: "wx" })); + validatePositiveBytes(byteSize, "actual_write_bytes"); + if (sniffMime(prefix) !== input.expectedMimeType) throw new Error("content_mime_invalid"); + const sha256 = hash.digest("hex"); + if (input.expectedSha256 && sha256.toLowerCase() !== input.expectedSha256.toLowerCase()) throw new Error("content_hash_invalid"); + const state = this.getState(); + const otherReservations = this.activeReservationBytes(input.operationId); + if (state.managed_content_bytes + otherReservations + byteSize > HARD_LIMIT_BYTES) { + throw new StorageCapacityError({ activeReservationBytes: otherReservations, managedContentBytes: state.managed_content_bytes, projectedWriteBytes: byteSize }); + } + this.database.prepare("UPDATE storage_reservations SET projected_bytes = ? WHERE operation_id = ? AND status = 'active'") + .run(byteSize, input.operationId); + this.refreshState(); + return { + bytes: byteSize, + destinationPath: destination.absolutePath, + fileId, + fileKind: input.fileKind, + mimeType: input.expectedMimeType, + operationId: input.operationId, + ownerRef: input.ownerRef, + relativePath: destination.relativePath, + sha256, + stagingDirectory, + stagingPath, + }; + } catch (error) { + rmSync(stagingDirectory, { force: true, recursive: true }); + this.releaseReservation(input.operationId); + throw error; + } + } + + async stagePrivateImage(input: { + content: Readable; + expectedMimeType: "image/png" | "image/jpeg" | "image/webp"; + fileName: string; + maximumBytes: number; + operationId: string; + ownerRef: string; + projectedWriteBytes: number; + }): Promise { + return this.stageManagedImage({ ...input, fileKind: "reference" }); + } + + moveStagedFile(file: StagedManagedFile) { + mkdirSync(dirname(file.destinationPath), { recursive: true }); + renameSync(file.stagingPath, file.destinationPath); + rmSync(file.stagingDirectory, { force: true, recursive: true }); + } + + abandonStagedFile(file: StagedManagedFile) { + if (existsSync(file.destinationPath)) this.queueCompensation(file.relativePath, statSync(file.destinationPath).size); + else rmSync(file.stagingDirectory, { force: true, recursive: true }); + this.releaseReservation(file.operationId); + } + async commitBufferFixture(fileKind: ManagedFileKind, fileName: string, bytes: Buffer) { return this.commitStream({ content: Readable.from(bytes), @@ -514,6 +855,26 @@ export class ManagedStorage { return row ? resolvePathWithinRoot(this.dataRoot, row.relative_path) : undefined; } + retireManagedFile(fileId: string, reason: "compensation" | "purge" = "compensation") { + const transaction = this.database.transaction(() => { + const file = this.database.prepare(` + SELECT file_id, relative_path, byte_size FROM managed_files + WHERE file_id = ? AND status = 'committed' + `).get(fileId) as { byte_size: number; file_id: string; relative_path: string } | undefined; + if (!file) return; + const retiredAt = now(); + this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId); + this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(retiredAt, fileId); + this.database.prepare(` + INSERT OR IGNORE INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 1, ?, 'pending', ?, NULL, NULL) + `).run(randomUUID(), fileId, file.relative_path, file.byte_size, reason, retiredAt); + }); + transaction.immediate(); + } + addAssetReference(fileId: string, referenceType: "project" | "release") { if (this.inspectAction("project_json_write") !== "allow") throw new StorageUnavailableError(); this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, ?, ?)`) @@ -525,6 +886,203 @@ export class ManagedStorage { this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId); } + listAssetCleanupCandidates(): AssetCleanupCandidatesView { + const createdAt = Date.now(); + const expiresAt = createdAt + 5 * 60 * 1_000; + const items = this.readAssetCleanupCandidates(); + const snapshotVersion = digest(JSON.stringify({ + created_at: createdAt, + nonce: randomUUID(), + items: items.map((item) => ({ byte_size: item.byte_size, file_id: item.file_id, hash_prefix: item.hash_prefix })), + })); + this.database.prepare("DELETE FROM asset_cleanup_candidate_snapshots WHERE expires_at <= ?").run(createdAt); + this.database.prepare(` + INSERT INTO asset_cleanup_candidate_snapshots ( + snapshot_version, items_json, created_at, expires_at + ) VALUES (?, ?, ?, ?) + `).run(snapshotVersion, JSON.stringify(items), createdAt, expiresAt); + return { + candidate_snapshot_version: snapshotVersion, + expires_at: new Date(expiresAt).toISOString(), + items, + }; + } + + createAssetCleanupIntent(input: { + actorId: string; + fileIds: string[]; + idempotencyKey: string; + snapshotVersion: string; + }): AssetCleanupIntentView { + if (this.inspectAction("explicit_cleanup") !== "allow") throw new Error("cleanup_uncommitted"); + const fileIds = [...new Set(input.fileIds)].sort(); + if (!uuidPattern.test(input.actorId) || fileIds.length === 0 || fileIds.length !== input.fileIds.length + || fileIds.length > 100 || fileIds.some((fileId) => !uuidPattern.test(fileId)) + || !/^[A-Za-z0-9_-]{32,200}$/.test(input.idempotencyKey) + || !/^[0-9a-f]{64}$/.test(input.snapshotVersion)) { + throw new Error("cleanup_candidates_invalid"); + } + const keyDigest = digest(input.idempotencyKey); + const requestHash = digest(JSON.stringify({ file_ids: fileIds, snapshot_version: input.snapshotVersion })); + const existing = this.database.prepare(` + SELECT request_id, request_hash, expires_at, file_count, total_bytes, status + FROM asset_cleanup_requests + WHERE created_by = ? AND idempotency_key_digest = ? + `).get(input.actorId, keyDigest) as { + expires_at: number; file_count: number; request_hash: string; request_id: string; status: AssetCleanupIntentView["status"]; total_bytes: number; + } | undefined; + if (existing) { + if (existing.request_hash !== requestHash) throw new Error("IDEMPOTENCY_KEY_CONFLICT"); + return { + confirmation_token: this.cleanupConfirmationToken(existing.request_id, input.actorId, keyDigest), + expires_at: new Date(existing.expires_at).toISOString(), + file_count: existing.file_count, + request_id: existing.request_id, + status: existing.status, + total_bytes: existing.total_bytes, + }; + } + + const requestId = randomUUID(); + const confirmationToken = this.cleanupConfirmationToken(requestId, input.actorId, keyDigest); + const createdAt = Date.now(); + let view!: AssetCleanupIntentView; + const transaction = this.database.transaction(() => { + this.assertActiveAdmin(input.actorId); + const snapshot = this.database.prepare(` + SELECT items_json, expires_at FROM asset_cleanup_candidate_snapshots + WHERE snapshot_version = ? + `).get(input.snapshotVersion) as { expires_at: number; items_json: string } | undefined; + if (!snapshot || snapshot.expires_at <= createdAt) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE"); + const snapshotItems = JSON.parse(snapshot.items_json) as AssetCleanupCandidateView[]; + const byId = new Map(snapshotItems.map((item) => [item.file_id, item])); + const selected = fileIds.map((fileId) => byId.get(fileId)); + if (selected.some((item) => !item)) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE"); + const current = new Map(this.readAssetCleanupCandidates().map((item) => [item.file_id, item])); + if (fileIds.some((fileId) => !current.has(fileId))) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE"); + const safeItems = selected as AssetCleanupCandidateView[]; + const totalBytes = safeItems.reduce((sum, item) => sum + item.byte_size, 0); + this.database.prepare(` + INSERT INTO asset_cleanup_requests ( + request_id, status, created_at, confirmed_at, created_by, confirmed_by, + snapshot_version, expires_at, confirmation_token_digest, + idempotency_key_digest, request_hash, file_count, total_bytes, denied_reason + ) VALUES (?, 'pending_confirmation', ?, NULL, ?, NULL, ?, ?, ?, ?, ?, ?, ?, NULL) + `).run( + requestId, new Date(createdAt).toISOString(), input.actorId, input.snapshotVersion, + snapshot.expires_at, digest(confirmationToken), keyDigest, requestHash, safeItems.length, totalBytes, + ); + const insert = this.database.prepare(` + INSERT INTO asset_cleanup_request_items ( + request_id, managed_file_id, stable_id, resource_version, file_kind, byte_size, sha256_prefix + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + for (const item of safeItems) { + insert.run(requestId, item.file_id, item.stable_id, item.resource_version, item.file_kind, item.byte_size, item.hash_prefix); + } + this.insertCleanupAudit({ + actorRef: input.actorId, + afterSummary: { file_count: safeItems.length, snapshot_version: input.snapshotVersion, total_bytes: totalBytes }, + operationType: "asset_cleanup_requested", + requestId, + result: "succeeded", + }, createdAt); + view = { + confirmation_token: confirmationToken, + expires_at: new Date(snapshot.expires_at).toISOString(), + file_count: safeItems.length, + request_id: requestId, + status: "pending_confirmation", + total_bytes: totalBytes, + }; + }); + transaction.immediate(); + return view; + } + + confirmAssetCleanupIntent(input: { actorId: string; confirmationToken: string; requestId: string }) { + if (this.inspectAction("explicit_cleanup") !== "allow") throw new Error("cleanup_uncommitted"); + if (!uuidPattern.test(input.actorId) || !uuidPattern.test(input.requestId) || !/^[0-9a-f]{64}$/.test(input.confirmationToken)) { + throw new Error("ASSET_CLEANUP_CANDIDATE_STALE"); + } + const confirmedAt = Date.now(); + const outcome = this.database.transaction(() => { + this.assertActiveAdmin(input.actorId); + const request = this.database.prepare(` + SELECT status, created_by, expires_at, confirmation_token_digest, file_count, total_bytes + FROM asset_cleanup_requests WHERE request_id = ? + `).get(input.requestId) as { + confirmation_token_digest: string | null; created_by: string | null; expires_at: number | null; + file_count: number | null; status: string; total_bytes: number | null; + } | undefined; + if (!request || request.status !== "pending_confirmation" || request.created_by !== input.actorId + || !request.expires_at || request.expires_at <= confirmedAt + || request.confirmation_token_digest !== digest(input.confirmationToken)) { + throw new Error("ASSET_CLEANUP_CANDIDATE_STALE"); + } + const files = this.database.prepare(` + SELECT mf.file_id, mf.file_kind, mf.relative_path, mf.byte_size, mf.status + FROM asset_cleanup_request_items items + JOIN managed_files mf ON mf.file_id = items.managed_file_id + WHERE items.request_id = ? ORDER BY mf.file_id + `).all(input.requestId) as ManagedFileRow[]; + if (files.length !== request.file_count) throw new Error("ASSET_CLEANUP_CANDIDATE_STALE"); + const conflicted = files.some((file) => file.status !== "committed" + || !new Set(["sticker_original", "sticker_thumbnail"]).has(file.file_kind) + || this.assetReferenceCount(file.file_id, input.requestId) > 0); + if (conflicted) { + this.database.prepare(` + UPDATE asset_cleanup_requests + SET status = 'denied', confirmed_at = ?, confirmed_by = ?, denied_reason = 'reference_conflict' + WHERE request_id = ? + `).run(new Date(confirmedAt).toISOString(), input.actorId, input.requestId); + this.insertCleanupAudit({ + actorRef: input.actorId, + afterSummary: { file_count: files.length, reason: "reference_conflict", status: "denied" }, + operationType: "asset_cleanup_reference_denied", + requestId: input.requestId, + result: "failed", + }, confirmedAt); + return { conflict: true as const }; + } + + this.insertCleanupAudit({ + actorRef: input.actorId, + afterSummary: { file_count: files.length, status: "validated" }, + operationType: "asset_cleanup_validated", + requestId: input.requestId, + result: "succeeded", + }, confirmedAt); + for (const file of files) { + this.database.prepare(` + UPDATE managed_files SET status = 'purged', purged_at = ?, cleanup_status = 'pending_delete' + WHERE file_id = ? AND status = 'committed' + `).run(new Date(confirmedAt).toISOString(), file.file_id); + this.database.prepare(` + INSERT INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL) + `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, new Date(confirmedAt).toISOString()); + } + this.database.prepare(` + UPDATE asset_cleanup_requests + SET status = 'queued', confirmed_at = ?, confirmed_by = ? + WHERE request_id = ? + `).run(new Date(confirmedAt).toISOString(), input.actorId, input.requestId); + this.insertCleanupAudit({ + actorRef: input.actorId, + afterSummary: { file_count: files.length, status: "queued", total_bytes: request.total_bytes ?? 0 }, + operationType: "asset_cleanup_scheduled", + requestId: input.requestId, + result: "succeeded", + }, confirmedAt + 1); + return { conflict: false as const, file_count: files.length, request_id: input.requestId, status: "queued" as const }; + }).immediate(); + if (outcome.conflict) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT"); + return outcome; + } + createCleanupIntent(fileIds: string[]) { if (this.inspectAction("explicit_cleanup") !== "allow") throw new Error("cleanup_uncommitted"); if (fileIds.length === 0 || new Set(fileIds).size !== fileIds.length) throw new Error("cleanup_candidates_invalid"); @@ -561,9 +1119,15 @@ export class ManagedStorage { return row.count > 0; }); if (conflict) { - this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(now(), requestId); - this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'denied_reference_conflict', ?, ?)") - .run(randomUUID(), requestId, now()); + const confirmedAt = now(); + const occurredAt = Date.now(); + this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(confirmedAt, requestId); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'failed', NULL, ?, ?, ?) + `).run(randomUUID(), requestId, serializeAuditSummary({ reason: "reference_conflict" }), occurredAt, auditExpiry(occurredAt)); return false; } for (const file of files) { @@ -574,9 +1138,15 @@ export class ManagedStorage { VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?) `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now()); } - this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(now(), requestId); - this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'queued', ?, ?)") - .run(randomUUID(), requestId, now()); + const confirmedAt = now(); + const occurredAt = Date.now(); + this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(confirmedAt, requestId); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'succeeded', NULL, ?, ?, ?) + `).run(randomUUID(), requestId, serializeAuditSummary({ status: "queued" }), occurredAt, auditExpiry(occurredAt)); return true; }); if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT"); @@ -598,6 +1168,7 @@ export class ManagedStorage { this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(row.managed_file_id); const requests = this.database.prepare("SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ?").all(row.managed_file_id) as Array<{ request_id: string }>; this.database.prepare("DELETE FROM asset_cleanup_request_items WHERE managed_file_id = ?").run(row.managed_file_id); + this.database.prepare("DELETE FROM sticker_managed_file_history WHERE managed_file_id = ?").run(row.managed_file_id); this.database.prepare("DELETE FROM managed_files WHERE file_id = ?").run(row.managed_file_id); for (const request of requests) { const pendingItems = this.database.prepare("SELECT COUNT(*) AS count FROM asset_cleanup_request_items WHERE request_id = ?").get(request.request_id) as { count: number }; @@ -605,8 +1176,13 @@ export class ManagedStorage { } } this.database.prepare("UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL WHERE cleanup_id = ?").run(now(), row.cleanup_id); - this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'physical_file_cleanup', 'completed', ?, ?)") - .run(randomUUID(), row.cleanup_id, now()); + const occurredAt = Date.now(); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage', 'physical_file_cleanup', 'cleanup_queue_item', ?, 'succeeded', NULL, ?, ?, ?) + `).run(randomUUID(), row.cleanup_id, serializeAuditSummary({ status: "completed" }), occurredAt, auditExpiry(occurredAt)); this.recordPhysicalMeasurement(); }); finish(); diff --git a/apps/api/src/model-configuration.ts b/apps/api/src/model-configuration.ts new file mode 100644 index 0000000..f8ff23e --- /dev/null +++ b/apps/api/src/model-configuration.ts @@ -0,0 +1,544 @@ +import { randomUUID, createHash } from "node:crypto"; +import type BetterSqlite3 from "better-sqlite3"; + +import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js"; + +export const modelIds = [ + "gemini-3.1-flash-image-preview", + "gemini-3-pro-image-preview", + "gpt-image-2", +] as const; + +export type ModelId = typeof modelIds[number]; +export type ContractValidationStatus = "blocked" | "unverified" | "verified"; +export type ModelRuntimeReason = + | "available" + | "configured_disabled" + | "contract_unverified" + | "contract_blocked" + | "gateway_balance_insufficient" + | "gateway_paused" + | "worker_degraded"; + +export interface ModelConfigCandidate { + model_id: string; + display_name: string; + enabled: boolean; + is_default: boolean; + recommendation_priority: number; + route_profile: Record; + gateway_account_ref: string; + error_mapping_profile: Record; + credit_cost: number; + supported_ratios: string[]; + reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number }; + prompt_max_length: number; + safety_source: string; + contract_validation_status?: ContractValidationStatus; + contract_evidence_ref?: string | null; + config_version?: number; +} + +export interface ModelRuntimeAvailability { + available_for_new_jobs: boolean; + reason: ModelRuntimeReason; + checked_at: string; +} + +export interface ModelConfigView extends ModelConfigCandidate { + config_version: number; + contract_validation_status: ContractValidationStatus; + contract_evidence_ref: string | null; + runtime_availability: ModelRuntimeAvailability; +} + +export interface ModelConfigurationView { + config_set_version: number; + configured_default_model_id: ModelId; + recommended_model_id: ModelId | null; + models: ModelConfigView[]; +} + +export class ModelConfigurationError extends Error { + constructor( + readonly code: + | "MODEL_CONFIG_VERSION_CONFLICT" + | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" + | "MODEL_DEFAULT_REPLACEMENT_INVALID" + | "MODEL_RECOMMENDATION_PRIORITY_INVALID" + | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" + | "IDEMPOTENCY_KEY_CONFLICT", + readonly details: Record = {}, + ) { + super(code); + this.name = "ModelConfigurationError"; + } +} + +interface StoredVersion { + config_version: number; + contract_evidence_ref: string | null; + contract_validation_status: ContractValidationStatus; + credit_cost: number; + display_name: string; + error_mapping_profile_json: string; + gateway_account_ref: string; + model_id: ModelId; + prompt_max_length: number; + reference_limits_json: string; + route_profile_json: string; + safety_source: string; + supported_ratios_json: string; +} + +interface StoredMember { + config_set_id: string; + config_version: number; + enabled: 0 | 1; + is_default: 0 | 1; + model_id: ModelId; + recommendation_priority: number; +} + +const defaultErrorMapping: Record = { + gateway_contract_invalid: "gateway_contract_invalid", + gateway_balance_insufficient: "gateway_balance_insufficient", + reference_invalid: "reference_invalid", + safety_rejected: "safety_rejected", + unknown_non_retryable: "unknown_non_retryable", + unknown_retryable: "unknown_retryable", + upstream_failed: "upstream_failed", + upstream_timeout: "upstream_timeout", +}; + +const seedCandidates: ModelConfigCandidate[] = [ + { + model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true, + recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" }, + gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1, + supported_ratios: ["3:4", "1:1", "4:3", "9:16"], + reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 }, + prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null, + }, + { + model_id: modelIds[1], display_name: "Gemini 3 Pro Image Preview", enabled: true, is_default: false, + recommendation_priority: 2, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" }, + gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1, + supported_ratios: ["3:4", "1:1", "4:3", "9:16"], + reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 }, + prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null, + }, + { + model_id: modelIds[2], display_name: "GPT Image 2", enabled: true, is_default: false, + recommendation_priority: 3, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" }, + gateway_account_ref: "mock-gateway", error_mapping_profile: defaultErrorMapping, credit_cost: 1, + supported_ratios: ["3:4", "1:1", "4:3", "9:16"], + reference_limits: { max_file_bytes: 10_485_760, max_files: 2, max_total_bytes: 20_971_520 }, + prompt_max_length: 1_000, safety_source: "provider", contract_validation_status: "unverified", contract_evidence_ref: null, + }, +]; + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function fingerprint(candidate: ModelConfigCandidate) { + return createHash("sha256").update(stableJson({ + error_mapping_profile: candidate.error_mapping_profile, + gateway_account_ref: candidate.gateway_account_ref, + prompt_max_length: candidate.prompt_max_length, + reference_limits: candidate.reference_limits, + route_profile: candidate.route_profile, + safety_source: candidate.safety_source, + supported_ratios: candidate.supported_ratios, + })).digest("hex"); +} + +function profileRef(prefix: "error" | "route", value: Record) { + return `${prefix}:${createHash("sha256").update(stableJson(value)).digest("hex")}`; +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function validateModelConfigurationCandidateSet(input: ModelConfigCandidate[]) { + if (input.length !== modelIds.length || new Set(input.map((item) => item.model_id)).size !== modelIds.length + || input.some((item) => !modelIds.includes(item.model_id as ModelId))) { + throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "fixed_model_set" }); + } + for (const item of input) { + if (!Number.isSafeInteger(item.recommendation_priority) || item.recommendation_priority <= 0) { + throw new ModelConfigurationError("MODEL_RECOMMENDATION_PRIORITY_INVALID", { + field_errors: [{ field: `models.${item.model_id}.recommendation_priority`, message_key: "model.priority.invalid" }], + }); + } + } + const byPriority = new Map(); + for (const item of input) byPriority.set(item.recommendation_priority, [...(byPriority.get(item.recommendation_priority) ?? []), item]); + const duplicate = [...byPriority.values()].find((items) => items.length > 1); + if (duplicate) { + throw new ModelConfigurationError("MODEL_RECOMMENDATION_PRIORITY_CONFLICT", { + conflict_model_ids: duplicate.map((item) => item.model_id), + }); + } + if (input.some((item) => !isPlainRecord(item.route_profile) || !isPlainRecord(item.error_mapping_profile))) { + throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "contract_fields_invalid" }); + } +} + +function runtimeFor(candidate: ModelConfigCandidate, previous: ModelRuntimeAvailability | undefined, contractChanged: boolean, now: number): ModelRuntimeAvailability { + if (!candidate.enabled) return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "configured_disabled" }; + if (candidate.contract_validation_status === "blocked" && !contractChanged) { + return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_blocked" }; + } + if (candidate.contract_validation_status !== "verified" || contractChanged) { + return { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_unverified" }; + } + return previous ?? { available_for_new_jobs: false, checked_at: new Date(now).toISOString(), reason: "contract_unverified" }; +} + +export interface ModelConfigurationServiceOptions { + clock?: () => number; + database: BetterSqlite3.Database; + onChanged?: (configSetVersion: number) => void; +} + +export class ModelConfigurationService { + readonly database: BetterSqlite3.Database; + readonly #clock: () => number; + readonly #onChanged: ((configSetVersion: number) => void) | undefined; + + constructor(options: ModelConfigurationServiceOptions) { + this.database = options.database; + this.#clock = options.clock ?? Date.now; + this.#onChanged = options.onChanged; + this.ensureSchema(); + } + + read(): ModelConfigurationView { + const current = this.database.prepare(` + SELECT c.config_set_id, c.config_set_version FROM model_config_current mc + JOIN model_config_sets c ON c.config_set_id = mc.config_set_id WHERE mc.singleton = 1 + `).get() as { config_set_id: string; config_set_version: number } | undefined; + if (!current) throw new Error("model_config_current_missing"); + const rows = this.database.prepare(` + SELECT m.model_id, m.enabled, m.is_default, m.recommendation_priority, v.*, + r.available_for_new_jobs, r.reason, r.checked_at + FROM model_config_set_members m + JOIN model_config_versions v ON v.model_id = m.model_id AND v.config_version = m.config_version + JOIN model_runtime_availability r ON r.model_id = m.model_id + WHERE m.config_set_id = ? ORDER BY m.recommendation_priority + `).all(current.config_set_id) as Array; + const models = rows.map((row) => ({ + model_id: row.model_id, + display_name: row.display_name, + config_version: row.config_version, + enabled: row.enabled === 1, + is_default: row.is_default === 1, + recommendation_priority: row.recommendation_priority, + route_profile: JSON.parse(row.route_profile_json) as Record, + gateway_account_ref: row.gateway_account_ref, + error_mapping_profile: JSON.parse(row.error_mapping_profile_json) as Record, + credit_cost: row.credit_cost, + supported_ratios: JSON.parse(row.supported_ratios_json) as string[], + reference_limits: JSON.parse(row.reference_limits_json) as ModelConfigCandidate["reference_limits"], + prompt_max_length: row.prompt_max_length, + safety_source: row.safety_source, + contract_validation_status: row.contract_validation_status, + contract_evidence_ref: row.contract_evidence_ref, + runtime_availability: { + available_for_new_jobs: row.available_for_new_jobs === 1, + checked_at: new Date(row.checked_at).toISOString(), + reason: row.reason, + }, + } satisfies ModelConfigView)); + const recommended = models.find((model) => model.enabled + && model.contract_validation_status === "verified" + && model.runtime_availability.available_for_new_jobs); + return { + config_set_version: current.config_set_version, + configured_default_model_id: models.find((model) => model.enabled && model.is_default)!.model_id as ModelId, + recommended_model_id: recommended?.model_id ?? null, + models, + }; + } + + readModel(modelId: string) { + return this.read().models.find((model) => model.model_id === modelId); + } + + replace(input: { actorId: string; expectedConfigSetVersion: number; idempotencyKey: string; models: ModelConfigCandidate[] }) { + const requestHash = createHash("sha256").update(JSON.stringify({ expected: input.expectedConfigSetVersion, models: input.models })).digest("hex"); + const now = this.#clock(); + const result = this.database.transaction(() => { + const existing = this.database.prepare("SELECT request_hash, response_json FROM model_config_idempotency WHERE idempotency_key = ?") + .get(input.idempotencyKey) as { request_hash: string; response_json: string } | undefined; + if (existing) { + if (existing.request_hash !== requestHash) throw new ModelConfigurationError("IDEMPOTENCY_KEY_CONFLICT"); + return { replayed: true, value: JSON.parse(existing.response_json) as ModelConfigurationView }; + } + validateModelConfigurationCandidateSet(input.models); + const current = this.database.prepare(` + SELECT c.config_set_id, c.config_set_version FROM model_config_current mc + JOIN model_config_sets c ON c.config_set_id = mc.config_set_id WHERE mc.singleton = 1 + `).get() as { config_set_id: string; config_set_version: number }; + if (input.expectedConfigSetVersion !== current.config_set_version) { + throw new ModelConfigurationError("MODEL_CONFIG_VERSION_CONFLICT", { latest_version: current.config_set_version }); + } + const previous = this.read(); + const oldDefault = previous.models.find((model) => model.is_default && model.enabled); + const declaredDefaults = input.models.filter((model) => model.is_default); + const nextDefaults = declaredDefaults.filter((model) => model.enabled); + const nextDefault = nextDefaults[0]; + if (oldDefault && !input.models.find((model) => model.model_id === oldDefault.model_id)?.enabled) { + if (declaredDefaults.length === 0) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_REQUIRED"); + if (!nextDefault) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID"); + if (nextDefault.model_id === oldDefault.model_id) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID"); + } + if (nextDefaults.length !== 1) throw new ModelConfigurationError("MODEL_DEFAULT_REPLACEMENT_INVALID", { reason: "enabled_default_count" }); + const setId = randomUUID(); + const nextSetVersion = current.config_set_version + 1; + this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, ?, ?, ?)") + .run(setId, nextSetVersion, now, input.actorId); + const insertVersion = this.database.prepare(` + INSERT INTO model_config_versions ( + model_id, config_version, display_name, enabled, is_default, recommendation_priority, + route_profile_id, route_profile_json, gateway_account_ref, error_mapping_profile_id, error_mapping_profile_json, + credit_cost, supported_ratios_json, reference_limits_json, prompt_max_length, safety_source, + contract_validation_status, contract_evidence_ref, contract_fingerprint, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const insertMember = this.database.prepare(` + INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority) + VALUES (?, ?, ?, ?, ?, ?) + `); + for (const candidate of input.models) { + const old = previous.models.find((model) => model.model_id === candidate.model_id); + const changed = !old || fingerprint(candidate) !== fingerprint(old) || candidate.display_name !== old.display_name + || candidate.credit_cost !== old.credit_cost || candidate.enabled !== old.enabled + || candidate.is_default !== old.is_default || candidate.recommendation_priority !== old.recommendation_priority + || (candidate.contract_validation_status !== undefined && candidate.contract_validation_status !== old.contract_validation_status); + const contractChanged = Boolean(old && fingerprint(candidate) !== fingerprint(old)); + const configVersion = changed ? (old?.config_version ?? 0) + 1 : old!.config_version; + const status: ContractValidationStatus = contractChanged + ? "unverified" + : candidate.contract_validation_status ?? old?.contract_validation_status ?? "unverified"; + const evidence = status === "unverified" ? null : candidate.contract_evidence_ref ?? old?.contract_evidence_ref ?? null; + if (changed) { + const routeProfileId = profileRef("route", candidate.route_profile); + const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile); + this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)") + .run(routeProfileId, stableJson(candidate.route_profile), now); + this.database.prepare("INSERT OR IGNORE INTO error_mapping_profiles (error_mapping_profile_id, profile_json, created_at) VALUES (?, ?, ?)") + .run(errorMappingProfileId, stableJson(candidate.error_mapping_profile), now); + insertVersion.run( + candidate.model_id, configVersion, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, + candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref, + errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), + stableJson(candidate.reference_limits), candidate.prompt_max_length, candidate.safety_source, status, evidence, fingerprint(candidate), now, + ); + } + insertMember.run(setId, candidate.model_id, configVersion, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority); + const runtime = runtimeFor({ ...candidate, contract_validation_status: status }, old?.runtime_availability, contractChanged, now); + this.database.prepare(` + INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version) + VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(runtime_availability_version), 0) + 1 FROM model_runtime_availability)) + ON CONFLICT(model_id) DO UPDATE SET available_for_new_jobs=excluded.available_for_new_jobs, reason=excluded.reason, + checked_at=excluded.checked_at, runtime_availability_version=excluded.runtime_availability_version + `).run(candidate.model_id, runtime.available_for_new_jobs ? 1 : 0, runtime.reason, now); + } + this.database.prepare("UPDATE model_config_current SET config_set_id = ? WHERE singleton = 1").run(setId); + const after = this.read(); + const occurredAt = now; + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, result, + before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'super_admin', ?, 'model_configuration_replace', 'model_config_set', ?, 'succeeded', ?, ?, ?, ?) + `).run( + randomUUID(), input.actorId, setId, + serializeAuditSummary({ config_set_version: current.config_set_version }), + serializeAuditSummary({ config_set_version: nextSetVersion, model_count: after.models.length }), occurredAt, occurredAt + auditRetentionMilliseconds, + ); + this.database.prepare(` + INSERT INTO outbox_events (event_id, operation_key, topic, aggregate_type, aggregate_id, payload_json, status, created_at, published_at) + VALUES (?, ?, 'model_config_changed', 'model_config_set', ?, ?, 'pending', ?, NULL) + `).run(randomUUID(), `model-config:${input.idempotencyKey}`, setId, JSON.stringify({ config_set_version: nextSetVersion }), now); + this.database.prepare("INSERT INTO model_config_idempotency (idempotency_key, request_hash, response_json, created_at) VALUES (?, ?, ?, ?)") + .run(input.idempotencyKey, requestHash, JSON.stringify(after), now); + return { replayed: false, value: after }; + }).immediate(); + if (!result.replayed) this.#onChanged?.(result.value.config_set_version); + return result.value; + } + + private ensureSchema() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS gateway_route_profiles ( + route_profile_id TEXT PRIMARY KEY, + profile_json TEXT NOT NULL CHECK (json_valid(profile_json)), + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS error_mapping_profiles ( + error_mapping_profile_id TEXT PRIMARY KEY, + profile_json TEXT NOT NULL CHECK (json_valid(profile_json)), + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS model_config_sets ( + config_set_id TEXT PRIMARY KEY, + config_set_version INTEGER NOT NULL UNIQUE CHECK (config_set_version > 0), + created_at INTEGER NOT NULL, + created_by TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS model_config_versions ( + model_id TEXT NOT NULL CHECK (model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')), + config_version INTEGER NOT NULL CHECK (config_version > 0), + display_name TEXT NOT NULL, + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + is_default INTEGER NOT NULL CHECK (is_default IN (0, 1)), + recommendation_priority INTEGER NOT NULL CHECK (recommendation_priority > 0), + route_profile_id TEXT NOT NULL REFERENCES gateway_route_profiles(route_profile_id), + route_profile_json TEXT NOT NULL CHECK (json_valid(route_profile_json)), + gateway_account_ref TEXT NOT NULL, + error_mapping_profile_id TEXT NOT NULL REFERENCES error_mapping_profiles(error_mapping_profile_id), + error_mapping_profile_json TEXT NOT NULL CHECK (json_valid(error_mapping_profile_json)), + credit_cost INTEGER NOT NULL CHECK (credit_cost > 0), + supported_ratios_json TEXT NOT NULL CHECK (json_valid(supported_ratios_json)), + reference_limits_json TEXT NOT NULL CHECK (json_valid(reference_limits_json)), + prompt_max_length INTEGER NOT NULL CHECK (prompt_max_length > 0), + safety_source TEXT NOT NULL, + contract_validation_status TEXT NOT NULL CHECK (contract_validation_status IN ('blocked', 'unverified', 'verified')), + contract_evidence_ref TEXT, + contract_fingerprint TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (model_id, config_version) + ); + CREATE TABLE IF NOT EXISTS model_config_set_members ( + config_set_id TEXT NOT NULL REFERENCES model_config_sets(config_set_id), + model_id TEXT NOT NULL CHECK (model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')), + config_version INTEGER NOT NULL, + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + is_default INTEGER NOT NULL CHECK (is_default IN (0, 1)), + recommendation_priority INTEGER NOT NULL CHECK (recommendation_priority > 0), + PRIMARY KEY (config_set_id, model_id), + FOREIGN KEY (model_id, config_version) REFERENCES model_config_versions(model_id, config_version) + ); + CREATE UNIQUE INDEX IF NOT EXISTS model_config_set_priority_unique + ON model_config_set_members(config_set_id, recommendation_priority); + CREATE UNIQUE INDEX IF NOT EXISTS model_config_set_enabled_default_unique + ON model_config_set_members(config_set_id) WHERE enabled = 1 AND is_default = 1; + CREATE TABLE IF NOT EXISTS model_config_current ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + config_set_id TEXT NOT NULL REFERENCES model_config_sets(config_set_id) + ); + CREATE TABLE IF NOT EXISTS model_runtime_availability ( + model_id TEXT PRIMARY KEY, + available_for_new_jobs INTEGER NOT NULL CHECK (available_for_new_jobs IN (0, 1)), + reason TEXT NOT NULL CHECK (reason IN ('available', 'configured_disabled', 'contract_unverified', 'contract_blocked', 'gateway_balance_insufficient', 'gateway_paused', 'worker_degraded')), + checked_at INTEGER NOT NULL, + runtime_availability_version INTEGER NOT NULL CHECK (runtime_availability_version >= 0) + ); + CREATE TABLE IF NOT EXISTS model_config_idempotency ( + idempotency_key TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + response_json TEXT NOT NULL CHECK (json_valid(response_json)), + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS outbox_events ( + event_id TEXT PRIMARY KEY, + operation_key TEXT NOT NULL UNIQUE, + topic TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + payload_json TEXT NOT NULL CHECK (json_valid(payload_json)), + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + published_at INTEGER + ); + `); + this.database.exec(` + DROP TRIGGER IF EXISTS model_config_current_default_guard_insert; + DROP TRIGGER IF EXISTS model_config_current_default_guard_update; + CREATE TRIGGER model_config_current_default_guard_insert + BEFORE INSERT ON model_config_current + WHEN (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id AND enabled = 1 AND is_default = 1) <> 1 + OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id) <> 3 + OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id + AND model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')) <> 3 + BEGIN SELECT RAISE(ABORT, 'model_config_default_invariant'); END; + CREATE TRIGGER model_config_current_default_guard_update + BEFORE UPDATE OF config_set_id ON model_config_current + WHEN (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id AND enabled = 1 AND is_default = 1) <> 1 + OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id) <> 3 + OR (SELECT COUNT(*) FROM model_config_set_members WHERE config_set_id = NEW.config_set_id + AND model_id IN ('gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gpt-image-2')) <> 3 + BEGIN SELECT RAISE(ABORT, 'model_config_default_invariant'); END; + CREATE TRIGGER IF NOT EXISTS model_config_sets_no_update BEFORE UPDATE ON model_config_sets + BEGIN SELECT RAISE(ABORT, 'model_config_sets_immutable'); END; + CREATE TRIGGER IF NOT EXISTS model_config_sets_no_delete BEFORE DELETE ON model_config_sets + BEGIN SELECT RAISE(ABORT, 'model_config_sets_immutable'); END; + CREATE TRIGGER IF NOT EXISTS model_config_versions_no_update BEFORE UPDATE ON model_config_versions + BEGIN SELECT RAISE(ABORT, 'model_config_versions_immutable'); END; + CREATE TRIGGER IF NOT EXISTS model_config_versions_no_delete BEFORE DELETE ON model_config_versions + BEGIN SELECT RAISE(ABORT, 'model_config_versions_immutable'); END; + CREATE TRIGGER IF NOT EXISTS model_config_members_no_update BEFORE UPDATE ON model_config_set_members + BEGIN SELECT RAISE(ABORT, 'model_config_members_immutable'); END; + CREATE TRIGGER IF NOT EXISTS model_config_members_no_delete BEFORE DELETE ON model_config_set_members + BEGIN SELECT RAISE(ABORT, 'model_config_members_immutable'); END; + CREATE TRIGGER IF NOT EXISTS gateway_route_profiles_no_update BEFORE UPDATE ON gateway_route_profiles + BEGIN SELECT RAISE(ABORT, 'gateway_route_profiles_immutable'); END; + CREATE TRIGGER IF NOT EXISTS gateway_route_profiles_no_delete BEFORE DELETE ON gateway_route_profiles + BEGIN SELECT RAISE(ABORT, 'gateway_route_profiles_immutable'); END; + CREATE TRIGGER IF NOT EXISTS error_mapping_profiles_no_update BEFORE UPDATE ON error_mapping_profiles + BEGIN SELECT RAISE(ABORT, 'error_mapping_profiles_immutable'); END; + CREATE TRIGGER IF NOT EXISTS error_mapping_profiles_no_delete BEFORE DELETE ON error_mapping_profiles + BEGIN SELECT RAISE(ABORT, 'error_mapping_profiles_immutable'); END; + `); + const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined; + if (current) return; + const seed = this.database.transaction(() => { + validateModelConfigurationCandidateSet(seedCandidates); + const now = this.#clock(); + const setId = randomUUID(); + this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')") + .run(setId, now); + const insertVersion = this.database.prepare(` + INSERT INTO model_config_versions ( + model_id, config_version, display_name, enabled, is_default, recommendation_priority, + route_profile_id, route_profile_json, gateway_account_ref, error_mapping_profile_id, error_mapping_profile_json, + credit_cost, supported_ratios_json, reference_limits_json, prompt_max_length, safety_source, + contract_validation_status, contract_evidence_ref, contract_fingerprint, created_at + ) VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const insertMember = this.database.prepare(` + INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority) + VALUES (?, ?, 1, ?, ?, ?) + `); + for (const candidate of seedCandidates) { + const routeProfileId = profileRef("route", candidate.route_profile); + const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile); + this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)") + .run(routeProfileId, stableJson(candidate.route_profile), now); + this.database.prepare("INSERT OR IGNORE INTO error_mapping_profiles (error_mapping_profile_id, profile_json, created_at) VALUES (?, ?, ?)") + .run(errorMappingProfileId, stableJson(candidate.error_mapping_profile), now); + insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, + candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref, + errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits), + candidate.prompt_max_length, candidate.safety_source, "unverified", null, fingerprint(candidate), now); + insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority); + this.database.prepare(` + INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version) + VALUES (?, 0, 'contract_unverified', ?, 0) + `).run(candidate.model_id, now); + } + this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId); + }); + seed.immediate(); + } +} diff --git a/apps/api/src/model-contract-evidence.ts b/apps/api/src/model-contract-evidence.ts new file mode 100644 index 0000000..1448812 --- /dev/null +++ b/apps/api/src/model-contract-evidence.ts @@ -0,0 +1,137 @@ +import type BetterSqlite3 from "better-sqlite3"; + +import { ModelConfigurationService, modelIds, type ModelConfigCandidate } from "./model-configuration.js"; + +const safeRefPattern = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/; +const requiredRatios = ["3:4", "1:1", "4:3", "9:16"]; +const requiredAdapterErrors = [ + "upstream_timeout", "upstream_failed", "safety_rejected", "gateway_balance_insufficient", + "gateway_contract_invalid", "reference_invalid", "unknown_retryable", "unknown_non_retryable", +]; +const forbiddenKeyFragments = ["credential", "password", "prompt_text", "raw", "secret", "token"]; + +export interface ModelContractEvidenceInput { + evidence_hash: string; + evidence_ref: string; + matrix: unknown; + model_id: string; + verified_at: string; + verifier_ref: string; +} + +function hasForbiddenEvidenceKey(value: unknown, depth = 0): boolean { + if (depth > 8 || !value || typeof value !== "object") return false; + if (Array.isArray(value)) return value.some((entry) => hasForbiddenEvidenceKey(entry, depth + 1)); + return Object.entries(value).some(([key, entry]) => ( + forbiddenKeyFragments.some((fragment) => key.toLowerCase().includes(fragment)) + || hasForbiddenEvidenceKey(entry, depth + 1) + )); +} + +function validateEvidence(input: ModelContractEvidenceInput) { + if (!modelIds.includes(input.model_id as typeof modelIds[number])) throw new Error("contract_evidence_model_invalid"); + if (!safeRefPattern.test(input.evidence_hash) || !safeRefPattern.test(input.evidence_ref) || !safeRefPattern.test(input.verifier_ref)) { + throw new Error("contract_evidence_reference_invalid"); + } + const verifiedAt = Date.parse(input.verified_at); + if (!Number.isFinite(verifiedAt) || new Date(verifiedAt).toISOString() !== input.verified_at) throw new Error("contract_evidence_time_invalid"); + if (hasForbiddenEvidenceKey(input.matrix)) throw new Error("contract_evidence_sensitive_field"); + if (!input.matrix || typeof input.matrix !== "object") throw new Error("contract_evidence_matrix_incomplete"); + const matrix = input.matrix as Record; + const passedSingleOutput = (value: unknown) => Boolean(value && typeof value === "object" + && "status" in value && value.status === "passed" && "outputs" in value && value.outputs === 1); + const ratios = Array.isArray(matrix.ratios) ? matrix.ratios as Array> : []; + const ratioNames = ratios.filter(passedSingleOutput).map((entry) => entry.ratio).toSorted(); + const executionModes = Array.isArray(matrix.execution_modes) ? matrix.execution_modes : []; + const errorMapping = Array.isArray(matrix.error_mapping) ? matrix.error_mapping : []; + const executionComplete = executionModes.includes("sync") || (executionModes.includes("async") && executionModes.includes("poll")); + if (matrix.model_id !== input.model_id || !passedSingleOutput(matrix.pure_text) || !passedSingleOutput(matrix.reference_image) + || JSON.stringify(ratioNames) !== JSON.stringify([...requiredRatios].toSorted()) || !executionComplete + || !requiredAdapterErrors.every((category) => errorMapping.includes(category))) { + throw new Error("contract_evidence_matrix_incomplete"); + } + return verifiedAt; +} + +function editableCandidates(models: ReturnType["models"]): ModelConfigCandidate[] { + return models.map(({ config_version: _configVersion, runtime_availability: _runtime, ...candidate }) => structuredClone(candidate)); +} + +export class ModelContractEvidenceService { + readonly database: BetterSqlite3.Database; + private readonly clock: () => number; + private readonly models: ModelConfigurationService; + + constructor(input: { clock?: () => number; database: BetterSqlite3.Database; models: ModelConfigurationService }) { + this.clock = input.clock ?? Date.now; + this.database = input.database; + this.models = input.models; + this.migrate(); + } + + recordVerified(input: { + actorId: string; + evidence: ModelContractEvidenceInput; + expectedConfigSetVersion: number; + idempotencyKey: string; + }) { + const verifiedAt = validateEvidence(input.evidence); + return this.database.transaction(() => { + const current = this.models.read(); + if (current.config_set_version !== input.expectedConfigSetVersion) throw new Error("contract_evidence_config_set_conflict"); + const existingHash = this.database.prepare("SELECT model_id FROM model_contract_evidence WHERE evidence_hash = ?").get(input.evidence.evidence_hash) as { model_id: string } | undefined; + if (existingHash && existingHash.model_id !== input.evidence.model_id) throw new Error("contract_evidence_shared_between_models"); + const candidates = editableCandidates(current.models); + const target = candidates.find((model) => model.model_id === input.evidence.model_id)!; + target.contract_validation_status = "verified"; + target.contract_evidence_ref = input.evidence.evidence_ref; + const configuration = this.models.replace({ + actorId: input.actorId, + expectedConfigSetVersion: input.expectedConfigSetVersion, + idempotencyKey: input.idempotencyKey, + models: candidates, + }); + const model = configuration.models.find((candidate) => candidate.model_id === input.evidence.model_id)!; + this.database.prepare(` + INSERT OR IGNORE INTO model_contract_evidence ( + model_id, config_version, evidence_hash, evidence_ref, verifier_ref, verified_at, evidence_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.evidence.model_id, model.config_version, input.evidence.evidence_hash, input.evidence.evidence_ref, + input.evidence.verifier_ref, verifiedAt, JSON.stringify(input.evidence.matrix), this.clock(), + ); + return { configuration, evidence: structuredClone(input.evidence), model }; + }).immediate(); + } + + read(modelId: string, configVersion: number) { + return this.database.prepare(` + SELECT model_id, config_version, evidence_hash, evidence_ref, verifier_ref, verified_at, evidence_json + FROM model_contract_evidence WHERE model_id = ? AND config_version = ? + `).get(modelId, configVersion) as { + config_version: number; evidence_hash: string; evidence_json: string; evidence_ref: string; + model_id: string; verified_at: number; verifier_ref: string; + } | undefined; + } + + private migrate() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS model_contract_evidence ( + model_id TEXT NOT NULL, + config_version INTEGER NOT NULL, + evidence_hash TEXT NOT NULL UNIQUE, + evidence_ref TEXT NOT NULL, + verifier_ref TEXT NOT NULL, + verified_at INTEGER NOT NULL, + evidence_json TEXT NOT NULL CHECK (json_valid(evidence_json)), + created_at INTEGER NOT NULL, + PRIMARY KEY (model_id, config_version), + FOREIGN KEY (model_id, config_version) REFERENCES model_config_versions(model_id, config_version) + ); + CREATE TRIGGER IF NOT EXISTS model_contract_evidence_no_update BEFORE UPDATE ON model_contract_evidence + BEGIN SELECT RAISE(ABORT, 'model_contract_evidence_immutable'); END; + CREATE TRIGGER IF NOT EXISTS model_contract_evidence_no_delete BEFORE DELETE ON model_contract_evidence + BEGIN SELECT RAISE(ABORT, 'model_contract_evidence_immutable'); END; + `); + } +} diff --git a/apps/api/src/preview-grants.ts b/apps/api/src/preview-grants.ts new file mode 100644 index 0000000..71ff023 --- /dev/null +++ b/apps/api/src/preview-grants.ts @@ -0,0 +1,483 @@ +import { createHash, randomUUID } from "node:crypto"; + +import type { + AssetReleaseManifestItem, + AssetReleaseManifestProjection, + AssetReleaseReader, +} from "@dada/asset-release-manifest"; + +import { auditRetentionMilliseconds, serializeAuditSummary } from "./audit-policy.js"; +import type { RegistrationService } from "./registration.js"; + +export type PreviewBatchStatus = "active" | "closed"; +export type PreviewGrantStatus = "active" | "revoked" | "expired"; + +export interface PreviewBatchView { + batchId: string; + createdAt: number; + createdBy: string; + name: string; + status: PreviewBatchStatus; +} + +export interface PreviewGrantView { + batchId: string; + expiresAt: number; + grantId: string; + grantedAt: number; + grantedBy: string; + status: PreviewGrantStatus; + userId: string; +} + +export class PreviewGrantError extends Error { + constructor( + public readonly reason: + | "admin_invalid" + | "batch_closed" + | "batch_not_found" + | "grant_not_found" + | "invalid_expiry" + | "invalid_request" + | "resource_not_found" + | "user_not_eligible", + ) { + super(reason); + this.name = "PreviewGrantError"; + } +} + +interface PreviewGrantServiceOptions { + assetReleases: AssetReleaseReader; + clock?: () => number; + registration: RegistrationService; +} + +interface PreviewManifestItemMapping { + releaseVersion: string; + resourceId: string; + userId: string; +} + +function isUuid(value: string) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function assertText(value: string, name: string) { + const normalized = value.trim(); + if (!normalized || normalized.length > 160) throw new PreviewGrantError("invalid_request"); + if (name === "batchId" && !isUuid(normalized)) throw new PreviewGrantError("invalid_request"); + return normalized; +} + +function manifestHash(items: readonly AssetReleaseManifestItem[], releaseVersion: string) { + return createHash("sha256") + .update(JSON.stringify({ + items, + release_version: releaseVersion, + schema_version: "AssetReleaseManifest/v1", + })) + .digest("hex"); +} + +/** + * Owns the P0-A preview grant state. Preview URLs are deliberately ephemeral: + * the random item id is kept only in this process and every read rechecks the + * persisted grant, so revocation and expiry take effect without cache busting. + */ +export class AssetPreviewGrantService { + readonly database: RegistrationService["database"]; + readonly options: Required> & PreviewGrantServiceOptions; + private readonly itemMappings = new Map(); + + constructor(options: PreviewGrantServiceOptions) { + this.database = options.registration.database; + this.options = { ...options, clock: options.clock ?? Date.now }; + this.migrate(); + } + + createBatch(input: { adminUserId: string; batchId?: string; name: string }): PreviewBatchView { + const adminUserId = assertText(input.adminUserId, "adminUserId"); + const name = assertText(input.name, "name"); + const batchId = input.batchId ? assertText(input.batchId, "batchId") : randomUUID(); + const now = this.options.clock(); + this.assertAdmin(adminUserId, now); + this.immediate(() => { + this.database.prepare(` + INSERT INTO test_batches (batch_id, name, status, created_by, created_at, closed_at) + VALUES (?, ?, 'active', ?, ?, NULL) + `).run(batchId, name, adminUserId, now); + this.audit({ + actorRef: adminUserId, + afterSummary: { batch_id: batchId, status: "active" }, + beforeSummary: null, + operationType: "preview_batch_create", + targetRef: batchId, + targetType: "preview_batch", + }, now); + }); + return { batchId, createdAt: now, createdBy: adminUserId, name, status: "active" }; + } + + closeBatch(input: { adminUserId: string; batchId: string }): PreviewBatchView { + const adminUserId = assertText(input.adminUserId, "adminUserId"); + const batchId = assertText(input.batchId, "batchId"); + const now = this.options.clock(); + this.assertAdmin(adminUserId, now); + return this.immediate(() => { + const batch = this.readBatch(batchId); + if (!batch) throw new PreviewGrantError("batch_not_found"); + if (batch.status === "active") { + this.database.prepare("UPDATE test_batches SET status = 'closed', closed_at = ? WHERE batch_id = ?").run(now, batchId); + this.audit({ + actorRef: adminUserId, + afterSummary: { batch_id: batchId, status: "closed" }, + beforeSummary: { batch_id: batchId, status: batch.status }, + operationType: "preview_batch_close", + targetRef: batchId, + targetType: "preview_batch", + }, now); + } + return { ...batch, status: "closed" as const }; + }); + } + + addBatchItems(input: { + adminUserId: string; + batchId: string; + releaseVersion: string; + resourceIds: readonly string[]; + }) { + const adminUserId = assertText(input.adminUserId, "adminUserId"); + const batchId = assertText(input.batchId, "batchId"); + const releaseVersion = assertText(input.releaseVersion, "releaseVersion"); + const resourceIds = [...new Set(input.resourceIds.map((resourceId) => assertText(resourceId, "resourceId")))]; + if (resourceIds.length === 0) throw new PreviewGrantError("invalid_request"); + const now = this.options.clock(); + this.assertAdmin(adminUserId, now); + for (const resourceId of resourceIds) { + if (!this.options.assetReleases.read("internal_preview_asset", releaseVersion, resourceId)) { + throw new PreviewGrantError("resource_not_found"); + } + } + this.immediate(() => { + const batch = this.readBatch(batchId); + if (!batch) throw new PreviewGrantError("batch_not_found"); + if (batch.status !== "active") throw new PreviewGrantError("batch_closed"); + const insert = this.database.prepare(` + INSERT OR IGNORE INTO test_batch_items (test_batch_id, release_version, resource_id) + VALUES (?, ?, ?) + `); + for (const resourceId of resourceIds) insert.run(batchId, releaseVersion, resourceId); + this.audit({ + actorRef: adminUserId, + afterSummary: { batch_id: batchId, item_count: resourceIds.length, release_version: releaseVersion }, + beforeSummary: null, + operationType: "preview_batch_items_add", + targetRef: batchId, + targetType: "preview_batch", + }, now); + }); + return { batchId, releaseVersion, resourceIds }; + } + + grant(input: { + adminUserId: string; + batchId: string; + expiresAt: number; + userId: string; + }): PreviewGrantView { + const adminUserId = assertText(input.adminUserId, "adminUserId"); + const batchId = assertText(input.batchId, "batchId"); + const userId = assertText(input.userId, "userId"); + if (!isUuid(userId)) throw new PreviewGrantError("invalid_request"); + const now = this.options.clock(); + if (!Number.isSafeInteger(input.expiresAt) || input.expiresAt <= now) throw new PreviewGrantError("invalid_expiry"); + this.assertAdmin(adminUserId, now); + return this.immediate(() => { + const batch = this.readBatch(batchId); + if (!batch) throw new PreviewGrantError("batch_not_found"); + if (batch.status !== "active") throw new PreviewGrantError("batch_closed"); + const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?").get(userId) as { role: string; status: string } | undefined; + if (!user || user.role !== "user" || user.status !== "active") throw new PreviewGrantError("user_not_eligible"); + const grantId = randomUUID(); + this.database.prepare(` + INSERT INTO asset_preview_grants ( + grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status + ) VALUES (?, ?, ?, ?, ?, ?, 'active') + `).run(grantId, userId, batchId, adminUserId, now, input.expiresAt); + this.audit({ + actorRef: adminUserId, + afterSummary: { batch_id: batchId, expires_at: input.expiresAt, grant_id: grantId, status: "active", user_id: userId }, + beforeSummary: null, + operationType: "preview_grant_create", + targetRef: grantId, + targetType: "preview_grant", + }, now); + return { + batchId, + expiresAt: input.expiresAt, + grantId, + grantedAt: now, + grantedBy: adminUserId, + status: "active" as const, + userId, + }; + }); + } + + revoke(input: { adminUserId: string; grantId: string }): PreviewGrantView { + const adminUserId = assertText(input.adminUserId, "adminUserId"); + const grantId = assertText(input.grantId, "grantId"); + const now = this.options.clock(); + this.assertAdmin(adminUserId, now); + return this.immediate(() => { + this.expireDue(now); + const grant = this.readGrant(grantId); + if (!grant) throw new PreviewGrantError("grant_not_found"); + if (grant.status === "active") { + this.database.prepare("UPDATE asset_preview_grants SET status = 'revoked' WHERE grant_id = ? AND status = 'active'").run(grantId); + this.audit({ + actorRef: adminUserId, + afterSummary: { grant_id: grantId, status: "revoked" }, + beforeSummary: { grant_id: grantId, status: grant.status }, + operationType: "preview_grant_revoke", + targetRef: grantId, + targetType: "preview_grant", + }, now); + } + return { ...grant, status: "revoked" as const }; + }); + } + + listBatches(input: { adminUserId: string }): PreviewBatchView[] { + const adminUserId = assertText(input.adminUserId, "adminUserId"); + this.assertAdmin(adminUserId, this.options.clock()); + return (this.database.prepare(` + SELECT batch_id, name, status, created_by, created_at + FROM test_batches ORDER BY created_at DESC, batch_id DESC + `).all() as Array<{ batch_id: string; created_at: number; created_by: string; name: string; status: PreviewBatchStatus }>).map((row) => ({ + batchId: row.batch_id, + createdAt: row.created_at, + createdBy: row.created_by, + name: row.name, + status: row.status, + })); + } + + listGrants(input: { adminUserId: string; batchId?: string; userId?: string }): PreviewGrantView[] { + const adminUserId = assertText(input.adminUserId, "adminUserId"); + this.assertAdmin(adminUserId, this.options.clock()); + const batchId = input.batchId ? assertText(input.batchId, "batchId") : undefined; + const userId = input.userId ? assertText(input.userId, "userId") : undefined; + const now = this.options.clock(); + return this.immediate(() => { + this.expireDue(now); + const rows = this.database.prepare(` + SELECT grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status + FROM asset_preview_grants + WHERE (? IS NULL OR test_batch_id = ?) AND (? IS NULL OR user_id = ?) + ORDER BY granted_at DESC, grant_id DESC + `).all(batchId ?? null, batchId ?? null, userId ?? null, userId ?? null) as Array<{ + expires_at: number; grant_id: string; granted_at: number; granted_by: string; + status: PreviewGrantStatus; test_batch_id: string; user_id: string; + }>; + return rows.map((row) => ({ + batchId: row.test_batch_id, + expiresAt: row.expires_at, + grantId: row.grant_id, + grantedAt: row.granted_at, + grantedBy: row.granted_by, + status: row.status, + userId: row.user_id, + })); + }); + } + + projectManifest(input: { releaseVersion: string; userId: string }): AssetReleaseManifestProjection | undefined { + const releaseVersion = assertText(input.releaseVersion, "releaseVersion"); + const userId = assertText(input.userId, "userId"); + const base = this.options.assetReleases.project("internal_preview_asset", releaseVersion); + if (!base) return undefined; + const authorized = base.items.filter((item) => this.authorizeAsset({ releaseVersion, resourceId: item.resource_id, userId })); + if (authorized.length === 0) return undefined; + const items = authorized.map((item) => { + const manifestItemId = randomUUID(); + const mapped: AssetReleaseManifestItem = { + ...item, + resource_id: manifestItemId, + url: `/api/v1/assets/preview/${releaseVersion}/${manifestItemId}`, + }; + this.itemMappings.set(manifestItemId, { + releaseVersion, + resourceId: item.resource_id, + userId, + }); + return mapped; + }); + return Object.freeze({ + items: Object.freeze(items.map((item) => Object.freeze(item))), + manifest_sha256: manifestHash(items, releaseVersion), + release_version: releaseVersion, + schema_version: "AssetReleaseManifest/v1" as const, + }); + } + + authorizeAsset(input: { releaseVersion: string; resourceId: string; userId: string }) { + const releaseVersion = assertText(input.releaseVersion, "releaseVersion"); + const resourceId = assertText(input.resourceId, "resourceId"); + const userId = assertText(input.userId, "userId"); + const now = this.options.clock(); + return this.immediate(() => { + this.expireDue(now); + const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?").get(userId) as { role: string; status: string } | undefined; + if (!user || user.role !== "user" || user.status !== "active") return false; + const row = this.database.prepare(` + SELECT 1 AS authorized + FROM asset_preview_grants g + JOIN test_batch_items i ON i.test_batch_id = g.test_batch_id + WHERE g.user_id = ? AND g.status = 'active' AND g.expires_at > ? + AND i.release_version = ? AND i.resource_id = ? + LIMIT 1 + `).get(userId, now, releaseVersion, resourceId) as { authorized: 1 } | undefined; + return Boolean(row); + }); + } + + readManifestItem(input: { manifestItemId: string; releaseVersion: string; userId: string }) { + const manifestItemId = assertText(input.manifestItemId, "manifestItemId"); + const releaseVersion = assertText(input.releaseVersion, "releaseVersion"); + const userId = assertText(input.userId, "userId"); + const mapping = this.itemMappings.get(manifestItemId); + if (!mapping || mapping.releaseVersion !== releaseVersion || mapping.userId !== userId) return undefined; + if (!this.authorizeAsset({ releaseVersion, resourceId: mapping.resourceId, userId })) { + this.itemMappings.delete(manifestItemId); + return undefined; + } + const resource = this.options.assetReleases.read("internal_preview_asset", releaseVersion, mapping.resourceId); + return resource ? { ...resource, resourceId: manifestItemId } : undefined; + } + + private migrate() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS test_batches ( + batch_id TEXT PRIMARY KEY, + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160), + status TEXT NOT NULL CHECK (status IN ('active', 'closed')), + created_by TEXT NOT NULL REFERENCES users(user_id), + created_at INTEGER NOT NULL, + closed_at INTEGER + ); + CREATE TABLE IF NOT EXISTS test_batch_items ( + test_batch_id TEXT NOT NULL REFERENCES test_batches(batch_id), + release_version TEXT NOT NULL, + resource_id TEXT NOT NULL, + PRIMARY KEY (test_batch_id, release_version, resource_id) + ); + CREATE TABLE IF NOT EXISTS asset_preview_grants ( + grant_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id), + test_batch_id TEXT NOT NULL REFERENCES test_batches(batch_id), + granted_by TEXT NOT NULL REFERENCES users(user_id), + granted_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL CHECK (expires_at > granted_at), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked', 'expired')) + ); + CREATE INDEX IF NOT EXISTS asset_preview_grants_user_status + ON asset_preview_grants(user_id, status, expires_at); + `); + } + + private immediate(action: () => T): T { + this.database.exec("BEGIN IMMEDIATE"); + try { + const value = action(); + this.database.exec("COMMIT"); + return value; + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + throw error; + } + } + + private assertAdmin(adminUserId: string, now: number) { + const admin = this.database.prepare(` + SELECT 1 AS allowed FROM users u JOIN admin_access a ON a.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(adminUserId) as { allowed: 1 } | undefined; + if (!admin) throw new PreviewGrantError("admin_invalid"); + void now; + } + + private readBatch(batchId: string): PreviewBatchView | undefined { + const row = this.database.prepare(` + SELECT batch_id, name, status, created_by, created_at + FROM test_batches WHERE batch_id = ? + `).get(batchId) as { batch_id: string; created_at: number; created_by: string; name: string; status: PreviewBatchStatus } | undefined; + return row ? { + batchId: row.batch_id, + createdAt: row.created_at, + createdBy: row.created_by, + name: row.name, + status: row.status, + } : undefined; + } + + private readGrant(grantId: string): PreviewGrantView | undefined { + const row = this.database.prepare(` + SELECT grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status + FROM asset_preview_grants WHERE grant_id = ? + `).get(grantId) as { + expires_at: number; grant_id: string; granted_at: number; granted_by: string; + status: PreviewGrantStatus; test_batch_id: string; user_id: string; + } | undefined; + return row ? { + batchId: row.test_batch_id, + expiresAt: row.expires_at, + grantId: row.grant_id, + grantedAt: row.granted_at, + grantedBy: row.granted_by, + status: row.status, + userId: row.user_id, + } : undefined; + } + + private expireDue(now: number) { + const rows = this.database.prepare(` + SELECT grant_id, user_id, test_batch_id FROM asset_preview_grants + WHERE status = 'active' AND expires_at <= ? + `).all(now) as Array<{ grant_id: string; test_batch_id: string; user_id: string }>; + if (rows.length === 0) return; + this.database.prepare("UPDATE asset_preview_grants SET status = 'expired' WHERE status = 'active' AND expires_at <= ?").run(now); + for (const row of rows) { + this.audit({ + actorRef: "preview_grant_expiry", + afterSummary: { grant_id: row.grant_id, status: "expired" }, + beforeSummary: { grant_id: row.grant_id, status: "active" }, + operationType: "preview_grant_expire", + targetRef: row.grant_id, + targetType: "preview_grant", + }, now, "system"); + } + } + + private audit(input: { + actorRef: string; + afterSummary: Record | null; + beforeSummary: Record | null; + operationType: string; + targetRef: string; + targetType: string; + }, now: number, actorType: "super_admin" | "system" = "super_admin") { + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?) + `).run( + randomUUID(), actorType, input.actorRef, input.operationType, input.targetType, input.targetRef, + serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), + now, now + auditRetentionMilliseconds, + ); + } +} 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/api/src/project-errors.ts b/apps/api/src/project-errors.ts new file mode 100644 index 0000000..1149ef8 --- /dev/null +++ b/apps/api/src/project-errors.ts @@ -0,0 +1,22 @@ +export type ProjectErrorCode = + | "generation_state_invalid" + | "project_active_limit" + | "project_history_limit" + | "project_name_invalid" + | "project_not_found" + | "project_ratio_fixed" + | "project_retry_not_allowed" + | "project_state_conflict" + | "project_state_invalid" + | "project_state_idempotency_conflict"; + +export class ProjectError extends Error { + readonly code: ProjectErrorCode; + readonly latestStateVersion: number | undefined; + + constructor(code: ProjectErrorCode, latestStateVersion?: number) { + super(code); + this.code = code; + this.latestStateVersion = latestStateVersion; + } +} diff --git a/apps/api/src/projects.ts b/apps/api/src/projects.ts new file mode 100644 index 0000000..6d9a733 --- /dev/null +++ b/apps/api/src/projects.ts @@ -0,0 +1,912 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; + +import type BetterSqlite3 from "better-sqlite3"; +import { isProjectEditableState, type CanvasState, type ProjectEditableState } from "@dada/shared-contracts"; + +import { ProjectError } from "./project-errors.js"; +export { ProjectError } from "./project-errors.js"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; + +export const projectRatios = ["3:4", "1:1", "4:3", "9:16"] as const; +export type ProjectRatio = typeof projectRatios[number]; +export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected"; +export type ProjectViewStatus = "active" | "failed_empty" | "trashed"; +export type ProjectManagedResourceKind = "derived" | "export" | "generated" | "reference"; +export const ratioPixels: Record = { + "3:4": { height: 1440, width: 1080 }, + "1:1": { height: 1080, width: 1080 }, + "4:3": { height: 1080, width: 1440 }, + "9:16": { height: 1920, width: 1080 }, +}; +export const projectLimit = 20; +export const historyLimit = 10; +const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000; +const generationErrorCategories = new Set([ + "upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled", + "gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid", + "unknown_retryable", "unknown_non_retryable", +]); + +interface ProjectRow { + created_at: number; + current_image_id: string | null; + deleted_at: number | null; + draft_prompt: string; + name: string; + owner_id: string; + pixel_height: number; + pixel_width: number; + project_id: string; + purge_at: number | null; + ratio: ProjectRatio; + state_version: number; + status: "active" | "trashed" | "purged"; + updated_at: number; +} + +interface GenerationRow { + created_at: number; + error_category: string | null; + generation_id: string; + prompt: string; + project_id: string; + ratio: ProjectRatio; + status: GenerationStatus; + updated_at: number; +} + +interface ImageRow { + created_at: number; + generation_id: string; + image_id: string; +} + +interface ProjectStateRow { + canvas_json: string; + created_at: number; + name: string; + project_id: string; + state_version: number; +} + +interface LatestExportRow { + byte_size: number; + created_at: number; + export_id: string; + format: "jpg" | "png"; + pixel_height: number; + pixel_width: number; + sha256: string; + state_version: number; +} + +function isProjectRatio(value: string): value is ProjectRatio { + return projectRatios.includes(value as ProjectRatio); +} + +export function normalizePrompt(value: string) { + const normalized = value.trim().replace(/\s+/gu, " "); + if (!normalized || normalized.length > 4_000) throw new ProjectError("generation_state_invalid"); + return normalized; +} + +function takeGraphemes(value: string, count: number) { + const Segmenter = Intl.Segmenter; + if (Segmenter) { + return [...new Segmenter("zh-CN", { granularity: "grapheme" }).segment(value)] + .slice(0, count) + .map((entry) => entry.segment) + .join(""); + } + return Array.from(value).slice(0, count).join(""); +} + +function localDate(timestamp: number) { + const date = new Date(timestamp); + return [date.getFullYear(), date.getMonth() + 1, date.getDate()] + .map((part, index) => index === 0 ? String(part) : String(part).padStart(2, "0")) + .join("-"); +} + +export function defaultProjectName(prompt: string, timestamp: number) { + const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作"; + return `${summary} ${localDate(timestamp)}`; +} + +function normalizeProjectName(value: string) { + const normalized = value.trim().replace(/\s+/gu, " "); + if (!normalized || [...normalized].length > 80) throw new ProjectError("project_name_invalid"); + return normalized; +} + +function iso(timestamp: number) { + return new Date(timestamp).toISOString(); +} + +export function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState { + return { + background: { + adjustments: { + brightness: 0, + contrast: 0, + crop: null, + filter: "none", + fit: "fill", + saturation: 0, + sharpness: 0, + temperature: 0, + }, + asset_id: assetId, + }, + elements: [], + pixel_height: pixels.height, + pixel_width: pixels.width, + ratio, + schema_version: 1, + }; +} + +export function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +export class ProjectService { + readonly database: BetterSqlite3.Database; + private readonly clock: () => number; + + constructor(input: { clock?: () => number; databasePath: string }) { + this.clock = input.clock ?? Date.now; + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined); + this.database.pragma("journal_mode = WAL"); + this.database.pragma("foreign_keys = ON"); + this.database.pragma("synchronous = FULL"); + this.database.pragma("busy_timeout = 5000"); + this.migrate(); + } + + close() { + this.database.close(); + } + + createProjectForGeneration(input: { + ownerId: string; + prompt: string; + ratio: ProjectRatio; + status: Exclude; + }) { + if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid"); + const prompt = normalizePrompt(input.prompt); + const projectId = randomUUID(); + const generationId = randomUUID(); + const now = this.clock(); + const pixels = ratioPixels[input.ratio]; + const transaction = this.database.transaction(() => { + const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") + .get(input.ownerId) as { count: number }; + if (active.count >= projectLimit) throw new ProjectError("project_active_limit"); + this.database.prepare(` + INSERT INTO projects ( + project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height, + status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL) + `).run( + projectId, input.ownerId, defaultProjectName(prompt, now), prompt, input.ratio, + pixels.width, pixels.height, now, now, + ); + this.insertProjectState({ + canvasState: defaultCanvasState(input.ratio, pixels, null), + name: defaultProjectName(prompt, now), + projectId, + stateVersion: 1, + }, now); + this.insertGeneration({ generationId, ownerId: input.ownerId, projectId, prompt, ratio: input.ratio, status: input.status }, now); + }); + transaction.immediate(); + return { generation: this.readGeneration(generationId), project: this.getProject(input.ownerId, projectId) }; + } + + continueProjectGeneration(input: { + ownerId: string; + projectId: string; + prompt: string; + ratio: ProjectRatio; + status: Exclude; + }) { + if (!isProjectRatio(input.ratio)) throw new ProjectError("generation_state_invalid"); + const generationId = randomUUID(); + const prompt = normalizePrompt(input.prompt); + const now = this.clock(); + const transaction = this.database.transaction(() => { + const project = this.readOwnedProject(input.ownerId, input.projectId); + if (project.status !== "active") throw new ProjectError("project_not_found"); + if (project.ratio !== input.ratio) throw new ProjectError("project_ratio_fixed"); + if (this.successfulImageCount(input.projectId) >= historyLimit) throw new ProjectError("project_history_limit"); + this.insertGeneration({ generationId, ownerId: input.ownerId, projectId: input.projectId, prompt, ratio: project.ratio, status: input.status }, now); + this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?") + .run(prompt, now, input.projectId); + }); + transaction.immediate(); + return this.readGeneration(generationId); + } + + retryFailedDraft(input: { ownerId: string; projectId: string; prompt: string }) { + const project = this.getProject(input.ownerId, input.projectId); + const latest = project.generations.at(-1); + if (project.status !== "failed_empty" || !latest || !["failed", "rejected"].includes(latest.status)) { + throw new ProjectError("project_retry_not_allowed"); + } + return this.continueProjectGeneration({ + ownerId: input.ownerId, + projectId: input.projectId, + prompt: input.prompt, + ratio: project.ratio, + status: "queued", + }); + } + + recordSuccessfulImage(input: { generationId: string; imageId: string }) { + const now = this.clock(); + const transaction = this.database.transaction(() => { + const generation = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?") + .get(input.generationId) as GenerationRow | undefined; + if (!generation || !["queued", "running"].includes(generation.status)) throw new ProjectError("generation_state_invalid"); + if (this.successfulImageCount(generation.project_id) >= historyLimit) throw new ProjectError("project_history_limit"); + const project = this.database.prepare("SELECT * FROM projects WHERE project_id = ?").get(generation.project_id) as ProjectRow; + const currentState = this.readProjectState(generation.project_id); + const nextCanvas = structuredClone(currentState.canvasState); + if (project.current_image_id === null) nextCanvas.background.asset_id = input.imageId; + this.database.prepare(` + UPDATE generation_jobs SET status = 'succeeded', error_category = NULL, updated_at = ? WHERE generation_id = ? + `).run(now, input.generationId); + this.database.prepare(` + INSERT INTO project_images (image_id, project_id, generation_id, created_at) VALUES (?, ?, ?, ?) + `).run(input.imageId, generation.project_id, input.generationId, now); + this.database.prepare(` + UPDATE projects + SET current_image_id = COALESCE(current_image_id, ?), updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? + `).run(input.imageId, now, generation.project_id); + this.insertProjectState({ + canvasState: nextCanvas, + name: project.name, + projectId: generation.project_id, + stateVersion: project.state_version + 1, + }, now); + }); + transaction.immediate(); + } + + markGenerationFailed(generationId: string, errorCategory: string) { + if (!generationErrorCategories.has(errorCategory)) throw new ProjectError("generation_state_invalid"); + const status = errorCategory === "safety_rejected" ? "rejected" : "failed"; + const changed = this.database.prepare(` + UPDATE generation_jobs SET status = ?, error_category = ?, updated_at = ? + WHERE generation_id = ? AND status IN ('queued', 'running') + `).run(status, errorCategory, this.clock(), generationId); + if (changed.changes !== 1) throw new ProjectError("generation_state_invalid"); + } + + renameProject(ownerId: string, projectId: string, name: string) { + const normalized = normalizeProjectName(name); + const now = this.clock(); + const transaction = this.database.transaction(() => { + const project = this.readOwnedProject(ownerId, projectId); + if (project.status !== "active") throw new ProjectError("project_not_found"); + const currentState = this.readProjectState(projectId); + this.database.prepare(` + UPDATE projects SET name = ?, updated_at = ?, state_version = state_version + 1 + WHERE owner_id = ? AND project_id = ? AND status = 'active' + `).run(normalized, now, ownerId, projectId); + this.insertProjectState({ + canvasState: currentState.canvasState, + name: normalized, + projectId, + stateVersion: project.state_version + 1, + }, now); + }); + transaction.immediate(); + return { name: normalized, stateVersion: this.readOwnedProject(ownerId, projectId).state_version }; + } + + saveProjectState(input: { + expectedStateVersion: number; + idempotencyKey: string; + ownerId: string; + projectId: string; + state: ProjectEditableState; + }) { + if (!Number.isSafeInteger(input.expectedStateVersion) || input.expectedStateVersion < 1 || !isProjectEditableState(input.state)) { + throw new ProjectError("project_state_invalid"); + } + const name = normalizeProjectName(input.state.name); + const state = { canvas_state: structuredClone(input.state.canvas_state), name } satisfies ProjectEditableState; + const requestHash = createHash("sha256").update(stableJson({ expected: input.expectedStateVersion, state })).digest("hex"); + const now = this.clock(); + let result!: { stateVersion: number }; + const transaction = this.database.transaction(() => { + const replay = this.database.prepare(` + SELECT request_hash, response_state_version FROM project_state_idempotency + WHERE owner_id = ? AND project_id = ? AND idempotency_key = ? + `).get(input.ownerId, input.projectId, input.idempotencyKey) as { request_hash: string; response_state_version: number } | undefined; + if (replay) { + if (replay.request_hash !== requestHash) throw new ProjectError("project_state_idempotency_conflict"); + result = { stateVersion: replay.response_state_version }; + return; + } + const project = this.readOwnedProject(input.ownerId, input.projectId); + if (project.status !== "active") throw new ProjectError("project_not_found"); + if (project.state_version !== input.expectedStateVersion) { + throw new ProjectError("project_state_conflict", project.state_version); + } + const canvas = state.canvas_state; + if (canvas.ratio !== project.ratio || canvas.pixel_width !== project.pixel_width || canvas.pixel_height !== project.pixel_height) { + throw new ProjectError("project_state_invalid"); + } + if (canvas.background.asset_id) { + const owned = this.database.prepare("SELECT 1 FROM project_images WHERE project_id = ? AND image_id = ?") + .get(input.projectId, canvas.background.asset_id); + if (!owned) throw new ProjectError("project_state_invalid"); + } + const nextVersion = project.state_version + 1; + const changed = this.database.prepare(` + UPDATE projects SET name = ?, state_version = ?, updated_at = ? + WHERE project_id = ? AND owner_id = ? AND state_version = ? AND status = 'active' + `).run(name, nextVersion, now, input.projectId, input.ownerId, input.expectedStateVersion); + if (changed.changes !== 1) { + const latest = this.readOwnedProject(input.ownerId, input.projectId).state_version; + throw new ProjectError("project_state_conflict", latest); + } + this.insertProjectState({ canvasState: canvas, name, projectId: input.projectId, stateVersion: nextVersion }, now); + this.rebuildProjectStickerReferences(input.projectId, canvas, now); + this.database.prepare(` + INSERT INTO project_state_idempotency ( + owner_id, project_id, idempotency_key, request_hash, response_state_version, created_at + ) VALUES (?, ?, ?, ?, ?, ?) + `).run(input.ownerId, input.projectId, input.idempotencyKey, requestHash, nextVersion, now); + result = { stateVersion: nextVersion }; + }); + transaction.immediate(); + return result; + } + + private rebuildProjectStickerReferences(projectId: string, canvas: CanvasState, now: number) { + if (!this.tableExists("project_sticker_asset_refs")) return; + this.database.prepare("DELETE FROM project_sticker_asset_refs WHERE project_id = ?").run(projectId); + const insert = this.database.prepare(` + INSERT INTO project_sticker_asset_refs (reference_id, project_id, stable_id, resource_version, created_at) + VALUES (?, ?, ?, ?, ?) + `); + for (const element of canvas.elements) { + if (element.type !== "static_sticker") continue; + insert.run( + `project:${projectId}:sticker:${element.element_id}`, + projectId, + element.template_or_asset_id, + element.resource_version, + now, + ); + } + } + + trashFailedEmpty(ownerId: string, projectIds: string[]) { + const uniqueIds = [...new Set(projectIds)]; + if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid"); + const trashedProjectIds: string[] = []; + const ignoredProjectIds: string[] = []; + const now = this.clock(); + const transaction = this.database.transaction(() => { + for (const projectId of uniqueIds) { + const row = this.database.prepare(` + SELECT p.project_id, + (SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count, + (SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status + FROM projects p WHERE p.owner_id = ? AND p.project_id = ? AND p.status = 'active' + `).get(ownerId, projectId) as { image_count: number; latest_status: string | null; project_id: string } | undefined; + if (!row || row.image_count !== 0 || !row.latest_status || !["failed", "rejected"].includes(row.latest_status)) { + ignoredProjectIds.push(projectId); + continue; + } + this.database.prepare(` + UPDATE projects SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? + `).run(now, now + trashRetentionMilliseconds, now, projectId); + trashedProjectIds.push(projectId); + } + }); + transaction.immediate(); + return { ignoredProjectIds, trashedProjectIds }; + } + + trashProject(ownerId: string, projectId: string) { + const now = this.clock(); + let purged = false; + let result!: ReturnType; + const transaction = this.database.transaction(() => { + const project = this.readOwnedProject(ownerId, projectId); + if (project.status === "purged") throw new ProjectError("project_not_found"); + if (project.status === "trashed") { + if (project.purge_at !== null && project.purge_at <= now) { + this.transitionToPurged(project, now); + purged = true; + return; + } + result = this.projectSummary(project, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId)); + return; + } + this.database.prepare(` + UPDATE projects + SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? AND owner_id = ? AND status = 'active' + `).run(now, now + trashRetentionMilliseconds, now, projectId, ownerId); + const updated = this.readOwnedProject(ownerId, projectId); + result = this.projectSummary(updated, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId)); + }); + transaction.immediate(); + if (purged) throw new ProjectError("project_not_found"); + return result; + } + + restoreProject(ownerId: string, projectId: string) { + const now = this.clock(); + let purged = false; + const transaction = this.database.transaction(() => { + const project = this.readOwnedProject(ownerId, projectId); + if (project.status === "purged") throw new ProjectError("project_not_found"); + if (project.status === "active") return; + if (project.purge_at !== null && project.purge_at <= now) { + this.transitionToPurged(project, now); + purged = true; + return; + } + const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") + .get(ownerId) as { count: number }; + if (active.count >= projectLimit) throw new ProjectError("project_active_limit"); + this.database.prepare(` + UPDATE projects + SET status = 'active', deleted_at = NULL, purge_at = NULL, updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? AND owner_id = ? AND status = 'trashed' + `).run(now, projectId, ownerId); + }); + transaction.immediate(); + if (purged) throw new ProjectError("project_not_found"); + return this.getProject(ownerId, projectId); + } + + purgeProject(ownerId: string, projectId: string) { + const now = this.clock(); + const transaction = this.database.transaction(() => { + const project = this.readOwnedProject(ownerId, projectId); + if (project.status !== "trashed") throw new ProjectError("project_not_found"); + this.transitionToPurged(project, now); + }); + transaction.immediate(); + return { projectId, status: "purged" as const }; + } + + linkManagedResource(ownerId: string, projectId: string, managedFileId: string, resourceKind: ProjectManagedResourceKind) { + const project = this.readOwnedProject(ownerId, projectId); + if (project.status === "purged" || !["derived", "export", "generated", "reference"].includes(resourceKind)) { + throw new ProjectError("project_not_found"); + } + if (!this.tableExists("managed_files") || !this.tableExists("project_asset_refs")) { + throw new ProjectError("project_state_invalid"); + } + const managed = this.database.prepare(` + SELECT file_id FROM managed_files WHERE file_id = ? AND owner_ref = ? AND status = 'committed' + `).get(managedFileId, ownerId); + if (!managed) throw new ProjectError("project_state_invalid"); + const now = this.clock(); + const transaction = this.database.transaction(() => { + this.database.prepare(` + INSERT OR IGNORE INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) + VALUES (?, ?, ?, ?) + `).run(projectId, managedFileId, resourceKind, now); + this.database.prepare(` + INSERT OR IGNORE INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) + VALUES (?, ?, 'project', ?) + `).run(`project:${projectId}:${managedFileId}`, managedFileId, iso(now)); + }); + transaction.immediate(); + } + + listProjects(ownerId: string, status: "active" | "trashed") { + this.purgeExpiredProjects(ownerId); + const rows = this.database.prepare(` + SELECT p.*, + (SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count, + (SELECT status FROM generation_jobs g WHERE g.project_id = p.project_id ORDER BY g.created_at DESC, g.rowid DESC LIMIT 1) AS latest_status + FROM projects p + WHERE p.owner_id = ? AND p.status = ? + ORDER BY p.updated_at DESC, p.project_id DESC + `).all(ownerId, status) as Array; + return rows.map((row) => this.projectSummary(row, row.image_count, row.latest_status)); + } + + getProject(ownerId: string, projectId: string) { + this.purgeExpiredProjects(ownerId, projectId); + const row = this.readOwnedProject(ownerId, projectId); + if (row.status === "purged") throw new ProjectError("project_not_found"); + const generations = this.database.prepare(` + SELECT * FROM generation_jobs WHERE project_id = ? ORDER BY created_at, rowid + `).all(projectId) as GenerationRow[]; + const images = this.database.prepare(` + SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid + `).all(projectId) as ImageRow[]; + const latestExports = this.database.prepare(` + SELECT export_id, format, sha256, byte_size, pixel_width, pixel_height, state_version, created_at + FROM latest_exports WHERE project_id = ? ORDER BY format + `).all(projectId) as LatestExportRow[]; + const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null); + const projectState = this.readProjectState(projectId); + return { + ...summary, + canvasState: projectState.canvasState, + createdAt: iso(row.created_at), + draftPrompt: row.draft_prompt, + generations: generations.map((generation) => this.generationView(generation)), + images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })), + latestExports: latestExports.map((item) => ({ + byteSize: item.byte_size, + createdAt: iso(item.created_at), + downloadUrl: `/api/v1/projects/${projectId}/latest-exports/${item.format}`, + exportId: item.export_id, + format: item.format, + pixelHeight: item.pixel_height, + pixelWidth: item.pixel_width, + sha256: item.sha256, + stateVersion: item.state_version, + })), + pixelHeight: row.pixel_height, + pixelWidth: row.pixel_width, + saveStatus: "saved" as const, + }; + } + + activeProjectCount(ownerId: string) { + const row = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") + .get(ownerId) as { count: number }; + return row.count; + } + + private insertGeneration(input: { + generationId: string; + ownerId: string; + projectId: string; + prompt: string; + ratio: ProjectRatio; + status: Exclude; + }, now: number) { + const errorCategory = input.status === "failed" ? "upstream_failed" : input.status === "rejected" ? "safety_rejected" : null; + this.database.prepare(` + INSERT INTO generation_jobs ( + generation_id, owner_id, project_id, prompt, ratio, status, error_category, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(input.generationId, input.ownerId, input.projectId, input.prompt, input.ratio, input.status, errorCategory, now, now); + } + + private readGeneration(generationId: string) { + const row = this.database.prepare("SELECT * FROM generation_jobs WHERE generation_id = ?").get(generationId) as GenerationRow | undefined; + if (!row) throw new ProjectError("generation_state_invalid"); + return this.generationView(row); + } + + private generationView(row: GenerationRow) { + return { + createdAt: iso(row.created_at), + errorCategory: row.error_category, + generationId: row.generation_id, + projectId: row.project_id, + prompt: row.prompt, + ratio: row.ratio, + status: row.status, + updatedAt: iso(row.updated_at), + }; + } + + private readOwnedProject(ownerId: string, projectId: string) { + const row = this.database.prepare("SELECT * FROM projects WHERE owner_id = ? AND project_id = ?") + .get(ownerId, projectId) as ProjectRow | undefined; + if (!row) throw new ProjectError("project_not_found"); + return row; + } + + private insertProjectState(input: { canvasState: CanvasState; name: string; projectId: string; stateVersion: number }, now: number) { + this.database.prepare(` + INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(input.projectId, input.stateVersion, input.name, stableJson(input.canvasState), now); + } + + private readProjectState(projectId: string) { + const row = this.database.prepare(` + SELECT project_id, state_version, name, canvas_json, created_at + FROM project_states WHERE project_id = ? ORDER BY state_version DESC LIMIT 1 + `).get(projectId) as ProjectStateRow | undefined; + if (!row) throw new ProjectError("project_state_invalid"); + const canvasState: unknown = JSON.parse(row.canvas_json); + if (!isProjectEditableState({ canvas_state: canvasState, name: row.name })) throw new ProjectError("project_state_invalid"); + return { canvasState, name: row.name, stateVersion: row.state_version } as { + canvasState: CanvasState; name: string; stateVersion: number; + }; + } + + private successfulImageCount(projectId: string) { + const row = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?") + .get(projectId) as { count: number }; + return row.count; + } + + private latestGenerationStatus(projectId: string) { + const row = this.database.prepare(` + SELECT status FROM generation_jobs WHERE project_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1 + `).get(projectId) as { status: string } | undefined; + return row?.status ?? null; + } + + private tableExists(name: string) { + return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + } + + private purgeExpiredProjects(ownerId: string, projectId?: string) { + const now = this.clock(); + const transaction = this.database.transaction(() => { + const rows = this.database.prepare(` + SELECT * FROM projects + WHERE owner_id = ? AND status = 'trashed' AND purge_at <= ? AND (? IS NULL OR project_id = ?) + ORDER BY purge_at, project_id + `).all(ownerId, now, projectId ?? null, projectId ?? null) as ProjectRow[]; + for (const row of rows) this.transitionToPurged(row, now); + }); + transaction.immediate(); + } + + private transitionToPurged(project: ProjectRow, now: number) { + const changed = this.database.prepare(` + UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1 + WHERE project_id = ? AND status = 'trashed' + `).run(now, project.project_id); + if (changed.changes !== 1) return; + this.queueManagedProjectFiles(project.project_id, now); + this.database.prepare(` + INSERT OR IGNORE INTO project_cleanup_queue ( + cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL) + `).run( + randomUUID(), project.project_id, project.owner_id, + stableJson(["project_state", "generation", "generated_image", "reference", "location", "latest_export"]), now, + ); + } + + private queueManagedProjectFiles(projectId: string, now: number) { + if (!["managed_files", "file_cleanup_queue", "project_asset_refs"].every((table) => this.tableExists(table))) return; + const files = this.database.prepare(` + SELECT mf.file_id, mf.relative_path, mf.byte_size + FROM project_resource_files prf + JOIN managed_files mf ON mf.file_id = prf.managed_file_id + WHERE prf.project_id = ? AND mf.status = 'committed' + ORDER BY mf.file_id + `).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>; + for (const file of files) { + this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?") + .run(`project:${projectId}:${file.file_id}`); + const otherProject = this.database.prepare(` + SELECT 1 FROM project_resource_files prf + JOIN projects p ON p.project_id = prf.project_id + WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged' + LIMIT 1 + `).get(file.file_id, projectId); + const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id); + if (otherProject || otherReference) continue; + this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'") + .run(iso(now), file.file_id); + this.database.prepare(` + INSERT OR IGNORE INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL) + `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now)); + } + } + + private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) { + const status: ProjectViewStatus = row.status === "trashed" + ? "trashed" + : imageCount === 0 && latestStatus && ["failed", "rejected"].includes(latestStatus) + ? "failed_empty" + : "active"; + return { + currentImageId: row.current_image_id, + deletedAt: row.deleted_at === null ? null : iso(row.deleted_at), + name: row.name, + projectId: row.project_id, + purgeAt: row.purge_at === null ? null : iso(row.purge_at), + ratio: row.ratio, + stateVersion: row.state_version, + status, + successfulImageCount: imageCount, + updatedAt: iso(row.updated_at), + }; + } + + private migrate() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS projects ( + project_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160), + draft_prompt TEXT NOT NULL CHECK (length(draft_prompt) BETWEEN 1 AND 4000), + ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')), + pixel_width INTEGER NOT NULL, + pixel_height INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'trashed', 'purged')), + state_version INTEGER NOT NULL CHECK (state_version >= 1), + current_image_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + purge_at INTEGER, + UNIQUE(project_id, ratio), + CHECK ( + (ratio = '3:4' AND pixel_width = 1080 AND pixel_height = 1440) OR + (ratio = '1:1' AND pixel_width = 1080 AND pixel_height = 1080) OR + (ratio = '4:3' AND pixel_width = 1440 AND pixel_height = 1080) OR + (ratio = '9:16' AND pixel_width = 1080 AND pixel_height = 1920) + ), + CHECK ( + (status = 'active' AND deleted_at IS NULL AND purge_at IS NULL) OR + (status = 'trashed' AND deleted_at IS NOT NULL AND purge_at = deleted_at + ${trashRetentionMilliseconds}) OR + (status = 'purged' AND deleted_at IS NOT NULL AND purge_at IS NOT NULL) + ) + ); + CREATE INDEX IF NOT EXISTS projects_owner_status_updated ON projects(owner_id, status, updated_at DESC); + DROP TRIGGER IF EXISTS projects_active_insert_limit; + CREATE TRIGGER projects_active_insert_limit + BEFORE INSERT ON projects + WHEN NEW.status = 'active' AND ( + SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active' + ) >= ${projectLimit} + BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END; + CREATE TABLE IF NOT EXISTS project_states ( + project_id TEXT NOT NULL, + state_version INTEGER NOT NULL CHECK (state_version >= 1), + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 80), + canvas_json TEXT NOT NULL CHECK (json_valid(canvas_json)), + created_at INTEGER NOT NULL, + PRIMARY KEY (project_id, state_version), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS project_states_latest ON project_states(project_id, state_version DESC); + CREATE TABLE IF NOT EXISTS project_resource_files ( + project_id TEXT NOT NULL, + managed_file_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('derived', 'export', 'generated', 'reference')), + created_at INTEGER NOT NULL, + PRIMARY KEY (project_id, managed_file_id), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS project_resource_files_managed ON project_resource_files(managed_file_id, project_id); + CREATE TABLE IF NOT EXISTS project_sticker_asset_refs ( + reference_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + stable_id TEXT NOT NULL, + resource_version TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS project_sticker_asset_refs_lookup + ON project_sticker_asset_refs (stable_id, resource_version); + CREATE TABLE IF NOT EXISTS latest_exports ( + project_id TEXT NOT NULL, + format TEXT NOT NULL CHECK (format IN ('jpg', 'png')), + export_id TEXT NOT NULL UNIQUE, + managed_file_id TEXT NOT NULL, + state_version INTEGER NOT NULL CHECK (state_version >= 1), + sha256 TEXT NOT NULL CHECK (length(sha256) = 64), + byte_size INTEGER NOT NULL CHECK (byte_size > 0), + pixel_width INTEGER NOT NULL CHECK (pixel_width > 0), + pixel_height INTEGER NOT NULL CHECK (pixel_height > 0), + created_at INTEGER NOT NULL, + PRIMARY KEY (project_id, format), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS project_cleanup_queue ( + cleanup_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL UNIQUE, + owner_id TEXT NOT NULL, + resource_scope_json TEXT NOT NULL CHECK (json_valid(resource_scope_json)), + status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')), + created_at INTEGER NOT NULL, + completed_at INTEGER, + last_error TEXT + ); + CREATE INDEX IF NOT EXISTS project_cleanup_queue_status_created ON project_cleanup_queue(status, created_at, project_id); + CREATE TABLE IF NOT EXISTS project_state_idempotency ( + owner_id TEXT NOT NULL, + project_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + response_state_version INTEGER NOT NULL CHECK (response_state_version >= 2), + created_at INTEGER NOT NULL, + PRIMARY KEY (owner_id, project_id, idempotency_key), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE + ); + DROP TRIGGER IF EXISTS projects_active_restore_limit; + CREATE TRIGGER projects_active_restore_limit + BEFORE UPDATE OF status ON projects + WHEN OLD.status <> 'active' AND NEW.status = 'active' AND ( + SELECT COUNT(*) FROM projects WHERE owner_id = NEW.owner_id AND status = 'active' + ) >= ${projectLimit} + BEGIN SELECT RAISE(ABORT, 'project_active_limit'); END; + CREATE TABLE IF NOT EXISTS generation_jobs ( + generation_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + project_id TEXT NOT NULL, + prompt TEXT NOT NULL CHECK (length(prompt) BETWEEN 1 AND 4000), + ratio TEXT NOT NULL CHECK (ratio IN ('3:4', '1:1', '4:3', '9:16')), + status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'rejected')), + model_id TEXT, + model_config_version INTEGER, + confirmed_credit_cost INTEGER CHECK (confirmed_credit_cost IS NULL OR confirmed_credit_cost > 0), + reserved_credits INTEGER NOT NULL DEFAULT 0 CHECK (reserved_credits >= 0), + final_credit_state TEXT CHECK (final_credit_state IS NULL OR final_credit_state IN ('committed', 'released')), + finished_at INTEGER, + error_category TEXT CHECK (error_category IS NULL OR error_category IN ( + 'upstream_timeout', 'upstream_failed', 'safety_rejected', 'model_disabled', + 'gateway_balance_insufficient', 'gateway_contract_invalid', 'reference_invalid', + 'unknown_retryable', 'unknown_non_retryable' + )), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(generation_id, project_id), + FOREIGN KEY (project_id, ratio) REFERENCES projects(project_id, ratio) ON DELETE CASCADE, + CHECK ( + (status IN ('queued', 'running', 'succeeded') AND error_category IS NULL) OR + (status IN ('failed', 'rejected') AND error_category IS NOT NULL) + ) + ); + CREATE INDEX IF NOT EXISTS generation_jobs_project_created ON generation_jobs(project_id, created_at, generation_id); + CREATE TABLE IF NOT EXISTS project_images ( + image_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + generation_id TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + FOREIGN KEY (generation_id, project_id) REFERENCES generation_jobs(generation_id, project_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS project_images_project_created ON project_images(project_id, created_at, image_id); + DROP TRIGGER IF EXISTS projects_ratio_immutable; + CREATE TRIGGER projects_ratio_immutable + BEFORE UPDATE OF ratio, pixel_width, pixel_height ON projects + WHEN NEW.ratio <> OLD.ratio OR NEW.pixel_width <> OLD.pixel_width OR NEW.pixel_height <> OLD.pixel_height + BEGIN SELECT RAISE(ABORT, 'project_ratio_fixed'); END; + DROP TRIGGER IF EXISTS project_images_history_limit; + CREATE TRIGGER project_images_history_limit + BEFORE INSERT ON project_images + WHEN (SELECT COUNT(*) FROM project_images WHERE project_id = NEW.project_id) >= ${historyLimit} + BEGIN SELECT RAISE(ABORT, 'project_history_limit'); END; + `); + const missingStates = this.database.prepare(` + SELECT p.* FROM projects p + WHERE NOT EXISTS (SELECT 1 FROM project_states s WHERE s.project_id = p.project_id) + `).all() as ProjectRow[]; + const insertBackfill = this.database.transaction(() => { + for (const project of missingStates) { + this.insertProjectState({ + canvasState: defaultCanvasState(project.ratio, { height: project.pixel_height, width: project.pixel_width }, project.current_image_id), + name: project.name, + projectId: project.project_id, + stateVersion: project.state_version, + }, project.updated_at); + } + }); + insertBackfill.immediate(); + } +} diff --git a/apps/api/src/recent-assets.ts b/apps/api/src/recent-assets.ts new file mode 100644 index 0000000..37f29f3 --- /dev/null +++ b/apps/api/src/recent-assets.ts @@ -0,0 +1,67 @@ +import { createRequire } from "node:module"; +import type BetterSqlite3 from "better-sqlite3"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; + +export type RecentAssetKind = "static_sticker" | "text_template"; + +export interface RecentAssetItem { + asset_id: string; + asset_kind: RecentAssetKind; + resource_version: string; +} + +export class RecentAssetService { + readonly database: BetterSqlite3.Database; + private readonly ownsDatabase: boolean; + private readonly clock: () => number; + + constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) { + if (!input.database && !input.databasePath) throw new Error("recent_asset_database_required"); + this.database = input.database ?? new Database(input.databasePath!); + this.ownsDatabase = !input.database; + this.clock = input.clock ?? Date.now; + this.database.exec(` + CREATE TABLE IF NOT EXISTS recent_assets ( + user_id TEXT NOT NULL, + asset_kind TEXT NOT NULL CHECK (asset_kind IN ('text_template', 'static_sticker')), + asset_id TEXT NOT NULL, + resource_version TEXT NOT NULL, + used_at INTEGER NOT NULL, + PRIMARY KEY (user_id, asset_kind, asset_id) + ); + CREATE INDEX IF NOT EXISTS recent_assets_user_kind_used + ON recent_assets (user_id, asset_kind, used_at DESC, asset_id ASC); + `); + } + + close() { + if (this.ownsDatabase) this.database.close(); + } + + list(userId: string, assetKind: RecentAssetKind, limit = 12): RecentAssetItem[] { + if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("recent_asset_limit_invalid"); + return this.database.prepare(` + SELECT asset_id, asset_kind, resource_version + FROM recent_assets + WHERE user_id = ? AND asset_kind = ? + ORDER BY used_at DESC, asset_id ASC + LIMIT ? + `).all(userId, assetKind, limit) as RecentAssetItem[]; + } + + recordSuccessfulUse(input: { assetId: string; assetKind: RecentAssetKind; resourceVersion: string; userId: string }) { + if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.assetId) + || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.resourceVersion)) { + throw new Error("recent_asset_reference_invalid"); + } + this.database.prepare(` + INSERT INTO recent_assets (user_id, asset_kind, asset_id, resource_version, used_at) + VALUES (@userId, @assetKind, @assetId, @resourceVersion, @usedAt) + ON CONFLICT (user_id, asset_kind, asset_id) DO UPDATE SET + resource_version = excluded.resource_version, + used_at = excluded.used_at + `).run({ ...input, usedAt: this.clock() }); + } +} diff --git a/apps/api/src/registration-errors.ts b/apps/api/src/registration-errors.ts new file mode 100644 index 0000000..e5a5d75 --- /dev/null +++ b/apps/api/src/registration-errors.ts @@ -0,0 +1,81 @@ +export type RegistrationErrorReason = + | "invite_not_found" + | "invite_expired" + | "invite_disabled" + | "invite_exhausted" + | "stage_limit_reached" + | "email_already_registered" + | "challenge_invalid" + | "challenge_expired" + | "privacy_consent_required" + | "privacy_notice_version_invalid" + | "profile_invalid" + | "idempotency_conflict" + | "registration_login_required" + | "login_registration_required" + | "account_suspended" + | "login_admin_required" + | "admin_not_allowed" + | "resend_too_soon" + | "too_many_attempts" + | "csrf_invalid" + | "deletion_confirmation_invalid" + | "session_invalid"; + +export type RegistrationErrorCode = + | "REGISTRATION_REJECTED" + | "REGISTRATION_REQUEST_INVALID" + | "IDEMPOTENCY_KEY_CONFLICT" + | "AUTH_ENTRY_REJECTED" + | "AUTH_RATE_LIMITED" + | "AUTH_CSRF_INVALID" + | "AUTH_SESSION_INVALID"; + +export class RegistrationError extends Error { + readonly code: RegistrationErrorCode; + readonly httpStatus: 400 | 401 | 403 | 409 | 429; + readonly reason: RegistrationErrorReason; + + constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) { + super(code); + this.code = code; + this.httpStatus = code === "REGISTRATION_REQUEST_INVALID" + ? 400 + : code === "AUTH_SESSION_INVALID" + ? 401 + : code === "AUTH_CSRF_INVALID" + ? 403 + : code === "AUTH_RATE_LIMITED" + ? 429 + : 409; + this.reason = reason; + } +} + +export function registrationFieldError(reason: RegistrationErrorReason) { + const entries: Record = { + challenge_expired: { field: "verification_code", message_key: "auth.challenge.expired" }, + challenge_invalid: { field: "verification_code", message_key: "auth.challenge.invalid" }, + email_already_registered: { field: "email", message_key: "auth.email.already_registered" }, + idempotency_conflict: { field: "idempotency_key", message_key: "request.idempotency_conflict" }, + invite_disabled: { field: "invite_code", message_key: "auth.invite.disabled" }, + invite_exhausted: { field: "invite_code", message_key: "auth.invite.exhausted" }, + invite_expired: { field: "invite_code", message_key: "auth.invite.expired" }, + invite_not_found: { field: "invite_code", message_key: "auth.invite.not_found" }, + privacy_consent_required: { field: "privacy_consent_accepted", message_key: "auth.privacy.consent_required" }, + privacy_notice_version_invalid: { field: "privacy_notice_version", message_key: "auth.privacy.notice_version_invalid" }, + profile_invalid: { field: "profile", message_key: "auth.profile.invalid" }, + stage_limit_reached: { field: "invite_code", message_key: "auth.registration.stage_limit_reached" }, + registration_login_required: { field: "email", message_key: "auth.registration.login_required" }, + login_registration_required: { field: "email", message_key: "auth.login.registration_required" }, + account_suspended: { field: "email", message_key: "auth.account.suspended" }, + login_admin_required: { field: "email", message_key: "auth.login.admin_required" }, + admin_not_allowed: { field: "email", message_key: "admin.auth.not_allowed" }, + resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" }, + too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" }, + csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" }, + deletion_confirmation_invalid: { field: "confirmation", message_key: "account.deletion.confirmation_invalid" }, + session_invalid: { field: "session", message_key: "auth.session.invalid" }, + }; + return entries[reason]; +} diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts new file mode 100644 index 0000000..73c3540 --- /dev/null +++ b/apps/api/src/registration.ts @@ -0,0 +1,1994 @@ +import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { createRequire } from "node:module"; + +import type BetterSqlite3 from "better-sqlite3"; + +import { + auditRetentionMilliseconds, + ensureAdminOperationAuditSchema, + ensurePrivateAccessAuditSchema, + isSafeAuditRef, + isSafeAuditSummaryJson, + serializeAuditSummary, +} from "./audit-policy.js"; +import type { ResendAdapter } from "./resend-adapter.js"; +import { ExternalServiceUsage } from "./external-service-usage.js"; +import { + RegistrationError, + type RegistrationErrorReason, +} from "./registration-errors.js"; + +export { RegistrationError } from "./registration-errors.js"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; +const stageLimit = 10; +const challengeLifetimeMilliseconds = 10 * 60 * 1_000; +const resendDelayMilliseconds = 60 * 1_000; +const sessionLifetimeMilliseconds = 30 * 24 * 60 * 60 * 1_000; +const rateWindowMilliseconds = 10 * 60 * 1_000; +const rateBlockMilliseconds = 10 * 60 * 1_000; +const maximumFailedAttempts = 5; +const maximumSendsPerWindow = 5; + +export interface RegistrationTransactionEvent { + mode: "BEGIN IMMEDIATE"; + operation: + | "invite_create" + | "registration_send" + | "registration_complete" + | "registration_send_compensation" + | "login_send" + | "login_complete" + | "admin_login_send" + | "admin_login_complete" + | "secure_config_apply" + | "session_issue" + | "session_revoke" + | "account_delete_send" + | "account_delete_complete" + | "profile_update" + | "csrf_issue"; + outcome: "committed" | "rejected" | "idempotent_replay"; +} + +interface RegistrationServiceOptions { + adminAllowlistPepper?: Buffer; + challengePepper: Buffer; + clock?: () => number; + codeGenerator?: () => string; + currentPrivacyNoticeVersion: string; + databasePath: string; + inviteCodeGenerator?: () => string; + invitePepper: Buffer; + onTransaction?: (event: RegistrationTransactionEvent) => void; + resend: ResendAdapter; + sessionPepper: Buffer; +} + +interface InviteRow { + expires_at: number; + invite_id: string; + max_uses: number; + status: "enabled" | "disabled"; + used_count: number; +} + +interface ChallengeRow { + challenge_id: string; + code_hmac: string; + consumed_at: number | null; + email: string; + expires_at: number; + invite_id: string | null; +} + +interface AttemptRow { + failure_reason: RegistrationErrorReason | null; + outcome_code: "success" | "failure"; + request_hash: string; + session_id: string | null; + user_id: string | null; +} + +interface UserResultRow { + available_balance: number; + creator_name: string; + expires_at: number; + reserved_balance: number; + session_id: string; + social_id: string; + user_id: string; +} + +interface RateLimitRow { + blocked_until: number | null; + failed_attempts: number; + send_count: number; + window_started_at: number; +} + +interface LoginAttemptRow { + failure_reason: RegistrationErrorReason | null; + outcome_code: "success" | "failure"; + request_hash: string; + session_id: string | null; + user_id: string | null; +} + +export interface RegistrationSendResult { + challengeExpiresAt: number; + registrationId: string; + resendAvailableAt: number; + status: "verification_sent"; +} + +export interface RegistrationCompleteInput { + code: string; + creatorName: string; + idempotencyKey: string; + privacyConsentAccepted: boolean; + privacyNoticeVersion: string; + registrationId: string; + socialId: string; +} + +export interface RegistrationCompleteResult { + credits: { availableBalance: number; reservedBalance: number }; + sessionExpiresAt: number; + sessionToken: string; + status: "registered"; + user: { + creatorName: string; + role: "user"; + socialId: string; + status: "active"; + userId: string; + }; +} + +export interface UserSessionResult { + audience: "user"; + credits: { availableBalance: number; reservedBalance: number }; + expiresAt: number; + user: RegistrationCompleteResult["user"]; + userId: string; +} + +export interface LoginCompleteInput { + clientKey: string; + code: string; + idempotencyKey: string; + registrationId: string; +} + +export type LoginCompleteResult = Omit & { + audience: "user"; + status: "authenticated"; +}; + +export interface AdminLoginCompleteResult { + admin: { + role: "super_admin"; + status: "active"; + userId: string; + }; + audience: "admin"; + sessionExpiresAt: number; + sessionToken: string; + status: "authenticated"; +} + +export interface SecureConfigCandidate { + adminAllowlistHashes: string[]; + adminRecoveryHashes: string[]; + secureConfigRevision: number; +} + +interface ImmediateResult { + outcome: RegistrationTransactionEvent["outcome"]; + value: T; +} + +function assertSecret(name: string, value: Buffer) { + if (value.byteLength < 32) throw new Error(`${name} must contain at least 32 bytes.`); +} + +function normalizeEmail(email: string) { + const normalized = email.trim().toLowerCase(); + if (normalized.length > 320 || !/^[^@\s]{1,128}@[^@\s]{1,190}$/.test(normalized)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); + } + return normalized; +} + +function normalizeProfileValue(value: string, maximumLength: number) { + const normalized = value.trim(); + if (!normalized || normalized.length > maximumLength) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); + } + return normalized; +} + +function normalizeSocialId(value: string) { + const body = normalizeProfileValue(value, 80).replace(/^@+/, ""); + if (!body) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); + return `@${body}`; +} + +function digest(value: string) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function constantTimeTextEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left, "hex"); + const rightBuffer = Buffer.from(right, "hex"); + return leftBuffer.byteLength === rightBuffer.byteLength && timingSafeEqual(leftBuffer, rightBuffer); +} + +export class RegistrationService { + readonly database: BetterSqlite3.Database; + readonly serviceUsage: ExternalServiceUsage; + readonly options: Required> & RegistrationServiceOptions; + private adminAllowlistHashes = new Set(); + private privacyPurgeActive = false; + private privacyPurgeSubject = ""; + + constructor(options: RegistrationServiceOptions) { + assertSecret("invitePepper", options.invitePepper); + assertSecret("challengePepper", options.challengePepper); + assertSecret("sessionPepper", options.sessionPepper); + if (options.adminAllowlistPepper) assertSecret("adminAllowlistPepper", options.adminAllowlistPepper); + this.options = { + ...options, + clock: options.clock ?? Date.now, + codeGenerator: options.codeGenerator ?? (() => String(randomBytes(4).readUInt32BE(0) % 1_000_000).padStart(6, "0")), + inviteCodeGenerator: options.inviteCodeGenerator ?? (() => randomBytes(24).toString("base64url")), + }; + this.database = new Database(options.databasePath); + this.database.pragma("journal_mode = WAL"); + this.database.pragma("foreign_keys = ON"); + this.database.pragma("synchronous = FULL"); + this.database.pragma("busy_timeout = 5000"); + this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef); + this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson); + this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => this.privacyPurgeActive ? 1 : 0); + this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => this.privacyPurgeSubject); + this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0); + this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0); + this.migrate(); + this.serviceUsage = new ExternalServiceUsage({ database: this.database, clock: this.options.clock }); + } + + close() { + this.database.close(); + } + + createInvite(input: { expiresAt: number; maxUses: number }) { + if (!Number.isSafeInteger(input.expiresAt) || !Number.isSafeInteger(input.maxUses) || input.maxUses < 1) { + throw new Error("Invite fixture is invalid."); + } + const code = this.options.inviteCodeGenerator(); + const inviteId = randomUUID(); + const now = this.options.clock(); + this.runImmediate("invite_create", () => { + this.database.prepare(` + INSERT INTO invite_codes ( + invite_id, code_hmac, max_uses, used_count, expires_at, status, created_at + ) VALUES (?, ?, ?, 0, ?, 'enabled', ?) + `).run(inviteId, this.inviteHmac(code), input.maxUses, input.expiresAt, now); + return { outcome: "committed", value: undefined }; + }); + return { code, inviteId }; + } + + createAdminInvite(input: { actorId: string; expiresAt: number; maxUses: number }) { + if (!Number.isSafeInteger(input.expiresAt) || !Number.isSafeInteger(input.maxUses) || input.maxUses < 1) { + throw new Error("Invite request is invalid."); + } + const code = this.options.inviteCodeGenerator(); + const inviteId = randomUUID(); + const now = this.options.clock(); + this.runImmediate("invite_create", () => { + const admin = this.database.prepare(` + SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(input.actorId); + if (!admin) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare(` + INSERT INTO invite_codes ( + invite_id, code_hmac, max_uses, used_count, expires_at, status, created_at + ) VALUES (?, ?, ?, 0, ?, 'enabled', ?) + `).run(inviteId, this.inviteHmac(code), input.maxUses, input.expiresAt, now); + this.recordAdminAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { max_uses: input.maxUses, status: "enabled" }, + beforeSummary: null, + operationType: "invite_create", + result: "succeeded", + targetRef: inviteId, + targetType: "invite", + }, now); + return { outcome: "committed", value: undefined }; + }); + return { code, inviteId }; + } + + async sendRegistrationCode(input: { email: string; inviteCode: string }): Promise { + const email = normalizeEmail(input.email); + const inviteCode = normalizeProfileValue(input.inviteCode, 160); + const now = this.options.clock(); + const challengeId = randomUUID(); + const code = this.options.codeGenerator(); + if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); + + const result = this.runImmediate("registration_send", () => { + const invite = this.database.prepare("SELECT * FROM invite_codes WHERE code_hmac = ?") + .get(this.inviteHmac(inviteCode)) as InviteRow | undefined; + this.assertInviteAvailable(invite, now); + this.assertStageCapacity(); + const existing = this.database.prepare(` + SELECT user_id, role, status FROM users + WHERE normalized_email = ? AND status <> 'deleted' + `).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended" } | undefined; + if (existing?.status === "suspended") throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended"); + if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required"); + this.assertChallengeSendAllowed(email, "register", "registration", now); + this.recordRateSend(email, "registration", now); + this.serviceUsage.claimResendWithinTransaction(now); + + this.database.prepare(` + INSERT INTO email_challenges ( + challenge_id, email, invite_id, code_hmac, purpose, expires_at, + resend_available_at, failure_count, consumed_at, created_at + ) VALUES (?, ?, ?, ?, 'register', ?, ?, 0, NULL, ?) + `).run( + challengeId, + email, + invite!.invite_id, + this.challengeHmac(challengeId, code), + now + challengeLifetimeMilliseconds, + now + resendDelayMilliseconds, + now, + ); + return { + outcome: "committed", + value: { + challengeExpiresAt: now + challengeLifetimeMilliseconds, + registrationId: challengeId, + resendAvailableAt: now + resendDelayMilliseconds, + status: "verification_sent" as const, + }, + }; + }); + + try { + await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" }); + } catch { + this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now }); + this.runImmediate("registration_send_compensation", () => { + this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); + return { outcome: "committed", value: undefined }; + }); + throw new Error("AUTH_SERVICE_UNAVAILABLE"); + } + return result; + } + + async sendLoginCode(input: { clientKey: string; email: string }): Promise { + const email = normalizeEmail(input.email); + const clientKey = normalizeProfileValue(input.clientKey, 160); + const now = this.options.clock(); + const challengeId = randomUUID(); + const code = this.options.codeGenerator(); + if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); + + const result = this.runImmediate("login_send", () => { + const user = this.database.prepare(` + SELECT user_id, role, status FROM users + WHERE normalized_email = ? AND status <> 'deleted' + `).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (!user) throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_registration_required"); + if (user.status === "suspended") throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended"); + if (user.role !== "user") throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_admin_required"); + this.assertChallengeSendAllowed(email, "login", clientKey, now); + this.recordRateSend(email, clientKey, now); + this.serviceUsage.claimResendWithinTransaction(now); + this.database.prepare(` + INSERT INTO email_challenges ( + challenge_id, email, invite_id, code_hmac, purpose, expires_at, + resend_available_at, failure_count, consumed_at, created_at + ) VALUES (?, ?, NULL, ?, 'login', ?, ?, 0, NULL, ?) + `).run( + challengeId, + email, + this.challengeHmac(challengeId, code), + now + challengeLifetimeMilliseconds, + now + resendDelayMilliseconds, + now, + ); + return { + outcome: "committed", + value: { + challengeExpiresAt: now + challengeLifetimeMilliseconds, + registrationId: challengeId, + resendAvailableAt: now + resendDelayMilliseconds, + status: "verification_sent" as const, + }, + }; + }); + + try { + await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" }); + } catch { + this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now }); + this.runImmediate("registration_send_compensation", () => { + this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); + return { outcome: "committed", value: undefined }; + }); + throw new Error("AUTH_SERVICE_UNAVAILABLE"); + } + return result; + } + + completeLogin(input: LoginCompleteInput): LoginCompleteResult { + if (!/^[0-9]{6}$/.test(input.code)) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); + const clientKey = normalizeProfileValue(input.clientKey, 160); + if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); + } + const now = this.options.clock(); + const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `login-idempotency:${input.idempotencyKey}`); + const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ + clientKey, + code: input.code, + registrationId: input.registrationId, + })); + + const outcome = this.runImmediate("login_complete", () => { + const previous = this.database.prepare(` + SELECT request_hash, outcome_code, failure_reason, user_id, session_id + FROM login_attempts WHERE idempotency_key_digest = ? + `).get(idempotencyDigest) as LoginAttemptRow | undefined; + if (previous) { + if (!constantTimeTextEqual(previous.request_hash, requestHash)) { + throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); + } + if (previous.outcome_code === "failure") { + const reason = previous.failure_reason ?? "challenge_invalid"; + const code = reason === "resend_too_soon" || reason === "too_many_attempts" + ? "AUTH_RATE_LIMITED" + : "AUTH_ENTRY_REJECTED"; + return { outcome: "idempotent_replay", value: new RegistrationError(code, reason) }; + } + return { + outcome: "idempotent_replay", + value: this.loginResult(this.readCompletedRegistration(previous.user_id!, previous.session_id!)), + }; + } + + const challenge = this.database.prepare(` + SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at + FROM email_challenges WHERE challenge_id = ? AND purpose = 'login' + `).get(input.registrationId) as ChallengeRow | undefined; + if (!challenge || challenge.consumed_at !== null) { + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); + } + const rate = this.readRateLimit(challenge.email, clientKey, now); + if (rate.blocked_until !== null && rate.blocked_until > now) { + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "too_many_attempts", now); + } + if (challenge.expires_at <= now) { + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); + } + if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { + this.database.prepare("UPDATE email_challenges SET failure_count = failure_count + 1 WHERE challenge_id = ?") + .run(challenge.challenge_id); + const failedAttempts = this.recordRateFailure(challenge.email, clientKey, now); + const reason = failedAttempts >= maximumFailedAttempts ? "too_many_attempts" : "challenge_invalid"; + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, reason, now); + } + const user = this.database.prepare(` + SELECT user_id, role, status FROM users + WHERE normalized_email = ? AND status <> 'deleted' + `).get(challenge.email) as { role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (!user) return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "login_registration_required", now); + if (user.status === "suspended") return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "account_suspended", now); + if (user.role !== "user") return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "login_admin_required", now); + + this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") + .run(now, challenge.challenge_id); + const issued = this.insertSession(user.user_id, "user", now); + this.database.prepare(` + INSERT INTO login_attempts ( + idempotency_key_digest, request_hash, challenge_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) + `).run(idempotencyDigest, requestHash, input.registrationId, user.user_id, issued.sessionId, now); + return { + outcome: "committed", + value: this.loginResult(this.readCompletedRegistration(user.user_id, issued.sessionId)), + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + return outcome; + } + + completeRegistration(input: RegistrationCompleteInput): RegistrationCompleteResult { + const creatorName = normalizeProfileValue(input.creatorName, 80); + const socialId = normalizeSocialId(input.socialId); + if (!/^[0-9]{6}$/.test(input.code)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); + } + if (!input.privacyConsentAccepted) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "privacy_consent_required"); + } + if (input.privacyNoticeVersion !== this.options.currentPrivacyNoticeVersion) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "privacy_notice_version_invalid"); + } + if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); + } + const now = this.options.clock(); + const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `idempotency:${input.idempotencyKey}`); + const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ + code: input.code, + creatorName, + privacyConsentAccepted: input.privacyConsentAccepted, + privacyNoticeVersion: input.privacyNoticeVersion, + registrationId: input.registrationId, + socialId, + })); + + const outcome = this.runImmediate("registration_complete", () => { + const previous = this.database.prepare(` + SELECT request_hash, outcome_code, failure_reason, user_id, session_id + FROM registration_attempts WHERE idempotency_key_digest = ? + `).get(idempotencyDigest) as AttemptRow | undefined; + if (previous) { + if (!constantTimeTextEqual(previous.request_hash, requestHash)) { + throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); + } + if (previous.outcome_code === "failure") { + return { + outcome: "idempotent_replay", + value: new RegistrationError("REGISTRATION_REJECTED", previous.failure_reason ?? "challenge_invalid"), + }; + } + return { + outcome: "idempotent_replay", + value: this.readCompletedRegistration(previous.user_id!, previous.session_id!), + }; + } + + const challenge = this.database.prepare(` + SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at + FROM email_challenges WHERE challenge_id = ? AND purpose = 'register' + `).get(input.registrationId) as ChallengeRow | undefined; + if (!challenge || challenge.consumed_at !== null) { + return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); + } + const invite = this.database.prepare("SELECT * FROM invite_codes WHERE invite_id = ?") + .get(challenge.invite_id) as InviteRow | undefined; + const inviteReason = this.inviteUnavailableReason(invite, now); + if (inviteReason) return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, inviteReason, now); + if (this.stageIsFull()) { + return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "stage_limit_reached", now); + } + if (challenge.expires_at <= now) { + return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); + } + if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { + return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); + } + const existing = this.database.prepare(` + SELECT user_id FROM users WHERE normalized_email = ? AND status <> 'deleted' + `).get(challenge.email); + if (existing) { + return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "email_already_registered", now); + } + + const userId = randomUUID(); + const sessionId = randomUUID(); + const sessionToken = this.sessionToken(sessionId); + const sessionExpiresAt = now + sessionLifetimeMilliseconds; + this.database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, 'user', 'active', 1, ?, ?) + `).run(userId, challenge.email, input.registrationId, now); + this.database.prepare(` + INSERT INTO user_profiles ( + user_id, creator_name, social_id, private_content_notice_version, + private_content_notice_acknowledged_at + ) VALUES (?, ?, ?, NULL, NULL) + `).run(userId, creatorName, socialId); + this.database.prepare(` + INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) + VALUES (?, 10, 0, ?) + `).run(userId, now); + this.database.prepare(` + INSERT INTO credit_ledger ( + ledger_id, user_id, operation_key, entry_type, amount, + available_before, available_after, reserved_before, reserved_after, created_at + ) VALUES (?, ?, ?, 'registration_grant', 10, 0, 10, 0, 0, ?) + `).run(randomUUID(), userId, `registration:${input.registrationId}`, now); + this.database.prepare(` + INSERT INTO privacy_consents (consent_id, user_id, notice_version, consented_at) + VALUES (?, ?, ?, ?) + `).run(randomUUID(), userId, input.privacyNoticeVersion, now); + this.database.prepare(` + UPDATE invite_codes SET used_count = used_count + 1 + WHERE invite_id = ? AND status = 'enabled' AND used_count < max_uses AND expires_at > ? + `).run(challenge.invite_id, now); + this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") + .run(now, challenge.challenge_id); + this.database.prepare(` + INSERT INTO sessions ( + session_id, user_id, audience, token_digest, created_at, expires_at, revoked_at + ) VALUES (?, ?, 'user', ?, ?, ?, NULL) + `).run(sessionId, userId, digest(sessionToken), now, sessionExpiresAt); + this.database.prepare(` + INSERT INTO registration_attempts ( + idempotency_key_digest, request_hash, registration_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) + `).run(idempotencyDigest, requestHash, input.registrationId, userId, sessionId, now); + + return { + outcome: "committed", + value: this.result({ + available_balance: 10, + creator_name: creatorName, + expires_at: sessionExpiresAt, + reserved_balance: 0, + session_id: sessionId, + social_id: socialId, + user_id: userId, + }), + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + return outcome; + } + + applySecureConfig(candidate: SecureConfigCandidate) { + const now = this.options.clock(); + const fail = (reason: string): never => { + this.recordConfigApplyFailure(reason, now); + throw new Error(reason); + }; + if (!this.options.adminAllowlistPepper) return fail("admin_pepper_not_configured"); + if (!Number.isSafeInteger(candidate.secureConfigRevision) || candidate.secureConfigRevision < 0) { + return fail("secure_config_revision_invalid"); + } + const normalizeHashes = (values: string[], name: string) => { + if (!Array.isArray(values)) return fail(`${name}_invalid`); + const normalized = [...new Set(values.map((value) => value.toUpperCase()))]; + if (normalized.some((value) => !/^[A-F0-9]{64}$/.test(value))) return fail("hmac_invalid"); + return normalized; + }; + const allowlist = normalizeHashes(candidate.adminAllowlistHashes, "admin_allowlist"); + const recoveries = normalizeHashes(candidate.adminRecoveryHashes, "admin_recovery"); + const allowlistSet = new Set(allowlist); + if (recoveries.some((value) => !allowlistSet.has(value))) return fail("admin_recovery_invalid"); + + const state = this.database.prepare(` + SELECT applied_revision FROM secure_config_apply_state WHERE singleton = 1 + `).get() as { applied_revision: number } | undefined; + const appliedRevision = state?.applied_revision ?? 0; + if (candidate.secureConfigRevision === appliedRevision) { + this.adminAllowlistHashes = allowlistSet; + return { appliedRevision, status: "unchanged" as const }; + } + if (candidate.secureConfigRevision !== appliedRevision + 1) return fail("secure_config_revision_out_of_sequence"); + + const ordinaryUsers = this.database.prepare(` + SELECT normalized_email FROM users WHERE role = 'user' AND status <> 'deleted' + `).all() as Array<{ normalized_email: string }>; + if (ordinaryUsers.some((user) => allowlistSet.has(this.adminAllowlistHmac(user.normalized_email)))) { + return fail("identity_conflict"); + } + + try { + const result = this.runImmediate("secure_config_apply", () => { + const recoverySet = new Set(recoveries); + const admins = this.database.prepare(` + SELECT u.user_id, u.normalized_email, u.status, COALESCE(a.allowed, 0) AS allowed + FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id + WHERE u.role = 'super_admin' AND u.status <> 'deleted' + `).all() as Array<{ + allowed: 0 | 1; + normalized_email: string; + status: "active" | "suspended"; + user_id: string; + }>; + let revokedSessions = 0; + let recoveredAdmins = 0; + for (const admin of admins) { + const adminHash = this.adminAllowlistHmac(admin.normalized_email); + const allowed = allowlistSet.has(adminHash); + this.database.prepare(` + INSERT INTO admin_access (user_id, allowed) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET allowed = excluded.allowed + `).run(admin.user_id, allowed ? 1 : 0); + if (!allowed) { + revokedSessions += this.database.prepare(` + UPDATE sessions SET revoked_at = ? + WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL + `).run(now, admin.user_id).changes; + if (admin.allowed === 1) { + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { access: "removed" }, + beforeSummary: { access: "allowed" }, + operationType: "admin_allowlist_remove", + result: "succeeded", + targetRef: admin.user_id, + targetType: "admin_account", + }, now); + } + } else if (admin.status === "suspended" && recoverySet.has(adminHash)) { + this.database.prepare("UPDATE users SET status = 'active' WHERE user_id = ?").run(admin.user_id); + this.database.prepare(` + DELETE FROM email_challenges WHERE email = ? AND purpose = 'admin_login' + `).run(admin.normalized_email); + recoveredAdmins += 1; + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { status: "active" }, + beforeSummary: { status: "suspended" }, + operationType: "admin_recover", + result: "succeeded", + targetRef: admin.user_id, + targetType: "admin_account", + }, now); + } + } + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { allowlist_count: allowlist.length, recovered_admins: recoveredAdmins, revoked_sessions: revokedSessions }, + beforeSummary: { allowlist_count: this.readAppliedAllowlistCount(), revision: appliedRevision }, + operationType: "secure_config_apply", + result: "succeeded", + targetRef: `revision:${candidate.secureConfigRevision}`, + targetType: "secure_config_revision", + }, now); + this.database.prepare(` + INSERT INTO secure_config_apply_state (singleton, applied_revision, allowlist_count, applied_at) + VALUES (1, ?, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + applied_revision = excluded.applied_revision, + allowlist_count = excluded.allowlist_count, + applied_at = excluded.applied_at + `).run(candidate.secureConfigRevision, allowlist.length, now); + return { + outcome: "committed", + value: { appliedRevision: candidate.secureConfigRevision, status: "applied" as const }, + }; + }); + this.adminAllowlistHashes = allowlistSet; + return result; + } catch (error) { + const reason = error instanceof Error ? error.message : "secure_config_apply_failed"; + this.recordConfigApplyFailure(reason, now); + throw error; + } + } + + async sendAdminLoginCode(input: { clientKey: string; email: string }): Promise { + const email = normalizeEmail(input.email); + const clientKey = normalizeProfileValue(input.clientKey, 160); + const now = this.options.clock(); + const challengeId = randomUUID(); + const code = this.options.codeGenerator(); + if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); + + const outcome = this.runImmediate("admin_login_send", () => { + if (!this.isAdminAllowlisted(email)) { + this.recordAdminLoginRejection("not_allowed", now); + return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") }; + } + const user = this.database.prepare(` + SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed + FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id + WHERE u.normalized_email = ? AND u.status <> 'deleted' + `).get(email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (user?.status === "suspended") { + this.recordAdminLoginRejection("suspended", now); + return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended") }; + } + if (user && (user.role !== "super_admin" || user.allowed !== 1)) { + this.recordAdminLoginRejection("not_allowed", now); + return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") }; + } + this.assertChallengeSendAllowed(email, "admin_login", clientKey, now); + this.recordRateSend(email, clientKey, now); + this.serviceUsage.claimResendWithinTransaction(now); + this.database.prepare(` + INSERT INTO email_challenges ( + challenge_id, email, invite_id, code_hmac, purpose, expires_at, + resend_available_at, failure_count, consumed_at, created_at + ) VALUES (?, ?, NULL, ?, 'admin_login', ?, ?, 0, NULL, ?) + `).run( + challengeId, + email, + this.challengeHmac(challengeId, code), + now + challengeLifetimeMilliseconds, + now + resendDelayMilliseconds, + now, + ); + return { + outcome: "committed", + value: { + challengeExpiresAt: now + challengeLifetimeMilliseconds, + registrationId: challengeId, + resendAvailableAt: now + resendDelayMilliseconds, + status: "verification_sent" as const, + }, + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + try { + await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" }); + } catch { + this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now }); + this.runImmediate("registration_send_compensation", () => { + this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); + this.recordAdminLoginRejection("service_unavailable", now); + return { outcome: "committed", value: undefined }; + }); + throw new Error("AUTH_SERVICE_UNAVAILABLE"); + } + return outcome; + } + + completeAdminLogin(input: LoginCompleteInput): AdminLoginCompleteResult { + if (!/^[0-9]{6}$/.test(input.code)) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); + const clientKey = normalizeProfileValue(input.clientKey, 160); + if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); + } + const now = this.options.clock(); + const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `admin-login-idempotency:${input.idempotencyKey}`); + const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ + clientKey, + code: input.code, + registrationId: input.registrationId, + })); + const outcome = this.runImmediate("admin_login_complete", () => { + const previous = this.database.prepare(` + SELECT request_hash, outcome_code, failure_reason, user_id, session_id + FROM login_attempts WHERE idempotency_key_digest = ? + `).get(idempotencyDigest) as LoginAttemptRow | undefined; + if (previous) { + if (!constantTimeTextEqual(previous.request_hash, requestHash)) { + throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); + } + if (previous.outcome_code === "failure") { + return { + outcome: "idempotent_replay", + value: new RegistrationError("AUTH_ENTRY_REJECTED", previous.failure_reason ?? "challenge_invalid"), + }; + } + return { + outcome: "idempotent_replay", + value: this.readAdminLoginResult(previous.user_id!, previous.session_id!), + }; + } + const challenge = this.database.prepare(` + SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at + FROM email_challenges WHERE challenge_id = ? AND purpose = 'admin_login' + `).get(input.registrationId) as ChallengeRow | undefined; + if (!challenge || challenge.consumed_at !== null) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); + } + if (!this.isAdminAllowlisted(challenge.email)) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now); + } + const rate = this.readRateLimit(challenge.email, clientKey, now); + if (rate.blocked_until !== null && rate.blocked_until > now) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "too_many_attempts", now); + } + if (challenge.expires_at <= now) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); + } + if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { + this.database.prepare("UPDATE email_challenges SET failure_count = failure_count + 1 WHERE challenge_id = ?") + .run(challenge.challenge_id); + const failedAttempts = this.recordRateFailure(challenge.email, clientKey, now); + return this.recordAdminLoginFailure( + idempotencyDigest, + requestHash, + input.registrationId, + failedAttempts >= maximumFailedAttempts ? "too_many_attempts" : "challenge_invalid", + now, + ); + } + let user = this.database.prepare(` + SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed + FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id + WHERE u.normalized_email = ? AND u.status <> 'deleted' + `).get(challenge.email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (user && (user.role !== "super_admin" || user.status !== "active" || user.allowed !== 1)) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now); + } + if (!user) { + const userId = randomUUID(); + this.database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, 'super_admin', 'active', 0, ?, ?) + `).run(userId, challenge.email, challenge.challenge_id, now); + this.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); + user = { allowed: 1, role: "super_admin", status: "active", user_id: userId }; + this.recordAdminAudit({ + actorRef: userId, + actorType: "super_admin", + afterSummary: { role: "super_admin", status: "active" }, + beforeSummary: null, + operationType: "admin_create", + result: "succeeded", + targetRef: userId, + targetType: "admin_account", + }, now); + } + this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") + .run(now, challenge.challenge_id); + const issued = this.insertSession(user.user_id, "admin", now); + this.database.prepare(` + INSERT INTO login_attempts ( + idempotency_key_digest, request_hash, challenge_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) + `).run(idempotencyDigest, requestHash, input.registrationId, user.user_id, issued.sessionId, now); + this.recordAdminAudit({ + actorRef: user.user_id, + actorType: "super_admin", + afterSummary: { audience: "admin" }, + beforeSummary: null, + operationType: "admin_login", + result: "succeeded", + targetRef: user.user_id, + targetType: "admin_session", + }, now); + return { + outcome: "committed", + value: this.readAdminLoginResult(user.user_id, issued.sessionId), + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + return outcome; + } + + issueAuthenticatedSession(userId: string, audience: "user" | "admin") { + const now = this.options.clock(); + return this.runImmediate("session_issue", () => { + const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?") + .get(userId) as { role: "user" | "super_admin"; status: "active" | "suspended" | "deleted" } | undefined; + const role = audience === "user" ? "user" : "super_admin"; + if (!user || user.status !== "active" || user.role !== role) { + throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + } + if (audience === "admin") { + const access = this.database.prepare("SELECT allowed FROM admin_access WHERE user_id = ?") + .get(userId) as { allowed: 0 | 1 } | undefined; + if (access?.allowed !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + } + const issued = this.insertSession(userId, audience, now); + return { + outcome: "committed", + value: { + sessionExpiresAt: issued.sessionExpiresAt, + sessionId: issued.sessionId, + sessionToken: issued.sessionToken, + }, + }; + }); + } + + issueUserCsrfToken(sessionToken: string) { + const now = this.options.clock(); + const csrfToken = randomBytes(32).toString("base64url"); + return this.runImmediate("csrf_issue", () => { + const session = this.database.prepare(` + SELECT s.session_id FROM sessions s + JOIN users u ON u.user_id = s.user_id + WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active' + `).get(digest(sessionToken), now) as { session_id: string } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?") + .run(digest(csrfToken), session.session_id); + return { outcome: "committed", value: csrfToken }; + }); + } + + issueAdminCsrfToken(sessionToken: string) { + const now = this.options.clock(); + const csrfToken = randomBytes(32).toString("base64url"); + return this.runImmediate("csrf_issue", () => { + const session = this.database.prepare(` + SELECT s.session_id FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN admin_access a ON a.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(digest(sessionToken), now) as { session_id: string } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?") + .run(digest(csrfToken), session.session_id); + return { outcome: "committed", value: csrfToken }; + }); + } + + authorizeUserMutation(input: { csrfToken: string; sessionToken: string }) { + const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, this.options.clock()); + return { userId: session.user_id }; + } + + authorizeAdminMutation(input: { csrfToken: string; sessionToken: string }) { + const session = this.authenticatedAdminMutationSession(input.sessionToken, input.csrfToken, this.options.clock()); + return { userId: session.user_id }; + } + + logoutUser(input: { csrfToken: string; sessionToken: string }) { + const now = this.options.clock(); + this.runImmediate("session_revoke", () => { + const session = this.database.prepare(` + SELECT session_id, user_id, csrf_token_digest FROM sessions + WHERE token_digest = ? AND audience = 'user' AND revoked_at IS NULL AND expires_at > ? + `).get(digest(input.sessionToken), now) as { csrf_token_digest: string | null; session_id: string; user_id: string } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + const csrfDigest = digest(input.csrfToken); + if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, csrfDigest)) { + throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid"); + } + this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") + .run(now, session.user_id); + return { outcome: "committed", value: undefined }; + }); + } + + readAccountSettings(sessionToken: string) { + const now = this.options.clock(); + const row = this.database.prepare(` + SELECT u.normalized_email, p.creator_name, p.social_id + FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN user_profiles p ON p.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active' + `).get(digest(sessionToken), now) as { creator_name: string; normalized_email: string; social_id: string } | undefined; + if (!row) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + const storageTable = this.database.prepare(` + SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'local_backend_storage_state' + `).get(); + const storage = storageTable + ? this.database.prepare(` + SELECT hard_limit_bytes, managed_content_bytes, storage_status + FROM local_backend_storage_state WHERE singleton = 1 + `).get() as { hard_limit_bytes: number; managed_content_bytes: number; storage_status: "active" | "full" | "unavailable" } | undefined + : undefined; + const capacityStatus = storage?.storage_status === "active" + ? (() => { + const bytes = storage.managed_content_bytes; + if (bytes >= 4_831_838_208) return "critical" as const; + if (bytes >= 4_294_967_296) return "warning" as const; + return "normal" as const; + })() + : (storage?.storage_status ?? "unavailable"); + return { + account: { email: row.normalized_email, status: "active" as const }, + localData: { + backupEnabled: false as const, + capacityStatus, + hardLimitBytes: storage?.hard_limit_bytes ?? 5_368_709_120, + location: "configured_local_data_root" as const, + managedContentBytes: storage?.managed_content_bytes ?? 0, + migrationSupported: false as const, + }, + profile: { creatorName: row.creator_name, socialId: row.social_id }, + }; + } + + updateAccountProfile(input: { creatorName: string; csrfToken: string; sessionToken: string; socialId: string }) { + const now = this.options.clock(); + const creatorName = normalizeProfileValue(input.creatorName, 80); + const socialId = normalizeSocialId(input.socialId); + if (!creatorName || !socialId) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); + return this.runImmediate("profile_update", () => { + const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, now); + this.database.prepare("UPDATE user_profiles SET creator_name = ?, social_id = ? WHERE user_id = ?") + .run(creatorName, socialId, session.user_id); + return { outcome: "committed", value: { creatorName, socialId, status: "saved" as const } }; + }); + } + + async sendAccountDeletionCode(input: { csrfToken: string; sessionToken: string }) { + const now = this.options.clock(); + const deletionId = randomUUID(); + const code = this.options.codeGenerator(); + if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); + const result = this.runImmediate("account_delete_send", () => { + const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, now); + const previous = this.database.prepare(` + SELECT resend_available_at FROM account_deletion_challenges + WHERE user_id = ? AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1 + `).get(session.user_id) as { resend_available_at: number } | undefined; + if (previous && previous.resend_available_at > now) { + throw new RegistrationError("AUTH_RATE_LIMITED", "resend_too_soon"); + } + this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id); + this.database.prepare(` + INSERT INTO account_deletion_challenges ( + deletion_id, user_id, email, code_hmac, expires_at, resend_available_at, + failure_count, consumed_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?) + `).run( + deletionId, + session.user_id, + session.normalized_email, + this.challengeHmac(deletionId, code), + now + challengeLifetimeMilliseconds, + now + resendDelayMilliseconds, + now, + ); + this.serviceUsage.claimResendWithinTransaction(now); + return { + outcome: "committed", + value: { + challengeExpiresAt: now + challengeLifetimeMilliseconds, + deletionId, + email: session.normalized_email, + resendAvailableAt: now + resendDelayMilliseconds, + status: "verification_sent" as const, + }, + }; + }); + try { + await this.options.resend.sendVerificationCode({ + challengeId: deletionId, + code, + email: result.email, + purpose: "account_delete", + }); + } catch { + this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now }); + this.runImmediate("registration_send_compensation", () => { + this.database.prepare("DELETE FROM account_deletion_challenges WHERE deletion_id = ? AND consumed_at IS NULL").run(deletionId); + return { outcome: "committed", value: undefined }; + }); + throw new Error("AUTH_SERVICE_UNAVAILABLE"); + } + return { + challengeExpiresAt: result.challengeExpiresAt, + deletionId: result.deletionId, + resendAvailableAt: result.resendAvailableAt, + status: result.status, + }; + } + + completeAccountDeletion(input: { + code: string; + confirmation: string; + csrfToken: string; + deletionId: string; + idempotencyKey: string; + sessionToken: string; + }) { + const now = this.options.clock(); + const idempotencyDigest = digest(input.idempotencyKey); + const requestHash = this.keyedHmac( + this.options.challengePepper, + JSON.stringify({ confirmation: input.confirmation, deletionId: input.deletionId, verificationCode: input.code }), + ); + const previous = this.database.prepare(` + SELECT request_hash FROM account_deletion_receipts WHERE idempotency_key_digest = ? + `).get(idempotencyDigest) as { request_hash: string } | undefined; + if (previous) { + if (!constantTimeTextEqual(previous.request_hash, requestHash)) { + throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); + } + return { status: "deleted" as const }; + } + const outcome = this.runImmediate<{ status: "deleted" } | RegistrationError>("account_delete_complete", () => { + const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, now); + if (input.confirmation !== "注销账号") { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "deletion_confirmation_invalid"); + } + const challenge = this.database.prepare(` + SELECT code_hmac, expires_at FROM account_deletion_challenges + WHERE deletion_id = ? AND user_id = ? AND consumed_at IS NULL + `).get(input.deletionId, session.user_id) as { code_hmac: string; expires_at: number } | undefined; + if (!challenge) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); + if (challenge.expires_at <= now) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_expired"); + if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(input.deletionId, input.code))) { + this.database.prepare("UPDATE account_deletion_challenges SET failure_count = failure_count + 1 WHERE deletion_id = ?") + .run(input.deletionId); + return { + outcome: "rejected", + value: new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"), + }; + } + + const anonymousSubjectId = randomUUID(); + const anonymousExpiresAt = now + 180 * 24 * 60 * 60 * 1_000; + const ledger = this.database.prepare(` + SELECT entry_type, amount, created_at FROM credit_ledger WHERE user_id = ? ORDER BY created_at, ledger_id + `).all(session.user_id) as Array<{ amount: number; created_at: number; entry_type: string }>; + const insertAnonymous = this.database.prepare(` + INSERT INTO anonymous_retained_events ( + event_id, anonymous_subject_id, event_type, model_id, outcome, + error_category, credit_delta, occurred_at, expires_at + ) VALUES (?, ?, ?, NULL, 'succeeded', NULL, ?, ?, ?) + `); + for (const entry of ledger) { + insertAnonymous.run(randomUUID(), anonymousSubjectId, entry.entry_type, entry.amount, entry.created_at, anonymousExpiresAt); + } + + this.privacyPurgeActive = true; + this.privacyPurgeSubject = session.user_id; + try { + this.database.prepare("DELETE FROM credit_ledger WHERE user_id = ?").run(session.user_id); + this.database.prepare(` + UPDATE private_content_access_logs SET subject_ref = ?, target_ref = ? WHERE subject_ref = ? + `).run(randomUUID(), randomUUID(), session.user_id); + } finally { + this.privacyPurgeActive = false; + this.privacyPurgeSubject = ""; + } + + this.queueOwnedManagedFiles(session.user_id, now); + this.database.prepare("DELETE FROM registration_attempts WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM login_attempts WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM privacy_consents WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM credit_accounts WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM user_profiles WHERE user_id = ?").run(session.user_id); + const projectsTable = this.database.prepare(` + SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'projects' + `).get(); + if (projectsTable) this.database.prepare("DELETE FROM projects WHERE owner_id = ?").run(session.user_id); + const exportReceiptsTable = this.database.prepare(` + SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'latest_export_receipts' + `).get(); + if (exportReceiptsTable) this.database.prepare("DELETE FROM latest_export_receipts WHERE owner_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM email_challenges WHERE email = ?").run(session.normalized_email); + this.database.prepare("DELETE FROM auth_rate_limits WHERE rate_key = ?") + .run(this.rateKey(session.normalized_email, "registration")); + this.database.prepare("DELETE FROM sessions WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM users WHERE user_id = ?").run(session.user_id); + this.database.prepare(` + INSERT INTO account_deletion_receipts (idempotency_key_digest, request_hash, deleted_at) + VALUES (?, ?, ?) + `).run(idempotencyDigest, requestHash, now); + return { outcome: "committed", value: { status: "deleted" as const } }; + }); + if (outcome instanceof RegistrationError) throw outcome; + return outcome; + } + + changeUserStatus(userId: string, status: "suspended" | "deleted", actorId?: string) { + const now = this.options.clock(); + this.runImmediate("session_revoke", () => { + if (actorId) { + const admin = this.database.prepare(` + SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(actorId); + if (!admin) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + } + const before = this.database.prepare("SELECT status FROM users WHERE user_id = ? AND role = 'user'") + .get(userId) as { status: "active" | "suspended" | "deleted" } | undefined; + const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'") + .run(status, userId); + if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") + .run(now, userId); + if (actorId) { + this.recordAdminAudit({ + actorRef: actorId, + actorType: "super_admin", + afterSummary: { status }, + beforeSummary: { status: before?.status ?? "unknown" }, + operationType: "user_status_change", + result: "succeeded", + targetRef: userId, + targetType: "user_account", + }, now); + } + return { outcome: "committed", value: undefined }; + }); + } + + revokeAdminSessions(userId: string, reason: "logout" | "disabled" | "whitelist_removed") { + const now = this.options.clock(); + this.runImmediate("session_revoke", () => { + const user = this.database.prepare("SELECT role FROM users WHERE user_id = ?").get(userId) as { role: string } | undefined; + if (user?.role !== "super_admin") throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + if (reason === "disabled") this.database.prepare("UPDATE users SET status = 'suspended' WHERE user_id = ?").run(userId); + if (reason === "whitelist_removed") this.database.prepare("UPDATE admin_access SET allowed = 0 WHERE user_id = ?").run(userId); + this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL") + .run(now, userId); + this.recordAdminAudit({ + actorRef: reason === "whitelist_removed" ? "backend_secure_config" : userId, + actorType: reason === "whitelist_removed" ? "system" : "super_admin", + afterSummary: { access: reason === "whitelist_removed" ? "removed" : reason }, + beforeSummary: { access: "active" }, + operationType: reason === "disabled" ? "admin_disable" : reason === "logout" ? "admin_logout" : "admin_allowlist_remove", + result: "succeeded", + targetRef: userId, + targetType: "admin_account", + }, now); + return { outcome: "committed", value: undefined }; + }); + } + + readAdminSession(token: string) { + const now = this.options.clock(); + return this.database.prepare(` + SELECT s.expires_at, u.user_id FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN admin_access a ON a.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(digest(token), now) as { expires_at: number; user_id: string } | undefined; + } + + readUserSession(token: string): UserSessionResult | undefined { + const now = this.options.clock(); + const row = this.database.prepare(` + SELECT + s.expires_at, u.user_id, p.creator_name, p.social_id, + c.available_balance, c.reserved_balance + FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN user_profiles p ON p.user_id = u.user_id + JOIN credit_accounts c ON c.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active' + `).get(digest(token), now) as Omit | undefined; + if (!row) return undefined; + return { + audience: "user", + credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance }, + expiresAt: row.expires_at, + user: { + creatorName: row.creator_name, + role: "user", + socialId: row.social_id, + status: "active", + userId: row.user_id, + }, + userId: row.user_id, + }; + } + + private migrate() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + normalized_email TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'super_admin')), + status TEXT NOT NULL CHECK (status IN ('active', 'suspended', 'deleted')), + counts_toward_stage_limit INTEGER NOT NULL CHECK (counts_toward_stage_limit IN (0, 1)), + registration_id TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS users_current_email_unique + ON users(normalized_email) WHERE status <> 'deleted'; + CREATE TABLE IF NOT EXISTS user_profiles ( + user_id TEXT PRIMARY KEY REFERENCES users(user_id), + creator_name TEXT NOT NULL, + social_id TEXT NOT NULL, + private_content_notice_version TEXT, + private_content_notice_acknowledged_at INTEGER + ); + CREATE TABLE IF NOT EXISTS credit_accounts ( + user_id TEXT PRIMARY KEY REFERENCES users(user_id), + available_balance INTEGER NOT NULL, + reserved_balance INTEGER NOT NULL CHECK (reserved_balance >= 0), + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS credit_ledger ( + ledger_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id), + operation_key TEXT NOT NULL UNIQUE, + entry_type TEXT NOT NULL CHECK (entry_type IN ( + 'registration_grant', 'generation_reserve', 'generation_commit', + 'generation_release', 'admin_adjustment' + )), + amount INTEGER NOT NULL, + available_before INTEGER NOT NULL, + available_after INTEGER NOT NULL, + reserved_before INTEGER NOT NULL CHECK (reserved_before >= 0), + reserved_after INTEGER NOT NULL CHECK (reserved_after >= 0), + created_at INTEGER NOT NULL + ); + CREATE TRIGGER IF NOT EXISTS credit_ledger_no_update + BEFORE UPDATE ON credit_ledger BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END; + DROP TRIGGER IF EXISTS credit_ledger_no_delete; + CREATE TRIGGER credit_ledger_no_delete + BEFORE DELETE ON credit_ledger + WHEN dada_allow_privacy_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END; + CREATE TABLE IF NOT EXISTS privacy_consents ( + consent_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id), + notice_version TEXT NOT NULL, + consented_at INTEGER NOT NULL, + UNIQUE(user_id, notice_version) + ); + CREATE TABLE IF NOT EXISTS invite_codes ( + invite_id TEXT PRIMARY KEY, + code_hmac TEXT NOT NULL UNIQUE, + max_uses INTEGER NOT NULL CHECK (max_uses > 0), + used_count INTEGER NOT NULL CHECK (used_count >= 0 AND used_count <= max_uses), + expires_at INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('enabled', 'disabled')), + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS email_challenges ( + challenge_id TEXT PRIMARY KEY, + email TEXT NOT NULL, + invite_id TEXT REFERENCES invite_codes(invite_id), + code_hmac TEXT NOT NULL, + purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'admin_login')), + expires_at INTEGER NOT NULL, + resend_available_at INTEGER NOT NULL, + failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0), + consumed_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id), + audience TEXT NOT NULL CHECK (audience IN ('user', 'admin')), + token_digest TEXT NOT NULL UNIQUE, + csrf_token_digest TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER + ); + CREATE TABLE IF NOT EXISTS registration_attempts ( + idempotency_key_digest TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + registration_id TEXT NOT NULL, + outcome_code TEXT NOT NULL CHECK (outcome_code IN ('success', 'failure')), + failure_reason TEXT, + user_id TEXT REFERENCES users(user_id), + session_id TEXT REFERENCES sessions(session_id), + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS admin_access ( + user_id TEXT PRIMARY KEY REFERENCES users(user_id), + allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS secure_config_apply_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + applied_revision INTEGER NOT NULL CHECK (applied_revision >= 0), + allowlist_count INTEGER NOT NULL CHECK (allowlist_count >= 0), + applied_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS admin_operation_logs ( + log_id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_ref TEXT NOT NULL, + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TABLE IF NOT EXISTS auth_rate_limits ( + rate_key TEXT PRIMARY KEY, + window_started_at INTEGER NOT NULL, + send_count INTEGER NOT NULL CHECK (send_count >= 0), + failed_attempts INTEGER NOT NULL CHECK (failed_attempts >= 0), + blocked_until INTEGER + ); + CREATE TABLE IF NOT EXISTS login_attempts ( + idempotency_key_digest TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + challenge_id TEXT NOT NULL, + outcome_code TEXT NOT NULL CHECK (outcome_code IN ('success', 'failure')), + failure_reason TEXT, + user_id TEXT REFERENCES users(user_id), + session_id TEXT REFERENCES sessions(session_id), + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS account_deletion_challenges ( + deletion_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id), + email TEXT NOT NULL, + code_hmac TEXT NOT NULL, + expires_at INTEGER NOT NULL, + resend_available_at INTEGER NOT NULL, + failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0), + consumed_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS account_deletion_receipts ( + idempotency_key_digest TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + deleted_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS private_content_access_logs ( + log_id TEXT PRIMARY KEY, + actor_ref TEXT NOT NULL, + subject_ref TEXT NOT NULL, + target_ref TEXT NOT NULL, + content_type TEXT NOT NULL CHECK (content_type IN ('image', 'prompt')), + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TRIGGER IF NOT EXISTS private_content_access_logs_no_update + BEFORE UPDATE ON private_content_access_logs + WHEN dada_allow_privacy_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS private_content_access_logs_no_delete + BEFORE DELETE ON private_content_access_logs + WHEN dada_allow_retention_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END; + CREATE TABLE IF NOT EXISTS anonymous_retained_events ( + event_id TEXT PRIMARY KEY, + anonymous_subject_id TEXT NOT NULL, + event_type TEXT NOT NULL, + model_id TEXT, + outcome TEXT NOT NULL, + error_category TEXT, + credit_delta INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TRIGGER IF NOT EXISTS anonymous_retained_events_no_update + BEFORE UPDATE ON anonymous_retained_events + BEGIN SELECT RAISE(ABORT, 'anonymous_retained_events_immutable'); END; + CREATE TRIGGER IF NOT EXISTS anonymous_retained_events_no_delete + BEFORE DELETE ON anonymous_retained_events + WHEN dada_allow_retention_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'anonymous_retained_events_immutable'); END; + INSERT OR IGNORE INTO secure_config_apply_state ( + singleton, applied_revision, allowlist_count, applied_at + ) VALUES (1, 0, 0, 0); + `); + ensureAdminOperationAuditSchema(this.database, this.options.clock()); + ensurePrivateAccessAuditSchema(this.database); + } + + private runImmediate( + operation: RegistrationTransactionEvent["operation"], + action: () => ImmediateResult, + ): T { + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = action(); + this.database.exec("COMMIT"); + this.options.onTransaction?.({ mode: "BEGIN IMMEDIATE", operation, outcome: result.outcome }); + return result.value; + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + this.options.onTransaction?.({ mode: "BEGIN IMMEDIATE", operation, outcome: "rejected" }); + throw error; + } + } + + private authenticatedUserMutationSession(sessionToken: string, csrfToken: string, now: number) { + const session = this.database.prepare(` + SELECT s.session_id, s.user_id, s.csrf_token_digest, u.normalized_email + FROM sessions s JOIN users u ON u.user_id = s.user_id + WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active' + `).get(digest(sessionToken), now) as { + csrf_token_digest: string | null; + normalized_email: string; + session_id: string; + user_id: string; + } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, digest(csrfToken))) { + throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid"); + } + return session; + } + + private authenticatedAdminMutationSession(sessionToken: string, csrfToken: string, now: number) { + const session = this.database.prepare(` + SELECT s.session_id, s.user_id, s.csrf_token_digest + FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN admin_access a ON a.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(digest(sessionToken), now) as { + csrf_token_digest: string | null; + session_id: string; + user_id: string; + } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, digest(csrfToken))) { + throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid"); + } + return session; + } + + private queueOwnedManagedFiles(userId: string, now: number) { + const table = this.database.prepare(` + SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'managed_files' + `).get(); + if (!table) return 0; + const columns = this.database.prepare("PRAGMA table_info(managed_files)").all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "owner_ref")) return 0; + const files = this.database.prepare(` + SELECT file_id, relative_path, byte_size FROM managed_files + WHERE owner_ref = ? AND status = 'committed' + AND file_kind IN ('reference', 'generated', 'export', 'derived') + `).all(userId) as Array<{ byte_size: number; file_id: string; relative_path: string }>; + const purgedAt = new Date(now).toISOString(); + for (const file of files) { + this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(file.file_id); + this.database.prepare(` + UPDATE managed_files SET status = 'purged', purged_at = ?, owner_ref = ? WHERE file_id = ? + `).run(purgedAt, randomUUID(), file.file_id); + this.database.prepare(` + INSERT OR IGNORE INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at + ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?) + `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, purgedAt); + } + return files.length; + } + + private keyedHmac(key: Buffer, value: string) { + return createHmac("sha256", key).update(value, "utf8").digest("hex"); + } + + private inviteHmac(code: string) { + return this.keyedHmac(this.options.invitePepper, code.trim()); + } + + private challengeHmac(challengeId: string, code: string) { + return this.keyedHmac(this.options.challengePepper, `${challengeId}:${code}`); + } + + private adminAllowlistHmac(email: string) { + if (!this.options.adminAllowlistPepper) throw new Error("admin_pepper_not_configured"); + return createHmac("sha256", this.options.adminAllowlistPepper) + .update(email.trim().toLowerCase(), "utf8") + .digest("hex") + .toUpperCase(); + } + + private isAdminAllowlisted(email: string) { + return Boolean(this.options.adminAllowlistPepper) + && this.adminAllowlistHashes.has(this.adminAllowlistHmac(email)); + } + + private sessionToken(sessionId: string) { + return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url"); + } + + private rateKey(email: string, clientKey: string) { + return this.keyedHmac(this.options.challengePepper, `rate:${email}:${clientKey}`); + } + + private readRateLimit(email: string, clientKey: string, now: number): RateLimitRow { + const row = this.database.prepare(` + SELECT window_started_at, send_count, failed_attempts, blocked_until + FROM auth_rate_limits WHERE rate_key = ? + `).get(this.rateKey(email, clientKey)) as RateLimitRow | undefined; + if (!row || now - row.window_started_at >= rateWindowMilliseconds) { + return { blocked_until: null, failed_attempts: 0, send_count: 0, window_started_at: now }; + } + return row; + } + + private recordRateSend(email: string, clientKey: string, now: number) { + const key = this.rateKey(email, clientKey); + const row = this.readRateLimit(email, clientKey, now); + if (row.blocked_until !== null && row.blocked_until > now) { + throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); + } + if (row.send_count >= maximumSendsPerWindow) { + throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); + } + this.database.prepare(` + INSERT INTO auth_rate_limits ( + rate_key, window_started_at, send_count, failed_attempts, blocked_until + ) VALUES (?, ?, 1, 0, NULL) + ON CONFLICT(rate_key) DO UPDATE SET + window_started_at = excluded.window_started_at, + send_count = ?, + failed_attempts = ?, + blocked_until = ? + `).run( + key, + row.window_started_at, + row.send_count + 1, + row.failed_attempts, + row.blocked_until, + ); + } + + private recordRateFailure(email: string, clientKey: string, now: number) { + const key = this.rateKey(email, clientKey); + const row = this.readRateLimit(email, clientKey, now); + const failedAttempts = row.failed_attempts + 1; + const blockedUntil = failedAttempts >= maximumFailedAttempts ? now + rateBlockMilliseconds : row.blocked_until; + this.database.prepare(` + INSERT INTO auth_rate_limits ( + rate_key, window_started_at, send_count, failed_attempts, blocked_until + ) VALUES (?, ?, 0, ?, ?) + ON CONFLICT(rate_key) DO UPDATE SET + window_started_at = excluded.window_started_at, + send_count = ?, + failed_attempts = excluded.failed_attempts, + blocked_until = excluded.blocked_until + `).run(key, row.window_started_at, failedAttempts, blockedUntil, row.send_count); + return failedAttempts; + } + + private assertChallengeSendAllowed( + email: string, + purpose: "register" | "login" | "admin_login", + clientKey: string, + now: number, + ) { + const latest = this.database.prepare(` + SELECT resend_available_at FROM email_challenges + WHERE email = ? AND purpose = ? ORDER BY created_at DESC LIMIT 1 + `).get(email, purpose) as { resend_available_at: number } | undefined; + if (latest && latest.resend_available_at > now) { + throw new RegistrationError("AUTH_RATE_LIMITED", "resend_too_soon"); + } + const rate = this.readRateLimit(email, clientKey, now); + if ((rate.blocked_until !== null && rate.blocked_until > now) || rate.send_count >= maximumSendsPerWindow) { + throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); + } + } + + private insertSession(userId: string, audience: "user" | "admin", now: number) { + const sessionId = randomUUID(); + const sessionToken = this.sessionToken(sessionId); + const sessionExpiresAt = now + sessionLifetimeMilliseconds; + this.database.prepare(` + INSERT INTO sessions ( + session_id, user_id, audience, token_digest, csrf_token_digest, + created_at, expires_at, revoked_at + ) VALUES (?, ?, ?, ?, NULL, ?, ?, NULL) + `).run(sessionId, userId, audience, digest(sessionToken), now, sessionExpiresAt); + return { sessionExpiresAt, sessionId, sessionToken }; + } + + private readAdminLoginResult(userId: string, sessionId: string): AdminLoginCompleteResult { + const row = this.database.prepare(` + SELECT u.user_id, s.expires_at + FROM users u + JOIN admin_access a ON a.user_id = u.user_id + JOIN sessions s ON s.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' + AND a.allowed = 1 AND s.session_id = ? AND s.audience = 'admin' + `).get(userId, sessionId) as { expires_at: number; user_id: string } | undefined; + if (!row) throw new RegistrationError("AUTH_ENTRY_REJECTED", "challenge_invalid"); + return { + admin: { role: "super_admin", status: "active", userId: row.user_id }, + audience: "admin", + sessionExpiresAt: row.expires_at, + sessionToken: this.sessionToken(sessionId), + status: "authenticated", + }; + } + + private readAppliedAllowlistCount() { + const state = this.database.prepare(` + SELECT allowlist_count FROM secure_config_apply_state WHERE singleton = 1 + `).get() as { allowlist_count: number } | undefined; + return state?.allowlist_count ?? 0; + } + + private recordAdminAudit(input: { + actorRef: string; + actorType: "system" | "super_admin"; + afterSummary: Record | null; + beforeSummary: Record | null; + operationType: string; + result: "succeeded" | "failed"; + targetRef: string; + targetType: string; + }, now: number) { + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + randomUUID(), + input.actorType, + input.actorRef, + input.operationType, + input.targetType, + input.targetRef, + input.result, + serializeAuditSummary(input.beforeSummary), + serializeAuditSummary(input.afterSummary), + now, + now + auditRetentionMilliseconds, + ); + } + + private recordAdminLoginRejection(reason: string, now: number) { + this.recordAdminAudit({ + actorRef: "admin_auth", + actorType: "system", + afterSummary: { reason }, + beforeSummary: null, + operationType: "admin_login", + result: "failed", + targetRef: "admin_login", + targetType: "admin_session", + }, now); + } + + private recordAdminLoginFailure( + idempotencyKeyDigest: string, + requestHash: string, + challengeId: string, + reason: RegistrationErrorReason, + now: number, + ) { + const failure = this.recordLoginFailure(idempotencyKeyDigest, requestHash, challengeId, reason, now); + this.recordAdminLoginRejection(reason === "admin_not_allowed" ? "not_allowed" : reason, now); + return failure; + } + + private recordConfigApplyFailure(reason: string, now: number) { + this.database.exec("BEGIN IMMEDIATE"); + try { + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { reason: this.safeConfigFailureReason(reason) }, + beforeSummary: { allowlist_count: this.readAppliedAllowlistCount() }, + operationType: "secure_config_apply", + result: "failed", + targetRef: "candidate_revision", + targetType: "secure_config_revision", + }, now); + this.database.exec("COMMIT"); + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + throw new Error("secure_config_failed_audit_unavailable", { cause: error }); + } + } + + private safeConfigFailureReason(reason: string) { + const allowed = new Set([ + "admin_pepper_not_configured", + "secure_config_revision_invalid", + "admin_allowlist_invalid", + "admin_recovery_invalid", + "hmac_invalid", + "secure_config_revision_out_of_sequence", + "identity_conflict", + ]); + return allowed.has(reason) ? reason : "secure_config_apply_failed"; + } + + private recordLoginFailure( + idempotencyKeyDigest: string, + requestHash: string, + challengeId: string, + reason: RegistrationErrorReason, + now: number, + ): ImmediateResult { + this.database.prepare(` + INSERT INTO login_attempts ( + idempotency_key_digest, request_hash, challenge_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'failure', ?, NULL, NULL, ?) + `).run(idempotencyKeyDigest, requestHash, challengeId, reason, now); + const code = reason === "resend_too_soon" || reason === "too_many_attempts" + ? "AUTH_RATE_LIMITED" + : "AUTH_ENTRY_REJECTED"; + return { outcome: "rejected", value: new RegistrationError(code, reason) }; + } + + private stageIsFull() { + const row = this.database.prepare(` + SELECT COUNT(*) AS count FROM users + WHERE role = 'user' AND counts_toward_stage_limit = 1 + AND status IN ('active', 'suspended') + `).get() as { count: number }; + return row.count >= stageLimit; + } + + private assertStageCapacity() { + if (this.stageIsFull()) throw new RegistrationError("REGISTRATION_REJECTED", "stage_limit_reached"); + } + + private inviteUnavailableReason(invite: InviteRow | undefined, now: number): RegistrationErrorReason | undefined { + if (!invite) return "invite_not_found"; + if (invite.status !== "enabled") return "invite_disabled"; + if (invite.expires_at <= now) return "invite_expired"; + if (invite.used_count >= invite.max_uses) return "invite_exhausted"; + return undefined; + } + + private assertInviteAvailable(invite: InviteRow | undefined, now: number): asserts invite is InviteRow { + const reason = this.inviteUnavailableReason(invite, now); + if (reason) throw new RegistrationError("REGISTRATION_REJECTED", reason); + } + + private recordRejectedAttempt( + idempotencyKeyDigest: string, + requestHash: string, + registrationId: string, + reason: RegistrationErrorReason, + now: number, + ): ImmediateResult { + this.database.prepare(` + INSERT INTO registration_attempts ( + idempotency_key_digest, request_hash, registration_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'failure', ?, NULL, NULL, ?) + `).run(idempotencyKeyDigest, requestHash, registrationId, reason, now); + return { outcome: "rejected", value: new RegistrationError("REGISTRATION_REJECTED", reason) }; + } + + private readCompletedRegistration(userId: string, sessionId: string) { + const row = this.database.prepare(` + SELECT + u.user_id, p.creator_name, p.social_id, c.available_balance, + c.reserved_balance, s.session_id, s.expires_at + FROM users u + JOIN user_profiles p ON p.user_id = u.user_id + JOIN credit_accounts c ON c.user_id = u.user_id + JOIN sessions s ON s.user_id = u.user_id + WHERE u.user_id = ? AND s.session_id = ? + `).get(userId, sessionId) as UserResultRow | undefined; + if (!row) throw new RegistrationError("REGISTRATION_REJECTED", "challenge_invalid"); + return this.result(row); + } + + private loginResult(result: RegistrationCompleteResult): LoginCompleteResult { + return { + audience: "user", + credits: result.credits, + sessionExpiresAt: result.sessionExpiresAt, + sessionToken: result.sessionToken, + status: "authenticated", + user: result.user, + }; + } + + private result(row: UserResultRow): RegistrationCompleteResult { + return { + credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance }, + sessionExpiresAt: row.expires_at, + sessionToken: this.sessionToken(row.session_id), + status: "registered", + user: { + creatorName: row.creator_name, + role: "user", + socialId: row.social_id, + status: "active", + userId: row.user_id, + }, + }; + } +} diff --git a/apps/api/src/resend-adapter.ts b/apps/api/src/resend-adapter.ts new file mode 100644 index 0000000..5ee78db --- /dev/null +++ b/apps/api/src/resend-adapter.ts @@ -0,0 +1,25 @@ +export interface RegistrationCodeMessage { + challengeId: string; + code: string; + email: string; + purpose: "register" | "login" | "admin_login" | "account_delete"; +} + +export interface ResendAdapter { + sendVerificationCode(message: RegistrationCodeMessage): Promise; +} + +export class MockResendAdapter implements ResendAdapter { + readonly calls: RegistrationCodeMessage[] = []; + + async sendVerificationCode(message: RegistrationCodeMessage) { + this.calls.push({ ...message }); + } + + readLatestCode(email: string) { + const normalizedEmail = email.trim().toLowerCase(); + const call = this.calls.findLast((candidate) => candidate.email === normalizedEmail); + if (!call) throw new Error("No mock registration message exists for that email."); + return call.code; + } +} diff --git a/apps/api/src/secure-config.ts b/apps/api/src/secure-config.ts new file mode 100644 index 0000000..4300f76 --- /dev/null +++ b/apps/api/src/secure-config.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; + +import type { SecureConfigCandidate } from "./registration.js"; + +export function readSecureConfigCandidate(path: string): SecureConfigCandidate { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record; + if (parsed.schema_version !== 1 || !Number.isSafeInteger(parsed.secure_config_revision)) { + throw new Error("secure_config_integrity_invalid"); + } + if (!Array.isArray(parsed.admin_allowlist_hashes) || !Array.isArray(parsed.admin_recovery_hashes)) { + throw new Error("secure_config_integrity_invalid"); + } + if (parsed.admin_allowlist_hashes.some((value) => typeof value !== "string") + || parsed.admin_recovery_hashes.some((value) => typeof value !== "string")) { + throw new Error("secure_config_integrity_invalid"); + } + return { + adminAllowlistHashes: parsed.admin_allowlist_hashes as string[], + adminRecoveryHashes: parsed.admin_recovery_hashes as string[], + secureConfigRevision: parsed.secure_config_revision as number, + }; +} diff --git a/apps/api/src/sticker-release-errors.ts b/apps/api/src/sticker-release-errors.ts new file mode 100644 index 0000000..ec5cc4a --- /dev/null +++ b/apps/api/src/sticker-release-errors.ts @@ -0,0 +1,8 @@ +export class StickerReleaseError extends Error { + readonly httpStatus: number; + + constructor(readonly reason: string, httpStatus = 400) { + super(reason); + this.httpStatus = httpStatus; + } +} diff --git a/apps/api/src/sticker-releases.ts b/apps/api/src/sticker-releases.ts new file mode 100644 index 0000000..20a552e --- /dev/null +++ b/apps/api/src/sticker-releases.ts @@ -0,0 +1,602 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { basename, extname } from "node:path"; +import { Readable } from "node:stream"; + +import type BetterSqlite3 from "better-sqlite3"; +import sharp, { type Metadata } from "sharp"; + +import type { StaticStickerCatalogItem } from "@dada/static-sticker-catalog"; + +import { + auditRetentionMilliseconds, + isSafeAuditRef, + isSafeAuditSummaryJson, + serializeAuditSummary, +} from "./audit-policy.js"; +import { ManagedStorage, type StagedManagedFile } from "./managed-storage.js"; +import { StickerReleaseError } from "./sticker-release-errors.js"; +import { classifyCapacity } from "./storage-policy.js"; + +export { StickerReleaseError } from "./sticker-release-errors.js"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; +const stableIdPattern = /^STK([0-9]{4,})$/; +const idempotencyPattern = /^[A-Za-z0-9_-]{32,200}$/; +const sha256Pattern = /^[0-9a-f]{64}$/i; +const maximumOriginalBytes = 20 * 1024 * 1024; +const maximumDimension = 8_192; +const bundledPartCounts = [203, 36, 27, 48, 38, 75, 37, 67, 48, 24, 40, 30, 27, 51, 62, 19, 36, 45, 92, 53, 69, 31, 36, 30, 183] as const; + +type StickerMime = "image/png" | "image/webp"; +type StickerVariant = "original" | "thumbnail"; + +interface StickerItemRow { + enabled: 0 | 1; + height: number; + mime_type: StickerMime; + order_index: number; + original_byte_size: number; + original_file_id: string; + original_filename: string; + original_relative_path: string; + original_sha256: string; + part: number; + release_version: string; + stable_id: string; + thumbnail_byte_size: number; + thumbnail_file_id: string; + thumbnail_relative_path: string; + thumbnail_sha256: string; + width: number; +} + +export interface StickerUploadInput { + actorId: string; + content: Readable; + enabled: boolean; + expectedByteSize: number; + expectedMimeType: StickerMime; + expectedSha256: string; + fileName: string; + idempotencyKey: string; + order: number; + part: number; + stableId: string; +} + +function digest(value: string) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function iso(timestamp: number) { + return new Date(timestamp).toISOString(); +} + +function itemView(row: StickerItemRow): StaticStickerCatalogItem { + const originalReference = `/api/v1/assets/public/${encodeURIComponent(row.release_version)}/${encodeURIComponent(row.stable_id)}`; + return { + enabled: row.enabled === 1, + height: row.height, + mime: row.mime_type, + mime_type: row.mime_type, + order: row.order_index, + original_filename: row.original_filename, + original_reference: originalReference, + origin: "admin_uploaded", + part: row.part, + relative_path: `static-stickers/${row.stable_id}${row.mime_type === "image/png" ? ".png" : ".webp"}`, + resource_version: row.release_version, + sha256: row.original_sha256, + stable_id: row.stable_id, + thumbnail_reference: { + media: "thumbnail", + resource_id: row.stable_id, + resource_version: row.release_version, + url: `${originalReference}?variant=thumbnail`, + }, + width: row.width, + }; +} + +export class StickerReleaseService { + private readonly clock: () => number; + private readonly database: BetterSqlite3.Database; + private readonly storage: ManagedStorage; + + constructor(input: { clock?: () => number; databasePath: string; storage: ManagedStorage }) { + this.clock = input.clock ?? Date.now; + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined); + this.database.pragma("journal_mode = WAL"); + this.database.pragma("foreign_keys = ON"); + this.database.pragma("busy_timeout = 5000"); + this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef); + this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson); + this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0); + this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => ""); + this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0); + this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0); + this.storage = input.storage; + this.migrate(); + } + + close() { + this.database.close(); + } + + async upload(input: StickerUploadInput) { + this.validateUpload(input); + const requestHash = digest(stableJson({ + enabled: input.enabled, + expected_byte_size: input.expectedByteSize, + expected_mime_type: input.expectedMimeType, + expected_sha256: input.expectedSha256.toLowerCase(), + order: input.order, + part: input.part, + stable_id: input.stableId, + })); + const keyDigest = digest(input.idempotencyKey); + const receipt = this.database.prepare(` + SELECT request_hash, release_version FROM sticker_upload_receipts + WHERE actor_id = ? AND idempotency_key_digest = ? + `).get(input.actorId, keyDigest) as { release_version: string; request_hash: string } | undefined; + if (receipt) { + input.content.destroy(); + if (receipt.request_hash !== requestHash) throw new StickerReleaseError("sticker_idempotency_conflict", 409); + return this.uploadResult(receipt.release_version, input.stableId, false); + } + this.assertNewPosition(input.stableId, input.part, input.order); + + const staged: StagedManagedFile[] = []; + try { + const original = await this.storage.stageManagedImage({ + content: input.content, + expectedMimeType: input.expectedMimeType, + expectedSha256: input.expectedSha256, + fileKind: "sticker_original", + fileName: `${input.stableId}${input.expectedMimeType === "image/png" ? ".png" : ".webp"}`, + maximumBytes: maximumOriginalBytes, + operationId: randomUUID(), + ownerRef: input.actorId, + projectedWriteBytes: input.expectedByteSize, + }); + staged.push(original); + if (original.bytes !== input.expectedByteSize) throw new StickerReleaseError("content_size_invalid"); + + let metadata: Metadata; + let thumbnail: Buffer; + const decoder = sharp(readFileSync(original.stagingPath), { failOn: "warning", limitInputPixels: maximumDimension * maximumDimension }); + try { + metadata = await decoder.metadata(); + if (metadata.format !== (input.expectedMimeType === "image/png" ? "png" : "webp") + || !metadata.width || !metadata.height || metadata.width > maximumDimension || metadata.height > maximumDimension) { + throw new Error("content_decode_invalid"); + } + thumbnail = await decoder + .rotate() + .resize({ fit: "inside", height: 256, width: 256, withoutEnlargement: true }) + .png({ adaptiveFiltering: true, compressionLevel: 9 }) + .toBuffer(); + } catch { + throw new StickerReleaseError("content_decode_invalid"); + } finally { + decoder.destroy(); + } + + const thumbnailStaged = await this.storage.stageManagedImage({ + content: Readable.from(thumbnail), + expectedMimeType: "image/png", + fileKind: "sticker_thumbnail", + fileName: `${input.stableId}-thumbnail.png`, + maximumBytes: maximumOriginalBytes, + operationId: randomUUID(), + ownerRef: input.actorId, + projectedWriteBytes: thumbnail.byteLength, + }); + staged.push(thumbnailStaged); + const releaseVersion = this.immediate(() => this.commitUpload({ + ...input, + height: metadata.height!, + keyDigest, + original, + requestHash, + thumbnail: thumbnailStaged, + width: metadata.width!, + })); + return this.uploadResult(releaseVersion, input.stableId, true); + } catch (error) { + for (const file of staged) this.storage.abandonStagedFile(file); + if (!(error instanceof StickerReleaseError) && error instanceof Error + && new Set(["content_hash_invalid", "content_mime_invalid", "content_size_invalid", "file_name_invalid"]).has(error.message)) { + throw new StickerReleaseError("sticker_upload_invalid"); + } + throw error; + } + } + + update(input: { actorId: string; enabled?: boolean; order?: number; part?: number; stableId: string }) { + const current = this.currentVersion(); + if (!current) throw new StickerReleaseError("sticker_not_found", 404); + const existing = this.readItem(current, input.stableId); + if (!existing) throw new StickerReleaseError("sticker_not_found", 404); + const part = input.part ?? existing.part; + const order = input.order ?? existing.order_index; + this.validatePosition(input.stableId, part, order); + const releaseVersion = this.immediate(() => { + const version = this.nextReleaseVersion(); + this.copyRelease(current, version); + const conflict = this.database.prepare(` + SELECT stable_id FROM sticker_release_items + WHERE release_version = ? AND part = ? AND order_index = ? AND stable_id <> ? + `).get(version, part, order, input.stableId); + if (conflict) throw new StickerReleaseError("sticker_order_conflict", 409); + this.database.prepare(` + UPDATE sticker_release_items SET enabled = ?, part = ?, order_index = ? + WHERE release_version = ? AND stable_id = ? + `).run((input.enabled ?? existing.enabled === 1) ? 1 : 0, part, order, version, input.stableId); + this.finalizeRelease(version, current, input.actorId); + this.insertReleaseAudit({ + actorId: input.actorId, + afterSummary: { enabled: input.enabled ?? existing.enabled === 1, order, part, stable_id: input.stableId }, + beforeSummary: { enabled: existing.enabled === 1, order: existing.order_index, part: existing.part, stable_id: input.stableId }, + operationType: "sticker_release_update", + releaseVersion: version, + }); + return version; + }); + return { item: itemView(this.readItem(releaseVersion, input.stableId)!), release_version: releaseVersion }; + } + + listPublic(releaseVersion = this.currentVersion()) { + if (!releaseVersion) return { count: 0, items: [], release_version: null }; + const exists = this.database.prepare("SELECT 1 FROM sticker_releases WHERE release_version = ?").get(releaseVersion); + if (!exists) return { count: 0, items: [], release_version: null }; + const items = (this.database.prepare(` + SELECT * FROM sticker_release_items WHERE release_version = ? AND enabled = 1 + ORDER BY part, order_index, stable_id + `).all(releaseVersion) as StickerItemRow[]).map(itemView); + return { count: items.length, items, release_version: releaseVersion }; + } + + adminView() { + const releaseVersion = this.currentVersion(); + const items = releaseVersion + ? (this.database.prepare("SELECT * FROM sticker_release_items WHERE release_version = ? ORDER BY part, order_index, stable_id").all(releaseVersion) as StickerItemRow[]) + : []; + return { + count: items.length, + items: items.map((row) => ({ + ...itemView(row), + file_state: "committed" as const, + original_byte_size: row.original_byte_size, + thumbnail_byte_size: row.thumbnail_byte_size, + })), + release_version: releaseVersion, + storage: this.storage.getState(), + }; + } + + readPublicAsset(releaseVersion: string, stableId: string, variant: StickerVariant) { + const row = this.readItem(releaseVersion, stableId); + if (!row || row.enabled !== 1) return undefined; + const fileId = variant === "thumbnail" ? row.thumbnail_file_id : row.original_file_id; + const path = this.storage.resolveManagedFile(fileId); + if (!path) return undefined; + return { + bytes: readFileSync(path), + mimeType: variant === "thumbnail" ? "image/png" as const : row.mime_type, + sha256: variant === "thumbnail" ? row.thumbnail_sha256 : row.original_sha256, + }; + } + + inspectCounts() { + const count = (table: string) => (this.database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count; + return { items: count("sticker_release_items"), releases: count("sticker_releases"), upload_receipts: count("sticker_upload_receipts") }; + } + + private uploadResult(releaseVersion: string, stableId: string, created: boolean) { + const row = this.readItem(releaseVersion, stableId); + if (!row) throw new StickerReleaseError("sticker_not_found", 404); + return { + created, + item: itemView(row), + original: { byte_size: row.original_byte_size, file_id: row.original_file_id, sha256: row.original_sha256 }, + release_version: releaseVersion, + thumbnail: { byte_size: row.thumbnail_byte_size, file_id: row.thumbnail_file_id, sha256: row.thumbnail_sha256 }, + }; + } + + private commitUpload(input: StickerUploadInput & { + height: number; + keyDigest: string; + original: StagedManagedFile; + requestHash: string; + thumbnail: StagedManagedFile; + width: number; + }) { + this.assertNewPosition(input.stableId, input.part, input.order); + const previous = this.currentVersion(); + const releaseVersion = this.nextReleaseVersion(); + if (previous) this.copyRelease(previous, releaseVersion); + for (const file of [input.original, input.thumbnail]) { + this.storage.moveStagedFile(file); + this.database.prepare(` + INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'committed', ?) + `).run(file.fileId, file.fileKind, file.ownerRef, file.relativePath, file.bytes, file.mimeType, file.sha256, iso(this.clock())); + } + this.database.prepare(` + INSERT INTO sticker_release_items ( + release_version, stable_id, part, order_index, original_filename, original_relative_path, + width, height, mime_type, original_sha256, original_file_id, original_byte_size, + thumbnail_file_id, thumbnail_relative_path, thumbnail_sha256, thumbnail_byte_size, enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + releaseVersion, input.stableId, input.part, input.order, input.fileName, input.original.relativePath, + input.width, input.height, input.expectedMimeType, input.original.sha256, input.original.fileId, input.original.bytes, + input.thumbnail.fileId, input.thumbnail.relativePath, input.thumbnail.sha256, input.thumbnail.bytes, input.enabled ? 1 : 0, + ); + this.database.prepare(` + INSERT OR IGNORE INTO sticker_managed_file_history ( + managed_file_id, stable_id, resource_version, file_kind, created_at + ) VALUES (?, ?, ?, 'original', ?), (?, ?, ?, 'thumbnail', ?) + `).run( + input.original.fileId, input.stableId, releaseVersion, this.clock(), + input.thumbnail.fileId, input.stableId, releaseVersion, this.clock(), + ); + this.consumeStagedStorage([input.original, input.thumbnail]); + this.database.prepare(` + INSERT INTO sticker_upload_receipts (actor_id, idempotency_key_digest, request_hash, release_version, stable_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(input.actorId, input.keyDigest, input.requestHash, releaseVersion, input.stableId, iso(this.clock())); + this.finalizeRelease(releaseVersion, previous, input.actorId); + this.insertReleaseAudit({ + actorId: input.actorId, + afterSummary: { enabled: input.enabled, order: input.order, part: input.part, stable_id: input.stableId }, + beforeSummary: previous ? { release_version: previous } : null, + operationType: "sticker_release_publish", + releaseVersion, + }); + return releaseVersion; + } + + private insertReleaseAudit(input: { + actorId: string; + afterSummary: Record; + beforeSummary: Record | null; + operationType: "sticker_release_publish" | "sticker_release_update"; + releaseVersion: string; + }) { + const occurredAt = this.clock(); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'super_admin', ?, ?, 'sticker_release', ?, 'succeeded', ?, ?, ?, ?) + `).run( + randomUUID(), input.actorId, input.operationType, input.releaseVersion, + serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), + occurredAt, occurredAt + auditRetentionMilliseconds, + ); + } + + private finalizeRelease(releaseVersion: string, previous: string | null, actorId: string) { + const rows = this.database.prepare(` + SELECT stable_id, part, order_index, original_sha256, thumbnail_sha256, enabled + FROM sticker_release_items WHERE release_version = ? ORDER BY stable_id + `).all(releaseVersion); + const manifestSha256 = digest(stableJson(rows)); + this.database.prepare(` + INSERT INTO sticker_releases (release_version, previous_release_version, manifest_sha256, published_at, published_by) + VALUES (?, ?, ?, ?, ?) + `).run(releaseVersion, previous, manifestSha256, iso(this.clock()), actorId); + this.database.prepare(` + INSERT INTO current_sticker_release (singleton, release_version) VALUES (1, ?) + ON CONFLICT(singleton) DO UPDATE SET release_version = excluded.release_version + `).run(releaseVersion); + const files = this.database.prepare(` + SELECT original_file_id AS file_id FROM sticker_release_items WHERE release_version = ? + UNION SELECT thumbnail_file_id AS file_id FROM sticker_release_items WHERE release_version = ? + `).all(releaseVersion, releaseVersion) as Array<{ file_id: string }>; + for (const file of files) { + this.database.prepare(` + INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) + VALUES (?, ?, 'release', ?) + `).run(`release:${releaseVersion}:${file.file_id}`, file.file_id, iso(this.clock())); + } + } + + private copyRelease(from: string, to: string) { + this.database.prepare(` + INSERT INTO sticker_release_items ( + release_version, stable_id, part, order_index, original_filename, original_relative_path, + width, height, mime_type, original_sha256, original_file_id, original_byte_size, + thumbnail_file_id, thumbnail_relative_path, thumbnail_sha256, thumbnail_byte_size, enabled + ) + SELECT ?, stable_id, part, order_index, original_filename, original_relative_path, + width, height, mime_type, original_sha256, original_file_id, original_byte_size, + thumbnail_file_id, thumbnail_relative_path, thumbnail_sha256, thumbnail_byte_size, enabled + FROM sticker_release_items WHERE release_version = ? + `).run(to, from); + } + + private consumeStagedStorage(files: StagedManagedFile[]) { + const timestamp = iso(this.clock()); + for (const file of files) { + this.database.prepare(` + UPDATE storage_reservations SET status = 'consumed', resolved_at = ? + WHERE operation_id = ? AND status = 'active' + `).run(timestamp, file.operationId); + } + const total = files.reduce((sum, file) => sum + file.bytes, 0); + const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1").get() as { managed_content_bytes: number }; + const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'").get() as { bytes: number }; + const nextBytes = state.managed_content_bytes + total; + const classification = classifyCapacity(nextBytes, active.bytes); + this.database.prepare(` + UPDATE local_backend_storage_state + SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1 + WHERE singleton = 1 + `).run(nextBytes, classification.capacity_notice_level, classification.storage_status, timestamp); + } + + private currentVersion() { + return (this.database.prepare("SELECT release_version FROM current_sticker_release WHERE singleton = 1").get() as { release_version: string } | undefined)?.release_version ?? null; + } + + private nextReleaseVersion() { + const date = new Date(this.clock()).toISOString().slice(0, 10).replaceAll("-", ""); + const row = this.database.prepare("SELECT next_sequence FROM sticker_release_sequences WHERE release_date = ?").get(date) as { next_sequence: number } | undefined; + const sequence = row?.next_sequence ?? 1; + this.database.prepare(` + INSERT INTO sticker_release_sequences (release_date, next_sequence) VALUES (?, ?) + ON CONFLICT(release_date) DO UPDATE SET next_sequence = excluded.next_sequence + `).run(date, sequence + 1); + return `asset-${date}.${sequence}`; + } + + private readItem(releaseVersion: string, stableId: string) { + return this.database.prepare("SELECT * FROM sticker_release_items WHERE release_version = ? AND stable_id = ?") + .get(releaseVersion, stableId) as StickerItemRow | undefined; + } + + private assertNewPosition(stableId: string, part: number, order: number) { + this.validatePosition(stableId, part, order); + const current = this.currentVersion(); + if (!current) return; + if (this.readItem(current, stableId)) throw new StickerReleaseError("sticker_stable_id_conflict", 409); + const conflict = this.database.prepare(` + SELECT stable_id FROM sticker_release_items WHERE release_version = ? AND part = ? AND order_index = ? + `).get(current, part, order); + if (conflict) throw new StickerReleaseError("sticker_order_conflict", 409); + } + + private validatePosition(stableId: string, part: number, order: number) { + const matched = stableId.match(stableIdPattern); + const numericId = matched ? Number(matched[1]) : Number.NaN; + if (!matched || !Number.isSafeInteger(numericId) || numericId <= 1_407) throw new StickerReleaseError("sticker_stable_id_invalid"); + if (!Number.isSafeInteger(part) || part < 1 || part > bundledPartCounts.length + || !Number.isSafeInteger(order) || order <= bundledPartCounts[part - 1]!) { + throw new StickerReleaseError("sticker_part_order_invalid"); + } + } + + private validateUpload(input: StickerUploadInput) { + this.validatePosition(input.stableId, input.part, input.order); + if (!/^[0-9a-f-]{36}$/i.test(input.actorId) || !idempotencyPattern.test(input.idempotencyKey) + || !sha256Pattern.test(input.expectedSha256) || !Number.isSafeInteger(input.expectedByteSize) + || input.expectedByteSize <= 0 || input.expectedByteSize > maximumOriginalBytes + || !new Set(["image/png", "image/webp"]).has(input.expectedMimeType)) { + throw new StickerReleaseError("sticker_upload_invalid"); + } + const expectedExtension = input.expectedMimeType === "image/png" ? ".png" : ".webp"; + if (input.fileName.length > 255 || basename(input.fileName) !== input.fileName || /[\u0000-\u001f]/.test(input.fileName) + || extname(input.fileName).toLowerCase() !== expectedExtension) throw new StickerReleaseError("sticker_upload_invalid"); + } + + private immediate(action: () => T) { + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = action(); + this.database.exec("COMMIT"); + return result; + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + throw error; + } + } + + private migrate() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS sticker_release_sequences ( + release_date TEXT PRIMARY KEY, + next_sequence INTEGER NOT NULL CHECK (next_sequence >= 1) + ); + CREATE TABLE IF NOT EXISTS sticker_releases ( + release_version TEXT PRIMARY KEY, + previous_release_version TEXT, + manifest_sha256 TEXT NOT NULL CHECK (length(manifest_sha256) = 64), + published_at TEXT NOT NULL, + published_by TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sticker_release_items ( + release_version TEXT NOT NULL, + stable_id TEXT NOT NULL, + part INTEGER NOT NULL CHECK (part BETWEEN 1 AND 25), + order_index INTEGER NOT NULL CHECK (order_index > 0), + original_filename TEXT NOT NULL, + original_relative_path TEXT NOT NULL, + width INTEGER NOT NULL CHECK (width > 0), + height INTEGER NOT NULL CHECK (height > 0), + mime_type TEXT NOT NULL CHECK (mime_type IN ('image/png', 'image/webp')), + original_sha256 TEXT NOT NULL CHECK (length(original_sha256) = 64), + original_file_id TEXT NOT NULL REFERENCES managed_files(file_id), + original_byte_size INTEGER NOT NULL CHECK (original_byte_size > 0), + thumbnail_file_id TEXT NOT NULL REFERENCES managed_files(file_id), + thumbnail_relative_path TEXT NOT NULL, + thumbnail_sha256 TEXT NOT NULL CHECK (length(thumbnail_sha256) = 64), + thumbnail_byte_size INTEGER NOT NULL CHECK (thumbnail_byte_size > 0), + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + PRIMARY KEY (release_version, stable_id), + UNIQUE (release_version, part, order_index) + ); + CREATE TABLE IF NOT EXISTS current_sticker_release ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + release_version TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sticker_upload_receipts ( + actor_id TEXT NOT NULL, + idempotency_key_digest TEXT NOT NULL CHECK (length(idempotency_key_digest) = 64), + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + release_version TEXT NOT NULL, + stable_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (actor_id, idempotency_key_digest) + ); + CREATE TABLE IF NOT EXISTS sticker_managed_file_history ( + managed_file_id TEXT NOT NULL, + stable_id TEXT NOT NULL, + resource_version TEXT NOT NULL, + file_kind TEXT NOT NULL CHECK (file_kind IN ('original', 'thumbnail')), + created_at INTEGER NOT NULL, + PRIMARY KEY (managed_file_id, file_kind), + FOREIGN KEY (managed_file_id) REFERENCES managed_files(file_id) + ); + CREATE TRIGGER IF NOT EXISTS sticker_releases_no_update + BEFORE UPDATE ON sticker_releases BEGIN SELECT RAISE(ABORT, 'sticker_releases_immutable'); END; + CREATE TRIGGER IF NOT EXISTS sticker_releases_no_delete + BEFORE DELETE ON sticker_releases BEGIN SELECT RAISE(ABORT, 'sticker_releases_immutable'); END; + CREATE TRIGGER IF NOT EXISTS sticker_release_items_no_update + BEFORE UPDATE ON sticker_release_items + WHEN EXISTS (SELECT 1 FROM sticker_releases WHERE release_version = OLD.release_version) + BEGIN SELECT RAISE(ABORT, 'sticker_release_items_immutable'); END; + CREATE TRIGGER IF NOT EXISTS sticker_release_items_no_delete + BEFORE DELETE ON sticker_release_items + WHEN EXISTS (SELECT 1 FROM sticker_releases WHERE release_version = OLD.release_version) + BEGIN SELECT RAISE(ABORT, 'sticker_release_items_immutable'); END; + `); + this.database.exec(` + INSERT OR IGNORE INTO sticker_managed_file_history ( + managed_file_id, stable_id, resource_version, file_kind, created_at + ) + SELECT original_file_id, stable_id, release_version, 'original', strftime('%s', 'now') * 1000 + FROM sticker_release_items; + INSERT OR IGNORE INTO sticker_managed_file_history ( + managed_file_id, stable_id, resource_version, file_kind, created_at + ) + SELECT thumbnail_file_id, stable_id, release_version, 'thumbnail', strftime('%s', 'now') * 1000 + FROM sticker_release_items; + `); + } +} diff --git a/apps/api/src/supervisor-channel.ts b/apps/api/src/supervisor-channel.ts index 45f41f4..c2339bb 100644 --- a/apps/api/src/supervisor-channel.ts +++ b/apps/api/src/supervisor-channel.ts @@ -1,6 +1,8 @@ import { createConnection } from "node:net"; -const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap"] as const; +import { RealAmapAdapter } from "./amap-adapter.js"; + +const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const; export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) { const chunks: Buffer[] = []; @@ -26,8 +28,15 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) { const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0); - for (const name of API_CREDENTIALS) credentials[name] = ""; - if (!configured) throw new Error("API credential client initialization failed."); + try { + if (!configured) throw new Error("API credential client initialization failed."); + return { + adminAllowlistPepper: Buffer.from(credentials["Dada/P0A/admin/pepper"], "utf8"), + amap: new RealAmapAdapter(credentials["Dada/P0A/api/amap"]), + }; + } finally { + for (const name of API_CREDENTIALS) credentials[name] = ""; + } } export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise) { diff --git a/apps/web/package.json b/apps/web/package.json index cb61df8..e1d7397 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,10 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@dada/asset-renderer": "workspace:*", + "@dada/static-sticker-catalog": "workspace:*", "@dada/shared-contracts": "workspace:*", + "@dada/template-registry": "workspace:*", "@vibrant/core": "4.0.4", "@vibrant/quantizer-mmcq": "4.0.4", "fabric": "7.4.0", diff --git a/apps/web/src/account-settings.css b/apps/web/src/account-settings.css new file mode 100644 index 0000000..bcc4faf --- /dev/null +++ b/apps/web/src/account-settings.css @@ -0,0 +1,364 @@ +.settings-page, +.settings-loading { + min-height: 100vh; + color: #111111; + background: #f6f6f4; +} + +.settings-loading { + display: grid; + place-content: center; + gap: 16px; + text-align: center; +} + +.settings-loading button { + min-height: 44px; + border: 1px solid #111111; + background: #ffffff; +} + +.settings-header { + display: flex; + min-height: 72px; + align-items: center; + justify-content: space-between; + padding: 0 5vw; + border-bottom: 1px solid #c8c8c3; + background: #ffffff; +} + +.settings-brand { + color: #111111; + font-family: Arial Black, "Segoe UI", sans-serif; + font-size: 24px; + font-weight: 900; + text-decoration: none; +} + +.settings-header nav { + display: flex; + gap: 28px; +} + +.settings-header nav a, +.settings-header nav span { + color: #111111; + text-decoration: none; +} + +.settings-header nav span { + font-weight: 700; +} + +.settings-title { + padding: 64px max(5vw, calc((100vw - 1120px) / 2)) 38px; + border-bottom: 1px solid #c8c8c3; +} + +.settings-title p, +.settings-dialog header p { + margin: 0 0 12px; + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.settings-title h1 { + margin: 0; + font-size: 40px; + line-height: 1.2; +} + +.settings-section, +.settings-danger { + display: grid; + grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.4fr); + gap: 64px; + max-width: 1120px; + margin: 0 auto; + padding: 46px 0; + border-bottom: 1px solid #c8c8c3; +} + +.settings-section-heading h2, +.settings-danger h2 { + margin: 0 0 8px; + font-size: 21px; +} + +.settings-section-heading p, +.settings-danger p, +.settings-detail { + margin: 0; + color: #585852; + line-height: 1.65; +} + +.settings-form { + display: grid; + gap: 10px; + max-width: 560px; +} + +.settings-form input, +.settings-dialog input { + width: 100%; + min-height: 46px; + padding: 10px 12px; + border: 1px solid #989891; + border-radius: 2px; + background: #ffffff; +} + +.settings-form label, +.settings-dialog label { + margin-top: 10px; + font-size: 13px; + font-weight: 700; +} + +.settings-primary, +.settings-code-button, +.settings-delete-confirm, +.settings-danger-button { + min-height: 44px; + border-radius: 2px; + font-weight: 700; +} + +.settings-primary { + width: 160px; + margin-top: 12px; + border: 1px solid #111111; + color: #111111; + background: #f2f500; +} + +.settings-primary:disabled, +.settings-code-button:disabled, +.settings-delete-confirm:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.settings-definition { + margin: 0; +} + +.settings-definition div { + display: grid; + grid-template-columns: 140px 1fr; + gap: 18px; + padding: 15px 0; + border-bottom: 1px solid #deded9; +} + +.settings-definition dt { + color: #686862; +} + +.settings-definition dd { + margin: 0; + overflow-wrap: anywhere; + font-weight: 600; +} + +.settings-local { + align-items: start; +} + +.settings-local > :not(.settings-section-heading) { + grid-column: 2; +} + +.settings-capacity { + display: grid; + gap: 14px; +} + +.settings-capacity > div:first-child { + display: flex; + justify-content: space-between; + gap: 24px; +} + +.settings-capacity-track { + height: 12px; + overflow: hidden; + border: 1px solid #111111; + background: #ffffff; +} + +.settings-capacity-track span { + display: block; + height: 100%; + background: #111111; +} + +.settings-capacity[data-status="warning"] .settings-capacity-track span, +.settings-capacity[data-status="critical"] .settings-capacity-track span { + background: #e2b500; +} + +.settings-capacity[data-status="full"] .settings-capacity-track span, +.settings-capacity[data-status="unavailable"] .settings-capacity-track span { + background: #c7352b; +} + +.settings-fixed-notice { + margin: 24px 0 0; + padding: 20px; + border-left: 5px solid #111111; + background: #f2f500; + font-weight: 800; + line-height: 1.5; +} + +.settings-detail { + margin-top: 16px; +} + +.settings-danger { + align-items: center; + border-bottom: 0; + padding-bottom: 80px; +} + +.settings-danger-button { + width: 160px; + justify-self: start; + border: 1px solid #a6251d; + color: #ffffff; + background: #b82f26; +} + +.settings-error { + margin: 8px 0 0; + color: #a6251d; + font-weight: 700; +} + +.settings-saved { + margin: 8px 0 0; + color: #256227; + font-weight: 700; +} + +.settings-dialog-backdrop { + position: fixed; + z-index: 20; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgb(17 17 17 / 62%); +} + +.settings-dialog { + width: min(640px, 100%); + max-height: calc(100vh - 40px); + overflow-y: auto; + border: 1px solid #111111; + border-radius: 4px; + background: #ffffff; + box-shadow: 10px 10px 0 #111111; +} + +.settings-dialog header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 24px 28px; + border-bottom: 1px solid #c8c8c3; +} + +.settings-dialog h2 { + margin: 0; + font-size: 24px; +} + +.settings-dialog header button { + width: 40px; + height: 40px; + border: 0; + background: transparent; + font-size: 28px; +} + +.settings-dialog-body { + padding: 26px 28px 30px; + line-height: 1.6; +} + +.settings-dialog-warning { + padding: 16px; + border-left: 5px solid #b82f26; + background: #fff2f0; + font-weight: 700; +} + +.settings-code-button { + width: 180px; + margin: 10px 0; + border: 1px solid #111111; + background: #ffffff; +} + +.settings-dialog form { + display: grid; + gap: 8px; +} + +.settings-delete-confirm { + width: 180px; + margin-top: 16px; + border: 1px solid #8e2019; + color: #ffffff; + background: #b82f26; +} + +@media (max-width: 760px) { + .settings-header { + padding: 0 18px; + } + + .settings-header nav { + gap: 14px; + font-size: 13px; + } + + .settings-title { + padding: 40px 20px 28px; + } + + .settings-title h1 { + font-size: 30px; + } + + .settings-section, + .settings-danger { + grid-template-columns: 1fr; + gap: 24px; + margin: 0 20px; + padding: 34px 0; + } + + .settings-local > :not(.settings-section-heading) { + grid-column: 1; + } + + .settings-definition div { + grid-template-columns: 88px 1fr; + } + + .settings-capacity > div:first-child { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + .settings-dialog { + box-shadow: 5px 5px 0 #111111; + } +} diff --git a/apps/web/src/account-settings.tsx b/apps/web/src/account-settings.tsx new file mode 100644 index 0000000..916181f --- /dev/null +++ b/apps/web/src/account-settings.tsx @@ -0,0 +1,273 @@ +import { type FormEvent, useEffect, useId, useState } from "react"; + +import "./account-settings.css"; + +interface SettingsPayload { + account: { email: string; status: "active" }; + csrf_token: string; + local_data: { + backup_enabled: false; + capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable"; + hard_limit_bytes: number; + location: "configured_local_data_root"; + managed_content_bytes: number; + migration_supported: false; + }; + profile: { creator_name: string; social_id: string }; +} + +function formatBytes(bytes: number) { + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`; + if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + return `${Math.max(0, bytes)} B`; +} + +function capacityLabel(status: SettingsPayload["local_data"]["capacity_status"]) { + if (status === "full") return "本机容量已满"; + if (status === "unavailable") return "本机数据暂不可写"; + if (status === "critical") return "本机容量接近上限"; + if (status === "warning") return "本机容量需要关注"; + return "本机容量正常"; +} + +export function AccountSettingsPage() { + const creatorNameId = useId(); + const socialId = useId(); + const confirmationId = useId(); + const deletionCodeId = useId(); + const [settings, setSettings] = useState(); + const [creatorName, setCreatorName] = useState(""); + const [socialHandle, setSocialHandle] = useState(""); + const [loadingError, setLoadingError] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(false); + const [saved, setSaved] = useState(false); + const [deletionOpen, setDeletionOpen] = useState(false); + const [deletionId, setDeletionId] = useState(); + const [deletionCode, setDeletionCode] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [sendingCode, setSendingCode] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deletionError, setDeletionError] = useState(false); + + useEffect(() => { + let active = true; + void fetch("/api/v1/account/settings", { credentials: "same-origin" }) + .then(async (response) => { + if (!response.ok) throw new Error("settings_load_failed"); + return response.json() as Promise; + }) + .then((payload) => { + if (!active) return; + setSettings(payload); + setCreatorName(payload.profile.creator_name); + setSocialHandle(payload.profile.social_id); + }) + .catch(() => { + if (active) setLoadingError(true); + }); + return () => { active = false; }; + }, []); + + async function saveProfile(event: FormEvent) { + event.preventDefault(); + if (!settings || saving || !creatorName.trim() || !socialHandle.trim()) return; + setSaving(true); + setSaveError(false); + setSaved(false); + try { + const response = await fetch("/api/v1/account/settings/profile", { + body: JSON.stringify({ creator_name: creatorName, social_id: socialHandle }), + credentials: "same-origin", + headers: { "Content-Type": "application/json", "X-CSRF-Token": settings.csrf_token }, + method: "PUT", + }); + if (!response.ok) throw new Error("profile_save_failed"); + setSaved(true); + } catch { + setSaveError(true); + } finally { + setSaving(false); + } + } + + async function sendDeletionCode() { + if (!settings || sendingCode || deleting) return; + setSendingCode(true); + setDeletionError(false); + try { + const response = await fetch("/api/v1/account/deletion/send", { + credentials: "same-origin", + headers: { "X-CSRF-Token": settings.csrf_token }, + method: "POST", + }); + const body = await response.json() as { deletion_id?: string }; + if (!response.ok || !body.deletion_id) throw new Error("deletion_code_failed"); + setDeletionId(body.deletion_id); + } catch { + setDeletionError(true); + } finally { + setSendingCode(false); + } + } + + async function completeDeletion(event: FormEvent) { + event.preventDefault(); + if (!settings || !deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting) return; + setDeleting(true); + setDeletionError(false); + try { + const response = await fetch("/api/v1/account/deletion/complete", { + body: JSON.stringify({ confirmation, deletion_id: deletionId, verification_code: deletionCode }), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""), + "X-CSRF-Token": settings.csrf_token, + }, + method: "POST", + }); + if (!response.ok) throw new Error("account_deletion_failed"); + window.location.assign("/"); + } catch { + setDeletionError(true); + setDeleting(false); + } + } + + function closeDeletion() { + if (deleting) return; + setDeletionOpen(false); + setDeletionId(undefined); + setDeletionCode(""); + setConfirmation(""); + setDeletionError(false); + } + + if (!settings && !loadingError) { + return
正在读取设置
; + } + + if (!settings) { + return ( +
+

设置暂时无法读取。

+ +
+ ); + } + + const capacityPercent = Math.min(100, Math.round((settings.local_data.managed_content_bytes / settings.local_data.hard_limit_bytes) * 100)); + + return ( +
+
+ DADA + +
+ +
+

ACCOUNT / LOCAL DATA

+

设置与本机数据

+
+ +
+
+

个人资料

+

用于生成内容中的署名信息。

+
+
+ + setCreatorName(event.target.value)} value={creatorName} /> + + setSocialHandle(event.target.value)} value={socialHandle} /> + {saveError ?

保存失败,当前输入已保留,请重试。

: null} + {saved ?

资料已保存。

: null} + +
+
+ +
+
+

账号

+

邮箱仅用于验证码认证。

+
+
+
邮箱
{settings.account.email}
+
状态
正常
+
登录方式
邮箱验证码
+
+
+ +
+
+

本机数据

+

逻辑位置:Dada 配置的本机数据目录

+
+
+
+ {capacityLabel(settings.local_data.capacity_status)} + {formatBytes(settings.local_data.managed_content_bytes)} / {formatBytes(settings.local_data.hard_limit_bytes)} +
+
+ +
+
+

测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。

+

数据依赖当前 Windows 用户登录和文件系统权限,不提供 Dada 应用层加密或云备份。机器损坏、重装或删除本机数据目录后无法恢复。

+
+ +
+
+

注销账号

+

注销会立即删除账号资料、未使用点数和全部作品,并使所有会话失效。

+
+ +
+ + {deletionOpen ? ( +
+
{ + if (event.key === "Escape" && !deleting) closeDeletion(); + }} + role="dialog" + > +
+
+

DANGER ZONE

+

确认注销账号

+
+ +
+
+

账号、资料、项目、图片、坐标、成品和未使用点数将立即删除且不可恢复。

+

匿名生成与点数事件最多保留 180 天,只保留随机主体、时间、模型、结果、错误类别和点数变化;不会保留邮箱或作品内容。

+ +
+ + setDeletionCode(event.target.value.replace(/\D/g, ""))} value={deletionCode} /> + + setConfirmation(event.target.value)} placeholder="输入:注销账号" value={confirmation} /> + {deletionError ?

注销请求未完成,请核对验证码后重试。

: null} + +
+
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/admin-assets.css b/apps/web/src/admin-assets.css new file mode 100644 index 0000000..99d4fb7 --- /dev/null +++ b/apps/web/src/admin-assets.css @@ -0,0 +1,71 @@ +.admin-assets-page { min-height: 100vh; color: #111111; background: #f6f6f4; } +.admin-assets-page > main { width: min(1360px, calc(100% - 64px)); margin: 0 auto; padding: 36px 0 80px; } +.admin-assets-heading { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding-bottom: 18px; border-bottom: 1px solid #999993; } +.admin-assets-heading p { margin: 0 0 4px; font: 700 11px Consolas, monospace; } +.admin-assets-heading h1 { margin: 0; font-size: 34px; } +.admin-assets-heading > strong { font: 700 13px Consolas, monospace; } +.admin-assets-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 22px 0; border-block: 1px solid #8c8c86; background: #ffffff; } +.admin-assets-summary > span { display: grid; min-width: 0; gap: 6px; padding: 17px 18px; border-right: 1px solid #c1c1ba; color: #65655f; font-size: 12px; } +.admin-assets-summary > span:last-child { border-right: 0; } +.admin-assets-summary strong { color: #111111; font-size: 15px; overflow-wrap: anywhere; } +.admin-assets-summary .is-active { color: #1f6639; } +.admin-assets-summary .is-full, +.admin-assets-summary .is-unavailable { color: #9b2c23; } +.admin-assets-upload, +.admin-assets-list { margin-top: 22px; border-block: 1px solid #8c8c86; background: #ffffff; } +.admin-assets-upload > header, +.admin-assets-list > header { display: flex; align-items: center; justify-content: space-between; min-height: 54px; padding: 0 16px; border-bottom: 1px solid #c1c1ba; background: #e7e7e2; } +.admin-assets-upload h2, +.admin-assets-list h2 { margin: 0; font-size: 16px; } +.admin-assets-upload header span, +.admin-assets-list header span { font: 700 11px Consolas, monospace; } +.admin-assets-form { display: grid; grid-template-columns: minmax(230px, 2fr) minmax(130px, 1fr) 84px 92px 130px auto; align-items: end; gap: 12px; padding: 18px 16px; } +.admin-assets-form label { display: grid; gap: 6px; min-width: 0; color: #4c4c47; font-size: 11px; font-weight: 800; } +.admin-assets-form input { width: 100%; min-height: 40px; padding: 7px 9px; border: 1px solid #777770; border-radius: 0; background: #ffffff; } +.admin-assets-form input[type="file"] { padding: 7px; } +.admin-assets-form .admin-assets-enabled { display: flex; min-height: 40px; align-items: center; gap: 8px; color: #111111; } +.admin-assets-enabled input { width: 18px; min-height: 18px; } +.admin-assets-form button, +.admin-assets-alert button { min-height: 42px; padding: 9px 14px; border: 1px solid #111111; border-radius: 0; background: #f2f500; font-weight: 900; } +.admin-assets-form button:disabled { color: #777770; background: #dfdfda; cursor: not-allowed; } +.admin-assets-blocked { margin: 0; padding: 12px 16px; border-top: 1px solid #e2b8b3; color: #812219; background: #fff1ef; font-weight: 700; } +.admin-assets-table-wrap { overflow-x: auto; } +.admin-assets-table-wrap table { width: 100%; min-width: 1120px; border-collapse: collapse; table-layout: fixed; } +.admin-assets-table-wrap th, +.admin-assets-table-wrap td { padding: 12px 10px; border-right: 1px solid #d0d0ca; border-bottom: 1px solid #d0d0ca; text-align: left; vertical-align: middle; font-size: 12px; } +.admin-assets-table-wrap thead th { background: #f1f1ed; font-weight: 900; } +.admin-assets-table-wrap th:first-child { width: 78px; } +.admin-assets-table-wrap th:nth-child(2) { width: 150px; } +.admin-assets-table-wrap th:nth-child(3) { width: 140px; } +.admin-assets-table-wrap th:nth-child(4) { width: 210px; } +.admin-assets-table-wrap th:nth-child(5) { width: 92px; } +.admin-assets-table-wrap th:nth-child(6), +.admin-assets-table-wrap th:nth-child(7) { width: 92px; } +.admin-assets-table-wrap th:last-child { width: 180px; } +.admin-assets-table-wrap img { display: block; width: 48px; height: 48px; object-fit: contain; border: 1px solid #c1c1ba; background: #f6f6f4; } +.admin-assets-table-wrap strong, +.admin-assets-table-wrap small { display: block; } +.admin-assets-table-wrap small { margin-top: 4px; color: #65655f; font-size: 10px; overflow-wrap: anywhere; } +.admin-assets-table-wrap input[type="number"] { width: 70px; min-height: 34px; margin-top: 5px; padding: 5px 7px; border: 1px solid #777770; border-radius: 0; } +.admin-assets-table-wrap td:last-child { display: flex; gap: 6px; } +.admin-assets-table-wrap button { min-height: 34px; padding: 6px 8px; border: 1px solid #555550; border-radius: 0; background: #ffffff; font-weight: 800; } +.admin-assets-table-wrap button:disabled { color: #8a8a84; background: #ecece8; } +.admin-assets-table-wrap .is-enabled { color: #1f6639; font-weight: 800; } +.admin-assets-table-wrap .is-disabled { color: #812219; font-weight: 800; } +.admin-assets-empty { margin: 0; padding: 34px 16px; color: #65655f; } +.admin-assets-notice { margin: 16px 0 0; padding: 13px 16px; border-left: 4px solid #287b45; background: #edf8f0; font-weight: 800; } +.admin-assets-alert { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 24px; padding: 16px; border-left: 5px solid #d14a3b; background: #fff1ef; } +.admin-assets-loading { display: grid; gap: 10px; margin-top: 24px; } +.admin-assets-loading span { display: block; height: 62px; background: #dfdfda; } +@media (max-width: 900px) { + .admin-assets-page > main { width: 100%; padding-right: 16px; padding-left: 16px; } + .admin-assets-summary { grid-template-columns: 1fr; } + .admin-assets-summary > span { border-right: 0; border-bottom: 1px solid #c1c1ba; } + .admin-assets-form { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 580px) { + .admin-product-header { padding: 0 12px; overflow-x: auto; } + .admin-product-header nav a { min-width: 66px; } + .admin-assets-form { grid-template-columns: 1fr; } + .admin-assets-heading { align-items: start; flex-direction: column; } +} diff --git a/apps/web/src/admin-assets.tsx b/apps/web/src/admin-assets.tsx new file mode 100644 index 0000000..ad40c50 --- /dev/null +++ b/apps/web/src/admin-assets.tsx @@ -0,0 +1,191 @@ +import { useEffect, useMemo, useState } from "react"; + +import "./admin-assets.css"; + +interface AdminSession { csrf_token: string } +interface StorageState { + capacity_notice_level: "normal" | "warning" | "critical"; + hard_limit_bytes: number; + managed_content_bytes: number; + storage_status: "active" | "full" | "unavailable"; +} +interface AdminSticker { + enabled: boolean; + file_state: "committed"; + height: number; + mime_type: "image/png" | "image/webp"; + order: number; + original_byte_size: number; + original_filename: string; + part: number; + resource_version: string; + stable_id: string; + thumbnail_byte_size: number; + thumbnail_reference: { url: string }; + width: number; +} +interface AdminAssetsResponse { + count: number; + items: AdminSticker[]; + release_version: string | null; + storage: StorageState; +} + +function idempotencyKey() { + return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""); +} + +function bytesLabel(bytes: number) { + return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(bytes / (1024 ** 3)); +} + +async function loadJson(url: string, init?: RequestInit) { + const response = await fetch(url, { credentials: "same-origin", ...init }); + const body = response.headers.get("content-type")?.includes("application/json") ? await response.json() as T : undefined; + return { body, response }; +} + +export function AdminAssetsPage() { + const [session, setSession] = useState(); + const [assets, setAssets] = useState(); + const [loadingFailed, setLoadingFailed] = useState(false); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + const [file, setFile] = useState(); + const [stableId, setStableId] = useState("STK1408"); + const [part, setPart] = useState(25); + const [order, setOrder] = useState(184); + const [enabled, setEnabled] = useState(true); + const [orderDrafts, setOrderDrafts] = useState>({}); + + async function load() { + setLoadingFailed(false); + try { + const [sessionResult, assetsResult] = await Promise.all([ + loadJson("/api/v1/admin-auth/session"), + loadJson("/api/v1/admin/assets/static-stickers"), + ]); + if (!sessionResult.response.ok || !assetsResult.response.ok || !sessionResult.body || !assetsResult.body) throw new Error("load_failed"); + setSession(sessionResult.body); + setAssets(assetsResult.body); + setOrderDrafts(Object.fromEntries(assetsResult.body.items.map((item) => [item.stable_id, item.order]))); + const numericIds = assetsResult.body.items.map((item) => Number(item.stable_id.slice(3))).filter(Number.isFinite); + setStableId(`STK${Math.max(1407, ...numericIds) + 1}`); + setOrder(Math.max(183, ...assetsResult.body.items.filter((item) => item.part === 25).map((item) => item.order)) + 1); + setNotice(""); + } catch { + setLoadingFailed(true); + } + } + + useEffect(() => { void load(); }, []); + + const uploadBlocked = !assets || assets.storage.storage_status !== "active"; + const formValid = useMemo(() => Boolean( + file && /^(image\/png|image\/webp)$/.test(file.type) && /^STK[0-9]{4,}$/.test(stableId) + && Number.isSafeInteger(part) && part >= 1 && part <= 25 && Number.isSafeInteger(order) && order > 0, + ), [file, order, part, stableId]); + + async function upload() { + if (!file || !session || !formValid || uploadBlocked || busy) return; + setBusy(true); + setNotice(""); + try { + const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))) + .map((byte) => byte.toString(16).padStart(2, "0")).join(""); + const form = new FormData(); + form.append("stable_id", stableId); + form.append("part", String(part)); + form.append("order", String(order)); + form.append("enabled", String(enabled)); + form.append("original_byte_size", String(file.size)); + form.append("original_sha256", sha256); + form.append("sticker_file", file, file.name); + const response = await fetch("/api/v1/admin/assets/static-stickers", { + body: form, + credentials: "same-origin", + headers: { "Idempotency-Key": idempotencyKey(), "X-CSRF-Token": session.csrf_token }, + method: "POST", + }); + if (!response.ok) { + setNotice(response.status === 507 ? "存储容量已满或暂不可用,未写入任何文件。" : response.status === 409 ? "稳定 ID 或 part 顺序已存在。" : "文件格式、内容或字段校验未通过。"); + return; + } + setFile(undefined); + await load(); + setNotice("贴纸已生成缩略图并发布新资源版本。"); + } catch { + setNotice("上传未完成,未发布新资源版本。"); + } finally { + setBusy(false); + } + } + + async function update(item: AdminSticker, change: { enabled?: boolean; order?: number }) { + if (!session || busy) return; + setBusy(true); + setNotice(""); + try { + const { response } = await loadJson(`/api/v1/admin/assets/static-stickers/${encodeURIComponent(item.stable_id)}`, { + body: JSON.stringify(change), + headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token }, + method: "PATCH", + }); + if (!response.ok) throw new Error("update_failed"); + await load(); + setNotice(change.enabled === false ? "贴纸已停用,新项目目录不再显示。" : change.enabled === true ? "贴纸已重新启用。" : "part 顺序已发布到新资源版本。"); + } catch { + setNotice("素材状态未更新。"); + } finally { + setBusy(false); + } + } + + return
+
+ DADA ADMIN + +
+
+

ASSET OPERATIONS

普通贴纸

{assets?.release_version ?? "尚未发布"}
+ {!assets && !loadingFailed ?
: null} + {loadingFailed ?

素材状态暂时无法读取。

: null} + {assets ? <> +
+ 后台贴纸{assets.count} + 受管内容{bytesLabel(assets.storage.managed_content_bytes)} / {bytesLabel(assets.storage.hard_limit_bytes)} GB + 存储状态{assets.storage.storage_status} +
+
+

上传并发布

PNG / WebP
+
+ + + + + + +
+ {uploadBlocked ?

当前存储状态禁止新增原图和缩略图。

: null} +
+
+

当前版本

{assets.count} 项
+ {assets.items.length === 0 ?

当前没有后台上传的普通贴纸。

:
+ + {assets.items.map((item) => + + + + + + + + + )} +
预览稳定 IDPart / 顺序原文件尺寸文件状态发布状态操作
{item.stable_id}{item.resource_version}part{item.part} setOrderDrafts((current) => ({ ...current, [item.stable_id]: Number(event.target.value) }))} type="number" value={orderDrafts[item.stable_id] ?? item.order} />{item.original_filename}{item.mime_type} · {item.original_byte_size.toLocaleString("zh-CN")} B{item.width} x {item.height}{item.file_state}{item.enabled ? "已启用" : "已停用"}
} +
+ {notice ?

{notice}

: null} + : null} +
+
; +} diff --git a/apps/web/src/admin-audit.css b/apps/web/src/admin-audit.css new file mode 100644 index 0000000..fa7c872 --- /dev/null +++ b/apps/web/src/admin-audit.css @@ -0,0 +1,164 @@ +.admin-audit-page { + width: min(100% - 48px, 1440px); + margin: 0 auto; + padding: 28px 0 40px; + color: #1a1a18; +} + +.admin-audit-heading { + display: flex; + min-height: 72px; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + border-bottom: 2px solid #1a1a18; +} + +.admin-audit-heading p, +.admin-audit-heading h2 { + margin: 0; +} + +.admin-audit-heading p { + color: #686861; + font-size: 12px; + font-weight: 800; +} + +.admin-audit-heading h2 { + padding: 4px 0 12px; + font-size: 28px; + line-height: 40px; +} + +.admin-audit-heading time { + padding-bottom: 14px; + color: #686861; + font-size: 12px; +} + +.admin-audit-tabs { + display: flex; + gap: 0; + margin-top: 24px; + border-bottom: 1px solid #a9a9a2; +} + +.admin-audit-tabs button { + min-height: 40px; + padding: 0 18px; + border: 0; + border-bottom: 3px solid transparent; + color: #4f4f49; + background: transparent; + font-weight: 700; +} + +.admin-audit-tabs button[aria-selected="true"] { + border-bottom-color: #1a1a18; + color: #1a1a18; + background: #f4df32; +} + +.admin-audit-failure { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding: 8px 12px; + border-left: 4px solid #c92a24; + background: #fff1ef; +} + +.admin-audit-failure button, +.admin-audit-pagination button { + min-height: 36px; + padding: 0 14px; + border: 1px solid #1a1a18; + background: #fff; + font-weight: 700; +} + +.admin-audit-status { + margin: 0; + padding: 48px 16px; + color: #686861; +} + +.admin-audit-table-scroll { + overflow-x: auto; + border-bottom: 1px solid #a9a9a2; +} + +.admin-audit-page table { + width: 100%; + min-width: 1120px; + border-collapse: collapse; + table-layout: fixed; +} + +.admin-audit-page th, +.admin-audit-page td { + min-height: 40px; + padding: 10px 12px; + border-bottom: 1px solid #d7d7d1; + overflow-wrap: anywhere; + text-align: left; + vertical-align: top; + font-size: 12px; +} + +.admin-audit-page th { + color: #55554f; + background: #efefeb; + font-weight: 800; +} + +.admin-audit-page th:nth-child(1) { width: 132px; } +.admin-audit-page th:nth-child(2) { width: 210px; } +.admin-audit-page th:nth-child(3) { width: 180px; } +.admin-audit-page th:nth-child(5) { width: 100px; } +.admin-audit-page th:nth-child(6) { width: 210px; } + +.admin-audit-page td small { + display: block; + margin-top: 4px; + color: #686861; +} + +.admin-audit-page td strong { + color: #16794b; +} + +.admin-audit-page td strong.is-failed { + color: #c92a24; +} + +.admin-audit-pagination { + display: flex; + justify-content: flex-end; + padding-top: 16px; +} + +.admin-audit-retention { + margin: 24px 0 0; + padding-top: 12px; + border-top: 1px solid #d7d7d1; + color: #686861; + font-size: 12px; +} + +.admin-audit-page :focus-visible { + outline: 2px solid #005fcc; + outline-offset: 2px; +} + +@media (max-width: 700px) { + .admin-audit-page { width: calc(100% - 24px); } + .admin-audit-heading { align-items: flex-start; flex-direction: column; gap: 4px; } + .admin-audit-heading time { padding-bottom: 12px; } + .admin-audit-tabs { display: grid; grid-template-columns: 1fr 1fr; } + .admin-audit-tabs button { min-width: 0; padding: 8px; } +} diff --git a/apps/web/src/admin-audit.tsx b/apps/web/src/admin-audit.tsx new file mode 100644 index 0000000..4dd74bd --- /dev/null +++ b/apps/web/src/admin-audit.tsx @@ -0,0 +1,181 @@ +import type { + AdminOperationAuditItem, + AdminOperationAuditResponse, + PrivateContentAccessAuditItem, + PrivateContentAccessAuditResponse, +} from "@dada/shared-contracts"; +import { useCallback, useEffect, useState } from "react"; + +import "./admin-audit.css"; + +type AuditTab = "operations" | "private-content"; + +interface AuditPageState { + failed: boolean; + generatedAt: string | null; + items: Item[]; + loading: boolean; + nextCursor: string | null; +} + +const emptyState = (): AuditPageState => ({ + failed: false, + generatedAt: null, + items: [], + loading: false, + nextCursor: null, +}); + +function formatTime(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + month: "2-digit", + second: "2-digit", + }).format(new Date(value)); +} + +function operationSummary(item: AdminOperationAuditItem) { + if (item.after_summary) return item.after_summary; + if (item.before_summary) return item.before_summary; + return "无变更摘要"; +} + +export function AdminAuditPage() { + const [tab, setTab] = useState("operations"); + const [operations, setOperations] = useState>(emptyState); + const [privateAccess, setPrivateAccess] = useState>(emptyState); + + const loadOperations = useCallback(async (cursor?: string, append = false) => { + setOperations((current) => ({ ...current, failed: false, loading: true })); + try { + const query = new URLSearchParams({ limit: "50" }); + if (cursor) query.set("cursor", cursor); + const response = await fetch(`/api/v1/admin/audit/operations?${query}`, { credentials: "same-origin" }); + if (response.status === 401) { + window.dispatchEvent(new Event("dada:session-invalid")); + return; + } + if (!response.ok) throw new Error("admin_operation_audit_unavailable"); + const body = await response.json() as AdminOperationAuditResponse; + setOperations((current) => ({ + failed: false, + generatedAt: body.generated_at, + items: append ? [...current.items, ...body.items] : body.items, + loading: false, + nextCursor: body.next_cursor, + })); + } catch { + setOperations((current) => ({ ...current, failed: true, loading: false })); + } + }, []); + + const loadPrivateAccess = useCallback(async (cursor?: string, append = false) => { + setPrivateAccess((current) => ({ ...current, failed: false, loading: true })); + try { + const query = new URLSearchParams({ limit: "50" }); + if (cursor) query.set("cursor", cursor); + const response = await fetch(`/api/v1/admin/audit/private-content?${query}`, { credentials: "same-origin" }); + if (response.status === 401) { + window.dispatchEvent(new Event("dada:session-invalid")); + return; + } + if (!response.ok) throw new Error("private_content_audit_unavailable"); + const body = await response.json() as PrivateContentAccessAuditResponse; + setPrivateAccess((current) => ({ + failed: false, + generatedAt: body.generated_at, + items: append ? [...current.items, ...body.items] : body.items, + loading: false, + nextCursor: body.next_cursor, + })); + } catch { + setPrivateAccess((current) => ({ ...current, failed: true, loading: false })); + } + }, []); + + useEffect(() => { void loadOperations(); }, [loadOperations]); + + function selectTab(next: AuditTab) { + setTab(next); + if (next === "private-content" && !privateAccess.generatedAt && !privateAccess.loading) void loadPrivateAccess(); + } + + const state = tab === "operations" ? operations : privateAccess; + const reload = tab === "operations" ? loadOperations : loadPrivateAccess; + + return ( +
+
+

IMMUTABLE / 180 DAYS

审计

+ {state.generatedAt ? : null} +
+ +
+ + +
+ + {state.failed ? ( +
+ 审计记录暂时无法读取{state.generatedAt ? ",已保留上次结果" : ""}。 + +
+ ) : null} + + {tab === "operations" ? ( +
+ {operations.loading && operations.items.length === 0 ?

正在读取后台操作审计

: null} + {!operations.loading && !operations.failed && operations.items.length === 0 ?

当前没有后台操作审计记录。

: null} + {operations.items.length > 0 ? ( +
+ + + {operations.items.map((item) => ( + + + + + + + + + ))} +
时间管理员操作类型对象安全摘要结果Operation ID
{item.actor_ref}{item.actor_type}{item.operation_type}{item.target_type}:{item.target_ref}{operationSummary(item)}{item.result}{item.log_id}
+
+ ) : null} +
+ ) : ( +
+ {privateAccess.loading && privateAccess.items.length === 0 ?

正在读取私有内容访问审计

: null} + {!privateAccess.loading && !privateAccess.failed && privateAccess.items.length === 0 ?

当前没有私有内容访问审计记录。

: null} + {privateAccess.items.length > 0 ? ( +
+ + + {privateAccess.items.map((item) => ( + + + + + + + + + ))} +
时间管理员安全目标标识内容类型到期时间Access ID
{item.actor_ref}{item.target_ref}{item.content_type}{item.log_id}
+
+ ) : null} +
+ )} + + {state.nextCursor ? ( +
+ +
+ ) : null} +

记录保留 180 天。此页面不提供编辑、删除或清空能力。

+
+ ); +} diff --git a/apps/web/src/admin-auth.css b/apps/web/src/admin-auth.css new file mode 100644 index 0000000..c54393d --- /dev/null +++ b/apps/web/src/admin-auth.css @@ -0,0 +1,157 @@ +:root { + color: #121212; + font-family: "Microsoft YaHei", "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.admin-auth-page { + min-height: 100vh; + background: #ffffff; + display: grid; + grid-template-rows: 8px 72px 1fr; +} + +.admin-auth-accent { + background: #eaff00; +} + +.admin-auth-header { + align-items: center; + border-bottom: 1px solid #dedede; + display: flex; + padding: 0 32px; +} + +.admin-auth-wordmark { + color: #111111; + font-family: Arial, sans-serif; + font-size: 20px; + font-weight: 800; + letter-spacing: 0; + text-decoration: none; +} + +.admin-auth-panel { + align-self: center; + justify-self: center; + margin: 48px 20px 96px; + width: min(440px, calc(100vw - 40px)); +} + +.admin-auth-kicker { + color: #606060; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + margin: 0 0 10px; +} + +.admin-auth-panel h1 { + font-size: 26px; + line-height: 1.35; + margin: 0 0 36px; +} + +.admin-auth-panel label { + display: block; + font-size: 14px; + font-weight: 650; + margin-bottom: 9px; +} + +.admin-auth-panel input { + border: 1px solid #b9b9b9; + border-radius: 4px; + font: inherit; + height: 48px; + min-width: 0; + padding: 0 13px; + width: 100%; +} + +.admin-auth-panel input:focus { + border-color: #111111; + box-shadow: 0 0 0 2px #eaff00; + outline: none; +} + +.admin-auth-send-row { + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr) 124px; +} + +.admin-auth-panel button { + border-radius: 4px; + cursor: pointer; + font: inherit; + font-weight: 700; + height: 48px; +} + +.admin-auth-panel button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.admin-auth-send { + background: #ffffff; + border: 1px solid #111111; + width: 124px; +} + +.admin-auth-code-field { + margin-top: 24px; +} + +.admin-auth-error { + border-left: 3px solid #c93333; + color: #8d1717; + font-size: 14px; + line-height: 1.55; + margin: 20px 0 0; + padding-left: 12px; +} + +.admin-auth-submit { + background: #151515; + border: 1px solid #151515; + color: #ffffff; + margin-top: 28px; + width: 100%; +} + +.admin-auth-return { + color: #363636; + display: inline-block; + font-size: 14px; + margin-top: 24px; + text-underline-offset: 4px; +} + +@media (max-width: 480px) { + .admin-auth-header { + padding: 0 20px; + } + + .admin-auth-panel { + align-self: start; + margin-top: 72px; + } + + .admin-auth-send-row { + grid-template-columns: minmax(0, 1fr) 112px; + } + + .admin-auth-send { + width: 112px; + } +} diff --git a/apps/web/src/admin-auth.tsx b/apps/web/src/admin-auth.tsx new file mode 100644 index 0000000..7b3ef42 --- /dev/null +++ b/apps/web/src/admin-auth.tsx @@ -0,0 +1,150 @@ +import { useEffect, useId, useState, type FormEvent } from "react"; + +import "./admin-auth.css"; + +interface ErrorEnvelopeBody { + error?: { + details?: { field_errors?: Array<{ message_key?: string }> }; + }; +} + +function adminErrorMessage(body: ErrorEnvelopeBody) { + const key = body.error?.details?.field_errors?.[0]?.message_key; + if (key === "auth.challenge.invalid") return "验证码不正确,请检查后重试。"; + if (key === "auth.challenge.expired") return "验证码已过期,请重新获取。"; + if (key === "auth.challenge.resend_too_soon") return "请等待倒计时结束后重新获取验证码。"; + if (key === "auth.account.suspended" || key === "admin.auth.not_allowed") return "无法使用管理员入口,请联系部署维护人员。"; + return "管理员登录暂时无法完成,请稍后重试。"; +} + +export function AdminAuthPage() { + const emailId = useId(); + const codeId = useId(); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [registrationId, setRegistrationId] = useState(); + const [countdown, setCountdown] = useState(0); + const [sending, setSending] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(); + const emailValid = /^[^@\s]+@[^@\s]+$/.test(email); + + useEffect(() => { + if (countdown <= 0) return; + const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000); + return () => window.clearInterval(timer); + }, [countdown]); + + async function sendCode() { + if (!emailValid || sending || countdown > 0) return; + setSending(true); + setError(undefined); + try { + const response = await fetch("/api/v1/admin-auth/login/send", { + body: JSON.stringify({ email }), + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string }; + if (!response.ok || !body.registration_id) { + setError(adminErrorMessage(body)); + return; + } + setRegistrationId(body.registration_id); + setCountdown(60); + window.requestAnimationFrame(() => document.getElementById(codeId)?.focus()); + } catch { + setError("管理员登录暂时无法完成,请稍后重试。"); + } finally { + setSending(false); + } + } + + async function completeLogin(event: FormEvent) { + event.preventDefault(); + if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return; + setSubmitting(true); + setError(undefined); + try { + const response = await fetch("/api/v1/admin-auth/login/complete", { + body: JSON.stringify({ registration_id: registrationId, verification_code: code }), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""), + }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody; + if (!response.ok) { + setError(adminErrorMessage(body)); + return; + } + window.location.assign("/admin"); + } catch { + setError("管理员登录暂时无法完成,请稍后重试。"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ ); +} 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/admin-models.css b/apps/web/src/admin-models.css new file mode 100644 index 0000000..c457c23 --- /dev/null +++ b/apps/web/src/admin-models.css @@ -0,0 +1,182 @@ +.admin-models-page { + min-height: 100vh; + color: #111111; + background: #f6f6f4; +} + +.admin-models-page > main { + width: min(1440px, calc(100% - 64px)); + margin: 0 auto; + padding: 36px 0 80px; +} + +.admin-models-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + padding-bottom: 18px; + border-bottom: 1px solid #999993; +} + +.admin-models-heading p { + margin: 0 0 4px; + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.admin-models-heading h1 { + margin: 0; + font-size: 34px; +} + +.admin-models-heading > strong { + font-family: Consolas, monospace; + font-size: 13px; +} + +.admin-models-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 22px 0; + border-block: 1px solid #8c8c86; + background: #ffffff; +} + +.admin-models-summary > span { + display: grid; + min-width: 0; + gap: 6px; + padding: 18px; + border-right: 1px solid #c1c1ba; + color: #65655f; + font-size: 12px; +} + +.admin-models-summary > span:last-child { border-right: 0; } +.admin-models-summary strong { overflow-wrap: anywhere; color: #111111; font-size: 15px; } + +.admin-models-table-wrap { + overflow-x: auto; + border: 1px solid #8c8c86; + background: #ffffff; +} + +.admin-models-table-wrap table { + width: 100%; + min-width: 1220px; + border-collapse: collapse; + table-layout: fixed; +} + +.admin-models-table-wrap th, +.admin-models-table-wrap td { + padding: 14px 12px; + border-right: 1px solid #d0d0ca; + border-bottom: 1px solid #d0d0ca; + text-align: left; + vertical-align: top; + font-size: 12px; +} + +.admin-models-table-wrap thead th { + background: #e7e7e2; + font-weight: 900; +} + +.admin-models-table-wrap th:first-child { width: 210px; } +.admin-models-table-wrap th:nth-child(2), +.admin-models-table-wrap th:nth-child(3) { width: 62px; text-align: center; } +.admin-models-table-wrap th:nth-child(4) { width: 112px; } +.admin-models-table-wrap th:nth-child(5) { width: 96px; } +.admin-models-table-wrap th:nth-child(6) { width: 170px; } +.admin-models-table-wrap th:nth-child(7) { width: 90px; } +.admin-models-table-wrap th:nth-child(8) { width: 180px; } +.admin-models-table-wrap th:nth-child(9) { width: 84px; } + +.admin-models-table-wrap tbody th strong, +.admin-models-table-wrap tbody th code, +.admin-models-table-wrap td small { + display: block; +} + +.admin-models-table-wrap tbody th code, +.admin-models-table-wrap td small { + margin-top: 5px; + color: #65655f; + font-size: 10px; + overflow-wrap: anywhere; +} + +.admin-models-table-wrap td:nth-child(2), +.admin-models-table-wrap td:nth-child(3) { text-align: center; } + +.admin-models-table-wrap input[type="number"] { + width: 76px; + min-height: 38px; + padding: 6px 8px; + border: 1px solid #777770; + border-radius: 0; +} + +.admin-models-table-wrap input[type="checkbox"], +.admin-models-table-wrap input[type="radio"] { + width: 18px; + height: 18px; +} + +.model-state { display: inline-block; padding: 4px 6px; border: 1px solid #7d7d77; font-family: Consolas, monospace; } +.model-state.is-available, +.model-state.is-verified { border-color: #287b45; color: #1f6639; background: #edf8f0; } +.model-state.is-unavailable, +.model-state.is-unverified, +.model-state.is-blocked { border-color: #a66c18; color: #7c4a06; background: #fff7df; } + +.admin-models-actions { + display: flex; + min-height: 74px; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 14px 0; + border-bottom: 1px solid #999993; +} + +.admin-models-actions > div:last-child { display: flex; gap: 8px; } +.admin-models-actions button, +.admin-models-alert button { + min-height: 42px; + padding: 9px 14px; + border: 1px solid #111111; + border-radius: 0; + background: #f2f500; + font-weight: 800; +} +.admin-models-actions button:disabled { color: #777770; background: #dfdfda; cursor: not-allowed; } +.admin-models-validation, +.admin-models-notice { margin: 0; font-weight: 700; } +.admin-models-validation { color: #a52e24; } +.admin-models-notice { color: #1f6639; } + +.admin-models-alert { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 24px; + padding: 16px; + border-left: 5px solid #d14a3b; + background: #fff1ef; +} + +.admin-models-loading { display: grid; gap: 10px; margin-top: 24px; } +.admin-models-loading span { display: block; height: 62px; background: #dfdfda; } + +@media (max-width: 760px) { + .admin-models-page > main { width: 100%; padding-right: 16px; padding-left: 16px; } + .admin-models-summary { grid-template-columns: 1fr; } + .admin-models-summary > span { border-right: 0; border-bottom: 1px solid #c1c1ba; } + .admin-models-actions { align-items: stretch; flex-direction: column; } + .admin-models-actions > div:last-child { display: grid; } +} diff --git a/apps/web/src/admin-models.tsx b/apps/web/src/admin-models.tsx new file mode 100644 index 0000000..022e055 --- /dev/null +++ b/apps/web/src/admin-models.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import "./admin-models.css"; + +interface AdminSession { csrf_token: string } +interface RuntimeAvailability { + available_for_new_jobs: boolean; + checked_at: string; + reason: string; +} +interface ModelConfig { + config_version: number; + contract_evidence_ref: string | null; + contract_validation_status: "blocked" | "unverified" | "verified"; + credit_cost: number; + display_name: string; + enabled: boolean; + error_mapping_profile: Record; + gateway_account_ref: string; + is_default: boolean; + model_id: string; + prompt_max_length: number; + recommendation_priority: number; + reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number }; + route_profile: Record; + runtime_availability: RuntimeAvailability; + safety_source: string; + supported_ratios: string[]; +} +interface ModelConfiguration { + config_set_version: number; + configured_default_model_id: string; + models: ModelConfig[]; + recommended_model_id: string | null; +} + +async function responseJson(url: string, init?: RequestInit) { + const response = await fetch(url, { credentials: "same-origin", ...init }); + const body = await response.json() as T; + return { body, response }; +} + +function idempotencyKey() { + return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""); +} + +function editableModel(model: ModelConfig) { + const { config_version: _configVersion, contract_evidence_ref: _evidence, contract_validation_status: _status, runtime_availability: _runtime, ...editable } = model; + return editable; +} + +function runtimeLabel(runtime: RuntimeAvailability) { + return runtime.available_for_new_jobs ? "可用于新任务" : `不可用 · ${runtime.reason}`; +} + +export function AdminModelsPage() { + const [session, setSession] = useState(); + const [configuration, setConfiguration] = useState(); + const [draft, setDraft] = useState(); + const [loadingFailed, setLoadingFailed] = useState(false); + const [saving, setSaving] = useState(false); + const [notice, setNotice] = useState(""); + const [conflicted, setConflicted] = useState(false); + const priorityRefs = useRef>({}); + const defaultRefs = useRef>({}); + + const load = useCallback(async () => { + setLoadingFailed(false); + setConflicted(false); + try { + const [sessionResult, modelResult] = await Promise.all([ + responseJson("/api/v1/admin-auth/session"), + responseJson("/api/v1/models"), + ]); + if (!sessionResult.response.ok || !modelResult.response.ok) throw new Error("load_failed"); + setSession(sessionResult.body); + setConfiguration(modelResult.body); + setDraft(structuredClone(modelResult.body.models)); + setNotice(""); + } catch { + setLoadingFailed(true); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + useEffect(() => { + if (typeof EventSource === "undefined") return undefined; + const source = new EventSource("/api/v1/events"); + source.onmessage = () => { void load(); }; + return () => source.close(); + }, [load]); + + const validation = useMemo(() => { + if (!draft) return { valid: false, message: "" }; + const invalidPriority = draft.find((model) => !Number.isSafeInteger(model.recommendation_priority) || model.recommendation_priority <= 0); + if (invalidPriority) return { field: invalidPriority.model_id, kind: "priority" as const, message: "推荐优先级必须是正整数。", valid: false }; + const duplicatePriority = draft.find((model, index) => draft.findIndex((item) => item.recommendation_priority === model.recommendation_priority) !== index); + if (duplicatePriority) return { field: duplicatePriority.model_id, kind: "priority" as const, message: "推荐优先级不能重复。", valid: false }; + const defaults = draft.filter((model) => model.enabled && model.is_default); + if (defaults.length !== 1) return { field: draft.find((model) => model.is_default)?.model_id ?? draft[0]?.model_id, kind: "default" as const, message: "必须指定一个已启用的默认模型。", valid: false }; + return { message: "", valid: true }; + }, [draft]); + + function updateModel(modelId: string, change: Partial) { + setDraft((current) => current?.map((model) => model.model_id === modelId ? { ...model, ...change } : model)); + setNotice(""); + } + + function chooseDefault(modelId: string) { + setDraft((current) => current?.map((model) => ({ ...model, is_default: model.model_id === modelId }))); + setNotice(""); + } + + async function save() { + if (!configuration || !draft || !session || !validation.valid || saving || conflicted) return; + setSaving(true); + setNotice(""); + try { + const { body, response } = await responseJson("/api/v1/admin/models/configuration", { + body: JSON.stringify({ expected_config_set_version: configuration.config_set_version, models: draft.map(editableModel) }), + headers: { + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey(), + "X-CSRF-Token": session.csrf_token, + }, + method: "PUT", + }); + if (!response.ok) { + const code = body.error?.code; + if (code === "MODEL_CONFIG_VERSION_CONFLICT") { + setConflicted(true); + setNotice("配置已被其他管理员更新,请刷新最新配置后重新编辑。"); + } else if (code === "MODEL_RECOMMENDATION_PRIORITY_INVALID" || code === "MODEL_RECOMMENDATION_PRIORITY_CONFLICT") { + const modelId = validation.field ?? draft[0]?.model_id; + if (modelId) priorityRefs.current[modelId]?.focus(); + setNotice("请修正推荐优先级后再保存。"); + } else if (code === "MODEL_DEFAULT_REPLACEMENT_REQUIRED" || code === "MODEL_DEFAULT_REPLACEMENT_INVALID") { + const modelId = validation.field ?? draft[0]?.model_id; + if (modelId) defaultRefs.current[modelId]?.focus(); + setNotice("请指定一个已启用的替代默认模型。"); + } else setNotice("操作未完成。"); + return; + } + setConfiguration(body); + setDraft(structuredClone(body.models)); + setNotice("模型配置已原子保存并记录审计。"); + } catch { + setNotice("操作未完成。"); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+

MODEL OPERATIONS

模型配置

+ {configuration ? 配置集合 v{configuration.config_set_version} : null} +
+ {!configuration && !loadingFailed ?
: null} + {loadingFailed ?

模型配置暂时无法读取。

: null} + {configuration && draft ? ( + <> +
+ 配置默认 {configuration.configured_default_model_id} + 当前推荐 {configuration.recommended_model_id ?? "无"} + 运行时可用 {configuration.models.filter((model) => model.runtime_availability.available_for_new_jobs).length} / 3 +
+
+ + + + {draft.map((model) => ( + + + + + + + + + + + + ))} + +
模型启用默认推荐优先级契约运行时可用当前推荐能力配置版本
{model.display_name}{model.model_id} updateModel(model.model_id, { enabled: event.target.checked })} type="checkbox" /> chooseDefault(model.model_id)} ref={(node) => { defaultRefs.current[model.model_id] = node; }} type="radio" /> updateModel(model.model_id, { recommendation_priority: Number(event.target.value) })} ref={(node) => { priorityRefs.current[model.model_id] = node; }} type="number" value={model.recommendation_priority} />{model.contract_validation_status}{runtimeLabel(model.runtime_availability)}{new Date(model.runtime_availability.checked_at).toLocaleString("zh-CN")}{configuration.recommended_model_id === model.model_id ? "是" : "否"}{model.credit_cost} 点{model.supported_ratios.join(" / ")} · 最多 {model.reference_limits.max_files} 张参考图v{model.config_version}
+
+
+
{validation.message ?

{validation.message}

: null}{notice ?

{notice}

: null}
+
{conflicted ? : null}
+
+ + ) : null} +
+
+ ); +} diff --git a/apps/web/src/admin-services-storage.css b/apps/web/src/admin-services-storage.css new file mode 100644 index 0000000..c96631f --- /dev/null +++ b/apps/web/src/admin-services-storage.css @@ -0,0 +1,37 @@ +.admin-services-storage { max-width: 1180px; } +.admin-services-heading { align-items: end; } +.admin-services-heading-actions { align-items: center; display: flex; gap: 16px; } +.admin-services-heading-actions button, .admin-diagnostics-section button { background: #111827; border: 0; color: #fff; cursor: pointer; font: inherit; padding: 10px 14px; } +.admin-health-section { border-top: 1px solid #d9dde5; margin-top: 26px; padding-top: 22px; } +.admin-health-section > header { align-items: center; display: flex; justify-content: space-between; margin-bottom: 18px; } +.admin-health-section h3 { margin: 4px 0 0; } +.admin-health-section header p { color: #7b8493; font-size: 11px; letter-spacing: .12em; margin: 0; } +.admin-safe-note { color: #667085; font-size: 13px; } +.admin-service-grid { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.admin-service-card { background: #fff; border: 1px solid #e1e5ea; min-height: 160px; padding: 18px; } +.admin-service-card.is-degraded, .admin-service-card.is-paused_quota, .admin-service-card.is-paused_provider, .admin-service-card.is-unavailable { border-color: #e5b6b6; } +.admin-service-card-heading { align-items: center; display: flex; justify-content: space-between; } +.admin-service-card-heading span, .admin-storage-state { color: #147a50; font-size: 13px; } +.admin-service-card.is-degraded .admin-service-card-heading span, .admin-service-card.is-paused_quota .admin-service-card-heading span, .admin-service-card.is-paused_provider .admin-service-card-heading span, .admin-service-card.is-unavailable .admin-service-card-heading span { color: #b42318; } +.admin-service-card dl, .admin-storage-details { display: grid; gap: 10px; margin: 18px 0 0; } +.admin-service-card dl div, .admin-storage-details div { align-items: baseline; display: flex; justify-content: space-between; } +.admin-service-card dt, .admin-storage-details dt { color: #667085; font-size: 12px; } +.admin-service-card dd, .admin-storage-details dd { margin: 0; text-align: right; } +.admin-storage-state.is-full, .admin-storage-state.is-unavailable { color: #b42318; } +.admin-storage-metrics { display: grid; gap: 16px; grid-template-columns: repeat(4, minmax(0, 1fr)); } +.admin-storage-metrics div { background: #f7f8fa; padding: 14px 16px; } +.admin-storage-metrics span { color: #667085; display: block; font-size: 12px; } +.admin-storage-metrics strong { display: block; font-size: 20px; margin-top: 6px; } +.admin-storage-progress { background: #e5e7eb; height: 8px; margin-top: 18px; overflow: hidden; } +.admin-storage-progress span { background: #147a50; display: block; height: 100%; } +.admin-storage-description { color: #667085; font-size: 13px; line-height: 1.7; max-width: 780px; } +.admin-storage-description code { color: #344054; } +.admin-diagnostics-section > header button:disabled { background: #98a2b3; cursor: not-allowed; } +.admin-diagnostics-section > p { color: #667085; font-size: 13px; } +.admin-diagnostics-section pre { background: #111827; color: #d1fadf; font: 12px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; margin: 16px 0 0; max-height: 280px; overflow: auto; padding: 16px; white-space: pre-wrap; } +.admin-services-loading { display: grid; gap: 12px; grid-template-columns: repeat(3, 1fr); } +.admin-services-loading span, .admin-diagnostics-placeholder { background: #eef1f4; display: block; height: 160px; } +.admin-services-failure { align-items: center; background: #fff4f2; color: #b42318; display: flex; gap: 16px; justify-content: space-between; padding: 14px 16px; } +.admin-services-failure button { background: transparent; border: 1px solid #b42318; color: #b42318; cursor: pointer; padding: 6px 12px; } +@media (max-width: 900px) { .admin-service-grid, .admin-storage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 620px) { .admin-service-grid, .admin-storage-metrics { grid-template-columns: 1fr; } .admin-services-heading-actions { align-items: flex-end; flex-direction: column; gap: 8px; } } diff --git a/apps/web/src/admin-services-storage.tsx b/apps/web/src/admin-services-storage.tsx new file mode 100644 index 0000000..b464246 --- /dev/null +++ b/apps/web/src/admin-services-storage.tsx @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts"; + +import "./admin-services-storage.css"; + +const serviceLabels: Record = { + ai_gateway: "AI 网关", + amap: "高德", + api: "API", + asset_root: "素材根", + resend: "Resend", + worker: "Worker", +}; + +const statusLabels: Record = { + active: "正常", + degraded: "有异常", + disabled: "已停用", + paused_provider: "供应商暂停", + paused_quota: "额度暂停", + unavailable: "不可用", +}; + +const impactLabels: Record = { + account: "账户模型", + api: "后台接口", + authentication: "认证", + generation: "生成", + location: "定位", + model: "单模型", + none: "无", + storage: "存储", + unknown: "未知范围", +}; + +function formatTime(value: string | null) { + if (!value) return "未记录"; + return new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); +} + +async function getJson(url: string) { + const response = await fetch(url, { credentials: "same-origin" }); + if (response.status === 401) window.dispatchEvent(new Event("dada:session-invalid")); + if (!response.ok) throw new Error("admin_state_unavailable"); + return await response.json() as T; +} + +export function AdminServicesStoragePage() { + const [state, setState] = useState(); + const [diagnostics, setDiagnostics] = useState(); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const [copied, setCopied] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setFailed(false); + try { + const [nextState, nextDiagnostics] = await Promise.all([ + getJson("/api/v1/admin/services-storage"), + getJson("/api/v1/admin/diagnostics"), + ]); + setState(nextState); + setDiagnostics(nextDiagnostics); + } catch { + setFailed(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + async function copyDiagnostics() { + if (!diagnostics) return; + try { + await navigator.clipboard.writeText(diagnostics.diagnostic_text); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } catch { + setCopied(false); + } + } + + return ( +
+
+

OPERATIONS / HEALTH

服务与存储

+
+ {state ? : null} + +
+
+ {loading && !state ?
: null} + {failed ?
状态暂时无法读取{state ? `,保留 ${formatTime(state.generated_at)} 的结果` : ""}。
: null} + {state ? ( + <> +
+

SERVICE STATUS

外部服务与本机组件

仅显示脱敏状态
+
+ {state.services.map((service) => ( +
+
{serviceLabels[service.service_id]}{statusLabels[service.status]}
+
+
配置状态
{service.configured ? "已配置" : "未配置"}
+
影响范围
{impactLabels[service.impact_scope]}
+
最近检查
{formatTime(service.checked_at)}
+ {service.pause_reason ?
安全原因
{service.pause_reason}
: null} +
+
+ ))} +
+
+
+

LOCAL DATA ROOT

本机内容容量

{state.storage.status === "active" ? "可写" : state.storage.status === "full" ? "已满" : "不可用"}
+
+
已用内容{(state.storage.managed_content_bytes / 1024 / 1024 / 1024).toFixed(2)} GB
+
固定上限{(state.storage.hard_limit_bytes / 1024 / 1024 / 1024).toFixed(2)} GB
+
容量提醒{state.storage.capacity_notice_level}
+
清理队列{state.storage.cleanup_pending_count}
+
+
+

测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。当前 Windows 用户的 Dada 本机数据目录引用:{state.storage.data_root_ref}。只读规范素材库不计入 5 GB 内容额度。

+
最后计量
{formatTime(state.storage.last_measured_at)}
重新计量
{state.storage.remeasurement_required ? "需要完成" : "无需等待"}
+
+
+

DIAGNOSTICS

脱敏诊断

+

诊断内容只包含固定版本、组件状态、逻辑位置和容量计量,不包含密钥、邮箱、绝对路径、提示词、图片或供应商原文。

+ {diagnostics ?
{diagnostics.diagnostic_text}
:
} +
+ + ) : null} +
+ ); +} diff --git a/apps/web/src/admin-shell.css b/apps/web/src/admin-shell.css new file mode 100644 index 0000000..e2c7470 --- /dev/null +++ b/apps/web/src/admin-shell.css @@ -0,0 +1,523 @@ +:root { + color-scheme: light; + font-family: "Segoe UI", "Microsoft YaHei UI", sans-serif; + background: #f3f3ef; +} + +* { + box-sizing: border-box; + letter-spacing: 0; +} + +body { + margin: 0; +} + +button, +a, +input, +textarea { + font: inherit; +} + +.admin-shell { + min-height: 100vh; + color: #171715; + background: #f3f3ef; +} + +.admin-skip-link { + position: fixed; + z-index: 100; + top: 8px; + left: 228px; + padding: 8px 12px; + color: #ffffff; + background: #171715; + transform: translateY(-160%); +} + +.admin-skip-link:focus { + transform: translateY(0); +} + +.admin-sidebar { + position: fixed; + z-index: 20; + inset: 0 auto 0 0; + display: grid; + width: 216px; + grid-template-rows: auto 1fr auto; + color: #ffffff; + background: #171715; +} + +.admin-wordmark { + display: grid; + min-height: 104px; + align-content: center; + padding: 20px 22px; + border-bottom: 1px solid #494944; + color: #ffffff; + text-decoration: none; +} + +.admin-wordmark span { + font-family: "Arial Black", "Segoe UI", sans-serif; + font-size: 30px; + line-height: 1; +} + +.admin-wordmark small { + margin-top: 6px; + color: #d9dc00; + font-family: Consolas, monospace; + font-size: 10px; +} + +.admin-sidebar nav { + display: grid; + align-content: start; + padding: 12px 0; +} + +.admin-sidebar nav a { + display: grid; + min-height: 48px; + grid-template-columns: 38px 1fr; + align-items: center; + padding: 0 18px; + border-left: 4px solid transparent; + color: #d5d5cf; + font-size: 13px; + font-weight: 700; + text-decoration: none; +} + +.admin-sidebar nav a > span { + color: #85857d; + font-family: Consolas, monospace; + font-size: 10px; +} + +.admin-sidebar nav a:hover, +.admin-sidebar nav a:focus-visible { + color: #ffffff; + background: #2c2c29; +} + +.admin-sidebar nav a[aria-current="page"] { + border-left-color: #e8eb00; + color: #171715; + background: #eef000; +} + +.admin-sidebar nav a[aria-current="page"] > span { + color: #4d4d00; +} + +.admin-sidebar-foot { + display: grid; + gap: 10px; + padding: 18px 22px; + border-top: 1px solid #494944; + font-family: Consolas, monospace; + font-size: 10px; +} + +.admin-sidebar-foot span { + color: #a5a59d; +} + +.admin-sidebar-foot strong { + color: #ffffff; + font-weight: 700; +} + +.admin-shell-workspace { + min-width: 0; + margin-left: 216px; + padding-top: 52px; +} + +.admin-topbar { + position: fixed; + z-index: 15; + top: 0; + right: 0; + left: 216px; + display: flex; + height: 52px; + align-items: center; + justify-content: space-between; + padding: 0 28px; + border-bottom: 1px solid #b7b7b0; + background: rgb(255 255 255 / 96%); +} + +.admin-topbar h1 { + margin: 0; + font-size: 15px; +} + +.admin-topbar-status { + display: flex; + align-items: center; + gap: 20px; + color: #62625c; + font-size: 11px; +} + +.admin-topbar-status span { + display: flex; + align-items: center; + gap: 7px; +} + +.admin-topbar-status i { + width: 8px; + height: 8px; + border-radius: 50%; + background: #777770; +} + +.admin-topbar-status code { + color: #171715; +} + +.admin-shell-content { + min-width: 0; +} + +.admin-session-gate { + display: grid; + min-height: 100vh; + place-items: center; + color: #171715; + background: #f3f3ef; +} + +.admin-session-gate p, +.admin-session-gate div { + padding: 22px; + border-left: 5px solid #171715; + background: #ffffff; +} + +.admin-session-gate div { + display: grid; + gap: 12px; +} + +.admin-session-gate button, +.admin-overview-failure button, +.admin-placeholder-toolbar button { + min-height: 40px; + padding: 8px 14px; + border: 1px solid #171715; + border-radius: 0; + color: #171715; + background: #eef000; + font-weight: 800; +} + +.admin-overview, +.admin-placeholder { + width: min(1320px, calc(100% - 64px)); + margin: 0 auto; + padding: 34px 0 72px; +} + +.admin-page-heading { + display: flex; + min-height: 74px; + align-items: end; + justify-content: space-between; + gap: 24px; + padding-bottom: 18px; + border-bottom: 1px solid #8c8c85; +} + +.admin-page-heading p, +.admin-status-section header p, +.admin-operation-strip header p { + margin: 0 0 5px; + font-family: Consolas, monospace; + font-size: 10px; + font-weight: 700; +} + +.admin-page-heading h2 { + margin: 0; + font-size: 32px; +} + +.admin-page-heading time { + color: #66665f; + font-size: 11px; +} + +.admin-capacity-alert { + display: grid; + min-height: 44px; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 18px; + padding: 9px 14px; + border-bottom: 1px solid #171715; + color: #171715; + background: #eef000; + font-size: 12px; + text-decoration: none; +} + +.admin-capacity-alert.is-full, +.admin-capacity-alert.is-unavailable { + color: #ffffff; + background: #b33a2f; +} + +.admin-overview-loading { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 22px; + border-block: 1px solid #b7b7b0; +} + +.admin-overview-loading span { + height: 130px; + border-right: 1px solid #c7c7c0; + background: #e2e2dd; +} + +.admin-overview-failure { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 20px; + padding: 14px 16px; + border-left: 5px solid #b33a2f; + background: #fff0ed; +} + +.admin-metric-band { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 22px; + border-block: 1px solid #8c8c85; + background: #ffffff; +} + +.admin-metric-band a { + display: grid; + min-width: 0; + min-height: 132px; + align-content: center; + gap: 7px; + padding: 20px; + border-right: 1px solid #c3c3bc; + color: #171715; + text-decoration: none; +} + +.admin-metric-band a:last-child { + border-right: 0; +} + +.admin-metric-band span, +.admin-metric-band small { + color: #65655f; + font-size: 11px; +} + +.admin-metric-band strong { + overflow-wrap: anywhere; + font-size: 25px; +} + +.admin-overview-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 24px; +} + +.admin-status-section, +.admin-operation-strip, +.admin-placeholder > section { + border-top: 3px solid #171715; + border-bottom: 1px solid #8c8c85; + background: #ffffff; +} + +.admin-status-section > header, +.admin-operation-strip > header { + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #c3c3bc; +} + +.admin-status-section h3, +.admin-operation-strip h3 { + margin: 0; + font-size: 17px; +} + +.admin-status-section header a, +.admin-operation-strip header a { + color: #171715; + font-size: 12px; + font-weight: 800; +} + +.admin-status-section dl { + margin: 0; +} + +.admin-status-section dl > div { + display: grid; + min-height: 52px; + grid-template-columns: 126px 1fr; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid #ddddD7; +} + +.admin-status-section dl > div:last-child { + border-bottom: 0; +} + +.admin-status-section dt { + color: #65655f; + font-size: 11px; +} + +.admin-status-section dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-family: Consolas, monospace; + font-size: 12px; + font-weight: 700; +} + +.admin-service-list { + margin: 0; + padding: 0; + list-style: none; +} + +.admin-service-list li { + display: grid; + min-height: 42px; + grid-template-columns: 1fr 84px 76px; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid #ddddd7; + font-size: 11px; +} + +.admin-service-list li:last-child { + border-bottom: 0; +} + +.admin-service-list strong { + color: #1f6639; +} + +.admin-service-list strong.is-degraded, +.admin-service-list strong.is-paused { + color: #8b5608; +} + +.admin-service-list strong.is-unavailable { + color: #a52e24; +} + +.admin-service-list time { + color: #65655f; + text-align: right; +} + +.admin-operation-strip { + margin-top: 24px; +} + +.admin-operation-strip > p { + margin: 0; + padding: 22px 16px; + color: #65655f; +} + +.admin-operation-strip table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.admin-operation-strip th, +.admin-operation-strip td { + padding: 12px 16px; + border-bottom: 1px solid #ddddd7; + overflow-wrap: anywhere; + text-align: left; + font-size: 11px; +} + +.admin-operation-strip th { + color: #65655f; + background: #efefeb; +} + +.admin-placeholder > section { + margin-top: 22px; +} + +.admin-placeholder-toolbar { + display: flex; + min-height: 58px; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + border-bottom: 1px solid #c3c3bc; + font-weight: 800; +} + +.admin-placeholder-toolbar button:disabled { + color: #777770; + background: #dfdfda; +} + +.admin-placeholder > section > p { + margin: 0; + padding: 44px 16px; + color: #65655f; +} + +:is(.admin-shell, .admin-session-gate) :focus-visible { + outline: 2px solid #225dd8; + outline-offset: 2px; +} + +@media (max-width: 1000px) { + .admin-overview, + .admin-placeholder { + width: calc(100% - 32px); + } + + .admin-metric-band { + grid-template-columns: 1fr 1fr; + } + + .admin-metric-band a:nth-child(2) { + border-right: 0; + } + + .admin-overview-columns { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/admin-shell.tsx b/apps/web/src/admin-shell.tsx new file mode 100644 index 0000000..cee2c52 --- /dev/null +++ b/apps/web/src/admin-shell.tsx @@ -0,0 +1,229 @@ +import type { AdminOverviewResponse } from "@dada/shared-contracts"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; + +import "./admin-shell.css"; + +interface AdminSession { + admin: { role: "super_admin"; status: "active"; user_id: string }; + audience: "admin"; + authenticated: true; + expires_at: string; +} + +interface AdminProtectedRouteProps { + children: ReactNode; + currentPath: string; + title: string; +} + +const adminNavigation = [ + { href: "/admin", label: "总览", marker: "01" }, + { href: "/admin/users", label: "用户与点数", marker: "02" }, + { href: "/admin/invites", label: "邀请码", marker: "03" }, + { href: "/admin/models", label: "模型", marker: "04" }, + { href: "/admin/assets", label: "素材", marker: "05" }, + { href: "/admin/preview", label: "内部预览", marker: "06" }, + { href: "/admin/generations", label: "生成记录", marker: "07" }, + { href: "/admin/services-storage", label: "服务与存储", marker: "08" }, + { href: "/admin/audit", label: "审计", marker: "09" }, +] as const; + +function redirectToAdminLogin() { + window.location.replace("/admin/login"); +} + +export function AdminProtectedRoute({ children, currentPath, title }: AdminProtectedRouteProps) { + const [session, setSession] = useState(); + const [failed, setFailed] = useState(false); + const [revision, setRevision] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setFailed(false); + void fetch("/api/v1/admin-auth/session", { credentials: "same-origin", signal: controller.signal }) + .then(async (response) => { + if (response.status === 401) { + redirectToAdminLogin(); + return; + } + if (!response.ok) throw new Error("admin_session_unavailable"); + const body = await response.json() as AdminSession; + if (body.audience !== "admin" || body.admin.role !== "super_admin" || body.admin.status !== "active") { + redirectToAdminLogin(); + return; + } + setSession(body); + }) + .catch((error: unknown) => { + if (!(error instanceof DOMException && error.name === "AbortError")) setFailed(true); + }); + return () => controller.abort(); + }, [revision]); + + if (!session) { + return ( +
+ {failed ? ( +
+ 管理员会话暂时无法确认 + +
+ ) :

正在确认管理员会话

} +
+ ); + } + + return ( +
+ 跳到主要内容 + +
+
+

{title}

+
+