From 2358f97e5c1b961872bd65edf0b2d0629282c1c2 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 28 Jul 2026 19:07:11 +0800 Subject: [PATCH] feat: implement TASK-WP1-05 account deletion --- apps/api/src/app.ts | 189 ++++++ apps/api/src/managed-storage.ts | 11 +- apps/api/src/registration-errors.ts | 2 + apps/api/src/registration.ts | 318 +++++++++- apps/api/src/resend-adapter.ts | 2 +- apps/web/src/account-settings.css | 364 +++++++++++ apps/web/src/account-settings.tsx | 273 ++++++++ apps/web/src/generated/api/sdk.gen.ts | 34 +- apps/web/src/generated/api/types.gen.ts | 54 ++ apps/web/src/main.tsx | 9 +- apps/worker/src/retention-cleanup.ts | 46 ++ apps/worker/src/worker.ts | 20 +- openapi/openapi.json | 584 ++++++++++++++++++ package.json | 6 +- packages/shared-contracts/src/auth.ts | 84 +++ scripts/run-wp1-05-validation.mjs | 106 ++++ tests/api/wp1-05-account-deletion.test.ts | 101 +++ tests/e2e/account-settings.spec.ts | 99 +++ .../wp1-05-account-deletion.test.ts | 204 ++++++ tests/worker/wp1-05-retention-cleanup.test.ts | 72 +++ 20 files changed, 2565 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/account-settings.css create mode 100644 apps/web/src/account-settings.tsx create mode 100644 apps/worker/src/retention-cleanup.ts create mode 100644 scripts/run-wp1-05-validation.mjs create mode 100644 tests/api/wp1-05-account-deletion.test.ts create mode 100644 tests/e2e/account-settings.spec.ts create mode 100644 tests/integration/wp1-05-account-deletion.test.ts create mode 100644 tests/worker/wp1-05-retention-cleanup.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6fdc62c..2ec522a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,6 +3,12 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { + AccountDeletionCompleteRequestSchema, + AccountDeletionResponseSchema, + AccountDeletionSendResponseSchema, + AccountProfileUpdateRequestSchema, + AccountProfileUpdateResponseSchema, + AccountSettingsResponseSchema, AdminAuthenticatedUserSchema, AdminLoginCompleteRequestSchema, AdminLoginCompleteResponseSchema, @@ -12,6 +18,7 @@ import { CorrelationIdSchema, AuthenticatedUserSchema, CreditSummarySchema, + CsrfHeadersSchema, ErrorDetailsSchema, ErrorEnvelopeSchema, GenerationErrorCategorySchema, @@ -37,6 +44,8 @@ import { type BootstrapResponse, type AdminLoginCompleteRequest, type AdminLoginSendRequest, + type AccountDeletionCompleteRequest, + type AccountProfileUpdateRequest, type LoginCompleteRequest, type LoginSendRequest, type RegistrationCompleteRequest, @@ -226,6 +235,13 @@ export async function createApp(options: CreateAppOptions = {}) { AdminLoginCompleteResponseSchema, AdminSessionResponseSchema, CreditSummarySchema, + CsrfHeadersSchema, + AccountSettingsResponseSchema, + AccountProfileUpdateRequestSchema, + AccountProfileUpdateResponseSchema, + AccountDeletionSendResponseSchema, + AccountDeletionCompleteRequestSchema, + AccountDeletionResponseSchema, RegistrationSendRequestSchema, RegistrationSendResponseSchema, RegistrationCompleteRequestSchema, @@ -731,6 +747,179 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.get( + "/api/v1/account/settings", + { + schema: { + operationId: "getAccountSettings", + response: { + 200: Type.Ref(AccountSettingsResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Account"], + }, + }, + async (request, reply) => { + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const settings = options.registration.readAccountSettings(token); + const csrfToken = options.registration.issueUserCsrfToken(token); + return { + account: settings.account, + csrf_token: csrfToken, + local_data: { + backup_enabled: settings.localData.backupEnabled, + capacity_status: settings.localData.capacityStatus, + hard_limit_bytes: settings.localData.hardLimitBytes, + location: settings.localData.location, + managed_content_bytes: settings.localData.managedContentBytes, + migration_supported: settings.localData.migrationSupported, + }, + profile: { + creator_name: settings.profile.creatorName, + social_id: settings.profile.socialId, + }, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.put( + "/api/v1/account/settings/profile", + { + attachValidation: true, + schema: { + body: Type.Ref(AccountProfileUpdateRequestSchema), + headers: Type.Ref(CsrfHeadersSchema), + operationId: "updateAccountProfile", + response: { + 200: Type.Ref(AccountProfileUpdateResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Account"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const body = request.body as AccountProfileUpdateRequest; + const saved = options.registration.updateAccountProfile({ + creatorName: body.creator_name, + csrfToken, + sessionToken: token, + socialId: body.social_id, + }); + return { profile: { creator_name: saved.creatorName, social_id: saved.socialId }, status: saved.status }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/account/deletion/send", + { + attachValidation: true, + schema: { + headers: Type.Ref(CsrfHeadersSchema), + operationId: "sendAccountDeletionCode", + response: { + 200: Type.Ref(AccountDeletionSendResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Account"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const sent = await options.registration.sendAccountDeletionCode({ csrfToken, sessionToken: token }); + return { + challenge_expires_at: new Date(sent.challengeExpiresAt).toISOString(), + deletion_id: sent.deletionId, + resend_available_at: new Date(sent.resendAvailableAt).toISOString(), + status: sent.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/account/deletion/complete", + { + attachValidation: true, + schema: { + body: Type.Ref(AccountDeletionCompleteRequestSchema), + headers: Type.Ref(LogoutHeadersSchema), + operationId: "completeAccountDeletion", + response: { + 200: Type.Ref(AccountDeletionResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Account"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!token || !csrfToken || !idempotencyKey) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + const body = request.body as AccountDeletionCompleteRequest; + const deleted = options.registration.completeAccountDeletion({ + code: body.verification_code, + confirmation: body.confirmation, + csrfToken, + deletionId: body.deletion_id, + idempotencyKey, + sessionToken: token, + }); + reply.header("Set-Cookie", `${userSessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`); + return deleted; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + app.post( "/api/v1/support/check", { diff --git a/apps/api/src/managed-storage.ts b/apps/api/src/managed-storage.ts index 8e45467..a74ebd3 100644 --- a/apps/api/src/managed-storage.ts +++ b/apps/api/src/managed-storage.ts @@ -184,6 +184,7 @@ export class ManagedStorage { CREATE TABLE IF NOT EXISTS managed_files ( file_id TEXT PRIMARY KEY, file_kind TEXT NOT NULL CHECK (file_kind IN ('reference', 'generated', 'export', 'derived', 'sticker_original', 'sticker_thumbnail')), + owner_ref TEXT, relative_path TEXT NOT NULL UNIQUE, byte_size INTEGER NOT NULL CHECK (byte_size > 0), mime_type TEXT NOT NULL, @@ -242,6 +243,10 @@ export class ManagedStorage { CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; `); + const managedFileColumns = this.database.prepare("PRAGMA table_info(managed_files)").all() as Array<{ name: string }>; + if (!managedFileColumns.some((column) => column.name === "owner_ref")) { + this.database.exec("ALTER TABLE managed_files ADD COLUMN owner_ref TEXT"); + } this.migrateLegacyAdminOperationLogs(); const initial = classifyCapacity(0, 0); this.database.prepare(` @@ -479,9 +484,9 @@ export class ManagedStorage { const commit = this.database.transaction(() => { this.database.prepare(` - INSERT INTO managed_files (file_id, file_kind, relative_path, byte_size, mime_type, sha256, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, 'committed', ?) - `).run(fileId, input.fileKind, destination.relativePath, byteSize, input.expectedMimeType, sha256, now()); + INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'committed', ?) + `).run(fileId, input.fileKind, input.ownerRef, destination.relativePath, byteSize, input.expectedMimeType, sha256, now()); this.database.prepare(` UPDATE local_backend_storage_state SET managed_content_bytes = managed_content_bytes + ? WHERE singleton = 1 `).run(byteSize); diff --git a/apps/api/src/registration-errors.ts b/apps/api/src/registration-errors.ts index 2b80917..e5a5d75 100644 --- a/apps/api/src/registration-errors.ts +++ b/apps/api/src/registration-errors.ts @@ -19,6 +19,7 @@ export type RegistrationErrorReason = | "resend_too_soon" | "too_many_attempts" | "csrf_invalid" + | "deletion_confirmation_invalid" | "session_invalid"; export type RegistrationErrorCode = @@ -73,6 +74,7 @@ export function registrationFieldError(reason: RegistrationErrorReason) { resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" }, too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" }, csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" }, + deletion_confirmation_invalid: { field: "confirmation", message_key: "account.deletion.confirmation_invalid" }, session_invalid: { field: "session", message_key: "auth.session.invalid" }, }; return entries[reason]; diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index 5504200..9a3e7b7 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -36,6 +36,9 @@ export interface RegistrationTransactionEvent { | "secure_config_apply" | "session_issue" | "session_revoke" + | "account_delete_send" + | "account_delete_complete" + | "profile_update" | "csrf_issue"; outcome: "committed" | "rejected" | "idempotent_replay"; } @@ -218,6 +221,7 @@ export class RegistrationService { readonly database: BetterSqlite3.Database; readonly options: Required> & RegistrationServiceOptions; private adminAllowlistHashes = new Set(); + private privacyPurgeActive = false; constructor(options: RegistrationServiceOptions) { assertSecret("invitePepper", options.invitePepper); @@ -235,6 +239,8 @@ export class RegistrationService { this.database.pragma("foreign_keys = ON"); this.database.pragma("synchronous = FULL"); this.database.pragma("busy_timeout = 5000"); + this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => this.privacyPurgeActive ? 1 : 0); + this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0); this.migrate(); } @@ -985,6 +991,213 @@ export class RegistrationService { }); } + readAccountSettings(sessionToken: string) { + const now = this.options.clock(); + const row = this.database.prepare(` + SELECT u.normalized_email, p.creator_name, p.social_id + FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN user_profiles p ON p.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active' + `).get(digest(sessionToken), now) as { creator_name: string; normalized_email: string; social_id: string } | undefined; + if (!row) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + const storageTable = this.database.prepare(` + SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'local_backend_storage_state' + `).get(); + const storage = storageTable + ? this.database.prepare(` + SELECT hard_limit_bytes, managed_content_bytes, storage_status + FROM local_backend_storage_state WHERE singleton = 1 + `).get() as { hard_limit_bytes: number; managed_content_bytes: number; storage_status: "active" | "full" | "unavailable" } | undefined + : undefined; + const capacityStatus = storage?.storage_status === "active" + ? (() => { + const bytes = storage.managed_content_bytes; + if (bytes >= 4_831_838_208) return "critical" as const; + if (bytes >= 4_294_967_296) return "warning" as const; + return "normal" as const; + })() + : (storage?.storage_status ?? "unavailable"); + return { + account: { email: row.normalized_email, status: "active" as const }, + localData: { + backupEnabled: false as const, + capacityStatus, + hardLimitBytes: storage?.hard_limit_bytes ?? 5_368_709_120, + location: "configured_local_data_root" as const, + managedContentBytes: storage?.managed_content_bytes ?? 0, + migrationSupported: false as const, + }, + profile: { creatorName: row.creator_name, socialId: row.social_id }, + }; + } + + updateAccountProfile(input: { creatorName: string; csrfToken: string; sessionToken: string; socialId: string }) { + const now = this.options.clock(); + const creatorName = normalizeProfileValue(input.creatorName, 80); + const socialId = normalizeSocialId(input.socialId); + if (!creatorName || !socialId) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); + return this.runImmediate("profile_update", () => { + const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, now); + this.database.prepare("UPDATE user_profiles SET creator_name = ?, social_id = ? WHERE user_id = ?") + .run(creatorName, socialId, session.user_id); + return { outcome: "committed", value: { creatorName, socialId, status: "saved" as const } }; + }); + } + + async sendAccountDeletionCode(input: { csrfToken: string; sessionToken: string }) { + const now = this.options.clock(); + const deletionId = randomUUID(); + const code = this.options.codeGenerator(); + if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); + const result = this.runImmediate("account_delete_send", () => { + const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, now); + const previous = this.database.prepare(` + SELECT resend_available_at FROM account_deletion_challenges + WHERE user_id = ? AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1 + `).get(session.user_id) as { resend_available_at: number } | undefined; + if (previous && previous.resend_available_at > now) { + throw new RegistrationError("AUTH_RATE_LIMITED", "resend_too_soon"); + } + this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id); + this.database.prepare(` + INSERT INTO account_deletion_challenges ( + deletion_id, user_id, email, code_hmac, expires_at, resend_available_at, + failure_count, consumed_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?) + `).run( + deletionId, + session.user_id, + session.normalized_email, + this.challengeHmac(deletionId, code), + now + challengeLifetimeMilliseconds, + now + resendDelayMilliseconds, + now, + ); + return { + outcome: "committed", + value: { + challengeExpiresAt: now + challengeLifetimeMilliseconds, + deletionId, + email: session.normalized_email, + resendAvailableAt: now + resendDelayMilliseconds, + status: "verification_sent" as const, + }, + }; + }); + try { + await this.options.resend.sendVerificationCode({ + challengeId: deletionId, + code, + email: result.email, + purpose: "account_delete", + }); + } catch { + this.runImmediate("registration_send_compensation", () => { + this.database.prepare("DELETE FROM account_deletion_challenges WHERE deletion_id = ? AND consumed_at IS NULL").run(deletionId); + return { outcome: "committed", value: undefined }; + }); + throw new Error("AUTH_SERVICE_UNAVAILABLE"); + } + return { + challengeExpiresAt: result.challengeExpiresAt, + deletionId: result.deletionId, + resendAvailableAt: result.resendAvailableAt, + status: result.status, + }; + } + + completeAccountDeletion(input: { + code: string; + confirmation: string; + csrfToken: string; + deletionId: string; + idempotencyKey: string; + sessionToken: string; + }) { + const now = this.options.clock(); + const idempotencyDigest = digest(input.idempotencyKey); + const requestHash = this.keyedHmac( + this.options.challengePepper, + JSON.stringify({ confirmation: input.confirmation, deletionId: input.deletionId, verificationCode: input.code }), + ); + const previous = this.database.prepare(` + SELECT request_hash FROM account_deletion_receipts WHERE idempotency_key_digest = ? + `).get(idempotencyDigest) as { request_hash: string } | undefined; + if (previous) { + if (!constantTimeTextEqual(previous.request_hash, requestHash)) { + throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); + } + return { status: "deleted" as const }; + } + const outcome = this.runImmediate<{ status: "deleted" } | RegistrationError>("account_delete_complete", () => { + const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, now); + if (input.confirmation !== "注销账号") { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "deletion_confirmation_invalid"); + } + const challenge = this.database.prepare(` + SELECT code_hmac, expires_at FROM account_deletion_challenges + WHERE deletion_id = ? AND user_id = ? AND consumed_at IS NULL + `).get(input.deletionId, session.user_id) as { code_hmac: string; expires_at: number } | undefined; + if (!challenge) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); + if (challenge.expires_at <= now) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_expired"); + if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(input.deletionId, input.code))) { + this.database.prepare("UPDATE account_deletion_challenges SET failure_count = failure_count + 1 WHERE deletion_id = ?") + .run(input.deletionId); + return { + outcome: "rejected", + value: new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"), + }; + } + + const anonymousSubjectId = randomUUID(); + const anonymousExpiresAt = now + 180 * 24 * 60 * 60 * 1_000; + const ledger = this.database.prepare(` + SELECT entry_type, amount, created_at FROM credit_ledger WHERE user_id = ? ORDER BY created_at, ledger_id + `).all(session.user_id) as Array<{ amount: number; created_at: number; entry_type: string }>; + const insertAnonymous = this.database.prepare(` + INSERT INTO anonymous_retained_events ( + event_id, anonymous_subject_id, event_type, model_id, outcome, + error_category, credit_delta, occurred_at, expires_at + ) VALUES (?, ?, ?, NULL, 'succeeded', NULL, ?, ?, ?) + `); + for (const entry of ledger) { + insertAnonymous.run(randomUUID(), anonymousSubjectId, entry.entry_type, entry.amount, entry.created_at, anonymousExpiresAt); + } + + this.privacyPurgeActive = true; + try { + this.database.prepare("DELETE FROM credit_ledger WHERE user_id = ?").run(session.user_id); + this.database.prepare(` + UPDATE private_content_access_logs SET subject_ref = ?, target_ref = ? WHERE subject_ref = ? + `).run(randomUUID(), randomUUID(), session.user_id); + } finally { + this.privacyPurgeActive = false; + } + + this.queueOwnedManagedFiles(session.user_id, now); + this.database.prepare("DELETE FROM registration_attempts WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM login_attempts WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM privacy_consents WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM credit_accounts WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM user_profiles WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM email_challenges WHERE email = ?").run(session.normalized_email); + this.database.prepare("DELETE FROM auth_rate_limits WHERE rate_key = ?") + .run(this.rateKey(session.normalized_email, "registration")); + this.database.prepare("DELETE FROM sessions WHERE user_id = ?").run(session.user_id); + this.database.prepare("DELETE FROM users WHERE user_id = ?").run(session.user_id); + this.database.prepare(` + INSERT INTO account_deletion_receipts (idempotency_key_digest, request_hash, deleted_at) + VALUES (?, ?, ?) + `).run(idempotencyDigest, requestHash, now); + return { outcome: "committed", value: { status: "deleted" as const } }; + }); + if (outcome instanceof RegistrationError) throw outcome; + return outcome; + } + changeUserStatus(userId: string, status: "suspended" | "deleted") { const now = this.options.clock(); this.runImmediate("session_revoke", () => { @@ -1103,8 +1316,11 @@ export class RegistrationService { ); CREATE TRIGGER IF NOT EXISTS credit_ledger_no_update BEFORE UPDATE ON credit_ledger BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END; - CREATE TRIGGER IF NOT EXISTS credit_ledger_no_delete - BEFORE DELETE ON credit_ledger BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END; + DROP TRIGGER IF EXISTS credit_ledger_no_delete; + CREATE TRIGGER credit_ledger_no_delete + BEFORE DELETE ON credit_ledger + WHEN dada_allow_privacy_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END; CREATE TABLE IF NOT EXISTS privacy_consents ( consent_id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id), @@ -1197,6 +1413,57 @@ export class RegistrationService { session_id TEXT REFERENCES sessions(session_id), created_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS account_deletion_challenges ( + deletion_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id), + email TEXT NOT NULL, + code_hmac TEXT NOT NULL, + expires_at INTEGER NOT NULL, + resend_available_at INTEGER NOT NULL, + failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0), + consumed_at INTEGER, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS account_deletion_receipts ( + idempotency_key_digest TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + deleted_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS private_content_access_logs ( + log_id TEXT PRIMARY KEY, + actor_ref TEXT NOT NULL, + subject_ref TEXT NOT NULL, + target_ref TEXT NOT NULL, + content_type TEXT NOT NULL CHECK (content_type IN ('image', 'prompt')), + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TRIGGER IF NOT EXISTS private_content_access_logs_no_update + BEFORE UPDATE ON private_content_access_logs + WHEN dada_allow_privacy_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS private_content_access_logs_no_delete + BEFORE DELETE ON private_content_access_logs + WHEN dada_allow_retention_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END; + CREATE TABLE IF NOT EXISTS anonymous_retained_events ( + event_id TEXT PRIMARY KEY, + anonymous_subject_id TEXT NOT NULL, + event_type TEXT NOT NULL, + model_id TEXT, + outcome TEXT NOT NULL, + error_category TEXT, + credit_delta INTEGER NOT NULL, + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TRIGGER IF NOT EXISTS anonymous_retained_events_no_update + BEFORE UPDATE ON anonymous_retained_events + BEGIN SELECT RAISE(ABORT, 'anonymous_retained_events_immutable'); END; + CREATE TRIGGER IF NOT EXISTS anonymous_retained_events_no_delete + BEFORE DELETE ON anonymous_retained_events + WHEN dada_allow_retention_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'anonymous_retained_events_immutable'); END; INSERT OR IGNORE INTO secure_config_apply_state ( singleton, applied_revision, allowlist_count, applied_at ) VALUES (1, 0, 0, 0); @@ -1273,6 +1540,53 @@ export class RegistrationService { } } + private authenticatedUserMutationSession(sessionToken: string, csrfToken: string, now: number) { + const session = this.database.prepare(` + SELECT s.session_id, s.user_id, s.csrf_token_digest, u.normalized_email + FROM sessions s JOIN users u ON u.user_id = s.user_id + WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active' + `).get(digest(sessionToken), now) as { + csrf_token_digest: string | null; + normalized_email: string; + session_id: string; + user_id: string; + } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, digest(csrfToken))) { + throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid"); + } + return session; + } + + private queueOwnedManagedFiles(userId: string, now: number) { + const table = this.database.prepare(` + SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'managed_files' + `).get(); + if (!table) return 0; + const columns = this.database.prepare("PRAGMA table_info(managed_files)").all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "owner_ref")) return 0; + const files = this.database.prepare(` + SELECT file_id, relative_path, byte_size FROM managed_files + WHERE owner_ref = ? AND status = 'committed' + AND file_kind IN ('reference', 'generated', 'export', 'derived') + `).all(userId) as Array<{ byte_size: number; file_id: string; relative_path: string }>; + const purgedAt = new Date(now).toISOString(); + for (const file of files) { + this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(file.file_id); + this.database.prepare(` + UPDATE managed_files SET status = 'purged', purged_at = ?, owner_ref = ? WHERE file_id = ? + `).run(purgedAt, randomUUID(), file.file_id); + this.database.prepare(` + INSERT OR IGNORE INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at + ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?) + `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, purgedAt); + } + return files.length; + } + private keyedHmac(key: Buffer, value: string) { return createHmac("sha256", key).update(value, "utf8").digest("hex"); } diff --git a/apps/api/src/resend-adapter.ts b/apps/api/src/resend-adapter.ts index 81ef9d7..5ee78db 100644 --- a/apps/api/src/resend-adapter.ts +++ b/apps/api/src/resend-adapter.ts @@ -2,7 +2,7 @@ export interface RegistrationCodeMessage { challengeId: string; code: string; email: string; - purpose: "register" | "login" | "admin_login"; + purpose: "register" | "login" | "admin_login" | "account_delete"; } export interface ResendAdapter { diff --git a/apps/web/src/account-settings.css b/apps/web/src/account-settings.css new file mode 100644 index 0000000..bcc4faf --- /dev/null +++ b/apps/web/src/account-settings.css @@ -0,0 +1,364 @@ +.settings-page, +.settings-loading { + min-height: 100vh; + color: #111111; + background: #f6f6f4; +} + +.settings-loading { + display: grid; + place-content: center; + gap: 16px; + text-align: center; +} + +.settings-loading button { + min-height: 44px; + border: 1px solid #111111; + background: #ffffff; +} + +.settings-header { + display: flex; + min-height: 72px; + align-items: center; + justify-content: space-between; + padding: 0 5vw; + border-bottom: 1px solid #c8c8c3; + background: #ffffff; +} + +.settings-brand { + color: #111111; + font-family: Arial Black, "Segoe UI", sans-serif; + font-size: 24px; + font-weight: 900; + text-decoration: none; +} + +.settings-header nav { + display: flex; + gap: 28px; +} + +.settings-header nav a, +.settings-header nav span { + color: #111111; + text-decoration: none; +} + +.settings-header nav span { + font-weight: 700; +} + +.settings-title { + padding: 64px max(5vw, calc((100vw - 1120px) / 2)) 38px; + border-bottom: 1px solid #c8c8c3; +} + +.settings-title p, +.settings-dialog header p { + margin: 0 0 12px; + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.settings-title h1 { + margin: 0; + font-size: 40px; + line-height: 1.2; +} + +.settings-section, +.settings-danger { + display: grid; + grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.4fr); + gap: 64px; + max-width: 1120px; + margin: 0 auto; + padding: 46px 0; + border-bottom: 1px solid #c8c8c3; +} + +.settings-section-heading h2, +.settings-danger h2 { + margin: 0 0 8px; + font-size: 21px; +} + +.settings-section-heading p, +.settings-danger p, +.settings-detail { + margin: 0; + color: #585852; + line-height: 1.65; +} + +.settings-form { + display: grid; + gap: 10px; + max-width: 560px; +} + +.settings-form input, +.settings-dialog input { + width: 100%; + min-height: 46px; + padding: 10px 12px; + border: 1px solid #989891; + border-radius: 2px; + background: #ffffff; +} + +.settings-form label, +.settings-dialog label { + margin-top: 10px; + font-size: 13px; + font-weight: 700; +} + +.settings-primary, +.settings-code-button, +.settings-delete-confirm, +.settings-danger-button { + min-height: 44px; + border-radius: 2px; + font-weight: 700; +} + +.settings-primary { + width: 160px; + margin-top: 12px; + border: 1px solid #111111; + color: #111111; + background: #f2f500; +} + +.settings-primary:disabled, +.settings-code-button:disabled, +.settings-delete-confirm:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.settings-definition { + margin: 0; +} + +.settings-definition div { + display: grid; + grid-template-columns: 140px 1fr; + gap: 18px; + padding: 15px 0; + border-bottom: 1px solid #deded9; +} + +.settings-definition dt { + color: #686862; +} + +.settings-definition dd { + margin: 0; + overflow-wrap: anywhere; + font-weight: 600; +} + +.settings-local { + align-items: start; +} + +.settings-local > :not(.settings-section-heading) { + grid-column: 2; +} + +.settings-capacity { + display: grid; + gap: 14px; +} + +.settings-capacity > div:first-child { + display: flex; + justify-content: space-between; + gap: 24px; +} + +.settings-capacity-track { + height: 12px; + overflow: hidden; + border: 1px solid #111111; + background: #ffffff; +} + +.settings-capacity-track span { + display: block; + height: 100%; + background: #111111; +} + +.settings-capacity[data-status="warning"] .settings-capacity-track span, +.settings-capacity[data-status="critical"] .settings-capacity-track span { + background: #e2b500; +} + +.settings-capacity[data-status="full"] .settings-capacity-track span, +.settings-capacity[data-status="unavailable"] .settings-capacity-track span { + background: #c7352b; +} + +.settings-fixed-notice { + margin: 24px 0 0; + padding: 20px; + border-left: 5px solid #111111; + background: #f2f500; + font-weight: 800; + line-height: 1.5; +} + +.settings-detail { + margin-top: 16px; +} + +.settings-danger { + align-items: center; + border-bottom: 0; + padding-bottom: 80px; +} + +.settings-danger-button { + width: 160px; + justify-self: start; + border: 1px solid #a6251d; + color: #ffffff; + background: #b82f26; +} + +.settings-error { + margin: 8px 0 0; + color: #a6251d; + font-weight: 700; +} + +.settings-saved { + margin: 8px 0 0; + color: #256227; + font-weight: 700; +} + +.settings-dialog-backdrop { + position: fixed; + z-index: 20; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgb(17 17 17 / 62%); +} + +.settings-dialog { + width: min(640px, 100%); + max-height: calc(100vh - 40px); + overflow-y: auto; + border: 1px solid #111111; + border-radius: 4px; + background: #ffffff; + box-shadow: 10px 10px 0 #111111; +} + +.settings-dialog header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 24px 28px; + border-bottom: 1px solid #c8c8c3; +} + +.settings-dialog h2 { + margin: 0; + font-size: 24px; +} + +.settings-dialog header button { + width: 40px; + height: 40px; + border: 0; + background: transparent; + font-size: 28px; +} + +.settings-dialog-body { + padding: 26px 28px 30px; + line-height: 1.6; +} + +.settings-dialog-warning { + padding: 16px; + border-left: 5px solid #b82f26; + background: #fff2f0; + font-weight: 700; +} + +.settings-code-button { + width: 180px; + margin: 10px 0; + border: 1px solid #111111; + background: #ffffff; +} + +.settings-dialog form { + display: grid; + gap: 8px; +} + +.settings-delete-confirm { + width: 180px; + margin-top: 16px; + border: 1px solid #8e2019; + color: #ffffff; + background: #b82f26; +} + +@media (max-width: 760px) { + .settings-header { + padding: 0 18px; + } + + .settings-header nav { + gap: 14px; + font-size: 13px; + } + + .settings-title { + padding: 40px 20px 28px; + } + + .settings-title h1 { + font-size: 30px; + } + + .settings-section, + .settings-danger { + grid-template-columns: 1fr; + gap: 24px; + margin: 0 20px; + padding: 34px 0; + } + + .settings-local > :not(.settings-section-heading) { + grid-column: 1; + } + + .settings-definition div { + grid-template-columns: 88px 1fr; + } + + .settings-capacity > div:first-child { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + .settings-dialog { + box-shadow: 5px 5px 0 #111111; + } +} diff --git a/apps/web/src/account-settings.tsx b/apps/web/src/account-settings.tsx new file mode 100644 index 0000000..916181f --- /dev/null +++ b/apps/web/src/account-settings.tsx @@ -0,0 +1,273 @@ +import { type FormEvent, useEffect, useId, useState } from "react"; + +import "./account-settings.css"; + +interface SettingsPayload { + account: { email: string; status: "active" }; + csrf_token: string; + local_data: { + backup_enabled: false; + capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable"; + hard_limit_bytes: number; + location: "configured_local_data_root"; + managed_content_bytes: number; + migration_supported: false; + }; + profile: { creator_name: string; social_id: string }; +} + +function formatBytes(bytes: number) { + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`; + if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + return `${Math.max(0, bytes)} B`; +} + +function capacityLabel(status: SettingsPayload["local_data"]["capacity_status"]) { + if (status === "full") return "本机容量已满"; + if (status === "unavailable") return "本机数据暂不可写"; + if (status === "critical") return "本机容量接近上限"; + if (status === "warning") return "本机容量需要关注"; + return "本机容量正常"; +} + +export function AccountSettingsPage() { + const creatorNameId = useId(); + const socialId = useId(); + const confirmationId = useId(); + const deletionCodeId = useId(); + const [settings, setSettings] = useState(); + const [creatorName, setCreatorName] = useState(""); + const [socialHandle, setSocialHandle] = useState(""); + const [loadingError, setLoadingError] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(false); + const [saved, setSaved] = useState(false); + const [deletionOpen, setDeletionOpen] = useState(false); + const [deletionId, setDeletionId] = useState(); + const [deletionCode, setDeletionCode] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [sendingCode, setSendingCode] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deletionError, setDeletionError] = useState(false); + + useEffect(() => { + let active = true; + void fetch("/api/v1/account/settings", { credentials: "same-origin" }) + .then(async (response) => { + if (!response.ok) throw new Error("settings_load_failed"); + return response.json() as Promise; + }) + .then((payload) => { + if (!active) return; + setSettings(payload); + setCreatorName(payload.profile.creator_name); + setSocialHandle(payload.profile.social_id); + }) + .catch(() => { + if (active) setLoadingError(true); + }); + return () => { active = false; }; + }, []); + + async function saveProfile(event: FormEvent) { + event.preventDefault(); + if (!settings || saving || !creatorName.trim() || !socialHandle.trim()) return; + setSaving(true); + setSaveError(false); + setSaved(false); + try { + const response = await fetch("/api/v1/account/settings/profile", { + body: JSON.stringify({ creator_name: creatorName, social_id: socialHandle }), + credentials: "same-origin", + headers: { "Content-Type": "application/json", "X-CSRF-Token": settings.csrf_token }, + method: "PUT", + }); + if (!response.ok) throw new Error("profile_save_failed"); + setSaved(true); + } catch { + setSaveError(true); + } finally { + setSaving(false); + } + } + + async function sendDeletionCode() { + if (!settings || sendingCode || deleting) return; + setSendingCode(true); + setDeletionError(false); + try { + const response = await fetch("/api/v1/account/deletion/send", { + credentials: "same-origin", + headers: { "X-CSRF-Token": settings.csrf_token }, + method: "POST", + }); + const body = await response.json() as { deletion_id?: string }; + if (!response.ok || !body.deletion_id) throw new Error("deletion_code_failed"); + setDeletionId(body.deletion_id); + } catch { + setDeletionError(true); + } finally { + setSendingCode(false); + } + } + + async function completeDeletion(event: FormEvent) { + event.preventDefault(); + if (!settings || !deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting) return; + setDeleting(true); + setDeletionError(false); + try { + const response = await fetch("/api/v1/account/deletion/complete", { + body: JSON.stringify({ confirmation, deletion_id: deletionId, verification_code: deletionCode }), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""), + "X-CSRF-Token": settings.csrf_token, + }, + method: "POST", + }); + if (!response.ok) throw new Error("account_deletion_failed"); + window.location.assign("/"); + } catch { + setDeletionError(true); + setDeleting(false); + } + } + + function closeDeletion() { + if (deleting) return; + setDeletionOpen(false); + setDeletionId(undefined); + setDeletionCode(""); + setConfirmation(""); + setDeletionError(false); + } + + if (!settings && !loadingError) { + return
正在读取设置
; + } + + if (!settings) { + return ( +
+

设置暂时无法读取。

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

ACCOUNT / LOCAL DATA

+

设置与本机数据

+
+ +
+
+

个人资料

+

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

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

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

: null} + {saved ?

资料已保存。

: null} + +
+
+ +
+
+

账号

+

邮箱仅用于验证码认证。

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

本机数据

+

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

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

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

+

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

+
+ +
+
+

注销账号

+

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

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

DANGER ZONE

+

确认注销账号

+
+ +
+
+

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

+

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

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

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

: null} + +
+
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 8e06543..f4a9135 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AdminSessionResponse, UserSessionResponse, LogoutResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js"; +import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, UserSessionResponse, LogoutResponse, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: { }>; } +export async function completeAccountDeletion(body: AccountDeletionCompleteRequest, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/complete`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -72,6 +81,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op return response.json() as Promise; } +export async function getAccountSettings(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function getAdminSession(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} }); @@ -136,6 +152,13 @@ export async function logoutUser(options: ClientOptions = {}): Promise; } +export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function sendAdminLoginCode(body: AdminLoginSendRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -162,3 +185,12 @@ export async function sendRegistrationCode(body: RegistrationSendRequest, option if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json() as Promise; } + +export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings/profile`, { body: JSON.stringify(body), method: "PUT", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 8727fc0..3904aba 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -1,5 +1,55 @@ // Generated from openapi/openapi.json. Do not edit by hand. +export type AccountDeletionCompleteRequest = { + "confirmation": "注销账号"; + "deletion_id": string; + "verification_code": string; +}; + +export type AccountDeletionResponse = { + "status": "deleted"; +}; + +export type AccountDeletionSendResponse = { + "challenge_expires_at": string; + "deletion_id": string; + "resend_available_at": string; + "status": "verification_sent"; +}; + +export type AccountProfileUpdateRequest = { + "creator_name": string; + "social_id": string; +}; + +export type AccountProfileUpdateResponse = { + "profile": { + "creator_name": string; + "social_id": string; +}; + "status": "saved"; +}; + +export type AccountSettingsResponse = { + "account": { + "email": string; + "status": "active"; +}; + "csrf_token": string; + "local_data": { + "backup_enabled": false; + "capacity_status": "normal" | "warning" | "critical" | "full" | "unavailable"; + "hard_limit_bytes": number; + "location": "configured_local_data_root"; + "managed_content_bytes": number; + "migration_supported": false; +}; + "profile": { + "creator_name": string; + "social_id": string; +}; +}; + export type AdminAuthenticatedUser = { "role": "super_admin"; "status": "active"; @@ -93,6 +143,10 @@ export type CreditSummary = { "reserved_balance": number; }; +export type CsrfHeaders = { + "x-csrf-token": string; +}; + export type ErrorDetails = { "capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable"; "current_task_ref"?: string; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 18c176c..4c48dab 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client"; import { registerPublicAssetServiceWorker } from "./public-asset-cache.js"; import { AdminAuthPage } from "./admin-auth.js"; import { UserAuthPage } from "./user-auth.js"; +import { AccountSettingsPage } from "./account-settings.js"; const root = document.getElementById("root"); @@ -19,9 +20,11 @@ let authRevision = 0; function renderAuthenticationEntry() { authRevision += 1; - const authenticationPage = window.location.pathname.startsWith("/admin") - ? - : ; + const authenticationPage = window.location.pathname === "/app/settings" + ? + : window.location.pathname.startsWith("/admin") + ? + : ; appRoot.render( {authenticationPage} diff --git a/apps/worker/src/retention-cleanup.ts b/apps/worker/src/retention-cleanup.ts new file mode 100644 index 0000000..795d50c --- /dev/null +++ b/apps/worker/src/retention-cleanup.ts @@ -0,0 +1,46 @@ +import { createRequire } from "node:module"; + +import type BetterSqlite3 from "better-sqlite3"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; + +export class RetentionCleanup { + private readonly clock: () => number; + private readonly database: BetterSqlite3.Database; + private retentionPurgeActive = false; + + constructor(input: { clock?: () => number; databasePath: string }) { + this.clock = input.clock ?? Date.now; + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined); + this.database.pragma("foreign_keys = ON"); + this.database.pragma("busy_timeout = 5000"); + this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0); + this.database.function("dada_allow_retention_purge", { deterministic: false }, () => this.retentionPurgeActive ? 1 : 0); + } + + purgeExpired() { + const now = this.clock(); + this.database.exec("BEGIN IMMEDIATE"); + try { + this.retentionPurgeActive = true; + const privateAccess = this.database.prepare("DELETE FROM private_content_access_logs WHERE expires_at <= ?").run(now); + const anonymous = this.database.prepare("DELETE FROM anonymous_retained_events WHERE expires_at <= ?").run(now); + this.retentionPurgeActive = false; + this.database.exec("COMMIT"); + return { + anonymous_events: anonymous.changes, + private_access_logs: privateAccess.changes, + }; + } catch (error) { + this.retentionPurgeActive = false; + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + throw error; + } + } + + close() { + this.database.close(); + } +} diff --git a/apps/worker/src/worker.ts b/apps/worker/src/worker.ts index 5144815..dc5d3cd 100644 --- a/apps/worker/src/worker.ts +++ b/apps/worker/src/worker.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { WorkerAiCallGate } from "./ai-call-gate.js"; import { readConfiguredLocalDataRoot } from "./runtime-config.js"; +import { RetentionCleanup } from "./retention-cleanup.js"; import { StructuredJsonlLogger } from "./structured-log.js"; import { WorkerStorageStatus } from "./storage-status.js"; import { attachWorkerSupervisorControl, initializeWorkerCredentialClient, receiveWorkerCredentials } from "./supervisor-channel.js"; @@ -26,14 +27,31 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) { if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required."); const keepAlive = setInterval(() => undefined, 30_000); let storage: WorkerStorageStatus | undefined; + let retention: RetentionCleanup | undefined; + let retentionTimer: ReturnType | undefined; const control = attachWorkerSupervisorControl(controlPipe, () => { clearInterval(keepAlive); + if (retentionTimer) clearInterval(retentionTimer); + retention?.close(); storage?.close(); }); let storageStatus: "active" | "unavailable" = "active"; try { const dataRoot = readConfiguredLocalDataRoot(); - storage = new WorkerStorageStatus(join(dataRoot, "db", "dada.sqlite3")); + const databasePath = join(dataRoot, "db", "dada.sqlite3"); + storage = new WorkerStorageStatus(databasePath); + retention = new RetentionCleanup({ databasePath }); + const runRetentionCleanup = () => { + try { + retention?.purgeExpired(); + } catch { + storageStatus = "unavailable"; + storage?.markLogUnavailable(); + control.reportStatus("storage_unavailable"); + } + }; + runRetentionCleanup(); + retentionTimer = setInterval(runRetentionCleanup, 24 * 60 * 60 * 1_000); const logger = new StructuredJsonlLogger({ component: "worker", directory: join(dataRoot, "logs", "worker"), diff --git a/openapi/openapi.json b/openapi/openapi.json index 7ee7bd9..14cdc96 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -1,6 +1,265 @@ { "components": { "schemas": { + "AccountDeletionCompleteRequest": { + "additionalProperties": false, + "properties": { + "confirmation": { + "enum": [ + "注销账号" + ], + "type": "string" + }, + "deletion_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" + }, + "verification_code": { + "pattern": "^[0-9]{6}$", + "type": "string" + } + }, + "required": [ + "confirmation", + "deletion_id", + "verification_code" + ], + "type": "object" + }, + "AccountDeletionResponse": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "deleted" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AccountDeletionSendResponse": { + "additionalProperties": false, + "properties": { + "challenge_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" + }, + "deletion_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" + }, + "resend_available_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" + }, + "status": { + "enum": [ + "verification_sent" + ], + "type": "string" + } + }, + "required": [ + "challenge_expires_at", + "deletion_id", + "resend_available_at", + "status" + ], + "type": "object" + }, + "AccountProfileUpdateRequest": { + "additionalProperties": false, + "properties": { + "creator_name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "social_id": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "creator_name", + "social_id" + ], + "type": "object" + }, + "AccountProfileUpdateResponse": { + "additionalProperties": false, + "properties": { + "profile": { + "additionalProperties": false, + "properties": { + "creator_name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "social_id": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "creator_name", + "social_id" + ], + "type": "object" + }, + "status": { + "enum": [ + "saved" + ], + "type": "string" + } + }, + "required": [ + "profile", + "status" + ], + "type": "object" + }, + "AccountSettingsResponse": { + "additionalProperties": false, + "properties": { + "account": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 320, + "pattern": "^[^@\\s]{1,128}@[^@\\s]{1,190}$", + "type": "string" + }, + "status": { + "enum": [ + "active" + ], + "type": "string" + } + }, + "required": [ + "email", + "status" + ], + "type": "object" + }, + "csrf_token": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "local_data": { + "additionalProperties": false, + "properties": { + "backup_enabled": { + "enum": [ + false + ], + "type": "boolean" + }, + "capacity_status": { + "anyOf": [ + { + "enum": [ + "normal" + ], + "type": "string" + }, + { + "enum": [ + "warning" + ], + "type": "string" + }, + { + "enum": [ + "critical" + ], + "type": "string" + }, + { + "enum": [ + "full" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + }, + "hard_limit_bytes": { + "minimum": 1, + "type": "integer" + }, + "location": { + "enum": [ + "configured_local_data_root" + ], + "type": "string" + }, + "managed_content_bytes": { + "minimum": 0, + "type": "integer" + }, + "migration_supported": { + "enum": [ + false + ], + "type": "boolean" + } + }, + "required": [ + "backup_enabled", + "capacity_status", + "hard_limit_bytes", + "location", + "managed_content_bytes", + "migration_supported" + ], + "type": "object" + }, + "profile": { + "additionalProperties": false, + "properties": { + "creator_name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "social_id": { + "maxLength": 80, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "creator_name", + "social_id" + ], + "type": "object" + } + }, + "required": [ + "account", + "csrf_token", + "local_data", + "profile" + ], + "type": "object" + }, "AdminAuthenticatedUser": { "additionalProperties": false, "properties": { @@ -549,6 +808,21 @@ ], "type": "object" }, + "CsrfHeaders": { + "additionalProperties": true, + "properties": { + "x-csrf-token": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + "required": [ + "x-csrf-token" + ], + "type": "object" + }, "ErrorDetails": { "additionalProperties": false, "properties": { @@ -1718,6 +1992,316 @@ }, "openapi": "3.1.0", "paths": { + "/api/v1/account/deletion/complete": { + "post": { + "operationId": "completeAccountDeletion", + "parameters": [ + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountDeletionCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountDeletionResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Account" + ] + } + }, + "/api/v1/account/deletion/send": { + "post": { + "operationId": "sendAccountDeletionCode", + "parameters": [ + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountDeletionSendResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Account" + ] + } + }, + "/api/v1/account/settings": { + "get": { + "operationId": "getAccountSettings", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountSettingsResponse" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Account" + ] + } + }, + "/api/v1/account/settings/profile": { + "put": { + "operationId": "updateAccountProfile", + "parameters": [ + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountProfileUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountProfileUpdateResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Account" + ] + } + }, "/api/v1/admin-auth/login/complete": { "post": { "operationId": "completeAdminLogin", diff --git a/package.json b/package.json index 11335e7..7452ae4 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "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: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 --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 --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -47,7 +47,9 @@ "test:wp1-03": "node scripts/run-wp1-03-validation.mjs", "test:wp1-03:red": "node scripts/run-wp1-03-validation.mjs --phase red", "test:wp1-04": "node scripts/run-wp1-04-validation.mjs", - "test:wp1-04:red": "node scripts/run-wp1-04-validation.mjs --phase red" + "test:wp1-04:red": "node scripts/run-wp1-04-validation.mjs --phase red", + "test:wp1-05": "node scripts/run-wp1-05-validation.mjs", + "test:wp1-05:red": "node scripts/run-wp1-05-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/auth.ts b/packages/shared-contracts/src/auth.ts index 7ea25e1..4bfb1b6 100644 --- a/packages/shared-contracts/src/auth.ts +++ b/packages/shared-contracts/src/auth.ts @@ -169,6 +169,88 @@ export const LogoutResponseSchema = Type.Object( { additionalProperties: false, $id: "LogoutResponse" }, ); +export const CsrfHeadersSchema = Type.Object( + { + "x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }), + }, + { additionalProperties: true, $id: "CsrfHeaders" }, +); + +export const AccountSettingsResponseSchema = Type.Object( + { + account: Type.Object( + { + email: Type.String({ maxLength: 320, pattern: emailPattern }), + status: Type.Literal("active"), + }, + { additionalProperties: false }, + ), + csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }), + local_data: Type.Object( + { + backup_enabled: Type.Literal(false), + capacity_status: Type.Union([ + Type.Literal("normal"), Type.Literal("warning"), Type.Literal("critical"), + Type.Literal("full"), Type.Literal("unavailable"), + ]), + hard_limit_bytes: Type.Integer({ minimum: 1 }), + location: Type.Literal("configured_local_data_root"), + managed_content_bytes: Type.Integer({ minimum: 0 }), + migration_supported: Type.Literal(false), + }, + { additionalProperties: false }, + ), + profile: Type.Object( + { + creator_name: Type.String({ maxLength: 80, minLength: 1 }), + social_id: Type.String({ maxLength: 80, minLength: 1 }), + }, + { additionalProperties: false }, + ), + }, + { additionalProperties: false, $id: "AccountSettingsResponse" }, +); + +export const AccountProfileUpdateRequestSchema = Type.Object( + { + creator_name: Type.String({ maxLength: 80, minLength: 1 }), + social_id: Type.String({ maxLength: 80, minLength: 1 }), + }, + { additionalProperties: false, $id: "AccountProfileUpdateRequest" }, +); + +export const AccountProfileUpdateResponseSchema = Type.Object( + { + profile: AccountSettingsResponseSchema.properties.profile, + status: Type.Literal("saved"), + }, + { additionalProperties: false, $id: "AccountProfileUpdateResponse" }, +); + +export const AccountDeletionSendResponseSchema = Type.Object( + { + challenge_expires_at: Type.String({ pattern: isoTimestampPattern }), + deletion_id: Type.String({ pattern: uuidPattern }), + resend_available_at: Type.String({ pattern: isoTimestampPattern }), + status: Type.Literal("verification_sent"), + }, + { additionalProperties: false, $id: "AccountDeletionSendResponse" }, +); + +export const AccountDeletionCompleteRequestSchema = Type.Object( + { + confirmation: Type.Literal("注销账号"), + deletion_id: Type.String({ pattern: uuidPattern }), + verification_code: Type.String({ pattern: "^[0-9]{6}$" }), + }, + { additionalProperties: false, $id: "AccountDeletionCompleteRequest" }, +); + +export const AccountDeletionResponseSchema = Type.Object( + { status: Type.Literal("deleted") }, + { additionalProperties: false, $id: "AccountDeletionResponse" }, +); + export type RegistrationSendRequest = Static; export type RegistrationSendResponse = Static; export type RegistrationCompleteRequest = Static; @@ -178,3 +260,5 @@ export type LoginSendRequest = Static; export type LoginCompleteRequest = Static; export type AdminLoginSendRequest = Static; export type AdminLoginCompleteRequest = Static; +export type AccountProfileUpdateRequest = Static; +export type AccountDeletionCompleteRequest = Static; diff --git a/scripts/run-wp1-05-validation.mjs b/scripts/run-wp1-05-validation.mjs new file mode 100644 index 0000000..7f68dd5 --- /dev/null +++ b/scripts/run-wp1-05-validation.mjs @@ -0,0 +1,106 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, 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 (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp1-05-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const deleteCase = "TDD-WP1-DEL-001-account-delete"; +const retentionCase = "TDD-WP1-DEL-002-anonymous-retention"; +const directories = { + [deleteCase]: resolve(runDirectory, "cases", deleteCase), + [retentionCase]: resolve(runDirectory, "cases", retentionCase), +}; +const playwrightDirectory = resolve(runDirectory, "playwright"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const directory of Object.values(directories)) mkdirSync(directory, { recursive: true }); + +const commandsToRun = phase === "red" + ? [ + ["delete-integration", ["exec", "vitest", "run", "tests/integration/wp1-05-account-deletion.test.ts"]], + ["retention-worker", ["exec", "vitest", "run", "tests/worker/wp1-05-retention-cleanup.test.ts"]], + ["delete-api", ["exec", "vitest", "run", "tests/api/wp1-05-account-deletion.test.ts"]], + ["settings-e2e", ["exec", "playwright", "test", "tests/e2e/account-settings.spec.ts", "--config", "playwright.config.ts"]], + ] + : [ + ["integration", ["test:integration"]], + ["api", ["test:api"]], + ["worker", ["test:worker"]], + ["e2e", ["test:e2e"]], + ["security", ["test:security"]], + ["package", ["test:package"]], + ["tdd-trace", ["validate:tdd-trace"]], + ]; +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_DELETE: directories[deleteCase], + DADA_EVIDENCE_DIR_RETENTION: directories[retentionCase], + DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory, +}; +const commandResults = []; +for (const [name, args] of commandsToRun) { + const command = `pnpm ${args.join(" ")}`; + const started_at = new Date().toISOString(); + const execution = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commandResults.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); +} + +function find(root, name) { + if (!existsSync(root)) return []; + return readdirSync(root).flatMap((entry) => { + const child = resolve(root, entry); + return statSync(child).isDirectory() ? find(child, name) : entry === name ? [child] : []; + }); +} +if (phase === "green") { + const trace = find(playwrightDirectory, "trace.zip").find((path) => path.replaceAll("\\", "/").includes("account-settings")); + if (trace) copyFileSync(trace, resolve(directories[deleteCase], "trace.zip")); +} +for (const directory of Object.values(directories)) { + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +} +const expectedEvidence = phase === "red" + ? { [deleteCase]: ["red-observation.json"], [retentionCase]: ["red-observation.json"] } + : { + [deleteCase]: ["response.json", "db-diff.json", "worker-events.json", "trace.zip"], + [retentionCase]: ["db-diff.json", "retention-events.json", "redaction.json"], + }; +const commandState = phase === "red" + ? commandResults.every((result) => result.exit_code !== 0) + : commandResults.every((result) => result.exit_code === 0); +if (phase === "red") { + for (const [testId, directory] of Object.entries(directories)) { + writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify({ + expected_failure: testId === deleteCase ? "account deletion transaction and settings flow are absent" : "anonymous retention cleanup is absent", + status: commandState ? "red_confirmed" : "failed", + }, null, 2)}\n`); + } +} +const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const results = Object.entries(directories).map(([testId, directory]) => { + const evidence_refs = expectedEvidence[testId]; + const missing_evidence = evidence_refs.filter((file) => !existsSync(resolve(directory, file))); + const status = commandState && missing_evidence.length === 0 ? (phase === "red" ? "red_confirmed" : "passed") : "failed"; + const result = { + acceptance_criteria: ["AC-22", "AC-39", "AC-56"], automation: ["automated"], commit, + evidence_refs, layer: testId === deleteCase ? ["DB", "API", "WRK", "E2E"] : ["DB", "WRK"], + manifest, missing_evidence, phase, requirements: ["AUTH-06"], run_id: runId, status, + task_id: "TASK-WP1-05", test_id: testId, work_package: "WP-1", + worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation", + }; + writeFileSync(resolve(directory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); + return result; +}); +const targetStatus = phase === "red" ? "red_confirmed" : "passed"; +const passed = results.every((result) => result.status === targetStatus); +const summary = { cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })), phase, run_id: runId, status: passed ? targetStatus : "failed" }; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (!passed) process.exit(1); diff --git a/tests/api/wp1-05-account-deletion.test.ts b/tests/api/wp1-05-account-deletion.test.ts new file mode 100644 index 0000000..cb1089d --- /dev/null +++ b/tests/api/wp1-05-account-deletion.test.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { ManagedStorage } from "../../apps/api/src/managed-storage.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; +import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js"; + +const now = Date.parse("2026-07-28T12:00:00.000Z"); +const roots: string[] = []; +const services: RegistrationService[] = []; +const storages: ManagedStorage[] = []; +const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; + +async function harness() { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-05-api-")); + roots.push(root); + const databasePath = join(root, "db", "dada.sqlite3"); + const storage = new ManagedStorage({ dataRoot: root, databasePath }); + storages.push(storage); + const resend = new MockResendAdapter(); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x71), clock: () => now, codeGenerator: () => "539126", + currentPrivacyNoticeVersion: registrationNotice.version, databasePath, + inviteCodeGenerator: () => "DADA-WP1-05-API", invitePepper: Buffer.alloc(32, 0x72), resend, + sessionPepper: Buffer.alloc(32, 0x73), + }); + services.push(registration); + const email = "account-api@example.invalid"; + const invite = registration.createInvite({ expiresAt: now + 86_400_000, maxUses: 1 }); + const sent = await registration.sendRegistrationCode({ email, inviteCode: invite.code }); + const completed = registration.completeRegistration({ + code: resend.readLatestCode(email), creatorName: "Account API", idempotencyKey: `register-${randomUUID()}-${randomUUID()}`, + privacyConsentAccepted: true, privacyNoticeVersion: registrationNotice.version, + registrationId: sent.registrationId, socialId: "@account_api", + }); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + return { app, completed, email, registration, resend }; +} + +afterEach(() => { + for (const service of services.splice(0)) service.close(); + for (const storage of storages.splice(0)) storage.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TDD-WP1-DEL-001 account settings and deletion API", () => { + it("serves safe settings and deletes only after CSRF plus a fresh email code", async () => { + const { app, completed, email, registration, resend } = await harness(); + const cookie = `dada_session=${completed.sessionToken}`; + const session = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/auth/session" }); + const settings = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/account/settings" }); + expect(settings.statusCode).toBe(200); + expect(settings.json()).toMatchObject({ + account: { email, status: "active" }, + local_data: { backup_enabled: false, migration_supported: false, location: "configured_local_data_root" }, + profile: { creator_name: "Account API", social_id: "@account_api" }, + }); + expect(JSON.stringify(settings.json())).not.toMatch(/[A-Z]:\\/i); + const csrf = settings.json().csrf_token; + + const deletionSend = await app.inject({ + headers: { ...headers, cookie, "x-csrf-token": csrf }, method: "POST", url: "/api/v1/account/deletion/send", + }); + expect(deletionSend.statusCode).toBe(200); + const code = resend.readLatestCode(email); + const rejected = await app.inject({ + headers: { + ...headers, cookie, "idempotency-key": `reject-${randomUUID()}-${randomUUID()}`, "x-csrf-token": csrf, + }, + method: "POST", + payload: { confirmation: "注销账号", deletion_id: deletionSend.json().deletion_id, verification_code: "000000" }, + url: "/api/v1/account/deletion/complete", + }); + expect(rejected.statusCode).toBe(400); + expect(registration.database.prepare("SELECT failure_count FROM account_deletion_challenges").get().failure_count).toBe(1); + expect(registration.readUserSession(completed.sessionToken)).toBeDefined(); + const deletion = await app.inject({ + headers: { + ...headers, cookie, "idempotency-key": `delete-${randomUUID()}-${randomUUID()}`, "x-csrf-token": csrf, + }, + method: "POST", + payload: { confirmation: "注销账号", deletion_id: deletionSend.json().deletion_id, verification_code: code }, + url: "/api/v1/account/deletion/complete", + }); + expect(deletion.statusCode).toBe(200); + expect(deletion.json()).toEqual({ status: "deleted" }); + expect(deletion.headers["set-cookie"]).toContain("Max-Age=0"); + expect(JSON.stringify(deletion.json())).not.toContain(email); + + const oldSession = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/auth/session" }); + expect(oldSession.statusCode).toBe(401); + expect(registration.readUserSession(completed.sessionToken)).toBeUndefined(); + await app.close(); + }); +}); diff --git a/tests/e2e/account-settings.spec.ts b/tests/e2e/account-settings.spec.ts new file mode 100644 index 0000000..727a864 --- /dev/null +++ b/tests/e2e/account-settings.spec.ts @@ -0,0 +1,99 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +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()); + +const settings = { + account: { email: "settings@example.invalid", status: "active" }, + csrf_token: "csrf-fixture-token-0000000000000000000000000000000000000000000", + local_data: { + backup_enabled: false, capacity_status: "full", hard_limit_bytes: 5368709120, + location: "configured_local_data_root", managed_content_bytes: 5368709120, migration_supported: false, + }, + profile: { creator_name: "Settings User", social_id: "@settings_user" }, +}; + +test("TDD-WP1-DEL-001 renders Ws5E0 states and returns to login after deletion", async ({ page }) => { + let deletionCalls = 0; + await page.route("**/api/v1/account/settings", async (route) => { + await new Promise((done) => setTimeout(done, 100)); + await route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify(settings) }); + }); + await page.route("**/api/v1/account/deletion/send", (route) => route.fulfill({ + contentType: "application/json", status: 200, + body: JSON.stringify({ challenge_expires_at: "2026-07-28T12:10:00.000Z", deletion_id: "00000000-0000-4000-8000-000000000051", resend_available_at: "2026-07-28T12:01:00.000Z", status: "verification_sent" }), + })); + await page.route("**/api/v1/account/deletion/complete", async (route) => { + deletionCalls += 1; + await new Promise((done) => setTimeout(done, 150)); + await route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify({ status: "deleted" }) }); + }); + + await page.goto(`${webUrl}/app/settings`); + await expect(page.getByText("正在读取设置")).toBeVisible(); + await expect(page.getByRole("heading", { name: "设置与本机数据" })).toBeVisible(); + await expect(page.getByLabel("创作署名")).toHaveValue("Settings User"); + await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible(); + await expect(page.getByText("本机容量已满")).toBeVisible(); + await expect(page.locator("body")).not.toContainText("C:\\Users"); + + await page.getByRole("button", { name: "注销账号" }).click(); + const dialog = page.getByRole("dialog", { name: "确认注销账号" }); + await expect(dialog).toContainText("不可恢复"); + await expect(dialog).toContainText("180 天"); + await page.getByRole("button", { name: "获取注销验证码" }).click(); + await page.getByLabel("注销验证码").fill("539126"); + await page.getByLabel("确认词").fill("注销账号"); + await page.getByRole("button", { name: "永久注销" }).click(); + await expect(page.getByRole("button", { name: "注销处理中" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "关闭注销确认" })).toBeDisabled(); + await page.getByRole("button", { name: "注销处理中" }).click({ force: true }); + await expect(page).toHaveURL(`${webUrl}/`); + expect(deletionCalls).toBe(1); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_DELETE; + if (evidenceDirectory) { + mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true }); + await page.screenshot({ path: resolve(evidenceDirectory, "screenshots", "settings-after-delete.png") }); + } +}); + +test("TDD-WP1-DEL-001 keeps profile input on save failure and exposes unavailable deletion", async ({ page }) => { + await page.setViewportSize({ height: 667, width: 375 }); + await page.route("**/api/v1/account/settings", (route) => route.fulfill({ + contentType: "application/json", status: 200, + body: JSON.stringify({ ...settings, local_data: { ...settings.local_data, capacity_status: "unavailable" } }), + })); + await page.route("**/api/v1/account/settings/profile", (route) => route.fulfill({ + contentType: "application/json", status: 503, + body: JSON.stringify({ error: { code: "AUTH_SERVICE_UNAVAILABLE", correlation_id: "00000000-0000-4000-8000-000000000052", details: {}, message_key: "auth.service.unavailable" } }), + })); + await page.goto(`${webUrl}/app/settings`); + await page.getByLabel("创作署名").fill("Unsaved Name"); + await page.getByRole("button", { name: "保存资料" }).click(); + await expect(page.getByRole("alert")).toContainText("保存失败"); + await expect(page.getByLabel("创作署名")).toHaveValue("Unsaved Name"); + await expect(page.getByText("本机数据暂不可写")).toBeVisible(); + await expect(page.getByRole("button", { name: "注销账号" })).toBeEnabled(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375); + await page.getByRole("button", { name: "注销账号" }).click(); + const dialogBounds = await page.getByRole("dialog", { name: "确认注销账号" }).boundingBox(); + expect(dialogBounds).not.toBeNull(); + expect(dialogBounds!.x).toBeGreaterThanOrEqual(0); + expect(dialogBounds!.x + dialogBounds!.width).toBeLessThanOrEqual(375); + await expect(page.getByRole("button", { name: "关闭注销确认" })).toBeFocused(); +}); diff --git a/tests/integration/wp1-05-account-deletion.test.ts b/tests/integration/wp1-05-account-deletion.test.ts new file mode 100644 index 0000000..c298770 --- /dev/null +++ b/tests/integration/wp1-05-account-deletion.test.ts @@ -0,0 +1,204 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Readable } from "node:stream"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { ManagedStorage } from "../../apps/api/src/managed-storage.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; +import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js"; + +const fixedNow = Date.parse("2026-07-28T10:00:00.000Z"); +const roots: string[] = []; +const services: RegistrationService[] = []; +const storages: ManagedStorage[] = []; + +function writeEvidence(environmentName: string, file: string, value: unknown) { + const directory = process.env[environmentName]; + if (!directory) return; + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); +} + +async function createRegisteredHarness(email = "delete-me@example.invalid") { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-05-delete-")); + roots.push(root); + const databasePath = join(root, "db", "dada.sqlite3"); + const storage = new ManagedStorage({ dataRoot: root, databasePath }); + storages.push(storage); + const resend = new MockResendAdapter(); + const service = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x51), + clock: () => fixedNow, + codeGenerator: () => "735104", + currentPrivacyNoticeVersion: registrationNotice.version, + databasePath, + inviteCodeGenerator: () => `DADA-DELETE-${randomUUID()}`, + invitePepper: Buffer.alloc(32, 0x52), + resend, + sessionPepper: Buffer.alloc(32, 0x53), + }); + services.push(service); + const invite = service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 2 }); + const sent = await service.sendRegistrationCode({ email, inviteCode: invite.code }); + const completed = service.completeRegistration({ + code: resend.readLatestCode(email), + creatorName: "Delete Me", + idempotencyKey: `register-${randomUUID()}-${randomUUID()}`, + privacyConsentAccepted: true, + privacyNoticeVersion: registrationNotice.version, + registrationId: sent.registrationId, + socialId: "@delete_me", + }); + return { completed, databasePath, email, resend, root, service, storage }; +} + +afterEach(() => { + for (const service of services.splice(0)) service.close(); + for (const storage of storages.splice(0)) storage.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TDD-WP1-DEL-001-account-delete", () => { + it("revokes every session, removes identity and queues owned files in one deletion transaction", async () => { + const harness = await createRegisteredHarness(); + const userId = harness.completed.user.userId; + const secondSession = harness.service.issueAuthenticatedSession(userId, "user"); + const csrfToken = harness.service.issueUserCsrfToken(harness.completed.sessionToken); + const files = await Promise.all([ + harness.storage.commitStream({ + content: Readable.from(Buffer.from("reference")), expectedMimeType: "application/octet-stream", + fileKind: "reference", fileName: "reference.bin", operationId: randomUUID(), ownerRef: userId, projectedWriteBytes: 9, + }), + harness.storage.commitStream({ + content: Readable.from(Buffer.from("generated")), expectedMimeType: "application/octet-stream", + fileKind: "generated", fileName: "generated.bin", operationId: randomUUID(), ownerRef: userId, projectedWriteBytes: 9, + }), + harness.storage.commitStream({ + content: Readable.from(Buffer.from("export")), expectedMimeType: "application/octet-stream", + fileKind: "export", fileName: "export.bin", operationId: randomUUID(), ownerRef: userId, projectedWriteBytes: 6, + }), + ]); + + const sent = await harness.service.sendAccountDeletionCode({ + csrfToken, + sessionToken: harness.completed.sessionToken, + }); + const result = harness.service.completeAccountDeletion({ + code: harness.resend.readLatestCode(harness.email), + confirmation: "注销账号", + csrfToken, + deletionId: sent.deletionId, + idempotencyKey: `delete-${randomUUID()}-${randomUUID()}`, + sessionToken: harness.completed.sessionToken, + }); + + expect(result).toMatchObject({ status: "deleted" }); + expect(harness.service.readUserSession(harness.completed.sessionToken)).toBeUndefined(); + expect(harness.service.readUserSession(secondSession.sessionToken)).toBeUndefined(); + const databaseState = { + account: harness.service.database.prepare("SELECT COUNT(*) AS count FROM users WHERE user_id = ?").get(userId).count, + consents: harness.service.database.prepare("SELECT COUNT(*) AS count FROM privacy_consents WHERE user_id = ?").get(userId).count, + credits: harness.service.database.prepare("SELECT COUNT(*) AS count FROM credit_accounts WHERE user_id = ?").get(userId).count, + ledger: harness.service.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger WHERE user_id = ?").get(userId).count, + profiles: harness.service.database.prepare("SELECT COUNT(*) AS count FROM user_profiles WHERE user_id = ?").get(userId).count, + sessions: harness.service.database.prepare("SELECT COUNT(*) AS count FROM sessions WHERE user_id = ?").get(userId).count, + }; + expect(databaseState).toEqual({ account: 0, consents: 0, credits: 0, ledger: 0, profiles: 0, sessions: 0 }); + expect(files.every((file) => harness.storage.resolveManagedFile(file.file_id) === undefined)).toBe(true); + expect(harness.storage.inspectCounts().pending_cleanup).toBe(3); + expect(harness.service.database.prepare("SELECT COUNT(*) AS count FROM users WHERE normalized_email = ?").get(harness.email).count).toBe(0); + + const freshInvite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 1 }); + const freshSent = await harness.service.sendRegistrationCode({ email: harness.email, inviteCode: freshInvite.code }); + const fresh = harness.service.completeRegistration({ + code: harness.resend.readLatestCode(harness.email), + creatorName: "Fresh Account", + idempotencyKey: `fresh-${randomUUID()}-${randomUUID()}`, + privacyConsentAccepted: true, + privacyNoticeVersion: registrationNotice.version, + registrationId: freshSent.registrationId, + socialId: "@fresh_account", + }); + expect(fresh.user.userId).not.toBe(userId); + expect(fresh.credits).toEqual({ availableBalance: 10, reservedBalance: 0 }); + const cleanup = await harness.storage.processCleanupQueue(); + expect(cleanup).toEqual({ completed: 3, failed: 0 }); + + writeEvidence("DADA_EVIDENCE_DIR_DELETE", "response.json", { + old_access: "revoked", + old_resources: "not_found", + same_email_registration: "fresh_account", + status: result.status, + }); + writeEvidence("DADA_EVIDENCE_DIR_DELETE", "db-diff.json", { + ...databaseState, + cleanup_queued: 3, + old_user_id_reused: false, + }); + writeEvidence("DADA_EVIDENCE_DIR_DELETE", "worker-events.json", { + cleanup_completed: cleanup.completed, + cleanup_failed: cleanup.failed, + physical_files_remaining: files.filter((file) => existsSync(resolve(harness.root, file.relative_path))).length, + }); + }); +}); + +describe("TDD-WP1-DEL-002-anonymous-retention", () => { + it("projects identity-linked records onto the allowed anonymous fields without extending access audit expiry", async () => { + const harness = await createRegisteredHarness("retention@example.invalid"); + const userId = harness.completed.user.userId; + const csrfToken = harness.service.issueUserCsrfToken(harness.completed.sessionToken); + const accessExpiresAt = fixedNow + 30_000; + harness.service.database.prepare(` + INSERT INTO private_content_access_logs ( + log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, 'image', ?, ?) + `).run(randomUUID(), "admin-fixture", userId, "private-target-fixture", fixedNow - 1_000, accessExpiresAt); + + const sent = await harness.service.sendAccountDeletionCode({ csrfToken, sessionToken: harness.completed.sessionToken }); + harness.service.completeAccountDeletion({ + code: harness.resend.readLatestCode(harness.email), confirmation: "注销账号", csrfToken, + deletionId: sent.deletionId, idempotencyKey: `retain-${randomUUID()}-${randomUUID()}`, + sessionToken: harness.completed.sessionToken, + }); + + const anonymous = harness.service.database.prepare("SELECT * FROM anonymous_retained_events").all(); + expect(anonymous).toHaveLength(1); + expect(Object.keys(anonymous[0]).sort()).toEqual([ + "anonymous_subject_id", "credit_delta", "error_category", "event_id", "event_type", + "expires_at", "model_id", "occurred_at", "outcome", + ]); + expect(anonymous[0]).toMatchObject({ + credit_delta: 10, + error_category: null, + event_type: "registration_grant", + expires_at: fixedNow + 180 * 86_400_000, + model_id: null, + outcome: "succeeded", + }); + expect(JSON.stringify(anonymous)).not.toContain(harness.email); + expect(JSON.stringify(anonymous)).not.toContain(userId); + expect(JSON.stringify(anonymous)).not.toContain("private-target-fixture"); + + const access = harness.service.database.prepare("SELECT * FROM private_content_access_logs").get(); + expect(access).toMatchObject({ actor_ref: "admin-fixture", expires_at: accessExpiresAt }); + expect(access.subject_ref).not.toBe(userId); + expect(access.target_ref).not.toBe("private-target-fixture"); + expect(access.subject_ref).not.toBe(anonymous[0].anonymous_subject_id); + + writeEvidence("DADA_EVIDENCE_DIR_RETENTION", "db-diff.json", { + access_actor_preserved: true, + access_expiry_preserved: access.expires_at === accessExpiresAt, + identity_relationships: 0, + anonymous_events: anonymous.length, + }); + writeEvidence("DADA_EVIDENCE_DIR_RETENTION", "redaction.json", { + forbidden_fields_present: [], + original_identifiers_present: false, + }); + }); +}); diff --git a/tests/worker/wp1-05-retention-cleanup.test.ts b/tests/worker/wp1-05-retention-cleanup.test.ts new file mode 100644 index 0000000..48cf0f9 --- /dev/null +++ b/tests/worker/wp1-05-retention-cleanup.test.ts @@ -0,0 +1,72 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { RetentionCleanup } from "../../apps/worker/src/retention-cleanup.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; +import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js"; + +const baseNow = Date.parse("2026-07-28T11:00:00.000Z"); +const roots: string[] = []; +const services: RegistrationService[] = []; + +function writeEvidence(value: unknown) { + const directory = process.env.DADA_EVIDENCE_DIR_RETENTION; + if (!directory) return; + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, "retention-events.json"), `${JSON.stringify(value, null, 2)}\n`); +} + +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-WP1-DEL-002 retention worker", () => { + it("refuses early/manual deletion and removes each row only at its original expiry", () => { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-05-retention-")); + roots.push(root); + const databasePath = join(root, "dada.sqlite3"); + const service = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x61), clock: () => baseNow, codeGenerator: () => "120984", + currentPrivacyNoticeVersion: registrationNotice.version, databasePath, + invitePepper: Buffer.alloc(32, 0x62), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x63), + }); + services.push(service); + const accessExpiry = baseNow + 1_000; + const anonymousExpiry = baseNow + 2_000; + service.database.prepare(` + INSERT INTO private_content_access_logs (log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at) + VALUES (?, 'admin-fixture', ?, ?, 'prompt', ?, ?) + `).run(randomUUID(), randomUUID(), randomUUID(), baseNow - 10_000, accessExpiry); + service.database.prepare(` + INSERT INTO anonymous_retained_events ( + event_id, anonymous_subject_id, event_type, model_id, outcome, error_category, + credit_delta, occurred_at, expires_at + ) VALUES (?, ?, 'generation_commit', 'model-a', 'succeeded', NULL, -1, ?, ?) + `).run(randomUUID(), randomUUID(), baseNow - 10_000, anonymousExpiry); + + expect(() => service.database.prepare("DELETE FROM anonymous_retained_events").run()).toThrow(); + expect(() => service.database.prepare("DELETE FROM private_content_access_logs").run()).toThrow(); + + const early = new RetentionCleanup({ clock: () => baseNow, databasePath }); + expect(early.purgeExpired()).toEqual({ anonymous_events: 0, private_access_logs: 0 }); + early.close(); + const firstExpiry = new RetentionCleanup({ clock: () => accessExpiry, databasePath }); + expect(firstExpiry.purgeExpired()).toEqual({ anonymous_events: 0, private_access_logs: 1 }); + firstExpiry.close(); + const secondExpiry = new RetentionCleanup({ clock: () => anonymousExpiry, databasePath }); + expect(secondExpiry.purgeExpired()).toEqual({ anonymous_events: 1, private_access_logs: 0 }); + secondExpiry.close(); + + writeEvidence({ + anonymous_deleted_at_expiry: true, + early_delete_blocked: true, + private_access_deleted_at_original_expiry: true, + }); + }); +});