import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { createRequire } from "node:module"; import type BetterSqlite3 from "better-sqlite3"; import { ensureAdminOperationAuditSchema, ensurePrivateAccessAuditSchema, isSafeAuditRef, isSafeAuditSummaryJson, serializeAuditSummary, } from "./audit-policy.js"; import type { ResendAdapter } from "./resend-adapter.js"; import { RegistrationError, type RegistrationErrorReason, } from "./registration-errors.js"; export { RegistrationError } from "./registration-errors.js"; const require = createRequire(import.meta.url); const Database = require("better-sqlite3") as typeof BetterSqlite3; const stageLimit = 10; const challengeLifetimeMilliseconds = 10 * 60 * 1_000; const resendDelayMilliseconds = 60 * 1_000; const sessionLifetimeMilliseconds = 30 * 24 * 60 * 60 * 1_000; const rateWindowMilliseconds = 10 * 60 * 1_000; const rateBlockMilliseconds = 10 * 60 * 1_000; const maximumFailedAttempts = 5; const maximumSendsPerWindow = 5; export interface RegistrationTransactionEvent { mode: "BEGIN IMMEDIATE"; operation: | "invite_create" | "registration_send" | "registration_complete" | "registration_send_compensation" | "login_send" | "login_complete" | "admin_login_send" | "admin_login_complete" | "secure_config_apply" | "session_issue" | "session_revoke" | "account_delete_send" | "account_delete_complete" | "profile_update" | "csrf_issue"; outcome: "committed" | "rejected" | "idempotent_replay"; } interface RegistrationServiceOptions { adminAllowlistPepper?: Buffer; challengePepper: Buffer; clock?: () => number; codeGenerator?: () => string; currentPrivacyNoticeVersion: string; databasePath: string; inviteCodeGenerator?: () => string; invitePepper: Buffer; onTransaction?: (event: RegistrationTransactionEvent) => void; resend: ResendAdapter; sessionPepper: Buffer; } interface InviteRow { expires_at: number; invite_id: string; max_uses: number; status: "enabled" | "disabled"; used_count: number; } interface ChallengeRow { challenge_id: string; code_hmac: string; consumed_at: number | null; email: string; expires_at: number; invite_id: string | null; } interface AttemptRow { failure_reason: RegistrationErrorReason | null; outcome_code: "success" | "failure"; request_hash: string; session_id: string | null; user_id: string | null; } interface UserResultRow { available_balance: number; creator_name: string; expires_at: number; reserved_balance: number; session_id: string; social_id: string; user_id: string; } interface RateLimitRow { blocked_until: number | null; failed_attempts: number; send_count: number; window_started_at: number; } interface LoginAttemptRow { failure_reason: RegistrationErrorReason | null; outcome_code: "success" | "failure"; request_hash: string; session_id: string | null; user_id: string | null; } export interface RegistrationSendResult { challengeExpiresAt: number; registrationId: string; resendAvailableAt: number; status: "verification_sent"; } export interface RegistrationCompleteInput { code: string; creatorName: string; idempotencyKey: string; privacyConsentAccepted: boolean; privacyNoticeVersion: string; registrationId: string; socialId: string; } export interface RegistrationCompleteResult { credits: { availableBalance: number; reservedBalance: number }; sessionExpiresAt: number; sessionToken: string; status: "registered"; user: { creatorName: string; role: "user"; socialId: string; status: "active"; userId: string; }; } export interface UserSessionResult { audience: "user"; credits: { availableBalance: number; reservedBalance: number }; expiresAt: number; user: RegistrationCompleteResult["user"]; userId: string; } export interface LoginCompleteInput { clientKey: string; code: string; idempotencyKey: string; registrationId: string; } export type LoginCompleteResult = Omit & { audience: "user"; status: "authenticated"; }; export interface AdminLoginCompleteResult { admin: { role: "super_admin"; status: "active"; userId: string; }; audience: "admin"; sessionExpiresAt: number; sessionToken: string; status: "authenticated"; } export interface SecureConfigCandidate { adminAllowlistHashes: string[]; adminRecoveryHashes: string[]; secureConfigRevision: number; } interface ImmediateResult { outcome: RegistrationTransactionEvent["outcome"]; value: T; } function assertSecret(name: string, value: Buffer) { if (value.byteLength < 32) throw new Error(`${name} must contain at least 32 bytes.`); } function normalizeEmail(email: string) { const normalized = email.trim().toLowerCase(); if (normalized.length > 320 || !/^[^@\s]{1,128}@[^@\s]{1,190}$/.test(normalized)) { throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); } return normalized; } function normalizeProfileValue(value: string, maximumLength: number) { const normalized = value.trim(); if (!normalized || normalized.length > maximumLength) { throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); } return normalized; } function normalizeSocialId(value: string) { const body = normalizeProfileValue(value, 80).replace(/^@+/, ""); if (!body) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); return `@${body}`; } function digest(value: string) { return createHash("sha256").update(value, "utf8").digest("hex"); } function constantTimeTextEqual(left: string, right: string) { const leftBuffer = Buffer.from(left, "hex"); const rightBuffer = Buffer.from(right, "hex"); return leftBuffer.byteLength === rightBuffer.byteLength && timingSafeEqual(leftBuffer, rightBuffer); } export class RegistrationService { readonly database: BetterSqlite3.Database; readonly options: Required> & RegistrationServiceOptions; private adminAllowlistHashes = new Set(); private privacyPurgeActive = false; private privacyPurgeSubject = ""; constructor(options: RegistrationServiceOptions) { assertSecret("invitePepper", options.invitePepper); assertSecret("challengePepper", options.challengePepper); assertSecret("sessionPepper", options.sessionPepper); if (options.adminAllowlistPepper) assertSecret("adminAllowlistPepper", options.adminAllowlistPepper); this.options = { ...options, clock: options.clock ?? Date.now, codeGenerator: options.codeGenerator ?? (() => String(randomBytes(4).readUInt32BE(0) % 1_000_000).padStart(6, "0")), inviteCodeGenerator: options.inviteCodeGenerator ?? (() => randomBytes(24).toString("base64url")), }; this.database = new Database(options.databasePath); this.database.pragma("journal_mode = WAL"); this.database.pragma("foreign_keys = ON"); this.database.pragma("synchronous = FULL"); this.database.pragma("busy_timeout = 5000"); this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef); this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson); this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => this.privacyPurgeActive ? 1 : 0); this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => this.privacyPurgeSubject); this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0); this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0); this.migrate(); } close() { this.database.close(); } createInvite(input: { expiresAt: number; maxUses: number }) { if (!Number.isSafeInteger(input.expiresAt) || !Number.isSafeInteger(input.maxUses) || input.maxUses < 1) { throw new Error("Invite fixture is invalid."); } const code = this.options.inviteCodeGenerator(); const inviteId = randomUUID(); const now = this.options.clock(); this.runImmediate("invite_create", () => { this.database.prepare(` INSERT INTO invite_codes ( invite_id, code_hmac, max_uses, used_count, expires_at, status, created_at ) VALUES (?, ?, ?, 0, ?, 'enabled', ?) `).run(inviteId, this.inviteHmac(code), input.maxUses, input.expiresAt, now); return { outcome: "committed", value: undefined }; }); return { code, inviteId }; } async sendRegistrationCode(input: { email: string; inviteCode: string }): Promise { const email = normalizeEmail(input.email); const inviteCode = normalizeProfileValue(input.inviteCode, 160); const now = this.options.clock(); const challengeId = 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("registration_send", () => { const invite = this.database.prepare("SELECT * FROM invite_codes WHERE code_hmac = ?") .get(this.inviteHmac(inviteCode)) as InviteRow | undefined; this.assertInviteAvailable(invite, now); this.assertStageCapacity(); const existing = this.database.prepare(` SELECT user_id, role, status FROM users WHERE normalized_email = ? AND status <> 'deleted' `).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended" } | undefined; if (existing?.status === "suspended") throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended"); if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required"); this.assertChallengeSendAllowed(email, "register", "registration", now); this.recordRateSend(email, "registration", now); this.database.prepare(` INSERT INTO email_challenges ( challenge_id, email, invite_id, code_hmac, purpose, expires_at, resend_available_at, failure_count, consumed_at, created_at ) VALUES (?, ?, ?, ?, 'register', ?, ?, 0, NULL, ?) `).run( challengeId, email, invite!.invite_id, this.challengeHmac(challengeId, code), now + challengeLifetimeMilliseconds, now + resendDelayMilliseconds, now, ); return { outcome: "committed", value: { challengeExpiresAt: now + challengeLifetimeMilliseconds, registrationId: challengeId, resendAvailableAt: now + resendDelayMilliseconds, status: "verification_sent" as const, }, }; }); try { await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" }); } catch { this.runImmediate("registration_send_compensation", () => { this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); return { outcome: "committed", value: undefined }; }); throw new Error("AUTH_SERVICE_UNAVAILABLE"); } return result; } async sendLoginCode(input: { clientKey: string; email: string }): Promise { const email = normalizeEmail(input.email); const clientKey = normalizeProfileValue(input.clientKey, 160); const now = this.options.clock(); const challengeId = 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("login_send", () => { const user = this.database.prepare(` SELECT user_id, role, status FROM users WHERE normalized_email = ? AND status <> 'deleted' `).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; if (!user) throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_registration_required"); if (user.status === "suspended") throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended"); if (user.role !== "user") throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_admin_required"); this.assertChallengeSendAllowed(email, "login", clientKey, now); this.recordRateSend(email, clientKey, now); this.database.prepare(` INSERT INTO email_challenges ( challenge_id, email, invite_id, code_hmac, purpose, expires_at, resend_available_at, failure_count, consumed_at, created_at ) VALUES (?, ?, NULL, ?, 'login', ?, ?, 0, NULL, ?) `).run( challengeId, email, this.challengeHmac(challengeId, code), now + challengeLifetimeMilliseconds, now + resendDelayMilliseconds, now, ); return { outcome: "committed", value: { challengeExpiresAt: now + challengeLifetimeMilliseconds, registrationId: challengeId, resendAvailableAt: now + resendDelayMilliseconds, status: "verification_sent" as const, }, }; }); try { await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" }); } catch { this.runImmediate("registration_send_compensation", () => { this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); return { outcome: "committed", value: undefined }; }); throw new Error("AUTH_SERVICE_UNAVAILABLE"); } return result; } completeLogin(input: LoginCompleteInput): LoginCompleteResult { if (!/^[0-9]{6}$/.test(input.code)) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); const clientKey = normalizeProfileValue(input.clientKey, 160); if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); } const now = this.options.clock(); const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `login-idempotency:${input.idempotencyKey}`); const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ clientKey, code: input.code, registrationId: input.registrationId, })); const outcome = this.runImmediate("login_complete", () => { const previous = this.database.prepare(` SELECT request_hash, outcome_code, failure_reason, user_id, session_id FROM login_attempts WHERE idempotency_key_digest = ? `).get(idempotencyDigest) as LoginAttemptRow | undefined; if (previous) { if (!constantTimeTextEqual(previous.request_hash, requestHash)) { throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); } if (previous.outcome_code === "failure") { const reason = previous.failure_reason ?? "challenge_invalid"; const code = reason === "resend_too_soon" || reason === "too_many_attempts" ? "AUTH_RATE_LIMITED" : "AUTH_ENTRY_REJECTED"; return { outcome: "idempotent_replay", value: new RegistrationError(code, reason) }; } return { outcome: "idempotent_replay", value: this.loginResult(this.readCompletedRegistration(previous.user_id!, previous.session_id!)), }; } const challenge = this.database.prepare(` SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at FROM email_challenges WHERE challenge_id = ? AND purpose = 'login' `).get(input.registrationId) as ChallengeRow | undefined; if (!challenge || challenge.consumed_at !== null) { return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); } const rate = this.readRateLimit(challenge.email, clientKey, now); if (rate.blocked_until !== null && rate.blocked_until > now) { return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "too_many_attempts", now); } if (challenge.expires_at <= now) { return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); } if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { this.database.prepare("UPDATE email_challenges SET failure_count = failure_count + 1 WHERE challenge_id = ?") .run(challenge.challenge_id); const failedAttempts = this.recordRateFailure(challenge.email, clientKey, now); const reason = failedAttempts >= maximumFailedAttempts ? "too_many_attempts" : "challenge_invalid"; return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, reason, now); } const user = this.database.prepare(` SELECT user_id, role, status FROM users WHERE normalized_email = ? AND status <> 'deleted' `).get(challenge.email) as { role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; if (!user) return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "login_registration_required", now); if (user.status === "suspended") return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "account_suspended", now); if (user.role !== "user") return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "login_admin_required", now); this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") .run(now, challenge.challenge_id); const issued = this.insertSession(user.user_id, "user", now); this.database.prepare(` INSERT INTO login_attempts ( idempotency_key_digest, request_hash, challenge_id, outcome_code, failure_reason, user_id, session_id, created_at ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) `).run(idempotencyDigest, requestHash, input.registrationId, user.user_id, issued.sessionId, now); return { outcome: "committed", value: this.loginResult(this.readCompletedRegistration(user.user_id, issued.sessionId)), }; }); if (outcome instanceof RegistrationError) throw outcome; return outcome; } completeRegistration(input: RegistrationCompleteInput): RegistrationCompleteResult { const creatorName = normalizeProfileValue(input.creatorName, 80); const socialId = normalizeSocialId(input.socialId); if (!/^[0-9]{6}$/.test(input.code)) { throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); } if (!input.privacyConsentAccepted) { throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "privacy_consent_required"); } if (input.privacyNoticeVersion !== this.options.currentPrivacyNoticeVersion) { throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "privacy_notice_version_invalid"); } if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); } const now = this.options.clock(); const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `idempotency:${input.idempotencyKey}`); const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ code: input.code, creatorName, privacyConsentAccepted: input.privacyConsentAccepted, privacyNoticeVersion: input.privacyNoticeVersion, registrationId: input.registrationId, socialId, })); const outcome = this.runImmediate("registration_complete", () => { const previous = this.database.prepare(` SELECT request_hash, outcome_code, failure_reason, user_id, session_id FROM registration_attempts WHERE idempotency_key_digest = ? `).get(idempotencyDigest) as AttemptRow | undefined; if (previous) { if (!constantTimeTextEqual(previous.request_hash, requestHash)) { throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); } if (previous.outcome_code === "failure") { return { outcome: "idempotent_replay", value: new RegistrationError("REGISTRATION_REJECTED", previous.failure_reason ?? "challenge_invalid"), }; } return { outcome: "idempotent_replay", value: this.readCompletedRegistration(previous.user_id!, previous.session_id!), }; } const challenge = this.database.prepare(` SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at FROM email_challenges WHERE challenge_id = ? AND purpose = 'register' `).get(input.registrationId) as ChallengeRow | undefined; if (!challenge || challenge.consumed_at !== null) { return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); } const invite = this.database.prepare("SELECT * FROM invite_codes WHERE invite_id = ?") .get(challenge.invite_id) as InviteRow | undefined; const inviteReason = this.inviteUnavailableReason(invite, now); if (inviteReason) return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, inviteReason, now); if (this.stageIsFull()) { return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "stage_limit_reached", now); } if (challenge.expires_at <= now) { return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); } if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); } const existing = this.database.prepare(` SELECT user_id FROM users WHERE normalized_email = ? AND status <> 'deleted' `).get(challenge.email); if (existing) { return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "email_already_registered", now); } const userId = randomUUID(); const sessionId = randomUUID(); const sessionToken = this.sessionToken(sessionId); const sessionExpiresAt = now + sessionLifetimeMilliseconds; this.database.prepare(` INSERT INTO users ( user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at ) VALUES (?, ?, 'user', 'active', 1, ?, ?) `).run(userId, challenge.email, input.registrationId, now); this.database.prepare(` INSERT INTO user_profiles ( user_id, creator_name, social_id, private_content_notice_version, private_content_notice_acknowledged_at ) VALUES (?, ?, ?, NULL, NULL) `).run(userId, creatorName, socialId); this.database.prepare(` INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?) `).run(userId, now); this.database.prepare(` INSERT INTO credit_ledger ( ledger_id, user_id, operation_key, entry_type, amount, available_before, available_after, reserved_before, reserved_after, created_at ) VALUES (?, ?, ?, 'registration_grant', 10, 0, 10, 0, 0, ?) `).run(randomUUID(), userId, `registration:${input.registrationId}`, now); this.database.prepare(` INSERT INTO privacy_consents (consent_id, user_id, notice_version, consented_at) VALUES (?, ?, ?, ?) `).run(randomUUID(), userId, input.privacyNoticeVersion, now); this.database.prepare(` UPDATE invite_codes SET used_count = used_count + 1 WHERE invite_id = ? AND status = 'enabled' AND used_count < max_uses AND expires_at > ? `).run(challenge.invite_id, now); this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") .run(now, challenge.challenge_id); this.database.prepare(` INSERT INTO sessions ( session_id, user_id, audience, token_digest, created_at, expires_at, revoked_at ) VALUES (?, ?, 'user', ?, ?, ?, NULL) `).run(sessionId, userId, digest(sessionToken), now, sessionExpiresAt); this.database.prepare(` INSERT INTO registration_attempts ( idempotency_key_digest, request_hash, registration_id, outcome_code, failure_reason, user_id, session_id, created_at ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) `).run(idempotencyDigest, requestHash, input.registrationId, userId, sessionId, now); return { outcome: "committed", value: this.result({ available_balance: 10, creator_name: creatorName, expires_at: sessionExpiresAt, reserved_balance: 0, session_id: sessionId, social_id: socialId, user_id: userId, }), }; }); if (outcome instanceof RegistrationError) throw outcome; return outcome; } applySecureConfig(candidate: SecureConfigCandidate) { const now = this.options.clock(); const fail = (reason: string): never => { this.recordConfigApplyFailure(reason, now); throw new Error(reason); }; if (!this.options.adminAllowlistPepper) return fail("admin_pepper_not_configured"); if (!Number.isSafeInteger(candidate.secureConfigRevision) || candidate.secureConfigRevision < 0) { return fail("secure_config_revision_invalid"); } const normalizeHashes = (values: string[], name: string) => { if (!Array.isArray(values)) return fail(`${name}_invalid`); const normalized = [...new Set(values.map((value) => value.toUpperCase()))]; if (normalized.some((value) => !/^[A-F0-9]{64}$/.test(value))) return fail("hmac_invalid"); return normalized; }; const allowlist = normalizeHashes(candidate.adminAllowlistHashes, "admin_allowlist"); const recoveries = normalizeHashes(candidate.adminRecoveryHashes, "admin_recovery"); const allowlistSet = new Set(allowlist); if (recoveries.some((value) => !allowlistSet.has(value))) return fail("admin_recovery_invalid"); const state = this.database.prepare(` SELECT applied_revision FROM secure_config_apply_state WHERE singleton = 1 `).get() as { applied_revision: number } | undefined; const appliedRevision = state?.applied_revision ?? 0; if (candidate.secureConfigRevision === appliedRevision) { this.adminAllowlistHashes = allowlistSet; return { appliedRevision, status: "unchanged" as const }; } if (candidate.secureConfigRevision !== appliedRevision + 1) return fail("secure_config_revision_out_of_sequence"); const ordinaryUsers = this.database.prepare(` SELECT normalized_email FROM users WHERE role = 'user' AND status <> 'deleted' `).all() as Array<{ normalized_email: string }>; if (ordinaryUsers.some((user) => allowlistSet.has(this.adminAllowlistHmac(user.normalized_email)))) { return fail("identity_conflict"); } try { const result = this.runImmediate("secure_config_apply", () => { const recoverySet = new Set(recoveries); const admins = this.database.prepare(` SELECT u.user_id, u.normalized_email, u.status, COALESCE(a.allowed, 0) AS allowed FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id WHERE u.role = 'super_admin' AND u.status <> 'deleted' `).all() as Array<{ allowed: 0 | 1; normalized_email: string; status: "active" | "suspended"; user_id: string; }>; let revokedSessions = 0; let recoveredAdmins = 0; for (const admin of admins) { const adminHash = this.adminAllowlistHmac(admin.normalized_email); const allowed = allowlistSet.has(adminHash); this.database.prepare(` INSERT INTO admin_access (user_id, allowed) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET allowed = excluded.allowed `).run(admin.user_id, allowed ? 1 : 0); if (!allowed) { revokedSessions += this.database.prepare(` UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL `).run(now, admin.user_id).changes; if (admin.allowed === 1) { this.recordAdminAudit({ actorRef: "backend_secure_config", actorType: "system", afterSummary: { access: "removed" }, beforeSummary: { access: "allowed" }, operationType: "admin_allowlist_remove", result: "succeeded", targetRef: admin.user_id, targetType: "admin_account", }, now); } } else if (admin.status === "suspended" && recoverySet.has(adminHash)) { this.database.prepare("UPDATE users SET status = 'active' WHERE user_id = ?").run(admin.user_id); this.database.prepare(` DELETE FROM email_challenges WHERE email = ? AND purpose = 'admin_login' `).run(admin.normalized_email); recoveredAdmins += 1; this.recordAdminAudit({ actorRef: "backend_secure_config", actorType: "system", afterSummary: { status: "active" }, beforeSummary: { status: "suspended" }, operationType: "admin_recover", result: "succeeded", targetRef: admin.user_id, targetType: "admin_account", }, now); } } this.recordAdminAudit({ actorRef: "backend_secure_config", actorType: "system", afterSummary: { allowlist_count: allowlist.length, recovered_admins: recoveredAdmins, revoked_sessions: revokedSessions }, beforeSummary: { allowlist_count: this.readAppliedAllowlistCount(), revision: appliedRevision }, operationType: "secure_config_apply", result: "succeeded", targetRef: `revision:${candidate.secureConfigRevision}`, targetType: "secure_config_revision", }, now); this.database.prepare(` INSERT INTO secure_config_apply_state (singleton, applied_revision, allowlist_count, applied_at) VALUES (1, ?, ?, ?) ON CONFLICT(singleton) DO UPDATE SET applied_revision = excluded.applied_revision, allowlist_count = excluded.allowlist_count, applied_at = excluded.applied_at `).run(candidate.secureConfigRevision, allowlist.length, now); return { outcome: "committed", value: { appliedRevision: candidate.secureConfigRevision, status: "applied" as const }, }; }); this.adminAllowlistHashes = allowlistSet; return result; } catch (error) { const reason = error instanceof Error ? error.message : "secure_config_apply_failed"; this.recordConfigApplyFailure(reason, now); throw error; } } async sendAdminLoginCode(input: { clientKey: string; email: string }): Promise { const email = normalizeEmail(input.email); const clientKey = normalizeProfileValue(input.clientKey, 160); const now = this.options.clock(); const challengeId = randomUUID(); const code = this.options.codeGenerator(); if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); const outcome = this.runImmediate("admin_login_send", () => { if (!this.isAdminAllowlisted(email)) { this.recordAdminLoginRejection("not_allowed", now); return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") }; } const user = this.database.prepare(` SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id WHERE u.normalized_email = ? AND u.status <> 'deleted' `).get(email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; if (user?.status === "suspended") { this.recordAdminLoginRejection("suspended", now); return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended") }; } if (user && (user.role !== "super_admin" || user.allowed !== 1)) { this.recordAdminLoginRejection("not_allowed", now); return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") }; } this.assertChallengeSendAllowed(email, "admin_login", clientKey, now); this.recordRateSend(email, clientKey, now); this.database.prepare(` INSERT INTO email_challenges ( challenge_id, email, invite_id, code_hmac, purpose, expires_at, resend_available_at, failure_count, consumed_at, created_at ) VALUES (?, ?, NULL, ?, 'admin_login', ?, ?, 0, NULL, ?) `).run( challengeId, email, this.challengeHmac(challengeId, code), now + challengeLifetimeMilliseconds, now + resendDelayMilliseconds, now, ); return { outcome: "committed", value: { challengeExpiresAt: now + challengeLifetimeMilliseconds, registrationId: challengeId, resendAvailableAt: now + resendDelayMilliseconds, status: "verification_sent" as const, }, }; }); if (outcome instanceof RegistrationError) throw outcome; try { await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" }); } catch { this.runImmediate("registration_send_compensation", () => { this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); this.recordAdminLoginRejection("service_unavailable", now); return { outcome: "committed", value: undefined }; }); throw new Error("AUTH_SERVICE_UNAVAILABLE"); } return outcome; } completeAdminLogin(input: LoginCompleteInput): AdminLoginCompleteResult { if (!/^[0-9]{6}$/.test(input.code)) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); const clientKey = normalizeProfileValue(input.clientKey, 160); if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); } const now = this.options.clock(); const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `admin-login-idempotency:${input.idempotencyKey}`); const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ clientKey, code: input.code, registrationId: input.registrationId, })); const outcome = this.runImmediate("admin_login_complete", () => { const previous = this.database.prepare(` SELECT request_hash, outcome_code, failure_reason, user_id, session_id FROM login_attempts WHERE idempotency_key_digest = ? `).get(idempotencyDigest) as LoginAttemptRow | undefined; if (previous) { if (!constantTimeTextEqual(previous.request_hash, requestHash)) { throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); } if (previous.outcome_code === "failure") { return { outcome: "idempotent_replay", value: new RegistrationError("AUTH_ENTRY_REJECTED", previous.failure_reason ?? "challenge_invalid"), }; } return { outcome: "idempotent_replay", value: this.readAdminLoginResult(previous.user_id!, previous.session_id!), }; } const challenge = this.database.prepare(` SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at FROM email_challenges WHERE challenge_id = ? AND purpose = 'admin_login' `).get(input.registrationId) as ChallengeRow | undefined; if (!challenge || challenge.consumed_at !== null) { return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); } if (!this.isAdminAllowlisted(challenge.email)) { return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now); } const rate = this.readRateLimit(challenge.email, clientKey, now); if (rate.blocked_until !== null && rate.blocked_until > now) { return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "too_many_attempts", now); } if (challenge.expires_at <= now) { return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); } if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { this.database.prepare("UPDATE email_challenges SET failure_count = failure_count + 1 WHERE challenge_id = ?") .run(challenge.challenge_id); const failedAttempts = this.recordRateFailure(challenge.email, clientKey, now); return this.recordAdminLoginFailure( idempotencyDigest, requestHash, input.registrationId, failedAttempts >= maximumFailedAttempts ? "too_many_attempts" : "challenge_invalid", now, ); } let user = this.database.prepare(` SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id WHERE u.normalized_email = ? AND u.status <> 'deleted' `).get(challenge.email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; if (user && (user.role !== "super_admin" || user.status !== "active" || user.allowed !== 1)) { return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now); } if (!user) { const userId = randomUUID(); this.database.prepare(` INSERT INTO users ( user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at ) VALUES (?, ?, 'super_admin', 'active', 0, ?, ?) `).run(userId, challenge.email, challenge.challenge_id, now); this.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); user = { allowed: 1, role: "super_admin", status: "active", user_id: userId }; this.recordAdminAudit({ actorRef: userId, actorType: "super_admin", afterSummary: { role: "super_admin", status: "active" }, beforeSummary: null, operationType: "admin_create", result: "succeeded", targetRef: userId, targetType: "admin_account", }, now); } this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") .run(now, challenge.challenge_id); const issued = this.insertSession(user.user_id, "admin", now); this.database.prepare(` INSERT INTO login_attempts ( idempotency_key_digest, request_hash, challenge_id, outcome_code, failure_reason, user_id, session_id, created_at ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) `).run(idempotencyDigest, requestHash, input.registrationId, user.user_id, issued.sessionId, now); this.recordAdminAudit({ actorRef: user.user_id, actorType: "super_admin", afterSummary: { audience: "admin" }, beforeSummary: null, operationType: "admin_login", result: "succeeded", targetRef: user.user_id, targetType: "admin_session", }, now); return { outcome: "committed", value: this.readAdminLoginResult(user.user_id, issued.sessionId), }; }); if (outcome instanceof RegistrationError) throw outcome; return outcome; } issueAuthenticatedSession(userId: string, audience: "user" | "admin") { const now = this.options.clock(); return this.runImmediate("session_issue", () => { const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?") .get(userId) as { role: "user" | "super_admin"; status: "active" | "suspended" | "deleted" } | undefined; const role = audience === "user" ? "user" : "super_admin"; if (!user || user.status !== "active" || user.role !== role) { throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); } if (audience === "admin") { const access = this.database.prepare("SELECT allowed FROM admin_access WHERE user_id = ?") .get(userId) as { allowed: 0 | 1 } | undefined; if (access?.allowed !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); } const issued = this.insertSession(userId, audience, now); return { outcome: "committed", value: { sessionExpiresAt: issued.sessionExpiresAt, sessionId: issued.sessionId, sessionToken: issued.sessionToken, }, }; }); } issueUserCsrfToken(sessionToken: string) { const now = this.options.clock(); const csrfToken = randomBytes(32).toString("base64url"); return this.runImmediate("csrf_issue", () => { const session = this.database.prepare(` SELECT s.session_id 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 { session_id: string } | undefined; if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?") .run(digest(csrfToken), session.session_id); return { outcome: "committed", value: csrfToken }; }); } issueAdminCsrfToken(sessionToken: string) { const now = this.options.clock(); const csrfToken = randomBytes(32).toString("base64url"); return this.runImmediate("csrf_issue", () => { const session = this.database.prepare(` SELECT s.session_id FROM sessions s JOIN users u ON u.user_id = s.user_id JOIN admin_access a ON a.user_id = u.user_id WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 `).get(digest(sessionToken), now) as { session_id: string } | undefined; if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?") .run(digest(csrfToken), session.session_id); return { outcome: "committed", value: csrfToken }; }); } logoutUser(input: { csrfToken: string; sessionToken: string }) { const now = this.options.clock(); this.runImmediate("session_revoke", () => { const session = this.database.prepare(` SELECT session_id, user_id, csrf_token_digest FROM sessions WHERE token_digest = ? AND audience = 'user' AND revoked_at IS NULL AND expires_at > ? `).get(digest(input.sessionToken), now) as { csrf_token_digest: string | null; session_id: string; user_id: string } | undefined; if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); const csrfDigest = digest(input.csrfToken); if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, csrfDigest)) { throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid"); } this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") .run(now, session.user_id); return { outcome: "committed", value: undefined }; }); } 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; this.privacyPurgeSubject = session.user_id; 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.privacyPurgeSubject = ""; } 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", () => { const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'") .run(status, userId); if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") .run(now, userId); return { outcome: "committed", value: undefined }; }); } revokeAdminSessions(userId: string, reason: "logout" | "disabled" | "whitelist_removed") { const now = this.options.clock(); this.runImmediate("session_revoke", () => { const user = this.database.prepare("SELECT role FROM users WHERE user_id = ?").get(userId) as { role: string } | undefined; if (user?.role !== "super_admin") throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); if (reason === "disabled") this.database.prepare("UPDATE users SET status = 'suspended' WHERE user_id = ?").run(userId); if (reason === "whitelist_removed") this.database.prepare("UPDATE admin_access SET allowed = 0 WHERE user_id = ?").run(userId); this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL") .run(now, userId); this.recordAdminAudit({ actorRef: reason === "whitelist_removed" ? "backend_secure_config" : userId, actorType: reason === "whitelist_removed" ? "system" : "super_admin", afterSummary: { access: reason === "whitelist_removed" ? "removed" : reason }, beforeSummary: { access: "active" }, operationType: reason === "disabled" ? "admin_disable" : reason === "logout" ? "admin_logout" : "admin_allowlist_remove", result: "succeeded", targetRef: userId, targetType: "admin_account", }, now); return { outcome: "committed", value: undefined }; }); } readAdminSession(token: string) { const now = this.options.clock(); return this.database.prepare(` SELECT s.expires_at, u.user_id FROM sessions s JOIN users u ON u.user_id = s.user_id JOIN admin_access a ON a.user_id = u.user_id WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 `).get(digest(token), now) as { expires_at: number; user_id: string } | undefined; } readUserSession(token: string): UserSessionResult | undefined { const now = this.options.clock(); const row = this.database.prepare(` SELECT s.expires_at, u.user_id, p.creator_name, p.social_id, c.available_balance, c.reserved_balance FROM sessions s JOIN users u ON u.user_id = s.user_id JOIN user_profiles p ON p.user_id = u.user_id JOIN credit_accounts c ON c.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(token), now) as Omit | undefined; if (!row) return undefined; return { audience: "user", credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance }, expiresAt: row.expires_at, user: { creatorName: row.creator_name, role: "user", socialId: row.social_id, status: "active", userId: row.user_id, }, userId: row.user_id, }; } private migrate() { this.database.exec(` CREATE TABLE IF NOT EXISTS users ( user_id TEXT PRIMARY KEY, normalized_email TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('user', 'super_admin')), status TEXT NOT NULL CHECK (status IN ('active', 'suspended', 'deleted')), counts_toward_stage_limit INTEGER NOT NULL CHECK (counts_toward_stage_limit IN (0, 1)), registration_id TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS users_current_email_unique ON users(normalized_email) WHERE status <> 'deleted'; CREATE TABLE IF NOT EXISTS user_profiles ( user_id TEXT PRIMARY KEY REFERENCES users(user_id), creator_name TEXT NOT NULL, social_id TEXT NOT NULL, private_content_notice_version TEXT, private_content_notice_acknowledged_at INTEGER ); CREATE TABLE IF NOT EXISTS credit_accounts ( user_id TEXT PRIMARY KEY REFERENCES users(user_id), available_balance INTEGER NOT NULL, reserved_balance INTEGER NOT NULL CHECK (reserved_balance >= 0), updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS credit_ledger ( ledger_id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id), operation_key TEXT NOT NULL UNIQUE, entry_type TEXT NOT NULL CHECK (entry_type IN ( 'registration_grant', 'generation_reserve', 'generation_commit', 'generation_release', 'admin_adjustment' )), amount INTEGER NOT NULL, available_before INTEGER NOT NULL, available_after INTEGER NOT NULL, reserved_before INTEGER NOT NULL CHECK (reserved_before >= 0), reserved_after INTEGER NOT NULL CHECK (reserved_after >= 0), created_at INTEGER NOT NULL ); CREATE TRIGGER IF NOT EXISTS credit_ledger_no_update BEFORE UPDATE 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), notice_version TEXT NOT NULL, consented_at INTEGER NOT NULL, UNIQUE(user_id, notice_version) ); CREATE TABLE IF NOT EXISTS invite_codes ( invite_id TEXT PRIMARY KEY, code_hmac TEXT NOT NULL UNIQUE, max_uses INTEGER NOT NULL CHECK (max_uses > 0), used_count INTEGER NOT NULL CHECK (used_count >= 0 AND used_count <= max_uses), expires_at INTEGER NOT NULL, status TEXT NOT NULL CHECK (status IN ('enabled', 'disabled')), created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS email_challenges ( challenge_id TEXT PRIMARY KEY, email TEXT NOT NULL, invite_id TEXT REFERENCES invite_codes(invite_id), code_hmac TEXT NOT NULL, purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'admin_login')), 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 sessions ( session_id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id), audience TEXT NOT NULL CHECK (audience IN ('user', 'admin')), token_digest TEXT NOT NULL UNIQUE, csrf_token_digest TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER ); CREATE TABLE IF NOT EXISTS registration_attempts ( idempotency_key_digest TEXT PRIMARY KEY, request_hash TEXT NOT NULL, registration_id TEXT NOT NULL, outcome_code TEXT NOT NULL CHECK (outcome_code IN ('success', 'failure')), failure_reason TEXT, user_id TEXT REFERENCES users(user_id), session_id TEXT REFERENCES sessions(session_id), created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS admin_access ( user_id TEXT PRIMARY KEY REFERENCES users(user_id), allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)) ); CREATE TABLE IF NOT EXISTS secure_config_apply_state ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), applied_revision INTEGER NOT NULL CHECK (applied_revision >= 0), allowlist_count INTEGER NOT NULL CHECK (allowlist_count >= 0), applied_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS admin_operation_logs ( log_id TEXT PRIMARY KEY, actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), actor_ref TEXT NOT NULL, operation_type TEXT NOT NULL, target_type TEXT NOT NULL, target_ref TEXT NOT NULL, result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), before_summary TEXT, after_summary TEXT, occurred_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; 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; CREATE TABLE IF NOT EXISTS auth_rate_limits ( rate_key TEXT PRIMARY KEY, window_started_at INTEGER NOT NULL, send_count INTEGER NOT NULL CHECK (send_count >= 0), failed_attempts INTEGER NOT NULL CHECK (failed_attempts >= 0), blocked_until INTEGER ); CREATE TABLE IF NOT EXISTS login_attempts ( idempotency_key_digest TEXT PRIMARY KEY, request_hash TEXT NOT NULL, challenge_id TEXT NOT NULL, outcome_code TEXT NOT NULL CHECK (outcome_code IN ('success', 'failure')), failure_reason TEXT, user_id TEXT REFERENCES users(user_id), 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); `); ensureAdminOperationAuditSchema(this.database, this.options.clock()); ensurePrivateAccessAuditSchema(this.database); } private runImmediate( operation: RegistrationTransactionEvent["operation"], action: () => ImmediateResult, ): T { this.database.exec("BEGIN IMMEDIATE"); try { const result = action(); this.database.exec("COMMIT"); this.options.onTransaction?.({ mode: "BEGIN IMMEDIATE", operation, outcome: result.outcome }); return result.value; } catch (error) { if (this.database.inTransaction) this.database.exec("ROLLBACK"); this.options.onTransaction?.({ mode: "BEGIN IMMEDIATE", operation, outcome: "rejected" }); throw error; } } 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"); } private inviteHmac(code: string) { return this.keyedHmac(this.options.invitePepper, code.trim()); } private challengeHmac(challengeId: string, code: string) { return this.keyedHmac(this.options.challengePepper, `${challengeId}:${code}`); } private adminAllowlistHmac(email: string) { if (!this.options.adminAllowlistPepper) throw new Error("admin_pepper_not_configured"); return createHmac("sha256", this.options.adminAllowlistPepper) .update(email.trim().toLowerCase(), "utf8") .digest("hex") .toUpperCase(); } private isAdminAllowlisted(email: string) { return Boolean(this.options.adminAllowlistPepper) && this.adminAllowlistHashes.has(this.adminAllowlistHmac(email)); } private sessionToken(sessionId: string) { return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url"); } private rateKey(email: string, clientKey: string) { return this.keyedHmac(this.options.challengePepper, `rate:${email}:${clientKey}`); } private readRateLimit(email: string, clientKey: string, now: number): RateLimitRow { const row = this.database.prepare(` SELECT window_started_at, send_count, failed_attempts, blocked_until FROM auth_rate_limits WHERE rate_key = ? `).get(this.rateKey(email, clientKey)) as RateLimitRow | undefined; if (!row || now - row.window_started_at >= rateWindowMilliseconds) { return { blocked_until: null, failed_attempts: 0, send_count: 0, window_started_at: now }; } return row; } private recordRateSend(email: string, clientKey: string, now: number) { const key = this.rateKey(email, clientKey); const row = this.readRateLimit(email, clientKey, now); if (row.blocked_until !== null && row.blocked_until > now) { throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); } if (row.send_count >= maximumSendsPerWindow) { throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); } this.database.prepare(` INSERT INTO auth_rate_limits ( rate_key, window_started_at, send_count, failed_attempts, blocked_until ) VALUES (?, ?, 1, 0, NULL) ON CONFLICT(rate_key) DO UPDATE SET window_started_at = excluded.window_started_at, send_count = ?, failed_attempts = ?, blocked_until = ? `).run( key, row.window_started_at, row.send_count + 1, row.failed_attempts, row.blocked_until, ); } private recordRateFailure(email: string, clientKey: string, now: number) { const key = this.rateKey(email, clientKey); const row = this.readRateLimit(email, clientKey, now); const failedAttempts = row.failed_attempts + 1; const blockedUntil = failedAttempts >= maximumFailedAttempts ? now + rateBlockMilliseconds : row.blocked_until; this.database.prepare(` INSERT INTO auth_rate_limits ( rate_key, window_started_at, send_count, failed_attempts, blocked_until ) VALUES (?, ?, 0, ?, ?) ON CONFLICT(rate_key) DO UPDATE SET window_started_at = excluded.window_started_at, send_count = ?, failed_attempts = excluded.failed_attempts, blocked_until = excluded.blocked_until `).run(key, row.window_started_at, failedAttempts, blockedUntil, row.send_count); return failedAttempts; } private assertChallengeSendAllowed( email: string, purpose: "register" | "login" | "admin_login", clientKey: string, now: number, ) { const latest = this.database.prepare(` SELECT resend_available_at FROM email_challenges WHERE email = ? AND purpose = ? ORDER BY created_at DESC LIMIT 1 `).get(email, purpose) as { resend_available_at: number } | undefined; if (latest && latest.resend_available_at > now) { throw new RegistrationError("AUTH_RATE_LIMITED", "resend_too_soon"); } const rate = this.readRateLimit(email, clientKey, now); if ((rate.blocked_until !== null && rate.blocked_until > now) || rate.send_count >= maximumSendsPerWindow) { throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); } } private insertSession(userId: string, audience: "user" | "admin", now: number) { const sessionId = randomUUID(); const sessionToken = this.sessionToken(sessionId); const sessionExpiresAt = now + sessionLifetimeMilliseconds; this.database.prepare(` INSERT INTO sessions ( session_id, user_id, audience, token_digest, csrf_token_digest, created_at, expires_at, revoked_at ) VALUES (?, ?, ?, ?, NULL, ?, ?, NULL) `).run(sessionId, userId, audience, digest(sessionToken), now, sessionExpiresAt); return { sessionExpiresAt, sessionId, sessionToken }; } private readAdminLoginResult(userId: string, sessionId: string): AdminLoginCompleteResult { const row = this.database.prepare(` SELECT u.user_id, s.expires_at FROM users u JOIN admin_access a ON a.user_id = u.user_id JOIN sessions s ON s.user_id = u.user_id WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 AND s.session_id = ? AND s.audience = 'admin' `).get(userId, sessionId) as { expires_at: number; user_id: string } | undefined; if (!row) throw new RegistrationError("AUTH_ENTRY_REJECTED", "challenge_invalid"); return { admin: { role: "super_admin", status: "active", userId: row.user_id }, audience: "admin", sessionExpiresAt: row.expires_at, sessionToken: this.sessionToken(sessionId), status: "authenticated", }; } private readAppliedAllowlistCount() { const state = this.database.prepare(` SELECT allowlist_count FROM secure_config_apply_state WHERE singleton = 1 `).get() as { allowlist_count: number } | undefined; return state?.allowlist_count ?? 0; } private recordAdminAudit(input: { actorRef: string; actorType: "system" | "super_admin"; afterSummary: Record | null; beforeSummary: Record | null; operationType: string; result: "succeeded" | "failed"; targetRef: string; targetType: string; }, now: number) { this.database.prepare(` INSERT INTO admin_operation_logs ( log_id, actor_type, actor_ref, operation_type, target_type, target_ref, result, before_summary, after_summary, occurred_at, expires_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( randomUUID(), input.actorType, input.actorRef, input.operationType, input.targetType, input.targetRef, input.result, serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), now, now + 180 * 24 * 60 * 60 * 1_000, ); } private recordAdminLoginRejection(reason: string, now: number) { this.recordAdminAudit({ actorRef: "admin_auth", actorType: "system", afterSummary: { reason }, beforeSummary: null, operationType: "admin_login", result: "failed", targetRef: "admin_login", targetType: "admin_session", }, now); } private recordAdminLoginFailure( idempotencyKeyDigest: string, requestHash: string, challengeId: string, reason: RegistrationErrorReason, now: number, ) { const failure = this.recordLoginFailure(idempotencyKeyDigest, requestHash, challengeId, reason, now); this.recordAdminLoginRejection(reason === "admin_not_allowed" ? "not_allowed" : reason, now); return failure; } private recordConfigApplyFailure(reason: string, now: number) { this.database.exec("BEGIN IMMEDIATE"); try { this.recordAdminAudit({ actorRef: "backend_secure_config", actorType: "system", afterSummary: { reason: this.safeConfigFailureReason(reason) }, beforeSummary: { allowlist_count: this.readAppliedAllowlistCount() }, operationType: "secure_config_apply", result: "failed", targetRef: "candidate_revision", targetType: "secure_config_revision", }, now); this.database.exec("COMMIT"); } catch (error) { if (this.database.inTransaction) this.database.exec("ROLLBACK"); throw new Error("secure_config_failed_audit_unavailable", { cause: error }); } } private safeConfigFailureReason(reason: string) { const allowed = new Set([ "admin_pepper_not_configured", "secure_config_revision_invalid", "admin_allowlist_invalid", "admin_recovery_invalid", "hmac_invalid", "secure_config_revision_out_of_sequence", "identity_conflict", ]); return allowed.has(reason) ? reason : "secure_config_apply_failed"; } private recordLoginFailure( idempotencyKeyDigest: string, requestHash: string, challengeId: string, reason: RegistrationErrorReason, now: number, ): ImmediateResult { this.database.prepare(` INSERT INTO login_attempts ( idempotency_key_digest, request_hash, challenge_id, outcome_code, failure_reason, user_id, session_id, created_at ) VALUES (?, ?, ?, 'failure', ?, NULL, NULL, ?) `).run(idempotencyKeyDigest, requestHash, challengeId, reason, now); const code = reason === "resend_too_soon" || reason === "too_many_attempts" ? "AUTH_RATE_LIMITED" : "AUTH_ENTRY_REJECTED"; return { outcome: "rejected", value: new RegistrationError(code, reason) }; } private stageIsFull() { const row = this.database.prepare(` SELECT COUNT(*) AS count FROM users WHERE role = 'user' AND counts_toward_stage_limit = 1 AND status IN ('active', 'suspended') `).get() as { count: number }; return row.count >= stageLimit; } private assertStageCapacity() { if (this.stageIsFull()) throw new RegistrationError("REGISTRATION_REJECTED", "stage_limit_reached"); } private inviteUnavailableReason(invite: InviteRow | undefined, now: number): RegistrationErrorReason | undefined { if (!invite) return "invite_not_found"; if (invite.status !== "enabled") return "invite_disabled"; if (invite.expires_at <= now) return "invite_expired"; if (invite.used_count >= invite.max_uses) return "invite_exhausted"; return undefined; } private assertInviteAvailable(invite: InviteRow | undefined, now: number): asserts invite is InviteRow { const reason = this.inviteUnavailableReason(invite, now); if (reason) throw new RegistrationError("REGISTRATION_REJECTED", reason); } private recordRejectedAttempt( idempotencyKeyDigest: string, requestHash: string, registrationId: string, reason: RegistrationErrorReason, now: number, ): ImmediateResult { this.database.prepare(` INSERT INTO registration_attempts ( idempotency_key_digest, request_hash, registration_id, outcome_code, failure_reason, user_id, session_id, created_at ) VALUES (?, ?, ?, 'failure', ?, NULL, NULL, ?) `).run(idempotencyKeyDigest, requestHash, registrationId, reason, now); return { outcome: "rejected", value: new RegistrationError("REGISTRATION_REJECTED", reason) }; } private readCompletedRegistration(userId: string, sessionId: string) { const row = this.database.prepare(` SELECT u.user_id, p.creator_name, p.social_id, c.available_balance, c.reserved_balance, s.session_id, s.expires_at FROM users u JOIN user_profiles p ON p.user_id = u.user_id JOIN credit_accounts c ON c.user_id = u.user_id JOIN sessions s ON s.user_id = u.user_id WHERE u.user_id = ? AND s.session_id = ? `).get(userId, sessionId) as UserResultRow | undefined; if (!row) throw new RegistrationError("REGISTRATION_REJECTED", "challenge_invalid"); return this.result(row); } private loginResult(result: RegistrationCompleteResult): LoginCompleteResult { return { audience: "user", credits: result.credits, sessionExpiresAt: result.sessionExpiresAt, sessionToken: result.sessionToken, status: "authenticated", user: result.user, }; } private result(row: UserResultRow): RegistrationCompleteResult { return { credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance }, sessionExpiresAt: row.expires_at, sessionToken: this.sessionToken(row.session_id), status: "registered", user: { creatorName: row.creator_name, role: "user", socialId: row.social_id, status: "active", userId: row.user_id, }, }; } }