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 {
|
||||
|
||||
Reference in New Issue
Block a user