feat: implement TASK-WP1-05 account deletion
This commit is contained in:
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
|
||||
private adminAllowlistHashes = new Set<string>();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<SettingsPayload>();
|
||||
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<string>();
|
||||
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<SettingsPayload>;
|
||||
})
|
||||
.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 <main className="settings-loading" aria-live="polite">正在读取设置</main>;
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<main className="settings-loading">
|
||||
<p role="alert">设置暂时无法读取。</p>
|
||||
<button onClick={() => window.location.reload()} type="button">重新读取</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const capacityPercent = Math.min(100, Math.round((settings.local_data.managed_content_bytes / settings.local_data.hard_limit_bytes) * 100));
|
||||
|
||||
return (
|
||||
<main className="settings-page">
|
||||
<header className="settings-header">
|
||||
<a className="settings-brand" href="/app">DADA</a>
|
||||
<nav aria-label="账号导航">
|
||||
<a href="/app">创作工作台</a>
|
||||
<span aria-current="page">设置</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="settings-title">
|
||||
<p>ACCOUNT / LOCAL DATA</p>
|
||||
<h1>设置与本机数据</h1>
|
||||
</div>
|
||||
|
||||
<section className="settings-section" aria-labelledby="profile-title">
|
||||
<div className="settings-section-heading">
|
||||
<h2 id="profile-title">个人资料</h2>
|
||||
<p>用于生成内容中的署名信息。</p>
|
||||
</div>
|
||||
<form className="settings-form" onSubmit={saveProfile}>
|
||||
<label htmlFor={creatorNameId}>创作署名</label>
|
||||
<input id={creatorNameId} maxLength={80} onChange={(event) => setCreatorName(event.target.value)} value={creatorName} />
|
||||
<label htmlFor={socialId}>社交 ID</label>
|
||||
<input id={socialId} maxLength={80} onChange={(event) => setSocialHandle(event.target.value)} value={socialHandle} />
|
||||
{saveError ? <p className="settings-error" role="alert">保存失败,当前输入已保留,请重试。</p> : null}
|
||||
{saved ? <p className="settings-saved" role="status">资料已保存。</p> : null}
|
||||
<button className="settings-primary" disabled={saving || !creatorName.trim() || !socialHandle.trim()} type="submit">
|
||||
{saving ? "保存中" : "保存资料"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="settings-section" aria-labelledby="account-title">
|
||||
<div className="settings-section-heading">
|
||||
<h2 id="account-title">账号</h2>
|
||||
<p>邮箱仅用于验证码认证。</p>
|
||||
</div>
|
||||
<dl className="settings-definition">
|
||||
<div><dt>邮箱</dt><dd>{settings.account.email}</dd></div>
|
||||
<div><dt>状态</dt><dd>正常</dd></div>
|
||||
<div><dt>登录方式</dt><dd>邮箱验证码</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="settings-section settings-local" aria-labelledby="local-title">
|
||||
<div className="settings-section-heading">
|
||||
<h2 id="local-title">本机数据</h2>
|
||||
<p>逻辑位置:Dada 配置的本机数据目录</p>
|
||||
</div>
|
||||
<div className="settings-capacity" data-status={settings.local_data.capacity_status}>
|
||||
<div>
|
||||
<strong>{capacityLabel(settings.local_data.capacity_status)}</strong>
|
||||
<span>{formatBytes(settings.local_data.managed_content_bytes)} / {formatBytes(settings.local_data.hard_limit_bytes)}</span>
|
||||
</div>
|
||||
<div className="settings-capacity-track" role="progressbar" aria-label="本机数据容量" aria-valuemax={100} aria-valuemin={0} aria-valuenow={capacityPercent}>
|
||||
<span style={{ width: `${capacityPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="settings-fixed-notice">测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。</p>
|
||||
<p className="settings-detail">数据依赖当前 Windows 用户登录和文件系统权限,不提供 Dada 应用层加密或云备份。机器损坏、重装或删除本机数据目录后无法恢复。</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-danger" aria-labelledby="danger-title">
|
||||
<div>
|
||||
<h2 id="danger-title">注销账号</h2>
|
||||
<p>注销会立即删除账号资料、未使用点数和全部作品,并使所有会话失效。</p>
|
||||
</div>
|
||||
<button className="settings-danger-button" onClick={() => setDeletionOpen(true)} type="button">注销账号</button>
|
||||
</section>
|
||||
|
||||
{deletionOpen ? (
|
||||
<div className="settings-dialog-backdrop">
|
||||
<section
|
||||
aria-labelledby="delete-dialog-title"
|
||||
aria-modal="true"
|
||||
className="settings-dialog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !deleting) closeDeletion();
|
||||
}}
|
||||
role="dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p>DANGER ZONE</p>
|
||||
<h2 id="delete-dialog-title">确认注销账号</h2>
|
||||
</div>
|
||||
<button aria-label="关闭注销确认" autoFocus disabled={deleting} onClick={closeDeletion} type="button">×</button>
|
||||
</header>
|
||||
<div className="settings-dialog-body">
|
||||
<p className="settings-dialog-warning">账号、资料、项目、图片、坐标、成品和未使用点数将立即删除且不可恢复。</p>
|
||||
<p>匿名生成与点数事件最多保留 180 天,只保留随机主体、时间、模型、结果、错误类别和点数变化;不会保留邮箱或作品内容。</p>
|
||||
<button className="settings-code-button" disabled={sendingCode || deleting || Boolean(deletionId)} onClick={sendDeletionCode} type="button">
|
||||
{sendingCode ? "发送中" : deletionId ? "验证码已发送" : "获取注销验证码"}
|
||||
</button>
|
||||
<form onSubmit={completeDeletion}>
|
||||
<label htmlFor={deletionCodeId}>注销验证码</label>
|
||||
<input disabled={deleting || !deletionId} id={deletionCodeId} inputMode="numeric" maxLength={6} onChange={(event) => setDeletionCode(event.target.value.replace(/\D/g, ""))} value={deletionCode} />
|
||||
<label htmlFor={confirmationId}>确认词</label>
|
||||
<input disabled={deleting} id={confirmationId} onChange={(event) => setConfirmation(event.target.value)} placeholder="输入:注销账号" value={confirmation} />
|
||||
{deletionError ? <p className="settings-error" role="alert">注销请求未完成,请核对验证码后重试。</p> : null}
|
||||
<button className="settings-delete-confirm" disabled={!deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting} type="submit">
|
||||
{deleting ? "注销处理中" : "永久注销"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<AccountDeletionResponse> {
|
||||
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<AccountDeletionResponse>;
|
||||
}
|
||||
|
||||
export async function completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise<AdminLoginCompleteResponse> {
|
||||
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<RegistrationCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function getAccountSettings(options: ClientOptions = {}): Promise<AccountSettingsResponse> {
|
||||
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<AccountSettingsResponse>;
|
||||
}
|
||||
|
||||
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
|
||||
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<LogoutRes
|
||||
return response.json() as Promise<LogoutResponse>;
|
||||
}
|
||||
|
||||
export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise<AccountDeletionSendResponse> {
|
||||
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<AccountDeletionSendResponse>;
|
||||
}
|
||||
|
||||
export async function sendAdminLoginCode(body: AdminLoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||
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<RegistrationSendResponse>;
|
||||
}
|
||||
|
||||
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
|
||||
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<AccountProfileUpdateResponse>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
? <AdminAuthPage key={authRevision} />
|
||||
: <UserAuthPage key={authRevision} />;
|
||||
const authenticationPage = window.location.pathname === "/app/settings"
|
||||
? <AccountSettingsPage key={authRevision} />
|
||||
: window.location.pathname.startsWith("/admin")
|
||||
? <AdminAuthPage key={authRevision} />
|
||||
: <UserAuthPage key={authRevision} />;
|
||||
appRoot.render(
|
||||
<StrictMode>
|
||||
{authenticationPage}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setInterval> | 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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
@@ -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<typeof RegistrationSendRequestSchema>;
|
||||
export type RegistrationSendResponse = Static<typeof RegistrationSendResponseSchema>;
|
||||
export type RegistrationCompleteRequest = Static<typeof RegistrationCompleteRequestSchema>;
|
||||
@@ -178,3 +260,5 @@ export type LoginSendRequest = Static<typeof LoginSendRequestSchema>;
|
||||
export type LoginCompleteRequest = Static<typeof LoginCompleteRequestSchema>;
|
||||
export type AdminLoginSendRequest = Static<typeof AdminLoginSendRequestSchema>;
|
||||
export type AdminLoginCompleteRequest = Static<typeof AdminLoginCompleteRequestSchema>;
|
||||
export type AccountProfileUpdateRequest = Static<typeof AccountProfileUpdateRequestSchema>;
|
||||
export type AccountDeletionCompleteRequest = Static<typeof AccountDeletionCompleteRequestSchema>;
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user