Files
tyx_AI_xhs/apps/api/src/app.ts
T

2019 lines
74 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,
AdminCreditParamsSchema,
AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema,
AdminLoginSendRequestSchema,
AdminSessionResponseSchema,
BootstrapResponseSchema,
CanvasBackgroundAdjustmentsSchema,
CanvasElementSchema,
CanvasStateSchema,
CorrelationIdSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
CreditAdjustmentHeadersSchema,
CreditAdjustmentRequestSchema,
CreditAdjustmentResponseSchema,
CreditBalanceResponseSchema,
CreditEntryStatusSchema,
CreditEntryTypeSchema,
CreditLedgerEntrySchema,
CreditLedgerQuerySchema,
CreditLedgerResponseSchema,
CreditReferenceTypeSchema,
CsrfHeadersSchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
GenerationErrorCategorySchema,
GenerationCreateHeadersSchema,
GenerationCreateResponseSchema,
GenerationParamsSchema,
GenerationMultipartBodySchema,
GenerationTaskResponseSchema,
GenerationTaskStatusSchema,
LoginCompleteRequestSchema,
LoginCompleteResponseSchema,
LoginSendRequestSchema,
LogoutHeadersSchema,
LogoutResponseSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
GenerationProjectItemSchema,
ProjectDetailResponseSchema,
ProjectEditableStateSchema,
ProjectIdSchema,
ProjectImageItemSchema,
ProjectListQuerySchema,
ProjectListResponseSchema,
ProjectParamsSchema,
ProjectRatioSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectRestoreResponseSchema,
ProjectPurgeResponseSchema,
ProjectTrashResponseSchema,
ProjectStateConflictResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectSummarySchema,
ProjectViewStatusSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteResponseSchema,
RegistrationSendRequestSchema,
RegistrationSendResponseSchema,
SseEventSchema,
StableEngineeringErrorCodeSchema,
StateSseEventSchema,
Type,
UserSessionResponseSchema,
createErrorEnvelope,
isCorrelationId,
type BootstrapResponse,
type AdminLoginCompleteRequest,
type AdminLoginSendRequest,
type AccountDeletionCompleteRequest,
type AccountProfileUpdateRequest,
type AdminCreditParams,
type CreditAdjustmentHeaders,
type CreditAdjustmentRequest,
type CreditLedgerQuery,
type LoginCompleteRequest,
type LoginSendRequest,
type FailedEmptyTrashRequest,
type GenerationCreateHeaders,
type GenerationParams,
type ProjectListQuery,
type ProjectParams,
type ProjectRenameRequest,
type ProjectEditableState,
type ProjectStateSaveHeaders,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
import swagger from "@fastify/swagger";
import multipart from "@fastify/multipart";
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 { CreditError } from "./credit-errors.js";
import type { CreditService } from "./credits.js";
import type { PublicAssetResolver } from "./local-data-root.js";
import { GenerationSubmissionError } from "./generation-submission-errors.js";
import type {
GenerationSubmissionFields,
GenerationSubmissionService,
GenerationTaskView,
GenerationUploadSession,
NewGenerationReference,
} from "./generation-submission.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
import { ProjectError } from "./project-errors.js";
import type { ProjectService } from "./projects.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;
credits?: CreditService;
eventHub?: EventHub;
generations?: GenerationSubmissionService;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
projects?: ProjectService;
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 projectFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (!(error instanceof ProjectError)) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
const mapping = {
generation_state_invalid: 400,
project_active_limit: 409,
project_history_limit: 409,
project_name_invalid: 400,
project_not_found: 404,
project_ratio_fixed: 409,
project_retry_not_allowed: 409,
project_state_conflict: 412,
project_state_idempotency_conflict: 409,
project_state_invalid: 400,
} as const;
return reply.code(mapping[error.code]).send(null);
}
function creditFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (!(error instanceof CreditError)) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
if (error.code === "credit_operation_conflict") {
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId }));
}
const status = error.code === "credit_request_invalid"
? 400
: error.code === "credit_insufficient" || error.code === "credit_invariant_failed"
? 409
: 404;
return reply.code(status).send(null);
}
function generationTaskResponse(task: GenerationTaskView) {
return {
confirmed_credit_cost: task.confirmedCreditCost,
created_at: task.createdAt,
generation_id: task.generationId,
model_config_version: task.modelConfigVersion,
model_id: task.modelId,
project_id: task.projectId,
prompt: task.prompt,
ratio: task.ratio,
reference_count: task.referenceCount,
reserved_credits: task.reservedCredits,
status: task.status,
updated_at: task.updatedAt,
};
}
function generationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
if (error instanceof GenerationSubmissionError) {
if (error.code === "model_config_stale") {
return reply.code(412).send(createErrorEnvelope({
code: "MODEL_CONFIG_VERSION_CONFLICT",
correlationId,
details: { latest_version: error.latest?.configVersion ?? 0 },
}));
}
if (error.code === "generation_idempotency_conflict") {
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId }));
}
if (error.code === "generation_blocked") {
return reply.code(503).send(createErrorEnvelope({
code: "AUTH_SERVICE_UNAVAILABLE",
correlationId,
...(error.errorCategory ? { errorCategory: error.errorCategory } : {}),
}));
}
if (error.code === "generation_storage_unavailable") {
return reply.code(507).send(createErrorEnvelope({
code: "STORAGE_CAPACITY_EXCEEDED",
correlationId,
details: {
capacity_status: error.storage?.capacityStatus ?? "unavailable",
remaining_bytes: error.storage?.remainingBytes ?? 0,
},
}));
}
return reply.code(error.code === "generation_not_found" ? 404 : 400).send(null);
}
if (error && typeof error === "object" && "code" in error) {
if (typeof error.code === "string" && error.code.startsWith("FST_")) {
return reply.code(400).send(null);
}
if (error.code === "STORAGE_CAPACITY_EXCEEDED") {
const details = "details" in error && error.details && typeof error.details === "object"
? error.details as { capacity_status?: "normal" | "warning" | "critical" | "full" | "unavailable"; remaining_bytes?: number }
: {};
return reply.code(507).send(createErrorEnvelope({
code: "STORAGE_CAPACITY_EXCEEDED",
correlationId,
details: {
capacity_status: details.capacity_status ?? "full",
remaining_bytes: details.remaining_bytes ?? 0,
},
}));
}
if (error.code === "storage_unavailable") {
return reply.code(507).send(createErrorEnvelope({
code: "STORAGE_CAPACITY_EXCEEDED",
correlationId,
details: { capacity_status: "unavailable", remaining_bytes: 0 },
}));
}
}
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
}
const generationFieldNames = new Set([
"client_submission_id",
"confirmed_credit_cost",
"creation_mode",
"existing_reference_asset_ids",
"model_config_version",
"model_id",
"project_id",
"prompt",
"ratio",
"reference_manifest",
]);
interface ReferenceManifestEntry {
fileName: string;
mimeType: NewGenerationReference["mimeType"];
projectedBytes: number;
}
function positiveIntegerField(value: string | undefined) {
if (!value || !/^[1-9][0-9]*$/.test(value)) throw new GenerationSubmissionError("generation_request_invalid");
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) throw new GenerationSubmissionError("generation_request_invalid");
return parsed;
}
function jsonStringArray(value: string | undefined) {
if (value === undefined) return [];
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new GenerationSubmissionError("generation_request_invalid");
}
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
throw new GenerationSubmissionError("generation_request_invalid");
}
return parsed;
}
function referenceManifest(value: string | undefined): ReferenceManifestEntry[] {
if (value === undefined) return [];
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new GenerationSubmissionError("generation_request_invalid");
}
if (!Array.isArray(parsed)) throw new GenerationSubmissionError("generation_request_invalid");
return parsed.map((entry) => {
if (!entry || typeof entry !== "object") throw new GenerationSubmissionError("generation_request_invalid");
const item = entry as Record<string, unknown>;
const keys = Object.keys(item).toSorted();
if (keys.join(",") !== "file_name,mime_type,size" || typeof item.file_name !== "string"
|| !["image/jpeg", "image/png", "image/webp"].includes(String(item.mime_type))
|| !Number.isSafeInteger(item.size) || Number(item.size) <= 0) {
throw new GenerationSubmissionError("generation_request_invalid");
}
return {
fileName: item.file_name,
mimeType: item.mime_type as NewGenerationReference["mimeType"],
projectedBytes: Number(item.size),
};
});
}
function generationFields(
values: Map<string, string>,
input: { idempotencyKey: string; userId: string },
): { fields: GenerationSubmissionFields; manifest: ReferenceManifestEntry[] } {
for (const name of values.keys()) {
if (!generationFieldNames.has(name)) throw new GenerationSubmissionError("generation_request_invalid");
}
const creationMode = values.get("creation_mode");
if (creationMode !== "new_project" && creationMode !== "existing_project") {
throw new GenerationSubmissionError("generation_request_invalid");
}
const fields: GenerationSubmissionFields = {
clientSubmissionId: values.get("client_submission_id") ?? "",
confirmedCreditCost: positiveIntegerField(values.get("confirmed_credit_cost")),
existingReferenceAssetIds: jsonStringArray(values.get("existing_reference_asset_ids")),
idempotencyKey: input.idempotencyKey,
mode: creationMode,
modelConfigVersion: positiveIntegerField(values.get("model_config_version")),
modelId: values.get("model_id") ?? "",
...(values.has("project_id") ? { projectId: values.get("project_id")! } : {}),
prompt: values.get("prompt") ?? "",
ratio: values.get("ratio") as GenerationSubmissionFields["ratio"],
userId: input.userId,
};
return { fields, manifest: referenceManifest(values.get("reference_manifest")) };
}
type ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
function projectSummaryResponse(project: ProjectSummaryView) {
return {
current_image_id: project.currentImageId,
deleted_at: project.deletedAt,
name: project.name,
project_id: project.projectId,
purge_at: project.purgeAt,
ratio: project.ratio,
state_version: project.stateVersion,
status: project.status,
successful_image_count: project.successfulImageCount,
updated_at: project.updatedAt,
};
}
function projectDetailResponse(project: ProjectDetailView) {
return {
...projectSummaryResponse(project),
canvas_state: project.canvasState,
created_at: project.createdAt,
draft_prompt: project.draftPrompt,
generations: project.generations.map((generation) => ({
created_at: generation.createdAt,
error_category: generation.errorCategory,
generation_id: generation.generationId,
prompt: generation.prompt,
ratio: generation.ratio,
status: generation.status,
updated_at: generation.updatedAt,
})),
images: project.images.map((image) => ({
created_at: image.createdAt,
generation_id: image.generationId,
image_id: image.imageId,
})),
pixel_height: project.pixelHeight,
pixel_width: project.pixelWidth,
save_status: project.saveStatus,
};
}
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}`,
},
});
await app.register(multipart, {
limits: {
fieldNameSize: 120,
fieldSize: 16 * 1024,
fields: 20,
fileSize: 100 * 1024 * 1024,
files: 16,
parts: 36,
},
});
for (const schema of [
CorrelationIdSchema,
GenerationErrorCategorySchema,
GenerationTaskStatusSchema,
GenerationTaskResponseSchema,
GenerationCreateResponseSchema,
GenerationParamsSchema,
GenerationCreateHeadersSchema,
GenerationMultipartBodySchema,
StableEngineeringErrorCodeSchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
AuthenticatedUserSchema,
AdminAuthenticatedUserSchema,
AdminLoginSendRequestSchema,
AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema,
AdminSessionResponseSchema,
CreditSummarySchema,
CreditEntryTypeSchema,
CreditEntryStatusSchema,
CreditReferenceTypeSchema,
CreditBalanceResponseSchema,
CreditLedgerEntrySchema,
CreditLedgerQuerySchema,
CreditLedgerResponseSchema,
AdminCreditParamsSchema,
CreditAdjustmentHeadersSchema,
CreditAdjustmentRequestSchema,
CreditAdjustmentResponseSchema,
CsrfHeadersSchema,
AccountSettingsResponseSchema,
AccountProfileUpdateRequestSchema,
AccountProfileUpdateResponseSchema,
AccountDeletionSendResponseSchema,
AccountDeletionCompleteRequestSchema,
AccountDeletionResponseSchema,
RegistrationSendRequestSchema,
RegistrationSendResponseSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteResponseSchema,
LoginSendRequestSchema,
LoginCompleteRequestSchema,
LoginCompleteResponseSchema,
LogoutHeadersSchema,
LogoutResponseSchema,
UserSessionResponseSchema,
BrowserUnsupportedReasonSchema,
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
BootstrapResponseSchema,
CanvasBackgroundAdjustmentsSchema,
CanvasElementSchema,
CanvasStateSchema,
StateSseEventSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
SseEventSchema,
ProjectIdSchema,
ProjectRatioSchema,
ProjectViewStatusSchema,
ProjectSummarySchema,
ProjectListQuerySchema,
ProjectListResponseSchema,
ProjectParamsSchema,
GenerationProjectItemSchema,
ProjectImageItemSchema,
ProjectDetailResponseSchema,
ProjectEditableStateSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectTrashResponseSchema,
ProjectRestoreResponseSchema,
ProjectPurgeResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectStateConflictResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
]) {
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.get(
"/api/v1/me/credits",
{
schema: {
operationId: "getMyCredits",
response: {
200: Type.Ref(CreditBalanceResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Credits"],
},
},
async (request, reply) => {
if (!options.registration || !options.credits) {
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 }));
try {
const account = options.credits.readAccount(session.userId);
return {
available_balance: account.availableBalance,
reserved_balance: account.reservedBalance,
updated_at: account.updatedAt,
};
} catch (error) {
return creditFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/me/credit-ledger",
{
attachValidation: true,
schema: {
operationId: "getMyCreditLedger",
querystring: Type.Ref(CreditLedgerQuerySchema),
response: {
200: Type.Ref(CreditLedgerResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Credits"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.credits) {
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 }));
try {
const query = request.query as CreditLedgerQuery;
const ledger = options.credits.listLedger({
...(query.cursor ? { cursor: query.cursor } : {}),
...(query.event_type ? { eventType: query.event_type } : {}),
...(query.from ? { from: query.from } : {}),
...(query.limit ? { limit: query.limit } : {}),
...(query.to ? { to: query.to } : {}),
userId: session.userId,
});
return {
credits: {
available_balance: ledger.account.availableBalance,
reserved_balance: ledger.account.reservedBalance,
},
entries: ledger.entries.map((entry) => ({
amount: entry.amount,
available_after: entry.availableAfter,
available_before: entry.availableBefore,
created_at: entry.createdAt,
entry_id: entry.entryId,
entry_type: entry.entryType,
model_id: entry.modelId,
reason: entry.reason,
reference_id: entry.referenceId,
reference_type: entry.referenceType,
reserved_after: entry.reservedAfter,
reserved_before: entry.reservedBefore,
status: entry.status,
})),
next_cursor: ledger.nextCursor,
updated_at: ledger.account.updatedAt,
};
} catch (error) {
return creditFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/admin/users/:userId/credits",
{
attachValidation: true,
schema: {
operationId: "getAdminUserCredits",
params: Type.Ref(AdminCreditParamsSchema),
response: {
200: Type.Ref(CreditBalanceResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Credits"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.credits) {
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 }));
try {
const account = options.credits.readAccount((request.params as AdminCreditParams).userId);
return {
available_balance: account.availableBalance,
reserved_balance: account.reservedBalance,
updated_at: account.updatedAt,
};
} catch (error) {
return creditFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/admin/users/:userId/credit-adjustments",
{
attachValidation: true,
schema: {
body: Type.Ref(CreditAdjustmentRequestSchema),
headers: Type.Ref(CreditAdjustmentHeadersSchema),
operationId: "adjustAdminUserCredits",
params: Type.Ref(AdminCreditParamsSchema),
response: {
200: Type.Ref(CreditAdjustmentResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
409: Type.Union([Type.Ref(ErrorEnvelopeSchema), Type.Null()]),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Credits"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.credits) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
const headers = request.headers as CreditAdjustmentHeaders;
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const admin = options.registration.authorizeAdminMutation({
csrfToken: headers["x-csrf-token"],
sessionToken: token,
});
const body = request.body as CreditAdjustmentRequest;
const adjusted = options.credits.adjustAvailable({
adjustmentId: body.adjustment_id,
adminId: admin.userId,
amount: body.amount,
idempotencyKey: headers["idempotency-key"],
reason: body.reason,
userId: (request.params as AdminCreditParams).userId,
});
return {
adjustment_id: adjusted.adjustmentId,
available_balance: adjusted.availableBalance,
reserved_balance: adjusted.reservedBalance,
status: adjusted.status,
};
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: creditFailure(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.get(
"/api/v1/generations/current",
{
schema: {
operationId: "getCurrentGeneration",
response: {
200: Type.Ref(GenerationTaskResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Generations"],
},
},
async (request, reply) => {
if (!options.registration || !options.generations) {
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 }));
const task = options.generations.readCurrentTask(session.userId);
return task ? generationTaskResponse(task) : reply.code(404).send(null);
},
);
app.get(
"/api/v1/generations/:generationId",
{
attachValidation: true,
schema: {
operationId: "getGeneration",
params: Type.Ref(GenerationParamsSchema),
response: {
200: Type.Ref(GenerationTaskResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Generations"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.generations) {
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 }));
try {
return generationTaskResponse(options.generations.readTask(session.userId, (request.params as GenerationParams).generationId));
} catch (error) {
return generationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/generations",
{
attachValidation: true,
schema: {
consumes: ["multipart/form-data"],
body: Type.Optional(Type.Ref(GenerationMultipartBodySchema)),
headers: Type.Ref(GenerationCreateHeadersSchema),
operationId: "createGeneration",
response: {
200: Type.Ref(GenerationCreateResponseSchema),
201: Type.Ref(GenerationCreateResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
412: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
507: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Generations"],
},
validatorCompiler: () => (data) => ({ value: data }),
},
async (request, reply) => {
if (request.validationError || !request.isMultipart()) return reply.code(400).send(null);
if (!options.registration || !options.generations) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!idempotencyKey || !/^[A-Za-z0-9_-]{32,200}$/.test(idempotencyKey)
|| !csrfToken || !/^[A-Za-z0-9_-]{43,64}$/.test(csrfToken)) return reply.code(400).send(null);
const headers = { "idempotency-key": idempotencyKey, "x-csrf-token": csrfToken } satisfies GenerationCreateHeaders;
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
let upload: GenerationUploadSession | undefined;
try {
const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const current = options.generations.readCurrentTask(owner.userId);
if (current) return { created: false, task: generationTaskResponse(current) };
const values = new Map<string, string>();
let manifest: ReferenceManifestEntry[] | undefined;
let fileIndex = 0;
for await (const part of request.parts()) {
if (part.type === "field") {
if (upload || values.has(part.fieldname) || typeof part.value !== "string") {
throw new GenerationSubmissionError("generation_request_invalid");
}
values.set(part.fieldname, part.value);
continue;
}
if (part.fieldname !== "reference_files" || !part.filename) {
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
}
if (!upload) {
const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId });
manifest = parsed.manifest;
upload = options.generations.beginUpload(parsed.fields);
}
const expected = manifest?.[fileIndex];
if (!expected || expected.fileName !== part.filename || expected.mimeType !== part.mimetype) {
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
}
await upload.stageReference({
content: part.file,
fileName: part.filename,
mimeType: expected.mimeType,
projectedBytes: expected.projectedBytes,
});
if (part.file.truncated) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
fileIndex += 1;
}
if (!upload) {
const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId });
manifest = parsed.manifest;
upload = options.generations.beginUpload(parsed.fields);
}
if (fileIndex !== (manifest?.length ?? 0)) {
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
}
const result = await upload.commit();
return reply.code(result.created ? 201 : 200).send({ created: result.created, task: generationTaskResponse(result.task) });
} catch (error) {
upload?.abort();
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: error instanceof CreditError
? creditFailure(reply, request.id, error)
: generationFailure(reply, request.id, error);
}
},
);
app.get(
"/api/v1/projects",
{
attachValidation: true,
schema: {
operationId: "listProjects",
querystring: Type.Ref(ProjectListQuerySchema),
response: {
200: Type.Ref(ProjectListResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) {
return reply.code(400).send(null);
}
if (!options.registration || !options.projects) {
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 }));
const query = request.query as ProjectListQuery;
const status = query.status ?? "active";
return {
active_count: options.projects.activeProjectCount(session.userId),
active_limit: 20 as const,
projects: options.projects.listProjects(session.userId, status).slice(0, 20).map(projectSummaryResponse),
};
},
);
app.get(
"/api/v1/projects/:projectId",
{
attachValidation: true,
schema: {
operationId: "getProject",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectDetailResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) {
return reply.code(400).send(null);
}
if (!options.registration || !options.projects) {
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 }));
try {
return projectDetailResponse(options.projects.getProject(session.userId, (request.params as ProjectParams).projectId));
} catch (error) {
return projectFailure(reply, request.id, error);
}
},
);
app.put(
"/api/v1/projects/:projectId/state",
{
attachValidation: true,
schema: {
body: Type.Ref(ProjectEditableStateSchema),
headers: Type.Ref(ProjectStateSaveHeadersSchema),
operationId: "saveProjectState",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectStateSaveResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
409: Type.Ref(ErrorEnvelopeSchema),
412: Type.Ref(ProjectStateConflictResponseSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.projects) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const headers = request.headers as ProjectStateSaveHeaders;
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const saved = options.projects.saveProjectState({
expectedStateVersion: Number(headers["if-match"]),
idempotencyKey: headers["idempotency-key"],
ownerId: owner.userId,
projectId: (request.params as ProjectParams).projectId,
state: request.body as ProjectEditableState,
});
return { save_status: "saved" as const, state_version: saved.stateVersion };
} catch (error) {
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
if (error instanceof ProjectError && error.code === "project_state_conflict") {
return reply.code(412).send({
latest_state_version: error.latestStateVersion ?? 1,
save_status: "conflicted" as const,
});
}
if (error instanceof ProjectError && error.code === "project_state_idempotency_conflict") {
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId: request.id }));
}
return projectFailure(reply, request.id, error);
}
},
);
app.patch(
"/api/v1/projects/:projectId",
{
attachValidation: true,
schema: {
body: Type.Ref(ProjectRenameRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "renameProject",
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(ProjectRenameResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) {
return reply.code(400).send(null);
}
if (!options.registration || !options.projects) {
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 owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const renamed = options.projects.renameProject(
owner.userId,
(request.params as ProjectParams).projectId,
(request.body as ProjectRenameRequest).name,
);
return { name: renamed.name, state_version: renamed.stateVersion, status: "renamed" as const };
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: projectFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/projects/failed-empty/trash",
{
attachValidation: true,
schema: {
body: Type.Ref(FailedEmptyTrashRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "trashFailedEmptyProjects",
response: {
200: Type.Ref(FailedEmptyTrashResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) {
return reply.code(400).send(null);
}
if (!options.registration || !options.projects) {
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 owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const result = options.projects.trashFailedEmpty(owner.userId, (request.body as FailedEmptyTrashRequest).project_ids);
return { ignored_project_ids: result.ignoredProjectIds, trashed_project_ids: result.trashedProjectIds };
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: projectFailure(reply, request.id, error);
}
},
);
const projectLifecycle = (
action: "trash" | "restore" | "purge",
operationId: string,
responseSchema: typeof ProjectTrashResponseSchema | typeof ProjectRestoreResponseSchema | typeof ProjectPurgeResponseSchema,
) => {
app.post(
`/api/v1/projects/:projectId/${action}`,
{
attachValidation: true,
schema: {
headers: Type.Ref(CsrfHeadersSchema),
operationId,
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(responseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
409: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.projects) {
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 owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const projectId = (request.params as ProjectParams).projectId;
if (action === "trash") {
const project = options.projects.trashProject(owner.userId, projectId);
return { deleted_at: project.deletedAt, project_id: projectId, purge_at: project.purgeAt, status: "trashed" as const };
}
if (action === "restore") {
options.projects.restoreProject(owner.userId, projectId);
return { deleted_at: null, project_id: projectId, purge_at: null, status: "active" as const };
}
options.projects.purgeProject(owner.userId, projectId);
return { project_id: projectId, status: "purged" as const };
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: projectFailure(reply, request.id, error);
}
},
);
};
projectLifecycle("trash", "trashProject", ProjectTrashResponseSchema);
projectLifecycle("restore", "restoreProject", ProjectRestoreResponseSchema);
projectLifecycle("purge", "purgeProject", ProjectPurgeResponseSchema);
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;
}