From f467e7c09f6e4485bcf6d390502e74d4582e20dc Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 28 Jul 2026 15:54:28 +0800 Subject: [PATCH] feat: implement TASK-WP1-01 registration transaction --- apps/api/src/app.ts | 196 ++++++ apps/api/src/registration-errors.ts | 49 ++ apps/api/src/registration.ts | 623 ++++++++++++++++++ apps/api/src/resend-adapter.ts | 25 + apps/web/src/generated/api/sdk.gen.ts | 27 + apps/web/src/generated/api/types.gen.ts | 58 +- openapi/openapi.json | 554 ++++++++++++++++ package.json | 4 +- packages/shared-contracts/src/api.ts | 10 + packages/shared-contracts/src/auth.ts | 89 +++ packages/shared-contracts/src/index.ts | 1 + scripts/run-wp1-01-validation.mjs | 119 ++++ tests/api/wp0-02-schema-envelope.test.ts | 18 +- tests/api/wp1-01-registration.test.ts | 124 ++++ tests/integration/wp1-01-registration.test.ts | 298 +++++++++ 15 files changed, 2191 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/registration-errors.ts create mode 100644 apps/api/src/registration.ts create mode 100644 apps/api/src/resend-adapter.ts create mode 100644 packages/shared-contracts/src/auth.ts create mode 100644 scripts/run-wp1-01-validation.mjs create mode 100644 tests/api/wp1-01-registration.test.ts create mode 100644 tests/integration/wp1-01-registration.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a62f488..16db547 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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", { diff --git a/apps/api/src/registration-errors.ts b/apps/api/src/registration-errors.ts new file mode 100644 index 0000000..6ab6241 --- /dev/null +++ b/apps/api/src/registration-errors.ts @@ -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 = { + 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]; +} diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts new file mode 100644 index 0000000..9e15400 --- /dev/null +++ b/apps/api/src/registration.ts @@ -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 { + outcome: RegistrationTransactionEvent["outcome"]; + value: T; +} + +function assertSecret(name: string, value: Buffer) { + if (value.byteLength < 32) throw new Error(`${name} must contain at least 32 bytes.`); +} + +function normalizeEmail(email: string) { + const normalized = email.trim().toLowerCase(); + if (normalized.length > 320 || !/^[^@\s]{1,128}@[^@\s]{1,190}$/.test(normalized)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); + } + return normalized; +} + +function normalizeProfileValue(value: string, maximumLength: number) { + const normalized = value.trim(); + if (!normalized || normalized.length > maximumLength) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); + } + return normalized; +} + +function normalizeSocialId(value: string) { + const body = normalizeProfileValue(value, 80).replace(/^@+/, ""); + if (!body) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid"); + return `@${body}`; +} + +function digest(value: string) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function constantTimeTextEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left, "hex"); + const rightBuffer = Buffer.from(right, "hex"); + return leftBuffer.byteLength === rightBuffer.byteLength && timingSafeEqual(leftBuffer, rightBuffer); +} + +export class RegistrationService { + readonly database: BetterSqlite3.Database; + readonly options: Required> & RegistrationServiceOptions; + + 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 { + 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("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 | 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( + operation: RegistrationTransactionEvent["operation"], + action: () => ImmediateResult, + ): T { + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = action(); + this.database.exec("COMMIT"); + this.options.onTransaction?.({ mode: "BEGIN IMMEDIATE", operation, outcome: result.outcome }); + return result.value; + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + this.options.onTransaction?.({ mode: "BEGIN IMMEDIATE", operation, outcome: "rejected" }); + throw error; + } + } + + private 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 { + 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, + }, + }; + } +} diff --git a/apps/api/src/resend-adapter.ts b/apps/api/src/resend-adapter.ts new file mode 100644 index 0000000..4cb2bed --- /dev/null +++ b/apps/api/src/resend-adapter.ts @@ -0,0 +1,25 @@ +export interface RegistrationCodeMessage { + challengeId: string; + code: string; + email: string; + purpose: "register"; +} + +export interface ResendAdapter { + sendRegistrationCode(message: RegistrationCodeMessage): Promise; +} + +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; + } +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 2248b03..566d531 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -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 { + 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; +} + 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 = {}): string { return `${options.baseUrl ?? ""}/api/v1/events`; } + +export async function getUserSession(options: ClientOptions = {}): Promise { + 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; +} + +export async function sendRegistrationCode(body: RegistrationSendRequest, options: ClientOptions = {}): Promise { + 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; +} diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 89510eb..0f45b13 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -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; +}; diff --git a/openapi/openapi.json b/openapi/openapi.json index d3d0b11..67d243c 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -1,6 +1,45 @@ { "components": { "schemas": { + "AuthenticatedUser": { + "additionalProperties": false, + "properties": { + "creator_name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "role": { + "enum": [ + "user" + ], + "type": "string" + }, + "social_id": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "status": { + "enum": [ + "active" + ], + "type": "string" + }, + "user_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + } + }, + "required": [ + "creator_name", + "role", + "social_id", + "status", + "user_id" + ], + "type": "object" + }, "BootstrapResponse": { "additionalProperties": false, "properties": { @@ -339,6 +378,23 @@ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", "type": "string" }, + "CreditSummary": { + "additionalProperties": false, + "properties": { + "available_balance": { + "type": "integer" + }, + "reserved_balance": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "available_balance", + "reserved_balance" + ], + "type": "object" + }, "ErrorDetails": { "additionalProperties": false, "properties": { @@ -553,6 +609,36 @@ "STORAGE_CAPACITY_EXCEEDED" ], "type": "string" + }, + { + "enum": [ + "REGISTRATION_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "REGISTRATION_REQUEST_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "IDEMPOTENCY_KEY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SESSION_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SERVICE_UNAVAILABLE" + ], + "type": "string" } ] }, @@ -915,6 +1001,139 @@ ], "type": "object" }, + "RegistrationCompleteHeaders": { + "additionalProperties": true, + "properties": { + "idempotency-key": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + "required": [ + "idempotency-key" + ], + "type": "object" + }, + "RegistrationCompleteRequest": { + "additionalProperties": false, + "properties": { + "creator_name": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "privacy_consent_accepted": { + "type": "boolean" + }, + "privacy_notice_version": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "registration_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "social_id": { + "maxLength": 80, + "minLength": 1, + "type": "string" + }, + "verification_code": { + "pattern": "^[0-9]{6}$", + "type": "string" + } + }, + "required": [ + "creator_name", + "privacy_consent_accepted", + "privacy_notice_version", + "registration_id", + "social_id", + "verification_code" + ], + "type": "object" + }, + "RegistrationCompleteResponse": { + "additionalProperties": false, + "properties": { + "credits": { + "$ref": "#/components/schemas/CreditSummary" + }, + "session_expires_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "status": { + "enum": [ + "registered" + ], + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "credits", + "session_expires_at", + "status", + "user" + ], + "type": "object" + }, + "RegistrationSendRequest": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 320, + "pattern": "^[^@\\s]{1,128}@[^@\\s]{1,190}$", + "type": "string" + }, + "invite_code": { + "maxLength": 160, + "minLength": 8, + "type": "string" + } + }, + "required": [ + "email", + "invite_code" + ], + "type": "object" + }, + "RegistrationSendResponse": { + "additionalProperties": false, + "properties": { + "challenge_expires_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "registration_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "resend_available_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "status": { + "enum": [ + "verification_sent" + ], + "type": "string" + } + }, + "required": [ + "challenge_expires_at", + "registration_id", + "resend_available_at", + "status" + ], + "type": "object" + }, "SseEvent": { "anyOf": [ { @@ -1087,6 +1306,36 @@ "STORAGE_CAPACITY_EXCEEDED" ], "type": "string" + }, + { + "enum": [ + "REGISTRATION_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "REGISTRATION_REQUEST_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "IDEMPOTENCY_KEY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SESSION_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SERVICE_UNAVAILABLE" + ], + "type": "string" } ] }, @@ -1124,6 +1373,48 @@ "state_version" ], "type": "object" + }, + "UserSessionResponse": { + "additionalProperties": false, + "properties": { + "audience": { + "enum": [ + "user" + ], + "type": "string" + }, + "authenticated": { + "enum": [ + true + ], + "type": "boolean" + }, + "credits": { + "$ref": "#/components/schemas/CreditSummary" + }, + "csrf_token": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "expires_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "audience", + "authenticated", + "credits", + "csrf_token", + "expires_at", + "user" + ], + "type": "object" } } }, @@ -1133,6 +1424,179 @@ }, "openapi": "3.1.0", "paths": { + "/api/v1/auth/register/complete": { + "post": { + "operationId": "completeRegistration", + "parameters": [ + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistrationCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistrationCompleteResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/auth/register/send": { + "post": { + "operationId": "sendRegistrationCode", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistrationSendRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistrationSendResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/auth/session": { + "get": { + "operationId": "getUserSession", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSessionResponse" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Authentication" + ] + } + }, "/api/v1/bootstrap": { "get": { "operationId": "getBootstrap", @@ -1375,6 +1839,36 @@ "STORAGE_CAPACITY_EXCEEDED" ], "type": "string" + }, + { + "enum": [ + "REGISTRATION_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "REGISTRATION_REQUEST_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "IDEMPOTENCY_KEY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SESSION_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SERVICE_UNAVAILABLE" + ], + "type": "string" } ] }, @@ -1809,6 +2303,36 @@ "STORAGE_CAPACITY_EXCEEDED" ], "type": "string" + }, + { + "enum": [ + "REGISTRATION_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "REGISTRATION_REQUEST_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "IDEMPOTENCY_KEY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SESSION_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SERVICE_UNAVAILABLE" + ], + "type": "string" } ] }, @@ -2284,6 +2808,36 @@ "STORAGE_CAPACITY_EXCEEDED" ], "type": "string" + }, + { + "enum": [ + "REGISTRATION_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "REGISTRATION_REQUEST_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "IDEMPOTENCY_KEY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SESSION_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_SERVICE_UNAVAILABLE" + ], + "type": "string" } ] }, diff --git a/package.json b/package.json index f2c7bc4..0420146 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "test:wp0-08": "node scripts/run-wp0-08-validation.mjs", "test:wp0-08:red": "node scripts/run-wp0-08-validation.mjs --phase red", "test:wp0-09": "node scripts/run-wp0-09-validation.mjs", - "test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red" + "test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red", + "test:wp1-01": "node scripts/run-wp1-01-validation.mjs", + "test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/api.ts b/packages/shared-contracts/src/api.ts index 28a399b..dab1ed8 100644 --- a/packages/shared-contracts/src/api.ts +++ b/packages/shared-contracts/src/api.ts @@ -14,6 +14,11 @@ export const stableEngineeringErrors = { ASSET_HISTORY_REFERENCE_CONFLICT: { httpStatus: 409, messageKey: "ASSET_HISTORY_REFERENCE_CONFLICT" }, ASSET_CLEANUP_CANDIDATE_STALE: { httpStatus: 409, messageKey: "ASSET_CLEANUP_CANDIDATE_STALE" }, STORAGE_CAPACITY_EXCEEDED: { httpStatus: 507, messageKey: "STORAGE_CAPACITY_EXCEEDED" }, + REGISTRATION_REJECTED: { httpStatus: 409, messageKey: "auth.registration.rejected" }, + REGISTRATION_REQUEST_INVALID: { httpStatus: 400, messageKey: "auth.registration.request_invalid" }, + IDEMPOTENCY_KEY_CONFLICT: { httpStatus: 409, messageKey: "request.idempotency_conflict" }, + AUTH_SESSION_INVALID: { httpStatus: 401, messageKey: "auth.session.invalid" }, + AUTH_SERVICE_UNAVAILABLE: { httpStatus: 503, messageKey: "auth.service.unavailable" }, } as const; export type StableEngineeringErrorCode = keyof typeof stableEngineeringErrors; @@ -45,6 +50,11 @@ export const StableEngineeringErrorCodeSchema = Type.Union( Type.Literal("ASSET_HISTORY_REFERENCE_CONFLICT"), Type.Literal("ASSET_CLEANUP_CANDIDATE_STALE"), Type.Literal("STORAGE_CAPACITY_EXCEEDED"), + Type.Literal("REGISTRATION_REJECTED"), + Type.Literal("REGISTRATION_REQUEST_INVALID"), + Type.Literal("IDEMPOTENCY_KEY_CONFLICT"), + Type.Literal("AUTH_SESSION_INVALID"), + Type.Literal("AUTH_SERVICE_UNAVAILABLE"), ], { $id: "StableEngineeringErrorCode" }, ); diff --git a/packages/shared-contracts/src/auth.ts b/packages/shared-contracts/src/auth.ts new file mode 100644 index 0000000..abe54d4 --- /dev/null +++ b/packages/shared-contracts/src/auth.ts @@ -0,0 +1,89 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"; +const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"; +const emailPattern = "^[^@\\s]{1,128}@[^@\\s]{1,190}$"; + +export const RegistrationSendRequestSchema = Type.Object( + { + email: Type.String({ maxLength: 320, pattern: emailPattern }), + invite_code: Type.String({ maxLength: 160, minLength: 8 }), + }, + { additionalProperties: false, $id: "RegistrationSendRequest" }, +); + +export const RegistrationSendResponseSchema = Type.Object( + { + challenge_expires_at: Type.String({ pattern: isoTimestampPattern }), + registration_id: Type.String({ pattern: uuidPattern }), + resend_available_at: Type.String({ pattern: isoTimestampPattern }), + status: Type.Literal("verification_sent"), + }, + { additionalProperties: false, $id: "RegistrationSendResponse" }, +); + +export const RegistrationCompleteRequestSchema = Type.Object( + { + creator_name: Type.String({ maxLength: 80, minLength: 1 }), + privacy_consent_accepted: Type.Boolean(), + privacy_notice_version: Type.String({ maxLength: 80, minLength: 1 }), + registration_id: Type.String({ pattern: uuidPattern }), + social_id: Type.String({ maxLength: 80, minLength: 1 }), + verification_code: Type.String({ pattern: "^[0-9]{6}$" }), + }, + { additionalProperties: false, $id: "RegistrationCompleteRequest" }, +); + +export const RegistrationCompleteHeadersSchema = Type.Object( + { + "idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }), + }, + { additionalProperties: true, $id: "RegistrationCompleteHeaders" }, +); + +export const AuthenticatedUserSchema = Type.Object( + { + creator_name: Type.String({ maxLength: 80, minLength: 1 }), + role: Type.Literal("user"), + social_id: Type.String({ maxLength: 80, minLength: 1 }), + status: Type.Literal("active"), + user_id: Type.String({ pattern: uuidPattern }), + }, + { additionalProperties: false, $id: "AuthenticatedUser" }, +); + +export const CreditSummarySchema = Type.Object( + { + available_balance: Type.Integer(), + reserved_balance: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false, $id: "CreditSummary" }, +); + +export const RegistrationCompleteResponseSchema = Type.Object( + { + credits: Type.Ref(CreditSummarySchema), + session_expires_at: Type.String({ pattern: isoTimestampPattern }), + status: Type.Literal("registered"), + user: Type.Ref(AuthenticatedUserSchema), + }, + { additionalProperties: false, $id: "RegistrationCompleteResponse" }, +); + +export const UserSessionResponseSchema = Type.Object( + { + audience: Type.Literal("user"), + authenticated: Type.Literal(true), + credits: Type.Ref(CreditSummarySchema), + csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }), + expires_at: Type.String({ pattern: isoTimestampPattern }), + user: Type.Ref(AuthenticatedUserSchema), + }, + { additionalProperties: false, $id: "UserSessionResponse" }, +); + +export type RegistrationSendRequest = Static; +export type RegistrationSendResponse = Static; +export type RegistrationCompleteRequest = Static; +export type RegistrationCompleteResponse = Static; +export type UserSessionResponse = Static; diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index d45cdbc..bd297ca 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -1,4 +1,5 @@ export { Type } from "@sinclair/typebox"; export * from "./api.js"; +export * from "./auth.js"; export * from "./bootstrap.js"; export * from "./events.js"; diff --git a/scripts/run-wp1-01-validation.mjs b/scripts/run-wp1-01-validation.mjs new file mode 100644 index 0000000..3766f65 --- /dev/null +++ b/scripts/run-wp1-01-validation.mjs @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp1-01-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const cases = [ + "TDD-WP1-AUTH-001-valid-register", + "TDD-WP1-AUTH-002-invalid-invite", + "TDD-WP1-AUTH-001-final-recheck-race", +]; +const directories = Object.fromEntries(cases.map((id) => [id, resolve(runDirectory, "cases", id)])); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const directory of Object.values(directories)) mkdirSync(directory, { recursive: true }); + +const commandSpecs = phase === "red" + ? [ + ["pnpm exec vitest run tests/integration/wp1-01-registration.test.ts", ["exec", "vitest", "run", "tests/integration/wp1-01-registration.test.ts"]], + ["pnpm exec vitest run tests/api/wp1-01-registration.test.ts", ["exec", "vitest", "run", "tests/api/wp1-01-registration.test.ts"]], + ] + : [ + ["pnpm test:integration", ["test:integration"]], + ["pnpm test:api", ["test:api"]], + ["pnpm test:e2e", ["test:e2e"]], + ["pnpm validate:tdd-trace", ["validate:tdd-trace"]], + ]; +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_AUTH_INVALID: directories[cases[1]], + DADA_EVIDENCE_DIR_AUTH_RACE: directories[cases[2]], + DADA_EVIDENCE_DIR_AUTH_VALID: directories[cases[0]], +}; +const startedAt = new Date().toISOString(); +const commands = []; +for (const [command, args] of commandSpecs) { + const started_at = new Date().toISOString(); + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm"; + const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", `pnpm ${args.join(" ")}`] : args; + const execution = spawnSync(executable, actualArgs, { encoding: "utf8", env: environment }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commands.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), started_at }); +} + +const commandEvidence = { commands, phase, run_id: runId, schema_version: "1.0" }; +for (const directory of Object.values(directories)) { + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify(commandEvidence, null, 2)}\n`); +} +const expectedEvidence = { + [cases[0]]: ["request.json", "response.json", "db-diff.json"], + [cases[1]]: [ + "expired-response.json", "disabled-response.json", "exhausted-response.json", "not_found-response.json", + "db-diff.json", "external-calls.json", + ], + [cases[2]]: [ + "exhausted-response.json", "disabled-response.json", "expired-response.json", "stage_limit-response.json", + "exhausted-db-diff.json", "disabled-db-diff.json", "expired-db-diff.json", "stage_limit-db-diff.json", + "transaction-trace.json", + ], +}; +const manifest = { + path: "tasks.manifest.json", + sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(), +}; +const commandState = phase === "red" ? commands.every((item) => item.exit_code !== 0) : commands.every((item) => item.exit_code === 0); +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const worktreeDirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const results = cases.map((testId) => { + const evidence_refs = expectedEvidence[testId]; + const missing_evidence = phase === "green" ? evidence_refs.filter((path) => !existsSync(resolve(directories[testId], path))) : []; + const status = phase === "red" + ? (commandState ? "red_confirmed" : "failed") + : (commandState && missing_evidence.length === 0 ? "passed" : "failed"); + const result = { + acceptance_criteria: ["AC-01", "AC-02", "AC-45"], + automation: ["automated"], + commit, + deferred_e2e_evidence: { + reason: "Registration UI ownership begins in TASK-WP1-03; no UI or screenshot is fabricated by TASK-WP1-01.", + required_by_case: ["trace.zip", "screenshots/"], + task_id: "TASK-WP1-03", + }, + environment: { arch: process.arch, node: process.version.slice(1), os: process.platform }, + evidence_refs, + finished_at: new Date().toISOString(), + layer: ["DB", "API", "E2E"], + manifest, + missing_evidence, + parent_family: testId.match(/^(TDD-WP[0-7]-[A-Z0-9]+-[0-9]{3})-/)?.[1], + phase, + release_gate: ["work_package:WP-1", "release:P0-A"], + requirements: ["AUTH-01", "AUTH-02", "AUTH-03", "AUTH-04", "NFR-04"], + run_id: runId, + schema_version: "1.0", + started_at: startedAt, + status, + task_id: "TASK-WP1-01", + test_id: testId, + work_package: "WP-1", + worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation", + }; + writeFileSync(resolve(directories[testId], "result.json"), `${JSON.stringify(result, null, 2)}\n`); + return result; +}); +const targetStatus = phase === "red" ? "red_confirmed" : "passed"; +const passed = results.every((result) => result.status === targetStatus); +const summary = { + cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })), + phase, + run_id: runId, + status: passed ? targetStatus : "failed", +}; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (!passed) process.exit(1); diff --git a/tests/api/wp0-02-schema-envelope.test.ts b/tests/api/wp0-02-schema-envelope.test.ts index dc9e30a..84fe072 100644 --- a/tests/api/wp0-02-schema-envelope.test.ts +++ b/tests/api/wp0-02-schema-envelope.test.ts @@ -109,7 +109,23 @@ describe("TDD-WP0-API-001 schema envelope", () => { }); it("accepts only the frozen error codes and details whitelist", () => { - expect(Object.keys(stableEngineeringErrors)).toHaveLength(10); + expect(Object.keys(stableEngineeringErrors).sort()).toEqual([ + "ASSET_CLEANUP_CANDIDATE_STALE", + "ASSET_HISTORY_REFERENCE_CONFLICT", + "AUTH_SERVICE_UNAVAILABLE", + "AUTH_SESSION_INVALID", + "BROWSER_UNSUPPORTED", + "IDEMPOTENCY_KEY_CONFLICT", + "MODEL_CONFIG_VERSION_CONFLICT", + "MODEL_DEFAULT_REPLACEMENT_INVALID", + "MODEL_DEFAULT_REPLACEMENT_REQUIRED", + "MODEL_RECOMMENDATION_PRIORITY_CONFLICT", + "MODEL_RECOMMENDATION_PRIORITY_INVALID", + "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", + "REGISTRATION_REJECTED", + "REGISTRATION_REQUEST_INVALID", + "STORAGE_CAPACITY_EXCEEDED", + ]); const envelope = createErrorEnvelope({ code: "MODEL_CONFIG_VERSION_CONFLICT", diff --git a/tests/api/wp1-01-registration.test.ts b/tests/api/wp1-01-registration.test.ts new file mode 100644 index 0000000..8d31d12 --- /dev/null +++ b/tests/api/wp1-01-registration.test.ts @@ -0,0 +1,124 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; +import { isErrorEnvelope } from "../../packages/shared-contracts/src/index.js"; + +const fixedNow = Date.parse("2026-07-28T06:00:00.000Z"); +const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; +const roots: string[] = []; +const services: RegistrationService[] = []; + +function createRegistrationService() { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-01-api-")); + roots.push(root); + const resend = new MockResendAdapter(); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x41), + clock: () => fixedNow, + codeGenerator: () => "572914", + currentPrivacyNoticeVersion: "p0a-notice-v1", + databasePath: join(root, "dada.sqlite3"), + inviteCodeGenerator: () => "DADA-WP1-API", + invitePepper: Buffer.alloc(32, 0x42), + resend, + sessionPepper: Buffer.alloc(32, 0x43), + }); + services.push(registration); + return { registration, resend }; +} + +afterEach(() => { + for (const service of services.splice(0)) { + try { service.close(); } catch { /* already closed by the test */ } + } + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TASK-WP1-01 registration API contract", () => { + it("resolves REGISTER_SEND and REGISTER_COMPLETE to one OpenAPI operation each", async () => { + const { registration, resend } = createRegistrationService(); + const invite = registration.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 2 }); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + + const sent = await app.inject({ + headers: writeHeaders, + method: "POST", + payload: { email: "api-user@example.invalid", invite_code: invite.code }, + url: "/api/v1/auth/register/send", + }); + expect(sent.statusCode).toBe(200); + expect(sent.json()).toMatchObject({ status: "verification_sent" }); + + const completed = await app.inject({ + headers: { ...writeHeaders, "idempotency-key": "wp1-api-idempotency-key-000000000001" }, + method: "POST", + payload: { + creator_name: "API Creator", + privacy_consent_accepted: true, + privacy_notice_version: "p0a-notice-v1", + registration_id: sent.json().registration_id, + social_id: "@@api_creator", + verification_code: resend.readLatestCode("api-user@example.invalid"), + }, + url: "/api/v1/auth/register/complete", + }); + expect(completed.statusCode).toBe(200); + expect(completed.json()).toMatchObject({ + credits: { available_balance: 10, reserved_balance: 0 }, + status: "registered", + user: { role: "user", social_id: "@api_creator", status: "active" }, + }); + expect(completed.headers["set-cookie"]).toContain("dada_session="); + expect(completed.headers["set-cookie"]).toContain("HttpOnly"); + expect(completed.headers["set-cookie"]).toContain("SameSite=Strict"); + + const session = await app.inject({ + headers: { cookie: completed.headers["set-cookie"], host: "127.0.0.1:43121" }, + method: "GET", + url: "/api/v1/auth/session", + }); + expect(session.statusCode).toBe(200); + expect(session.json()).toMatchObject({ + audience: "user", + authenticated: true, + credits: { available_balance: 10, reserved_balance: 0 }, + }); + + const openapi = app.swagger() as { paths?: Record> }; + const operationIds = Object.values(openapi.paths ?? {}).flatMap((path) => + Object.values(path).map((operation) => operation.operationId), + ); + expect(operationIds.filter((id) => id === "sendRegistrationCode")).toHaveLength(1); + expect(operationIds.filter((id) => id === "completeRegistration")).toHaveLength(1); + await app.close(); + }); + + it("returns a stable field error and does not send mail for an invalid invite", async () => { + const { registration, resend } = createRegistrationService(); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + const response = await app.inject({ + headers: writeHeaders, + method: "POST", + payload: { email: "api-rejected@example.invalid", invite_code: "missing-invite" }, + url: "/api/v1/auth/register/send", + }); + + expect(response.statusCode).toBe(409); + expect(isErrorEnvelope(response.json())).toBe(true); + expect(response.json()).toMatchObject({ + error: { + code: "REGISTRATION_REJECTED", + details: { field_errors: [{ field: "invite_code", message_key: "auth.invite.not_found" }] }, + message_key: "auth.registration.rejected", + }, + }); + expect(resend.calls).toEqual([]); + await app.close(); + }); +}); diff --git a/tests/integration/wp1-01-registration.test.ts b/tests/integration/wp1-01-registration.test.ts new file mode 100644 index 0000000..171de55 --- /dev/null +++ b/tests/integration/wp1-01-registration.test.ts @@ -0,0 +1,298 @@ +import { createRequire } from "node:module"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + RegistrationError, + RegistrationService, + type RegistrationTransactionEvent, +} from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; + +const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url)); +const Database = requireFromApi("better-sqlite3"); +const fixedNow = Date.parse("2026-07-28T06:00:00.000Z"); +const privacyNoticeVersion = "p0a-notice-v1"; +const roots: string[] = []; +const services: RegistrationService[] = []; + +interface Snapshot { + challenges: number; + consents: number; + credits: number; + inviteUsedCount: number; + ledger: number; + profiles: number; + sessions: number; + users: number; +} + +function createHarness(inviteCode = "DADA-WP1-VALID") { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-01-")); + roots.push(root); + const databasePath = join(root, "dada.sqlite3"); + const resend = new MockResendAdapter(); + const transactionEvents: RegistrationTransactionEvent[] = []; + const service = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x31), + clock: () => fixedNow, + codeGenerator: () => "482913", + currentPrivacyNoticeVersion: privacyNoticeVersion, + databasePath, + inviteCodeGenerator: () => inviteCode, + invitePepper: Buffer.alloc(32, 0x32), + onTransaction: (event) => transactionEvents.push(event), + resend, + sessionPepper: Buffer.alloc(32, 0x33), + }); + services.push(service); + return { databasePath, resend, service, transactionEvents }; +} + +function withDatabase(databasePath: string, operation: (database: any) => T): T { + const database = new Database(databasePath); + try { + return operation(database); + } finally { + database.close(); + } +} + +function seedOccupiedSlots(databasePath: string, count: number) { + withDatabase(databasePath, (database) => { + const insert = database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, 'user', 'active', 1, ?, ?) + `); + for (let index = 0; index < count; index += 1) { + insert.run(randomUUID(), `occupied-${index}@example.invalid`, randomUUID(), fixedNow - 1_000); + } + }); +} + +function snapshot(databasePath: string): Snapshot { + return withDatabase(databasePath, (database) => ({ + challenges: database.prepare("SELECT COUNT(*) AS count FROM email_challenges").get().count, + consents: database.prepare("SELECT COUNT(*) AS count FROM privacy_consents").get().count, + credits: database.prepare("SELECT COUNT(*) AS count FROM credit_accounts").get().count, + inviteUsedCount: database.prepare("SELECT COALESCE(SUM(used_count), 0) AS count FROM invite_codes").get().count, + ledger: database.prepare("SELECT COUNT(*) AS count FROM credit_ledger").get().count, + profiles: database.prepare("SELECT COUNT(*) AS count FROM user_profiles").get().count, + sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, + users: database.prepare("SELECT COUNT(*) AS count FROM users").get().count, + })); +} + +function writeEvidence(relativeDirectory: string, file: string, value: unknown) { + const directory = process.env[relativeDirectory]; + if (!directory) return; + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); +} + +afterEach(() => { + for (const service of services.splice(0)) service.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TDD-WP1-AUTH-001-valid-register", () => { + it("commits the account, invite use, initial credit, consent and one 30-day session once", async () => { + const harness = createHarness(); + const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 3 }); + seedOccupiedSlots(harness.databasePath, 9); + const before = snapshot(harness.databasePath); + + const sent = await harness.service.sendRegistrationCode({ + email: "new-user@example.invalid", + inviteCode: invite.code, + }); + const code = harness.resend.readLatestCode("new-user@example.invalid"); + expect(code).toBe("482913"); + expect(sent).toMatchObject({ status: "verification_sent" }); + + const request = { + code, + creatorName: "Dada Creator", + idempotencyKey: "wp1-valid-register-idempotency-key-0001", + privacyConsentAccepted: true, + privacyNoticeVersion, + registrationId: sent.registrationId, + socialId: "@@dada_creator", + }; + const completed = harness.service.completeRegistration(request); + const replayed = harness.service.completeRegistration(request); + const session = harness.service.readUserSession(completed.sessionToken); + const after = snapshot(harness.databasePath); + + expect(completed).toMatchObject({ + credits: { availableBalance: 10, reservedBalance: 0 }, + sessionExpiresAt: fixedNow + 30 * 86_400_000, + status: "registered", + user: { creatorName: "Dada Creator", role: "user", socialId: "@dada_creator", status: "active" }, + }); + expect(replayed).toEqual(completed); + expect(session).toMatchObject({ audience: "user", userId: completed.user.userId }); + expect(after).toEqual({ + challenges: before.challenges + 1, + consents: before.consents + 1, + credits: before.credits + 1, + inviteUsedCount: before.inviteUsedCount + 1, + ledger: before.ledger + 1, + profiles: before.profiles + 1, + sessions: before.sessions + 1, + users: before.users + 1, + }); + + const stored = withDatabase(harness.databasePath, (database) => ({ + challenge: database.prepare("SELECT code_hmac, consumed_at FROM email_challenges").get(), + ledger: database.prepare("SELECT entry_type, amount, available_before, available_after, reserved_before, reserved_after FROM credit_ledger").get(), + session: database.prepare("SELECT token_digest, audience, revoked_at FROM sessions").get(), + })); + expect(stored.challenge.code_hmac).not.toContain(code); + expect(stored.challenge.consumed_at).toBe(fixedNow); + expect(stored.ledger).toEqual({ + amount: 10, + available_after: 10, + available_before: 0, + entry_type: "registration_grant", + reserved_after: 0, + reserved_before: 0, + }); + expect(stored.session).toMatchObject({ audience: "user", revoked_at: null }); + expect(stored.session.token_digest).not.toBe(completed.sessionToken); + + writeEvidence("DADA_EVIDENCE_DIR_AUTH_VALID", "request.json", { + operation: ["REGISTER_SEND", "REGISTER_COMPLETE", "AUTH_SESSION"], + privacy_notice_version: privacyNoticeVersion, + sensitive_fields: "redacted", + }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_VALID", "response.json", { + audience: session?.audience, + available_balance: completed.credits.availableBalance, + reserved_balance: completed.credits.reservedBalance, + session_days: 30, + status: completed.status, + }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_VALID", "db-diff.json", { after, before, plaintext_secret_fields: 0 }); + }); +}); + +describe("TDD-WP1-AUTH-002-invalid-invite", () => { + it.each(["expired", "disabled", "exhausted", "not_found"] as const)( + "rejects %s before creating a challenge or calling Resend", + async (scenario) => { + const harness = createHarness(`DADA-WP1-${scenario.toUpperCase()}`); + const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 1 }); + withDatabase(harness.databasePath, (database) => { + if (scenario === "expired") database.prepare("UPDATE invite_codes SET expires_at = ?").run(fixedNow - 1); + if (scenario === "disabled") database.prepare("UPDATE invite_codes SET status = 'disabled'").run(); + if (scenario === "exhausted") database.prepare("UPDATE invite_codes SET used_count = max_uses").run(); + }); + const before = snapshot(harness.databasePath); + const expectedReason = scenario === "not_found" ? "invite_not_found" : `invite_${scenario}`; + + await expect( + harness.service.sendRegistrationCode({ + email: "rejected-user@example.invalid", + inviteCode: scenario === "not_found" ? `${invite.code}-missing` : invite.code, + }), + ).rejects.toMatchObject({ code: "REGISTRATION_REJECTED", reason: expectedReason }); + + expect(snapshot(harness.databasePath)).toEqual(before); + expect(harness.resend.calls).toEqual([]); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_INVALID", `${scenario}-response.json`, { + code: "REGISTRATION_REJECTED", + field: "invite_code", + reason: expectedReason, + }); + }, + ); + + it("writes the aggregate zero-side-effect evidence", () => { + writeEvidence("DADA_EVIDENCE_DIR_AUTH_INVALID", "db-diff.json", { + challenge_delta: 0, + consent_delta: 0, + credit_delta: 0, + invite_used_count_delta: 0, + session_delta: 0, + user_delta: 0, + }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_INVALID", "external-calls.json", { resend_calls: 0 }); + }); +}); + +describe("TDD-WP1-AUTH-001-final-recheck-race", () => { + it.each(["exhausted", "disabled", "expired", "stage_limit"] as const)( + "rechecks %s under BEGIN IMMEDIATE and replays the same failure", + async (scenario) => { + const harness = createHarness(`DADA-WP1-RACE-${scenario.toUpperCase()}`); + const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 1 }); + seedOccupiedSlots(harness.databasePath, 9); + const sent = await harness.service.sendRegistrationCode({ + email: "race-user@example.invalid", + inviteCode: invite.code, + }); + const code = harness.resend.readLatestCode("race-user@example.invalid"); + withDatabase(harness.databasePath, (database) => { + if (scenario === "exhausted") database.prepare("UPDATE invite_codes SET used_count = max_uses").run(); + if (scenario === "disabled") database.prepare("UPDATE invite_codes SET status = 'disabled'").run(); + if (scenario === "expired") database.prepare("UPDATE invite_codes SET expires_at = ?").run(fixedNow - 1); + if (scenario === "stage_limit") { + database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, 'user', 'active', 1, ?, ?) + `).run(randomUUID(), "tenth-user@example.invalid", randomUUID(), fixedNow); + } + }); + const before = snapshot(harness.databasePath); + const request = { + code, + creatorName: "Race User", + idempotencyKey: `wp1-race-idempotency-key-${scenario}-0001`, + privacyConsentAccepted: true, + privacyNoticeVersion, + registrationId: sent.registrationId, + socialId: "@race_user", + }; + const expectedReason = scenario === "stage_limit" ? "stage_limit_reached" : `invite_${scenario}`; + + let first: RegistrationError | undefined; + let second: RegistrationError | undefined; + try { harness.service.completeRegistration(request); } catch (error) { first = error as RegistrationError; } + try { harness.service.completeRegistration(request); } catch (error) { second = error as RegistrationError; } + + expect(first).toMatchObject({ code: "REGISTRATION_REJECTED", reason: expectedReason }); + expect(second).toMatchObject({ code: first?.code, reason: first?.reason }); + expect(snapshot(harness.databasePath)).toEqual(before); + expect(harness.transactionEvents.filter((event) => event.operation === "registration_complete")) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ mode: "BEGIN IMMEDIATE", outcome: "rejected" }), + expect.objectContaining({ mode: "BEGIN IMMEDIATE", outcome: "idempotent_replay" }), + ])); + + writeEvidence("DADA_EVIDENCE_DIR_AUTH_RACE", `${scenario}-response.json`, { + code: first?.code, + reason: first?.reason, + retry_code: second?.code, + retry_reason: second?.reason, + }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_RACE", `${scenario}-db-diff.json`, { after: snapshot(harness.databasePath), before }); + }, + ); + + it("writes the aggregate transaction evidence", () => { + writeEvidence("DADA_EVIDENCE_DIR_AUTH_RACE", "transaction-trace.json", { + business_side_effects: 0, + failure_replay: "stable", + transaction_mode: "BEGIN IMMEDIATE", + }); + }); +});