1891 lines
83 KiB
TypeScript
1891 lines
83 KiB
TypeScript
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<RegistrationCompleteResult, "status"> & {
|
|
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<T> {
|
|
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<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
|
|
private adminAllowlistHashes = new Set<string>();
|
|
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<RegistrationSendResult> {
|
|
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<RegistrationSendResult> {
|
|
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<LoginCompleteResult | RegistrationError>("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<RegistrationCompleteResult | RegistrationError>("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<RegistrationSendResult> {
|
|
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<RegistrationSendResult | RegistrationError>("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<AdminLoginCompleteResult | RegistrationError>("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<UserResultRow, "session_id"> | 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<T>(
|
|
operation: RegistrationTransactionEvent["operation"],
|
|
action: () => ImmediateResult<T>,
|
|
): 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<string, unknown> | null;
|
|
beforeSummary: Record<string, unknown> | 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<RegistrationError> {
|
|
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<RegistrationError> {
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
}
|