From f467e7c09f6e4485bcf6d390502e74d4582e20dc Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 28 Jul 2026 15:54:28 +0800 Subject: [PATCH 001/101] 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", + }); + }); +}); -- 2.54.0 From ee70001d442edf4511eba2a36bab2cb7f9ecea74 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 28 Jul 2026 16:28:30 +0800 Subject: [PATCH 002/101] feat: implement TASK-WP1-02 login sessions --- apps/api/src/app.ts | 163 +++++++- apps/api/src/registration-errors.ts | 36 +- apps/api/src/registration.ts | 435 ++++++++++++++++++- apps/api/src/resend-adapter.ts | 6 +- apps/web/src/generated/api/sdk.gen.ts | 27 +- apps/web/src/generated/api/types.gen.ts | 30 +- apps/web/src/main.tsx | 22 +- apps/web/src/user-auth.css | 325 +++++++++++++++ apps/web/src/user-auth.tsx | 267 ++++++++++++ openapi/openapi.json | 441 ++++++++++++++++++++ package.json | 6 +- packages/shared-contracts/src/api.ts | 6 + packages/shared-contracts/src/auth.ts | 41 ++ scripts/run-wp1-02-validation.mjs | 101 +++++ tests/api/wp0-02-schema-envelope.test.ts | 3 + tests/api/wp1-02-login-session.test.ts | 145 +++++++ tests/e2e/entry-state-ui.spec.ts | 40 ++ tests/e2e/session-invalid-ui.spec.ts | 43 ++ tests/e2e/user-auth.spec.ts | 88 ++++ tests/integration/wp1-02-auth-state.test.ts | 283 +++++++++++++ 20 files changed, 2480 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/user-auth.css create mode 100644 apps/web/src/user-auth.tsx create mode 100644 scripts/run-wp1-02-validation.mjs create mode 100644 tests/api/wp1-02-login-session.test.ts create mode 100644 tests/e2e/entry-state-ui.spec.ts create mode 100644 tests/e2e/session-invalid-ui.spec.ts create mode 100644 tests/e2e/user-auth.spec.ts create mode 100644 tests/integration/wp1-02-auth-state.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 16db547..900f5ca 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,11 @@ import { ErrorDetailsSchema, ErrorEnvelopeSchema, GenerationErrorCategorySchema, + LoginCompleteRequestSchema, + LoginCompleteResponseSchema, + LoginSendRequestSchema, + LogoutHeadersSchema, + LogoutResponseSchema, ModelConfigSseEventSchema, ModelRuntimeSseEventSchema, RegistrationCompleteHeadersSchema, @@ -25,6 +30,8 @@ import { createErrorEnvelope, isCorrelationId, type BootstrapResponse, + type LoginCompleteRequest, + type LoginSendRequest, type RegistrationCompleteRequest, type RegistrationSendRequest, } from "@dada/shared-contracts"; @@ -92,6 +99,8 @@ const contentSecurityPolicy = [ "base-uri 'none'", "frame-ancestors 'none'", ].join("; "); +const authFlowCookieName = "dada_auth_flow"; +const userSessionCookieName = "dada_session"; function requestCorrelationId(headers: Record) { const header = headers["x-correlation-id"]; @@ -208,6 +217,11 @@ export async function createApp(options: CreateAppOptions = {}) { RegistrationCompleteRequestSchema, RegistrationCompleteHeadersSchema, RegistrationCompleteResponseSchema, + LoginSendRequestSchema, + LoginCompleteRequestSchema, + LoginCompleteResponseSchema, + LogoutHeadersSchema, + LogoutResponseSchema, UserSessionResponseSchema, BrowserUnsupportedReasonSchema, BrowserSupportRequestSchema, @@ -288,6 +302,111 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.post( + "/api/v1/auth/login/send", + { + attachValidation: true, + schema: { + body: Type.Ref(LoginSendRequestSchema), + operationId: "sendLoginCode", + response: { + 200: Type.Ref(RegistrationSendResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: 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 })); + } + const existingFlow = cookieValue(headerValue(request.headers.cookie), authFlowCookieName); + const clientKey = existingFlow ?? randomBytes(32).toString("base64url"); + try { + const body = request.body as LoginSendRequest; + const result = await options.registration.sendLoginCode({ clientKey, email: body.email }); + if (!existingFlow) { + reply.header( + "Set-Cookie", + `${authFlowCookieName}=${clientKey}; Max-Age=${10 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + } + 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/login/complete", + { + attachValidation: true, + schema: { + body: Type.Ref(LoginCompleteRequestSchema), + headers: Type.Ref(RegistrationCompleteHeadersSchema), + operationId: "completeLogin", + response: { + 200: Type.Ref(LoginCompleteResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: 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 })); + } + const clientKey = cookieValue(headerValue(request.headers.cookie), authFlowCookieName); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id); + try { + const body = request.body as LoginCompleteRequest; + const result = options.registration.completeLogin({ + clientKey, + code: body.verification_code, + idempotencyKey, + registrationId: body.registration_id, + }); + reply.header( + "Set-Cookie", + `${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + return { + audience: result.audience, + 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.post( "/api/v1/auth/register/send", { @@ -299,6 +418,7 @@ export async function createApp(options: CreateAppOptions = {}) { 200: Type.Ref(RegistrationSendResponseSchema), 400: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema), }, tags: ["Authentication"], @@ -361,7 +481,7 @@ export async function createApp(options: CreateAppOptions = {}) { }); reply.header( "Set-Cookie", - `dada_session=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + `${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, ); return { credits: { @@ -401,7 +521,7 @@ export async function createApp(options: CreateAppOptions = {}) { 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 token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); const session = token ? options.registration.readUserSession(token) : undefined; if (!session) { return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); @@ -413,7 +533,7 @@ export async function createApp(options: CreateAppOptions = {}) { available_balance: session.credits.availableBalance, reserved_balance: session.credits.reservedBalance, }, - csrf_token: randomBytes(32).toString("base64url"), + csrf_token: options.registration.issueUserCsrfToken(token!), expires_at: new Date(session.expiresAt).toISOString(), user: { creator_name: session.user.creatorName, @@ -426,6 +546,43 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.post( + "/api/v1/auth/logout", + { + attachValidation: true, + schema: { + headers: Type.Ref(LogoutHeadersSchema), + operationId: "logoutUser", + response: { + 200: Type.Ref(LogoutResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: 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 })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + options.registration.logoutUser({ csrfToken, sessionToken: token }); + reply.header("Set-Cookie", `${userSessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`); + return { status: "logged_out" as const }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + app.post( "/api/v1/support/check", { diff --git a/apps/api/src/registration-errors.ts b/apps/api/src/registration-errors.ts index 6ab6241..b2a91c7 100644 --- a/apps/api/src/registration-errors.ts +++ b/apps/api/src/registration-errors.ts @@ -10,22 +10,42 @@ export type RegistrationErrorReason = | "privacy_consent_required" | "privacy_notice_version_invalid" | "profile_invalid" - | "idempotency_conflict"; + | "idempotency_conflict" + | "registration_login_required" + | "login_registration_required" + | "account_suspended" + | "login_admin_required" + | "resend_too_soon" + | "too_many_attempts" + | "csrf_invalid" + | "session_invalid"; export type RegistrationErrorCode = | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" - | "IDEMPOTENCY_KEY_CONFLICT"; + | "IDEMPOTENCY_KEY_CONFLICT" + | "AUTH_ENTRY_REJECTED" + | "AUTH_RATE_LIMITED" + | "AUTH_CSRF_INVALID" + | "AUTH_SESSION_INVALID"; export class RegistrationError extends Error { readonly code: RegistrationErrorCode; - readonly httpStatus: 400 | 409; + readonly httpStatus: 400 | 401 | 403 | 409 | 429; readonly reason: RegistrationErrorReason; constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) { super(code); this.code = code; - this.httpStatus = code === "REGISTRATION_REQUEST_INVALID" ? 400 : 409; + this.httpStatus = code === "REGISTRATION_REQUEST_INVALID" + ? 400 + : code === "AUTH_SESSION_INVALID" + ? 401 + : code === "AUTH_CSRF_INVALID" + ? 403 + : code === "AUTH_RATE_LIMITED" + ? 429 + : 409; this.reason = reason; } } @@ -44,6 +64,14 @@ export function registrationFieldError(reason: RegistrationErrorReason) { 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" }, + registration_login_required: { field: "email", message_key: "auth.registration.login_required" }, + login_registration_required: { field: "email", message_key: "auth.login.registration_required" }, + account_suspended: { field: "email", message_key: "auth.account.suspended" }, + login_admin_required: { field: "email", message_key: "auth.login.admin_required" }, + resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" }, + too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" }, + csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" }, + session_invalid: { field: "session", message_key: "auth.session.invalid" }, }; return entries[reason]; } diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index 9e15400..ea67670 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -17,10 +17,23 @@ const stageLimit = 10; const challengeLifetimeMilliseconds = 10 * 60 * 1_000; const resendDelayMilliseconds = 60 * 1_000; const sessionLifetimeMilliseconds = 30 * 24 * 60 * 60 * 1_000; +const rateWindowMilliseconds = 10 * 60 * 1_000; +const rateBlockMilliseconds = 10 * 60 * 1_000; +const maximumFailedAttempts = 5; +const maximumSendsPerWindow = 5; export interface RegistrationTransactionEvent { mode: "BEGIN IMMEDIATE"; - operation: "invite_create" | "registration_send" | "registration_complete" | "registration_send_compensation"; + operation: + | "invite_create" + | "registration_send" + | "registration_complete" + | "registration_send_compensation" + | "login_send" + | "login_complete" + | "session_issue" + | "session_revoke" + | "csrf_issue"; outcome: "committed" | "rejected" | "idempotent_replay"; } @@ -51,7 +64,7 @@ interface ChallengeRow { consumed_at: number | null; email: string; expires_at: number; - invite_id: string; + invite_id: string | null; } interface AttemptRow { @@ -72,6 +85,21 @@ interface UserResultRow { user_id: string; } +interface RateLimitRow { + blocked_until: number | null; + failed_attempts: number; + send_count: number; + window_started_at: number; +} + +interface LoginAttemptRow { + failure_reason: RegistrationErrorReason | null; + outcome_code: "success" | "failure"; + request_hash: string; + session_id: string | null; + user_id: string | null; +} + export interface RegistrationSendResult { challengeExpiresAt: number; registrationId: string; @@ -111,6 +139,18 @@ export interface UserSessionResult { userId: string; } +export interface LoginCompleteInput { + clientKey: string; + code: string; + idempotencyKey: string; + registrationId: string; +} + +export type LoginCompleteResult = Omit & { + audience: "user"; + status: "authenticated"; +}; + interface ImmediateResult { outcome: RegistrationTransactionEvent["outcome"]; value: T; @@ -210,10 +250,13 @@ export class RegistrationService { this.assertInviteAvailable(invite, now); this.assertStageCapacity(); const existing = this.database.prepare(` - SELECT user_id FROM users + SELECT user_id, role, status FROM users WHERE normalized_email = ? AND status <> 'deleted' - `).get(email); - if (existing) throw new RegistrationError("REGISTRATION_REJECTED", "email_already_registered"); + `).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended" } | undefined; + if (existing?.status === "suspended") throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended"); + if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required"); + this.assertChallengeSendAllowed(email, "register", "registration", now); + this.recordRateSend(email, "registration", now); this.database.prepare(` INSERT INTO email_challenges ( @@ -241,7 +284,7 @@ export class RegistrationService { }); try { - await this.options.resend.sendRegistrationCode({ challengeId, code, email, purpose: "register" }); + await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" }); } catch { this.runImmediate("registration_send_compensation", () => { this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); @@ -252,6 +295,143 @@ export class RegistrationService { return result; } + async sendLoginCode(input: { clientKey: string; email: string }): Promise { + const email = normalizeEmail(input.email); + const clientKey = normalizeProfileValue(input.clientKey, 160); + const now = this.options.clock(); + const challengeId = randomUUID(); + const code = this.options.codeGenerator(); + if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); + + const result = this.runImmediate("login_send", () => { + const user = this.database.prepare(` + SELECT user_id, role, status FROM users + WHERE normalized_email = ? AND status <> 'deleted' + `).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (!user) throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_registration_required"); + if (user.status === "suspended") throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended"); + if (user.role !== "user") throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_admin_required"); + this.assertChallengeSendAllowed(email, "login", clientKey, now); + this.recordRateSend(email, clientKey, now); + this.database.prepare(` + INSERT INTO email_challenges ( + challenge_id, email, invite_id, code_hmac, purpose, expires_at, + resend_available_at, failure_count, consumed_at, created_at + ) VALUES (?, ?, NULL, ?, 'login', ?, ?, 0, NULL, ?) + `).run( + challengeId, + email, + this.challengeHmac(challengeId, code), + now + challengeLifetimeMilliseconds, + now + resendDelayMilliseconds, + now, + ); + return { + outcome: "committed", + value: { + challengeExpiresAt: now + challengeLifetimeMilliseconds, + registrationId: challengeId, + resendAvailableAt: now + resendDelayMilliseconds, + status: "verification_sent" as const, + }, + }; + }); + + try { + await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" }); + } catch { + this.runImmediate("registration_send_compensation", () => { + this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); + return { outcome: "committed", value: undefined }; + }); + throw new Error("AUTH_SERVICE_UNAVAILABLE"); + } + return result; + } + + completeLogin(input: LoginCompleteInput): LoginCompleteResult { + if (!/^[0-9]{6}$/.test(input.code)) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); + const clientKey = normalizeProfileValue(input.clientKey, 160); + if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); + } + const now = this.options.clock(); + const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `login-idempotency:${input.idempotencyKey}`); + const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ + clientKey, + code: input.code, + registrationId: input.registrationId, + })); + + const outcome = this.runImmediate("login_complete", () => { + const previous = this.database.prepare(` + SELECT request_hash, outcome_code, failure_reason, user_id, session_id + FROM login_attempts WHERE idempotency_key_digest = ? + `).get(idempotencyDigest) as LoginAttemptRow | undefined; + if (previous) { + if (!constantTimeTextEqual(previous.request_hash, requestHash)) { + throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); + } + if (previous.outcome_code === "failure") { + const reason = previous.failure_reason ?? "challenge_invalid"; + const code = reason === "resend_too_soon" || reason === "too_many_attempts" + ? "AUTH_RATE_LIMITED" + : "AUTH_ENTRY_REJECTED"; + return { outcome: "idempotent_replay", value: new RegistrationError(code, reason) }; + } + return { + outcome: "idempotent_replay", + value: this.loginResult(this.readCompletedRegistration(previous.user_id!, previous.session_id!)), + }; + } + + const challenge = this.database.prepare(` + SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at + FROM email_challenges WHERE challenge_id = ? AND purpose = 'login' + `).get(input.registrationId) as ChallengeRow | undefined; + if (!challenge || challenge.consumed_at !== null) { + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); + } + const rate = this.readRateLimit(challenge.email, clientKey, now); + if (rate.blocked_until !== null && rate.blocked_until > now) { + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "too_many_attempts", now); + } + if (challenge.expires_at <= now) { + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); + } + if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { + this.database.prepare("UPDATE email_challenges SET failure_count = failure_count + 1 WHERE challenge_id = ?") + .run(challenge.challenge_id); + const failedAttempts = this.recordRateFailure(challenge.email, clientKey, now); + const reason = failedAttempts >= maximumFailedAttempts ? "too_many_attempts" : "challenge_invalid"; + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, reason, now); + } + const user = this.database.prepare(` + SELECT user_id, role, status FROM users + WHERE normalized_email = ? AND status <> 'deleted' + `).get(challenge.email) as { role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (!user) return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "login_registration_required", now); + if (user.status === "suspended") return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "account_suspended", now); + if (user.role !== "user") return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "login_admin_required", now); + + this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") + .run(now, challenge.challenge_id); + const issued = this.insertSession(user.user_id, "user", now); + this.database.prepare(` + INSERT INTO login_attempts ( + idempotency_key_digest, request_hash, challenge_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) + `).run(idempotencyDigest, requestHash, input.registrationId, user.user_id, issued.sessionId, now); + return { + outcome: "committed", + value: this.loginResult(this.readCompletedRegistration(user.user_id, issued.sessionId)), + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + return outcome; + } + completeRegistration(input: RegistrationCompleteInput): RegistrationCompleteResult { const creatorName = normalizeProfileValue(input.creatorName, 80); const socialId = normalizeSocialId(input.socialId); @@ -391,6 +571,103 @@ export class RegistrationService { return outcome; } + issueAuthenticatedSession(userId: string, audience: "user" | "admin") { + const now = this.options.clock(); + return this.runImmediate("session_issue", () => { + const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?") + .get(userId) as { role: "user" | "super_admin"; status: "active" | "suspended" | "deleted" } | undefined; + const role = audience === "user" ? "user" : "super_admin"; + if (!user || user.status !== "active" || user.role !== role) { + throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + } + if (audience === "admin") { + const access = this.database.prepare("SELECT allowed FROM admin_access WHERE user_id = ?") + .get(userId) as { allowed: 0 | 1 } | undefined; + if (access?.allowed !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + } + const issued = this.insertSession(userId, audience, now); + return { + outcome: "committed", + value: { + sessionExpiresAt: issued.sessionExpiresAt, + sessionId: issued.sessionId, + sessionToken: issued.sessionToken, + }, + }; + }); + } + + issueUserCsrfToken(sessionToken: string) { + const now = this.options.clock(); + const csrfToken = randomBytes(32).toString("base64url"); + return this.runImmediate("csrf_issue", () => { + const session = this.database.prepare(` + SELECT s.session_id FROM sessions s + JOIN users u ON u.user_id = s.user_id + WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active' + `).get(digest(sessionToken), now) as { session_id: string } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?") + .run(digest(csrfToken), session.session_id); + return { outcome: "committed", value: csrfToken }; + }); + } + + logoutUser(input: { csrfToken: string; sessionToken: string }) { + const now = this.options.clock(); + this.runImmediate("session_revoke", () => { + const session = this.database.prepare(` + SELECT session_id, user_id, csrf_token_digest FROM sessions + WHERE token_digest = ? AND audience = 'user' AND revoked_at IS NULL AND expires_at > ? + `).get(digest(input.sessionToken), now) as { csrf_token_digest: string | null; session_id: string; user_id: string } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + const csrfDigest = digest(input.csrfToken); + if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, csrfDigest)) { + throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid"); + } + this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") + .run(now, session.user_id); + return { outcome: "committed", value: undefined }; + }); + } + + changeUserStatus(userId: string, status: "suspended" | "deleted") { + const now = this.options.clock(); + this.runImmediate("session_revoke", () => { + const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'") + .run(status, userId); + if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") + .run(now, userId); + return { outcome: "committed", value: undefined }; + }); + } + + revokeAdminSessions(userId: string, reason: "logout" | "disabled" | "whitelist_removed") { + const now = this.options.clock(); + this.runImmediate("session_revoke", () => { + const user = this.database.prepare("SELECT role FROM users WHERE user_id = ?").get(userId) as { role: string } | undefined; + if (user?.role !== "super_admin") throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + if (reason === "disabled") this.database.prepare("UPDATE users SET status = 'suspended' WHERE user_id = ?").run(userId); + if (reason === "whitelist_removed") this.database.prepare("UPDATE admin_access SET allowed = 0 WHERE user_id = ?").run(userId); + this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL") + .run(now, userId); + return { outcome: "committed", value: undefined }; + }); + } + + readAdminSession(token: string) { + const now = this.options.clock(); + return this.database.prepare(` + SELECT s.expires_at, u.user_id FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN admin_access a ON a.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(digest(token), now) as { expires_at: number; user_id: string } | undefined; + } + readUserSession(token: string): UserSessionResult | undefined { const now = this.options.clock(); const row = this.database.prepare(` @@ -484,7 +761,7 @@ export class RegistrationService { 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), + invite_id TEXT REFERENCES invite_codes(invite_id), code_hmac TEXT NOT NULL, purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'admin_login')), expires_at INTEGER NOT NULL, @@ -498,6 +775,7 @@ export class RegistrationService { user_id TEXT NOT NULL REFERENCES users(user_id), audience TEXT NOT NULL CHECK (audience IN ('user', 'admin')), token_digest TEXT NOT NULL UNIQUE, + csrf_token_digest TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER @@ -512,6 +790,27 @@ export class RegistrationService { session_id TEXT REFERENCES sessions(session_id), created_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS admin_access ( + user_id TEXT PRIMARY KEY REFERENCES users(user_id), + allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS auth_rate_limits ( + rate_key TEXT PRIMARY KEY, + window_started_at INTEGER NOT NULL, + send_count INTEGER NOT NULL CHECK (send_count >= 0), + failed_attempts INTEGER NOT NULL CHECK (failed_attempts >= 0), + blocked_until INTEGER + ); + CREATE TABLE IF NOT EXISTS login_attempts ( + idempotency_key_digest TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + challenge_id TEXT NOT NULL, + outcome_code TEXT NOT NULL CHECK (outcome_code IN ('success', 'failure')), + failure_reason TEXT, + user_id TEXT REFERENCES users(user_id), + session_id TEXT REFERENCES sessions(session_id), + created_at INTEGER NOT NULL + ); `); } @@ -548,6 +847,117 @@ export class RegistrationService { return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url"); } + private rateKey(email: string, clientKey: string) { + return this.keyedHmac(this.options.challengePepper, `rate:${email}:${clientKey}`); + } + + private readRateLimit(email: string, clientKey: string, now: number): RateLimitRow { + const row = this.database.prepare(` + SELECT window_started_at, send_count, failed_attempts, blocked_until + FROM auth_rate_limits WHERE rate_key = ? + `).get(this.rateKey(email, clientKey)) as RateLimitRow | undefined; + if (!row || now - row.window_started_at >= rateWindowMilliseconds) { + return { blocked_until: null, failed_attempts: 0, send_count: 0, window_started_at: now }; + } + return row; + } + + private recordRateSend(email: string, clientKey: string, now: number) { + const key = this.rateKey(email, clientKey); + const row = this.readRateLimit(email, clientKey, now); + if (row.blocked_until !== null && row.blocked_until > now) { + throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); + } + if (row.send_count >= maximumSendsPerWindow) { + throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); + } + this.database.prepare(` + INSERT INTO auth_rate_limits ( + rate_key, window_started_at, send_count, failed_attempts, blocked_until + ) VALUES (?, ?, 1, 0, NULL) + ON CONFLICT(rate_key) DO UPDATE SET + window_started_at = excluded.window_started_at, + send_count = ?, + failed_attempts = ?, + blocked_until = ? + `).run( + key, + row.window_started_at, + row.send_count + 1, + row.failed_attempts, + row.blocked_until, + ); + } + + private recordRateFailure(email: string, clientKey: string, now: number) { + const key = this.rateKey(email, clientKey); + const row = this.readRateLimit(email, clientKey, now); + const failedAttempts = row.failed_attempts + 1; + const blockedUntil = failedAttempts >= maximumFailedAttempts ? now + rateBlockMilliseconds : row.blocked_until; + this.database.prepare(` + INSERT INTO auth_rate_limits ( + rate_key, window_started_at, send_count, failed_attempts, blocked_until + ) VALUES (?, ?, 0, ?, ?) + ON CONFLICT(rate_key) DO UPDATE SET + window_started_at = excluded.window_started_at, + send_count = ?, + failed_attempts = excluded.failed_attempts, + blocked_until = excluded.blocked_until + `).run(key, row.window_started_at, failedAttempts, blockedUntil, row.send_count); + return failedAttempts; + } + + private assertChallengeSendAllowed( + email: string, + purpose: "register" | "login", + clientKey: string, + now: number, + ) { + const latest = this.database.prepare(` + SELECT resend_available_at FROM email_challenges + WHERE email = ? AND purpose = ? ORDER BY created_at DESC LIMIT 1 + `).get(email, purpose) as { resend_available_at: number } | undefined; + if (latest && latest.resend_available_at > now) { + throw new RegistrationError("AUTH_RATE_LIMITED", "resend_too_soon"); + } + const rate = this.readRateLimit(email, clientKey, now); + if ((rate.blocked_until !== null && rate.blocked_until > now) || rate.send_count >= maximumSendsPerWindow) { + throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); + } + } + + private insertSession(userId: string, audience: "user" | "admin", now: number) { + const sessionId = randomUUID(); + const sessionToken = this.sessionToken(sessionId); + const sessionExpiresAt = now + sessionLifetimeMilliseconds; + this.database.prepare(` + INSERT INTO sessions ( + session_id, user_id, audience, token_digest, csrf_token_digest, + created_at, expires_at, revoked_at + ) VALUES (?, ?, ?, ?, NULL, ?, ?, NULL) + `).run(sessionId, userId, audience, digest(sessionToken), now, sessionExpiresAt); + return { sessionExpiresAt, sessionId, sessionToken }; + } + + private recordLoginFailure( + idempotencyKeyDigest: string, + requestHash: string, + challengeId: string, + reason: RegistrationErrorReason, + now: number, + ): ImmediateResult { + this.database.prepare(` + INSERT INTO login_attempts ( + idempotency_key_digest, request_hash, challenge_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'failure', ?, NULL, NULL, ?) + `).run(idempotencyKeyDigest, requestHash, challengeId, reason, now); + const code = reason === "resend_too_soon" || reason === "too_many_attempts" + ? "AUTH_RATE_LIMITED" + : "AUTH_ENTRY_REJECTED"; + return { outcome: "rejected", value: new RegistrationError(code, reason) }; + } + private stageIsFull() { const row = this.database.prepare(` SELECT COUNT(*) AS count FROM users @@ -605,6 +1015,17 @@ export class RegistrationService { return this.result(row); } + private loginResult(result: RegistrationCompleteResult): LoginCompleteResult { + return { + audience: "user", + credits: result.credits, + sessionExpiresAt: result.sessionExpiresAt, + sessionToken: result.sessionToken, + status: "authenticated", + user: result.user, + }; + } + private result(row: UserResultRow): RegistrationCompleteResult { return { credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance }, diff --git a/apps/api/src/resend-adapter.ts b/apps/api/src/resend-adapter.ts index 4cb2bed..a9f9516 100644 --- a/apps/api/src/resend-adapter.ts +++ b/apps/api/src/resend-adapter.ts @@ -2,17 +2,17 @@ export interface RegistrationCodeMessage { challengeId: string; code: string; email: string; - purpose: "register"; + purpose: "register" | "login"; } export interface ResendAdapter { - sendRegistrationCode(message: RegistrationCodeMessage): Promise; + sendVerificationCode(message: RegistrationCodeMessage): Promise; } export class MockResendAdapter implements ResendAdapter { readonly calls: RegistrationCodeMessage[] = []; - async sendRegistrationCode(message: RegistrationCodeMessage) { + async sendVerificationCode(message: RegistrationCodeMessage) { this.calls.push({ ...message }); } diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 566d531..8a19298 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, RegistrationSendResponse, RegistrationSendRequest } from "./types.gen.js"; +import type { LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, LogoutResponse, RegistrationSendResponse, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: { }>; } +export async function completeLogin(body: LoginCompleteRequest, 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/login/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 completeRegistration(body: RegistrationCompleteRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -104,6 +113,22 @@ export async function getUserSession(options: ClientOptions = {}): Promise; } +export async function logoutUser(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + +export async function sendLoginCode(body: LoginSendRequest, 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/login/send`, { body: JSON.stringify(body), method: "POST", 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); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 0f45b13..cb2d72e 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -78,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" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE"; + "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" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID"; "correlation_id": string; "details": { "capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable"; @@ -102,6 +102,32 @@ export type ErrorEnvelope = { export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable"; +export type LoginCompleteRequest = { + "registration_id": string; + "verification_code": string; +}; + +export type LoginCompleteResponse = { + "audience": "user"; + "credits": CreditSummary; + "session_expires_at": string; + "status": "authenticated"; + "user": AuthenticatedUser; +}; + +export type LoginSendRequest = { + "email": string; +}; + +export type LogoutHeaders = { + "idempotency-key": string; + "x-csrf-token": string; +}; + +export type LogoutResponse = { + "status": "logged_out"; +}; + export type ModelConfigSseEvent = { "config_set_version": number; "entity_ref": string; @@ -170,7 +196,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" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE"; +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" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID"; export type StateSseEvent = { "entity_ref": string; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index cf3cf82..75d77c7 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,8 +1,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { ToolchainProbe } from "./toolchain-probe.js"; import { registerPublicAssetServiceWorker } from "./public-asset-cache.js"; +import { UserAuthPage } from "./user-auth.js"; const root = document.getElementById("root"); @@ -13,8 +13,18 @@ if (!root) { // Cache failure leaves public assets network-backed and must not create alternate persistence. void registerPublicAssetServiceWorker().catch(() => undefined); -createRoot(root).render( - - - , -); +const appRoot = createRoot(root); +let authRevision = 0; + +function renderAuthenticationEntry() { + authRevision += 1; + appRoot.render( + + + , + ); +} + +// Any authenticated surface can dispatch this after a revoked/invalid session response. +window.addEventListener("dada:session-invalid", renderAuthenticationEntry); +renderAuthenticationEntry(); diff --git a/apps/web/src/user-auth.css b/apps/web/src/user-auth.css new file mode 100644 index 0000000..f5afd8b --- /dev/null +++ b/apps/web/src/user-auth.css @@ -0,0 +1,325 @@ +:root { + color: #111111; + background: #f6f6f4; + font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif; + font-synthesis: none; + letter-spacing: 0; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + overflow-x: hidden; + background: #f6f6f4; +} + +button, +input { + font: inherit; + letter-spacing: 0; +} + +button, +a, +input { + outline-offset: 3px; +} + +.auth-page { + min-height: 100vh; + display: grid; + grid-template-rows: 230px minmax(520px, 1fr) 48px; +} + +.auth-art { + position: relative; + display: grid; + grid-template-columns: minmax(320px, 30%) 1fr; + overflow: hidden; + border-bottom: 1px solid #c8c8c3; + background: #eeeee9; +} + +.auth-wordmark { + display: flex; + align-items: center; + padding: 0 48px; + color: #111111; + background: #f2f500; + font-family: Arial Black, "Segoe UI", sans-serif; + font-size: 96px; + font-weight: 900; + line-height: 1; +} + +.auth-art-copy { + z-index: 2; + display: flex; + flex-direction: column; + justify-content: center; + gap: 54px; + padding: 26px 54px 20px; +} + +.auth-art-copy span { + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.auth-art-copy strong { + max-width: 520px; + font-size: 27px; + line-height: 1.2; +} + +.auth-art-block { + position: absolute; + top: 90px; + right: 16%; + width: 31%; + height: 92px; + background: #b9bab4; +} + +.auth-art-line { + position: absolute; + right: 4%; + bottom: 38px; + width: 48%; + height: 48px; + border-right: 1px solid #111111; + border-bottom: 1px solid #111111; + background: #f2f500; +} + +.auth-content { + position: relative; + width: min(1180px, 100%); + margin: 0 auto; + padding: 32px 28px 64px; +} + +.auth-admin-link { + position: absolute; + top: 34px; + right: 30px; + color: #333333; + font-size: 13px; + text-decoration-thickness: 1px; + text-underline-offset: 4px; +} + +.auth-panel { + width: min(480px, 100%); + margin-left: 84px; +} + +.auth-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + height: 50px; + border: 1px solid #8a8a86; +} + +.auth-tab { + border: 0; + color: #222222; + background: #f6f6f4; + font-weight: 700; + cursor: pointer; +} + +.auth-tab + .auth-tab { + border-left: 1px solid #8a8a86; +} + +.auth-tab[aria-selected="true"] { + background: #f2f500; +} + +.auth-form { + display: flex; + flex-direction: column; + min-height: 350px; + padding-top: 20px; +} + +.auth-form h1 { + margin: 0 0 20px; + font-size: 27px; + line-height: 1.25; +} + +.auth-form label { + margin: 0 0 7px; + font-size: 13px; + font-weight: 700; +} + +.auth-form input { + width: 100%; + height: 46px; + margin-bottom: 16px; + border: 1px solid #777773; + border-radius: 0; + padding: 0 13px; + color: #111111; + background: #ffffff; +} + +.auth-form input:focus { + border-color: #111111; + outline: 2px solid #f2f500; +} + +.auth-code-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 142px; + gap: 10px; +} + +.auth-code-row input { + margin-bottom: 0; +} + +.auth-secondary, +.auth-primary { + height: 46px; + border: 1px solid #777773; + border-radius: 0; + font-weight: 700; +} + +.auth-secondary { + background: #ffffff; +} + +.auth-primary { + width: 100%; + margin-top: 14px; + color: #111111; + background: #f2f500; +} + +.auth-secondary:not(:disabled), +.auth-primary:not(:disabled) { + cursor: pointer; +} + +.auth-secondary:disabled, +.auth-primary:disabled { + color: #777773; + background: #deded9; +} + +.auth-status, +.auth-error { + min-height: 20px; + margin: 10px 0 0; + font-size: 13px; + line-height: 1.5; +} + +.auth-status { + color: #3c5b32; +} + +.auth-error { + border-left: 4px solid #c7432f; + padding: 8px 10px; + color: #8d281b; + background: #fff0ed; +} + +.auth-local-notice { + display: grid; + place-items: center; + min-width: 0; + padding: 0 18px; + color: #f2f500; + background: #111111; + font-size: 12px; + font-weight: 700; + text-align: center; +} + +@media (max-width: 760px) { + .auth-page { + grid-template-rows: 150px minmax(560px, 1fr) auto; + } + + .auth-art { + grid-template-columns: 42% 58%; + } + + .auth-wordmark { + padding: 0 18px; + font-size: 48px; + } + + .auth-art-copy { + gap: 30px; + padding: 18px; + } + + .auth-art-copy span { + font-size: 8px; + } + + .auth-art-copy strong { + font-size: 18px; + } + + .auth-art-block { + top: 54px; + right: 8%; + width: 33%; + height: 52px; + } + + .auth-art-line { + right: 2%; + bottom: 20px; + width: 44%; + height: 28px; + } + + .auth-content { + padding: 56px 20px 42px; + } + + .auth-admin-link { + top: 22px; + right: 20px; + } + + .auth-panel { + margin-left: 0; + } + + .auth-code-row { + grid-template-columns: minmax(0, 1fr) 126px; + } + + .auth-local-notice { + min-height: 56px; + padding-block: 12px; + } +} + +@media (max-width: 380px) { + .auth-code-row { + grid-template-columns: 1fr; + } + + .auth-secondary { + width: 100%; + } +} diff --git a/apps/web/src/user-auth.tsx b/apps/web/src/user-auth.tsx new file mode 100644 index 0000000..2db1c6e --- /dev/null +++ b/apps/web/src/user-auth.tsx @@ -0,0 +1,267 @@ +import { useEffect, useId, useRef, useState, type FormEvent } from "react"; + +import "./user-auth.css"; + +type AuthMode = "login" | "register"; +type SendState = "idle" | "sending" | "sent" | "error"; + +interface ErrorEnvelopeBody { + error?: { + details?: { field_errors?: Array<{ message_key?: string }> }; + message_key?: string; + }; +} + +const messageByKey: Record = { + "auth.account.suspended": "账号已暂停,请联系管理员。", + "auth.challenge.expired": "验证码已过期,请重新获取。", + "auth.challenge.invalid": "验证码不正确,请检查后重试。", + "auth.challenge.resend_too_soon": "请等待倒计时结束后重新获取验证码。", + "auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。", + "auth.login.admin_required": "此邮箱需从管理员登录入口进入。", + "auth.login.registration_required": "该邮箱尚未注册,请切换到注册。", + "auth.service.unavailable": "邮件服务暂时不可用,请稍后重试。", +}; + +function errorMessage(body: ErrorEnvelopeBody) { + const key = body.error?.details?.field_errors?.[0]?.message_key ?? body.error?.message_key; + return key ? (messageByKey[key] ?? "请求未完成,请检查后重试。") : "请求未完成,请检查后重试。"; +} + +function maskedEmail(email: string) { + const [local = "", domain = ""] = email.split("@", 2); + const visible = local.slice(0, Math.min(2, local.length)); + return `${visible}${"*".repeat(Math.max(3, local.length - visible.length))}@${domain}`; +} + +export function UserAuthPage() { + const emailId = useId(); + const codeId = useId(); + const inviteId = useId(); + const loginTab = useRef(null); + const registerTab = useRef(null); + const [mode, setMode] = useState("login"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [inviteCode, setInviteCode] = useState(""); + const [registrationId, setRegistrationId] = useState(); + const [sendState, setSendState] = useState("idle"); + const [countdown, setCountdown] = useState(0); + const [error, setError] = useState(); + const [submitting, setSubmitting] = useState(false); + const emailValid = /^[^@\s]+@[^@\s]+$/.test(email); + + useEffect(() => { + if (countdown <= 0) return; + const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000); + return () => window.clearInterval(timer); + }, [countdown]); + + function switchMode(nextMode: AuthMode) { + if (nextMode === mode) return; + setMode(nextMode); + setCode(""); + setInviteCode(""); + setRegistrationId(undefined); + setSendState("idle"); + setCountdown(0); + setError(undefined); + } + + function switchTab(nextMode: AuthMode) { + switchMode(nextMode); + window.requestAnimationFrame(() => (nextMode === "login" ? loginTab : registerTab).current?.focus()); + } + + async function sendCode() { + if (!emailValid || sendState === "sending" || countdown > 0) return; + setSendState("sending"); + setError(undefined); + const endpoint = mode === "login" ? "/api/v1/auth/login/send" : "/api/v1/auth/register/send"; + const payload = mode === "login" ? { email } : { email, invite_code: inviteCode }; + try { + const response = await fetch(endpoint, { + body: JSON.stringify(payload), + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string }; + if (!response.ok || !body.registration_id) { + setSendState("error"); + setError(errorMessage(body)); + return; + } + setRegistrationId(body.registration_id); + setSendState("sent"); + setCountdown(60); + } catch { + setSendState("error"); + setError(messageByKey["auth.service.unavailable"]); + } + } + + async function login(event: FormEvent) { + event.preventDefault(); + if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return; + setSubmitting(true); + setError(undefined); + try { + const response = await fetch("/api/v1/auth/login/complete", { + body: JSON.stringify({ registration_id: registrationId, verification_code: code }), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""), + }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody; + if (!response.ok) { + setError(errorMessage(body)); + return; + } + window.location.assign("/app"); + } catch { + setError("登录请求未完成,请重试。"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
DADA
+
+ LOCAL CREATIVE SYSTEM / WINDOWS P0-A + 在本机展开你的创作 +
+
+ +
+ 管理员登录 +
+
+ + +
+ + {mode === "login" ? ( +
+

邮箱验证码登录

+ + setEmail(event.target.value)} + placeholder="请输入邮箱" + type="email" + value={email} + /> + +
+ setCode(event.target.value.replace(/\D/g, ""))} + placeholder="6 位验证码" + value={code} + /> + +
+ {sendState === "sent" ? ( +

验证码已发送至 {maskedEmail(email)}

+ ) : null} + {error ?

{error}

: null} + +
+ ) : ( +
+

邀请码注册

+ + setInviteCode(event.target.value)} + value={inviteCode} + /> + + setEmail(event.target.value)} + type="email" + value={email} + /> + + {error ?

{error}

: null} +
+ )} +
+
+ +
+ 测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。 +
+
+ ); +} diff --git a/openapi/openapi.json b/openapi/openapi.json index 67d243c..9b33945 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -639,6 +639,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, @@ -929,6 +947,110 @@ } ] }, + "LoginCompleteRequest": { + "additionalProperties": false, + "properties": { + "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" + }, + "verification_code": { + "pattern": "^[0-9]{6}$", + "type": "string" + } + }, + "required": [ + "registration_id", + "verification_code" + ], + "type": "object" + }, + "LoginCompleteResponse": { + "additionalProperties": false, + "properties": { + "audience": { + "enum": [ + "user" + ], + "type": "string" + }, + "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": [ + "authenticated" + ], + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "audience", + "credits", + "session_expires_at", + "status", + "user" + ], + "type": "object" + }, + "LoginSendRequest": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 320, + "pattern": "^[^@\\s]{1,128}@[^@\\s]{1,190}$", + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "LogoutHeaders": { + "additionalProperties": true, + "properties": { + "idempotency-key": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "x-csrf-token": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + "required": [ + "idempotency-key", + "x-csrf-token" + ], + "type": "object" + }, + "LogoutResponse": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "logged_out" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, "ModelConfigSseEvent": { "additionalProperties": false, "properties": { @@ -1336,6 +1458,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, @@ -1424,6 +1564,243 @@ }, "openapi": "3.1.0", "paths": { + "/api/v1/auth/login/complete": { + "post": { + "operationId": "completeLogin", + "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/LoginCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginCompleteResponse" + } + } + }, + "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" + }, + "429": { + "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/login/send": { + "post": { + "operationId": "sendLoginCode", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginSendRequest" + } + } + }, + "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" + }, + "429": { + "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/logout": { + "post": { + "operationId": "logoutUser", + "parameters": [ + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "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/complete": { "post": { "operationId": "completeRegistration", @@ -1541,6 +1918,16 @@ }, "description": "Default Response" }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, "503": { "content": { "application/json": { @@ -1869,6 +2256,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, @@ -2333,6 +2738,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, @@ -2838,6 +3261,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, diff --git a/package.json b/package.json index 0420146..dba05c9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -41,7 +41,9 @@ "test:wp0-09": "node scripts/run-wp0-09-validation.mjs", "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" + "test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red", + "test:wp1-02": "node scripts/run-wp1-02-validation.mjs", + "test:wp1-02:red": "node scripts/run-wp1-02-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 dab1ed8..59f76b7 100644 --- a/packages/shared-contracts/src/api.ts +++ b/packages/shared-contracts/src/api.ts @@ -19,6 +19,9 @@ export const stableEngineeringErrors = { 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" }, + AUTH_ENTRY_REJECTED: { httpStatus: 409, messageKey: "auth.entry.rejected" }, + AUTH_RATE_LIMITED: { httpStatus: 429, messageKey: "auth.rate_limited" }, + AUTH_CSRF_INVALID: { httpStatus: 403, messageKey: "auth.csrf.invalid" }, } as const; export type StableEngineeringErrorCode = keyof typeof stableEngineeringErrors; @@ -55,6 +58,9 @@ export const StableEngineeringErrorCodeSchema = Type.Union( Type.Literal("IDEMPOTENCY_KEY_CONFLICT"), Type.Literal("AUTH_SESSION_INVALID"), Type.Literal("AUTH_SERVICE_UNAVAILABLE"), + Type.Literal("AUTH_ENTRY_REJECTED"), + Type.Literal("AUTH_RATE_LIMITED"), + Type.Literal("AUTH_CSRF_INVALID"), ], { $id: "StableEngineeringErrorCode" }, ); diff --git a/packages/shared-contracts/src/auth.ts b/packages/shared-contracts/src/auth.ts index abe54d4..a01a909 100644 --- a/packages/shared-contracts/src/auth.ts +++ b/packages/shared-contracts/src/auth.ts @@ -82,8 +82,49 @@ export const UserSessionResponseSchema = Type.Object( { additionalProperties: false, $id: "UserSessionResponse" }, ); +export const LoginSendRequestSchema = Type.Object( + { + email: Type.String({ maxLength: 320, pattern: emailPattern }), + }, + { additionalProperties: false, $id: "LoginSendRequest" }, +); + +export const LoginCompleteRequestSchema = Type.Object( + { + registration_id: Type.String({ pattern: uuidPattern }), + verification_code: Type.String({ pattern: "^[0-9]{6}$" }), + }, + { additionalProperties: false, $id: "LoginCompleteRequest" }, +); + +export const LoginCompleteResponseSchema = Type.Object( + { + audience: Type.Literal("user"), + credits: Type.Ref(CreditSummarySchema), + session_expires_at: Type.String({ pattern: isoTimestampPattern }), + status: Type.Literal("authenticated"), + user: Type.Ref(AuthenticatedUserSchema), + }, + { additionalProperties: false, $id: "LoginCompleteResponse" }, +); + +export const LogoutHeadersSchema = Type.Object( + { + "idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }), + "x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }), + }, + { additionalProperties: true, $id: "LogoutHeaders" }, +); + +export const LogoutResponseSchema = Type.Object( + { status: Type.Literal("logged_out") }, + { additionalProperties: false, $id: "LogoutResponse" }, +); + export type RegistrationSendRequest = Static; export type RegistrationSendResponse = Static; export type RegistrationCompleteRequest = Static; export type RegistrationCompleteResponse = Static; export type UserSessionResponse = Static; +export type LoginSendRequest = Static; +export type LoginCompleteRequest = Static; diff --git a/scripts/run-wp1-02-validation.mjs b/scripts/run-wp1-02-validation.mjs new file mode 100644 index 0000000..f45dab1 --- /dev/null +++ b/scripts/run-wp1-02-validation.mjs @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, 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-02-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const cases = [ + "TDD-WP1-AUTH-003-entry-state-matrix", + "TDD-WP1-AUTH-004-challenge-guards", + "TDD-WP1-AUTH-004-session-revocation", +]; +const directories = Object.fromEntries(cases.map((id) => [id, resolve(runDirectory, "cases", id)])); +const playwrightDirectory = resolve(runDirectory, "playwright"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const directory of Object.values(directories)) mkdirSync(directory, { recursive: true }); + +const commandsToRun = phase === "red" + ? [ + ["pnpm exec vitest run tests/integration/wp1-02-auth-state.test.ts", ["exec", "vitest", "run", "tests/integration/wp1-02-auth-state.test.ts"]], + ["pnpm exec vitest run tests/api/wp1-02-login-session.test.ts", ["exec", "vitest", "run", "tests/api/wp1-02-login-session.test.ts"]], + ["pnpm exec playwright test tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts --config playwright.config.ts", ["exec", "playwright", "test", "tests/e2e/user-auth.spec.ts", "tests/e2e/entry-state-ui.spec.ts", "tests/e2e/session-invalid-ui.spec.ts", "--config", "playwright.config.ts"]], + ] + : [ + ["pnpm test:api", ["test:api"]], + ["pnpm test:e2e", ["test:e2e"]], + ["pnpm test:integration", ["test:integration"]], + ["pnpm validate:tdd-trace", ["validate:tdd-trace"]], + ]; +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_AUTH_GUARDS: directories[cases[1]], + DADA_EVIDENCE_DIR_AUTH_MATRIX: directories[cases[0]], + DADA_EVIDENCE_DIR_AUTH_REVOKE: directories[cases[2]], + DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory, +}; +const startedAt = new Date().toISOString(); +const commandResults = []; +for (const [command, args] of commandsToRun) { + 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); + commandResults.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), started_at }); +} + +function find(root, name) { + if (!existsSync(root)) return []; + return readdirSync(root).flatMap((entry) => { + const child = resolve(root, entry); + return statSync(child).isDirectory() ? find(child, name) : entry === name ? [child] : []; + }); +} +if (phase === "green") { + const traces = find(playwrightDirectory, "trace.zip"); + const entryTrace = traces.find((path) => path.replaceAll("\\", "/").includes("entry-state-ui")); + const revocationTrace = traces.find((path) => path.replaceAll("\\", "/").includes("session-invalid-ui")); + if (entryTrace) copyFileSync(entryTrace, resolve(directories[cases[0]], "trace.zip")); + if (revocationTrace) copyFileSync(revocationTrace, resolve(directories[cases[2]], "trace.zip")); +} + +const commands = { commands: commandResults, phase, run_id: runId, schema_version: "1.0" }; +for (const directory of Object.values(directories)) writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify(commands, null, 2)}\n`); +const expectedEvidence = { + [cases[0]]: ["response.json", "db-diff.json", "trace.zip", "screenshots/entry-state.png"], + [cases[1]]: ["response.json", "db-diff.json", "external-calls.json"], + [cases[2]]: ["response.json", "db-diff.json", "trace.zip", "screenshots/revoked.png"], +}; +const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }; +const commandState = phase === "red" ? commandResults.every((item) => item.exit_code !== 0) : commandResults.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-22", "AC-33", "AC-49"], automation: ["automated"], commit, + environment: { arch: process.arch, node: process.version.slice(1), os: process.platform }, evidence_refs, + finished_at: new Date().toISOString(), layer: ["API", "E2E", "DB"], 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-04", "AUTH-05", "AUTH-06", "AUTH-07"], + run_id: runId, schema_version: "1.0", started_at: startedAt, status, + task_id: "TASK-WP1-02", 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 84fe072..bf224a3 100644 --- a/tests/api/wp0-02-schema-envelope.test.ts +++ b/tests/api/wp0-02-schema-envelope.test.ts @@ -112,6 +112,9 @@ describe("TDD-WP0-API-001 schema envelope", () => { expect(Object.keys(stableEngineeringErrors).sort()).toEqual([ "ASSET_CLEANUP_CANDIDATE_STALE", "ASSET_HISTORY_REFERENCE_CONFLICT", + "AUTH_CSRF_INVALID", + "AUTH_ENTRY_REJECTED", + "AUTH_RATE_LIMITED", "AUTH_SERVICE_UNAVAILABLE", "AUTH_SESSION_INVALID", "BROWSER_UNSUPPORTED", diff --git a/tests/api/wp1-02-login-session.test.ts b/tests/api/wp1-02-login-session.test.ts new file mode 100644 index 0000000..d36096c --- /dev/null +++ b/tests/api/wp1-02-login-session.test.ts @@ -0,0 +1,145 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +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"; + +const roots: string[] = []; +const services: RegistrationService[] = []; +const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; + +function harness() { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-02-api-")); + roots.push(root); + const resend = new MockResendAdapter(); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x61), + codeGenerator: () => "621904", + currentPrivacyNoticeVersion: "p0a-notice-v1", + databasePath: join(root, "dada.sqlite3"), + invitePepper: Buffer.alloc(32, 0x62), + resend, + sessionPepper: Buffer.alloc(32, 0x63), + }); + services.push(registration); + return { registration, resend }; +} + +function seedUser(registration: RegistrationService, email: string, status: "active" | "suspended" = "active") { + const userId = randomUUID(); + registration.database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, 'user', ?, 1, ?, ?) + `).run(userId, email, status, randomUUID(), Date.now()); + registration.database.prepare(` + INSERT INTO user_profiles ( + user_id, creator_name, social_id, private_content_notice_version, + private_content_notice_acknowledged_at + ) VALUES (?, 'API User', '@api_user', NULL, NULL) + `).run(userId); + registration.database.prepare(` + INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) + VALUES (?, 10, 0, ?) + `).run(userId, Date.now()); + return userId; +} + +afterEach(() => { + for (const service of services.splice(0)) service.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TASK-WP1-02 login and session API", () => { + it("logs an active user in, issues CSRF, and revokes all sessions on logout", async () => { + const { registration, resend } = harness(); + const userId = seedUser(registration, "login-api@example.invalid"); + registration.issueAuthenticatedSession(userId, "user"); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + + const sent = await app.inject({ + headers: writeHeaders, + method: "POST", + payload: { email: "login-api@example.invalid" }, + url: "/api/v1/auth/login/send", + }); + expect(sent.statusCode).toBe(200); + const flowCookie = sent.headers["set-cookie"]; + + const completed = await app.inject({ + headers: { ...writeHeaders, cookie: flowCookie, "idempotency-key": "wp1-02-api-login-complete-000000001" }, + method: "POST", + payload: { + registration_id: sent.json().registration_id, + verification_code: resend.readLatestCode("login-api@example.invalid"), + }, + url: "/api/v1/auth/login/complete", + }); + expect(completed.statusCode).toBe(200); + expect(completed.json()).toMatchObject({ audience: "user", status: "authenticated" }); + const sessionCookie = completed.headers["set-cookie"]; + + const session = await app.inject({ + headers: { cookie: sessionCookie, host: writeHeaders.host }, + method: "GET", + url: "/api/v1/auth/session", + }); + expect(session.statusCode).toBe(200); + expect(session.json().csrf_token).toMatch(/^[A-Za-z0-9_-]{43}$/); + + const logout = await app.inject({ + headers: { + ...writeHeaders, + cookie: sessionCookie, + "idempotency-key": "wp1-02-api-logout-00000000000001", + "x-csrf-token": session.json().csrf_token, + }, + method: "POST", + url: "/api/v1/auth/logout", + }); + expect(logout.statusCode).toBe(200); + expect(logout.json()).toEqual({ status: "logged_out" }); + + const revoked = await app.inject({ + headers: { cookie: sessionCookie, host: writeHeaders.host }, + method: "GET", + url: "/api/v1/auth/session", + }); + expect(revoked.statusCode).toBe(401); + await app.close(); + }); + + it("returns stable entry-state and 429 errors without switching flows", async () => { + const { registration } = harness(); + seedUser(registration, "suspended-api@example.invalid", "suspended"); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + const suspended = await app.inject({ + headers: writeHeaders, + method: "POST", + payload: { email: "suspended-api@example.invalid" }, + url: "/api/v1/auth/login/send", + }); + expect(suspended.statusCode).toBe(409); + expect(suspended.json()).toMatchObject({ + error: { code: "AUTH_ENTRY_REJECTED", details: { field_errors: [{ message_key: "auth.account.suspended" }] } }, + }); + + const missing = await app.inject({ + headers: writeHeaders, + method: "POST", + payload: { email: "missing-api@example.invalid" }, + url: "/api/v1/auth/login/send", + }); + expect(missing.statusCode).toBe(409); + expect(missing.json()).toMatchObject({ + error: { details: { field_errors: [{ message_key: "auth.login.registration_required" }] } }, + }); + await app.close(); + }); +}); diff --git a/tests/e2e/entry-state-ui.spec.ts b/tests/e2e/entry-state-ui.spec.ts new file mode 100644 index 0000000..87f6f16 --- /dev/null +++ b/tests/e2e/entry-state-ui.spec.ts @@ -0,0 +1,40 @@ +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +test.beforeAll(async () => { + vite = await createServer({ + configFile: resolve("apps/web/vite.config.ts"), + root: resolve("apps/web"), + server: { host: "127.0.0.1", port: 0 }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP1-AUTH-003 keeps registration and login as separate keyboard entries", async ({ page }) => { + await page.goto(webUrl); + const loginTab = page.getByRole("tab", { name: "登录" }); + const registerTab = page.getByRole("tab", { name: "注册" }); + + await expect(loginTab).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("textbox", { name: "邀请码" })).toHaveCount(0); + await loginTab.focus(); + await page.keyboard.press("ArrowRight"); + await expect(registerTab).toBeFocused(); + await expect(registerTab).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("textbox", { name: "邀请码" })).toBeVisible(); + await page.keyboard.press("ArrowLeft"); + await expect(loginTab).toBeFocused(); + await expect(page.getByRole("textbox", { name: "邀请码" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin"); + await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible(); +}); diff --git a/tests/e2e/session-invalid-ui.spec.ts b/tests/e2e/session-invalid-ui.spec.ts new file mode 100644 index 0000000..a68b06a --- /dev/null +++ b/tests/e2e/session-invalid-ui.spec.ts @@ -0,0 +1,43 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +test.beforeAll(async () => { + vite = await createServer({ + configFile: resolve("apps/web/vite.config.ts"), + root: resolve("apps/web"), + server: { host: "127.0.0.1", port: 0 }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP1-AUTH-004 purges the opened surface on session invalidation", async ({ page }) => { + await page.goto(webUrl); + await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible(); + const email = page.getByLabel("邮箱"); + await email.fill("private-local-state"); + await expect(email).toHaveValue("private-local-state"); + + await page.evaluate(() => { + window.dispatchEvent(new Event("dada:session-invalid")); + }); + + await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible(); + await expect(page.getByLabel("邮箱")).toHaveValue(""); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_AUTH_REVOKE; + if (evidenceDirectory) { + mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true }); + await page.screenshot({ fullPage: true, path: resolve(evidenceDirectory, "screenshots", "revoked.png") }); + } +}); diff --git a/tests/e2e/user-auth.spec.ts b/tests/e2e/user-auth.spec.ts new file mode 100644 index 0000000..5bb626d --- /dev/null +++ b/tests/e2e/user-auth.spec.ts @@ -0,0 +1,88 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +// Raw traces for these mocked auth requests would retain the submitted email. +test.use({ trace: "off" }); + +test.beforeAll(async () => { + vite = await createServer({ + configFile: resolve("apps/web/vite.config.ts"), + root: resolve("apps/web"), + server: { host: "127.0.0.1", port: 0 }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP1-AUTH-003 renders Z0pf8 login states without silently switching entry", async ({ page }) => { + await page.route("**/api/v1/auth/login/send", async (route) => { + await route.fulfill({ + contentType: "application/json", + status: 409, + body: JSON.stringify({ + error: { + code: "AUTH_ENTRY_REJECTED", + correlation_id: "00000000-0000-4000-8000-000000000001", + details: { field_errors: [{ field: "email", message_key: "auth.account.suspended" }] }, + message_key: "auth.entry.rejected", + }, + }), + }); + }); + await page.goto(webUrl); + await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible(); + await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible(); + await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin"); + await expect(page.getByLabel("邀请码")).toHaveCount(0); + await expect(page.getByLabel("创作署名")).toHaveCount(0); + await expect(page.getByLabel("社交 ID")).toHaveCount(0); + + await page.getByRole("tab", { name: "登录" }).focus(); + await page.keyboard.press("Tab"); + await expect(page.getByLabel("邮箱")).toBeFocused(); + await page.getByLabel("邮箱").fill("suspended@example.invalid"); + await page.getByRole("button", { name: "获取验证码" }).click(); + await expect(page.getByRole("alert")).toContainText("账号已暂停"); + await expect(page.getByRole("tab", { name: "登录" })).toHaveAttribute("aria-selected", "true"); + await page.getByLabel("邮箱").fill(""); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_AUTH_MATRIX; + if (evidenceDirectory) { + mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true }); + await page.screenshot({ fullPage: true, path: resolve(evidenceDirectory, "screenshots", "entry-state.png") }); + } +}); + +test("TDD-WP1-AUTH-004 login controls remain stable on mobile and loading states", async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); + await page.route("**/api/v1/auth/login/send", async (route) => { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 150)); + await route.fulfill({ + contentType: "application/json", + status: 200, + body: JSON.stringify({ + challenge_expires_at: "2026-07-28T08:10:00.000Z", + registration_id: "00000000-0000-4000-8000-000000000002", + resend_available_at: "2026-07-28T08:01:00.000Z", + status: "verification_sent", + }), + }); + }); + await page.goto(webUrl); + await page.getByLabel("邮箱").fill("mobile@example.invalid"); + const send = page.getByRole("button", { name: "获取验证码" }); + await send.click(); + await expect(page.getByRole("button", { name: "发送中" })).toBeDisabled(); + await expect(page.getByText(/验证码已发送至/)).toBeVisible(); + await expect(page.locator("body")).not.toHaveCSS("overflow-x", "scroll"); +}); diff --git a/tests/integration/wp1-02-auth-state.test.ts b/tests/integration/wp1-02-auth-state.test.ts new file mode 100644 index 0000000..b59b499 --- /dev/null +++ b/tests/integration/wp1-02-auth-state.test.ts @@ -0,0 +1,283 @@ +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 } 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 roots: string[] = []; +const services: RegistrationService[] = []; + +function createHarness() { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-02-")); + roots.push(root); + const databasePath = join(root, "dada.sqlite3"); + const resend = new MockResendAdapter(); + let now = Date.parse("2026-07-28T08:00:00.000Z"); + let inviteSequence = 0; + let codeSequence = 100_000; + const service = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x51), + clock: () => now, + codeGenerator: () => String(codeSequence++), + currentPrivacyNoticeVersion: "p0a-notice-v1", + databasePath, + inviteCodeGenerator: () => `DADA-WP1-02-${String(inviteSequence++).padStart(3, "0")}`, + invitePepper: Buffer.alloc(32, 0x52), + resend, + sessionPepper: Buffer.alloc(32, 0x53), + }); + services.push(service); + return { + advance(milliseconds: number) { now += milliseconds; }, + databasePath, + now: () => now, + resend, + service, + }; +} + +function withDatabase(databasePath: string, operation: (database: any) => T): T { + const database = new Database(databasePath); + try { return operation(database); } finally { database.close(); } +} + +function seedUser( + databasePath: string, + input: { email: string; role?: "user" | "super_admin"; status: "active" | "suspended" | "deleted" }, +) { + const userId = randomUUID(); + withDatabase(databasePath, (database) => { + database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(userId, input.email, input.role ?? "user", input.status, input.role === "super_admin" ? 0 : 1, randomUUID(), Date.now()); + 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, "Fixture User", "@fixture_user"); + database.prepare(` + INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) + VALUES (?, 10, 0, ?) + `).run(userId, Date.now()); + if (input.role === "super_admin") { + database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); + } + }); + return userId; +} + +function writeEvidence(environmentName: string, file: string, value: unknown) { + const directory = process.env[environmentName]; + 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-003-entry-state-matrix", () => { + it("keeps register and login entry behavior separate for every account state", async () => { + const harness = createHarness(); + const invite = harness.service.createInvite({ expiresAt: harness.now() + 86_400_000, maxUses: 4 }); + seedUser(harness.databasePath, { email: "active@example.invalid", status: "active" }); + seedUser(harness.databasePath, { email: "deleted@example.invalid", status: "deleted" }); + seedUser(harness.databasePath, { email: "suspended@example.invalid", status: "suspended" }); + + const registrationResults: Record = {}; + for (const state of ["unregistered", "active", "deleted", "suspended"] as const) { + const email = state === "unregistered" ? "new@example.invalid" : `${state}@example.invalid`; + try { + const sent = await harness.service.sendRegistrationCode({ email, inviteCode: invite.code }); + registrationResults[state] = sent.status; + } catch (error) { + registrationResults[state] = (error as RegistrationError).reason; + } + harness.advance(61_000); + } + expect(registrationResults).toEqual({ + active: "registration_login_required", + deleted: "verification_sent", + suspended: "account_suspended", + unregistered: "verification_sent", + }); + + const loginResults: Record = {}; + for (const state of ["unregistered", "active", "deleted", "suspended"] as const) { + const email = state === "unregistered" ? "missing@example.invalid" : `${state}@example.invalid`; + try { + const sent = await harness.service.sendLoginCode({ clientKey: `matrix-${state}`, email }); + loginResults[state] = sent.status; + if (state === "active") { + const completed = harness.service.completeLogin({ + clientKey: `matrix-${state}`, + code: harness.resend.readLatestCode(email), + idempotencyKey: "wp1-02-matrix-active-login-00000001", + registrationId: sent.registrationId, + }); + expect(harness.service.readUserSession(completed.sessionToken)?.audience).toBe("user"); + } + } catch (error) { + loginResults[state] = (error as RegistrationError).reason; + } + harness.advance(61_000); + } + expect(loginResults).toEqual({ + active: "verification_sent", + deleted: "login_registration_required", + suspended: "account_suspended", + unregistered: "login_registration_required", + }); + + const deletedRows = withDatabase(harness.databasePath, (database) => database.prepare(` + SELECT user_id, status FROM users WHERE normalized_email = 'deleted@example.invalid' ORDER BY created_at + `).all()); + expect(deletedRows).toHaveLength(1); + expect(deletedRows[0].status).toBe("deleted"); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_MATRIX", "response.json", { login: loginResults, registration: registrationResults }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_MATRIX", "db-diff.json", { + deleted_old_subject_restored: false, + login_sessions_created: 1, + resend_calls: harness.resend.calls.length, + }); + }); +}); + +describe("TDD-WP1-AUTH-004-challenge-guards", () => { + it("enforces one use, ten minutes, sixty seconds and consecutive-failure limiting", async () => { + const harness = createHarness(); + seedUser(harness.databasePath, { email: "guard@example.invalid", status: "active" }); + + const first = await harness.service.sendLoginCode({ clientKey: "guard-flow", email: "guard@example.invalid" }); + const firstCode = harness.resend.readLatestCode("guard@example.invalid"); + const loggedIn = harness.service.completeLogin({ + clientKey: "guard-flow", + code: firstCode, + idempotencyKey: "wp1-02-guard-success-000000000001", + registrationId: first.registrationId, + }); + expect(harness.service.readUserSession(loggedIn.sessionToken)).toBeDefined(); + expect(() => harness.service.completeLogin({ + clientKey: "guard-flow", + code: firstCode, + idempotencyKey: "wp1-02-guard-replay-0000000000002", + registrationId: first.registrationId, + })).toThrowError(expect.objectContaining({ reason: "challenge_invalid" })); + + harness.advance(61_000); + const expiring = await harness.service.sendLoginCode({ clientKey: "guard-flow", email: "guard@example.invalid" }); + const expiringCode = harness.resend.readLatestCode("guard@example.invalid"); + harness.advance(600_001); + expect(() => harness.service.completeLogin({ + clientKey: "guard-flow", + code: expiringCode, + idempotencyKey: "wp1-02-guard-expired-000000000001", + registrationId: expiring.registrationId, + })).toThrowError(expect.objectContaining({ reason: "challenge_expired" })); + + const resendBlocked = await harness.service.sendLoginCode({ clientKey: "resend-flow", email: "guard@example.invalid" }); + await expect(harness.service.sendLoginCode({ clientKey: "resend-flow", email: "guard@example.invalid" })) + .rejects.toMatchObject({ code: "AUTH_RATE_LIMITED", httpStatus: 429, reason: "resend_too_soon" }); + + harness.advance(61_000); + const guarded = await harness.service.sendLoginCode({ clientKey: "failure-flow", email: "guard@example.invalid" }); + for (let attempt = 0; attempt < 4; attempt += 1) { + expect(() => harness.service.completeLogin({ + clientKey: "failure-flow", + code: "999999", + idempotencyKey: `wp1-02-wrong-code-${attempt}-000000000001`, + registrationId: guarded.registrationId, + })).toThrowError(expect.objectContaining({ reason: "challenge_invalid" })); + } + expect(() => harness.service.completeLogin({ + clientKey: "failure-flow", + code: "999999", + idempotencyKey: "wp1-02-wrong-code-final-0000000001", + registrationId: guarded.registrationId, + })).toThrowError(expect.objectContaining({ code: "AUTH_RATE_LIMITED", httpStatus: 429, reason: "too_many_attempts" })); + + const databaseState = withDatabase(harness.databasePath, (database) => ({ + consumed: database.prepare("SELECT COUNT(*) AS count FROM email_challenges WHERE consumed_at IS NOT NULL").get().count, + failed: database.prepare("SELECT MAX(failure_count) AS count FROM email_challenges").get().count, + sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, + })); + expect(databaseState).toEqual({ consumed: 1, failed: 5, sessions: 1 }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "response.json", { + expired: "challenge_expired", + replay: "challenge_invalid", + resend: "resend_too_soon", + rate_limit: "too_many_attempts", + }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "db-diff.json", databaseState); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "external-calls.json", { + resend_calls: harness.resend.calls.length, + resend_calls_after_block: harness.resend.calls.length, + blocked_challenge_id: resendBlocked.registrationId, + }); + }); +}); + +describe("TDD-WP1-AUTH-004-session-revocation", () => { + it.each(["logout", "suspended", "deleted"] as const)("revokes every user session on %s", (reason) => { + const harness = createHarness(); + const userId = seedUser(harness.databasePath, { email: `${reason}@example.invalid`, status: "active" }); + const first = harness.service.issueAuthenticatedSession(userId, "user"); + const second = harness.service.issueAuthenticatedSession(userId, "user"); + expect(harness.service.readUserSession(first.sessionToken)).toBeDefined(); + expect(harness.service.readUserSession(second.sessionToken)).toBeDefined(); + + if (reason === "logout") { + const csrf = harness.service.issueUserCsrfToken(first.sessionToken); + harness.service.logoutUser({ csrfToken: csrf, sessionToken: first.sessionToken }); + } else { + harness.service.changeUserStatus(userId, reason); + } + + expect(harness.service.readUserSession(first.sessionToken)).toBeUndefined(); + expect(harness.service.readUserSession(second.sessionToken)).toBeUndefined(); + const state = withDatabase(harness.databasePath, (database) => ({ + revoked: database.prepare("SELECT COUNT(*) AS count FROM sessions WHERE user_id = ? AND revoked_at IS NOT NULL").get(userId).count, + status: database.prepare("SELECT status FROM users WHERE user_id = ?").get(userId).status, + })); + expect(state.revoked).toBe(2); + expect(state.status).toBe(reason === "logout" ? "active" : reason); + }); + + it.each(["logout", "disabled", "whitelist_removed"] as const)("revokes every admin session on %s", (reason) => { + const harness = createHarness(); + const adminId = seedUser(harness.databasePath, { email: `admin-${reason}@example.invalid`, role: "super_admin", status: "active" }); + const first = harness.service.issueAuthenticatedSession(adminId, "admin"); + const second = harness.service.issueAuthenticatedSession(adminId, "admin"); + expect(harness.service.readAdminSession(first.sessionToken)).toBeDefined(); + expect(harness.service.readUserSession(first.sessionToken)).toBeUndefined(); + harness.service.revokeAdminSessions(adminId, reason); + expect(harness.service.readAdminSession(first.sessionToken)).toBeUndefined(); + expect(harness.service.readAdminSession(second.sessionToken)).toBeUndefined(); + expect(harness.service.readAdminSession(harness.service.issueAuthenticatedSession( + seedUser(harness.databasePath, { email: `audience-${reason}@example.invalid`, role: "super_admin", status: "active" }), + "admin", + ).sessionToken)).toBeDefined(); + }); + + it("writes aggregate revocation evidence", () => { + writeEvidence("DADA_EVIDENCE_DIR_AUTH_REVOKE", "response.json", { + admin_reasons: ["logout", "disabled", "whitelist_removed"], + audience_separation: true, + user_reasons: ["logout", "suspended", "deleted"], + }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_REVOKE", "db-diff.json", { sessions_revoked_per_subject: 2 }); + }); +}); -- 2.54.0 From 4d81f9c723315921eaaa273e8bca73588bb2ccb9 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 28 Jul 2026 16:32:50 +0800 Subject: [PATCH 003/101] docs: add post-v1 development conventions --- DEVELOPMENT_CONVENTIONS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 DEVELOPMENT_CONVENTIONS.md diff --git a/DEVELOPMENT_CONVENTIONS.md b/DEVELOPMENT_CONVENTIONS.md new file mode 100644 index 0000000..09cf554 --- /dev/null +++ b/DEVELOPMENT_CONVENTIONS.md @@ -0,0 +1,12 @@ +# Dada 后续问题修改开发约定 + +## 适用范围 + +本约定在“Dada P0-A Windows 单机内测版”完整开发完成后生效,仅适用于该版完成后发现问题所产生的修改和相关工单;该版完成之前的当前第一版开发不适用本约定。 + +## 开发约定 + +- 修复任何工单必须在独立的 git worktree 中进行;开工前先用 `git worktree add` 创建专属工作目录,避免污染主工作区、便于多工单并行。修 CI 配置(Dockerfile、Drone 流水线等)可以直接在主仓库改,因为 CI 改动要打 tag 才能触发构建。 +- 处理任何工单必须先检查并使用适用的 Superpowers Skill;在分析、提问、制定计划或改代码前,至少先启用 `using-superpowers`,并按任务性质继续使用 `systematic-debugging`、`test-driven-development`、`using-git-worktrees`、`verification-before-completion` 等相关技能。若判断没有适用技能,必须简短说明原因后再继续。 +- 新功能需求类工单必须先使用 Superpowers 的 `brainstorming` 技能帮助澄清目标、约束和方案,再进入计划或实现;缺陷类工单必须先使用 `systematic-debugging` 技能复现问题并分析 root cause,再开始修复,禁止在根因未明确时直接改代码。 +- 实现或修复工单完成后,必须继续按 Superpowers 收尾流程执行验证、代码审查、PR/合并准备和工作区清理;通常应依次使用 `verification-before-completion`、`requesting-code-review`、`finishing-a-development-branch` 等适用技能,在完成这些流程前不得声称工单已结束。 -- 2.54.0 From aec4c83ecaeaae99e8c65c1bcbcc24bffaf17487 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 28 Jul 2026 16:48:49 +0800 Subject: [PATCH 004/101] feat: implement TASK-WP1-03 registration notice --- apps/web/src/user-auth.css | 192 +++++++++++ apps/web/src/user-auth.tsx | 297 ++++++++++++++++-- package.json | 6 +- packages/shared-contracts/src/index.ts | 1 + .../src/registration-notice.ts | 39 +++ scripts/run-wp1-03-validation.mjs | 111 +++++++ tests/api/wp1-03-registration-consent.test.ts | 127 ++++++++ tests/e2e/user-registration.spec.ts | 119 +++++++ tests/integration/wp1-03-slot-limit.test.ts | 155 +++++++++ tests/unit/wp1-03-notice.test.ts | 35 +++ 10 files changed, 1049 insertions(+), 33 deletions(-) create mode 100644 packages/shared-contracts/src/registration-notice.ts create mode 100644 scripts/run-wp1-03-validation.mjs create mode 100644 tests/api/wp1-03-registration-consent.test.ts create mode 100644 tests/e2e/user-registration.spec.ts create mode 100644 tests/integration/wp1-03-slot-limit.test.ts create mode 100644 tests/unit/wp1-03-notice.test.ts diff --git a/apps/web/src/user-auth.css b/apps/web/src/user-auth.css index f5afd8b..8207328 100644 --- a/apps/web/src/user-auth.css +++ b/apps/web/src/user-auth.css @@ -30,6 +30,10 @@ input { outline-offset: 3px; } +button { + letter-spacing: 0; +} + .auth-page { min-height: 100vh; display: grid; @@ -157,6 +161,10 @@ input { line-height: 1.25; } +.auth-registration-form { + min-height: 520px; +} + .auth-form label { margin: 0 0 7px; font-size: 13px; @@ -231,6 +239,169 @@ input { color: #3c5b32; } +.auth-registration-status { + align-self: flex-end; + margin: -52px 0 14px; + padding: 6px 10px; + color: #f2f500; + background: #111111; + font-size: 12px; + font-weight: 700; +} + +.auth-verified-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 8px; +} + +.auth-verified-grid > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 7px; + min-height: 64px; + border: 1px solid #b4b4af; + padding: 10px; + background: #ffffff; +} + +.auth-verified-grid strong { + color: #2f6b45; + font-size: 11px; +} + +.auth-verified-grid span { + overflow: hidden; + font-family: Consolas, monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auth-text-action { + align-self: flex-end; + border: 0; + padding: 4px 0; + color: #075f8f; + background: transparent; + font-size: 12px; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.auth-registration-form > .auth-text-action { + margin-bottom: 12px; +} + +.auth-consent { + border: 1px solid #b4b4af; + padding: 10px; + background: #ffffff; +} + +.auth-consent-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 9px; + font-size: 13px; +} + +.auth-checkbox { + display: grid; + grid-template-columns: 20px 1fr; + align-items: start; + gap: 8px; + margin: 0; + font-weight: 600; + line-height: 1.5; +} + +.auth-form .auth-checkbox input { + width: 18px; + height: 18px; + margin: 1px 0 0; + accent-color: #111111; +} + +.auth-consent p { + margin: 8px 0 0 28px; + color: #8d281b; + font-size: 11px; +} + +.auth-dialog-backdrop { + position: fixed; + z-index: 20; + inset: 0; + display: grid; + place-items: center; + padding: 28px; + background: rgba(17, 17, 17, 0.82); +} + +.auth-dialog { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(720px, 100%); + max-height: min(760px, calc(100vh - 56px)); + border: 2px solid #111111; + background: #f6f6f4; + box-shadow: 12px 12px 0 #f2f500; +} + +.auth-dialog > header { + border-bottom: 1px solid #111111; + padding: 22px 24px 18px; +} + +.auth-dialog header p { + margin: 0 0 6px; + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.auth-dialog h2 { + margin: 0; + font-size: 24px; +} + +.auth-dialog-content { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 24px; + overflow-y: auto; + padding: 6px 24px 18px; +} + +.auth-dialog-content section { + border-bottom: 1px solid #ccccb9; + padding: 14px 0; +} + +.auth-dialog-content h3 { + margin: 0 0 6px; + font-size: 15px; +} + +.auth-dialog-content p { + margin: 0; + font-size: 13px; + line-height: 1.7; +} + +.auth-dialog > footer { + border-top: 1px solid #111111; + padding: 0 24px 18px; + background: #f6f6f4; +} + .auth-error { border-left: 4px solid #c7432f; padding: 8px 10px; @@ -304,6 +475,27 @@ input { margin-left: 0; } + .auth-registration-status { + margin-top: 0; + } + + .auth-verified-grid { + grid-template-columns: 1fr; + } + + .auth-dialog-backdrop { + padding: 16px; + } + + .auth-dialog { + max-height: calc(100vh - 32px); + box-shadow: 6px 6px 0 #f2f500; + } + + .auth-dialog-content { + display: block; + } + .auth-code-row { grid-template-columns: minmax(0, 1fr) 126px; } diff --git a/apps/web/src/user-auth.tsx b/apps/web/src/user-auth.tsx index 2db1c6e..5c2970c 100644 --- a/apps/web/src/user-auth.tsx +++ b/apps/web/src/user-auth.tsx @@ -1,4 +1,5 @@ -import { useEffect, useId, useRef, useState, type FormEvent } from "react"; +import { registrationNotice } from "@dada/shared-contracts"; +import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; import "./user-auth.css"; @@ -20,6 +21,16 @@ const messageByKey: Record = { "auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。", "auth.login.admin_required": "此邮箱需从管理员登录入口进入。", "auth.login.registration_required": "该邮箱尚未注册,请切换到注册。", + "auth.email.already_registered": "该邮箱已注册,请切换到登录。", + "auth.invite.disabled": "邀请码已停用,请更换邀请码。", + "auth.invite.exhausted": "邀请码使用次数已耗尽,请更换邀请码。", + "auth.invite.expired": "邀请码已过期,请更换邀请码。", + "auth.invite.not_found": "邀请码无效,请检查后重试。", + "auth.privacy.consent_required": "请阅读并同意《内测使用与隐私告知》。", + "auth.privacy.notice_version_invalid": "告知版本已更新,请重新阅读后同意。", + "auth.profile.invalid": "请检查创作署名和社交 ID。", + "auth.registration.login_required": "该邮箱已注册,请切换到登录。", + "auth.registration.stage_limit_reached": "本轮内测名额已满,请返回登录。", "auth.service.unavailable": "邮件服务暂时不可用,请稍后重试。", }; @@ -34,22 +45,47 @@ function maskedEmail(email: string) { return `${visible}${"*".repeat(Math.max(3, local.length - visible.length))}@${domain}`; } +function maskedRegistrationEmail(email: string) { + const [local = "", domain = ""] = email.split("@", 2); + return `${local.slice(0, 1)}***@${domain.slice(0, 1)}***`; +} + +function maskedInvite(inviteCode: string) { + return `••••${inviteCode.slice(-3)}`; +} + export function UserAuthPage() { const emailId = useId(); const codeId = useId(); const inviteId = useId(); + const creatorNameId = useId(); + const socialId = useId(); const loginTab = useRef(null); const registerTab = useRef(null); + const noticeButton = useRef(null); + const noticeCloseButton = useRef(null); + const noticeDialog = useRef(null); const [mode, setMode] = useState("login"); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); const [inviteCode, setInviteCode] = useState(""); + const [creatorName, setCreatorName] = useState(""); + const [socialHandle, setSocialHandle] = useState(""); + const [privacyConsentAccepted, setPrivacyConsentAccepted] = useState(false); + const [noticeOpen, setNoticeOpen] = useState(false); const [registrationId, setRegistrationId] = useState(); const [sendState, setSendState] = useState("idle"); const [countdown, setCountdown] = useState(0); const [error, setError] = useState(); const [submitting, setSubmitting] = useState(false); const emailValid = /^[^@\s]+@[^@\s]+$/.test(email); + const registrationReady = Boolean( + registrationId + && /^[0-9]{6}$/.test(code) + && creatorName.trim() + && socialHandle.trim() + && privacyConsentAccepted, + ); useEffect(() => { if (countdown <= 0) return; @@ -57,10 +93,28 @@ export function UserAuthPage() { return () => window.clearInterval(timer); }, [countdown]); + useEffect(() => { + if (!noticeOpen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + noticeCloseButton.current?.focus(); + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [noticeOpen]); + + function resetRegistrationDetails() { + setCode(""); + setCreatorName(""); + setSocialHandle(""); + setPrivacyConsentAccepted(false); + setNoticeOpen(false); + } + function switchMode(nextMode: AuthMode) { if (nextMode === mode) return; setMode(nextMode); - setCode(""); + resetRegistrationDetails(); setInviteCode(""); setRegistrationId(undefined); setSendState("idle"); @@ -68,6 +122,43 @@ export function UserAuthPage() { setError(undefined); } + function modifyRegistrationEntry() { + resetRegistrationDetails(); + setRegistrationId(undefined); + setSendState("idle"); + setCountdown(0); + setError(undefined); + window.requestAnimationFrame(() => document.getElementById(inviteId)?.focus()); + } + + function closeNotice() { + setNoticeOpen(false); + window.requestAnimationFrame(() => noticeButton.current?.focus()); + } + + function handleNoticeKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + closeNotice(); + return; + } + if (event.key === "Tab") { + const focusable = Array.from( + noticeDialog.current?.querySelectorAll("button, [href], input, [tabindex]:not([tabindex='-1'])") ?? [], + ).filter((element) => !element.hasAttribute("disabled")); + const first = focusable[0]; + const last = focusable.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + } + function switchTab(nextMode: AuthMode) { switchMode(nextMode); window.requestAnimationFrame(() => (nextMode === "login" ? loginTab : registerTab).current?.focus()); @@ -129,8 +220,44 @@ export function UserAuthPage() { } } + async function completeRegistration(event: FormEvent) { + event.preventDefault(); + if (!registrationReady || !registrationId || submitting) return; + setSubmitting(true); + setError(undefined); + try { + const response = await fetch("/api/v1/auth/register/complete", { + body: JSON.stringify({ + creator_name: creatorName, + privacy_consent_accepted: privacyConsentAccepted, + privacy_notice_version: registrationNotice.version, + registration_id: registrationId, + social_id: socialHandle, + verification_code: code, + }), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""), + }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody; + if (!response.ok) { + setError(errorMessage(body)); + return; + } + window.location.assign("/app"); + } catch { + setError("注册请求未完成,请重试。"); + } finally { + setSubmitting(false); + } + } + return ( -
+ <> +
DADA
@@ -227,34 +354,109 @@ export function UserAuthPage() { ) : ( -
-

邀请码注册

- - setInviteCode(event.target.value)} - value={inviteCode} - /> - - setEmail(event.target.value)} - type="email" - value={email} - /> - +
+

{registrationId ? "完善注册资料" : "邀请码注册"}

+ {!registrationId ? ( + <> + + setInviteCode(event.target.value)} + value={inviteCode} + /> + + setEmail(event.target.value)} + placeholder="请输入邮箱" + type="email" + value={email} + /> + + + ) : ( + <> +
验证码已发送
+
+
+ 邀请码 · 已验证 + {maskedInvite(inviteCode)} +
+
+ 邮箱 · 已验证 + {maskedRegistrationEmail(email)} +
+
+ + + setCode(event.target.value.replace(/\D/g, ""))} + placeholder="输入 6 位验证码" + value={code} + /> + + setCreatorName(event.target.value)} + placeholder="成品中显示的名称" + value={creatorName} + /> + + setSocialHandle(event.target.value)} + placeholder="成品中显示的账号文本" + value={socialHandle} + /> +
+
+ 《内测使用与隐私告知》 + +
+ + {!privacyConsentAccepted ?

完成注册前必须阅读并勾选同意。

: null} +
+ + + )} {error ?

{error}

: null} -
+ )}
@@ -262,6 +464,39 @@ export function UserAuthPage() {
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
-
+
+ {noticeOpen ? ( +
+
+
+
+

版本 {registrationNotice.version} · 生效日期 {registrationNotice.effectiveAt}

+

{registrationNotice.title}

+
+
+
+ {registrationNotice.sections.map((section) => ( +
+

{section.title}

+

{section.body}

+
+ ))} +
+
+ +
+
+
+ ) : null} + ); } diff --git a/package.json b/package.json index dba05c9..b65e538 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -43,7 +43,9 @@ "test:wp1-01": "node scripts/run-wp1-01-validation.mjs", "test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red", "test:wp1-02": "node scripts/run-wp1-02-validation.mjs", - "test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red" + "test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red", + "test:wp1-03": "node scripts/run-wp1-03-validation.mjs", + "test:wp1-03:red": "node scripts/run-wp1-03-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index bd297ca..a29cbc2 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -3,3 +3,4 @@ export * from "./api.js"; export * from "./auth.js"; export * from "./bootstrap.js"; export * from "./events.js"; +export * from "./registration-notice.js"; diff --git a/packages/shared-contracts/src/registration-notice.ts b/packages/shared-contracts/src/registration-notice.ts new file mode 100644 index 0000000..7d7bd33 --- /dev/null +++ b/packages/shared-contracts/src/registration-notice.ts @@ -0,0 +1,39 @@ +export const registrationNoticeSections = [ + { + title: "外部服务", + body: "注册验证码通过 Resend 发送;创作功能会按用户主动操作调用 AI 网关;使用 DYN004 自动定位时会调用高德处理地点文字和坐标。未使用相应功能时不会为该功能发起调用。", + }, + { + title: "本机存储与保护", + body: "P0-A 数据保存在当前 Windows 电脑的 LocalDataRoot,依赖当前 Windows 用户登录和文件系统权限保护。Dada 不提供应用层加密或云备份;机器损坏、系统重装或 LocalDataRoot 被删除后数据不可恢复,重要图片请主动下载。", + }, + { + title: "超级管理员访问", + body: "超级管理员可按产品权限查看运营信息,并可打开用户私有图片或完整提示词;每次打开都会记录访问审计。审计记录在规定保留期内不可人工修改或删除。", + }, + { + title: "DYN004 坐标", + body: "DYN004 可在用户主动使用时处理和保存原始定位坐标;坐标属于私有项目数据,账号注销时按规则删除。高德不可用或达到应用硬上限时,自动定位停止。", + }, + { + title: "账号注销与保留", + body: "账号注销后,账号资料、项目、图片、提示词、原始坐标、导出成品和未使用点数立即不可恢复。允许的匿名生成与点数事件及私有访问审计最多保留 180 天,且不得保留可回溯个人或单次作品的信息。", + }, + { + title: "无备份与迁移", + body: "测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。P0-A 不提供可编辑项目包或业务数据的导出、导入和迁移;用户下载的图片仅用于自行留存。", + }, +] as const; + +export const registrationNoticeContent = registrationNoticeSections + .map((section) => `${section.title}\n${section.body}`) + .join("\n\n"); + +export const registrationNotice = Object.freeze({ + content: registrationNoticeContent, + contentSha256: "10dea4fb1b54d7208c017c126693d2061d8261bc5f8bc2b656309329ef22a791", + effectiveAt: "2026-07-28", + sections: registrationNoticeSections, + title: "内测使用与隐私告知", + version: "p0a-registration-notice-v1", +}); diff --git a/scripts/run-wp1-03-validation.mjs b/scripts/run-wp1-03-validation.mjs new file mode 100644 index 0000000..06688e8 --- /dev/null +++ b/scripts/run-wp1-03-validation.mjs @@ -0,0 +1,111 @@ +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-03-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const noticeCase = "TDD-WP1-NOTICE-001-registration-consent"; +const slotCase = "TDD-WP1-SLOT-001-stage-limit"; +const directories = { + [noticeCase]: resolve(runDirectory, "cases", noticeCase), + [slotCase]: resolve(runDirectory, "cases", slotCase), +}; +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const directory of Object.values(directories)) mkdirSync(directory, { recursive: true }); + +const commandsToRun = phase === "red" + ? [ + ["notice-unit", ["exec", "vitest", "run", "tests/unit/wp1-03-notice.test.ts"]], + ["slot-integration", ["exec", "vitest", "run", "tests/integration/wp1-03-slot-limit.test.ts"]], + ["notice-e2e", ["exec", "playwright", "test", "tests/e2e/user-registration.spec.ts", "--config", "playwright.config.ts"]], + ] + : [ + ["unit", ["test:unit"]], + ["api", ["test:api"]], + ["e2e", ["test:e2e"]], + ["integration", ["test:integration"]], + ["tdd-trace", ["validate:tdd-trace"]], + ]; +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_NOTICE: directories[noticeCase], + DADA_EVIDENCE_DIR_SLOT: directories[slotCase], +}; +const commandResults = []; +for (const [name, args] of commandsToRun) { + const command = `pnpm ${args.join(" ")}`; + const executable = process.env.ComSpec ?? "cmd.exe"; + const started_at = new Date().toISOString(); + const execution = spawnSync(executable, ["/d", "/s", "/c", command], { encoding: "utf8", env: environment }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commandResults.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); +} + +const byName = Object.fromEntries(commandResults.map((result) => [result.name, result])); +const statuses = phase === "red" + ? { + [noticeCase]: byName["notice-unit"].exit_code !== 0 && byName["notice-e2e"].exit_code !== 0 ? "red_confirmed" : "failed", + [slotCase]: byName["slot-integration"].exit_code === 0 ? "preexisting_green" : "red_confirmed", + } + : { + [noticeCase]: commandResults.every((result) => result.exit_code === 0) ? "awaiting_manual_review" : "failed", + [slotCase]: commandResults.every((result) => result.exit_code === 0) ? "passed" : "failed", + }; +for (const directory of Object.values(directories)) { + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); +} +if (phase === "red") { + writeFileSync(resolve(directories[noticeCase], "red-observation.json"), `${JSON.stringify({ + expected_failures: ["registration notice contract missing", "DVPM8 second stage missing"], + status: statuses[noticeCase], + }, null, 2)}\n`); + writeFileSync(resolve(directories[slotCase], "red-observation.json"), `${JSON.stringify({ + explanation: "TASK-WP1-01 already implemented the normative slot transaction; no failure was fabricated.", + status: statuses[slotCase], + }, null, 2)}\n`); +} +const expected = phase === "red" + ? { [noticeCase]: ["red-observation.json"], [slotCase]: ["red-observation.json"] } + : { + [noticeCase]: ["response.json", "db-diff.json", "screenshots/notice-expanded.png", "manual-review.json"], + [slotCase]: ["response.json", "db-diff.json", "concurrency-trace.json"], + }; +const manifest = { + path: "tasks.manifest.json", + sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(), +}; +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 = Object.entries(statuses).map(([testId, status]) => { + const evidence_refs = expected[testId]; + const missing_evidence = evidence_refs.filter((file) => !existsSync(resolve(directories[testId], file))); + const result = { + acceptance_criteria: ["AC-01", "AC-32", "AC-41", "AC-45", "AC-56"], + automation: ["automated", "manual_review"], + commit, + evidence_refs, + manifest, + missing_evidence, + phase, + requirements: ["AUTH-01", "AUTH-03", "NFR-04", "PRIV-04", "PRIV-06"], + run_id: runId, + status, + task_id: "TASK-WP1-03", + 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 redAccepted = phase === "red" && statuses[noticeCase] === "red_confirmed" && ["preexisting_green", "red_confirmed"].includes(statuses[slotCase]); +const greenAccepted = phase === "green" && statuses[noticeCase] === "awaiting_manual_review" && statuses[slotCase] === "passed"; +const summary = { cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })), phase, run_id: runId, status: redAccepted ? "red_confirmed" : greenAccepted ? "awaiting_manual_review" : "failed" }; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (!redAccepted && !greenAccepted) process.exit(1); diff --git a/tests/api/wp1-03-registration-consent.test.ts b/tests/api/wp1-03-registration-consent.test.ts new file mode 100644 index 0000000..bc9b220 --- /dev/null +++ b/tests/api/wp1-03-registration-consent.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } 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 { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js"; + +const roots: string[] = []; +const services: RegistrationService[] = []; +const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; + +function createHarness() { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-03-notice-")); + roots.push(root); + const resend = new MockResendAdapter(); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x74), + clock: () => Date.parse("2026-07-28T09:30:00.000Z"), + codeGenerator: () => "418205", + currentPrivacyNoticeVersion: registrationNotice.version, + databasePath: join(root, "dada.sqlite3"), + inviteCodeGenerator: () => "DADA-WP1-03-NOTICE", + invitePepper: Buffer.alloc(32, 0x75), + resend, + sessionPepper: Buffer.alloc(32, 0x76), + }); + services.push(registration); + return { registration, resend }; +} + +function snapshot(registration: RegistrationService) { + return { + consents: registration.database.prepare("SELECT COUNT(*) AS count FROM privacy_consents").get().count, + credits: registration.database.prepare("SELECT COUNT(*) AS count FROM credit_accounts").get().count, + invite_used: registration.database.prepare("SELECT COALESCE(SUM(used_count), 0) AS count FROM invite_codes").get().count, + sessions: registration.database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, + users: registration.database.prepare("SELECT COUNT(*) AS count FROM users").get().count, + }; +} + +function writeEvidence(file: string, value: unknown) { + const directory = process.env.DADA_EVIDENCE_DIR_NOTICE; + 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-NOTICE-001-registration-consent", () => { + it("rejects missing/stale consent without side effects and records current consent atomically", async () => { + const { registration, resend } = createHarness(); + const invite = registration.createInvite({ expiresAt: Date.parse("2026-07-29T09:30:00.000Z"), maxUses: 1 }); + const sent = await registration.sendRegistrationCode({ email: "notice-api@example.invalid", inviteCode: invite.code }); + const code = resend.readLatestCode("notice-api@example.invalid"); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + const before = snapshot(registration); + const basePayload = { + creator_name: "Notice User", + privacy_notice_version: registrationNotice.version, + registration_id: sent.registrationId, + social_id: "@notice_user", + verification_code: code, + }; + + const missingConsent = await app.inject({ + headers: { ...writeHeaders, "idempotency-key": "wp1-03-consent-missing-0000000000001" }, + method: "POST", + payload: { ...basePayload, privacy_consent_accepted: false }, + url: "/api/v1/auth/register/complete", + }); + expect(missingConsent.statusCode).toBe(400); + expect(missingConsent.json()).toMatchObject({ + error: { details: { field_errors: [{ message_key: "auth.privacy.consent_required" }] } }, + }); + expect(snapshot(registration)).toEqual(before); + + const staleVersion = await app.inject({ + headers: { ...writeHeaders, "idempotency-key": "wp1-03-consent-stale-00000000000001" }, + method: "POST", + payload: { ...basePayload, privacy_consent_accepted: true, privacy_notice_version: "stale-notice" }, + url: "/api/v1/auth/register/complete", + }); + expect(staleVersion.statusCode).toBe(400); + expect(staleVersion.json()).toMatchObject({ + error: { details: { field_errors: [{ message_key: "auth.privacy.notice_version_invalid" }] } }, + }); + expect(snapshot(registration)).toEqual(before); + + const completed = await app.inject({ + headers: { ...writeHeaders, "idempotency-key": "wp1-03-consent-success-0000000000001" }, + method: "POST", + payload: { ...basePayload, privacy_consent_accepted: true }, + url: "/api/v1/auth/register/complete", + }); + expect(completed.statusCode).toBe(200); + const after = snapshot(registration); + expect(after).toEqual({ consents: 1, credits: 1, invite_used: 1, sessions: 1, users: 1 }); + const consent = registration.database.prepare("SELECT notice_version, consented_at FROM privacy_consents").get(); + expect(consent).toEqual({ + consented_at: Date.parse("2026-07-28T09:30:00.000Z"), + notice_version: registrationNotice.version, + }); + + writeEvidence("response.json", { + accepted: completed.json().status, + missing_consent: missingConsent.json().error.details.field_errors[0].message_key, + stale_version: staleVersion.json().error.details.field_errors[0].message_key, + }); + writeEvidence("db-diff.json", { + after, + before, + consent_recorded_at: "2026-07-28T09:30:00.000Z", + notice_content_sha256: registrationNotice.contentSha256, + notice_effective_at: registrationNotice.effectiveAt, + notice_version: registrationNotice.version, + }); + await app.close(); + }); +}); diff --git a/tests/e2e/user-registration.spec.ts b/tests/e2e/user-registration.spec.ts new file mode 100644 index 0000000..e52c8f9 --- /dev/null +++ b/tests/e2e/user-registration.spec.ts @@ -0,0 +1,119 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +test.use({ trace: "off" }); + +test.beforeAll(async () => { + vite = await createServer({ + configFile: resolve("apps/web/vite.config.ts"), + root: resolve("apps/web"), + server: { host: "127.0.0.1", port: 0 }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP1-NOTICE-001 expands DVPM8 only after successful code delivery", async ({ page }) => { + await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({ + contentType: "application/json", + status: 200, + body: JSON.stringify({ + challenge_expires_at: "2026-07-28T09:10:00.000Z", + registration_id: "00000000-0000-4000-8000-000000000003", + resend_available_at: "2026-07-28T09:01:00.000Z", + status: "verification_sent", + }), + })); + await page.goto(webUrl); + await page.getByRole("tab", { name: "注册" }).click(); + await page.getByRole("textbox", { name: "邀请码" }).fill("DADA-P0A-TEST-7K2"); + await page.getByRole("textbox", { name: "邮箱" }).fill("registration@example.invalid"); + await page.getByRole("button", { name: "获取验证码" }).click(); + + await expect(page.getByRole("heading", { name: "完善注册资料" })).toBeVisible(); + await expect(page.getByText("邀请码 · 已验证")).toBeVisible(); + await expect(page.getByText("邮箱 · 已验证")).toBeVisible(); + await expect(page.getByRole("button", { name: "修改邀请码和邮箱" })).toBeVisible(); + await expect(page.getByLabel("验证码")).toBeVisible(); + await expect(page.getByLabel("创作署名")).toBeVisible(); + await expect(page.getByLabel("社交 ID")).toBeVisible(); + await expect(page.getByRole("checkbox", { name: /同意/ })).not.toBeChecked(); + await expect(page.getByRole("button", { name: "注册并进入 Dada" })).toBeDisabled(); + + await page.getByRole("button", { name: "查看全文" }).click(); + const dialog = page.getByRole("dialog", { name: "内测使用与隐私告知" }); + const noticeClose = page.getByRole("button", { name: "我已阅读" }); + await expect(noticeClose).toBeFocused(); + await expect(dialog).toContainText("AI 网关"); + await expect(dialog).toContainText("Resend"); + await expect(dialog).toContainText("高德"); + await expect(dialog).toContainText("DYN004"); + await expect(dialog).toContainText("180 天"); + await expect(dialog).toContainText("不会迁移到正式系统"); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_NOTICE; + if (evidenceDirectory) { + mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true }); + await page.screenshot({ path: resolve(evidenceDirectory, "screenshots", "notice-expanded.png") }); + } + await page.keyboard.press("Tab"); + await expect(noticeClose).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("button", { name: "查看全文" })).toBeFocused(); + await expect(page.getByRole("checkbox", { name: /同意/ })).not.toBeChecked(); +}); + +test("TDD-WP1-SLOT-001 preserves profile fields when final capacity recheck fails", async ({ page }) => { + await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({ + contentType: "application/json", + status: 200, + body: JSON.stringify({ + challenge_expires_at: "2026-07-28T09:10:00.000Z", + registration_id: "00000000-0000-4000-8000-000000000004", + resend_available_at: "2026-07-28T09:01:00.000Z", + status: "verification_sent", + }), + })); + await page.route("**/api/v1/auth/register/complete", async (route) => { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 150)); + await route.fulfill({ + contentType: "application/json", + status: 409, + body: JSON.stringify({ + error: { + code: "REGISTRATION_REJECTED", + correlation_id: "00000000-0000-4000-8000-000000000005", + details: { field_errors: [{ field: "invite_code", message_key: "auth.registration.stage_limit_reached" }] }, + message_key: "registration.rejected", + }, + }), + }); + }); + + await page.goto(webUrl); + await page.getByRole("tab", { name: "注册" }).click(); + await page.getByRole("textbox", { name: "邀请码" }).fill("DADA-P0A-TEST-9M4"); + await page.getByRole("textbox", { name: "邮箱" }).fill("capacity@example.invalid"); + await page.getByRole("button", { name: "获取验证码" }).click(); + await page.getByLabel("验证码").fill("418205"); + await page.getByLabel("创作署名").fill("Capacity User"); + await page.getByLabel("社交 ID").fill("@capacity_user"); + await page.getByRole("checkbox", { name: /同意/ }).check(); + await page.getByRole("button", { name: "注册并进入 Dada" }).click(); + await expect(page.getByRole("button", { name: "注册中" })).toBeDisabled(); + await expect(page.getByRole("alert")).toContainText("本轮内测名额已满"); + await expect(page.getByLabel("创作署名")).toHaveValue("Capacity User"); + await expect(page.getByLabel("社交 ID")).toHaveValue("@capacity_user"); + await expect(page.getByRole("checkbox", { name: /同意/ })).toBeChecked(); +}); diff --git a/tests/integration/wp1-03-slot-limit.test.ts b/tests/integration/wp1-03-slot-limit.test.ts new file mode 100644 index 0000000..c785a12 --- /dev/null +++ b/tests/integration/wp1-03-slot-limit.test.ts @@ -0,0 +1,155 @@ +import { randomUUID } from "node:crypto"; +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 { afterEach, describe, expect, it } from "vitest"; + +import { RegistrationService } 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-28T09:00:00.000Z"); +const roots: string[] = []; +const services: RegistrationService[] = []; + +function createHarness() { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-03-slot-")); + roots.push(root); + const databasePath = join(root, "dada.sqlite3"); + const resend = new MockResendAdapter(); + const service = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x71), + clock: () => fixedNow, + codeGenerator: () => "731905", + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", + databasePath, + inviteCodeGenerator: () => `DADA-SLOT-${randomUUID()}`, + invitePepper: Buffer.alloc(32, 0x72), + resend, + sessionPepper: Buffer.alloc(32, 0x73), + }); + services.push(service); + return { databasePath, resend, service }; +} + +function withDatabase(databasePath: string, operation: (database: any) => T): T { + const database = new Database(databasePath); + try { return operation(database); } finally { database.close(); } +} + +function seedSubject( + databasePath: string, + index: number, + role: "user" | "super_admin", + status: "active" | "suspended" | "deleted", +) { + withDatabase(databasePath, (database) => { + database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + randomUUID(), + `slot-fixture-${role}-${status}-${index}@example.invalid`, + role, + status, + role === "user" ? 1 : 0, + randomUUID(), + fixedNow - 1_000, + ); + }); +} + +function writeEvidence(file: string, value: unknown) { + const directory = process.env.DADA_EVIDENCE_DIR_SLOT; + 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-SLOT-001-stage-limit", () => { + it("counts active and suspended users while excluding deleted users and multiple super_admins", async () => { + const harness = createHarness(); + for (let index = 0; index < 8; index += 1) seedSubject(harness.databasePath, index, "user", "active"); + seedSubject(harness.databasePath, 8, "user", "suspended"); + seedSubject(harness.databasePath, 9, "user", "deleted"); + seedSubject(harness.databasePath, 10, "super_admin", "active"); + seedSubject(harness.databasePath, 11, "super_admin", "active"); + + const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 3 }); + const tenth = await harness.service.sendRegistrationCode({ + email: "stage-tenth@example.invalid", + inviteCode: invite.code, + }); + const completed = harness.service.completeRegistration({ + code: harness.resend.readLatestCode("stage-tenth@example.invalid"), + creatorName: "Tenth User", + idempotencyKey: "wp1-03-stage-tenth-complete-00000001", + privacyConsentAccepted: true, + privacyNoticeVersion: "p0a-registration-notice-v1", + registrationId: tenth.registrationId, + socialId: "@stage_tenth", + }); + expect(completed.status).toBe("registered"); + + await expect(harness.service.sendRegistrationCode({ + email: "stage-eleventh@example.invalid", + inviteCode: invite.code, + })).rejects.toMatchObject({ reason: "stage_limit_reached" }); + + const counts = withDatabase(harness.databasePath, (database) => ({ + active_and_suspended_users: database.prepare(` + SELECT COUNT(*) AS count FROM users + WHERE role = 'user' AND status IN ('active', 'suspended') + `).get().count, + deleted_users: database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'user' AND status = 'deleted'").get().count, + super_admins: database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'super_admin'").get().count, + })); + expect(counts).toEqual({ active_and_suspended_users: 10, deleted_users: 1, super_admins: 2 }); + writeEvidence("response.json", { eleventh: "stage_limit_reached", tenth: completed.status }); + writeEvidence("db-diff.json", { ...counts, failed_invite_use_delta: 0, failed_user_delta: 0 }); + }); + + it("rechecks capacity after challenge issuance under BEGIN IMMEDIATE", async () => { + const harness = createHarness(); + for (let index = 0; index < 9; index += 1) seedSubject(harness.databasePath, index, "user", "active"); + const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 2 }); + const sent = await harness.service.sendRegistrationCode({ + email: "stage-race@example.invalid", + inviteCode: invite.code, + }); + seedSubject(harness.databasePath, 9, "user", "suspended"); + + expect(() => harness.service.completeRegistration({ + code: harness.resend.readLatestCode("stage-race@example.invalid"), + creatorName: "Race User", + idempotencyKey: "wp1-03-stage-race-complete-000000001", + privacyConsentAccepted: true, + privacyNoticeVersion: "p0a-registration-notice-v1", + registrationId: sent.registrationId, + socialId: "@stage_race", + })).toThrowError(expect.objectContaining({ reason: "stage_limit_reached" })); + + const state = withDatabase(harness.databasePath, (database) => ({ + consents: database.prepare("SELECT COUNT(*) AS count FROM privacy_consents").get().count, + invite_used: database.prepare("SELECT used_count AS count FROM invite_codes WHERE invite_id = ?").get(invite.inviteId).count, + sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, + users: database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'user'").get().count, + })); + expect(state).toEqual({ consents: 0, invite_used: 0, sessions: 0, users: 10 }); + writeEvidence("concurrency-trace.json", { + final_recheck: "stage_limit_reached", + mode: "BEGIN IMMEDIATE", + side_effects: state, + }); + }); +}); diff --git a/tests/unit/wp1-03-notice.test.ts b/tests/unit/wp1-03-notice.test.ts new file mode 100644 index 0000000..7f29fca --- /dev/null +++ b/tests/unit/wp1-03-notice.test.ts @@ -0,0 +1,35 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { registrationNotice } from "../../packages/shared-contracts/src/index.js"; + +describe("TDD-WP1-NOTICE-001-registration-consent", () => { + it("freezes a versioned and hash-verifiable registration notice", () => { + expect(registrationNotice.version).toMatch(/^p0a-registration-notice-v[1-9][0-9]*$/); + expect(registrationNotice.effectiveAt).toMatch(/^20[0-9]{2}-[0-9]{2}-[0-9]{2}$/); + expect(registrationNotice.sections.length).toBeGreaterThanOrEqual(5); + expect( + createHash("sha256").update(registrationNotice.content, "utf8").digest("hex"), + ).toBe(registrationNotice.contentSha256); + }); + + it.each([ + "AI 网关", + "Resend", + "高德", + "DYN004", + "Windows", + "文件系统权限", + "应用层加密", + "云备份", + "LocalDataRoot", + "超级管理员", + "账号注销", + "180 天", + "不自动备份", + "不会迁移到正式系统", + ])("contains the frozen topic %s", (topic) => { + expect(registrationNotice.content).toContain(topic); + }); +}); -- 2.54.0 From 66fe3b763ac147d30e5348d388a5ebde91ac872c Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 28 Jul 2026 17:17:03 +0800 Subject: [PATCH 005/101] fix: center TASK-WP1-03 registration layout --- apps/web/src/user-auth.css | 4 ++-- tests/e2e/user-registration.spec.ts | 37 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/web/src/user-auth.css b/apps/web/src/user-auth.css index 8207328..05c3f29 100644 --- a/apps/web/src/user-auth.css +++ b/apps/web/src/user-auth.css @@ -121,7 +121,7 @@ button { .auth-panel { width: min(480px, 100%); - margin-left: 84px; + margin-inline: auto; } .auth-tabs { @@ -472,7 +472,7 @@ button { } .auth-panel { - margin-left: 0; + margin-inline: 0; } .auth-registration-status { diff --git a/tests/e2e/user-registration.spec.ts b/tests/e2e/user-registration.spec.ts index e52c8f9..5ff7195 100644 --- a/tests/e2e/user-registration.spec.ts +++ b/tests/e2e/user-registration.spec.ts @@ -50,6 +50,12 @@ test("TDD-WP1-NOTICE-001 expands DVPM8 only after successful code delivery", asy await expect(page.getByRole("checkbox", { name: /同意/ })).not.toBeChecked(); await expect(page.getByRole("button", { name: "注册并进入 Dada" })).toBeDisabled(); + const panelBounds = await page.locator(".auth-panel").boundingBox(); + const viewport = page.viewportSize(); + expect(panelBounds).not.toBeNull(); + expect(viewport).not.toBeNull(); + expect(Math.abs((panelBounds!.x + panelBounds!.width / 2) - viewport!.width / 2)).toBeLessThanOrEqual(2); + await page.getByRole("button", { name: "查看全文" }).click(); const dialog = page.getByRole("dialog", { name: "内测使用与隐私告知" }); const noticeClose = page.getByRole("button", { name: "我已阅读" }); @@ -117,3 +123,34 @@ test("TDD-WP1-SLOT-001 preserves profile fields when final capacity recheck fail await expect(page.getByLabel("社交 ID")).toHaveValue("@capacity_user"); await expect(page.getByRole("checkbox", { name: /同意/ })).toBeChecked(); }); + +test("TDD-WP1-NOTICE-001 hands a successful registration to the workspace route", async ({ page }) => { + await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({ + contentType: "application/json", + status: 200, + body: JSON.stringify({ + challenge_expires_at: "2026-07-28T09:10:00.000Z", + registration_id: "00000000-0000-4000-8000-000000000006", + resend_available_at: "2026-07-28T09:01:00.000Z", + status: "verification_sent", + }), + })); + await page.route("**/api/v1/auth/register/complete", (route) => route.fulfill({ + contentType: "application/json", + status: 200, + body: JSON.stringify({ status: "registered" }), + })); + + await page.goto(webUrl); + await page.getByRole("tab", { name: "注册" }).click(); + await page.getByRole("textbox", { name: "邀请码" }).fill("DADA-P0A-TEST-4R8"); + await page.getByRole("textbox", { name: "邮箱" }).fill("handoff@example.invalid"); + await page.getByRole("button", { name: "获取验证码" }).click(); + await page.getByLabel("验证码").fill("418205"); + await page.getByLabel("创作署名").fill("Handoff User"); + await page.getByLabel("社交 ID").fill("@handoff_user"); + await page.getByRole("checkbox", { name: /同意/ }).check(); + await page.getByRole("button", { name: "注册并进入 Dada" }).click(); + + await expect(page).toHaveURL(`${webUrl}/app`); +}); -- 2.54.0 From 03f1509de72bf2fa79e7ae0642279f1cc76450e5 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 28 Jul 2026 18:32:50 +0800 Subject: [PATCH 006/101] feat: implement TASK-WP1-04 admin security --- apps/api/src/app.ts | 148 +++++ apps/api/src/main.ts | 42 +- apps/api/src/managed-storage.ts | 100 ++- apps/api/src/registration-errors.ts | 2 + apps/api/src/registration.ts | 571 +++++++++++++++++- apps/api/src/resend-adapter.ts | 2 +- apps/api/src/secure-config.ts | 22 + apps/api/src/supervisor-channel.ts | 4 +- apps/web/src/admin-auth.css | 157 +++++ apps/web/src/admin-auth.tsx | 150 +++++ apps/web/src/generated/api/sdk.gen.ts | 27 +- apps/web/src/generated/api/types.gen.ts | 33 + apps/web/src/main.tsx | 6 +- apps/web/src/user-auth.tsx | 2 +- openapi/openapi.json | 347 +++++++++++ package.json | 6 +- packages/shared-contracts/src/auth.ts | 50 ++ scripts/run-wp1-04-validation.mjs | 127 ++++ scripts/supervisor-channel-smoke.mjs | 6 +- supervisor/Dada.Supervisor.Tests/Program.cs | 34 +- supervisor/Dada.Supervisor/Credentials.cs | 2 +- .../Dada.Supervisor/OfflineCommandRouter.cs | 66 +- .../Dada.Supervisor/SupervisorRuntime.cs | 1 + tests/api/wp1-04-admin-auth.test.ts | 111 ++++ tests/e2e/admin-auth.spec.ts | 74 +++ tests/e2e/entry-state-ui.spec.ts | 2 +- tests/e2e/user-auth.spec.ts | 2 +- tests/integration/wp1-04-admin-auth.test.ts | 129 ++++ .../integration/wp1-04-secure-config.test.ts | 161 +++++ 29 files changed, 2344 insertions(+), 40 deletions(-) create mode 100644 apps/api/src/secure-config.ts create mode 100644 apps/web/src/admin-auth.css create mode 100644 apps/web/src/admin-auth.tsx create mode 100644 scripts/run-wp1-04-validation.mjs create mode 100644 tests/api/wp1-04-admin-auth.test.ts create mode 100644 tests/e2e/admin-auth.spec.ts create mode 100644 tests/integration/wp1-04-admin-auth.test.ts create mode 100644 tests/integration/wp1-04-secure-config.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 900f5ca..6fdc62c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,6 +3,11 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { + AdminAuthenticatedUserSchema, + AdminLoginCompleteRequestSchema, + AdminLoginCompleteResponseSchema, + AdminLoginSendRequestSchema, + AdminSessionResponseSchema, BootstrapResponseSchema, CorrelationIdSchema, AuthenticatedUserSchema, @@ -30,6 +35,8 @@ import { createErrorEnvelope, isCorrelationId, type BootstrapResponse, + type AdminLoginCompleteRequest, + type AdminLoginSendRequest, type LoginCompleteRequest, type LoginSendRequest, type RegistrationCompleteRequest, @@ -101,6 +108,8 @@ const contentSecurityPolicy = [ ].join("; "); const authFlowCookieName = "dada_auth_flow"; const userSessionCookieName = "dada_session"; +const adminAuthFlowCookieName = "dada_admin_auth_flow"; +const adminSessionCookieName = "dada_admin_session"; function requestCorrelationId(headers: Record) { const header = headers["x-correlation-id"]; @@ -211,6 +220,11 @@ export async function createApp(options: CreateAppOptions = {}) { ErrorDetailsSchema, ErrorEnvelopeSchema, AuthenticatedUserSchema, + AdminAuthenticatedUserSchema, + AdminLoginSendRequestSchema, + AdminLoginCompleteRequestSchema, + AdminLoginCompleteResponseSchema, + AdminSessionResponseSchema, CreditSummarySchema, RegistrationSendRequestSchema, RegistrationSendResponseSchema, @@ -302,6 +316,140 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.post( + "/api/v1/admin-auth/login/send", + { + attachValidation: true, + schema: { + body: Type.Ref(AdminLoginSendRequestSchema), + operationId: "sendAdminLoginCode", + response: { + 200: Type.Ref(RegistrationSendResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin 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 })); + } + const existingFlow = cookieValue(headerValue(request.headers.cookie), adminAuthFlowCookieName); + const clientKey = existingFlow ?? randomBytes(32).toString("base64url"); + try { + const body = request.body as AdminLoginSendRequest; + const result = await options.registration.sendAdminLoginCode({ clientKey, email: body.email }); + if (!existingFlow) { + reply.header( + "Set-Cookie", + `${adminAuthFlowCookieName}=${clientKey}; Max-Age=${10 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + } + 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/admin-auth/login/complete", + { + attachValidation: true, + schema: { + body: Type.Ref(AdminLoginCompleteRequestSchema), + headers: Type.Ref(RegistrationCompleteHeadersSchema), + operationId: "completeAdminLogin", + response: { + 200: Type.Ref(AdminLoginCompleteResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin 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 })); + } + const clientKey = cookieValue(headerValue(request.headers.cookie), adminAuthFlowCookieName); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id); + try { + const body = request.body as AdminLoginCompleteRequest; + const result = options.registration.completeAdminLogin({ + clientKey, + code: body.verification_code, + idempotencyKey, + registrationId: body.registration_id, + }); + reply.header( + "Set-Cookie", + `${adminSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + return { + admin: { + role: result.admin.role, + status: result.admin.status, + user_id: result.admin.userId, + }, + audience: result.audience, + session_expires_at: new Date(result.sessionExpiresAt).toISOString(), + status: result.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/admin-auth/session", + { + schema: { + operationId: "getAdminSession", + response: { + 200: Type.Ref(AdminSessionResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin 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), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + return { + acknowledged_private_content_notice_version: null, + admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id }, + audience: "admin" as const, + authenticated: true as const, + csrf_token: options.registration.issueAdminCsrfToken(token!), + current_private_content_notice_version: null, + expires_at: new Date(session.expires_at).toISOString(), + notice_acknowledged: false, + }; + }, + ); + app.post( "/api/v1/auth/login/send", { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 416f9bc..6320357 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,19 +1,52 @@ +import { createHmac } from "node:crypto"; import { join, resolve } from "node:path"; +import { registrationNotice } from "@dada/shared-contracts"; + import { createApp } from "./app.js"; import { readBrowserSupportRelease } from "./browser-support.js"; -import { readConfiguredLocalDataRoot } from "./local-data-root.js"; +import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js"; import { ManagedStorage } from "./managed-storage.js"; +import { RegistrationService } from "./registration.js"; +import { MockResendAdapter } from "./resend-adapter.js"; +import { readSecureConfigCandidate } from "./secure-config.js"; import { StructuredJsonlLogger } from "./structured-log.js"; import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js"; const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); +let registration: RegistrationService | undefined; +const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); if (credentialChannelEnabled) { - initializeApiCredentialClients(await receiveApiCredentials()); + const clients = initializeApiCredentialClients(await receiveApiCredentials()); + try { + const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper) + .update(`Dada/P0A/${purpose}/v1`, "utf8") + .digest(); + const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); + registration = new RegistrationService({ + adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper), + challengePepper: derivePepper("challenge-pepper"), + currentPrivacyNoticeVersion: registrationNotice.version, + databasePath: join(dataRoot, "db", "dada.sqlite3"), + invitePepper: derivePepper("invite-pepper"), + resend: new MockResendAdapter(), + sessionPepper: derivePepper("session-pepper"), + }); + registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); + } catch (error) { + registration?.close(); + registration = undefined; + throw error; + } finally { + clients.adminAllowlistPepper.fill(0); + } } const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); -const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {}); +const app = await createApp({ + ...(browserSupportRelease ? { browserSupportRelease } : {}), + ...(registration ? { registration } : {}), +}); await app.listen({ host: "127.0.0.1", @@ -27,10 +60,11 @@ if (controlPipeIndex >= 0) { let storage: ManagedStorage | undefined; const control = attachApiSupervisorControl(controlPipe, async () => { await app.close(); + registration?.close(); storage?.close(); }); try { - const dataRoot = readConfiguredLocalDataRoot(); + const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") }); const logger = new StructuredJsonlLogger({ component: "api", diff --git a/apps/api/src/managed-storage.ts b/apps/api/src/managed-storage.ts index 4b19087..8e45467 100644 --- a/apps/api/src/managed-storage.ts +++ b/apps/api/src/managed-storage.ts @@ -93,6 +93,10 @@ function now() { return new Date().toISOString(); } +function auditExpiry(occurredAt: string) { + return new Date(Date.parse(occurredAt) + 180 * 24 * 60 * 60 * 1_000).toISOString(); +} + function validatePositiveBytes(value: number, name: string) { if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`); } @@ -222,12 +226,23 @@ export class ManagedStorage { ); CREATE TABLE IF NOT EXISTS admin_operation_logs ( log_id TEXT PRIMARY KEY, - operation TEXT NOT NULL, - outcome TEXT NOT NULL, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, target_ref TEXT NOT NULL, - created_at TEXT NOT NULL + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at TEXT NOT NULL, + expires_at TEXT NOT NULL ); + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; `); + this.migrateLegacyAdminOperationLogs(); const initial = classifyCapacity(0, 0); this.database.prepare(` INSERT OR IGNORE INTO local_backend_storage_state @@ -236,6 +251,54 @@ export class ManagedStorage { `).run(HARD_LIMIT_BYTES, initial.capacity_notice_level, initial.storage_status, now()); } + private migrateLegacyAdminOperationLogs() { + const columns = this.database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>; + if (columns.some((column) => column.name === "actor_type")) return; + const entries = this.database.prepare(` + SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs + `).all() as Array<{ created_at: string; log_id: string; operation: string; outcome: string; target_ref: string }>; + this.database.exec(` + ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_legacy; + CREATE TABLE admin_operation_logs ( + log_id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_ref TEXT NOT NULL, + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + `); + const insert = this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage_migration', ?, 'legacy_operation', ?, ?, NULL, ?, ?, ?) + `); + for (const entry of entries) { + insert.run( + entry.log_id, + entry.operation, + entry.target_ref, + entry.outcome.startsWith("denied") ? "failed" : "succeeded", + JSON.stringify({ legacy_outcome: entry.outcome }), + entry.created_at, + auditExpiry(entry.created_at), + ); + } + this.database.exec(` + DROP TABLE admin_operation_logs_legacy; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + `); + } + close() { this.database.close(); } @@ -561,9 +624,14 @@ export class ManagedStorage { return row.count > 0; }); if (conflict) { - this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(now(), requestId); - this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'denied_reference_conflict', ?, ?)") - .run(randomUUID(), requestId, now()); + const occurredAt = now(); + this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(occurredAt, requestId); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'failed', NULL, ?, ?, ?) + `).run(randomUUID(), requestId, JSON.stringify({ reason: "reference_conflict" }), occurredAt, auditExpiry(occurredAt)); return false; } for (const file of files) { @@ -574,9 +642,14 @@ export class ManagedStorage { VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?) `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now()); } - this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(now(), requestId); - this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'queued', ?, ?)") - .run(randomUUID(), requestId, now()); + const occurredAt = now(); + this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(occurredAt, requestId); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'succeeded', NULL, ?, ?, ?) + `).run(randomUUID(), requestId, JSON.stringify({ status: "queued" }), occurredAt, auditExpiry(occurredAt)); return true; }); if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT"); @@ -605,8 +678,13 @@ export class ManagedStorage { } } this.database.prepare("UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL WHERE cleanup_id = ?").run(now(), row.cleanup_id); - this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'physical_file_cleanup', 'completed', ?, ?)") - .run(randomUUID(), row.cleanup_id, now()); + const occurredAt = now(); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage', 'physical_file_cleanup', 'cleanup_queue_item', ?, 'succeeded', NULL, ?, ?, ?) + `).run(randomUUID(), row.cleanup_id, JSON.stringify({ status: "completed" }), occurredAt, auditExpiry(occurredAt)); this.recordPhysicalMeasurement(); }); finish(); diff --git a/apps/api/src/registration-errors.ts b/apps/api/src/registration-errors.ts index b2a91c7..2b80917 100644 --- a/apps/api/src/registration-errors.ts +++ b/apps/api/src/registration-errors.ts @@ -15,6 +15,7 @@ export type RegistrationErrorReason = | "login_registration_required" | "account_suspended" | "login_admin_required" + | "admin_not_allowed" | "resend_too_soon" | "too_many_attempts" | "csrf_invalid" @@ -68,6 +69,7 @@ export function registrationFieldError(reason: RegistrationErrorReason) { login_registration_required: { field: "email", message_key: "auth.login.registration_required" }, account_suspended: { field: "email", message_key: "auth.account.suspended" }, login_admin_required: { field: "email", message_key: "auth.login.admin_required" }, + admin_not_allowed: { field: "email", message_key: "admin.auth.not_allowed" }, resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" }, too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" }, csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" }, diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index ea67670..5504200 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -31,6 +31,9 @@ export interface RegistrationTransactionEvent { | "registration_send_compensation" | "login_send" | "login_complete" + | "admin_login_send" + | "admin_login_complete" + | "secure_config_apply" | "session_issue" | "session_revoke" | "csrf_issue"; @@ -38,6 +41,7 @@ export interface RegistrationTransactionEvent { } interface RegistrationServiceOptions { + adminAllowlistPepper?: Buffer; challengePepper: Buffer; clock?: () => number; codeGenerator?: () => string; @@ -151,6 +155,24 @@ export type LoginCompleteResult = Omit & { status: "authenticated"; }; +export interface AdminLoginCompleteResult { + admin: { + role: "super_admin"; + status: "active"; + userId: string; + }; + audience: "admin"; + sessionExpiresAt: number; + sessionToken: string; + status: "authenticated"; +} + +export interface SecureConfigCandidate { + adminAllowlistHashes: string[]; + adminRecoveryHashes: string[]; + secureConfigRevision: number; +} + interface ImmediateResult { outcome: RegistrationTransactionEvent["outcome"]; value: T; @@ -195,11 +217,13 @@ function constantTimeTextEqual(left: string, right: string) { export class RegistrationService { readonly database: BetterSqlite3.Database; readonly options: Required> & RegistrationServiceOptions; + private adminAllowlistHashes = new Set(); constructor(options: RegistrationServiceOptions) { assertSecret("invitePepper", options.invitePepper); assertSecret("challengePepper", options.challengePepper); assertSecret("sessionPepper", options.sessionPepper); + if (options.adminAllowlistPepper) assertSecret("adminAllowlistPepper", options.adminAllowlistPepper); this.options = { ...options, clock: options.clock ?? Date.now, @@ -571,6 +595,317 @@ export class RegistrationService { return outcome; } + applySecureConfig(candidate: SecureConfigCandidate) { + const now = this.options.clock(); + const fail = (reason: string): never => { + this.recordConfigApplyFailure(reason, now); + throw new Error(reason); + }; + if (!this.options.adminAllowlistPepper) return fail("admin_pepper_not_configured"); + if (!Number.isSafeInteger(candidate.secureConfigRevision) || candidate.secureConfigRevision < 0) { + return fail("secure_config_revision_invalid"); + } + const normalizeHashes = (values: string[], name: string) => { + if (!Array.isArray(values)) return fail(`${name}_invalid`); + const normalized = [...new Set(values.map((value) => value.toUpperCase()))]; + if (normalized.some((value) => !/^[A-F0-9]{64}$/.test(value))) return fail("hmac_invalid"); + return normalized; + }; + const allowlist = normalizeHashes(candidate.adminAllowlistHashes, "admin_allowlist"); + const recoveries = normalizeHashes(candidate.adminRecoveryHashes, "admin_recovery"); + const allowlistSet = new Set(allowlist); + if (recoveries.some((value) => !allowlistSet.has(value))) return fail("admin_recovery_invalid"); + + const state = this.database.prepare(` + SELECT applied_revision FROM secure_config_apply_state WHERE singleton = 1 + `).get() as { applied_revision: number } | undefined; + const appliedRevision = state?.applied_revision ?? 0; + if (candidate.secureConfigRevision === appliedRevision) { + this.adminAllowlistHashes = allowlistSet; + return { appliedRevision, status: "unchanged" as const }; + } + if (candidate.secureConfigRevision !== appliedRevision + 1) return fail("secure_config_revision_out_of_sequence"); + + const ordinaryUsers = this.database.prepare(` + SELECT normalized_email FROM users WHERE role = 'user' AND status <> 'deleted' + `).all() as Array<{ normalized_email: string }>; + if (ordinaryUsers.some((user) => allowlistSet.has(this.adminAllowlistHmac(user.normalized_email)))) { + return fail("identity_conflict"); + } + + try { + const result = this.runImmediate("secure_config_apply", () => { + const recoverySet = new Set(recoveries); + const admins = this.database.prepare(` + SELECT u.user_id, u.normalized_email, u.status, COALESCE(a.allowed, 0) AS allowed + FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id + WHERE u.role = 'super_admin' AND u.status <> 'deleted' + `).all() as Array<{ + allowed: 0 | 1; + normalized_email: string; + status: "active" | "suspended"; + user_id: string; + }>; + let revokedSessions = 0; + let recoveredAdmins = 0; + for (const admin of admins) { + const adminHash = this.adminAllowlistHmac(admin.normalized_email); + const allowed = allowlistSet.has(adminHash); + this.database.prepare(` + INSERT INTO admin_access (user_id, allowed) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET allowed = excluded.allowed + `).run(admin.user_id, allowed ? 1 : 0); + if (!allowed) { + revokedSessions += this.database.prepare(` + UPDATE sessions SET revoked_at = ? + WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL + `).run(now, admin.user_id).changes; + if (admin.allowed === 1) { + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { access: "removed" }, + beforeSummary: { access: "allowed" }, + operationType: "admin_allowlist_remove", + result: "succeeded", + targetRef: admin.user_id, + targetType: "admin_account", + }, now); + } + } else if (admin.status === "suspended" && recoverySet.has(adminHash)) { + this.database.prepare("UPDATE users SET status = 'active' WHERE user_id = ?").run(admin.user_id); + this.database.prepare(` + DELETE FROM email_challenges WHERE email = ? AND purpose = 'admin_login' + `).run(admin.normalized_email); + recoveredAdmins += 1; + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { status: "active" }, + beforeSummary: { status: "suspended" }, + operationType: "admin_recover", + result: "succeeded", + targetRef: admin.user_id, + targetType: "admin_account", + }, now); + } + } + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { allowlist_count: allowlist.length, recovered_admins: recoveredAdmins, revoked_sessions: revokedSessions }, + beforeSummary: { allowlist_count: this.readAppliedAllowlistCount(), revision: appliedRevision }, + operationType: "secure_config_apply", + result: "succeeded", + targetRef: `revision:${candidate.secureConfigRevision}`, + targetType: "secure_config_revision", + }, now); + this.database.prepare(` + INSERT INTO secure_config_apply_state (singleton, applied_revision, allowlist_count, applied_at) + VALUES (1, ?, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + applied_revision = excluded.applied_revision, + allowlist_count = excluded.allowlist_count, + applied_at = excluded.applied_at + `).run(candidate.secureConfigRevision, allowlist.length, now); + return { + outcome: "committed", + value: { appliedRevision: candidate.secureConfigRevision, status: "applied" as const }, + }; + }); + this.adminAllowlistHashes = allowlistSet; + return result; + } catch (error) { + const reason = error instanceof Error ? error.message : "secure_config_apply_failed"; + this.recordConfigApplyFailure(reason, now); + throw error; + } + } + + async sendAdminLoginCode(input: { clientKey: string; email: string }): Promise { + const email = normalizeEmail(input.email); + const clientKey = normalizeProfileValue(input.clientKey, 160); + const now = this.options.clock(); + const challengeId = randomUUID(); + const code = this.options.codeGenerator(); + if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); + + const outcome = this.runImmediate("admin_login_send", () => { + if (!this.isAdminAllowlisted(email)) { + this.recordAdminLoginRejection("not_allowed", now); + return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") }; + } + const user = this.database.prepare(` + SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed + FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id + WHERE u.normalized_email = ? AND u.status <> 'deleted' + `).get(email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (user?.status === "suspended") { + this.recordAdminLoginRejection("suspended", now); + return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended") }; + } + if (user && (user.role !== "super_admin" || user.allowed !== 1)) { + this.recordAdminLoginRejection("not_allowed", now); + return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") }; + } + this.assertChallengeSendAllowed(email, "admin_login", clientKey, now); + this.recordRateSend(email, clientKey, now); + this.database.prepare(` + INSERT INTO email_challenges ( + challenge_id, email, invite_id, code_hmac, purpose, expires_at, + resend_available_at, failure_count, consumed_at, created_at + ) VALUES (?, ?, NULL, ?, 'admin_login', ?, ?, 0, NULL, ?) + `).run( + challengeId, + email, + this.challengeHmac(challengeId, code), + now + challengeLifetimeMilliseconds, + now + resendDelayMilliseconds, + now, + ); + return { + outcome: "committed", + value: { + challengeExpiresAt: now + challengeLifetimeMilliseconds, + registrationId: challengeId, + resendAvailableAt: now + resendDelayMilliseconds, + status: "verification_sent" as const, + }, + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + try { + await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" }); + } catch { + this.runImmediate("registration_send_compensation", () => { + this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); + this.recordAdminLoginRejection("service_unavailable", now); + return { outcome: "committed", value: undefined }; + }); + throw new Error("AUTH_SERVICE_UNAVAILABLE"); + } + return outcome; + } + + completeAdminLogin(input: LoginCompleteInput): AdminLoginCompleteResult { + if (!/^[0-9]{6}$/.test(input.code)) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); + const clientKey = normalizeProfileValue(input.clientKey, 160); + if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); + } + const now = this.options.clock(); + const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `admin-login-idempotency:${input.idempotencyKey}`); + const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ + clientKey, + code: input.code, + registrationId: input.registrationId, + })); + const outcome = this.runImmediate("admin_login_complete", () => { + const previous = this.database.prepare(` + SELECT request_hash, outcome_code, failure_reason, user_id, session_id + FROM login_attempts WHERE idempotency_key_digest = ? + `).get(idempotencyDigest) as LoginAttemptRow | undefined; + if (previous) { + if (!constantTimeTextEqual(previous.request_hash, requestHash)) { + throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); + } + if (previous.outcome_code === "failure") { + return { + outcome: "idempotent_replay", + value: new RegistrationError("AUTH_ENTRY_REJECTED", previous.failure_reason ?? "challenge_invalid"), + }; + } + return { + outcome: "idempotent_replay", + value: this.readAdminLoginResult(previous.user_id!, previous.session_id!), + }; + } + const challenge = this.database.prepare(` + SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at + FROM email_challenges WHERE challenge_id = ? AND purpose = 'admin_login' + `).get(input.registrationId) as ChallengeRow | undefined; + if (!challenge || challenge.consumed_at !== null) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); + } + if (!this.isAdminAllowlisted(challenge.email)) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now); + } + const rate = this.readRateLimit(challenge.email, clientKey, now); + if (rate.blocked_until !== null && rate.blocked_until > now) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "too_many_attempts", now); + } + if (challenge.expires_at <= now) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); + } + if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { + this.database.prepare("UPDATE email_challenges SET failure_count = failure_count + 1 WHERE challenge_id = ?") + .run(challenge.challenge_id); + const failedAttempts = this.recordRateFailure(challenge.email, clientKey, now); + return this.recordAdminLoginFailure( + idempotencyDigest, + requestHash, + input.registrationId, + failedAttempts >= maximumFailedAttempts ? "too_many_attempts" : "challenge_invalid", + now, + ); + } + let user = this.database.prepare(` + SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed + FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id + WHERE u.normalized_email = ? AND u.status <> 'deleted' + `).get(challenge.email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (user && (user.role !== "super_admin" || user.status !== "active" || user.allowed !== 1)) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now); + } + if (!user) { + const userId = randomUUID(); + this.database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, 'super_admin', 'active', 0, ?, ?) + `).run(userId, challenge.email, challenge.challenge_id, now); + this.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); + user = { allowed: 1, role: "super_admin", status: "active", user_id: userId }; + this.recordAdminAudit({ + actorRef: userId, + actorType: "super_admin", + afterSummary: { role: "super_admin", status: "active" }, + beforeSummary: null, + operationType: "admin_create", + result: "succeeded", + targetRef: userId, + targetType: "admin_account", + }, now); + } + this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") + .run(now, challenge.challenge_id); + const issued = this.insertSession(user.user_id, "admin", now); + this.database.prepare(` + INSERT INTO login_attempts ( + idempotency_key_digest, request_hash, challenge_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) + `).run(idempotencyDigest, requestHash, input.registrationId, user.user_id, issued.sessionId, now); + this.recordAdminAudit({ + actorRef: user.user_id, + actorType: "super_admin", + afterSummary: { audience: "admin" }, + beforeSummary: null, + operationType: "admin_login", + result: "succeeded", + targetRef: user.user_id, + targetType: "admin_session", + }, now); + return { + outcome: "committed", + value: this.readAdminLoginResult(user.user_id, issued.sessionId), + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + return outcome; + } + issueAuthenticatedSession(userId: string, audience: "user" | "admin") { const now = this.options.clock(); return this.runImmediate("session_issue", () => { @@ -614,6 +949,24 @@ export class RegistrationService { }); } + issueAdminCsrfToken(sessionToken: string) { + const now = this.options.clock(); + const csrfToken = randomBytes(32).toString("base64url"); + return this.runImmediate("csrf_issue", () => { + const session = this.database.prepare(` + SELECT s.session_id FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN admin_access a ON a.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(digest(sessionToken), now) as { session_id: string } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?") + .run(digest(csrfToken), session.session_id); + return { outcome: "committed", value: csrfToken }; + }); + } + logoutUser(input: { csrfToken: string; sessionToken: string }) { const now = this.options.clock(); this.runImmediate("session_revoke", () => { @@ -653,6 +1006,16 @@ export class RegistrationService { if (reason === "whitelist_removed") this.database.prepare("UPDATE admin_access SET allowed = 0 WHERE user_id = ?").run(userId); this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL") .run(now, userId); + this.recordAdminAudit({ + actorRef: reason === "whitelist_removed" ? "backend_secure_config" : userId, + actorType: reason === "whitelist_removed" ? "system" : "super_admin", + afterSummary: { access: reason === "whitelist_removed" ? "removed" : reason }, + beforeSummary: { access: "active" }, + operationType: reason === "disabled" ? "admin_disable" : reason === "logout" ? "admin_logout" : "admin_allowlist_remove", + result: "succeeded", + targetRef: userId, + targetType: "admin_account", + }, now); return { outcome: "committed", value: undefined }; }); } @@ -794,6 +1157,29 @@ export class RegistrationService { user_id TEXT PRIMARY KEY REFERENCES users(user_id), allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)) ); + CREATE TABLE IF NOT EXISTS secure_config_apply_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + applied_revision INTEGER NOT NULL CHECK (applied_revision >= 0), + allowlist_count INTEGER NOT NULL CHECK (allowlist_count >= 0), + applied_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS admin_operation_logs ( + log_id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_ref TEXT NOT NULL, + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; CREATE TABLE IF NOT EXISTS auth_rate_limits ( rate_key TEXT PRIMARY KEY, window_started_at INTEGER NOT NULL, @@ -811,6 +1197,62 @@ export class RegistrationService { session_id TEXT REFERENCES sessions(session_id), created_at INTEGER NOT NULL ); + INSERT OR IGNORE INTO secure_config_apply_state ( + singleton, applied_revision, allowlist_count, applied_at + ) VALUES (1, 0, 0, 0); + `); + this.migrateLegacyAdminOperationLogs(); + } + + private migrateLegacyAdminOperationLogs() { + const columns = this.database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>; + if (columns.some((column) => column.name === "actor_type")) return; + const legacy = this.database.prepare(` + SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs + `).all() as Array<{ created_at: string | number; log_id: string; operation: string; outcome: string; target_ref: string }>; + this.database.exec(` + DROP TRIGGER IF EXISTS admin_operation_logs_no_update; + DROP TRIGGER IF EXISTS admin_operation_logs_no_delete; + ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_legacy; + CREATE TABLE admin_operation_logs ( + log_id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_ref TEXT NOT NULL, + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + `); + const insert = this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage_migration', ?, 'legacy_operation', ?, ?, NULL, ?, ?, ?) + `); + for (const entry of legacy) { + const parsed = typeof entry.created_at === "number" ? entry.created_at : Date.parse(entry.created_at); + const occurredAt = Number.isFinite(parsed) ? parsed : this.options.clock(); + insert.run( + entry.log_id, + entry.operation, + entry.target_ref, + entry.outcome.startsWith("denied") ? "failed" : "succeeded", + JSON.stringify({ legacy_outcome: entry.outcome }), + occurredAt, + occurredAt + 180 * 24 * 60 * 60 * 1_000, + ); + } + this.database.exec(` + DROP TABLE admin_operation_logs_legacy; + CREATE TRIGGER admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; `); } @@ -843,6 +1285,19 @@ export class RegistrationService { return this.keyedHmac(this.options.challengePepper, `${challengeId}:${code}`); } + private adminAllowlistHmac(email: string) { + if (!this.options.adminAllowlistPepper) throw new Error("admin_pepper_not_configured"); + return createHmac("sha256", this.options.adminAllowlistPepper) + .update(email.trim().toLowerCase(), "utf8") + .digest("hex") + .toUpperCase(); + } + + private isAdminAllowlisted(email: string) { + return Boolean(this.options.adminAllowlistPepper) + && this.adminAllowlistHashes.has(this.adminAllowlistHmac(email)); + } + private sessionToken(sessionId: string) { return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url"); } @@ -909,7 +1364,7 @@ export class RegistrationService { private assertChallengeSendAllowed( email: string, - purpose: "register" | "login", + purpose: "register" | "login" | "admin_login", clientKey: string, now: number, ) { @@ -939,6 +1394,120 @@ export class RegistrationService { return { sessionExpiresAt, sessionId, sessionToken }; } + private readAdminLoginResult(userId: string, sessionId: string): AdminLoginCompleteResult { + const row = this.database.prepare(` + SELECT u.user_id, s.expires_at + FROM users u + JOIN admin_access a ON a.user_id = u.user_id + JOIN sessions s ON s.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' + AND a.allowed = 1 AND s.session_id = ? AND s.audience = 'admin' + `).get(userId, sessionId) as { expires_at: number; user_id: string } | undefined; + if (!row) throw new RegistrationError("AUTH_ENTRY_REJECTED", "challenge_invalid"); + return { + admin: { role: "super_admin", status: "active", userId: row.user_id }, + audience: "admin", + sessionExpiresAt: row.expires_at, + sessionToken: this.sessionToken(sessionId), + status: "authenticated", + }; + } + + private readAppliedAllowlistCount() { + const state = this.database.prepare(` + SELECT allowlist_count FROM secure_config_apply_state WHERE singleton = 1 + `).get() as { allowlist_count: number } | undefined; + return state?.allowlist_count ?? 0; + } + + private recordAdminAudit(input: { + actorRef: string; + actorType: "system" | "super_admin"; + afterSummary: Record | null; + beforeSummary: Record | null; + operationType: string; + result: "succeeded" | "failed"; + targetRef: string; + targetType: string; + }, now: number) { + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + randomUUID(), + input.actorType, + input.actorRef, + input.operationType, + input.targetType, + input.targetRef, + input.result, + input.beforeSummary === null ? null : JSON.stringify(input.beforeSummary), + input.afterSummary === null ? null : JSON.stringify(input.afterSummary), + now, + now + 180 * 24 * 60 * 60 * 1_000, + ); + } + + private recordAdminLoginRejection(reason: string, now: number) { + this.recordAdminAudit({ + actorRef: "admin_auth", + actorType: "system", + afterSummary: { reason }, + beforeSummary: null, + operationType: "admin_login", + result: "failed", + targetRef: "admin_login", + targetType: "admin_session", + }, now); + } + + private recordAdminLoginFailure( + idempotencyKeyDigest: string, + requestHash: string, + challengeId: string, + reason: RegistrationErrorReason, + now: number, + ) { + const failure = this.recordLoginFailure(idempotencyKeyDigest, requestHash, challengeId, reason, now); + this.recordAdminLoginRejection(reason === "admin_not_allowed" ? "not_allowed" : reason, now); + return failure; + } + + private recordConfigApplyFailure(reason: string, now: number) { + this.database.exec("BEGIN IMMEDIATE"); + try { + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { reason: this.safeConfigFailureReason(reason) }, + beforeSummary: { allowlist_count: this.readAppliedAllowlistCount() }, + operationType: "secure_config_apply", + result: "failed", + targetRef: "candidate_revision", + targetType: "secure_config_revision", + }, now); + this.database.exec("COMMIT"); + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + throw new Error("secure_config_failed_audit_unavailable", { cause: error }); + } + } + + private safeConfigFailureReason(reason: string) { + const allowed = new Set([ + "admin_pepper_not_configured", + "secure_config_revision_invalid", + "admin_allowlist_invalid", + "admin_recovery_invalid", + "hmac_invalid", + "secure_config_revision_out_of_sequence", + "identity_conflict", + ]); + return allowed.has(reason) ? reason : "secure_config_apply_failed"; + } + private recordLoginFailure( idempotencyKeyDigest: string, requestHash: string, diff --git a/apps/api/src/resend-adapter.ts b/apps/api/src/resend-adapter.ts index a9f9516..81ef9d7 100644 --- a/apps/api/src/resend-adapter.ts +++ b/apps/api/src/resend-adapter.ts @@ -2,7 +2,7 @@ export interface RegistrationCodeMessage { challengeId: string; code: string; email: string; - purpose: "register" | "login"; + purpose: "register" | "login" | "admin_login"; } export interface ResendAdapter { diff --git a/apps/api/src/secure-config.ts b/apps/api/src/secure-config.ts new file mode 100644 index 0000000..4300f76 --- /dev/null +++ b/apps/api/src/secure-config.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; + +import type { SecureConfigCandidate } from "./registration.js"; + +export function readSecureConfigCandidate(path: string): SecureConfigCandidate { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record; + if (parsed.schema_version !== 1 || !Number.isSafeInteger(parsed.secure_config_revision)) { + throw new Error("secure_config_integrity_invalid"); + } + if (!Array.isArray(parsed.admin_allowlist_hashes) || !Array.isArray(parsed.admin_recovery_hashes)) { + throw new Error("secure_config_integrity_invalid"); + } + if (parsed.admin_allowlist_hashes.some((value) => typeof value !== "string") + || parsed.admin_recovery_hashes.some((value) => typeof value !== "string")) { + throw new Error("secure_config_integrity_invalid"); + } + return { + adminAllowlistHashes: parsed.admin_allowlist_hashes as string[], + adminRecoveryHashes: parsed.admin_recovery_hashes as string[], + secureConfigRevision: parsed.secure_config_revision as number, + }; +} diff --git a/apps/api/src/supervisor-channel.ts b/apps/api/src/supervisor-channel.ts index 45f41f4..17b8cc8 100644 --- a/apps/api/src/supervisor-channel.ts +++ b/apps/api/src/supervisor-channel.ts @@ -1,6 +1,6 @@ import { createConnection } from "node:net"; -const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap"] as const; +const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const; export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) { const chunks: Buffer[] = []; @@ -26,8 +26,10 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) { const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0); + const adminPepperValue = credentials["Dada/P0A/admin/pepper"]; for (const name of API_CREDENTIALS) credentials[name] = ""; if (!configured) throw new Error("API credential client initialization failed."); + return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") }; } export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise) { diff --git a/apps/web/src/admin-auth.css b/apps/web/src/admin-auth.css new file mode 100644 index 0000000..c54393d --- /dev/null +++ b/apps/web/src/admin-auth.css @@ -0,0 +1,157 @@ +:root { + color: #121212; + font-family: "Microsoft YaHei", "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.admin-auth-page { + min-height: 100vh; + background: #ffffff; + display: grid; + grid-template-rows: 8px 72px 1fr; +} + +.admin-auth-accent { + background: #eaff00; +} + +.admin-auth-header { + align-items: center; + border-bottom: 1px solid #dedede; + display: flex; + padding: 0 32px; +} + +.admin-auth-wordmark { + color: #111111; + font-family: Arial, sans-serif; + font-size: 20px; + font-weight: 800; + letter-spacing: 0; + text-decoration: none; +} + +.admin-auth-panel { + align-self: center; + justify-self: center; + margin: 48px 20px 96px; + width: min(440px, calc(100vw - 40px)); +} + +.admin-auth-kicker { + color: #606060; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + margin: 0 0 10px; +} + +.admin-auth-panel h1 { + font-size: 26px; + line-height: 1.35; + margin: 0 0 36px; +} + +.admin-auth-panel label { + display: block; + font-size: 14px; + font-weight: 650; + margin-bottom: 9px; +} + +.admin-auth-panel input { + border: 1px solid #b9b9b9; + border-radius: 4px; + font: inherit; + height: 48px; + min-width: 0; + padding: 0 13px; + width: 100%; +} + +.admin-auth-panel input:focus { + border-color: #111111; + box-shadow: 0 0 0 2px #eaff00; + outline: none; +} + +.admin-auth-send-row { + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr) 124px; +} + +.admin-auth-panel button { + border-radius: 4px; + cursor: pointer; + font: inherit; + font-weight: 700; + height: 48px; +} + +.admin-auth-panel button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.admin-auth-send { + background: #ffffff; + border: 1px solid #111111; + width: 124px; +} + +.admin-auth-code-field { + margin-top: 24px; +} + +.admin-auth-error { + border-left: 3px solid #c93333; + color: #8d1717; + font-size: 14px; + line-height: 1.55; + margin: 20px 0 0; + padding-left: 12px; +} + +.admin-auth-submit { + background: #151515; + border: 1px solid #151515; + color: #ffffff; + margin-top: 28px; + width: 100%; +} + +.admin-auth-return { + color: #363636; + display: inline-block; + font-size: 14px; + margin-top: 24px; + text-underline-offset: 4px; +} + +@media (max-width: 480px) { + .admin-auth-header { + padding: 0 20px; + } + + .admin-auth-panel { + align-self: start; + margin-top: 72px; + } + + .admin-auth-send-row { + grid-template-columns: minmax(0, 1fr) 112px; + } + + .admin-auth-send { + width: 112px; + } +} diff --git a/apps/web/src/admin-auth.tsx b/apps/web/src/admin-auth.tsx new file mode 100644 index 0000000..7b3ef42 --- /dev/null +++ b/apps/web/src/admin-auth.tsx @@ -0,0 +1,150 @@ +import { useEffect, useId, useState, type FormEvent } from "react"; + +import "./admin-auth.css"; + +interface ErrorEnvelopeBody { + error?: { + details?: { field_errors?: Array<{ message_key?: string }> }; + }; +} + +function adminErrorMessage(body: ErrorEnvelopeBody) { + const key = body.error?.details?.field_errors?.[0]?.message_key; + if (key === "auth.challenge.invalid") return "验证码不正确,请检查后重试。"; + if (key === "auth.challenge.expired") return "验证码已过期,请重新获取。"; + if (key === "auth.challenge.resend_too_soon") return "请等待倒计时结束后重新获取验证码。"; + if (key === "auth.account.suspended" || key === "admin.auth.not_allowed") return "无法使用管理员入口,请联系部署维护人员。"; + return "管理员登录暂时无法完成,请稍后重试。"; +} + +export function AdminAuthPage() { + const emailId = useId(); + const codeId = useId(); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [registrationId, setRegistrationId] = useState(); + const [countdown, setCountdown] = useState(0); + const [sending, setSending] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(); + const emailValid = /^[^@\s]+@[^@\s]+$/.test(email); + + useEffect(() => { + if (countdown <= 0) return; + const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000); + return () => window.clearInterval(timer); + }, [countdown]); + + async function sendCode() { + if (!emailValid || sending || countdown > 0) return; + setSending(true); + setError(undefined); + try { + const response = await fetch("/api/v1/admin-auth/login/send", { + body: JSON.stringify({ email }), + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string }; + if (!response.ok || !body.registration_id) { + setError(adminErrorMessage(body)); + return; + } + setRegistrationId(body.registration_id); + setCountdown(60); + window.requestAnimationFrame(() => document.getElementById(codeId)?.focus()); + } catch { + setError("管理员登录暂时无法完成,请稍后重试。"); + } finally { + setSending(false); + } + } + + async function completeLogin(event: FormEvent) { + event.preventDefault(); + if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return; + setSubmitting(true); + setError(undefined); + try { + const response = await fetch("/api/v1/admin-auth/login/complete", { + body: JSON.stringify({ registration_id: registrationId, verification_code: code }), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""), + }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody; + if (!response.ok) { + setError(adminErrorMessage(body)); + return; + } + window.location.assign("/admin"); + } catch { + setError("管理员登录暂时无法完成,请稍后重试。"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ ); +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 8a19298..8e06543 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, LogoutResponse, RegistrationSendResponse, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js"; +import type { AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AdminSessionResponse, UserSessionResponse, LogoutResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: { }>; } +export async function completeAdminLogin(body: AdminLoginCompleteRequest, 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/admin-auth/login/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 completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -63,6 +72,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op return response.json() as Promise; } +export async function getAdminSession(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.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<{ @@ -120,6 +136,15 @@ export async function logoutUser(options: ClientOptions = {}): Promise; } +export async function sendAdminLoginCode(body: AdminLoginSendRequest, 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/admin-auth/login/send`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index cb2d72e..8727fc0 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -1,5 +1,38 @@ // Generated from openapi/openapi.json. Do not edit by hand. +export type AdminAuthenticatedUser = { + "role": "super_admin"; + "status": "active"; + "user_id": string; +}; + +export type AdminLoginCompleteRequest = { + "registration_id": string; + "verification_code": string; +}; + +export type AdminLoginCompleteResponse = { + "admin": AdminAuthenticatedUser; + "audience": "admin"; + "session_expires_at": string; + "status": "authenticated"; +}; + +export type AdminLoginSendRequest = { + "email": string; +}; + +export type AdminSessionResponse = { + "acknowledged_private_content_notice_version": string | null; + "admin": AdminAuthenticatedUser; + "audience": "admin"; + "authenticated": true; + "csrf_token": string; + "current_private_content_notice_version": string | null; + "expires_at": string; + "notice_acknowledged": boolean; +}; + export type AuthenticatedUser = { "creator_name": string; "role": "user"; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 75d77c7..18c176c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { registerPublicAssetServiceWorker } from "./public-asset-cache.js"; +import { AdminAuthPage } from "./admin-auth.js"; import { UserAuthPage } from "./user-auth.js"; const root = document.getElementById("root"); @@ -18,9 +19,12 @@ let authRevision = 0; function renderAuthenticationEntry() { authRevision += 1; + const authenticationPage = window.location.pathname.startsWith("/admin") + ? + : ; appRoot.render( - + {authenticationPage} , ); } diff --git a/apps/web/src/user-auth.tsx b/apps/web/src/user-auth.tsx index 5c2970c..d9fd197 100644 --- a/apps/web/src/user-auth.tsx +++ b/apps/web/src/user-auth.tsx @@ -269,7 +269,7 @@ export function UserAuthPage() {
- 管理员登录 + 管理员登录