feat: implement TASK-WP1-01 registration transaction

This commit is contained in:
suyx
2026-07-28 15:54:28 +08:00
parent 1bf7e42294
commit f467e7c09f
15 changed files with 2191 additions and 4 deletions
+196
View File
@@ -5,17 +5,28 @@ import { resolve } from "node:path";
import {
BootstrapResponseSchema,
CorrelationIdSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
GenerationErrorCategorySchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteResponseSchema,
RegistrationSendRequestSchema,
RegistrationSendResponseSchema,
SseEventSchema,
StableEngineeringErrorCodeSchema,
StateSseEventSchema,
Type,
UserSessionResponseSchema,
createErrorEnvelope,
isCorrelationId,
type BootstrapResponse,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
import swagger from "@fastify/swagger";
import Fastify, { type FastifyReply } from "fastify";
@@ -36,6 +47,11 @@ import {
import { EventHub } from "./event-hub.js";
import type { PublicAssetResolver } from "./local-data-root.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
import {
RegistrationError,
registrationFieldError,
} from "./registration-errors.js";
import type { RegistrationService } from "./registration.js";
const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0",
@@ -57,6 +73,7 @@ export interface CreateAppOptions {
eventHub?: EventHub;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
registration?: RegistrationService;
}
const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate");
@@ -86,6 +103,39 @@ function headerValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function cookieValue(cookieHeader: string | undefined, name: string) {
if (!cookieHeader) return undefined;
for (const item of cookieHeader.split(";")) {
const separator = item.indexOf("=");
if (separator < 0) continue;
if (item.slice(0, separator).trim() === name) return item.slice(separator + 1).trim();
}
return undefined;
}
function registrationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (error instanceof RegistrationError) {
return reply.code(error.httpStatus).send(
createErrorEnvelope({
code: error.code,
correlationId,
details: { field_errors: [registrationFieldError(error.reason)] },
}),
);
}
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
function registrationValidationFailure(reply: FastifyReply, correlationId: string) {
return reply.code(400).send(
createErrorEnvelope({
code: "REGISTRATION_REQUEST_INVALID",
correlationId,
details: { field_errors: [{ field: "request", message_key: "auth.registration.request_invalid" }] },
}),
);
}
function isSupportGateRequest(method: string, path: string) {
if (method === "POST" && path === "/api/v1/support/check") return true;
if (method !== "GET" && method !== "HEAD") return false;
@@ -151,6 +201,14 @@ export async function createApp(options: CreateAppOptions = {}) {
StableEngineeringErrorCodeSchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
RegistrationSendRequestSchema,
RegistrationSendResponseSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteResponseSchema,
UserSessionResponseSchema,
BrowserUnsupportedReasonSchema,
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
@@ -230,6 +288,144 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.post(
"/api/v1/auth/register/send",
{
attachValidation: true,
schema: {
body: Type.Ref(RegistrationSendRequestSchema),
operationId: "sendRegistrationCode",
response: {
200: Type.Ref(RegistrationSendResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
try {
const body = request.body as RegistrationSendRequest;
const result = await options.registration.sendRegistrationCode({ email: body.email, inviteCode: body.invite_code });
return {
challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(),
registration_id: result.registrationId,
resend_available_at: new Date(result.resendAvailableAt).toISOString(),
status: result.status,
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/auth/register/complete",
{
attachValidation: true,
schema: {
body: Type.Ref(RegistrationCompleteRequestSchema),
headers: Type.Ref(RegistrationCompleteHeadersSchema),
operationId: "completeRegistration",
response: {
200: Type.Ref(RegistrationCompleteResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
try {
const body = request.body as RegistrationCompleteRequest;
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
if (!idempotencyKey) return registrationValidationFailure(reply, request.id);
const result = options.registration.completeRegistration({
code: body.verification_code,
creatorName: body.creator_name,
idempotencyKey,
privacyConsentAccepted: body.privacy_consent_accepted,
privacyNoticeVersion: body.privacy_notice_version,
registrationId: body.registration_id,
socialId: body.social_id,
});
reply.header(
"Set-Cookie",
`dada_session=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
credits: {
available_balance: result.credits.availableBalance,
reserved_balance: result.credits.reservedBalance,
},
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
status: result.status,
user: {
creator_name: result.user.creatorName,
role: result.user.role,
social_id: result.user.socialId,
status: result.user.status,
user_id: result.user.userId,
},
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/auth/session",
{
schema: {
operationId: "getUserSession",
response: {
200: Type.Ref(UserSessionResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), "dada_session");
const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
}
return {
audience: session.audience,
authenticated: true as const,
credits: {
available_balance: session.credits.availableBalance,
reserved_balance: session.credits.reservedBalance,
},
csrf_token: randomBytes(32).toString("base64url"),
expires_at: new Date(session.expiresAt).toISOString(),
user: {
creator_name: session.user.creatorName,
role: session.user.role,
social_id: session.user.socialId,
status: session.user.status,
user_id: session.user.userId,
},
};
},
);
app.post(
"/api/v1/support/check",
{