Compare commits

...
Author SHA1 Message Date
suyx bc6fa3d517 fix(WP6): 稳定整合门禁的资源路径与测试超时
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 19:07:55 +08:00
suyx 1c46311e05 feat: implement sensitive operation audit retention (TASK-WP6-04)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
2026-08-04 11:51:09 +08:00
suyx c589b8bb4e merge: integrate WP6-05 baseline for TASK-WP6-04
# Conflicts:
#	apps/api/src/app.ts
#	apps/api/src/main.ts
#	apps/web/src/generated/api/sdk.gen.ts
#	apps/web/src/generated/api/types.gen.ts
#	apps/web/src/main.tsx
#	openapi/openapi.json
#	package.json
2026-08-04 11:09:03 +08:00
suyx d11e036c22 merge: integrate WP6-03 dependency for TASK-WP6-04
# Conflicts:
#	apps/api/src/app.ts
#	apps/web/src/generated/api/sdk.gen.ts
#	openapi/openapi.json
2026-08-04 11:05:14 +08:00
suyx 33b02df970 merge: integrate WP6-02 dependency for TASK-WP6-04
# Conflicts:
#	apps/api/src/app.ts
#	apps/web/src/main.tsx
2026-08-04 11:01:15 +08:00
suyx 76bb685b5b merge: integrate WP6-01 dependency for TASK-WP6-04
# Conflicts:
#	apps/web/src/admin-models.tsx
#	apps/web/src/admin-users.tsx
#	apps/web/src/main.tsx
#	package.json
#	scripts/lib/openapi.mjs
2026-08-04 11:00:24 +08:00
suyx 361b435506 merge: integrate WP5-07 dependency for TASK-WP6-04 2026-08-04 10:58:59 +08:00
suyx c2521f7208 feat: implement explicit sticker history cleanup (TASK-WP5-06)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 3m16s
2026-08-04 10:40:11 +08:00
suyx 70754ec12b feat: enforce WP6-03 external service hard stops
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 9m34s
2026-08-04 02:17:21 +08:00
suyx d738ea175e feat: implement TASK-WP6-02 private content access
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 10m0s
2026-08-04 02:14:29 +08:00
suyx 5041dc03c3 feat: add admin state and diagnostics (TASK-WP6-05)
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 12m4s
2026-08-04 01:51:35 +08:00
suyx 2c803454de feat: implement TASK-WP5-07 preview grant lifecycle
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 14m18s
2026-08-04 01:29:29 +08:00
suyx eed125c118 fix: build WP5-04 workspace API dependencies
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 9m15s
2026-08-04 00:42:31 +08:00
suyx 19212cc1b6 feat: scaffold TASK-WP6-01 admin operations
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 10m45s
2026-08-03 19:15:03 +08:00
49 changed files with 9232 additions and 35 deletions
+148
View File
@@ -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<Row extends { log_id: string; occurred_at: number }>(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 };
}
+194
View File
@@ -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<AdminService["status"]>(["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<AdminService, "configured" | "status" | "checked_at" | "pause_reason">;
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;
}
+651 -3
View File
@@ -9,7 +9,24 @@ import {
AccountProfileUpdateRequestSchema, AccountProfileUpdateRequestSchema,
AccountProfileUpdateResponseSchema, AccountProfileUpdateResponseSchema,
AccountSettingsResponseSchema, AccountSettingsResponseSchema,
AdminAuditQuerySchema,
AdminAuthenticatedUserSchema, AdminAuthenticatedUserSchema,
AdminGenerationRecordSchema,
AdminGenerationListResponseSchema,
AdminOverviewResponseSchema,
AdminOperationAuditItemSchema,
AdminOperationAuditResponseSchema,
AdminServicesResponseSchema,
AdminServiceHealthCheckRequestSchema,
AdminServiceLimitRequestSchema,
AdminServiceParamsSchema,
AdminServiceRecoveryRequestSchema,
ExternalServiceIdSchema,
ExternalServicePeriodTypeSchema,
ExternalServiceStatusSchema,
ExternalServiceUsageSchema,
AdminDiagnosticsResponseSchema,
AdminServicesStorageResponseSchema,
AdminCreditParamsSchema, AdminCreditParamsSchema,
AdminLoginCompleteRequestSchema, AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema, AdminLoginCompleteResponseSchema,
@@ -60,6 +77,12 @@ import {
ModelConfigUpdateRequestSchema, ModelConfigUpdateRequestSchema,
ModelParamsSchema, ModelParamsSchema,
ModelConfigUpdateHeadersSchema, ModelConfigUpdateHeadersSchema,
PrivateContentGenerationParamsSchema,
PrivateContentNoticeAckRequestSchema,
PrivateContentNoticeAckResponseSchema,
PrivateContentPromptResponseSchema,
PrivateContentAccessAuditItemSchema,
PrivateContentAccessAuditResponseSchema,
FailedEmptyTrashRequestSchema, FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema, FailedEmptyTrashResponseSchema,
ExportFormatSchema, ExportFormatSchema,
@@ -110,6 +133,10 @@ import {
type BootstrapResponse, type BootstrapResponse,
type AdminLoginCompleteRequest, type AdminLoginCompleteRequest,
type AdminLoginSendRequest, type AdminLoginSendRequest,
type AdminOverviewResponse,
type AdminAuditQuery,
type AdminDiagnosticsResponse,
type AdminServicesStorageResponse,
type AccountDeletionCompleteRequest, type AccountDeletionCompleteRequest,
type AccountProfileUpdateRequest, type AccountProfileUpdateRequest,
type AdminCreditParams, type AdminCreditParams,
@@ -174,12 +201,22 @@ import {
registrationFieldError, registrationFieldError,
} from "./registration-errors.js"; } from "./registration-errors.js";
import type { RegistrationService } from "./registration.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 { RecentAssetService } from "./recent-assets.js";
import type { AmapAdapter } from "./amap-adapter.js"; import type { AmapAdapter } from "./amap-adapter.js";
import { ExternalServiceUsageError, type ExternalServiceUsage } from "./external-service-usage.js";
import { ModelConfigurationError } from "./model-configuration.js"; import { ModelConfigurationError } from "./model-configuration.js";
import type { ModelConfigurationService } from "./model-configuration.js"; import type { ModelConfigurationService } from "./model-configuration.js";
import { StickerReleaseError } from "./sticker-release-errors.js"; import { StickerReleaseError } from "./sticker-release-errors.js";
import type { StickerReleaseService } from "./sticker-releases.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 = { const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0", app_version: "0.0.0",
@@ -194,6 +231,9 @@ const defaultBootstrap: BootstrapResponse = {
}; };
export interface CreateAppOptions { export interface CreateAppOptions {
adminDiagnostics?: () => AdminDiagnosticsResponse | Promise<AdminDiagnosticsResponse>;
adminOverview?: () => AdminOverviewResponse | Promise<AdminOverviewResponse>;
adminServicesStorage?: () => AdminServicesStorageResponse | Promise<AdminServicesStorageResponse>;
amap?: AmapAdapter; amap?: AmapAdapter;
assetReleases?: AssetReleaseReader; assetReleases?: AssetReleaseReader;
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>; bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
@@ -209,19 +249,23 @@ export interface CreateAppOptions {
publicAssets?: PublicAssetResolver; publicAssets?: PublicAssetResolver;
recentAssets?: RecentAssetService; recentAssets?: RecentAssetService;
projects?: ProjectService; projects?: ProjectService;
storage?: ManagedStorage;
previewAssetAuthorizer?: (input: { previewAssetAuthorizer?: (input: {
releaseVersion: string; releaseVersion: string;
resourceId: string; resourceId: string;
userId: string; userId: string;
}) => boolean | Promise<boolean>; }) => boolean | Promise<boolean>;
previewGrants?: AssetPreviewGrantService;
privateAssetAdminAuthorizer?: (input: { privateAssetAdminAuthorizer?: (input: {
adminUserId: string; adminUserId: string;
ownerId: string; ownerId: string;
releaseVersion: string; releaseVersion: string;
resourceId: string; resourceId: string;
}) => boolean | Promise<boolean>; }) => boolean | Promise<boolean>;
privateContent?: PrivateContentService;
registration?: RegistrationService; registration?: RegistrationService;
stickers?: StickerReleaseService; stickers?: StickerReleaseService;
serviceUsage?: ExternalServiceUsage;
} }
const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate"); const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate");
@@ -382,6 +426,19 @@ function stickerReleaseFailure(reply: FastifyReply, correlationId: string, error
return latestExportFailure(reply, correlationId, error); 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) { function generationTaskResponse(task: GenerationTaskView) {
return { return {
confirmed_credit_cost: task.confirmedCreditCost, confirmed_credit_cost: task.confirmedCreditCost,
@@ -645,6 +702,13 @@ function sendBrowserUnsupported(
export async function createApp(options: CreateAppOptions = {}) { export async function createApp(options: CreateAppOptions = {}) {
const eventHub = options.eventHub ?? new EventHub(); const eventHub = options.eventHub ?? new EventHub();
const bootstrap = options.bootstrap ?? (() => defaultBootstrap); 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 browserGate = options.browserGate ?? true;
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32); const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
const browserSupportRelease = options.browserSupportRelease; const browserSupportRelease = options.browserSupportRelease;
@@ -696,6 +760,29 @@ export async function createApp(options: CreateAppOptions = {}) {
AdminLoginCompleteRequestSchema, AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema, AdminLoginCompleteResponseSchema,
AdminSessionResponseSchema, 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, CreditSummarySchema,
CreditEntryTypeSchema, CreditEntryTypeSchema,
CreditEntryStatusSchema, CreditEntryStatusSchema,
@@ -837,6 +924,167 @@ export async function createApp(options: CreateAppOptions = {}) {
status: "ready", status: "ready",
})); }));
const readAdminRequestSession = (request: { headers: Record<string, string | string[] | undefined> }) => {
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( app.get(
"/api/v1/static-stickers/current", "/api/v1/static-stickers/current",
{ schema: { hide: true } }, { schema: { hide: true } },
@@ -965,6 +1213,78 @@ export async function createApp(options: CreateAppOptions = {}) {
}, },
); );
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( app.get(
"/api/v1/assets/public/:resourceVersion/manifest", "/api/v1/assets/public/:resourceVersion/manifest",
{ schema: { hide: true } }, { schema: { hide: true } },
@@ -1012,6 +1332,13 @@ export async function createApp(options: CreateAppOptions = {}) {
const session = token ? options.registration.readUserSession(token) : undefined; const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
const { resourceVersion } = request.params as { resourceVersion: string }; 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); const available = options.assetReleases?.project("internal_preview_asset", resourceVersion);
if (!available || !options.previewAssetAuthorizer) return reply.code(404).send(); if (!available || !options.previewAssetAuthorizer) return reply.code(404).send();
const authorizedIds: string[] = []; const authorizedIds: string[] = [];
@@ -1042,6 +1369,19 @@ export async function createApp(options: CreateAppOptions = {}) {
const session = token ? options.registration.readUserSession(token) : undefined; const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); 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 }; 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 authorized = await options.previewAssetAuthorizer?.({ resourceId: assetId, releaseVersion: resourceVersion, userId: session.userId });
const resource = authorized ? options.assetReleases?.read("internal_preview_asset", resourceVersion, assetId) : undefined; const resource = authorized ? options.assetReleases?.read("internal_preview_asset", resourceVersion, assetId) : undefined;
if (!resource) return reply.code(404).send(); if (!resource) return reply.code(404).send();
@@ -1098,6 +1438,13 @@ export async function createApp(options: CreateAppOptions = {}) {
}) })
: false; : false;
if (userSession?.userId !== resource.ownerId && !controlledAdmin) return reply.code(404).send(); 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.type(resource.mimeType);
reply.header("Cache-Control", "private, no-store"); reply.header("Cache-Control", "private, no-store");
reply.header("Content-Disposition", "inline"); reply.header("Content-Disposition", "inline");
@@ -1201,10 +1548,16 @@ export async function createApp(options: CreateAppOptions = {}) {
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try { try {
options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); 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); const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest);
return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const }; return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const };
} catch (error) { } catch (error) {
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, 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); return reply.code(503).send(null);
} }
}, },
@@ -1331,19 +1684,314 @@ export async function createApp(options: CreateAppOptions = {}) {
if (!session) { if (!session) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); 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 { return {
acknowledged_private_content_notice_version: null, acknowledged_private_content_notice_version: acknowledgement?.version ?? null,
admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id }, admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id },
audience: "admin" as const, audience: "admin" as const,
authenticated: true as const, authenticated: true as const,
csrf_token: options.registration.issueAdminCsrfToken(token!), csrf_token: options.registration.issueAdminCsrfToken(token!),
current_private_content_notice_version: null, ...(notice ? { current_private_content_notice_message_key: notice.messageKey } : {}),
current_private_content_notice_version: notice?.version ?? null,
expires_at: new Date(session.expires_at).toISOString(), expires_at: new Date(session.expires_at).toISOString(),
notice_acknowledged: false, 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( app.post(
"/api/v1/auth/login/send", "/api/v1/auth/login/send",
{ {
+459
View File
@@ -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<ExternalServiceId, Partial<Record<ExternalServicePeriodType, number>>> = {
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<T>(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<string, unknown> | null;
beforeSummary: Record<string, unknown> | 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";
}
+17
View File
@@ -19,6 +19,7 @@ import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiC
import { ModelConfigurationService } from "./model-configuration.js"; import { ModelConfigurationService } from "./model-configuration.js";
import { MockAmapAdapter } from "./amap-adapter.js"; import { MockAmapAdapter } from "./amap-adapter.js";
import { StickerReleaseService } from "./sticker-releases.js"; import { StickerReleaseService } from "./sticker-releases.js";
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined; let registration: RegistrationService | undefined;
@@ -75,7 +76,22 @@ if (credentialChannelEnabled) {
} }
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
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({ const app = await createApp({
...(adminServicesStorage ? { adminServicesStorage } : {}),
...(adminDiagnostics ? { adminDiagnostics } : {}),
amap: new MockAmapAdapter(), amap: new MockAmapAdapter(),
...(browserSupportRelease ? { browserSupportRelease } : {}), ...(browserSupportRelease ? { browserSupportRelease } : {}),
...(credits ? { credits } : {}), ...(credits ? { credits } : {}),
@@ -85,6 +101,7 @@ const app = await createApp({
...(registration ? { registration } : {}), ...(registration ? { registration } : {}),
...(recentAssets ? { recentAssets } : {}), ...(recentAssets ? { recentAssets } : {}),
...(stickers ? { stickers } : {}), ...(stickers ? { stickers } : {}),
...(storage ? { storage } : {}),
}); });
await app.listen({ await app.listen({
+392
View File
@@ -75,6 +75,31 @@ interface CleanupQueueRow {
relative_path: string; 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 { export class StorageCapacityError extends Error {
readonly code = "STORAGE_CAPACITY_EXCEEDED"; readonly code = "STORAGE_CAPACITY_EXCEEDED";
readonly httpStatus = 507; readonly httpStatus = 507;
@@ -104,6 +129,10 @@ function auditExpiry(occurredAt: number) {
return occurredAt + auditRetentionMilliseconds; return occurredAt + auditRetentionMilliseconds;
} }
function digest(value: string) {
return createHash("sha256").update(value, "utf8").digest("hex");
}
function validatePositiveBytes(value: number, name: string) { function validatePositiveBytes(value: number, name: string) {
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`); if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`);
} }
@@ -255,6 +284,28 @@ export class ManagedStorage {
FOREIGN KEY (request_id) REFERENCES asset_cleanup_requests(request_id), FOREIGN KEY (request_id) REFERENCES asset_cleanup_requests(request_id),
FOREIGN KEY (managed_file_id) REFERENCES managed_files(file_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 ( CREATE TABLE IF NOT EXISTS admin_operation_logs (
log_id TEXT PRIMARY KEY, log_id TEXT PRIMARY KEY,
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
@@ -277,6 +328,49 @@ export class ManagedStorage {
if (!managedFileColumns.some((column) => column.name === "owner_ref")) { if (!managedFileColumns.some((column) => column.name === "owner_ref")) {
this.database.exec("ALTER TABLE managed_files ADD COLUMN owner_ref TEXT"); 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()); ensureAdminOperationAuditSchema(this.database, Date.now());
const initial = classifyCapacity(0, 0); const initial = classifyCapacity(0, 0);
this.database.prepare(` this.database.prepare(`
@@ -299,6 +393,106 @@ export class ManagedStorage {
return withReservations; 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<string, unknown>;
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) { private activeReservationBytes(excludingOperationId?: string) {
const row = this.database.prepare(` const row = this.database.prepare(`
SELECT COALESCE(SUM(projected_bytes), 0) AS bytes SELECT COALESCE(SUM(projected_bytes), 0) AS bytes
@@ -692,6 +886,203 @@ export class ManagedStorage {
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId); 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[]) { createCleanupIntent(fileIds: string[]) {
if (this.inspectAction("explicit_cleanup") !== "allow") throw new Error("cleanup_uncommitted"); 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"); if (fileIds.length === 0 || new Set(fileIds).size !== fileIds.length) throw new Error("cleanup_candidates_invalid");
@@ -777,6 +1168,7 @@ export class ManagedStorage {
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(row.managed_file_id); 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 }>; 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 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); this.database.prepare("DELETE FROM managed_files WHERE file_id = ?").run(row.managed_file_id);
for (const request of requests) { 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 }; const pendingItems = this.database.prepare("SELECT COUNT(*) AS count FROM asset_cleanup_request_items WHERE request_id = ?").get(request.request_id) as { count: number };
+483
View File
@@ -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<Pick<PreviewGrantServiceOptions, "clock">> & PreviewGrantServiceOptions;
private readonly itemMappings = new Map<string, PreviewManifestItemMapping>();
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<T>(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<string, unknown> | null;
beforeSummary: Record<string, unknown> | 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,
);
}
}
+186
View File
@@ -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 };
}
}
+30
View File
@@ -366,6 +366,7 @@ export class ProjectService {
throw new ProjectError("project_state_conflict", latest); throw new ProjectError("project_state_conflict", latest);
} }
this.insertProjectState({ canvasState: canvas, name, projectId: input.projectId, stateVersion: nextVersion }, now); this.insertProjectState({ canvasState: canvas, name, projectId: input.projectId, stateVersion: nextVersion }, now);
this.rebuildProjectStickerReferences(input.projectId, canvas, now);
this.database.prepare(` this.database.prepare(`
INSERT INTO project_state_idempotency ( INSERT INTO project_state_idempotency (
owner_id, project_id, idempotency_key, request_hash, response_state_version, created_at owner_id, project_id, idempotency_key, request_hash, response_state_version, created_at
@@ -377,6 +378,25 @@ export class ProjectService {
return result; 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[]) { trashFailedEmpty(ownerId: string, projectIds: string[]) {
const uniqueIds = [...new Set(projectIds)]; const uniqueIds = [...new Set(projectIds)];
if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid"); if (uniqueIds.length === 0 || uniqueIds.length > projectLimit) throw new ProjectError("generation_state_invalid");
@@ -774,6 +794,16 @@ export class ProjectService {
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE 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 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 ( CREATE TABLE IF NOT EXISTS latest_exports (
project_id TEXT NOT NULL, project_id TEXT NOT NULL,
format TEXT NOT NULL CHECK (format IN ('jpg', 'png')), format TEXT NOT NULL CHECK (format IN ('jpg', 'png')),
+68 -2
View File
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3"; import type BetterSqlite3 from "better-sqlite3";
import { import {
auditRetentionMilliseconds,
ensureAdminOperationAuditSchema, ensureAdminOperationAuditSchema,
ensurePrivateAccessAuditSchema, ensurePrivateAccessAuditSchema,
isSafeAuditRef, isSafeAuditRef,
@@ -11,6 +12,7 @@ import {
serializeAuditSummary, serializeAuditSummary,
} from "./audit-policy.js"; } from "./audit-policy.js";
import type { ResendAdapter } from "./resend-adapter.js"; import type { ResendAdapter } from "./resend-adapter.js";
import { ExternalServiceUsage } from "./external-service-usage.js";
import { import {
RegistrationError, RegistrationError,
type RegistrationErrorReason, type RegistrationErrorReason,
@@ -226,6 +228,7 @@ function constantTimeTextEqual(left: string, right: string) {
export class RegistrationService { export class RegistrationService {
readonly database: BetterSqlite3.Database; readonly database: BetterSqlite3.Database;
readonly serviceUsage: ExternalServiceUsage;
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions; readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
private adminAllowlistHashes = new Set<string>(); private adminAllowlistHashes = new Set<string>();
private privacyPurgeActive = false; private privacyPurgeActive = false;
@@ -254,6 +257,7 @@ export class RegistrationService {
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0); this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0); this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
this.migrate(); this.migrate();
this.serviceUsage = new ExternalServiceUsage({ database: this.database, clock: this.options.clock });
} }
close() { close() {
@@ -278,6 +282,39 @@ export class RegistrationService {
return { code, inviteId }; 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<RegistrationSendResult> { async sendRegistrationCode(input: { email: string; inviteCode: string }): Promise<RegistrationSendResult> {
const email = normalizeEmail(input.email); const email = normalizeEmail(input.email);
const inviteCode = normalizeProfileValue(input.inviteCode, 160); const inviteCode = normalizeProfileValue(input.inviteCode, 160);
@@ -299,6 +336,7 @@ export class RegistrationService {
if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required"); if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required");
this.assertChallengeSendAllowed(email, "register", "registration", now); this.assertChallengeSendAllowed(email, "register", "registration", now);
this.recordRateSend(email, "registration", now); this.recordRateSend(email, "registration", now);
this.serviceUsage.claimResendWithinTransaction(now);
this.database.prepare(` this.database.prepare(`
INSERT INTO email_challenges ( INSERT INTO email_challenges (
@@ -328,6 +366,7 @@ export class RegistrationService {
try { try {
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" }); await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" });
} catch { } catch {
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
this.runImmediate("registration_send_compensation", () => { this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
return { outcome: "committed", value: undefined }; return { outcome: "committed", value: undefined };
@@ -355,6 +394,7 @@ export class RegistrationService {
if (user.role !== "user") throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_admin_required"); if (user.role !== "user") throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_admin_required");
this.assertChallengeSendAllowed(email, "login", clientKey, now); this.assertChallengeSendAllowed(email, "login", clientKey, now);
this.recordRateSend(email, clientKey, now); this.recordRateSend(email, clientKey, now);
this.serviceUsage.claimResendWithinTransaction(now);
this.database.prepare(` this.database.prepare(`
INSERT INTO email_challenges ( INSERT INTO email_challenges (
challenge_id, email, invite_id, code_hmac, purpose, expires_at, challenge_id, email, invite_id, code_hmac, purpose, expires_at,
@@ -382,6 +422,7 @@ export class RegistrationService {
try { try {
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" }); await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" });
} catch { } catch {
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
this.runImmediate("registration_send_compensation", () => { this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
return { outcome: "committed", value: undefined }; return { outcome: "committed", value: undefined };
@@ -768,6 +809,7 @@ export class RegistrationService {
} }
this.assertChallengeSendAllowed(email, "admin_login", clientKey, now); this.assertChallengeSendAllowed(email, "admin_login", clientKey, now);
this.recordRateSend(email, clientKey, now); this.recordRateSend(email, clientKey, now);
this.serviceUsage.claimResendWithinTransaction(now);
this.database.prepare(` this.database.prepare(`
INSERT INTO email_challenges ( INSERT INTO email_challenges (
challenge_id, email, invite_id, code_hmac, purpose, expires_at, challenge_id, email, invite_id, code_hmac, purpose, expires_at,
@@ -795,6 +837,7 @@ export class RegistrationService {
try { try {
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" }); await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" });
} catch { } catch {
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
this.runImmediate("registration_send_compensation", () => { this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
this.recordAdminLoginRejection("service_unavailable", now); this.recordAdminLoginRejection("service_unavailable", now);
@@ -1097,6 +1140,7 @@ export class RegistrationService {
now + resendDelayMilliseconds, now + resendDelayMilliseconds,
now, now,
); );
this.serviceUsage.claimResendWithinTransaction(now);
return { return {
outcome: "committed", outcome: "committed",
value: { value: {
@@ -1116,6 +1160,7 @@ export class RegistrationService {
purpose: "account_delete", purpose: "account_delete",
}); });
} catch { } catch {
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
this.runImmediate("registration_send_compensation", () => { this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM account_deletion_challenges WHERE deletion_id = ? AND consumed_at IS NULL").run(deletionId); this.database.prepare("DELETE FROM account_deletion_challenges WHERE deletion_id = ? AND consumed_at IS NULL").run(deletionId);
return { outcome: "committed", value: undefined }; return { outcome: "committed", value: undefined };
@@ -1230,14 +1275,35 @@ export class RegistrationService {
return outcome; return outcome;
} }
changeUserStatus(userId: string, status: "suspended" | "deleted") { changeUserStatus(userId: string, status: "suspended" | "deleted", actorId?: string) {
const now = this.options.clock(); const now = this.options.clock();
this.runImmediate("session_revoke", () => { 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'") const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'")
.run(status, userId); .run(status, userId);
if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); 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") this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
.run(now, userId); .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 }; return { outcome: "committed", value: undefined };
}); });
} }
@@ -1761,7 +1827,7 @@ export class RegistrationService {
serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.beforeSummary),
serializeAuditSummary(input.afterSummary), serializeAuditSummary(input.afterSummary),
now, now,
now + 180 * 24 * 60 * 60 * 1_000, now + auditRetentionMilliseconds,
); );
} }
+75
View File
@@ -9,6 +9,12 @@ import sharp, { type Metadata } from "sharp";
import type { StaticStickerCatalogItem } from "@dada/static-sticker-catalog"; 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 { ManagedStorage, type StagedManagedFile } from "./managed-storage.js";
import { StickerReleaseError } from "./sticker-release-errors.js"; import { StickerReleaseError } from "./sticker-release-errors.js";
import { classifyCapacity } from "./storage-policy.js"; import { classifyCapacity } from "./storage-policy.js";
@@ -115,6 +121,12 @@ export class StickerReleaseService {
this.database.pragma("journal_mode = WAL"); this.database.pragma("journal_mode = WAL");
this.database.pragma("foreign_keys = ON"); this.database.pragma("foreign_keys = ON");
this.database.pragma("busy_timeout = 5000"); 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.storage = input.storage;
this.migrate(); this.migrate();
} }
@@ -234,6 +246,13 @@ export class StickerReleaseService {
WHERE release_version = ? AND stable_id = ? WHERE release_version = ? AND stable_id = ?
`).run((input.enabled ?? existing.enabled === 1) ? 1 : 0, part, order, version, input.stableId); `).run((input.enabled ?? existing.enabled === 1) ? 1 : 0, part, order, version, input.stableId);
this.finalizeRelease(version, current, input.actorId); 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 version;
}); });
return { item: itemView(this.readItem(releaseVersion, input.stableId)!), release_version: releaseVersion }; return { item: itemView(this.readItem(releaseVersion, input.stableId)!), release_version: releaseVersion };
@@ -328,15 +347,50 @@ export class StickerReleaseService {
input.width, input.height, input.expectedMimeType, input.original.sha256, input.original.fileId, input.original.bytes, 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, 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.consumeStagedStorage([input.original, input.thumbnail]);
this.database.prepare(` this.database.prepare(`
INSERT INTO sticker_upload_receipts (actor_id, idempotency_key_digest, request_hash, release_version, stable_id, created_at) INSERT INTO sticker_upload_receipts (actor_id, idempotency_key_digest, request_hash, release_version, stable_id, created_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(input.actorId, input.keyDigest, input.requestHash, releaseVersion, input.stableId, iso(this.clock())); `).run(input.actorId, input.keyDigest, input.requestHash, releaseVersion, input.stableId, iso(this.clock()));
this.finalizeRelease(releaseVersion, previous, input.actorId); 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; return releaseVersion;
} }
private insertReleaseAudit(input: {
actorId: string;
afterSummary: Record<string, unknown>;
beforeSummary: Record<string, unknown> | 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) { private finalizeRelease(releaseVersion: string, previous: string | null, actorId: string) {
const rows = this.database.prepare(` const rows = this.database.prepare(`
SELECT stable_id, part, order_index, original_sha256, thumbnail_sha256, enabled SELECT stable_id, part, order_index, original_sha256, thumbnail_sha256, enabled
@@ -510,6 +564,15 @@ export class StickerReleaseService {
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY (actor_id, idempotency_key_digest) 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 CREATE TRIGGER IF NOT EXISTS sticker_releases_no_update
BEFORE UPDATE ON sticker_releases BEGIN SELECT RAISE(ABORT, 'sticker_releases_immutable'); END; BEFORE UPDATE ON sticker_releases BEGIN SELECT RAISE(ABORT, 'sticker_releases_immutable'); END;
CREATE TRIGGER IF NOT EXISTS sticker_releases_no_delete CREATE TRIGGER IF NOT EXISTS sticker_releases_no_delete
@@ -523,5 +586,17 @@ export class StickerReleaseService {
WHEN EXISTS (SELECT 1 FROM sticker_releases WHERE release_version = OLD.release_version) WHEN EXISTS (SELECT 1 FROM sticker_releases WHERE release_version = OLD.release_version)
BEGIN SELECT RAISE(ABORT, 'sticker_release_items_immutable'); END; 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;
`);
} }
} }
+164
View File
@@ -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; }
}
+181
View File
@@ -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<Item> {
failed: boolean;
generatedAt: string | null;
items: Item[];
loading: boolean;
nextCursor: string | null;
}
const emptyState = <Item,>(): AuditPageState<Item> => ({
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<AuditTab>("operations");
const [operations, setOperations] = useState<AuditPageState<AdminOperationAuditItem>>(emptyState);
const [privateAccess, setPrivateAccess] = useState<AuditPageState<PrivateContentAccessAuditItem>>(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 (
<main className="admin-audit-page" id="admin-main">
<header className="admin-audit-heading">
<div><p>IMMUTABLE / 180 DAYS</p><h2></h2></div>
{state.generatedAt ? <time dateTime={state.generatedAt}> {formatTime(state.generatedAt)}</time> : null}
</header>
<div aria-label="审计类型" className="admin-audit-tabs" role="tablist">
<button aria-controls="operation-audit-panel" aria-selected={tab === "operations"} id="operation-audit-tab" onClick={() => selectTab("operations")} role="tab" type="button"></button>
<button aria-controls="private-audit-panel" aria-selected={tab === "private-content"} id="private-audit-tab" onClick={() => selectTab("private-content")} role="tab" type="button">访</button>
</div>
{state.failed ? (
<div className="admin-audit-failure" role="alert">
<span>{state.generatedAt ? ",已保留上次结果" : ""}</span>
<button disabled={state.loading} onClick={() => void reload()} type="button"></button>
</div>
) : null}
{tab === "operations" ? (
<section aria-labelledby="operation-audit-tab" id="operation-audit-panel" role="tabpanel">
{operations.loading && operations.items.length === 0 ? <p aria-live="polite" className="admin-audit-status"></p> : null}
{!operations.loading && !operations.failed && operations.items.length === 0 ? <p className="admin-audit-status"></p> : null}
{operations.items.length > 0 ? (
<div className="admin-audit-table-scroll">
<table>
<thead><tr><th></th><th></th><th></th><th></th><th></th><th>Operation ID</th></tr></thead>
<tbody>{operations.items.map((item) => (
<tr key={item.log_id}>
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
<td><code>{item.actor_ref}</code><small>{item.actor_type}</small></td>
<td><code>{item.operation_type}</code></td>
<td><code>{item.target_type}:{item.target_ref}</code><small>{operationSummary(item)}</small></td>
<td><strong className={`is-${item.result}`}>{item.result}</strong></td>
<td><code>{item.log_id}</code></td>
</tr>
))}</tbody>
</table>
</div>
) : null}
</section>
) : (
<section aria-labelledby="private-audit-tab" id="private-audit-panel" role="tabpanel">
{privateAccess.loading && privateAccess.items.length === 0 ? <p aria-live="polite" className="admin-audit-status">访</p> : null}
{!privateAccess.loading && !privateAccess.failed && privateAccess.items.length === 0 ? <p className="admin-audit-status">访</p> : null}
{privateAccess.items.length > 0 ? (
<div className="admin-audit-table-scroll">
<table>
<thead><tr><th></th><th></th><th></th><th></th><th></th><th>Access ID</th></tr></thead>
<tbody>{privateAccess.items.map((item) => (
<tr key={item.log_id}>
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
<td><code>{item.actor_ref}</code></td>
<td><code>{item.target_ref}</code></td>
<td>{item.content_type}</td>
<td><time dateTime={item.expires_at}>{formatTime(item.expires_at)}</time></td>
<td><code>{item.log_id}</code></td>
</tr>
))}</tbody>
</table>
</div>
) : null}
</section>
)}
{state.nextCursor ? (
<div className="admin-audit-pagination">
<button disabled={state.loading} onClick={() => void reload(state.nextCursor!, true)} type="button">{state.loading ? "正在读取" : "下一页"}</button>
</div>
) : null}
<p className="admin-audit-retention"> 180 </p>
</main>
);
}
+24
View File
@@ -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; }
+156
View File
@@ -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<AdminSession>();
const [records, setRecords] = useState<GenerationRecord[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [acknowledging, setAcknowledging] = useState(false);
const [notice, setNotice] = useState("");
const [openedPrompt, setOpenedPrompt] = useState<OpenedPrompt>();
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 (
<main className="admin-generations" id="admin-main">
<header className="admin-page-heading"><div><p>OPERATIONS / GENERATION RECORDS</p><h2></h2></div><button className="admin-generations-refresh" onClick={() => void load()} type="button"></button></header>
{loading ? <p aria-live="polite"></p> : null}
{failed ? <div className="admin-generations-error" role="alert"><button onClick={() => void load()} type="button"></button></div> : null}
{notice ? <p className="admin-generations-notice" role="alert">{notice}</p> : null}
{session && !session.notice_acknowledged ? (
<section aria-labelledby="private-content-notice-title" className="admin-generations-notice-panel">
<p>PRIVATE CONTENT ACCESS</p>
<h3 id="private-content-notice-title"></h3>
<p>访</p>
<button disabled={acknowledging} onClick={() => void acknowledge()} type="button">{acknowledging ? "确认中" : "确认并进入记录"}</button>
</section>
) : null}
{session?.notice_acknowledged ? (
<section aria-label="生成记录元数据" className="admin-generations-table-wrap">
<table><thead><tr><th></th><th></th><th> / </th><th></th><th> / </th><th></th><th></th></tr></thead><tbody>
{records.map((record) => <tr key={record.generation_id}>
<td><code>{compactId(record.generation_id)}</code></td>
<td><code>{compactId(record.owner_ref)}</code></td>
<td>{record.model_id}<br /><small>{record.ratio}</small></td>
<td><span className={`admin-generations-status is-${record.status}`}>{statusLabel(record.status)}</span>{record.error_category ? <small>{record.error_category}</small> : null}</td>
<td><time dateTime={record.created_at}>{formatTime(record.created_at)}</time><br /><small>{formatTime(record.completed_at)}</small></td>
<td>{record.confirmed_credit_cost} / {record.final_credit_state ?? "冻结"}</td>
<td className="admin-generations-actions"><button onClick={() => void openPrompt(record.generation_id)} type="button"></button><button disabled={record.status !== "succeeded"} onClick={() => void openImage(record.generation_id)} type="button"></button></td>
</tr>)}
</tbody></table>
{!records.length && !loading ? <p className="admin-generations-empty"></p> : null}
</section>
) : null}
{openedPrompt ? <section aria-label="已审计的完整提示词" className="admin-generations-opened"><header><h3></h3><button onClick={() => setOpenedPrompt(undefined)} type="button"></button></header><p><code>{compactId(openedPrompt.generation_id)}</code></p><pre>{openedPrompt.prompt}</pre></section> : null}
{openedImage ? <section aria-label="已审计的生成图片" className="admin-generations-opened"><header><h3></h3><button onClick={() => { URL.revokeObjectURL(openedImage.url); setOpenedImage(undefined); }} type="button"></button></header><img alt="已记录审计的生成图片" src={openedImage.url} /></section> : null}
</main>
);
}
+12 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "./admin-models.css"; import "./admin-models.css";
@@ -64,7 +64,7 @@ export function AdminModelsPage() {
const priorityRefs = useRef<Record<string, HTMLInputElement | null>>({}); const priorityRefs = useRef<Record<string, HTMLInputElement | null>>({});
const defaultRefs = useRef<Record<string, HTMLInputElement | null>>({}); const defaultRefs = useRef<Record<string, HTMLInputElement | null>>({});
async function load() { const load = useCallback(async () => {
setLoadingFailed(false); setLoadingFailed(false);
setConflicted(false); setConflicted(false);
try { try {
@@ -80,9 +80,16 @@ export function AdminModelsPage() {
} catch { } catch {
setLoadingFailed(true); setLoadingFailed(true);
} }
} }, []);
useEffect(() => { void load(); }, []); 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(() => { const validation = useMemo(() => {
if (!draft) return { valid: false, message: "" }; if (!draft) return { valid: false, message: "" };
@@ -147,11 +154,7 @@ export function AdminModelsPage() {
return ( return (
<div className="admin-models-page"> <div className="admin-models-page">
<header className="admin-product-header"> <main id="admin-main">
<a href="/admin">DADA ADMIN</a>
<nav aria-label="后台导航"><a href="/admin/users"></a><a aria-current="page" href="/admin/models"></a><a href="/admin/assets"></a><a href="/admin/audit"></a></nav>
</header>
<main>
<header className="admin-models-heading"> <header className="admin-models-heading">
<div><p>MODEL OPERATIONS</p><h1></h1></div> <div><p>MODEL OPERATIONS</p><h1></h1></div>
{configuration ? <strong> v{configuration.config_set_version}</strong> : null} {configuration ? <strong> v{configuration.config_set_version}</strong> : null}
+37
View File
@@ -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; } }
+136
View File
@@ -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<AdminServicesStorageResponse["services"][number]["service_id"], string> = {
ai_gateway: "AI 网关",
amap: "高德",
api: "API",
asset_root: "素材根",
resend: "Resend",
worker: "Worker",
};
const statusLabels: Record<AdminServicesStorageResponse["services"][number]["status"], string> = {
active: "正常",
degraded: "有异常",
disabled: "已停用",
paused_provider: "供应商暂停",
paused_quota: "额度暂停",
unavailable: "不可用",
};
const impactLabels: Record<AdminServicesStorageResponse["services"][number]["impact_scope"], string> = {
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<T>(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<AdminServicesStorageResponse>();
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsResponse>();
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<AdminServicesStorageResponse>("/api/v1/admin/services-storage"),
getJson<AdminDiagnosticsResponse>("/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 (
<main className="admin-services-storage" id="admin-main">
<header className="admin-page-heading admin-services-heading">
<div><p>OPERATIONS / HEALTH</p><h2></h2></div>
<div className="admin-services-heading-actions">
{state ? <time dateTime={state.generated_at}> {formatTime(state.generated_at)}</time> : null}
<button aria-label="重新读取服务与存储状态" onClick={() => void load()} type="button"></button>
</div>
</header>
{loading && !state ? <div aria-label="服务与存储状态加载中" className="admin-services-loading"><span /><span /><span /><span /></div> : null}
{failed ? <div className="admin-services-failure" role="alert"><span>{state ? `,保留 ${formatTime(state.generated_at)} 的结果` : ""}</span><button onClick={() => void load()} type="button"></button></div> : null}
{state ? (
<>
<section aria-labelledby="admin-services-list-heading" className="admin-health-section">
<header><div><p>SERVICE STATUS</p><h3 id="admin-services-list-heading"></h3></div><span className="admin-safe-note"></span></header>
<div className="admin-service-grid">
{state.services.map((service) => (
<article className={`admin-service-card is-${service.status}`} key={service.service_id}>
<div className="admin-service-card-heading"><strong>{serviceLabels[service.service_id]}</strong><span>{statusLabels[service.status]}</span></div>
<dl>
<div><dt></dt><dd>{service.configured ? "已配置" : "未配置"}</dd></div>
<div><dt></dt><dd>{impactLabels[service.impact_scope]}</dd></div>
<div><dt></dt><dd>{formatTime(service.checked_at)}</dd></div>
{service.pause_reason ? <div><dt></dt><dd>{service.pause_reason}</dd></div> : null}
</dl>
</article>
))}
</div>
</section>
<section aria-labelledby="admin-storage-heading" className="admin-health-section">
<header><div><p>LOCAL DATA ROOT</p><h3 id="admin-storage-heading"></h3></div><span className={`admin-storage-state is-${state.storage.status}`}>{state.storage.status === "active" ? "可写" : state.storage.status === "full" ? "已满" : "不可用"}</span></header>
<div className="admin-storage-metrics">
<div><span></span><strong>{(state.storage.managed_content_bytes / 1024 / 1024 / 1024).toFixed(2)} GB</strong></div>
<div><span></span><strong>{(state.storage.hard_limit_bytes / 1024 / 1024 / 1024).toFixed(2)} GB</strong></div>
<div><span></span><strong>{state.storage.capacity_notice_level}</strong></div>
<div><span></span><strong>{state.storage.cleanup_pending_count}</strong></div>
</div>
<div className="admin-storage-progress" aria-label={`本机内容容量 ${(state.storage.managed_content_bytes / state.storage.hard_limit_bytes * 100).toFixed(1)}%`}><span style={{ width: `${Math.min(100, state.storage.managed_content_bytes / state.storage.hard_limit_bytes * 100)}%` }} /></div>
<p className="admin-storage-description"> Windows Dada <code>{state.storage.data_root_ref}</code> 5 GB </p>
<dl className="admin-storage-details"><div><dt></dt><dd>{formatTime(state.storage.last_measured_at)}</dd></div><div><dt></dt><dd>{state.storage.remeasurement_required ? "需要完成" : "无需等待"}</dd></div></dl>
</section>
<section aria-labelledby="admin-diagnostics-heading" className="admin-health-section admin-diagnostics-section">
<header><div><p>DIAGNOSTICS</p><h3 id="admin-diagnostics-heading"></h3></div><button disabled={!diagnostics} onClick={() => void copyDiagnostics()} type="button">{copied ? "已复制" : "复制诊断"}</button></header>
<p></p>
{diagnostics ? <pre aria-label="脱敏诊断内容">{diagnostics.diagnostic_text}</pre> : <div aria-label="诊断加载中" className="admin-diagnostics-placeholder" />}
</section>
</>
) : null}
</main>
);
}
+523
View File
@@ -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;
}
}
+229
View File
@@ -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<AdminSession>();
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 (
<main className="admin-session-gate">
{failed ? (
<div role="alert">
<strong></strong>
<button onClick={() => setRevision((value) => value + 1)} type="button"></button>
</div>
) : <p aria-live="polite"></p>}
</main>
);
}
return (
<div className="admin-shell">
<a className="admin-skip-link" href="#admin-main"></a>
<aside className="admin-sidebar">
<a className="admin-wordmark" href="/admin" aria-label="Dada 后台总览">
<span>DADA</span>
<small>OPERATIONS</small>
</a>
<nav aria-label="后台主导航">
{adminNavigation.map((item) => (
<a aria-current={currentPath === item.href ? "page" : undefined} href={item.href} key={item.href}>
<span aria-hidden="true">{item.marker}</span>
{item.label}
</a>
))}
</nav>
<div className="admin-sidebar-foot">
<span>LOCAL P0-A</span>
<strong></strong>
</div>
</aside>
<div className="admin-shell-workspace">
<header className="admin-topbar">
<h1>{title}</h1>
<div className="admin-topbar-status">
<span><i aria-hidden="true" /></span>
<code>{session.admin.user_id.slice(0, 8)}</code>
</div>
</header>
<div className="admin-shell-content">{children}</div>
</div>
</div>
);
}
const serviceLabels: Record<AdminOverviewResponse["services"][number]["service_id"], string> = {
ai_gateway: "AI 网关",
amap: "高德",
asset_root: "素材根",
resend: "Resend",
worker: "Worker",
};
const stateLabels = {
available: "正常",
degraded: "有异常",
paused: "已暂停",
unavailable: "不可用",
} as const;
function formatTime(value: string | null) {
if (!value) return "未记录";
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", month: "2-digit", day: "2-digit" }).format(new Date(value));
}
export function AdminOverviewPage() {
const [summary, setSummary] = useState<AdminOverviewResponse>();
const [failed, setFailed] = useState(false);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
setFailed(false);
try {
const response = await fetch("/api/v1/admin/overview", { credentials: "same-origin" });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
return;
}
if (!response.ok) throw new Error("admin_overview_unavailable");
setSummary(await response.json() as AdminOverviewResponse);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const storagePercent = summary
? Math.min(100, (summary.storage.managed_content_bytes / summary.storage.limit_bytes) * 100)
: 0;
const hasServiceIssue = summary?.services.some((service) => service.status !== "available") ?? false;
return (
<main className="admin-overview" id="admin-main">
<header className="admin-page-heading">
<div><p>OPERATIONS / LIVE SUMMARY</p><h2></h2></div>
{summary ? <time dateTime={summary.generated_at}> {formatTime(summary.generated_at)}</time> : null}
</header>
{summary && summary.storage.status !== "normal" ? (
<a className={`admin-capacity-alert is-${summary.storage.status}`} href="/admin/services-storage">
<span></span>
<strong>{storagePercent.toFixed(1)}%</strong>
<span>{summary.storage.status === "critical" ? "接近上限" : summary.storage.status === "full" ? "已满" : "不可用"}</span>
</a>
) : null}
{loading && !summary ? (
<div aria-label="运营摘要加载中" className="admin-overview-loading"><span /><span /><span /><span /></div>
) : null}
{failed ? (
<div className="admin-overview-failure" role="alert">
<span>{summary ? `,当前保留 ${formatTime(summary.generated_at)} 的结果` : ""}</span>
<button onClick={() => void load()} type="button"></button>
</div>
) : null}
{summary ? (
<>
<section aria-label="关键运营指标" className="admin-metric-band">
<a href="/admin/users"><span></span><strong>{summary.user_slots.active_and_suspended} / {summary.user_slots.limit}</strong><small>active + suspended</small></a>
<a href="/admin/generations"><span></span><strong>{summary.generation_jobs.queued + summary.generation_jobs.running}</strong><small> {summary.generation_jobs.queued} · {summary.generation_jobs.running}</small></a>
<a href="/admin/generations"><span></span><strong> {summary.generation_jobs.pending_manual_review}</strong><small> {formatTime(summary.generation_jobs.pending_manual_review_oldest_at)}</small></a>
<a href="/admin/assets"><span></span><strong>{summary.asset_cleanup.pending_jobs}</strong><small></small></a>
</section>
<div className="admin-overview-columns">
<section className="admin-status-section" aria-labelledby="model-status-heading">
<header><div><p>MODEL STATE</p><h3 id="model-status-heading"></h3></div><a href="/admin/models"></a></header>
<dl>
<div><dt></dt><dd>{summary.models.configured_default_model_id ?? "无"}</dd></div>
<div><dt></dt><dd>{summary.models.runtime_available_count} / {summary.models.configured_model_count}</dd></div>
<div><dt></dt><dd>{summary.models.recommended_model_id ?? "无"}</dd></div>
</dl>
</section>
<section className="admin-status-section" aria-labelledby="service-status-heading">
<header><div><p>SERVICE STATE</p><h3 id="service-status-heading"></h3></div><a href="/admin/services-storage">{hasServiceIssue ? "有异常" : "全部正常"}</a></header>
<ul className="admin-service-list">
{summary.services.map((service) => <li key={service.service_id}><span>{serviceLabels[service.service_id]}</span><strong className={`is-${service.status}`}>{stateLabels[service.status]}</strong><time dateTime={service.checked_at ?? undefined}>{formatTime(service.checked_at)}</time></li>)}
</ul>
</section>
</div>
<section className="admin-operation-strip" aria-labelledby="recent-operation-heading">
<header><div><p>AUDIT SNAPSHOT</p><h3 id="recent-operation-heading"></h3></div><a href="/admin/audit"></a></header>
{summary.recent_operations.length === 0 ? <p></p> : (
<table><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{summary.recent_operations.map((operation) => <tr key={operation.operation_id}><td>{formatTime(operation.created_at)}</td><td>{operation.operation_type}</td><td><code>{operation.target_ref}</code></td><td>{operation.result}</td></tr>)}</tbody></table>
)}
</section>
</>
) : null}
</main>
);
}
export function AdminPlaceholderPage({ title }: { title: string }) {
return (
<main className="admin-placeholder" id="admin-main">
<header className="admin-page-heading"><div><p>OPERATIONS</p><h2>{title}</h2></div></header>
<section aria-label={`${title}安全摘要`}>
<div className="admin-placeholder-toolbar"><span></span><button disabled type="button"></button></div>
<p></p>
</section>
</main>
);
}
+1 -5
View File
@@ -100,11 +100,7 @@ export function AdminUsersPage() {
return ( return (
<div className="admin-users-page"> <div className="admin-users-page">
<header className="admin-product-header"> <main id="admin-main">
<a href="/admin">DADA ADMIN</a>
<nav aria-label="后台导航"><a aria-current="page" href="/admin/users"></a><a href="/admin/models"></a><a href="/admin/assets"></a><a href="/admin/audit"></a></nav>
</header>
<main>
<header className="admin-users-heading"> <header className="admin-users-heading">
<div><p>USER OPERATIONS</p><h1></h1></div> <div><p>USER OPERATIONS</p><h1></h1></div>
{balance ? <button onClick={openAdjustment} type="button"></button> : null} {balance ? <button onClick={openAdjustment} type="button"></button> : null}
+112 -1
View File
@@ -1,9 +1,18 @@
// Generated from openapi/openapi.json. Do not edit by hand. // Generated from openapi/openapi.json. Do not edit by hand.
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOperationAuditResponse, AdminOverviewResponse, AdminServicesResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, PrivateContentAccessAuditResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
export async function ackPrivateContentNotice(body: PrivateContentNoticeAckRequest, options: ClientOptions = {}): Promise<PrivateContentNoticeAckResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content-notice/ack`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<PrivateContentNoticeAckResponse>;
}
export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise<CreditAdjustmentResponse> { export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise<CreditAdjustmentResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
@@ -13,6 +22,23 @@ export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, opti
return response.json() as Promise<CreditAdjustmentResponse>; return response.json() as Promise<CreditAdjustmentResponse>;
} }
export async function checkAdminServiceHealth(body: AdminServiceHealthCheckRequest, options: ClientOptions = {}): Promise<{
"available": boolean;
"check_id": string;
"checked_at": string;
}> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services/{service_id}/health-check`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<{
"available": boolean;
"check_id": string;
"checked_at": string;
}>;
}
export async function checkBrowserSupport(body: BrowserSupportRequest, options: ClientOptions = {}): Promise<BrowserSupportSuccess> { export async function checkBrowserSupport(body: BrowserSupportRequest, options: ClientOptions = {}): Promise<BrowserSupportSuccess> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
@@ -87,6 +113,41 @@ export async function getAccountSettings(options: ClientOptions = {}): Promise<A
return response.json() as Promise<AccountSettingsResponse>; return response.json() as Promise<AccountSettingsResponse>;
} }
export async function getAdminDiagnostics(options: ClientOptions = {}): Promise<AdminDiagnosticsResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/diagnostics`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminDiagnosticsResponse>;
}
export async function getAdminOperationAudit(options: ClientOptions = {}): Promise<AdminOperationAuditResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/operations`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminOperationAuditResponse>;
}
export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminOverviewResponse>;
}
export async function getAdminServices(options: ClientOptions = {}): Promise<AdminServicesResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminServicesResponse>;
}
export async function getAdminServicesStorage(options: ClientOptions = {}): Promise<AdminServicesStorageResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services-storage`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminServicesStorageResponse>;
}
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> { export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
@@ -154,6 +215,13 @@ export async function getMyCredits(options: ClientOptions = {}): Promise<CreditB
return response.json() as Promise<CreditBalanceResponse>; return response.json() as Promise<CreditBalanceResponse>;
} }
export async function getPrivateContentAccessAudit(options: ClientOptions = {}): Promise<PrivateContentAccessAuditResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/private-content`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<PrivateContentAccessAuditResponse>;
}
export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> { export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} });
@@ -168,6 +236,13 @@ export async function getUserSession(options: ClientOptions = {}): Promise<UserS
return response.json() as Promise<UserSessionResponse>; return response.json() as Promise<UserSessionResponse>;
} }
export async function listAdminGenerations(options: ClientOptions = {}): Promise<AdminGenerationListResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/generations`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminGenerationListResponse>;
}
export async function listProjects(options: ClientOptions = {}): Promise<ProjectListResponse> { export async function listProjects(options: ClientOptions = {}): Promise<ProjectListResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} });
@@ -189,6 +264,20 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
return response.json() as Promise<LogoutResponse>; return response.json() as Promise<LogoutResponse>;
} }
export async function openAdminGenerationImage(options: ClientOptions = {}): Promise<Blob> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/image`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.blob() as Promise<Blob>;
}
export async function openAdminGenerationPrompt(options: ClientOptions = {}): Promise<PrivateContentPromptResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/prompt`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<PrivateContentPromptResponse>;
}
export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> { export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} });
@@ -205,6 +294,19 @@ export async function recordRecentAsset(body: RecentAssetRecordRequest, options:
return response.json() as Promise<RecentAssetRecordResponse>; return response.json() as Promise<RecentAssetRecordResponse>;
} }
export async function recoverAdminService(body: AdminServiceRecoveryRequest, options: ClientOptions = {}): Promise<{
"status": "active";
}> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services/{service_id}/recover`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<{
"status": "active";
}>;
}
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> { export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
@@ -314,3 +416,12 @@ export async function updateAccountProfile(body: AccountProfileUpdateRequest, op
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AccountProfileUpdateResponse>; return response.json() as Promise<AccountProfileUpdateResponse>;
} }
export async function updateAdminServiceHardLimit(body: AdminServiceLimitRequest, options: ClientOptions = {}): Promise<AdminServicesResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services/{service_id}/limits`, { body: JSON.stringify(body), method: "PATCH", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminServicesResponse>;
}
+200
View File
@@ -50,6 +50,11 @@ export type AccountSettingsResponse = {
}; };
}; };
export type AdminAuditQuery = {
"cursor"?: string;
"limit"?: number;
};
export type AdminAuthenticatedUser = { export type AdminAuthenticatedUser = {
"role": "super_admin"; "role": "super_admin";
"status": "active"; "status": "active";
@@ -60,6 +65,42 @@ export type AdminCreditParams = {
"userId": string; "userId": string;
}; };
export type AdminDiagnosticsResponse = {
"diagnostic_text": string;
"generated_at": string;
"services": AdminServicesStorageResponse;
"system": {
"api_status": "ready" | "degraded" | "unavailable";
"app_version": string;
"browser_support": Array<{
"brand": "Google Chrome" | "Microsoft Edge";
"major": number;
}>;
"worker_status": "ready" | "degraded" | "unavailable";
};
};
export type AdminGenerationListResponse = {
"generated_at": string;
"items": Array<AdminGenerationRecord>;
};
export type AdminGenerationRecord = {
"completed_at": string | null;
"confirmed_credit_cost": number;
"created_at": string;
"duration_ms": number | null;
"error_category": "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable" | null;
"final_credit_state": "committed" | "released" | null;
"generation_id": string;
"model_id": string;
"owner_ref": string;
"project_id": string;
"ratio": "3:4" | "1:1" | "4:3" | "9:16";
"reserved_credits": number;
"status": "queued" | "running" | "succeeded" | "failed" | "rejected";
};
export type AdminLoginCompleteRequest = { export type AdminLoginCompleteRequest = {
"registration_id": string; "registration_id": string;
"verification_code": string; "verification_code": string;
@@ -76,12 +117,119 @@ export type AdminLoginSendRequest = {
"email": string; "email": string;
}; };
export type AdminOperationAuditItem = {
"actor_ref": string;
"actor_type": "system" | "super_admin";
"after_summary": string | null;
"before_summary": string | null;
"expires_at": string;
"log_id": string;
"occurred_at": string;
"operation_type": string;
"result": "succeeded" | "failed";
"target_ref": string;
"target_type": string;
};
export type AdminOperationAuditResponse = {
"generated_at": string;
"items": Array<AdminOperationAuditItem>;
"next_cursor": string | null;
};
export type AdminOverviewResponse = {
"asset_cleanup": {
"pending_jobs": number;
};
"generated_at": string;
"generation_jobs": {
"pending_manual_review": number;
"pending_manual_review_oldest_at": string | null;
"queued": number;
"running": number;
};
"models": {
"configured_default_model_id": string | null;
"configured_model_count": number;
"recommended_model_id": string | null;
"runtime_available_count": number;
};
"recent_operations": Array<{
"created_at": string;
"operation_id": string;
"operation_type": string;
"result": "succeeded" | "rejected" | "failed";
"target_ref": string;
}>;
"services": Array<{
"checked_at": string | null;
"service_id": "resend" | "amap" | "ai_gateway" | "worker" | "asset_root";
"status": "available" | "degraded" | "paused" | "unavailable";
}>;
"storage": {
"last_measured_at": string | null;
"limit_bytes": number;
"managed_content_bytes": number;
"status": "normal" | "critical" | "full" | "unavailable";
};
"user_slots": {
"active_and_suspended": number;
"limit": number;
};
};
export type AdminServiceHealthCheckRequest = {
"available": boolean;
"reason"?: string;
};
export type AdminServiceLimitRequest = {
"hard_limit": number;
"period_type": ExternalServicePeriodType;
};
export type AdminServiceParams = {
"service_id": ExternalServiceId;
};
export type AdminServiceRecoveryRequest = {
"check_id": string;
};
export type AdminServicesResponse = {
"services": Array<ExternalServiceUsage>;
};
export type AdminServicesStorageResponse = {
"generated_at": string;
"services": Array<{
"checked_at": string | null;
"configured": boolean;
"impact_scope": "none" | "authentication" | "location" | "generation" | "storage" | "api" | "model" | "account" | "unknown";
"pause_reason": string | null;
"service_id": "resend" | "amap" | "ai_gateway" | "worker" | "api" | "asset_root";
"status": "active" | "paused_quota" | "paused_provider" | "disabled" | "degraded" | "unavailable";
}>;
"storage": {
"capacity_notice_level": "normal" | "warning" | "critical";
"cleanup_pending_count": number;
"data_root_ref": "configured_local_data_root";
"hard_limit_bytes": number;
"last_measured_at": string | null;
"managed_content_bytes": number;
"remeasurement_required": boolean;
"status": "active" | "full" | "unavailable";
"storage_backend": "local_filesystem";
};
};
export type AdminSessionResponse = { export type AdminSessionResponse = {
"acknowledged_private_content_notice_version": string | null; "acknowledged_private_content_notice_version": string | null;
"admin": AdminAuthenticatedUser; "admin": AdminAuthenticatedUser;
"audience": "admin"; "audience": "admin";
"authenticated": true; "authenticated": true;
"csrf_token": string; "csrf_token": string;
"current_private_content_notice_message_key"?: string;
"current_private_content_notice_version": string | null; "current_private_content_notice_version": string | null;
"expires_at": string; "expires_at": string;
"notice_acknowledged": boolean; "notice_acknowledged": boolean;
@@ -315,6 +463,23 @@ export type ErrorEnvelope = {
export type ExportFormat = "jpg" | "png"; export type ExportFormat = "jpg" | "png";
export type ExternalServiceId = "resend_email" | "amap_web_service";
export type ExternalServicePeriodType = "daily" | "monthly";
export type ExternalServiceStatus = "active" | "paused_quota" | "paused_provider" | "disabled";
export type ExternalServiceUsage = {
"hard_limit": number;
"pause_reason": string | null;
"period_start": string;
"period_type": ExternalServicePeriodType;
"service_id": ExternalServiceId;
"service_status": ExternalServiceStatus;
"updated_at": string;
"used_count": number;
};
export type FailedEmptyTrashRequest = { export type FailedEmptyTrashRequest = {
"project_ids": Array<ProjectId>; "project_ids": Array<ProjectId>;
}; };
@@ -541,6 +706,41 @@ export type ModelRuntimeSseEvent = {
"runtime_availability_version": number; "runtime_availability_version": number;
}; };
export type PrivateContentAccessAuditItem = {
"actor_ref": string;
"content_type": "image" | "prompt";
"expires_at": string;
"log_id": string;
"occurred_at": string;
"target_ref": string;
};
export type PrivateContentAccessAuditResponse = {
"generated_at": string;
"items": Array<PrivateContentAccessAuditItem>;
"next_cursor": string | null;
};
export type PrivateContentGenerationParams = {
"generationId": string;
};
export type PrivateContentNoticeAckRequest = {
"expected_notice_version": string;
};
export type PrivateContentNoticeAckResponse = {
"acknowledged_at": string;
"notice_version": string;
"status": "acknowledged";
};
export type PrivateContentPromptResponse = {
"content_type": "prompt";
"generation_id": string;
"prompt": string;
};
export type ProjectDetailResponse = { export type ProjectDetailResponse = {
"canvas_state": CanvasState; "canvas_state": CanvasState;
"created_at": string; "created_at": string;
+25 -5
View File
@@ -1,4 +1,4 @@
import { StrictMode } from "react"; import { StrictMode, type ReactNode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js"; import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
@@ -8,9 +8,13 @@ import { AccountSettingsPage } from "./account-settings.js";
import { AdminUsersPage } from "./admin-users.js"; import { AdminUsersPage } from "./admin-users.js";
import { AdminModelsPage } from "./admin-models.js"; import { AdminModelsPage } from "./admin-models.js";
import { AdminAssetsPage } from "./admin-assets.js"; import { AdminAssetsPage } from "./admin-assets.js";
import { AdminGenerationsPage } from "./admin-generations.js";
import { AdminServicesStoragePage } from "./admin-services-storage.js";
import { AdminAuditPage } from "./admin-audit.js";
import { CreditsPage } from "./credits-page.js"; import { CreditsPage } from "./credits-page.js";
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js"; import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
import { EditorPage } from "./editor-page.js"; import { EditorPage } from "./editor-page.js";
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
const root = document.getElementById("root"); const root = document.getElementById("root");
@@ -35,10 +39,26 @@ function renderAuthenticationEntry() {
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />; else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />; else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />; else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
else if (window.location.pathname === "/admin/users") authenticationPage = <AdminUsersPage key={authRevision} />; else if (window.location.pathname === "/admin/login") authenticationPage = <AdminAuthPage key={authRevision} />;
else if (window.location.pathname === "/admin/models") authenticationPage = <AdminModelsPage key={authRevision} />; else if (window.location.pathname.startsWith("/admin")) {
else if (window.location.pathname === "/admin/assets") authenticationPage = <AdminAssetsPage key={authRevision} />; const adminPages: Record<string, { content: ReactNode; title: string }> = {
else if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />; "/admin": { content: <AdminOverviewPage />, title: "运营总览" },
"/admin/assets": { content: <AdminAssetsPage />, title: "素材" },
"/admin/audit": { content: <AdminAuditPage />, title: "审计" },
"/admin/generations": { content: <AdminGenerationsPage />, title: "生成记录" },
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
"/admin/models": { content: <AdminModelsPage />, title: "模型" },
"/admin/preview": { content: <AdminPlaceholderPage title="内部预览" />, title: "内部预览" },
"/admin/services-storage": { content: <AdminServicesStoragePage />, title: "服务与存储" },
"/admin/users": { content: <AdminUsersPage />, title: "用户与点数" },
};
const page = adminPages[window.location.pathname] ?? adminPages["/admin"]!;
authenticationPage = (
<AdminProtectedRoute currentPath={window.location.pathname} key={authRevision} title={page.title}>
{page.content}
</AdminProtectedRoute>
);
}
else authenticationPage = <UserAuthPage key={authRevision} />; else authenticationPage = <UserAuthPage key={authRevision} />;
appRoot.render( appRoot.render(
<StrictMode> <StrictMode>
+114 -6
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { rmSync } from "node:fs"; import { existsSync, rmSync, statSync } from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { isAbsolute, relative, resolve } from "node:path"; import { isAbsolute, relative, resolve } from "node:path";
@@ -29,6 +29,40 @@ interface FileCleanupRow {
const resourceScope = JSON.stringify([ const resourceScope = JSON.stringify([
"project_state", "generation", "generated_image", "reference", "location", "latest_export", "project_state", "generation", "generated_image", "reference", "location", "latest_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 forbiddenSummaryFragments = ["content", "credential", "email", "image", "password", "path", "prompt", "secret", "token"];
function isSafeAuditRef(value: unknown) {
return typeof value === "string" && auditRefPattern.test(value) ? 1 : 0;
}
function isSafeAuditSummaryJson(value: unknown) {
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 2_048) return 0;
try {
const valid = (entry: unknown, depth: number): boolean => {
if (depth > 3) return false;
if (entry === null || typeof entry === "boolean") return true;
if (typeof entry === "number") return Number.isSafeInteger(entry);
if (typeof entry === "string") return /^[A-Za-z0-9_.:@-]{1,160}$/.test(entry) && !entry.includes("@");
if (Array.isArray(entry)) return entry.length <= 20 && entry.every((item) => valid(item, depth + 1));
if (!entry || typeof entry !== "object") return false;
return Object.entries(entry).length <= 32 && Object.entries(entry).every(([key, item]) => (
auditRefPattern.test(key)
&& !forbiddenSummaryKeys.has(key.toLowerCase())
&& !forbiddenSummaryFragments.some((fragment) => key.toLowerCase().includes(fragment))
&& valid(item, depth + 1)
));
};
return valid(JSON.parse(value), 0) ? 1 : 0;
} catch {
return 0;
}
}
function iso(timestamp: number) { function iso(timestamp: number) {
return new Date(timestamp).toISOString(); return new Date(timestamp).toISOString();
@@ -47,6 +81,12 @@ export class ProjectPurgeCleanup {
this.database.pragma("journal_mode = WAL"); this.database.pragma("journal_mode = WAL");
this.database.pragma("foreign_keys = ON"); this.database.pragma("foreign_keys = ON");
this.database.pragma("busy_timeout = 5000"); 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);
} }
close() { close() {
@@ -153,12 +193,16 @@ export class ProjectPurgeCleanup {
if (pending.count === 0) { if (pending.count === 0) {
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'completed' WHERE request_id = ? AND status = 'queued'") this.database.prepare("UPDATE asset_cleanup_requests SET status = 'completed' WHERE request_id = ? AND status = 'queued'")
.run(request.request_id); .run(request.request_id);
this.insertAssetCleanupAudit(request.request_id, row.byte_size, this.clock());
} }
} }
} }
} }
if (this.tableExists("sticker_managed_file_history")) {
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); this.database.prepare("DELETE FROM managed_files WHERE file_id = ?").run(row.managed_file_id);
if (this.tableExists("local_backend_storage_state")) this.decrementManagedCapacity(row.byte_size); if (this.tableExists("local_backend_storage_state")) this.remeasureManagedCapacity();
} }
this.database.prepare(` this.database.prepare(`
UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL
@@ -168,10 +212,19 @@ export class ProjectPurgeCleanup {
transaction.immediate(); transaction.immediate();
completed += 1; completed += 1;
} catch { } catch {
this.database.prepare(` const transaction = this.database.transaction(() => {
UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed' this.database.prepare(`
WHERE cleanup_id = ? UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed'
`).run(row.cleanup_id); WHERE cleanup_id = ?
`).run(row.cleanup_id);
if (row.managed_file_id && this.tableExists("asset_cleanup_request_items")) {
const request = this.database.prepare(`
SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ? LIMIT 1
`).get(row.managed_file_id) as { request_id: string } | undefined;
if (request) this.insertAssetCleanupFailureAudit(request.request_id, this.clock());
}
});
transaction.immediate();
failed += 1; failed += 1;
} }
} }
@@ -238,4 +291,59 @@ export class ProjectPurgeCleanup {
WHERE singleton = 1 WHERE singleton = 1
`).run(managed, notice, status, iso(this.clock())); `).run(managed, notice, status, iso(this.clock()));
} }
private insertAssetCleanupAudit(requestId: string, deletedBytes: number, occurredAt: number) {
if (!this.tableExists("admin_operation_logs")) return;
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', 'project_purge_worker', 'asset_cleanup_physical_completed', 'asset_cleanup', ?, 'succeeded', NULL, ?, ?, ?)
`).run(
randomUUID(), requestId, JSON.stringify({ deleted_bytes: deletedBytes, status: "completed" }), occurredAt,
occurredAt + auditRetentionMilliseconds,
);
}
private insertAssetCleanupFailureAudit(requestId: string, occurredAt: number) {
if (!this.tableExists("admin_operation_logs")) return;
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', 'project_purge_worker', 'asset_cleanup_physical_failed', 'asset_cleanup', ?, 'failed', NULL, ?, ?, ?)
`).run(
randomUUID(), requestId, JSON.stringify({ failed_count: 1, status: "retry_pending" }), occurredAt,
occurredAt + auditRetentionMilliseconds,
);
}
private remeasureManagedCapacity() {
const state = this.database.prepare(`
SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1
`).get() as { managed_content_bytes: number } | undefined;
if (!state) return;
const rows = this.database.prepare(`
SELECT relative_path FROM managed_files WHERE status = 'committed'
`).all() as Array<{ relative_path: string }>;
let managed = 0;
for (const row of rows) {
try {
const path = this.resolveManagedPath(row.relative_path);
if (existsSync(path)) managed += statSync(path).size;
} catch {
// A missing or invalid path is excluded from the measured physical total.
}
}
const reservations = this.tableExists("storage_reservations")
? (this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'").get() as { bytes: number }).bytes
: 0;
const notice = managed < 4_294_967_296 ? "normal" : managed < 4_831_838_208 ? "warning" : "critical";
const status = managed + reservations >= 5_368_709_120 ? "full" : "active";
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(managed, notice, status, iso(this.clock()));
}
} }
+2430
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -14,7 +14,7 @@
"test:integration": "vitest run tests/integration", "test:integration": "vitest run tests/integration",
"test:api": "pnpm check:openapi && vitest run tests/api", "test:api": "pnpm check:openapi && vitest run tests/api",
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp5-05-admin-assets.spec.ts --config playwright.config.ts", "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp5-05-admin-assets.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts tests/e2e/wp6-04-audit.spec.ts tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts",
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
@@ -96,7 +96,12 @@
"test:wp5-04": "node scripts/run-wp5-04-validation.mjs", "test:wp5-04": "node scripts/run-wp5-04-validation.mjs",
"test:wp5-04:red": "node scripts/run-wp5-04-validation.mjs --phase red", "test:wp5-04:red": "node scripts/run-wp5-04-validation.mjs --phase red",
"test:wp5-05": "node scripts/run-wp5-05-validation.mjs", "test:wp5-05": "node scripts/run-wp5-05-validation.mjs",
"test:wp5-05:red": "node scripts/run-wp5-05-validation.mjs --phase red" "test:wp5-05:red": "node scripts/run-wp5-05-validation.mjs --phase red",
"test:wp6-01": "node scripts/run-wp6-01-validation.mjs --phase scaffold",
"test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red",
"test:wp6-04": "node scripts/run-wp6-04-validation.mjs --phase green",
"test:wp6-04:red": "node scripts/run-wp6-04-validation.mjs --phase red",
"test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.0", "@playwright/test": "1.62.0",
+290
View File
@@ -0,0 +1,290 @@
import { Type, type Static } from "@sinclair/typebox";
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$";
const modelIdPattern = "^[a-z0-9][a-z0-9.-]+$";
const safeReferencePattern = "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$";
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
export const AdminGenerationRecordSchema = Type.Object(
{
generation_id: Type.String({ pattern: uuidPattern }),
owner_ref: Type.String({ pattern: uuidPattern }),
project_id: Type.String({ pattern: uuidPattern }),
model_id: Type.String({ maxLength: 80, pattern: modelIdPattern }),
ratio: Type.Union([Type.Literal("3:4"), Type.Literal("1:1"), Type.Literal("4:3"), Type.Literal("9:16")]),
status: Type.Union([Type.Literal("queued"), Type.Literal("running"), Type.Literal("succeeded"), Type.Literal("failed"), Type.Literal("rejected")]),
created_at: Type.String({ pattern: isoTimestampPattern }),
completed_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
duration_ms: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]),
confirmed_credit_cost: Type.Integer({ minimum: 0 }),
reserved_credits: Type.Integer({ minimum: 0 }),
final_credit_state: Type.Union([Type.Literal("committed"), Type.Literal("released"), Type.Null()]),
error_category: Type.Union([
Type.Literal("upstream_timeout"), Type.Literal("upstream_failed"), Type.Literal("safety_rejected"),
Type.Literal("model_disabled"), Type.Literal("gateway_balance_insufficient"), Type.Literal("gateway_contract_invalid"),
Type.Literal("reference_invalid"), Type.Literal("unknown_retryable"), Type.Literal("unknown_non_retryable"), Type.Null(),
]),
},
{ additionalProperties: false, $id: "AdminGenerationRecord" },
);
export const AdminGenerationListResponseSchema = Type.Object(
{
generated_at: Type.String({ pattern: isoTimestampPattern }),
items: Type.Array(Type.Ref(AdminGenerationRecordSchema), { maxItems: 100 }),
},
{ additionalProperties: false, $id: "AdminGenerationListResponse" },
);
export const PrivateContentNoticeAckRequestSchema = Type.Object(
{ expected_notice_version: Type.String({ minLength: 1, maxLength: 80, pattern: "^[A-Za-z0-9_.:-]+$" }) },
{ additionalProperties: false, $id: "PrivateContentNoticeAckRequest" },
);
export const PrivateContentNoticeAckResponseSchema = Type.Object(
{
notice_version: Type.String({ minLength: 1, maxLength: 80, pattern: "^[A-Za-z0-9_.:-]+$" }),
acknowledged_at: Type.String({ pattern: isoTimestampPattern }),
status: Type.Literal("acknowledged"),
},
{ additionalProperties: false, $id: "PrivateContentNoticeAckResponse" },
);
export const PrivateContentPromptResponseSchema = Type.Object(
{
generation_id: Type.String({ pattern: uuidPattern }),
content_type: Type.Literal("prompt"),
prompt: Type.String({ minLength: 1, maxLength: 4000 }),
},
{ additionalProperties: false, $id: "PrivateContentPromptResponse" },
);
export const PrivateContentGenerationParamsSchema = Type.Object(
{ generationId: Type.String({ pattern: uuidPattern }) },
{ additionalProperties: false, $id: "PrivateContentGenerationParams" },
);
export type AdminGenerationRecord = Static<typeof AdminGenerationRecordSchema>;
export type AdminGenerationListResponse = Static<typeof AdminGenerationListResponseSchema>;
export type PrivateContentNoticeAckRequest = Static<typeof PrivateContentNoticeAckRequestSchema>;
export type PrivateContentNoticeAckResponse = Static<typeof PrivateContentNoticeAckResponseSchema>;
export type PrivateContentPromptResponse = Static<typeof PrivateContentPromptResponseSchema>;
export const AdminAuditQuerySchema = Type.Object(
{
cursor: Type.Optional(Type.String({ maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" })),
limit: Type.Optional(Type.Integer({ maximum: 100, minimum: 1 })),
},
{ additionalProperties: false, $id: "AdminAuditQuery" },
);
export const AdminOperationAuditItemSchema = Type.Object(
{
actor_ref: Type.String({ pattern: safeReferencePattern }),
actor_type: Type.Union([Type.Literal("system"), Type.Literal("super_admin")]),
after_summary: Type.Union([Type.String({ maxLength: 2048 }), Type.Null()]),
before_summary: Type.Union([Type.String({ maxLength: 2048 }), Type.Null()]),
expires_at: Type.String({ pattern: isoTimestampPattern }),
log_id: Type.String({ pattern: uuidPattern }),
occurred_at: Type.String({ pattern: isoTimestampPattern }),
operation_type: Type.String({ maxLength: 160, pattern: safeReferencePattern }),
result: Type.Union([Type.Literal("succeeded"), Type.Literal("failed")]),
target_ref: Type.String({ pattern: safeReferencePattern }),
target_type: Type.String({ maxLength: 160, pattern: safeReferencePattern }),
},
{ additionalProperties: false, $id: "AdminOperationAuditItem" },
);
export const AdminOperationAuditResponseSchema = Type.Object(
{
generated_at: Type.String({ pattern: isoTimestampPattern }),
items: Type.Array(Type.Ref(AdminOperationAuditItemSchema), { maxItems: 100 }),
next_cursor: Type.Union([Type.String({ maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" }), Type.Null()]),
},
{ additionalProperties: false, $id: "AdminOperationAuditResponse" },
);
export const PrivateContentAccessAuditItemSchema = Type.Object(
{
actor_ref: Type.String({ pattern: safeReferencePattern }),
content_type: Type.Union([Type.Literal("image"), Type.Literal("prompt")]),
expires_at: Type.String({ pattern: isoTimestampPattern }),
log_id: Type.String({ pattern: uuidPattern }),
occurred_at: Type.String({ pattern: isoTimestampPattern }),
target_ref: Type.String({ pattern: safeReferencePattern }),
},
{ additionalProperties: false, $id: "PrivateContentAccessAuditItem" },
);
export const PrivateContentAccessAuditResponseSchema = Type.Object(
{
generated_at: Type.String({ pattern: isoTimestampPattern }),
items: Type.Array(Type.Ref(PrivateContentAccessAuditItemSchema), { maxItems: 100 }),
next_cursor: Type.Union([Type.String({ maxLength: 512, pattern: "^[A-Za-z0-9_-]+$" }), Type.Null()]),
},
{ additionalProperties: false, $id: "PrivateContentAccessAuditResponse" },
);
export type AdminAuditQuery = Static<typeof AdminAuditQuerySchema>;
export type AdminOperationAuditItem = Static<typeof AdminOperationAuditItemSchema>;
export type AdminOperationAuditResponse = Static<typeof AdminOperationAuditResponseSchema>;
export type PrivateContentAccessAuditItem = Static<typeof PrivateContentAccessAuditItemSchema>;
export type PrivateContentAccessAuditResponse = Static<typeof PrivateContentAccessAuditResponseSchema>;
export const AdminOverviewResponseSchema = Type.Object(
{
generated_at: Type.String({ pattern: isoTimestampPattern }),
user_slots: Type.Object(
{
active_and_suspended: Type.Integer({ minimum: 0 }),
limit: Type.Integer({ minimum: 1 }),
},
{ additionalProperties: false },
),
generation_jobs: Type.Object(
{
pending_manual_review: Type.Integer({ minimum: 0 }),
pending_manual_review_oldest_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
queued: Type.Integer({ minimum: 0 }),
running: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
),
models: Type.Object(
{
configured_default_model_id: Type.Union([Type.String({ maxLength: 80, pattern: modelIdPattern }), Type.Null()]),
configured_model_count: Type.Integer({ minimum: 0 }),
recommended_model_id: Type.Union([Type.String({ maxLength: 80, pattern: modelIdPattern }), Type.Null()]),
runtime_available_count: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
),
storage: Type.Object(
{
last_measured_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
limit_bytes: Type.Integer({ minimum: 1 }),
managed_content_bytes: Type.Integer({ minimum: 0 }),
status: Type.Union([
Type.Literal("normal"),
Type.Literal("critical"),
Type.Literal("full"),
Type.Literal("unavailable"),
]),
},
{ additionalProperties: false },
),
services: Type.Array(
Type.Object(
{
checked_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
service_id: Type.Union([
Type.Literal("resend"),
Type.Literal("amap"),
Type.Literal("ai_gateway"),
Type.Literal("worker"),
Type.Literal("asset_root"),
]),
status: Type.Union([
Type.Literal("available"),
Type.Literal("degraded"),
Type.Literal("paused"),
Type.Literal("unavailable"),
]),
},
{ additionalProperties: false },
),
{ maxItems: 5 },
),
recent_operations: Type.Array(
Type.Object(
{
created_at: Type.String({ pattern: isoTimestampPattern }),
operation_id: Type.String({ pattern: "^[0-9a-fA-F-]{36}$" }),
operation_type: Type.String({ maxLength: 80, pattern: "^[a-z][a-z0-9_]+$" }),
result: Type.Union([Type.Literal("succeeded"), Type.Literal("rejected"), Type.Literal("failed")]),
target_ref: Type.String({ pattern: safeReferencePattern }),
},
{ additionalProperties: false },
),
{ maxItems: 10 },
),
asset_cleanup: Type.Object(
{
pending_jobs: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
),
},
{ additionalProperties: false, $id: "AdminOverviewResponse" },
);
export type AdminOverviewResponse = Static<typeof AdminOverviewResponseSchema>;
const adminServiceStatusSchema = Type.Union([
Type.Literal("active"),
Type.Literal("paused_quota"),
Type.Literal("paused_provider"),
Type.Literal("disabled"),
Type.Literal("degraded"),
Type.Literal("unavailable"),
]);
const adminServiceIdSchema = Type.Union([
Type.Literal("resend"),
Type.Literal("amap"),
Type.Literal("ai_gateway"),
Type.Literal("worker"),
Type.Literal("api"),
Type.Literal("asset_root"),
]);
export const AdminServicesStorageResponseSchema = Type.Object({
generated_at: Type.String({ pattern: isoTimestampPattern }),
services: Type.Array(Type.Object({
checked_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
configured: Type.Boolean(),
impact_scope: Type.Union([
Type.Literal("none"),
Type.Literal("authentication"),
Type.Literal("location"),
Type.Literal("generation"),
Type.Literal("storage"),
Type.Literal("api"),
Type.Literal("model"),
Type.Literal("account"),
Type.Literal("unknown"),
]),
pause_reason: Type.Union([Type.String({ maxLength: 80, pattern: "^[a-z][a-z0-9_]*$" }), Type.Null()]),
service_id: adminServiceIdSchema,
status: adminServiceStatusSchema,
}, { additionalProperties: false }), { minItems: 6, maxItems: 6 }),
storage: Type.Object({
capacity_notice_level: Type.Union([Type.Literal("normal"), Type.Literal("warning"), Type.Literal("critical")]),
cleanup_pending_count: Type.Integer({ minimum: 0 }),
data_root_ref: Type.Literal("configured_local_data_root"),
hard_limit_bytes: Type.Integer({ minimum: 1 }),
last_measured_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
managed_content_bytes: Type.Integer({ minimum: 0 }),
remeasurement_required: Type.Boolean(),
status: Type.Union([Type.Literal("active"), Type.Literal("full"), Type.Literal("unavailable")]),
storage_backend: Type.Literal("local_filesystem"),
}, { additionalProperties: false }),
}, { additionalProperties: false, $id: "AdminServicesStorageResponse" });
export const AdminDiagnosticsResponseSchema = Type.Object({
generated_at: Type.String({ pattern: isoTimestampPattern }),
diagnostic_text: Type.String({ minLength: 1, maxLength: 12_000 }),
services: Type.Ref(AdminServicesStorageResponseSchema),
system: Type.Object({
api_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
app_version: Type.String({ maxLength: 80, pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$" }),
browser_support: Type.Array(Type.Object({
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
major: Type.Integer({ minimum: 1 }),
}, { additionalProperties: false }), { maxItems: 2 }),
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
}, { additionalProperties: false }),
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
export type AdminServicesStorageResponse = Static<typeof AdminServicesStorageResponseSchema>;
export type AdminDiagnosticsResponse = Static<typeof AdminDiagnosticsResponseSchema>;
+1
View File
@@ -149,6 +149,7 @@ export const AdminSessionResponseSchema = Type.Object(
audience: Type.Literal("admin"), audience: Type.Literal("admin"),
authenticated: Type.Literal(true), authenticated: Type.Literal(true),
csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }), csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
current_private_content_notice_message_key: Type.Optional(Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.-]+$" })),
current_private_content_notice_version: Type.Union([Type.String(), Type.Null()]), current_private_content_notice_version: Type.Union([Type.String(), Type.Null()]),
expires_at: Type.String({ pattern: isoTimestampPattern }), expires_at: Type.String({ pattern: isoTimestampPattern }),
notice_acknowledged: Type.Boolean(), notice_acknowledged: Type.Boolean(),
+2
View File
@@ -1,5 +1,7 @@
export { Type } from "@sinclair/typebox"; export { Type } from "@sinclair/typebox";
export * from "./api.js"; export * from "./api.js";
export * from "./admin.js";
export * from "./services.js";
export * from "./assets.js"; export * from "./assets.js";
export * from "./auth.js"; export * from "./auth.js";
export * from "./bootstrap.js"; export * from "./bootstrap.js";
+62
View File
@@ -0,0 +1,62 @@
import { Type, type Static } from "@sinclair/typebox";
const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$";
export const ExternalServiceIdSchema = Type.Union([
Type.Literal("resend_email"),
Type.Literal("amap_web_service"),
], { $id: "ExternalServiceId" });
export const ExternalServicePeriodTypeSchema = Type.Union([
Type.Literal("daily"),
Type.Literal("monthly"),
], { $id: "ExternalServicePeriodType" });
export const ExternalServiceStatusSchema = Type.Union([
Type.Literal("active"),
Type.Literal("paused_quota"),
Type.Literal("paused_provider"),
Type.Literal("disabled"),
], { $id: "ExternalServiceStatus" });
export const ExternalServiceUsageSchema = Type.Object({
service_id: Type.Ref(ExternalServiceIdSchema),
period_type: Type.Ref(ExternalServicePeriodTypeSchema),
period_start: Type.String({ pattern: isoTimestampPattern }),
hard_limit: Type.Integer({ minimum: 1 }),
used_count: Type.Integer({ minimum: 0 }),
service_status: Type.Ref(ExternalServiceStatusSchema),
pause_reason: Type.Union([Type.String({ maxLength: 120, pattern: "^[a-z0-9_.-]+$" }), Type.Null()]),
updated_at: Type.String({ pattern: isoTimestampPattern }),
}, { additionalProperties: false, $id: "ExternalServiceUsage" });
export const AdminServicesResponseSchema = Type.Object({
services: Type.Array(Type.Ref(ExternalServiceUsageSchema), { maxItems: 3 }),
}, { additionalProperties: false, $id: "AdminServicesResponse" });
export const AdminServiceLimitRequestSchema = Type.Object({
period_type: Type.Ref(ExternalServicePeriodTypeSchema),
hard_limit: Type.Integer({ minimum: 1 }),
}, { additionalProperties: false, $id: "AdminServiceLimitRequest" });
export const AdminServiceHealthCheckRequestSchema = Type.Object({
available: Type.Boolean(),
reason: Type.Optional(Type.String({ maxLength: 120, pattern: "^[a-zA-Z0-9_. -]+$" })),
}, { additionalProperties: false, $id: "AdminServiceHealthCheckRequest" });
export const AdminServiceRecoveryRequestSchema = Type.Object({
check_id: Type.String({ maxLength: 64, minLength: 1, pattern: "^[0-9a-fA-F-]+$" }),
}, { additionalProperties: false, $id: "AdminServiceRecoveryRequest" });
export const AdminServiceParamsSchema = Type.Object({
service_id: Type.Ref(ExternalServiceIdSchema),
}, { additionalProperties: false, $id: "AdminServiceParams" });
export type ExternalServiceId = Static<typeof ExternalServiceIdSchema>;
export type ExternalServicePeriodType = Static<typeof ExternalServicePeriodTypeSchema>;
export type ExternalServiceUsage = Static<typeof ExternalServiceUsageSchema>;
export type AdminServicesResponse = Static<typeof AdminServicesResponseSchema>;
export type AdminServiceLimitRequest = Static<typeof AdminServiceLimitRequestSchema>;
export type AdminServiceHealthCheckRequest = Static<typeof AdminServiceHealthCheckRequestSchema>;
export type AdminServiceRecoveryRequest = Static<typeof AdminServiceRecoveryRequestSchema>;
export type AdminServiceParams = Static<typeof AdminServiceParamsSchema>;
+115
View File
@@ -0,0 +1,115 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const phaseIndex = process.argv.indexOf("--phase");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "scaffold";
if (!new Set(["red", "scaffold"]).has(phase)) throw new Error(`Unsupported phase: ${phase}`);
const runId = process.env.DADA_TDD_RUN_ID ?? `wp6-01-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const caseDirectory = resolve(runDirectory, "cases", "TDD-WP6-ADM-001-role-and-summary");
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(caseDirectory, { recursive: true });
const environment = {
...process.env,
DADA_EVIDENCE_DIR_ADMIN: caseDirectory,
DADA_PLAYWRIGHT_OUTPUT_DIR: resolve(runDirectory, "playwright-output"),
DADA_WP6_01_EVIDENCE_DIR: caseDirectory,
};
const commands = phase === "red"
? [
["api-red", "pnpm exec vitest run tests/api/wp6-01-admin-shell.test.ts"],
["e2e-red", "pnpm exec playwright test tests/e2e/wp6-01-admin-shell.spec.ts --config playwright.config.ts"],
]
: [
["api", "pnpm test:api"],
["e2e", "pnpm test:e2e"],
["security", "pnpm test:security"],
["tdd-trace", "pnpm validate:tdd-trace"],
];
const commandResults = [];
for (const [name, command] of commands) {
const started_at = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
encoding: "utf8",
env: environment,
maxBuffer: 40 * 1024 * 1024,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
if (phase === "scaffold" && (result.status ?? 1) !== 0) break;
}
const redConfirmed = phase === "red" && commandResults.length === commands.length && commandResults.every((item) => item.exit_code !== 0);
if (phase === "red") {
writeFileSync(resolve(caseDirectory, "red-observation.json"), `${JSON.stringify({
expected_failure: "The protected admin overview route, nine-entry admin shell, denied-session redirect, and disabled-session ejection are absent before TASK-WP6-01.",
observed_commands: commandResults,
red_reason: "TDD-WP6-ADM-001 first Red: ordinary or preview subjects can reach the unguarded admin route, while no safe summary API exists.",
status: redConfirmed ? "red_confirmed" : "failed",
}, null, 2)}\n`);
}
function findFiles(directory, name) {
if (!existsSync(directory)) return [];
const matches = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name);
if (entry.isDirectory()) matches.push(...findFiles(path, name));
else if (entry.name === name) matches.push(path);
}
return matches;
}
if (phase === "scaffold") {
const trace = findFiles(environment.DADA_PLAYWRIGHT_OUTPUT_DIR, "trace.zip")
.find((path) => path.toLowerCase().includes("wp6-01-admin-shell"));
if (trace) copyFileSync(trace, resolve(caseDirectory, "trace.zip"));
}
const expectedEvidence = phase === "red"
? ["red-observation.json"]
: ["response.json", "db-access.json", "trace.zip", "screenshots/admin-denied.png", "screenshots/admin-overview.png"];
const missingEvidence = expectedEvidence.filter((file) => !existsSync(resolve(caseDirectory, file)));
const commandsPassed = phase === "scaffold" && commandResults.length === commands.length && commandResults.every((item) => item.exit_code === 0);
const status = phase === "red"
? redConfirmed && missingEvidence.length === 0 ? "red_confirmed" : "failed"
: commandsPassed && missingEvidence.length === 0 ? "red" : "failed";
const commit = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim();
const wp5BaselineSha = spawnSync("git", ["rev-parse", "origin/codex/wp5-04"], { encoding: "utf8" }).stdout.trim();
const manifest = {
path: "tasks.manifest.json",
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
};
const result = {
acceptance_criteria: ["AC-25", "AC-49"],
automation: ["automated"],
commit,
dependency_gate: {
blocked_by: ["TASK-WP5-05", "TASK-WP5-06", "TASK-WP5-07"],
baseline_remote_branch: "origin/codex/wp5-04",
baseline_remote_sha: wp5BaselineSha,
final_green_allowed: false,
},
evidence_refs: expectedEvidence,
layer: ["API", "E2E"],
manifest,
missing_evidence: missingEvidence,
phase,
requirements: ["ADMIN-01", "ADMIN-02", "ADMIN-04", "ADMIN-08"],
run_id: runId,
status,
task_id: "TASK-WP6-01",
test_id: "TDD-WP6-ADM-001-role-and-summary",
work_package: "WP-6",
};
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: [{ missing_evidence: missingEvidence, status, test_id: result.test_id }], phase, run_id: runId, status }, null, 2)}\n`);
console.log(JSON.stringify({ phase, run_id: runId, status }, null, 2));
if (status === "failed") process.exit(1);
+107
View File
@@ -0,0 +1,107 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
const phaseIndex = process.argv.indexOf("--phase");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (!new Set(["red", "green"]).has(phase)) throw new Error(`Unsupported phase: ${phase}`);
const runId = process.env.DADA_TDD_RUN_ID ?? `wp6-04-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const caseDirectory = resolve(runDirectory, "cases", "TDD-WP6-AUD-001-sensitive-operations");
const replicationRoot = resolve(process.env.DADA_REPLICATION_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_web_replication_assets"));
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(caseDirectory, { recursive: true });
const environment = {
...process.env,
DADA_EVIDENCE_DIR_WP6_AUD: caseDirectory,
DADA_PLAYWRIGHT_OUTPUT_DIR: resolve(runDirectory, "playwright-output"),
DADA_STATIC_STICKER_ROOT: process.env.DADA_STATIC_STICKER_ROOT ?? join(replicationRoot, "sticker_normal"),
DADA_DYNAMIC_ASSET_ROOT: process.env.DADA_DYNAMIC_ASSET_ROOT ?? join(replicationRoot, "sticker_interactive", "单模板归档", "templates"),
DADA_TEXT_ASSET_ROOT: process.env.DADA_TEXT_ASSET_ROOT ?? join(replicationRoot, "sticker_text"),
};
const commands = phase === "red"
? [
["integration-red", ".\\node_modules\\.bin\\vitest.CMD run tests/integration/wp6-04-sensitive-audit.test.ts tests/integration/wp5-05-sticker-release.test.ts"],
["api-red", ".\\node_modules\\.bin\\vitest.CMD run tests/api/wp6-04-audit.test.ts"],
["e2e-red", ".\\node_modules\\.bin\\playwright.CMD test tests/e2e/wp6-04-audit.spec.ts --config playwright.config.ts"],
]
: [
["integration", "pnpm.cmd exec vitest run tests/integration --testTimeout=20000"],
["api", "pnpm.cmd check:openapi && pnpm.cmd exec vitest run tests/api --testTimeout=20000"],
["worker", "pnpm.cmd test:worker"],
["e2e", "pnpm.cmd test:e2e"],
["tdd-trace", "pnpm.cmd validate:tdd-trace"],
];
const commandResults = [];
for (const [name, command] of commands) {
const started_at = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
encoding: "utf8",
env: environment,
maxBuffer: 40 * 1024 * 1024,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
if (phase === "green" && (result.status ?? 1) !== 0) break;
}
function findFiles(directory, name) {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? findFiles(path, name) : entry.name === name ? [path] : [];
});
}
const trace = findFiles(environment.DADA_PLAYWRIGHT_OUTPUT_DIR, "trace.zip")
.find((path) => path.toLowerCase().includes("wp6-04-audit"));
if (trace) copyFileSync(trace, resolve(caseDirectory, "trace.zip"));
const redConfirmed = phase === "red" && commandResults.length === commands.length && commandResults.every((item) => item.exit_code !== 0);
if (phase === "red") {
writeFileSync(resolve(caseDirectory, "red-observation.json"), `${JSON.stringify({
expected_failure: "Audit list APIs and UI are absent, while invite, user-status, and sticker-release mutations are missing same-transaction AdminOperationLog coverage.",
observed_commands: commandResults,
red_reason: "TDD-WP6-AUD-001 first Red: required operations can be missing audit rows, the two log types have no separate admin read contract, and no admin audit page exists.",
status: redConfirmed ? "red_confirmed" : "failed",
}, null, 2)}\n`);
}
const expectedEvidence = phase === "red"
? ["red-observation.json"]
: ["operation-matrix.json", "db-diff.json", "redaction.json", "trace.zip"];
const missingEvidence = expectedEvidence.filter((file) => !existsSync(resolve(caseDirectory, file)));
const greenPassed = phase === "green" && commandResults.length === commands.length && commandResults.every((item) => item.exit_code === 0);
const status = phase === "red"
? redConfirmed && missingEvidence.length === 0 ? "red_confirmed" : "failed"
: greenPassed && missingEvidence.length === 0 ? "green" : "failed";
const result = {
acceptance_criteria: ["AC-50"],
automation: ["automated"],
commit: spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim(),
evidence_refs: expectedEvidence,
layer: ["DB", "API", "WRK", "E2E"],
manifest: {
path: "tasks.manifest.json",
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
},
missing_evidence: missingEvidence,
phase,
requirements: ["ADMIN-09"],
run_id: runId,
status,
task_id: "TASK-WP6-04",
test_id: "TDD-WP6-AUD-001-sensitive-operations",
work_package: "WP-6",
};
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: [{ missing_evidence: missingEvidence, status, test_id: result.test_id }], phase, run_id: runId, status }, null, 2)}\n`);
console.log(JSON.stringify({ phase, run_id: runId, status }, null, 2));
if (status === "failed") process.exit(1);
+179
View File
@@ -0,0 +1,179 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../apps/api/src/app.js";
import { AssetPreviewGrantService } from "../../apps/api/src/preview-grants.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { createAssetReleaseManifest } from "../../packages/asset-release-manifest/src/index.js";
const start = Date.parse("2026-08-04T08:00:00.000Z");
const releaseVersion = "asset-20260804.1";
const previewResourceId = "8f9b5c62-7488-4c7a-9f0c-3b8f3fc34f92";
const roots: string[] = [];
const registrations: RegistrationService[] = [];
function harness() {
const root = mkdtempSync(join(tmpdir(), "dada-wp5-07-"));
roots.push(root);
let now = start;
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x71),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0x72),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0x73),
});
registrations.push(registration);
const userId = randomUUID();
registration.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, `${userId}@example.invalid`, randomUUID(), start);
registration.database.prepare(`
INSERT INTO user_profiles (user_id, creator_name, social_id)
VALUES (?, 'Preview User', '@preview_user')
`).run(userId);
registration.database.prepare(`
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
VALUES (?, 10, 0, ?)
`).run(userId, start);
const adminId = randomUUID();
registration.database.prepare(`
INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit,
registration_id, created_at
) VALUES (?, ?, 'super_admin', 'active', 0, ?, ?)
`).run(adminId, `${adminId}@example.invalid`, randomUUID(), start);
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
const assetReleases = createAssetReleaseManifest({
items: [{
access_class: "internal_preview_asset",
content: Buffer.from("preview-content"),
mime_type: "image/webp",
relative_path: "preview/TEMPLATE.webp",
resource_id: previewResourceId,
root_ref: "canonical-assets",
}],
release_version: releaseVersion,
});
const service = new AssetPreviewGrantService({ assetReleases, registration, clock: () => now });
return {
advance(milliseconds: number) { now += milliseconds; },
adminId,
registration,
service,
userId,
};
}
afterEach(() => {
for (const registration of registrations.splice(0)) registration.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TASK-WP5-07 internal preview grant lifecycle", () => {
it("keeps ordinary role, returns randomized manifest item IDs, and blocks revoked/expired grants", () => {
const test = harness();
const batch = test.service.createBatch({
name: "WP5 preview batch",
adminUserId: test.adminId,
});
test.service.addBatchItems({
adminUserId: test.adminId,
batchId: batch.batchId,
releaseVersion,
resourceIds: [previewResourceId],
});
const grant = test.service.grant({
adminUserId: test.adminId,
batchId: batch.batchId,
expiresAt: start + 60_000,
userId: test.userId,
});
const firstManifest = test.service.projectManifest({ releaseVersion, userId: test.userId });
expect(firstManifest?.items).toHaveLength(1);
expect(firstManifest?.items[0].resource_id).not.toBe(previewResourceId);
expect(firstManifest?.items[0].url).toContain(firstManifest?.items[0].resource_id ?? "");
expect(test.service.readManifestItem({
manifestItemId: firstManifest!.items[0].resource_id,
releaseVersion,
userId: test.userId,
})?.bytes).toEqual(Buffer.from("preview-content"));
expect(test.registration.database.prepare("SELECT role FROM users WHERE user_id = ?").get(test.userId)).toEqual({ role: "user" });
test.service.revoke({ adminUserId: test.adminId, grantId: grant.grantId });
expect(test.service.projectManifest({ releaseVersion, userId: test.userId })).toBeUndefined();
expect(test.service.readManifestItem({
manifestItemId: firstManifest!.items[0].resource_id,
releaseVersion,
userId: test.userId,
})).toBeUndefined();
const secondGrant = test.service.grant({
adminUserId: test.adminId,
batchId: batch.batchId,
expiresAt: start + 10_000,
userId: test.userId,
});
expect(secondGrant.status).toBe("active");
test.advance(10_001);
expect(test.service.projectManifest({ releaseVersion, userId: test.userId })).toBeUndefined();
expect(test.registration.database.prepare("SELECT status FROM asset_preview_grants WHERE grant_id = ?").get(secondGrant.grantId)).toEqual({ status: "expired" });
test.registration.database.prepare("UPDATE users SET status = 'active' WHERE user_id = ?").run(test.userId);
test.service.grant({
adminUserId: test.adminId,
batchId: batch.batchId,
expiresAt: start + 120_000,
userId: test.userId,
});
test.registration.changeUserStatus(test.userId, "suspended");
expect(test.service.projectManifest({ releaseVersion, userId: test.userId })).toBeUndefined();
expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type LIKE 'preview_grant_%'").get()).toEqual({ count: 5 });
});
it("serves the randomized item through the authenticated no-store route", async () => {
const test = harness();
const batch = test.service.createBatch({ name: "WP5 route batch", adminUserId: test.adminId });
test.service.addBatchItems({
adminUserId: test.adminId,
batchId: batch.batchId,
releaseVersion,
resourceIds: [previewResourceId],
});
const grant = test.service.grant({ adminUserId: test.adminId, batchId: batch.batchId, expiresAt: start + 60_000, userId: test.userId });
const session = test.registration.issueAuthenticatedSession(test.userId, "user");
const app = await createApp({
browserGate: false,
networkBoundary: { allowTestPort: true },
previewGrants: test.service,
registration: test.registration,
});
try {
const headers = { cookie: `dada_session=${session.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
const manifest = await app.inject({ headers, method: "GET", url: `/api/v1/assets/preview/${releaseVersion}/manifest` });
expect(manifest.statusCode).toBe(200);
const itemId = manifest.json().items[0].resource_id;
expect(itemId).not.toBe(previewResourceId);
const asset = await app.inject({ headers, method: "GET", url: `/api/v1/assets/preview/${releaseVersion}/${itemId}` });
expect(asset.statusCode).toBe(200);
expect(asset.headers["cache-control"]).toBe("private, no-store");
expect(asset.rawPayload).toEqual(Buffer.from("preview-content"));
test.service.revoke({ adminUserId: test.adminId, grantId: grant.grantId });
const revoked = await app.inject({ headers, method: "GET", url: `/api/v1/assets/preview/${releaseVersion}/${itemId}` });
expect(revoked.statusCode).toBe(404);
} finally {
await app.close();
}
});
});
+140
View File
@@ -0,0 +1,140 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../apps/api/src/app.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { adminOverviewFixture } from "../fixtures/wp6-01-admin-overview.js";
const roots: string[] = [];
const services: RegistrationService[] = [];
const now = Date.parse("2026-08-03T09:30:00.000Z");
const requestHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
function createRegistration() {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-01-api-"));
roots.push(root);
const registration = new RegistrationService({
adminAllowlistPepper: Buffer.alloc(32, 0xd1),
challengePepper: Buffer.alloc(32, 0xd2),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0xd3),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0xd4),
});
services.push(registration);
return registration;
}
function seedSubject(registration: RegistrationService, role: "super_admin" | "user") {
const userId = randomUUID();
registration.database.prepare(`
INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit,
registration_id, created_at
) VALUES (?, ?, ?, 'active', ?, ?, ?)
`).run(userId, `${role}-${userId}@example.invalid`, role, role === "user" ? 1 : 0, randomUUID(), now);
if (role === "super_admin") {
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
}
return userId;
}
function tableCounts(registration: RegistrationService) {
return {
admin: (registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs").get() as { count: number }).count,
private: (registration.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get() as { count: number }).count,
};
}
afterEach(() => {
for (const service of services.splice(0)) service.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP6-ADM-001-role-and-summary", () => {
it("authorizes only an active admin audience and returns a schema-redacted summary", async () => {
const registration = createRegistration();
const adminId = seedSubject(registration, "super_admin");
const ordinaryId = seedSubject(registration, "user");
const previewId = seedSubject(registration, "user");
registration.database.exec("CREATE TABLE asset_preview_grants_fixture (user_id TEXT PRIMARY KEY, status TEXT NOT NULL)");
registration.database.prepare("INSERT INTO asset_preview_grants_fixture (user_id, status) VALUES (?, 'active')").run(previewId);
const adminSession = registration.issueAuthenticatedSession(adminId, "admin");
const ordinarySession = registration.issueAuthenticatedSession(ordinaryId, "user");
const previewSession = registration.issueAuthenticatedSession(previewId, "user");
let providerCalls = 0;
const app = await createApp({
adminOverview: async () => {
providerCalls += 1;
return {
...adminOverviewFixture,
absolute_path: "forbidden-path-trap",
["api" + "_key"]: "forbidden-key-trap",
private_prompt: "forbidden-prompt-trap",
recent_operations: adminOverviewFixture.recent_operations.map((operation) => ({
...operation,
actor_email: "forbidden@example.invalid",
})),
};
},
browserGate: false,
networkBoundary: { allowTestPort: true },
registration,
});
for (const token of [undefined, ordinarySession.sessionToken, previewSession.sessionToken]) {
const response = await app.inject({
headers: token ? { ...requestHeaders, cookie: `dada_admin_session=${token}` } : requestHeaders,
method: "GET",
url: "/api/v1/admin/overview",
});
expect(response.statusCode).toBe(401);
}
expect(providerCalls).toBe(0);
const before = tableCounts(registration);
const allowed = await app.inject({
headers: { ...requestHeaders, cookie: `dada_admin_session=${adminSession.sessionToken}` },
method: "GET",
url: "/api/v1/admin/overview",
});
expect(allowed.statusCode).toBe(200);
expect(allowed.json()).toEqual(adminOverviewFixture);
expect(JSON.stringify(allowed.json())).not.toMatch(/absolute_path|api_key|private_prompt|actor_email|forbidden/i);
expect(providerCalls).toBe(1);
expect(tableCounts(registration)).toEqual(before);
registration.revokeAdminSessions(adminId, "disabled");
const afterDisable = tableCounts(registration);
const revoked = await app.inject({
headers: { ...requestHeaders, cookie: `dada_admin_session=${adminSession.sessionToken}` },
method: "GET",
url: "/api/v1/admin/overview",
});
expect(revoked.statusCode).toBe(401);
expect(providerCalls).toBe(1);
expect(tableCounts(registration)).toEqual(afterDisable);
const evidenceRoot = process.env.DADA_WP6_01_EVIDENCE_DIR;
if (evidenceRoot) {
mkdirSync(evidenceRoot, { recursive: true });
writeFileSync(resolve(evidenceRoot, "response.json"), `${JSON.stringify({
active_admin: allowed.json(),
denied_statuses: { anonymous: 401, ordinary: 401, preview: 401, suspended_admin: revoked.statusCode },
}, null, 2)}\n`);
writeFileSync(resolve(evidenceRoot, "db-access.json"), `${JSON.stringify({
active_read_delta: { admin_operation_logs: 0, private_content_access_logs: 0 },
denied_read_delta: { admin_operation_logs: 0, private_content_access_logs: 0 },
provider_calls: providerCalls,
}, null, 2)}\n`);
}
await app.close();
});
});
+105
View File
@@ -0,0 +1,105 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../apps/api/src/app.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
const roots: string[] = [];
const services: RegistrationService[] = [];
const now = Date.parse("2026-08-04T09:30:00.000Z");
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
function harness() {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-02-api-"));
roots.push(root);
const registration = new RegistrationService({
adminAllowlistPepper: Buffer.alloc(32, 0xc1),
challengePepper: Buffer.alloc(32, 0xc2),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-private-content-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0xc3),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0xc4),
});
services.push(registration);
const adminId = randomUUID();
const ownerId = randomUUID();
const projectId = randomUUID();
const generationId = randomUUID();
registration.database.prepare(`
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
VALUES (?, ?, 'super_admin', 'active', 0, ?, ?), (?, ?, 'user', 'active', 1, ?, ?)
`).run(adminId, `admin-${adminId}@example.invalid`, randomUUID(), now, ownerId, `user-${ownerId}@example.invalid`, randomUUID(), now);
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Admin', '@admin')").run(adminId);
registration.database.exec(`
CREATE TABLE generation_jobs (
generation_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, project_id TEXT NOT NULL,
prompt TEXT NOT NULL, ratio TEXT NOT NULL, status TEXT NOT NULL, model_id TEXT NOT NULL,
model_config_version INTEGER NOT NULL, confirmed_credit_cost INTEGER NOT NULL,
reserved_credits INTEGER NOT NULL, final_credit_state TEXT, error_category TEXT,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, submission_ready INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE project_images (image_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, generation_id TEXT NOT NULL, created_at INTEGER NOT NULL);
`);
registration.database.prepare(`
INSERT INTO generation_jobs (
generation_id, owner_id, project_id, prompt, ratio, status, model_id, model_config_version,
confirmed_credit_cost, reserved_credits, final_credit_state, error_category, created_at, updated_at, submission_ready
) VALUES (?, ?, ?, ?, '1:1', 'succeeded', 'demo.model', 1, 2, 0, 'committed', NULL, ?, ?, 1)
`).run(generationId, ownerId, projectId, "secret prompt must never be listed", now - 1000, now);
return { registration, adminId, generationId, adminSession: registration.issueAuthenticatedSession(adminId, "admin") };
}
afterEach(() => {
for (const service of services.splice(0)) service.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP6-PRIV-001/TDD-WP6-PRIV-002", () => {
it("persists notice acknowledgement, returns metadata only, and audits every prompt open", async () => {
const fixture = harness();
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration: fixture.registration });
const cookie = `dada_admin_session=${fixture.adminSession.sessionToken}`;
const denied = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/admin/generations" });
expect(denied.statusCode).toBe(428);
expect(denied.json()).toMatchObject({ error: { code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", details: { latest_version: "p0a-private-content-v1" } } });
expect(JSON.stringify(denied.json())).not.toContain("secret prompt");
const csrf = fixture.registration.issueAdminCsrfToken(fixture.adminSession.sessionToken);
const ack = await app.inject({
headers: { ...headers, cookie, "x-csrf-token": csrf, "idempotency-key": "wp6-02-ack-000000000000000000000000000000" },
method: "POST", payload: { expected_notice_version: "p0a-private-content-v1" }, url: "/api/v1/admin/private-content-notice/ack",
});
expect(ack.statusCode).toBe(200);
expect(ack.json()).toMatchObject({ notice_version: "p0a-private-content-v1", status: "acknowledged" });
const stale = await app.inject({
headers: { ...headers, cookie, "x-csrf-token": fixture.registration.issueAdminCsrfToken(fixture.adminSession.sessionToken), "idempotency-key": "wp6-02-ack-stale-000000000000000000000000000" },
method: "POST", payload: { expected_notice_version: "old-private-content-v0" }, url: "/api/v1/admin/private-content-notice/ack",
});
expect(stale.statusCode).toBe(428);
expect(stale.json()).toMatchObject({ error: { code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", details: { latest_version: "p0a-private-content-v1" } } });
const list = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/admin/generations" });
expect(list.statusCode).toBe(200);
expect(list.json().items[0]).toMatchObject({ generation_id: fixture.generationId, owner_ref: expect.any(String), status: "succeeded" });
expect(JSON.stringify(list.json())).not.toContain("secret prompt");
const opened = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: `/api/v1/admin/private-content/generations/${fixture.generationId}/prompt` });
expect(opened.statusCode).toBe(200);
expect(opened.json()).toEqual({ content_type: "prompt", generation_id: fixture.generationId, prompt: "secret prompt must never be listed" });
expect((fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get() as { count: number }).count).toBe(1);
const reopened = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: `/api/v1/admin/private-content/generations/${fixture.generationId}/prompt` });
expect(reopened.statusCode).toBe(200);
expect((fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get() as { count: number }).count).toBe(2);
await app.close();
});
});
+157
View File
@@ -0,0 +1,157 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ExternalServiceUsage, ExternalServiceUsageError } from "../../apps/api/src/external-service-usage.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { createApp } from "../../apps/api/src/app.js";
import { MockAmapAdapter } from "../../apps/api/src/amap-adapter.js";
const roots: string[] = [];
const registrations: RegistrationService[] = [];
afterEach(() => {
for (const registration of registrations.splice(0)) registration.close();
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function createRegistration(now = Date.parse("2026-08-04T09:00:00.000Z")) {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-03-services-"));
roots.push(root);
const registration = new RegistrationService({
adminAllowlistPepper: Buffer.alloc(32, 0x91),
challengePepper: Buffer.alloc(32, 0x92),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0x93),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0x94),
});
registrations.push(registration);
return registration;
}
function seedAdmin(registration: RegistrationService, now = Date.parse("2026-08-04T09:00:00.000Z")) {
const userId = randomUUID();
registration.database.prepare(`
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
VALUES (?, ?, 'super_admin', 'active', 0, ?, ?)
`).run(userId, `${userId}@example.invalid`, randomUUID(), now);
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
return userId;
}
function seedUser(registration: RegistrationService, now = Date.parse("2026-08-04T09:00:00.000Z")) {
const userId = randomUUID();
registration.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, `${userId}@example.invalid`, randomUUID(), now);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Test User', '@test_user')").run(userId);
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
return userId;
}
describe("TDD-WP6-SVC-001 quota hard stop", () => {
it("claims Resend daily and monthly quotas transactionally and pauses before the next call", () => {
const registration = createRegistration();
const usage = registration.serviceUsage;
usage.setHardLimit({ serviceId: "resend_email", periodType: "daily", hardLimit: 2, actorId: "fixture-admin" });
usage.setHardLimit({ serviceId: "resend_email", periodType: "monthly", hardLimit: 2, actorId: "fixture-admin" });
expect(usage.claimResend()).toMatchObject({ allowed: true, remaining: 1 });
expect(usage.claimResend()).toMatchObject({ allowed: true, remaining: 0 });
expect(() => usage.claimResend()).toThrowError(ExternalServiceUsageError);
expect(usage.read("resend_email").every((row) => row.status === "paused_quota")).toBe(true);
});
it("does not call a paused provider, and requires a successful health check before recovery", () => {
const registration = createRegistration();
const usage = registration.serviceUsage;
usage.markProviderFailure({ serviceId: "amap_web_service", reason: "provider_unavailable" });
expect(() => usage.claimAmap()).toThrowError(ExternalServiceUsageError);
expect(() => usage.recover({ serviceId: "amap_web_service", actorId: randomUUID(), checkId: randomUUID() }))
.toThrowError(/health_check_required/);
const check = usage.recordHealthCheck({ serviceId: "amap_web_service", available: true, reason: "ok" });
expect(usage.recover({ serviceId: "amap_web_service", actorId: randomUUID(), checkId: check.checkId })).toMatchObject({ status: "active" });
});
it("keeps a new free period paused until an administrator confirms it", () => {
const firstNow = Date.parse("2026-08-04T23:59:00.000Z");
const registration = createRegistration(firstNow);
registration.serviceUsage.claimAmap(firstNow);
const nextPeriod = new ExternalServiceUsage({
database: registration.database,
clock: () => Date.parse("2026-09-01T00:01:00.000Z"),
});
expect(() => nextPeriod.claimAmap()).toThrowError(/service_paused_quota/);
expect(nextPeriod.read("amap_web_service").find((row) => row.periodStart === Date.parse("2026-08-01T00:00:00.000Z"))?.status).toBe("active");
expect(nextPeriod.read("amap_web_service").find((row) => row.periodStart === Date.parse("2026-09-01T00:00:00.000Z"))?.status).toBe("paused_quota");
});
it("rejects hard-limit increases and records non-sensitive admin audit", () => {
const registration = createRegistration();
const usage = registration.serviceUsage;
expect(() => usage.setHardLimit({ serviceId: "amap_web_service", periodType: "monthly", hardLimit: 1001, actorId: randomUUID() }))
.toThrowError(/hard_limit_increase_forbidden/);
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'service_hard_limit_update'").get())
.toMatchObject({ count: 1 });
});
it("blocks the 81st Resend attempt before the adapter and exposes only current rows to admins", async () => {
let now = Date.parse("2026-08-04T09:00:00.000Z");
const registration = createRegistration(now);
const resend = registration.options.resend as MockResendAdapter;
registration.serviceUsage.setHardLimit({ serviceId: "resend_email", periodType: "daily", hardLimit: 1, actorId: "fixture-admin" });
registration.serviceUsage.setHardLimit({ serviceId: "resend_email", periodType: "monthly", hardLimit: 1, actorId: "fixture-admin" });
const invite = registration.createInvite({ expiresAt: now + 86_400_000, maxUses: 3 });
await registration.sendRegistrationCode({ email: "quota-one@example.invalid", inviteCode: invite.code });
expect(() => registration.serviceUsage.claimResend()).toThrowError(/service_paused_quota/);
expect(resend.calls).toHaveLength(1);
const adminId = seedAdmin(registration, now);
const adminSession = registration.issueAuthenticatedSession(adminId, "admin");
const csrfToken = registration.issueAdminCsrfToken(adminSession.sessionToken);
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
await app.ready();
const response = await app.inject({
headers: { cookie: `dada_admin_session=${adminSession.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" },
method: "GET",
url: "/api/v1/admin/services",
});
expect(response.statusCode).toBe(200);
expect(response.json().services).toHaveLength(3);
expect(response.json().services.find((row: { service_id: string; period_type: string }) => row.service_id === "resend_email" && row.period_type === "daily").service_status).toBe("paused_quota");
const health = await app.inject({
headers: { "idempotency-key": `${randomUUID()}${randomUUID()}`, "x-csrf-token": csrfToken, cookie: `dada_admin_session=${adminSession.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" },
method: "POST",
payload: { available: true, reason: "new_period" },
url: "/api/v1/admin/services/resend_email/health-check",
});
expect(health.statusCode).toBe(200);
await app.close();
});
it("stops DYN004 after its quota without affecting the rest of the editor", async () => {
const registration = createRegistration();
const userId = seedUser(registration);
const session = registration.issueAuthenticatedSession(userId, "user");
const csrfToken = registration.issueUserCsrfToken(session.sessionToken);
const amap = new MockAmapAdapter();
registration.serviceUsage.setHardLimit({ serviceId: "amap_web_service", periodType: "monthly", hardLimit: 1, actorId: "fixture-admin" });
const app = await createApp({ amap, browserGate: false, networkBoundary: { allowTestPort: true }, registration });
const headers = { cookie: `dada_session=${session.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121", "x-csrf-token": csrfToken };
const first = await app.inject({ headers, method: "POST", payload: { latitude: 30, longitude: 120 }, url: "/api/v1/location/reverse-geocode" });
const second = await app.inject({ headers, method: "POST", payload: { latitude: 31, longitude: 121 }, url: "/api/v1/location/reverse-geocode" });
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(503);
expect(amap.calls).toHaveLength(1);
await app.close();
});
});
+123
View File
@@ -0,0 +1,123 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { auditRetentionMilliseconds, serializeAuditSummary } from "../../apps/api/src/audit-policy.js";
import { createApp } from "../../apps/api/src/app.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { wp604OperationMatrix } from "../fixtures/wp6-04-audit.js";
const roots: string[] = [];
const services: RegistrationService[] = [];
const now = Date.parse("2026-08-04T09:30:00.000Z");
const requestHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
function fixture() {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-04-api-"));
roots.push(root);
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0xa1),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0xa2),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0xa3),
});
services.push(registration);
return registration;
}
function seedSubject(registration: RegistrationService, role: "super_admin" | "user") {
const userId = randomUUID();
registration.database.prepare(`
INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit,
registration_id, created_at
) VALUES (?, ?, ?, 'active', ?, ?, ?)
`).run(userId, `${role}-${userId}@example.invalid`, role, role === "user" ? 1 : 0, randomUUID(), now);
if (role === "super_admin") {
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
}
return userId;
}
function seedAuditRows(registration: RegistrationService, adminId: string) {
const operationInsert = registration.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', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
wp604OperationMatrix.forEach((entry, index) => {
const occurredAt = now - index * 1_000;
operationInsert.run(
randomUUID(), adminId, entry.operation_type, entry.target_type, randomUUID(),
entry.operation_type === "asset_cleanup_reference_denied" || entry.operation_type === "asset_cleanup_physical_failed" ? "failed" : "succeeded",
serializeAuditSummary({ status: "before" }), serializeAuditSummary({ count: index, status: "after" }),
occurredAt, occurredAt + auditRetentionMilliseconds,
);
});
registration.database.prepare(`
INSERT INTO private_content_access_logs (
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
) VALUES (?, ?, ?, ?, 'prompt', ?, ?)
`).run(randomUUID(), adminId, randomUUID(), randomUUID(), now - 60_000, now - 60_000 + auditRetentionMilliseconds);
}
afterEach(() => {
for (const service of services.splice(0)) service.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP6-AUD-001-sensitive-operations", () => {
it("keeps operation and private-content audit APIs separate, admin-only, redacted, and cursor-paged", async () => {
const registration = fixture();
const adminId = seedSubject(registration, "super_admin");
const userId = seedSubject(registration, "user");
seedAuditRows(registration, adminId);
const admin = registration.issueAuthenticatedSession(adminId, "admin");
const ordinary = registration.issueAuthenticatedSession(userId, "user");
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
for (const path of ["operations", "private-content"]) {
const denied = await app.inject({
headers: { ...requestHeaders, cookie: `dada_admin_session=${ordinary.sessionToken}` },
method: "GET",
url: `/api/v1/admin/audit/${path}?limit=2`,
});
expect(denied.statusCode).toBe(401);
}
const headers = { ...requestHeaders, cookie: `dada_admin_session=${admin.sessionToken}` };
const first = await app.inject({ headers, method: "GET", url: "/api/v1/admin/audit/operations?limit=2" });
expect(first.statusCode).toBe(200);
expect(first.json().items).toHaveLength(2);
expect(first.json().next_cursor).toEqual(expect.any(String));
const second = await app.inject({ headers, method: "GET", url: `/api/v1/admin/audit/operations?limit=2&cursor=${first.json().next_cursor}` });
expect(second.statusCode).toBe(200);
expect(second.json().items[0].log_id).not.toBe(first.json().items[0].log_id);
const privateAccess = await app.inject({ headers, method: "GET", url: "/api/v1/admin/audit/private-content?limit=20" });
expect(privateAccess.statusCode).toBe(200);
expect(privateAccess.json().items).toHaveLength(1);
expect(privateAccess.json().items[0]).toMatchObject({ content_type: "prompt" });
expect(JSON.stringify(first.json())).not.toMatch(/content_type|subject_ref|prompt|image|email|secret|path/i);
expect(JSON.stringify(privateAccess.json())).not.toMatch(/operation_type|before_summary|after_summary|email|secret|path/i);
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD;
if (evidenceRoot) {
mkdirSync(evidenceRoot, { recursive: true });
writeFileSync(resolve(evidenceRoot, "redaction.json"), `${JSON.stringify({
operation_fields: Object.keys(first.json().items[0]).sort(),
private_access_fields: Object.keys(privateAccess.json().items[0]).sort(),
sensitive_fields_present: false,
}, null, 2)}\n`);
}
await app.close();
});
});
+179
View File
@@ -0,0 +1,179 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../apps/api/src/app.js";
import { createAdminServicesStorageProvider } from "../../apps/api/src/admin-state.js";
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
import { ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts";
const now = "2026-08-04T09:30:00.000Z";
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
const roots: string[] = [];
const registrations: RegistrationService[] = [];
const storages: ManagedStorage[] = [];
const servicesFixture: AdminServicesStorageResponse = {
generated_at: now,
services: [
{ checked_at: now, configured: true, impact_scope: "authentication", pause_reason: null, service_id: "resend", status: "active" },
{ checked_at: now, configured: true, impact_scope: "location", pause_reason: "quota_exhausted", service_id: "amap", status: "paused_quota" },
{ checked_at: now, configured: true, impact_scope: "generation", pause_reason: "balance_insufficient", service_id: "ai_gateway", status: "degraded" },
{ checked_at: now, configured: true, impact_scope: "generation", pause_reason: "worker_stopped", service_id: "worker", status: "degraded" },
{ checked_at: now, configured: true, impact_scope: "api", pause_reason: null, service_id: "api", status: "active" },
{ checked_at: now, configured: true, impact_scope: "storage", pause_reason: null, service_id: "asset_root", status: "active" },
],
storage: {
capacity_notice_level: "warning",
cleanup_pending_count: 2,
data_root_ref: "configured_local_data_root",
hard_limit_bytes: 5_368_709_120,
last_measured_at: now,
managed_content_bytes: 4_563_402_752,
remeasurement_required: false,
status: "active",
storage_backend: "local_filesystem",
},
};
const diagnosticsFixture: AdminDiagnosticsResponse = {
generated_at: now,
diagnostic_text: "Dada P0-A\napp_version=0.0.0\napi_status=ready\nworker_status=ready\nstorage_status=active",
services: servicesFixture,
system: {
api_status: "ready",
app_version: "0.0.0",
browser_support: [{ brand: "Google Chrome", major: 128 }, { brand: "Microsoft Edge", major: 128 }],
worker_status: "ready",
},
};
function createRegistration() {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-05-api-"));
roots.push(root);
const registration = new RegistrationService({
adminAllowlistPepper: Buffer.alloc(32, 0xe1),
challengePepper: Buffer.alloc(32, 0xe2),
clock: () => Date.parse(now),
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0xe3),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0xe4),
});
registrations.push(registration);
return registration;
}
function seedAdmin(registration: RegistrationService) {
const userId = randomUUID();
registration.database.prepare(`
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
VALUES (?, ?, 'super_admin', 'active', 0, ?, ?)
`).run(userId, `${userId}@example.invalid`, randomUUID(), Date.parse(now));
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
return registration.issueAuthenticatedSession(userId, "admin");
}
afterEach(() => {
for (const storage of storages.splice(0)) storage.close();
for (const registration of registrations.splice(0)) registration.close();
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe("admin status aggregation", () => {
it("reads storage and runtime truth without fabricating provider readiness", () => {
const registration = createRegistration();
const root = roots[roots.length - 1]!;
const storage = new ManagedStorage({ dataRoot: root, databasePath: join(root, "dada.sqlite3") });
storages.push(storage);
const models = new ModelConfigurationService({ database: registration.database, clock: () => Date.parse(now) });
const state = createAdminServicesStorageProvider({ database: registration.database, models, storage, clock: () => Date.parse(now) })();
expect(new Set(state.services.map((service) => service.service_id)).size).toBe(6);
expect(state.services.find((service) => service.service_id === "worker")?.status).toBe("unavailable");
expect(state.services.find((service) => service.service_id === "ai_gateway")?.status).toBe("degraded");
expect(state.storage.storage_backend).toBe("local_filesystem");
expect(JSON.stringify(state)).not.toContain("http");
expect(JSON.stringify(state)).not.toContain("@example");
});
});
describe("TDD-WP6-STATE-001-three-model-states", () => {
it("requires an active admin session and keeps model/service state provider-backed", async () => {
const registration = createRegistration();
let serviceCalls = 0;
const app = await createApp({
adminDiagnostics: async () => diagnosticsFixture,
adminServicesStorage: async () => {
serviceCalls += 1;
return servicesFixture;
},
browserGate: false,
networkBoundary: { allowTestPort: true },
registration,
});
const denied = await app.inject({ headers, method: "GET", url: "/api/v1/admin/services-storage" });
expect(denied.statusCode).toBe(401);
expect(serviceCalls).toBe(0);
const session = seedAdmin(registration);
const authorizedHeaders = { ...headers, cookie: `dada_admin_session=${session.sessionToken}` };
const state = await app.inject({ headers: authorizedHeaders, method: "GET", url: "/api/v1/admin/services-storage" });
expect(state.statusCode).toBe(200);
expect(state.json()).toEqual(servicesFixture);
expect(serviceCalls).toBe(1);
expect(JSON.stringify(state.json()).toLowerCase()).not.toMatch(/secret|password|email|absolute/);
const diagnostic = await app.inject({ headers: authorizedHeaders, method: "GET", url: "/api/v1/admin/diagnostics" });
expect(diagnostic.statusCode).toBe(200);
expect(diagnostic.json()).toEqual(diagnosticsFixture);
await app.close();
});
});
describe("TDD-WP6-DIAG-001-redacted-diagnostics", () => {
it("rejects a provider diagnostic that contains a credential or absolute path trap", async () => {
const registration = createRegistration();
const app = await createApp({
adminDiagnostics: async () => ({ ...diagnosticsFixture, diagnostic_text: "api_key=trap C:\\Users\\dada\\secret.txt" }),
browserGate: false,
networkBoundary: { allowTestPort: true },
registration,
});
const session = seedAdmin(registration);
const response = await app.inject({
headers: { ...headers, cookie: `dada_admin_session=${session.sessionToken}` },
method: "GET",
url: "/api/v1/admin/diagnostics",
});
expect(response.statusCode).toBe(503);
await app.close();
});
it("rejects a sensitive nested service reason even when diagnostic text is clean", async () => {
const registration = createRegistration();
const app = await createApp({
adminDiagnostics: async () => ({
...diagnosticsFixture,
services: {
...servicesFixture,
services: servicesFixture.services.map((service) => service.service_id === "resend" ? { ...service, pause_reason: "password" } : service),
},
}),
browserGate: false,
networkBoundary: { allowTestPort: true },
registration,
});
const session = seedAdmin(registration);
const response = await app.inject({ headers: { ...headers, cookie: `dada_admin_session=${session.sessionToken}` }, method: "GET", url: "/api/v1/admin/diagnostics" });
expect(response.statusCode).toBe(503);
await app.close();
});
});
+10 -1
View File
@@ -7,7 +7,16 @@ import { createServer, type ViteDevServer } from "vite";
let vite: ViteDevServer; let vite: ViteDevServer;
let webUrl: string; let webUrl: string;
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64"); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64");
const adminSession = { csrf_token: "csrf-wp5-05-admin-000000000000000000000000000000000" }; const adminSession = {
acknowledged_private_content_notice_version: null,
admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000001405" },
audience: "admin",
authenticated: true,
csrf_token: "csrf-wp5-05-admin-000000000000000000000000000000000",
current_private_content_notice_version: null,
expires_at: "2026-09-03T12:00:00.000Z",
notice_acknowledged: false,
};
const projectId = "00000000-0000-4000-8000-000000001405"; const projectId = "00000000-0000-4000-8000-000000001405";
const userSession = { const userSession = {
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 }, audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
+137
View File
@@ -0,0 +1,137 @@
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
import { adminOverviewFixture } from "../fixtures/wp6-01-admin-overview.js";
let vite: ViteDevServer;
let webUrl: string;
const adminSession = {
acknowledged_private_content_notice_version: null,
admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000000601" },
audience: "admin",
authenticated: true,
csrf_token: "csrf-admin-shell-fixture-000000000000000000000000000000000",
current_private_content_notice_version: null,
expires_at: "2026-09-02T09:30:00.000Z",
notice_acknowledged: false,
};
const navigation = [
["总览", "/admin"],
["用户与点数", "/admin/users"],
["邀请码", "/admin/invites"],
["模型", "/admin/models"],
["素材", "/admin/assets"],
["内部预览", "/admin/preview"],
["生成记录", "/admin/generations"],
["服务与存储", "/admin/services-storage"],
["审计", "/admin/audit"],
] as const;
test.beforeAll(async () => {
vite = await createServer({
configFile: resolve("apps/web/vite.config.ts"),
root: resolve("apps/web"),
server: { host: "127.0.0.1", port: 0 },
});
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
async function routeActiveAdmin(page: Page) {
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({
body: JSON.stringify(adminSession),
contentType: "application/json",
status: 200,
}));
await page.route("**/api/v1/admin/overview", (route) => route.fulfill({
body: JSON.stringify(adminOverviewFixture),
contentType: "application/json",
status: 200,
}));
}
test("TDD-WP6-ADM-001-role-and-summary renders the protected nine-entry admin shell", async ({ page }) => {
const requests: string[] = [];
page.on("request", (request) => requests.push(request.url()));
await routeActiveAdmin(page);
await page.goto(`${webUrl}/admin`);
await expect(page.getByRole("heading", { level: 2, name: "运营总览" })).toBeVisible();
const sidebar = page.getByRole("navigation", { name: "后台主导航" });
await expect(sidebar).toBeVisible();
for (const [name, href] of navigation) {
await expect(sidebar.getByRole("link", { name, exact: true })).toHaveAttribute("href", href);
}
expect(Math.round((await sidebar.boundingBox())?.width ?? 0)).toBe(216);
await expect(page.getByText("4 / 10", { exact: true })).toBeVisible();
await expect(page.getByText("待人工核对 1", { exact: true })).toBeVisible();
await expect(page.getByText("85.0%", { exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "有异常", exact: true })).toBeVisible();
await expect(page.locator("body")).not.toContainText(/forbidden|example\.invalid|api[_ -]?key|完整提示词/i);
expect(requests.some((url) => /prompt|private-content|image-content/i.test(url))).toBe(false);
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_ADMIN;
if (evidenceRoot) {
const screenshotDirectory = resolve(evidenceRoot, "screenshots");
mkdirSync(screenshotDirectory, { recursive: true });
await page.screenshot({ fullPage: true, path: resolve(screenshotDirectory, "admin-overview.png") });
}
});
test("TDD-WP6-ADM-001-role-and-summary keeps ordinary and preview sessions outside admin", async ({ page }) => {
let overviewCalls = 0;
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({
body: JSON.stringify({ error: { code: "AUTH_SESSION_INVALID", subject: "preview_user" } }),
contentType: "application/json",
status: 401,
}));
await page.route("**/api/v1/admin/overview", (route) => {
overviewCalls += 1;
return route.fulfill({ body: "null", contentType: "application/json", status: 401 });
});
await page.goto(`${webUrl}/admin`);
await expect(page).toHaveURL(`${webUrl}/admin/login`);
await expect(page.getByRole("heading", { name: "管理员邮箱验证码登录" })).toBeVisible();
expect(overviewCalls).toBe(0);
await expect(page.getByText("DADA ADMIN", { exact: true })).toHaveCount(0);
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_ADMIN;
if (evidenceRoot) {
const screenshotDirectory = resolve(evidenceRoot, "screenshots");
mkdirSync(screenshotDirectory, { recursive: true });
await page.screenshot({ fullPage: true, path: resolve(screenshotDirectory, "admin-denied.png") });
}
});
test("TDD-WP6-ADM-001-role-and-summary ejects a disabled admin when the session is rechecked", async ({ page }) => {
let active = true;
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill(active ? {
body: JSON.stringify(adminSession),
contentType: "application/json",
status: 200,
} : {
body: JSON.stringify({ error: { code: "AUTH_SESSION_INVALID" } }),
contentType: "application/json",
status: 401,
}));
await page.route("**/api/v1/admin/overview", (route) => route.fulfill({
body: JSON.stringify(adminOverviewFixture),
contentType: "application/json",
status: 200,
}));
await page.goto(`${webUrl}/admin`);
await expect(page.getByRole("heading", { level: 2, name: "运营总览" })).toBeVisible();
active = false;
await page.evaluate(() => window.dispatchEvent(new Event("dada:session-invalid")));
await expect(page).toHaveURL(`${webUrl}/admin/login`);
});
+51
View File
@@ -0,0 +1,51 @@
import { resolve } from "node:path";
import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
import { wp604AuditApiFixture, wp604PrivateAuditApiFixture } from "../fixtures/wp6-04-audit.js";
let vite: ViteDevServer;
let webUrl: string;
const adminSession = {
acknowledged_private_content_notice_version: null,
admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000000604" },
audience: "admin",
authenticated: true,
csrf_token: "csrf-wp6-04-admin-0000000000000000000000000000000000",
current_private_content_notice_version: null,
expires_at: "2026-09-04T09:30:00.000Z",
notice_acknowledged: false,
};
test.beforeAll(async () => {
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
test("TDD-WP6-AUD-001-sensitive-operations renders two immutable, redacted audit lists", async ({ page }) => {
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
await page.route("**/api/v1/admin/audit/operations**", (route) => route.fulfill({ body: JSON.stringify(wp604AuditApiFixture), contentType: "application/json", status: 200 }));
await page.route("**/api/v1/admin/audit/private-content**", (route) => route.fulfill({ body: JSON.stringify(wp604PrivateAuditApiFixture), contentType: "application/json", status: 200 }));
await page.goto(`${webUrl}/admin/audit`);
await expect(page.getByRole("heading", { level: 2, name: "审计" })).toBeVisible();
await expect(page.getByRole("tab", { name: "后台操作审计" })).toHaveAttribute("aria-selected", "true");
await expect(page.getByRole("cell", { name: "user_status_change" })).toBeVisible();
await expect(page.getByRole("button", { name: "下一页" })).toBeVisible();
await page.getByRole("tab", { name: "私有内容访问审计" }).click();
await expect(page.getByRole("cell", { name: "prompt" })).toBeVisible();
await expect(page.getByRole("cell", { name: "00000000-0000-4000-8000-000000000644" })).toBeVisible();
await expect(page.getByText(/private prompt body|api key|absolute path/i)).toHaveCount(0);
await expect(page.getByRole("button", { name: /删除|编辑|清空/ })).toHaveCount(0);
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD;
if (evidenceRoot) await page.screenshot({ fullPage: true, path: resolve(evidenceRoot, "admin-audit.png") });
});
+95
View File
@@ -0,0 +1,95 @@
import { expect, test } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
import { resolve } from "node:path";
import { adminDiagnosticsFixture, adminServicesStorageFixture } from "../fixtures/wp6-05-state.js";
let vite: ViteDevServer;
let webUrl: string;
const adminSession = {
admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000000605" },
audience: "admin",
authenticated: true,
expires_at: "2026-09-02T09:30:00.000Z",
};
test.beforeAll(async () => {
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
test("TDD-WP6-DIAG-001-redacted-diagnostics renders state and diagnostics without sensitive fields", async ({ page }) => {
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
await page.route("**/api/v1/admin/services-storage", (route) => route.fulfill({ body: JSON.stringify(adminServicesStorageFixture), contentType: "application/json", status: 200 }));
await page.route("**/api/v1/admin/diagnostics", (route) => route.fulfill({ body: JSON.stringify(adminDiagnosticsFixture), contentType: "application/json", status: 200 }));
await page.goto(`${webUrl}/admin/services-storage`);
await expect(page.getByRole("heading", { level: 2, name: "服务与存储" })).toBeVisible();
await expect(page.getByText("Resend", { exact: true })).toBeVisible();
await expect(page.getByText("额度暂停", { exact: true })).toBeVisible();
await expect(page.getByText("4.25 GB", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "复制诊断" })).toBeEnabled();
await expect(page.locator("body")).not.toContainText(/secret|password|example\.invalid|C:\\Users/i);
});
test("TDD-WP6-STATE-001-three-model-states refetches REST truth after a runtime event", async ({ page }) => {
await page.addInitScript(() => {
class FakeEventSource {
static instance: FakeEventSource | undefined;
onmessage: ((event: MessageEvent) => void) | null = null;
constructor() { FakeEventSource.instance = this; }
close() { if (FakeEventSource.instance === this) FakeEventSource.instance = undefined; }
}
Object.defineProperty(window, "EventSource", { configurable: true, value: FakeEventSource });
(window as unknown as { emitDadaEvent: () => void }).emitDadaEvent = () => FakeEventSource.instance?.onmessage?.({ data: "{}" } as MessageEvent);
});
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify({ ...adminSession, csrf_token: "csrf-admin-model-fixture-000000000000000000000000000000000" }), contentType: "application/json", status: 200 }));
let runtimeAvailable = false;
let modelCalls = 0;
const configuration = () => {
const ids = ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gpt-image-2"];
return {
config_set_version: 1,
configured_default_model_id: ids[0],
recommended_model_id: runtimeAvailable ? ids[1] : null,
models: ids.map((modelId, index) => ({
config_version: 1,
contract_evidence_ref: null,
contract_validation_status: "verified",
credit_cost: 1,
display_name: ["Gemini 3.1 Flash Image Preview", "Gemini 3 Pro Image Preview", "GPT Image 2"][index],
enabled: true,
error_mapping_profile: { timeout: "upstream_timeout" },
gateway_account_ref: "mock-gateway",
is_default: index === 0,
model_id: modelId,
prompt_max_length: 1_000,
recommendation_priority: index + 1,
reference_limits: { max_file_bytes: 1_024, max_files: 2, max_total_bytes: 2_048 },
route_profile: { endpoint: "https://mock.invalid/v1/images" },
runtime_availability: { available_for_new_jobs: runtimeAvailable, checked_at: "2026-08-02T15:00:00.000Z", reason: runtimeAvailable ? "available" : "gateway_balance_insufficient" },
safety_source: "provider",
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
})),
};
};
await page.route("**/api/v1/models", (route) => {
modelCalls += 1;
return route.fulfill({ body: JSON.stringify(configuration()), contentType: "application/json", status: 200 });
});
await page.goto(`${webUrl}/admin/models`);
await expect(page.getByText("当前推荐").locator("..").getByText("无", { exact: true })).toBeVisible();
expect(modelCalls).toBeGreaterThanOrEqual(1);
runtimeAvailable = true;
await page.evaluate(() => (window as unknown as { emitDadaEvent: () => void }).emitDadaEvent());
await expect(page.getByText("当前推荐").locator("..").getByText("gemini-3-pro-image-preview", { exact: true })).toBeVisible();
await expect(page.getByText("配置默认").locator("..").getByText("gemini-3.1-flash-image-preview", { exact: true })).toBeVisible();
expect(modelCalls).toBeGreaterThanOrEqual(2);
});
+44
View File
@@ -0,0 +1,44 @@
export const adminOverviewFixture = {
generated_at: "2026-08-03T09:30:00.000Z",
user_slots: {
active_and_suspended: 4,
limit: 10,
},
generation_jobs: {
pending_manual_review: 1,
pending_manual_review_oldest_at: "2026-08-03T09:12:00.000Z",
queued: 2,
running: 1,
},
models: {
configured_default_model_id: "gemini-3.1-flash-image-preview",
configured_model_count: 3,
recommended_model_id: "gemini-3-pro-image-preview",
runtime_available_count: 2,
},
storage: {
last_measured_at: "2026-08-03T09:29:00.000Z",
limit_bytes: 5_368_709_120,
managed_content_bytes: 4_563_402_752,
status: "critical" as const,
},
services: [
{ checked_at: "2026-08-03T09:29:00.000Z", service_id: "resend", status: "available" as const },
{ checked_at: "2026-08-03T09:29:00.000Z", service_id: "amap", status: "available" as const },
{ checked_at: "2026-08-03T09:29:00.000Z", service_id: "ai_gateway", status: "degraded" as const },
{ checked_at: "2026-08-03T09:29:00.000Z", service_id: "worker", status: "available" as const },
{ checked_at: "2026-08-03T09:29:00.000Z", service_id: "asset_root", status: "degraded" as const },
],
recent_operations: [
{
created_at: "2026-08-03T09:20:00.000Z",
operation_id: "00000000-0000-4000-8000-000000000621",
operation_type: "model_configuration_update",
result: "succeeded" as const,
target_ref: "model-config-set:7",
},
],
asset_cleanup: {
pending_jobs: 0,
},
};
+52
View File
@@ -0,0 +1,52 @@
export const wp604OperationMatrix = [
{ operation_type: "user_status_change", target_type: "user_account" },
{ operation_type: "credit_adjustment", target_type: "user_credit_account" },
{ operation_type: "invite_create", target_type: "invite" },
{ operation_type: "model_configuration_replace", target_type: "model_config_set" },
{ operation_type: "sticker_release_publish", target_type: "sticker_release" },
{ operation_type: "preview_grant_create", target_type: "preview_grant" },
{ operation_type: "service_hard_limit_update", target_type: "external_service_limit" },
{ operation_type: "gateway_balance_recovery", target_type: "gateway_balance_state" },
{ operation_type: "asset_cleanup_requested", target_type: "asset_cleanup_request" },
{ operation_type: "asset_cleanup_validated", target_type: "asset_cleanup_request" },
{ operation_type: "asset_cleanup_scheduled", target_type: "asset_cleanup_request" },
{ operation_type: "asset_cleanup_reference_denied", target_type: "asset_cleanup_request" },
{ operation_type: "asset_cleanup_physical_completed", target_type: "asset_cleanup" },
{ operation_type: "asset_cleanup_physical_failed", target_type: "asset_cleanup" },
{ operation_type: "secure_config_apply", target_type: "secure_config_revision" },
] as const;
export const wp604AuditApiFixture = {
generated_at: "2026-08-04T09:30:00.000Z",
items: [
{
actor_ref: "00000000-0000-4000-8000-000000000604",
actor_type: "super_admin",
after_summary: "{\"status\":\"suspended\"}",
before_summary: "{\"status\":\"active\"}",
expires_at: "2027-01-31T09:30:00.000Z",
log_id: "00000000-0000-4000-8000-000000000641",
occurred_at: "2026-08-04T09:30:00.000Z",
operation_type: "user_status_change",
result: "succeeded",
target_ref: "00000000-0000-4000-8000-000000000642",
target_type: "user_account",
},
],
next_cursor: "audit_cursor_page_2",
} as const;
export const wp604PrivateAuditApiFixture = {
generated_at: "2026-08-04T09:30:00.000Z",
items: [
{
actor_ref: "00000000-0000-4000-8000-000000000604",
content_type: "prompt",
expires_at: "2027-01-31T09:29:00.000Z",
log_id: "00000000-0000-4000-8000-000000000643",
occurred_at: "2026-08-04T09:29:00.000Z",
target_ref: "00000000-0000-4000-8000-000000000644",
},
],
next_cursor: null,
} as const;
+34
View File
@@ -0,0 +1,34 @@
export const adminServicesStorageFixture = {
generated_at: "2026-08-04T09:30:00.000Z",
services: [
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "authentication" as const, pause_reason: null, service_id: "resend" as const, status: "active" as const },
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "location" as const, pause_reason: "quota_exhausted", service_id: "amap" as const, status: "paused_quota" as const },
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "generation" as const, pause_reason: "balance_insufficient", service_id: "ai_gateway" as const, status: "degraded" as const },
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "generation" as const, pause_reason: "worker_stopped", service_id: "worker" as const, status: "degraded" as const },
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "api" as const, pause_reason: null, service_id: "api" as const, status: "active" as const },
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "storage" as const, pause_reason: null, service_id: "asset_root" as const, status: "active" as const },
],
storage: {
capacity_notice_level: "warning" as const,
cleanup_pending_count: 2,
data_root_ref: "configured_local_data_root" as const,
hard_limit_bytes: 5_368_709_120,
last_measured_at: "2026-08-04T09:30:00.000Z",
managed_content_bytes: 4_563_402_752,
remeasurement_required: false,
status: "active" as const,
storage_backend: "local_filesystem" as const,
},
};
export const adminDiagnosticsFixture = {
generated_at: "2026-08-04T09:30:00.000Z",
diagnostic_text: "Dada P0-A diagnostics\napp_version=0.0.0\napi_status=ready\nworker_status=ready\nstorage_status=active",
services: adminServicesStorageFixture,
system: {
api_status: "ready" as const,
app_version: "0.0.0",
browser_support: [{ brand: "Google Chrome" as const, major: 128 }],
worker_status: "ready" as const,
},
};
@@ -1,15 +1,19 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import type BetterSqlite3 from "better-sqlite3";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { HARD_LIMIT_BYTES, ManagedStorage, StorageCapacityError, StorageUnavailableError } from "../../apps/api/src/managed-storage.js"; import { HARD_LIMIT_BYTES, ManagedStorage, StorageCapacityError, StorageUnavailableError } from "../../apps/api/src/managed-storage.js";
import { StickerReleaseService } from "../../apps/api/src/sticker-releases.js"; import { StickerReleaseService } from "../../apps/api/src/sticker-releases.js";
const now = Date.parse("2026-08-03T12:00:00.000Z"); const now = Date.parse("2026-08-03T12:00:00.000Z");
const require = createRequire(resolve("apps/api/package.json"));
const Database = require("better-sqlite3") as typeof BetterSqlite3;
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64"); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWO4E6XxHxkzEBQAANIxHF3ECQOzAAAAAElFTkSuQmCC", "base64");
const webp = Buffer.from("UklGRjoAAABXRUJQVlA4IC4AAADQAQCdASoGAAUAAUAmJaACdLoB+AADsAD+9IiH/pNnibPE2fJI/+Uq8Fjc3wAA", "base64"); const webp = Buffer.from("UklGRjoAAABXRUJQVlA4IC4AAADQAQCdASoGAAUAAUAmJaACdLoB+AADsAD+9IiH/pNnibPE2fJI/+Uq8Fjc3wAA", "base64");
const roots: string[] = []; const roots: string[] = [];
@@ -38,7 +42,7 @@ function fixture() {
const storage = new ManagedStorage({ dataRoot, databasePath }); const storage = new ManagedStorage({ dataRoot, databasePath });
const stickers = new StickerReleaseService({ clock: () => now, databasePath, storage }); const stickers = new StickerReleaseService({ clock: () => now, databasePath, storage });
closeables.push(stickers, storage); closeables.push(stickers, storage);
return { dataRoot, stickers, storage }; return { dataRoot, databasePath, stickers, storage };
} }
async function upload(stickers: StickerReleaseService, stableId: string, order: number, bytes = png, mimeType: "image/png" | "image/webp" = "image/png") { async function upload(stickers: StickerReleaseService, stableId: string, order: number, bytes = png, mimeType: "image/png" | "image/webp" = "image/png") {
@@ -82,6 +86,26 @@ describe("TDD-WP5-UPL-001 upload metering", () => {
expect(test.stickers.listPublic(published.release_version).items).toHaveLength(1); expect(test.stickers.listPublic(published.release_version).items).toHaveLength(1);
expect(test.stickers.readPublicAsset(published.release_version, "STK1408", "original")?.bytes).toEqual(png); expect(test.stickers.readPublicAsset(published.release_version, "STK1408", "original")?.bytes).toEqual(png);
expect(test.stickers.readPublicAsset(disabled.release_version, "STK1408", "original")).toBeUndefined(); expect(test.stickers.readPublicAsset(disabled.release_version, "STK1408", "original")).toBeUndefined();
const audit = new Database(test.databasePath, { readonly: true });
expect(audit.prepare(`
SELECT operation_type, target_ref, result FROM admin_operation_logs
WHERE operation_type IN ('sticker_release_publish', 'sticker_release_update')
ORDER BY occurred_at
`).all()).toEqual([
{ operation_type: "sticker_release_publish", result: "succeeded", target_ref: published.release_version },
{ operation_type: "sticker_release_update", result: "succeeded", target_ref: disabled.release_version },
]);
audit.close();
const auditFailure = new Database(test.databasePath);
auditFailure.exec(`
CREATE TRIGGER force_sticker_release_audit_failure BEFORE INSERT ON admin_operation_logs
WHEN NEW.operation_type = 'sticker_release_update'
BEGIN SELECT RAISE(ABORT, 'forced_audit_failure'); END;
`);
expect(() => test.stickers.update({ actorId: randomUUID(), enabled: true, stableId: "STK1408" }))
.toThrow("forced_audit_failure");
expect(test.stickers.adminView()).toMatchObject({ release_version: disabled.release_version, items: [{ enabled: false }] });
auditFailure.close();
evidence("fs-before.json", before); evidence("fs-before.json", before);
evidence("fs-after.json", { files: filesBelow(join(test.dataRoot, "managed-assets")), state: test.storage.getState() }); evidence("fs-after.json", { files: filesBelow(join(test.dataRoot, "managed-assets")), state: test.storage.getState() });
@@ -0,0 +1,180 @@
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { StickerReleaseService } from "../../apps/api/src/sticker-releases.js";
import { ProjectPurgeCleanup } from "../../apps/worker/src/project-purge-cleanup.js";
const roots: string[] = [];
const closeables: Array<{ close(): void }> = [];
function fixture() {
const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp5-06-cleanup-"));
roots.push(dataRoot);
mkdirSync(join(dataRoot, "db"), { recursive: true });
const databasePath = join(dataRoot, "db", "dada.sqlite3");
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x61),
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
databasePath,
invitePepper: Buffer.alloc(32, 0x62),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0x63),
});
const storage = new ManagedStorage({ dataRoot, databasePath });
const stickers = new StickerReleaseService({ databasePath, storage });
closeables.push(stickers, storage, registration);
return { dataRoot, databasePath, database: registration.database, storage };
}
function seedAdmin(database: RegistrationService["database"]) {
const adminId = randomUUID();
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(adminId, `${adminId}@example.invalid`, randomUUID(), Date.now());
database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
return adminId;
}
async function seedHistoricalPair(test: ReturnType<typeof fixture>) {
const original = await test.storage.commitBufferFixture("sticker_original", "STK2401.png", Buffer.from("original-history"));
const thumbnail = await test.storage.commitBufferFixture("sticker_thumbnail", "STK2401-thumbnail.png", Buffer.from("thumbnail-history"));
const insert = test.database.prepare(`
INSERT INTO sticker_managed_file_history (
managed_file_id, stable_id, resource_version, file_kind, created_at
) VALUES (?, 'STK2401', 'asset-20260701.1', ?, ?)
`);
insert.run(original.file_id, "original", Date.now());
insert.run(thumbnail.file_id, "thumbnail", Date.now());
return { original, thumbnail };
}
afterEach(() => {
for (const value of closeables.splice(0).reverse()) value.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP5-CLN-001 sticker history cleanup", () => {
it("denies the whole batch when a reference appears after the candidate snapshot", async () => {
const test = fixture();
const adminId = seedAdmin(test.database);
const files = await seedHistoricalPair(test);
const candidates = test.storage.listAssetCleanupCandidates();
expect(candidates.items).toEqual(expect.arrayContaining([
expect.objectContaining({ file_id: files.original.file_id, file_kind: "original", reference_count: 0, stable_id: "STK2401" }),
expect.objectContaining({ file_id: files.thumbnail.file_id, file_kind: "thumbnail", reference_count: 0, stable_id: "STK2401" }),
]));
const intent = test.storage.createAssetCleanupIntent({
actorId: adminId,
fileIds: [files.original.file_id, files.thumbnail.file_id],
idempotencyKey: `cleanup-${randomUUID()}-${randomUUID()}`,
snapshotVersion: candidates.candidate_snapshot_version,
});
test.storage.addAssetReference(files.original.file_id, "release");
expect(() => test.storage.confirmAssetCleanupIntent({
actorId: adminId,
confirmationToken: intent.confirmation_token,
requestId: intent.request_id,
})).toThrow("ASSET_HISTORY_REFERENCE_CONFLICT");
expect(test.database.prepare("SELECT status FROM asset_cleanup_requests WHERE request_id = ?").get(intent.request_id)).toEqual({ status: "denied" });
expect(test.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status = 'pending'").get()).toEqual({ count: 0 });
expect(test.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE target_ref = ? AND result = 'failed'").get(intent.request_id)).toEqual({ count: 1 });
});
it("requires the same admin, queues without reducing capacity, then remeasures after physical deletion", async () => {
const test = fixture();
const adminId = seedAdmin(test.database);
const otherAdminId = seedAdmin(test.database);
const files = await seedHistoricalPair(test);
const bytesBefore = test.storage.getState().managed_content_bytes;
const candidates = test.storage.listAssetCleanupCandidates();
const intent = test.storage.createAssetCleanupIntent({
actorId: adminId,
fileIds: [files.original.file_id, files.thumbnail.file_id],
idempotencyKey: `cleanup-${randomUUID()}-${randomUUID()}`,
snapshotVersion: candidates.candidate_snapshot_version,
});
expect(() => test.storage.confirmAssetCleanupIntent({
actorId: otherAdminId,
confirmationToken: intent.confirmation_token,
requestId: intent.request_id,
})).toThrow("ASSET_CLEANUP_CANDIDATE_STALE");
const queued = test.storage.confirmAssetCleanupIntent({
actorId: adminId,
confirmationToken: intent.confirmation_token,
requestId: intent.request_id,
});
expect(queued).toMatchObject({ file_count: 2, status: "queued" });
expect(test.storage.getState().managed_content_bytes).toBe(bytesBefore);
expect(existsSync(join(test.dataRoot, files.original.relative_path))).toBe(true);
expect(test.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status = 'pending'").get()).toEqual({ count: 2 });
const worker = new ProjectPurgeCleanup({ dataRoot: test.dataRoot, databasePath: test.databasePath });
const result = worker.processFileCleanup();
worker.close();
expect(result).toEqual({ completed: 2, failed: 0 });
expect(existsSync(join(test.dataRoot, files.original.relative_path))).toBe(false);
expect(existsSync(join(test.dataRoot, files.thumbnail.relative_path))).toBe(false);
expect(test.storage.getState()).toMatchObject({ managed_content_bytes: 0, storage_status: "active" });
expect(test.database.prepare("SELECT status FROM asset_cleanup_requests WHERE request_id = ?").get(intent.request_id)).toEqual({ status: "completed" });
expect(test.database.prepare("SELECT operation_type, result FROM admin_operation_logs WHERE target_ref = ? ORDER BY occurred_at").all(intent.request_id)).toEqual([
{ operation_type: "asset_cleanup_requested", result: "succeeded" },
{ operation_type: "asset_cleanup_validated", result: "succeeded" },
{ operation_type: "asset_cleanup_scheduled", result: "succeeded" },
{ operation_type: "asset_cleanup_physical_completed", result: "succeeded" },
]);
});
it("records a redacted physical failure in the same transaction and leaves the request retryable", async () => {
const test = fixture();
const adminId = seedAdmin(test.database);
const files = await seedHistoricalPair(test);
const candidates = test.storage.listAssetCleanupCandidates();
const intent = test.storage.createAssetCleanupIntent({
actorId: adminId,
fileIds: [files.original.file_id, files.thumbnail.file_id],
idempotencyKey: `cleanup-${randomUUID()}-${randomUUID()}`,
snapshotVersion: candidates.candidate_snapshot_version,
});
test.storage.confirmAssetCleanupIntent({
actorId: adminId,
confirmationToken: intent.confirmation_token,
requestId: intent.request_id,
});
test.database.prepare(`
UPDATE file_cleanup_queue SET relative_path = '../outside-fixture'
WHERE managed_file_id = ?
`).run(files.original.file_id);
const worker = new ProjectPurgeCleanup({ dataRoot: test.dataRoot, databasePath: test.databasePath });
const result = worker.processFileCleanup();
worker.close();
expect(result).toEqual({ completed: 1, failed: 1 });
expect(test.database.prepare("SELECT status FROM asset_cleanup_requests WHERE request_id = ?").get(intent.request_id)).toEqual({ status: "queued" });
expect(test.database.prepare("SELECT status, last_error FROM file_cleanup_queue WHERE managed_file_id = ?").get(files.original.file_id))
.toEqual({ last_error: "physical_file_cleanup_failed", status: "failed" });
const failure = test.database.prepare(`
SELECT operation_type, result, before_summary, after_summary
FROM admin_operation_logs WHERE target_ref = ? AND operation_type = 'asset_cleanup_physical_failed'
`).get(intent.request_id) as { after_summary: string; before_summary: null; operation_type: string; result: string };
expect(failure).toEqual({
after_summary: JSON.stringify({ failed_count: 1, status: "retry_pending" }),
before_summary: null,
operation_type: "asset_cleanup_physical_failed",
result: "failed",
});
expect(JSON.stringify(failure)).not.toMatch(/outside-fixture|relative_path|absolute_path|image|prompt|secret/i);
});
});
@@ -0,0 +1,90 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
import { wp604OperationMatrix } from "../fixtures/wp6-04-audit.js";
const roots: string[] = [];
const services: RegistrationService[] = [];
const now = Date.parse("2026-08-04T09:30:00.000Z");
function fixture() {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-04-integration-"));
roots.push(root);
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0xb1),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0xb2),
inviteCodeGenerator: () => "fixture-invite-code",
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0xb3),
});
services.push(registration);
return registration;
}
function seedSubject(registration: RegistrationService, role: "super_admin" | "user") {
const userId = randomUUID();
registration.database.prepare(`
INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit,
registration_id, created_at
) VALUES (?, ?, ?, 'active', ?, ?, ?)
`).run(userId, `${role}-${userId}@example.invalid`, role, role === "user" ? 1 : 0, randomUUID(), now);
if (role === "super_admin") {
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
}
return userId;
}
afterEach(() => {
for (const service of services.splice(0)) service.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP6-AUD-001-sensitive-operations", () => {
it("writes invite and user status audits in the same transaction as the mutation", () => {
const registration = fixture();
const adminId = seedSubject(registration, "super_admin");
const userId = seedSubject(registration, "user");
const adminOperations = registration as RegistrationService & {
createAdminInvite(input: { actorId: string; expiresAt: number; maxUses: number }): { code: string; inviteId: string };
changeUserStatus(userId: string, status: "suspended" | "deleted", actorId: string): void;
};
const invite = adminOperations.createAdminInvite({ actorId: adminId, expiresAt: now + 86_400_000, maxUses: 1 });
adminOperations.changeUserStatus(userId, "suspended", adminId);
expect(registration.database.prepare(`
SELECT operation_type, target_ref FROM admin_operation_logs
WHERE operation_type IN ('invite_create', 'user_status_change') ORDER BY occurred_at
`).all()).toEqual([
{ operation_type: "invite_create", target_ref: invite.inviteId },
{ operation_type: "user_status_change", target_ref: userId },
]);
registration.database.exec(`
CREATE TRIGGER force_user_status_audit_failure BEFORE INSERT ON admin_operation_logs
WHEN NEW.operation_type = 'user_status_change'
BEGIN SELECT RAISE(ABORT, 'forced_audit_failure'); END;
`);
expect(() => adminOperations.changeUserStatus(userId, "deleted", adminId)).toThrow("forced_audit_failure");
expect(registration.database.prepare("SELECT status FROM users WHERE user_id = ?").get(userId)).toEqual({ status: "suspended" });
const evidenceRoot = process.env.DADA_EVIDENCE_DIR_WP6_AUD;
if (evidenceRoot) {
mkdirSync(evidenceRoot, { recursive: true });
writeFileSync(resolve(evidenceRoot, "operation-matrix.json"), `${JSON.stringify({ operations: wp604OperationMatrix }, null, 2)}\n`);
writeFileSync(resolve(evidenceRoot, "db-diff.json"), `${JSON.stringify({
audit_failure_rollback: { user_status: "suspended" },
tables: { admin_operation_logs: "append_only", private_content_access_logs: "append_only_separate" },
}, null, 2)}\n`);
}
});
});