feat: implement sensitive operation audit retention (TASK-WP6-04)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -9,10 +9,13 @@ import {
|
|||||||
AccountProfileUpdateRequestSchema,
|
AccountProfileUpdateRequestSchema,
|
||||||
AccountProfileUpdateResponseSchema,
|
AccountProfileUpdateResponseSchema,
|
||||||
AccountSettingsResponseSchema,
|
AccountSettingsResponseSchema,
|
||||||
|
AdminAuditQuerySchema,
|
||||||
AdminAuthenticatedUserSchema,
|
AdminAuthenticatedUserSchema,
|
||||||
AdminGenerationRecordSchema,
|
AdminGenerationRecordSchema,
|
||||||
AdminGenerationListResponseSchema,
|
AdminGenerationListResponseSchema,
|
||||||
AdminOverviewResponseSchema,
|
AdminOverviewResponseSchema,
|
||||||
|
AdminOperationAuditItemSchema,
|
||||||
|
AdminOperationAuditResponseSchema,
|
||||||
AdminServicesResponseSchema,
|
AdminServicesResponseSchema,
|
||||||
AdminServiceHealthCheckRequestSchema,
|
AdminServiceHealthCheckRequestSchema,
|
||||||
AdminServiceLimitRequestSchema,
|
AdminServiceLimitRequestSchema,
|
||||||
@@ -78,6 +81,8 @@ import {
|
|||||||
PrivateContentNoticeAckRequestSchema,
|
PrivateContentNoticeAckRequestSchema,
|
||||||
PrivateContentNoticeAckResponseSchema,
|
PrivateContentNoticeAckResponseSchema,
|
||||||
PrivateContentPromptResponseSchema,
|
PrivateContentPromptResponseSchema,
|
||||||
|
PrivateContentAccessAuditItemSchema,
|
||||||
|
PrivateContentAccessAuditResponseSchema,
|
||||||
FailedEmptyTrashRequestSchema,
|
FailedEmptyTrashRequestSchema,
|
||||||
FailedEmptyTrashResponseSchema,
|
FailedEmptyTrashResponseSchema,
|
||||||
ExportFormatSchema,
|
ExportFormatSchema,
|
||||||
@@ -129,6 +134,7 @@ import {
|
|||||||
type AdminLoginCompleteRequest,
|
type AdminLoginCompleteRequest,
|
||||||
type AdminLoginSendRequest,
|
type AdminLoginSendRequest,
|
||||||
type AdminOverviewResponse,
|
type AdminOverviewResponse,
|
||||||
|
type AdminAuditQuery,
|
||||||
type AdminDiagnosticsResponse,
|
type AdminDiagnosticsResponse,
|
||||||
type AdminServicesStorageResponse,
|
type AdminServicesStorageResponse,
|
||||||
type AccountDeletionCompleteRequest,
|
type AccountDeletionCompleteRequest,
|
||||||
@@ -195,6 +201,11 @@ 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 { 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";
|
||||||
@@ -749,11 +760,16 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
AdminLoginCompleteRequestSchema,
|
AdminLoginCompleteRequestSchema,
|
||||||
AdminLoginCompleteResponseSchema,
|
AdminLoginCompleteResponseSchema,
|
||||||
AdminSessionResponseSchema,
|
AdminSessionResponseSchema,
|
||||||
|
AdminAuditQuerySchema,
|
||||||
|
AdminOperationAuditItemSchema,
|
||||||
|
AdminOperationAuditResponseSchema,
|
||||||
AdminGenerationRecordSchema,
|
AdminGenerationRecordSchema,
|
||||||
AdminGenerationListResponseSchema,
|
AdminGenerationListResponseSchema,
|
||||||
PrivateContentNoticeAckRequestSchema,
|
PrivateContentNoticeAckRequestSchema,
|
||||||
PrivateContentNoticeAckResponseSchema,
|
PrivateContentNoticeAckResponseSchema,
|
||||||
PrivateContentPromptResponseSchema,
|
PrivateContentPromptResponseSchema,
|
||||||
|
PrivateContentAccessAuditItemSchema,
|
||||||
|
PrivateContentAccessAuditResponseSchema,
|
||||||
PrivateContentGenerationParamsSchema,
|
PrivateContentGenerationParamsSchema,
|
||||||
AdminOverviewResponseSchema,
|
AdminOverviewResponseSchema,
|
||||||
AdminServicesResponseSchema,
|
AdminServicesResponseSchema,
|
||||||
@@ -1684,6 +1700,70 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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(
|
app.get(
|
||||||
"/api/v1/admin/overview",
|
"/api/v1/admin/overview",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -281,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);
|
||||||
@@ -1241,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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1772,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -342,9 +361,36 @@ export class StickerReleaseService {
|
|||||||
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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||||
|
|
||||||
import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOverviewResponse, AdminServicesResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, 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";
|
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; }
|
||||||
|
|
||||||
@@ -120,6 +120,13 @@ export async function getAdminDiagnostics(options: ClientOptions = {}): Promise<
|
|||||||
return response.json() as Promise<AdminDiagnosticsResponse>;
|
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> {
|
export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
|
||||||
@@ -208,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 ?? {} });
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -112,6 +117,26 @@ 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 = {
|
export type AdminOverviewResponse = {
|
||||||
"asset_cleanup": {
|
"asset_cleanup": {
|
||||||
"pending_jobs": number;
|
"pending_jobs": number;
|
||||||
@@ -681,6 +706,21 @@ 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 = {
|
export type PrivateContentGenerationParams = {
|
||||||
"generationId": string;
|
"generationId": string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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 { AdminGenerationsPage } from "./admin-generations.js";
|
||||||
import { AdminServicesStoragePage } from "./admin-services-storage.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";
|
||||||
@@ -43,7 +44,7 @@ function renderAuthenticationEntry() {
|
|||||||
const adminPages: Record<string, { content: ReactNode; title: string }> = {
|
const adminPages: Record<string, { content: ReactNode; title: string }> = {
|
||||||
"/admin": { content: <AdminOverviewPage />, title: "运营总览" },
|
"/admin": { content: <AdminOverviewPage />, title: "运营总览" },
|
||||||
"/admin/assets": { content: <AdminAssetsPage />, title: "素材" },
|
"/admin/assets": { content: <AdminAssetsPage />, title: "素材" },
|
||||||
"/admin/audit": { content: <AdminPlaceholderPage title="审计" />, title: "审计" },
|
"/admin/audit": { content: <AdminAuditPage />, title: "审计" },
|
||||||
"/admin/generations": { content: <AdminGenerationsPage />, title: "生成记录" },
|
"/admin/generations": { content: <AdminGenerationsPage />, title: "生成记录" },
|
||||||
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
|
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
|
||||||
"/admin/models": { content: <AdminModelsPage />, title: "模型" },
|
"/admin/models": { content: <AdminModelsPage />, title: "模型" },
|
||||||
|
|||||||
@@ -212,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,6 +305,19 @@ export class ProjectPurgeCleanup {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
private remeasureManagedCapacity() {
|
||||||
const state = this.database.prepare(`
|
const state = this.database.prepare(`
|
||||||
SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1
|
SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1
|
||||||
|
|||||||
@@ -260,6 +260,22 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"AdminAuditQuery": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"cursor": {
|
||||||
|
"maxLength": 512,
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AdminAuthenticatedUser": {
|
"AdminAuthenticatedUser": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -717,6 +733,143 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"AdminOperationAuditItem": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"actor_ref": {
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"actor_type": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"after_summary": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 2048,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"before_summary": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 2048,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"log_id": {
|
||||||
|
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"operation_type": {
|
||||||
|
"maxLength": 160,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"succeeded"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target_ref": {
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"target_type": {
|
||||||
|
"maxLength": 160,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"actor_ref",
|
||||||
|
"actor_type",
|
||||||
|
"after_summary",
|
||||||
|
"before_summary",
|
||||||
|
"expires_at",
|
||||||
|
"log_id",
|
||||||
|
"occurred_at",
|
||||||
|
"operation_type",
|
||||||
|
"result",
|
||||||
|
"target_ref",
|
||||||
|
"target_type"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AdminOperationAuditResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"generated_at": {
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AdminOperationAuditItem"
|
||||||
|
},
|
||||||
|
"maxItems": 100,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"next_cursor": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 512,
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"generated_at",
|
||||||
|
"items",
|
||||||
|
"next_cursor"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AdminOverviewResponse": {
|
"AdminOverviewResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -4471,6 +4624,90 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PrivateContentAccessAuditItem": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"actor_ref": {
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"content_type": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"prompt"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"log_id": {
|
||||||
|
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"occurred_at": {
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"target_ref": {
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"actor_ref",
|
||||||
|
"content_type",
|
||||||
|
"expires_at",
|
||||||
|
"log_id",
|
||||||
|
"occurred_at",
|
||||||
|
"target_ref"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"PrivateContentAccessAuditResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"generated_at": {
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PrivateContentAccessAuditItem"
|
||||||
|
},
|
||||||
|
"maxItems": 100,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"next_cursor": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"maxLength": 512,
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"generated_at",
|
||||||
|
"items",
|
||||||
|
"next_cursor"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PrivateContentGenerationParams": {
|
"PrivateContentGenerationParams": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6214,6 +6451,122 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/audit/operations": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAdminOperationAudit",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "cursor",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"maxLength": 512,
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "limit",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AdminOperationAuditResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Default Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Admin Operations"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/audit/private-content": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getPrivateContentAccessAudit",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "cursor",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"maxLength": 512,
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "limit",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"maximum": 100,
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PrivateContentAccessAuditResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Default Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Admin Operations"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/diagnostics": {
|
"/api/v1/admin/diagnostics": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAdminDiagnostics",
|
"operationId": "getAdminDiagnostics",
|
||||||
|
|||||||
+3
-1
@@ -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 tests/e2e/wp6-01-admin-shell.spec.ts tests/e2e/wp6-05-state.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",
|
||||||
@@ -99,6 +99,8 @@
|
|||||||
"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": "node scripts/run-wp6-01-validation.mjs --phase scaffold",
|
||||||
"test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red",
|
"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"
|
"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": {
|
||||||
|
|||||||
@@ -70,6 +70,67 @@ export type PrivateContentNoticeAckRequest = Static<typeof PrivateContentNoticeA
|
|||||||
export type PrivateContentNoticeAckResponse = Static<typeof PrivateContentNoticeAckResponseSchema>;
|
export type PrivateContentNoticeAckResponse = Static<typeof PrivateContentNoticeAckResponseSchema>;
|
||||||
export type PrivateContentPromptResponse = Static<typeof PrivateContentPromptResponseSchema>;
|
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(
|
export const AdminOverviewResponseSchema = Type.Object(
|
||||||
{
|
{
|
||||||
generated_at: Type.String({ pattern: isoTimestampPattern }),
|
generated_at: Type.String({ pattern: isoTimestampPattern }),
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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] : "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");
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
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 test:integration"],
|
||||||
|
["api", "pnpm.cmd test:api"],
|
||||||
|
["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);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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") });
|
||||||
|
});
|
||||||
Vendored
+52
@@ -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;
|
||||||
@@ -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() });
|
||||||
|
|||||||
@@ -136,4 +136,45 @@ describe("TDD-WP5-CLN-001 sticker history cleanup", () => {
|
|||||||
{ operation_type: "asset_cleanup_physical_completed", 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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user