feat: implement TASK-WP1-01 registration transaction
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
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;
|
||||
|
||||
export interface RegistrationTransactionEvent {
|
||||
mode: "BEGIN IMMEDIATE";
|
||||
operation: "invite_create" | "registration_send" | "registration_complete" | "registration_send_compensation";
|
||||
outcome: "committed" | "rejected" | "idempotent_replay";
|
||||
}
|
||||
|
||||
interface RegistrationServiceOptions {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
constructor(options: RegistrationServiceOptions) {
|
||||
assertSecret("invitePepper", options.invitePepper);
|
||||
assertSecret("challengePepper", options.challengePepper);
|
||||
assertSecret("sessionPepper", options.sessionPepper);
|
||||
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.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 FROM users
|
||||
WHERE normalized_email = ? AND status <> 'deleted'
|
||||
`).get(email);
|
||||
if (existing) throw new RegistrationError("REGISTRATION_REJECTED", "email_already_registered");
|
||||
|
||||
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.sendRegistrationCode({ 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
CREATE TRIGGER IF NOT EXISTS credit_ledger_no_delete
|
||||
BEFORE DELETE ON credit_ledger 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 NOT NULL 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,
|
||||
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
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
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 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 sessionToken(sessionId: string) {
|
||||
return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url");
|
||||
}
|
||||
|
||||
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 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user