1030 lines
36 KiB
TypeScript
1030 lines
36 KiB
TypeScript
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
import {
|
|
AccountDeletionCompleteRequestSchema,
|
|
AccountDeletionResponseSchema,
|
|
AccountDeletionSendResponseSchema,
|
|
AccountProfileUpdateRequestSchema,
|
|
AccountProfileUpdateResponseSchema,
|
|
AccountSettingsResponseSchema,
|
|
AdminAuthenticatedUserSchema,
|
|
AdminLoginCompleteRequestSchema,
|
|
AdminLoginCompleteResponseSchema,
|
|
AdminLoginSendRequestSchema,
|
|
AdminSessionResponseSchema,
|
|
BootstrapResponseSchema,
|
|
CorrelationIdSchema,
|
|
AuthenticatedUserSchema,
|
|
CreditSummarySchema,
|
|
CsrfHeadersSchema,
|
|
ErrorDetailsSchema,
|
|
ErrorEnvelopeSchema,
|
|
GenerationErrorCategorySchema,
|
|
LoginCompleteRequestSchema,
|
|
LoginCompleteResponseSchema,
|
|
LoginSendRequestSchema,
|
|
LogoutHeadersSchema,
|
|
LogoutResponseSchema,
|
|
ModelConfigSseEventSchema,
|
|
ModelRuntimeSseEventSchema,
|
|
RegistrationCompleteHeadersSchema,
|
|
RegistrationCompleteRequestSchema,
|
|
RegistrationCompleteResponseSchema,
|
|
RegistrationSendRequestSchema,
|
|
RegistrationSendResponseSchema,
|
|
SseEventSchema,
|
|
StableEngineeringErrorCodeSchema,
|
|
StateSseEventSchema,
|
|
Type,
|
|
UserSessionResponseSchema,
|
|
createErrorEnvelope,
|
|
isCorrelationId,
|
|
type BootstrapResponse,
|
|
type AdminLoginCompleteRequest,
|
|
type AdminLoginSendRequest,
|
|
type AccountDeletionCompleteRequest,
|
|
type AccountProfileUpdateRequest,
|
|
type LoginCompleteRequest,
|
|
type LoginSendRequest,
|
|
type RegistrationCompleteRequest,
|
|
type RegistrationSendRequest,
|
|
} from "@dada/shared-contracts";
|
|
import swagger from "@fastify/swagger";
|
|
import Fastify, { type FastifyReply } from "fastify";
|
|
|
|
import {
|
|
BrowserSupportRequestSchema,
|
|
BrowserSupportSuccessSchema,
|
|
BrowserUnsupportedReasonSchema,
|
|
browserSupportCookieMaxAgeSeconds,
|
|
browserSupportCookieName,
|
|
checkBrowserSupport,
|
|
createBrowserSupportCookie,
|
|
supportedBrowserSummary,
|
|
verifyBrowserSupportCookie,
|
|
type BrowserSupportRelease,
|
|
type BrowserUnsupportedReason,
|
|
} from "./browser-support.js";
|
|
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",
|
|
dependencies: [],
|
|
model_summary: {
|
|
config_set_version: null,
|
|
configured_default_model_id: null,
|
|
recommended_model_id: null,
|
|
runtime_availability_version: null,
|
|
},
|
|
public_features: [],
|
|
};
|
|
|
|
export interface CreateAppOptions {
|
|
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
|
browserGate?: boolean;
|
|
browserSupportRelease?: BrowserSupportRelease;
|
|
browserSupportSecret?: Buffer;
|
|
eventHub?: EventHub;
|
|
networkBoundary?: NetworkBoundaryOptions;
|
|
publicAssets?: PublicAssetResolver;
|
|
registration?: RegistrationService;
|
|
}
|
|
|
|
const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate");
|
|
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
|
|
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
|
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
|
|
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
|
|
const contentSecurityPolicy = [
|
|
"default-src 'self'",
|
|
"script-src 'self'",
|
|
"style-src 'self'",
|
|
"img-src 'self' blob:",
|
|
"font-src 'self' blob:",
|
|
"connect-src 'self'",
|
|
"object-src 'none'",
|
|
"base-uri 'none'",
|
|
"frame-ancestors 'none'",
|
|
].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<string, string | string[] | undefined>) {
|
|
const header = headers["x-correlation-id"];
|
|
const candidate = Array.isArray(header) ? header[0] : header;
|
|
return isCorrelationId(candidate) ? candidate : randomUUID();
|
|
}
|
|
|
|
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;
|
|
return (
|
|
path === "/" ||
|
|
path === "/app" ||
|
|
path.startsWith("/app/") ||
|
|
path === "/admin" ||
|
|
path.startsWith("/admin/") ||
|
|
path === "/support-gate.css" ||
|
|
path === "/support-gate.js" ||
|
|
path === "/RELEASE.json" ||
|
|
path === "/healthz"
|
|
);
|
|
}
|
|
|
|
function sendBrowserUnsupported(
|
|
reply: FastifyReply,
|
|
correlationId: string,
|
|
reason: BrowserUnsupportedReason,
|
|
release: BrowserSupportRelease | undefined,
|
|
) {
|
|
return reply.code(426).send(
|
|
createErrorEnvelope({
|
|
code: "BROWSER_UNSUPPORTED",
|
|
correlationId,
|
|
details: {
|
|
reason,
|
|
supported_browsers: supportedBrowserSummary(release),
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function createApp(options: CreateAppOptions = {}) {
|
|
const eventHub = options.eventHub ?? new EventHub();
|
|
const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
|
|
const browserGate = options.browserGate ?? true;
|
|
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
|
|
const browserSupportRelease = options.browserSupportRelease;
|
|
const app = Fastify({
|
|
genReqId: (request) => requestCorrelationId(request.headers),
|
|
logger: false,
|
|
});
|
|
|
|
await app.register(swagger, {
|
|
openapi: {
|
|
info: {
|
|
title: "Dada P0-A",
|
|
version: "0.0.0",
|
|
},
|
|
openapi: "3.1.0",
|
|
},
|
|
refResolver: {
|
|
buildLocalReference: (schema, _baseUri, _fragment, index) =>
|
|
typeof schema.$id === "string" ? schema.$id : `schema-${index}`,
|
|
},
|
|
});
|
|
|
|
for (const schema of [
|
|
CorrelationIdSchema,
|
|
GenerationErrorCategorySchema,
|
|
StableEngineeringErrorCodeSchema,
|
|
ErrorDetailsSchema,
|
|
ErrorEnvelopeSchema,
|
|
AuthenticatedUserSchema,
|
|
AdminAuthenticatedUserSchema,
|
|
AdminLoginSendRequestSchema,
|
|
AdminLoginCompleteRequestSchema,
|
|
AdminLoginCompleteResponseSchema,
|
|
AdminSessionResponseSchema,
|
|
CreditSummarySchema,
|
|
CsrfHeadersSchema,
|
|
AccountSettingsResponseSchema,
|
|
AccountProfileUpdateRequestSchema,
|
|
AccountProfileUpdateResponseSchema,
|
|
AccountDeletionSendResponseSchema,
|
|
AccountDeletionCompleteRequestSchema,
|
|
AccountDeletionResponseSchema,
|
|
RegistrationSendRequestSchema,
|
|
RegistrationSendResponseSchema,
|
|
RegistrationCompleteRequestSchema,
|
|
RegistrationCompleteHeadersSchema,
|
|
RegistrationCompleteResponseSchema,
|
|
LoginSendRequestSchema,
|
|
LoginCompleteRequestSchema,
|
|
LoginCompleteResponseSchema,
|
|
LogoutHeadersSchema,
|
|
LogoutResponseSchema,
|
|
UserSessionResponseSchema,
|
|
BrowserUnsupportedReasonSchema,
|
|
BrowserSupportRequestSchema,
|
|
BrowserSupportSuccessSchema,
|
|
BootstrapResponseSchema,
|
|
StateSseEventSchema,
|
|
ModelConfigSseEventSchema,
|
|
ModelRuntimeSseEventSchema,
|
|
SseEventSchema,
|
|
]) {
|
|
app.addSchema(schema);
|
|
}
|
|
|
|
app.addHook("onRequest", async (request, reply) => {
|
|
reply.header("X-Correlation-Id", request.id);
|
|
reply.header("Accept-CH", clientHints);
|
|
reply.header("Cache-Control", "no-store");
|
|
reply.header("Content-Security-Policy", contentSecurityPolicy);
|
|
reply.header("X-Content-Type-Options", "nosniff");
|
|
|
|
const host = headerValue(request.headers.host);
|
|
const origin = headerValue(request.headers.origin);
|
|
if (!isAllowedNetworkRequest({ host, method: request.method, origin }, options.networkBoundary)) {
|
|
return sendBrowserUnsupported(reply, request.id, "identity_unavailable", browserSupportRelease);
|
|
}
|
|
|
|
const path = request.url.split("?", 1)[0] ?? "/";
|
|
if (!browserGate || isSupportGateRequest(request.method, path)) return;
|
|
const verified = verifyBrowserSupportCookie({
|
|
cookieHeader: headerValue(request.headers.cookie),
|
|
release: browserSupportRelease,
|
|
secChUa: headerValue(request.headers["sec-ch-ua"]),
|
|
secret: browserSupportSecret,
|
|
});
|
|
if (!verified.supported) {
|
|
return sendBrowserUnsupported(reply, request.id, verified.reason, browserSupportRelease);
|
|
}
|
|
});
|
|
|
|
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
|
|
app.get(route, { schema: { hide: true } }, async (_request, reply) => {
|
|
reply.type("text/html; charset=utf-8");
|
|
return supportGateHtml;
|
|
});
|
|
}
|
|
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
|
|
reply.type("text/css; charset=utf-8");
|
|
return supportGateCss;
|
|
});
|
|
app.get("/support-gate.js", { schema: { hide: true } }, async (_request, reply) => {
|
|
reply.type("text/javascript; charset=utf-8");
|
|
return supportGateJavaScript;
|
|
});
|
|
app.get("/RELEASE.json", { schema: { hide: true } }, async () => ({
|
|
app_version: browserSupportRelease?.appVersion ?? null,
|
|
browsers: supportedBrowserSummary(browserSupportRelease),
|
|
}));
|
|
app.get("/healthz", { schema: { hide: true } }, async () => ({
|
|
bind_scope: "loopback",
|
|
port: 43121,
|
|
status: "ready",
|
|
}));
|
|
|
|
app.get(
|
|
"/api/v1/assets/public/:resourceVersion/:assetId",
|
|
{ schema: { hide: true } },
|
|
async (request, reply) => {
|
|
const { assetId, resourceVersion } = request.params as { assetId?: string; resourceVersion?: string };
|
|
const resource = assetId && resourceVersion
|
|
? options.publicAssets?.read(resourceVersion, assetId)
|
|
: undefined;
|
|
if (!resource) return reply.code(404).send();
|
|
reply.type(resource.mimeType);
|
|
reply.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
reply.header("Content-Disposition", "inline");
|
|
reply.header("ETag", `"sha256-${resource.sha256}"`);
|
|
return resource.bytes;
|
|
},
|
|
);
|
|
|
|
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",
|
|
{
|
|
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",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
body: Type.Ref(RegistrationSendRequestSchema),
|
|
operationId: "sendRegistrationCode",
|
|
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 }));
|
|
}
|
|
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",
|
|
`${userSessionCookieName}=${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), userSessionCookieName);
|
|
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: options.registration.issueUserCsrfToken(token!),
|
|
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/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.get(
|
|
"/api/v1/account/settings",
|
|
{
|
|
schema: {
|
|
operationId: "getAccountSettings",
|
|
response: {
|
|
200: Type.Ref(AccountSettingsResponseSchema),
|
|
401: Type.Ref(ErrorEnvelopeSchema),
|
|
503: Type.Ref(ErrorEnvelopeSchema),
|
|
},
|
|
tags: ["Account"],
|
|
},
|
|
},
|
|
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), userSessionCookieName);
|
|
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
|
try {
|
|
const settings = options.registration.readAccountSettings(token);
|
|
const csrfToken = options.registration.issueUserCsrfToken(token);
|
|
return {
|
|
account: settings.account,
|
|
csrf_token: csrfToken,
|
|
local_data: {
|
|
backup_enabled: settings.localData.backupEnabled,
|
|
capacity_status: settings.localData.capacityStatus,
|
|
hard_limit_bytes: settings.localData.hardLimitBytes,
|
|
location: settings.localData.location,
|
|
managed_content_bytes: settings.localData.managedContentBytes,
|
|
migration_supported: settings.localData.migrationSupported,
|
|
},
|
|
profile: {
|
|
creator_name: settings.profile.creatorName,
|
|
social_id: settings.profile.socialId,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
return registrationFailure(reply, request.id, error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.put(
|
|
"/api/v1/account/settings/profile",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
body: Type.Ref(AccountProfileUpdateRequestSchema),
|
|
headers: Type.Ref(CsrfHeadersSchema),
|
|
operationId: "updateAccountProfile",
|
|
response: {
|
|
200: Type.Ref(AccountProfileUpdateResponseSchema),
|
|
400: Type.Ref(ErrorEnvelopeSchema),
|
|
401: Type.Ref(ErrorEnvelopeSchema),
|
|
403: Type.Ref(ErrorEnvelopeSchema),
|
|
503: Type.Ref(ErrorEnvelopeSchema),
|
|
},
|
|
tags: ["Account"],
|
|
},
|
|
},
|
|
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 {
|
|
const body = request.body as AccountProfileUpdateRequest;
|
|
const saved = options.registration.updateAccountProfile({
|
|
creatorName: body.creator_name,
|
|
csrfToken,
|
|
sessionToken: token,
|
|
socialId: body.social_id,
|
|
});
|
|
return { profile: { creator_name: saved.creatorName, social_id: saved.socialId }, status: saved.status };
|
|
} catch (error) {
|
|
return registrationFailure(reply, request.id, error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/api/v1/account/deletion/send",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
headers: Type.Ref(CsrfHeadersSchema),
|
|
operationId: "sendAccountDeletionCode",
|
|
response: {
|
|
200: Type.Ref(AccountDeletionSendResponseSchema),
|
|
400: Type.Ref(ErrorEnvelopeSchema),
|
|
401: Type.Ref(ErrorEnvelopeSchema),
|
|
403: Type.Ref(ErrorEnvelopeSchema),
|
|
429: Type.Ref(ErrorEnvelopeSchema),
|
|
503: Type.Ref(ErrorEnvelopeSchema),
|
|
},
|
|
tags: ["Account"],
|
|
},
|
|
},
|
|
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 {
|
|
const sent = await options.registration.sendAccountDeletionCode({ csrfToken, sessionToken: token });
|
|
return {
|
|
challenge_expires_at: new Date(sent.challengeExpiresAt).toISOString(),
|
|
deletion_id: sent.deletionId,
|
|
resend_available_at: new Date(sent.resendAvailableAt).toISOString(),
|
|
status: sent.status,
|
|
};
|
|
} catch (error) {
|
|
return registrationFailure(reply, request.id, error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/api/v1/account/deletion/complete",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
body: Type.Ref(AccountDeletionCompleteRequestSchema),
|
|
headers: Type.Ref(LogoutHeadersSchema),
|
|
operationId: "completeAccountDeletion",
|
|
response: {
|
|
200: Type.Ref(AccountDeletionResponseSchema),
|
|
400: Type.Ref(ErrorEnvelopeSchema),
|
|
401: Type.Ref(ErrorEnvelopeSchema),
|
|
403: Type.Ref(ErrorEnvelopeSchema),
|
|
409: Type.Ref(ErrorEnvelopeSchema),
|
|
503: Type.Ref(ErrorEnvelopeSchema),
|
|
},
|
|
tags: ["Account"],
|
|
},
|
|
},
|
|
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"]);
|
|
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
|
|
if (!token || !csrfToken || !idempotencyKey) {
|
|
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
|
}
|
|
try {
|
|
const body = request.body as AccountDeletionCompleteRequest;
|
|
const deleted = options.registration.completeAccountDeletion({
|
|
code: body.verification_code,
|
|
confirmation: body.confirmation,
|
|
csrfToken,
|
|
deletionId: body.deletion_id,
|
|
idempotencyKey,
|
|
sessionToken: token,
|
|
});
|
|
reply.header("Set-Cookie", `${userSessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`);
|
|
return deleted;
|
|
} catch (error) {
|
|
return registrationFailure(reply, request.id, error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/api/v1/support/check",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
body: BrowserSupportRequestSchema,
|
|
operationId: "checkBrowserSupport",
|
|
response: {
|
|
200: BrowserSupportSuccessSchema,
|
|
426: ErrorEnvelopeSchema,
|
|
},
|
|
tags: ["Browser support"],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const checked = checkBrowserSupport(
|
|
request.validationError ? undefined : request.body,
|
|
{
|
|
secChUa: headerValue(request.headers["sec-ch-ua"]),
|
|
secChUaFullVersionList: headerValue(request.headers["sec-ch-ua-full-version-list"]),
|
|
secChUaPlatform: headerValue(request.headers["sec-ch-ua-platform"]),
|
|
},
|
|
browserSupportRelease,
|
|
);
|
|
if (!checked.supported || !browserSupportRelease) {
|
|
return sendBrowserUnsupported(
|
|
reply,
|
|
request.id,
|
|
checked.supported ? "version_unsupported" : checked.reason,
|
|
browserSupportRelease,
|
|
);
|
|
}
|
|
|
|
const cookie = createBrowserSupportCookie(
|
|
browserSupportSecret,
|
|
browserSupportRelease,
|
|
checked.identity,
|
|
);
|
|
reply.header(
|
|
"Set-Cookie",
|
|
`${browserSupportCookieName}=${cookie}; Max-Age=${browserSupportCookieMaxAgeSeconds}; Path=/; HttpOnly; SameSite=Strict`,
|
|
);
|
|
return {
|
|
app_version: browserSupportRelease.appVersion,
|
|
browser: checked.identity,
|
|
status: "supported" as const,
|
|
supported_browsers: supportedBrowserSummary(browserSupportRelease),
|
|
};
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
"/api/v1/bootstrap",
|
|
{
|
|
schema: {
|
|
operationId: "getBootstrap",
|
|
response: {
|
|
200: BootstrapResponseSchema,
|
|
426: ErrorEnvelopeSchema,
|
|
},
|
|
tags: ["Bootstrap"],
|
|
},
|
|
},
|
|
async () => bootstrap(),
|
|
);
|
|
|
|
app.get(
|
|
"/api/v1/events",
|
|
{
|
|
schema: {
|
|
operationId: "getEvents",
|
|
response: {
|
|
200: {
|
|
content: {
|
|
"text/event-stream": {
|
|
schema: SseEventSchema,
|
|
},
|
|
},
|
|
description: "Non-sensitive state change hints. REST remains authoritative.",
|
|
},
|
|
426: ErrorEnvelopeSchema,
|
|
},
|
|
tags: ["State events"],
|
|
},
|
|
},
|
|
(request, reply) => {
|
|
reply.hijack();
|
|
reply.raw.setHeader("Cache-Control", "no-store");
|
|
reply.raw.setHeader("Connection", "keep-alive");
|
|
reply.raw.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
reply.raw.setHeader("X-Correlation-Id", request.id);
|
|
reply.raw.writeHead(200);
|
|
reply.raw.write(": connected\n\n");
|
|
|
|
const unsubscribe = eventHub.connect(
|
|
(event) => {
|
|
reply.raw.write(`id: ${event.event_id}\n`);
|
|
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
},
|
|
() => reply.raw.end(),
|
|
);
|
|
request.raw.once("close", unsubscribe);
|
|
},
|
|
);
|
|
|
|
return app;
|
|
}
|