2491 lines
95 KiB
TypeScript
2491 lines
95 KiB
TypeScript
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { createReadStream, 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,
|
|
ModelIdSchema,
|
|
ModelContractValidationStatusSchema,
|
|
ModelRuntimeReasonSchema,
|
|
ModelReferenceLimitsSchema,
|
|
ModelRuntimeAvailabilitySchema,
|
|
ModelConfigurationResponseSchema,
|
|
ModelConfigSchema,
|
|
ModelConfigCandidateSchema,
|
|
ModelConfigUpdateRequestSchema,
|
|
ModelParamsSchema,
|
|
ModelConfigUpdateHeadersSchema,
|
|
FailedEmptyTrashRequestSchema,
|
|
FailedEmptyTrashResponseSchema,
|
|
ExportFormatSchema,
|
|
GenerationProjectItemSchema,
|
|
LatestExportItemSchema,
|
|
LatestExportMultipartBodySchema,
|
|
LatestExportParamsSchema,
|
|
LatestExportSaveResponseSchema,
|
|
ProjectDetailResponseSchema,
|
|
ProjectEditableStateSchema,
|
|
ProjectIdSchema,
|
|
ProjectImageItemSchema,
|
|
ProjectImageParamsSchema,
|
|
ProjectListQuerySchema,
|
|
ProjectListResponseSchema,
|
|
ProjectParamsSchema,
|
|
ProjectRatioSchema,
|
|
ProjectRenameRequestSchema,
|
|
ProjectRenameResponseSchema,
|
|
ProjectRestoreResponseSchema,
|
|
ProjectPurgeResponseSchema,
|
|
ProjectTrashResponseSchema,
|
|
ProjectStateConflictResponseSchema,
|
|
ProjectStateSaveHeadersSchema,
|
|
ProjectStateSaveResponseSchema,
|
|
ProjectSummarySchema,
|
|
ProjectViewStatusSchema,
|
|
RecentAssetItemSchema,
|
|
RecentAssetKindSchema,
|
|
RecentAssetListResponseSchema,
|
|
RecentAssetQuerySchema,
|
|
RecentAssetRecordRequestSchema,
|
|
RecentAssetRecordResponseSchema,
|
|
ReverseGeocodeRequestSchema,
|
|
ReverseGeocodeResponseSchema,
|
|
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 LatestExportParams,
|
|
type ProjectImageParams,
|
|
type GenerationCreateHeaders,
|
|
type GenerationParams,
|
|
type ProjectListQuery,
|
|
type ProjectParams,
|
|
type ProjectRenameRequest,
|
|
type ProjectEditableState,
|
|
type ProjectStateSaveHeaders,
|
|
type RecentAssetQuery,
|
|
type RecentAssetRecordRequest,
|
|
type ReverseGeocodeRequest,
|
|
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 { LatestExportError } from "./latest-export-errors.js";
|
|
import type { LatestExportService } from "./latest-exports.js";
|
|
import {
|
|
RegistrationError,
|
|
registrationFieldError,
|
|
} from "./registration-errors.js";
|
|
import type { RegistrationService } from "./registration.js";
|
|
import type { RecentAssetService } from "./recent-assets.js";
|
|
import type { AmapAdapter } from "./amap-adapter.js";
|
|
import { ModelConfigurationError } from "./model-configuration.js";
|
|
import type { ModelConfigurationService } from "./model-configuration.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 {
|
|
amap?: AmapAdapter;
|
|
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
|
browserGate?: boolean;
|
|
browserSupportRelease?: BrowserSupportRelease;
|
|
browserSupportSecret?: Buffer;
|
|
credits?: CreditService;
|
|
eventHub?: EventHub;
|
|
generations?: GenerationSubmissionService;
|
|
latestExports?: LatestExportService;
|
|
models?: ModelConfigurationService;
|
|
networkBoundary?: NetworkBoundaryOptions;
|
|
publicAssets?: PublicAssetResolver;
|
|
recentAssets?: RecentAssetService;
|
|
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 latestExportResponse(item: ReturnType<LatestExportService["getLatest"]> | Awaited<ReturnType<LatestExportService["saveLatest"]>>) {
|
|
return {
|
|
byte_size: item.byteSize,
|
|
created_at: item.createdAt,
|
|
download_url: item.downloadUrl,
|
|
export_id: item.exportId,
|
|
format: item.format,
|
|
pixel_height: item.pixelHeight,
|
|
pixel_width: item.pixelWidth,
|
|
sha256: item.sha256,
|
|
state_version: item.stateVersion,
|
|
status: "saved" as const,
|
|
};
|
|
}
|
|
|
|
function latestExportFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
|
if (error instanceof LatestExportError) {
|
|
return reply.code(error.code === "not_found" ? 404 : error.code === "conflict" ? 409 : 400).send(null);
|
|
}
|
|
if (error && typeof error === "object" && "code" in error && 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 }
|
|
: undefined;
|
|
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 && typeof error === "object" && "code" in error && 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 }));
|
|
}
|
|
|
|
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 modelConfigurationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
|
if (!(error instanceof ModelConfigurationError)) {
|
|
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
|
}
|
|
const status = error.code === "MODEL_CONFIG_VERSION_CONFLICT" ? 412
|
|
: error.code === "MODEL_RECOMMENDATION_PRIORITY_INVALID" ? 400
|
|
: error.code === "IDEMPOTENCY_KEY_CONFLICT" ? 409 : 409;
|
|
const details = {
|
|
...(error.code === "MODEL_CONFIG_VERSION_CONFLICT" && typeof error.details.latest_version === "number"
|
|
? { latest_version: error.details.latest_version } : {}),
|
|
...(Array.isArray(error.details.conflict_model_ids) ? { conflict_model_ids: error.details.conflict_model_ids as string[] } : {}),
|
|
...(Array.isArray(error.details.field_errors) ? { field_errors: error.details.field_errors as Array<{ field: string; message_key: string }> } : {}),
|
|
};
|
|
return reply.code(status).send(createErrorEnvelope({ code: error.code, correlationId, details }));
|
|
}
|
|
|
|
function generationTaskResponse(task: GenerationTaskView) {
|
|
return {
|
|
confirmed_credit_cost: task.confirmedCreditCost,
|
|
created_at: task.createdAt,
|
|
error_category: task.errorCategory,
|
|
generation_id: task.generationId,
|
|
model_config_version: task.modelConfigVersion,
|
|
model_id: task.modelId,
|
|
project_id: task.projectId,
|
|
prompt: task.prompt,
|
|
ratio: task.ratio,
|
|
reference_asset_ids: task.referenceAssetIds,
|
|
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,
|
|
})),
|
|
latest_exports: project.latestExports.map((item) => ({
|
|
byte_size: item.byteSize,
|
|
created_at: item.createdAt,
|
|
download_url: item.downloadUrl,
|
|
export_id: item.exportId,
|
|
format: item.format,
|
|
pixel_height: item.pixelHeight,
|
|
pixel_width: item.pixelWidth,
|
|
sha256: item.sha256,
|
|
state_version: item.stateVersion,
|
|
})),
|
|
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,
|
|
ExportFormatSchema,
|
|
LatestExportItemSchema,
|
|
LatestExportMultipartBodySchema,
|
|
LatestExportParamsSchema,
|
|
LatestExportSaveResponseSchema,
|
|
ProjectImageParamsSchema,
|
|
ProjectDetailResponseSchema,
|
|
ProjectEditableStateSchema,
|
|
ProjectRenameRequestSchema,
|
|
ProjectRenameResponseSchema,
|
|
ProjectTrashResponseSchema,
|
|
ProjectRestoreResponseSchema,
|
|
ProjectPurgeResponseSchema,
|
|
ProjectStateSaveHeadersSchema,
|
|
ProjectStateSaveResponseSchema,
|
|
ProjectStateConflictResponseSchema,
|
|
RecentAssetKindSchema,
|
|
RecentAssetItemSchema,
|
|
RecentAssetQuerySchema,
|
|
RecentAssetListResponseSchema,
|
|
RecentAssetRecordRequestSchema,
|
|
RecentAssetRecordResponseSchema,
|
|
ReverseGeocodeRequestSchema,
|
|
ReverseGeocodeResponseSchema,
|
|
FailedEmptyTrashRequestSchema,
|
|
FailedEmptyTrashResponseSchema,
|
|
ModelIdSchema,
|
|
ModelContractValidationStatusSchema,
|
|
ModelRuntimeReasonSchema,
|
|
ModelReferenceLimitsSchema,
|
|
ModelRuntimeAvailabilitySchema,
|
|
ModelConfigSchema,
|
|
ModelConfigCandidateSchema,
|
|
ModelConfigurationResponseSchema,
|
|
ModelParamsSchema,
|
|
ModelConfigUpdateRequestSchema,
|
|
ModelConfigUpdateHeadersSchema,
|
|
]) {
|
|
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.get(
|
|
"/api/v1/assets/recent",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
operationId: "listRecentAssets",
|
|
querystring: Type.Ref(RecentAssetQuerySchema),
|
|
response: {
|
|
200: Type.Ref(RecentAssetListResponseSchema),
|
|
400: Type.Null(),
|
|
401: Type.Ref(ErrorEnvelopeSchema),
|
|
503: Type.Ref(ErrorEnvelopeSchema),
|
|
},
|
|
tags: ["Assets"],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
if (request.validationError) return reply.code(400).send(null);
|
|
if (!options.registration || !options.recentAssets) {
|
|
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 RecentAssetQuery;
|
|
return { items: options.recentAssets.list(session.userId, query.asset_kind) };
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/api/v1/assets/recent",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
body: Type.Ref(RecentAssetRecordRequestSchema),
|
|
headers: Type.Ref(CsrfHeadersSchema),
|
|
operationId: "recordRecentAsset",
|
|
response: {
|
|
200: Type.Ref(RecentAssetRecordResponseSchema),
|
|
400: Type.Null(),
|
|
401: Type.Ref(ErrorEnvelopeSchema),
|
|
503: Type.Ref(ErrorEnvelopeSchema),
|
|
},
|
|
tags: ["Assets"],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
if (request.validationError) return reply.code(400).send(null);
|
|
if (!options.registration || !options.recentAssets) {
|
|
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 body = request.body as RecentAssetRecordRequest;
|
|
options.recentAssets.recordSuccessfulUse({
|
|
assetId: body.asset_id,
|
|
assetKind: body.asset_kind,
|
|
resourceVersion: body.resource_version,
|
|
userId: owner.userId,
|
|
});
|
|
return { status: "recorded" as const };
|
|
} catch (error) {
|
|
return registrationFailure(reply, request.id, error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.post(
|
|
"/api/v1/location/reverse-geocode",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
body: Type.Ref(ReverseGeocodeRequestSchema),
|
|
headers: Type.Ref(CsrfHeadersSchema),
|
|
operationId: "reverseGeocodeLocation",
|
|
response: {
|
|
200: Type.Ref(ReverseGeocodeResponseSchema),
|
|
400: Type.Null(),
|
|
401: Type.Ref(ErrorEnvelopeSchema),
|
|
503: Type.Null(),
|
|
},
|
|
tags: ["Location"],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
if (request.validationError) return reply.code(400).send(null);
|
|
if (!options.registration || !options.amap) return reply.code(503).send(null);
|
|
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.authorizeUserMutation({ csrfToken, sessionToken: token });
|
|
const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest);
|
|
return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const };
|
|
} catch (error) {
|
|
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
|
|
return reply.code(503).send(null);
|
|
}
|
|
},
|
|
);
|
|
|
|
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/latest-exports/:format",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
body: Type.Optional(Type.Ref(LatestExportMultipartBodySchema)),
|
|
consumes: ["multipart/form-data"],
|
|
headers: Type.Ref(CsrfHeadersSchema),
|
|
operationId: "saveLatestExport",
|
|
params: Type.Ref(LatestExportParamsSchema),
|
|
response: {
|
|
200: Type.Ref(LatestExportSaveResponseSchema),
|
|
400: Type.Null(),
|
|
401: Type.Ref(ErrorEnvelopeSchema),
|
|
403: Type.Ref(ErrorEnvelopeSchema),
|
|
404: Type.Null(),
|
|
409: Type.Null(),
|
|
503: Type.Ref(ErrorEnvelopeSchema),
|
|
507: Type.Ref(ErrorEnvelopeSchema),
|
|
},
|
|
tags: ["Projects"],
|
|
},
|
|
validatorCompiler: () => (data) => ({ value: data }),
|
|
},
|
|
async (request, reply) => {
|
|
if (request.validationError || !request.isMultipart()) return reply.code(400).send(null);
|
|
if (!options.registration || !options.latestExports) {
|
|
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 params = request.params as LatestExportParams;
|
|
const fields = new Map<string, string>();
|
|
let saved: Awaited<ReturnType<LatestExportService["saveLatest"]>> | undefined;
|
|
for await (const part of request.parts()) {
|
|
if (part.type === "field") {
|
|
if (saved || fields.has(part.fieldname) || typeof part.value !== "string") throw new LatestExportError("invalid");
|
|
fields.set(part.fieldname, part.value);
|
|
continue;
|
|
}
|
|
if (saved || part.fieldname !== "export_file" || !part.filename) throw new LatestExportError("invalid");
|
|
const format = fields.get("format");
|
|
const expectedMime = params.format === "png" ? "image/png" : "image/jpeg";
|
|
if (format !== params.format || part.mimetype !== expectedMime) throw new LatestExportError("invalid");
|
|
saved = await options.latestExports.saveLatest({
|
|
byteSize: Number(fields.get("byte_size")),
|
|
content: part.file,
|
|
exportId: fields.get("export_id") ?? "",
|
|
format: params.format,
|
|
ownerId: owner.userId,
|
|
pixelHeight: Number(fields.get("pixel_height")),
|
|
pixelWidth: Number(fields.get("pixel_width")),
|
|
projectId: params.projectId,
|
|
sha256: fields.get("sha256") ?? "",
|
|
stateVersion: Number(fields.get("state_version")),
|
|
});
|
|
if (part.file.truncated) throw new LatestExportError("invalid");
|
|
}
|
|
if (!saved) throw new LatestExportError("invalid");
|
|
return latestExportResponse(saved);
|
|
} catch (error) {
|
|
return error instanceof RegistrationError
|
|
? registrationFailure(reply, request.id, error)
|
|
: latestExportFailure(reply, request.id, error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
"/api/v1/projects/:projectId/latest-exports/:format",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
operationId: "downloadLatestExport",
|
|
params: Type.Ref(LatestExportParamsSchema),
|
|
produces: ["application/octet-stream"],
|
|
response: { 200: Type.String({ format: "binary" }), 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.latestExports) 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 params = request.params as LatestExportParams;
|
|
const item = options.latestExports.getLatest(session.userId, params.projectId, params.format);
|
|
reply.header("Cache-Control", "private, no-store");
|
|
reply.header("Content-Disposition", `attachment; filename="dada-latest.${params.format === "jpg" ? "jpg" : "png"}"`);
|
|
reply.type(item.mimeType);
|
|
return reply.send(createReadStream(item.path));
|
|
} catch (error) {
|
|
return latestExportFailure(reply, request.id, error);
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
"/api/v1/private-assets/projects/:projectId/images/:imageId",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
operationId: "downloadOriginalGeneration",
|
|
params: Type.Ref(ProjectImageParamsSchema),
|
|
produces: ["application/octet-stream"],
|
|
response: { 200: Type.String({ format: "binary" }), 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.latestExports) 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 params = request.params as ProjectImageParams;
|
|
const item = options.latestExports.getOriginal(session.userId, params.projectId, params.imageId);
|
|
const extension = item.mime_type === "image/jpeg" ? "jpg" : item.mime_type === "image/webp" ? "webp" : "png";
|
|
reply.header("Cache-Control", "private, no-store");
|
|
reply.header("Content-Disposition", `attachment; filename="dada-original.${extension}"`);
|
|
reply.type(item.mime_type);
|
|
return reply.send(createReadStream(item.path));
|
|
} catch (error) {
|
|
return latestExportFailure(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/models",
|
|
{
|
|
schema: {
|
|
operationId: "getModels",
|
|
response: { 200: ModelConfigurationResponseSchema, 503: ErrorEnvelopeSchema, 426: ErrorEnvelopeSchema },
|
|
tags: ["Models"],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
if (!options.models) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
|
try {
|
|
return options.models.read();
|
|
} catch {
|
|
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
|
}
|
|
},
|
|
);
|
|
|
|
app.get(
|
|
"/api/v1/models/:model_id",
|
|
{
|
|
schema: {
|
|
operationId: "getModel",
|
|
params: ModelParamsSchema,
|
|
response: { 200: ModelConfigSchema, 404: Type.Null(), 503: ErrorEnvelopeSchema, 426: ErrorEnvelopeSchema },
|
|
tags: ["Models"],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
if (!options.models) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
|
const model = options.models.readModel((request.params as { model_id: string }).model_id);
|
|
return model ?? reply.code(404).send(null);
|
|
},
|
|
);
|
|
|
|
app.put(
|
|
"/api/v1/admin/models/configuration",
|
|
{
|
|
attachValidation: true,
|
|
schema: {
|
|
operationId: "replaceModelConfiguration",
|
|
body: ModelConfigUpdateRequestSchema,
|
|
headers: ModelConfigUpdateHeadersSchema,
|
|
response: {
|
|
200: ModelConfigurationResponseSchema,
|
|
400: ErrorEnvelopeSchema,
|
|
401: ErrorEnvelopeSchema,
|
|
403: ErrorEnvelopeSchema,
|
|
409: ErrorEnvelopeSchema,
|
|
412: ErrorEnvelopeSchema,
|
|
429: ErrorEnvelopeSchema,
|
|
503: ErrorEnvelopeSchema,
|
|
426: ErrorEnvelopeSchema,
|
|
},
|
|
tags: ["Admin Models"],
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
if (request.validationError) {
|
|
const validation = JSON.stringify(request.validationError.validation ?? []);
|
|
const priority = validation.includes("recommendation_priority");
|
|
return reply.code(priority ? 400 : 409).send(createErrorEnvelope({
|
|
code: priority ? "MODEL_RECOMMENDATION_PRIORITY_INVALID" : "MODEL_DEFAULT_REPLACEMENT_INVALID",
|
|
correlationId: request.id,
|
|
...(priority ? { details: { field_errors: [{ field: "models.recommendation_priority", message_key: "model.priority.invalid" }] } } : {}),
|
|
}));
|
|
}
|
|
if (!options.models || !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 (!token || !session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
|
try {
|
|
const headers = request.headers as { "idempotency-key": string; "x-csrf-token": string };
|
|
const admin = options.registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
|
|
const body = request.body as {
|
|
expected_config_set_version: number;
|
|
models: Array<Record<string, unknown>>;
|
|
};
|
|
const result = options.models.replace({
|
|
actorId: admin.userId,
|
|
expectedConfigSetVersion: body.expected_config_set_version,
|
|
idempotencyKey: headers["idempotency-key"],
|
|
models: body.models as never,
|
|
});
|
|
return result;
|
|
} catch (error) {
|
|
if (error instanceof RegistrationError) {
|
|
return reply.code(error.httpStatus).send(createErrorEnvelope({
|
|
code: error.code,
|
|
correlationId: request.id,
|
|
details: { field_errors: [registrationFieldError(error.reason)] },
|
|
}));
|
|
}
|
|
return modelConfigurationFailure(reply, request.id, error);
|
|
}
|
|
},
|
|
);
|
|
|
|
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;
|
|
}
|