feat: implement TASK-WP1-01 registration transaction
This commit is contained in:
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user