feat: implement TASK-WP1-01 registration transaction

This commit is contained in:
suyx
2026-07-28 15:54:28 +08:00
parent 1bf7e42294
commit f467e7c09f
15 changed files with 2191 additions and 4 deletions
+196
View File
@@ -5,17 +5,28 @@ import { resolve } from "node:path";
import {
BootstrapResponseSchema,
CorrelationIdSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
GenerationErrorCategorySchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteResponseSchema,
RegistrationSendRequestSchema,
RegistrationSendResponseSchema,
SseEventSchema,
StableEngineeringErrorCodeSchema,
StateSseEventSchema,
Type,
UserSessionResponseSchema,
createErrorEnvelope,
isCorrelationId,
type BootstrapResponse,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
import swagger from "@fastify/swagger";
import Fastify, { type FastifyReply } from "fastify";
@@ -36,6 +47,11 @@ import {
import { EventHub } from "./event-hub.js";
import type { PublicAssetResolver } from "./local-data-root.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
import {
RegistrationError,
registrationFieldError,
} from "./registration-errors.js";
import type { RegistrationService } from "./registration.js";
const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0",
@@ -57,6 +73,7 @@ export interface CreateAppOptions {
eventHub?: EventHub;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
registration?: RegistrationService;
}
const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate");
@@ -86,6 +103,39 @@ function headerValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function cookieValue(cookieHeader: string | undefined, name: string) {
if (!cookieHeader) return undefined;
for (const item of cookieHeader.split(";")) {
const separator = item.indexOf("=");
if (separator < 0) continue;
if (item.slice(0, separator).trim() === name) return item.slice(separator + 1).trim();
}
return undefined;
}
function registrationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (error instanceof RegistrationError) {
return reply.code(error.httpStatus).send(
createErrorEnvelope({
code: error.code,
correlationId,
details: { field_errors: [registrationFieldError(error.reason)] },
}),
);
}
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
function registrationValidationFailure(reply: FastifyReply, correlationId: string) {
return reply.code(400).send(
createErrorEnvelope({
code: "REGISTRATION_REQUEST_INVALID",
correlationId,
details: { field_errors: [{ field: "request", message_key: "auth.registration.request_invalid" }] },
}),
);
}
function isSupportGateRequest(method: string, path: string) {
if (method === "POST" && path === "/api/v1/support/check") return true;
if (method !== "GET" && method !== "HEAD") return false;
@@ -151,6 +201,14 @@ export async function createApp(options: CreateAppOptions = {}) {
StableEngineeringErrorCodeSchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
RegistrationSendRequestSchema,
RegistrationSendResponseSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteResponseSchema,
UserSessionResponseSchema,
BrowserUnsupportedReasonSchema,
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
@@ -230,6 +288,144 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.post(
"/api/v1/auth/register/send",
{
attachValidation: true,
schema: {
body: Type.Ref(RegistrationSendRequestSchema),
operationId: "sendRegistrationCode",
response: {
200: Type.Ref(RegistrationSendResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
try {
const body = request.body as RegistrationSendRequest;
const result = await options.registration.sendRegistrationCode({ email: body.email, inviteCode: body.invite_code });
return {
challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(),
registration_id: result.registrationId,
resend_available_at: new Date(result.resendAvailableAt).toISOString(),
status: result.status,
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/auth/register/complete",
{
attachValidation: true,
schema: {
body: Type.Ref(RegistrationCompleteRequestSchema),
headers: Type.Ref(RegistrationCompleteHeadersSchema),
operationId: "completeRegistration",
response: {
200: Type.Ref(RegistrationCompleteResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
try {
const body = request.body as RegistrationCompleteRequest;
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
if (!idempotencyKey) return registrationValidationFailure(reply, request.id);
const result = options.registration.completeRegistration({
code: body.verification_code,
creatorName: body.creator_name,
idempotencyKey,
privacyConsentAccepted: body.privacy_consent_accepted,
privacyNoticeVersion: body.privacy_notice_version,
registrationId: body.registration_id,
socialId: body.social_id,
});
reply.header(
"Set-Cookie",
`dada_session=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
credits: {
available_balance: result.credits.availableBalance,
reserved_balance: result.credits.reservedBalance,
},
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
status: result.status,
user: {
creator_name: result.user.creatorName,
role: result.user.role,
social_id: result.user.socialId,
status: result.user.status,
user_id: result.user.userId,
},
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/auth/session",
{
schema: {
operationId: "getUserSession",
response: {
200: Type.Ref(UserSessionResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), "dada_session");
const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
}
return {
audience: session.audience,
authenticated: true as const,
credits: {
available_balance: session.credits.availableBalance,
reserved_balance: session.credits.reservedBalance,
},
csrf_token: randomBytes(32).toString("base64url"),
expires_at: new Date(session.expiresAt).toISOString(),
user: {
creator_name: session.user.creatorName,
role: session.user.role,
social_id: session.user.socialId,
status: session.user.status,
user_id: session.user.userId,
},
};
},
);
app.post(
"/api/v1/support/check",
{
+49
View File
@@ -0,0 +1,49 @@
export type RegistrationErrorReason =
| "invite_not_found"
| "invite_expired"
| "invite_disabled"
| "invite_exhausted"
| "stage_limit_reached"
| "email_already_registered"
| "challenge_invalid"
| "challenge_expired"
| "privacy_consent_required"
| "privacy_notice_version_invalid"
| "profile_invalid"
| "idempotency_conflict";
export type RegistrationErrorCode =
| "REGISTRATION_REJECTED"
| "REGISTRATION_REQUEST_INVALID"
| "IDEMPOTENCY_KEY_CONFLICT";
export class RegistrationError extends Error {
readonly code: RegistrationErrorCode;
readonly httpStatus: 400 | 409;
readonly reason: RegistrationErrorReason;
constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) {
super(code);
this.code = code;
this.httpStatus = code === "REGISTRATION_REQUEST_INVALID" ? 400 : 409;
this.reason = reason;
}
}
export function registrationFieldError(reason: RegistrationErrorReason) {
const entries: Record<RegistrationErrorReason, { field: string; message_key: string }> = {
challenge_expired: { field: "verification_code", message_key: "auth.challenge.expired" },
challenge_invalid: { field: "verification_code", message_key: "auth.challenge.invalid" },
email_already_registered: { field: "email", message_key: "auth.email.already_registered" },
idempotency_conflict: { field: "idempotency_key", message_key: "request.idempotency_conflict" },
invite_disabled: { field: "invite_code", message_key: "auth.invite.disabled" },
invite_exhausted: { field: "invite_code", message_key: "auth.invite.exhausted" },
invite_expired: { field: "invite_code", message_key: "auth.invite.expired" },
invite_not_found: { field: "invite_code", message_key: "auth.invite.not_found" },
privacy_consent_required: { field: "privacy_consent_accepted", message_key: "auth.privacy.consent_required" },
privacy_notice_version_invalid: { field: "privacy_notice_version", message_key: "auth.privacy.notice_version_invalid" },
profile_invalid: { field: "profile", message_key: "auth.profile.invalid" },
stage_limit_reached: { field: "invite_code", message_key: "auth.registration.stage_limit_reached" },
};
return entries[reason];
}
+623
View File
@@ -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,
},
};
}
}
+25
View File
@@ -0,0 +1,25 @@
export interface RegistrationCodeMessage {
challengeId: string;
code: string;
email: string;
purpose: "register";
}
export interface ResendAdapter {
sendRegistrationCode(message: RegistrationCodeMessage): Promise<void>;
}
export class MockResendAdapter implements ResendAdapter {
readonly calls: RegistrationCodeMessage[] = [];
async sendRegistrationCode(message: RegistrationCodeMessage) {
this.calls.push({ ...message });
}
readLatestCode(email: string) {
const normalizedEmail = email.trim().toLowerCase();
const call = this.calls.findLast((candidate) => candidate.email === normalizedEmail);
if (!call) throw new Error("No mock registration message exists for that email.");
return call.code;
}
}
+27
View File
@@ -1,5 +1,7 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, RegistrationSendResponse, RegistrationSendRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
export async function checkBrowserSupport(body: {
@@ -43,6 +45,15 @@ export async function checkBrowserSupport(body: {
}>;
}
export async function completeRegistration(body: RegistrationCompleteRequest, options: ClientOptions = {}): Promise<RegistrationCompleteResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/register/complete`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<RegistrationCompleteResponse>;
}
export async function getBootstrap(options: ClientOptions = {}): Promise<{
"app_version": string;
"dependencies": Array<{
@@ -85,3 +96,19 @@ export async function getBootstrap(options: ClientOptions = {}): Promise<{
export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string {
return `${options.baseUrl ?? ""}/api/v1/events`;
}
export async function getUserSession(options: ClientOptions = {}): Promise<UserSessionResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/session`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<UserSessionResponse>;
}
export async function sendRegistrationCode(body: RegistrationSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/register/send`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<RegistrationSendResponse>;
}
+56 -2
View File
@@ -1,5 +1,13 @@
// Generated from openapi/openapi.json. Do not edit by hand.
export type AuthenticatedUser = {
"creator_name": string;
"role": "user";
"social_id": string;
"status": "active";
"user_id": string;
};
export type BootstrapResponse = {
"app_version": string;
"dependencies": Array<{
@@ -47,6 +55,11 @@ export type BrowserUnsupportedReason = "platform_unsupported" | "brand_unsupport
export type CorrelationId = string;
export type CreditSummary = {
"available_balance": number;
"reserved_balance": number;
};
export type ErrorDetails = {
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
"current_task_ref"?: string;
@@ -65,7 +78,7 @@ export type ErrorDetails = {
export type ErrorEnvelope = {
"error": {
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE";
"correlation_id": string;
"details": {
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
@@ -105,6 +118,38 @@ export type ModelRuntimeSseEvent = {
"runtime_availability_version": number;
};
export type RegistrationCompleteHeaders = {
"idempotency-key": string;
};
export type RegistrationCompleteRequest = {
"creator_name": string;
"privacy_consent_accepted": boolean;
"privacy_notice_version": string;
"registration_id": string;
"social_id": string;
"verification_code": string;
};
export type RegistrationCompleteResponse = {
"credits": CreditSummary;
"session_expires_at": string;
"status": "registered";
"user": AuthenticatedUser;
};
export type RegistrationSendRequest = {
"email": string;
"invite_code": string;
};
export type RegistrationSendResponse = {
"challenge_expires_at": string;
"registration_id": string;
"resend_available_at": string;
"status": "verification_sent";
};
export type SseEvent = {
"entity_ref": string;
"event_id": number;
@@ -125,7 +170,7 @@ export type SseEvent = {
"runtime_availability_version": number;
};
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE";
export type StateSseEvent = {
"entity_ref": string;
@@ -134,3 +179,12 @@ export type StateSseEvent = {
"occurred_at": string;
"state_version": number;
};
export type UserSessionResponse = {
"audience": "user";
"authenticated": true;
"credits": CreditSummary;
"csrf_token": string;
"expires_at": string;
"user": AuthenticatedUser;
};