Initial WallMuse project
This commit is contained in:
25
apps/api/package.json
Executable file
25
apps/api/package.json
Executable file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@wallmuse/api",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "node dist/server.js",
|
||||
"smoke": "tsx src/smoke-test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wallmuse/api-client": "workspace:*",
|
||||
"@wallmuse/db": "workspace:*",
|
||||
"@wallmuse/shared": "workspace:*",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"fastify": "^5.6.2",
|
||||
"nanoid": "^5.1.6"
|
||||
}
|
||||
}
|
||||
51
apps/api/src/auth.ts
Executable file
51
apps/api/src/auth.ts
Executable file
@@ -0,0 +1,51 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { PublicUser, UserRole } from "@wallmuse/shared";
|
||||
import { hasAdminRole, toPublicUser, type JsonWallMuseDb } from "@wallmuse/db";
|
||||
import { ApiError } from "./errors.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
db: JsonWallMuseDb;
|
||||
}
|
||||
interface FastifyRequest {
|
||||
currentUser?: PublicUser;
|
||||
}
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
roles: UserRole[];
|
||||
}
|
||||
|
||||
const extractToken = (request: FastifyRequest): string | undefined => {
|
||||
const header = request.headers.authorization;
|
||||
if (header?.startsWith("Bearer ")) {
|
||||
return header.slice("Bearer ".length);
|
||||
}
|
||||
const token = request.cookies?.wallmuse_token;
|
||||
return typeof token === "string" ? token : undefined;
|
||||
};
|
||||
|
||||
export const requireAuth = async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
||||
const token = extractToken(request);
|
||||
if (!token) {
|
||||
throw new ApiError(401, "UNAUTHORIZED", "Authentication is required");
|
||||
}
|
||||
const payload = await request.server.jwt.verify<JwtPayload>(token);
|
||||
const data = await request.server.db.read();
|
||||
const user = data.users.find((item) => item.id === payload.sub);
|
||||
if (!user) {
|
||||
throw new ApiError(401, "UNAUTHORIZED", "User no longer exists");
|
||||
}
|
||||
request.currentUser = toPublicUser(user);
|
||||
};
|
||||
|
||||
export const requireAdmin = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
await requireAuth(request, reply);
|
||||
if (!request.currentUser || !hasAdminRole(request.currentUser.roles)) {
|
||||
throw new ApiError(403, "FORBIDDEN", "Administrator permission is required");
|
||||
}
|
||||
};
|
||||
|
||||
export const signUserToken = async (app: FastifyInstance, user: PublicUser): Promise<string> =>
|
||||
app.jwt.sign({ sub: user.id, roles: user.roles } satisfies JwtPayload, { expiresIn: "7d" });
|
||||
22
apps/api/src/errors.ts
Executable file
22
apps/api/src/errors.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
import type { FastifyReply } from "fastify";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const sendError = (reply: FastifyReply, error: ApiError): void => {
|
||||
void reply.status(error.statusCode).send({
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details
|
||||
}
|
||||
});
|
||||
};
|
||||
380
apps/api/src/routes.ts
Executable file
380
apps/api/src/routes.ts
Executable file
@@ -0,0 +1,380 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { apiPaths } from "@wallmuse/api-client";
|
||||
import {
|
||||
CreateGenerationRequestSchema,
|
||||
CreateModelRequestSchema,
|
||||
CreateProviderRequestSchema,
|
||||
CreateUserApiKeyRequestSchema,
|
||||
LoginRequestSchema,
|
||||
RegisterRequestSchema,
|
||||
UpdateModelRequestSchema,
|
||||
UpdateProviderRequestSchema,
|
||||
type CreateGenerationRequest,
|
||||
type ModelSummary,
|
||||
type ProviderSummary,
|
||||
type UserRole
|
||||
} from "@wallmuse/shared";
|
||||
import { makeGenerationAssets, toPublicUser, type ProviderRecord } from "@wallmuse/db";
|
||||
import { requireAdmin, requireAuth, signUserToken } from "./auth.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { decryptApiKey, encryptApiKey, hashPassword, maskApiKey, verifyPassword } from "./security.js";
|
||||
|
||||
const parseBody = <T>(schema: { parse: (input: unknown) => T }, request: FastifyRequest): T => {
|
||||
try {
|
||||
return schema.parse(request.body);
|
||||
} catch (error) {
|
||||
throw new ApiError(400, "VALIDATION_ERROR", "Request body validation failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getIdParam = (request: FastifyRequest): string => {
|
||||
const params = request.params as { id?: string };
|
||||
if (!params.id) {
|
||||
throw new ApiError(400, "VALIDATION_ERROR", "Missing id parameter");
|
||||
}
|
||||
return params.id;
|
||||
};
|
||||
|
||||
const sendAuth = async (
|
||||
app: FastifyInstance,
|
||||
reply: FastifyReply,
|
||||
user: ReturnType<typeof toPublicUser>
|
||||
) => {
|
||||
const token = await signUserToken(app, user);
|
||||
reply.setCookie("wallmuse_token", token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60
|
||||
});
|
||||
return { user, token };
|
||||
};
|
||||
|
||||
const assertCurrentUser = (request: FastifyRequest) => {
|
||||
if (!request.currentUser) {
|
||||
throw new ApiError(401, "UNAUTHORIZED", "Authentication is required");
|
||||
}
|
||||
return request.currentUser;
|
||||
};
|
||||
|
||||
const safeApiKey = <T extends { encryptedKey: string }>(apiKey: T): Omit<T, "encryptedKey"> => {
|
||||
const { encryptedKey: _encryptedKey, ...safe } = apiKey;
|
||||
return safe;
|
||||
};
|
||||
|
||||
const providerMatches = (provider: ProviderSummary, idOrSlug: string): boolean =>
|
||||
provider.id === idOrSlug || provider.slug === idOrSlug;
|
||||
|
||||
const modelMatches = (model: ModelSummary, idOrSlug: string): boolean =>
|
||||
model.id === idOrSlug || model.slug === idOrSlug;
|
||||
|
||||
const ensureProviderAndModel = async (
|
||||
app: FastifyInstance,
|
||||
providerId: string | undefined,
|
||||
modelId: string
|
||||
): Promise<{ provider: ProviderSummary; model: ModelSummary }> => {
|
||||
const data = await app.db.read();
|
||||
const model = data.models.find((item) => modelMatches(item, modelId) && item.status === "enabled");
|
||||
if (!model) {
|
||||
throw new ApiError(400, "MODEL_NOT_AVAILABLE", "Model is not available");
|
||||
}
|
||||
const provider = data.providers.find((item) => providerMatches(item, providerId ?? model.providerId));
|
||||
if (!provider || provider.status === "disabled" || provider.status === "error") {
|
||||
throw new ApiError(400, "PROVIDER_NOT_AVAILABLE", "Provider is not available");
|
||||
}
|
||||
if (model.providerId !== provider.id) {
|
||||
throw new ApiError(400, "MODEL_PROVIDER_MISMATCH", "Model does not belong to provider");
|
||||
}
|
||||
return { provider, model };
|
||||
};
|
||||
|
||||
const makeTask = (
|
||||
groupId: string,
|
||||
input: CreateGenerationRequest,
|
||||
aspectRatio: "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "21:9",
|
||||
now: string
|
||||
) => ({
|
||||
id: crypto.randomUUID(),
|
||||
groupId,
|
||||
status: "queued" as const,
|
||||
mode: input.mode,
|
||||
aspectRatio,
|
||||
resolution: input.resolution,
|
||||
quality: input.quality,
|
||||
attempt: 0,
|
||||
maxAttempts: 3,
|
||||
progress: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
export const registerRoutes = async (app: FastifyInstance): Promise<void> => {
|
||||
app.get("/api/v1/health", async () => ({
|
||||
ok: true as const,
|
||||
service: "wallmuse-api" as const,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
app.get(apiPaths.appConfig, async () => {
|
||||
const data = await app.db.read();
|
||||
return app.db.getAppConfig(data);
|
||||
});
|
||||
|
||||
app.post("/api/v1/auth/register", async (request, reply) => {
|
||||
const input = parseBody(RegisterRequestSchema, request);
|
||||
const normalizedEmail = input.email.toLowerCase();
|
||||
const now = new Date().toISOString();
|
||||
const user = await app.db.mutate(async (data) => {
|
||||
if (data.users.some((item) => item.email.toLowerCase() === normalizedEmail)) {
|
||||
throw new ApiError(409, "EMAIL_EXISTS", "Email is already registered");
|
||||
}
|
||||
const roles: UserRole[] = data.users.length === 0 ? ["user", "admin", "super_admin"] : ["user"];
|
||||
const created = {
|
||||
id: crypto.randomUUID(),
|
||||
email: normalizedEmail,
|
||||
name: input.name,
|
||||
roles,
|
||||
passwordHash: await hashPassword(input.password),
|
||||
createdAt: now
|
||||
};
|
||||
data.users.push(created);
|
||||
return toPublicUser(created);
|
||||
});
|
||||
return sendAuth(app, reply, user);
|
||||
});
|
||||
|
||||
app.post("/api/v1/auth/login", async (request, reply) => {
|
||||
const input = parseBody(LoginRequestSchema, request);
|
||||
const data = await app.db.read();
|
||||
const user = data.users.find((item) => item.email.toLowerCase() === input.email.toLowerCase());
|
||||
if (!user || !(await verifyPassword(input.password, user.passwordHash))) {
|
||||
throw new ApiError(401, "INVALID_CREDENTIALS", "Invalid email or password");
|
||||
}
|
||||
return sendAuth(app, reply, toPublicUser(user));
|
||||
});
|
||||
|
||||
app.get("/api/v1/me", { preHandler: requireAuth }, async (request) => ({
|
||||
user: assertCurrentUser(request)
|
||||
}));
|
||||
|
||||
app.get(apiPaths.providers, async () => {
|
||||
const data = await app.db.read();
|
||||
return data.providers;
|
||||
});
|
||||
|
||||
app.post("/api/v1/providers", { preHandler: requireAdmin }, async (request) => {
|
||||
const input = parseBody(CreateProviderRequestSchema, request);
|
||||
const now = new Date().toISOString();
|
||||
const provider = await app.db.mutate((data) => {
|
||||
if (data.providers.some((item) => item.slug === input.slug)) {
|
||||
throw new ApiError(409, "PROVIDER_EXISTS", "Provider slug already exists");
|
||||
}
|
||||
const created: ProviderRecord = {
|
||||
id: crypto.randomUUID(),
|
||||
modelCount: 0,
|
||||
...input,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
data.providers.push(created);
|
||||
return created;
|
||||
});
|
||||
return provider;
|
||||
});
|
||||
|
||||
app.patch("/api/v1/providers/:id", { preHandler: requireAdmin }, async (request) => {
|
||||
const id = getIdParam(request);
|
||||
const input = parseBody(UpdateProviderRequestSchema, request);
|
||||
const provider = await app.db.mutate((data) => {
|
||||
const existing = data.providers.find((item) => item.id === id);
|
||||
if (!existing) {
|
||||
throw new ApiError(404, "PROVIDER_NOT_FOUND", "Provider not found");
|
||||
}
|
||||
Object.assign(existing, input, { updatedAt: new Date().toISOString() });
|
||||
return existing;
|
||||
});
|
||||
return provider;
|
||||
});
|
||||
|
||||
app.delete("/api/v1/providers/:id", { preHandler: requireAdmin }, async (request) => {
|
||||
const id = getIdParam(request);
|
||||
await app.db.mutate((data) => {
|
||||
data.providers = data.providers.filter((item) => item.id !== id);
|
||||
data.models = data.models.filter((item) => item.providerId !== id);
|
||||
});
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
app.get(apiPaths.models, async (request) => {
|
||||
const providerId = (request.query as { providerId?: string }).providerId;
|
||||
const data = await app.db.read();
|
||||
return providerId ? data.models.filter((item) => item.providerId === providerId) : data.models;
|
||||
});
|
||||
|
||||
app.post("/api/v1/models", { preHandler: requireAdmin }, async (request) => {
|
||||
const input = parseBody(CreateModelRequestSchema, request);
|
||||
const model = await app.db.mutate((data) => {
|
||||
const provider = data.providers.find((item) => item.id === input.providerId);
|
||||
if (!provider) {
|
||||
throw new ApiError(400, "PROVIDER_NOT_FOUND", "Provider not found");
|
||||
}
|
||||
const created: ModelSummary = {
|
||||
id: crypto.randomUUID(),
|
||||
...input
|
||||
};
|
||||
data.models.push(created);
|
||||
provider.modelCount = data.models.filter((item) => item.providerId === provider.id).length;
|
||||
return created;
|
||||
});
|
||||
return model;
|
||||
});
|
||||
|
||||
app.patch("/api/v1/models/:id", { preHandler: requireAdmin }, async (request) => {
|
||||
const id = getIdParam(request);
|
||||
const input = parseBody(UpdateModelRequestSchema, request);
|
||||
const model = await app.db.mutate((data) => {
|
||||
const existing = data.models.find((item) => item.id === id);
|
||||
if (!existing) {
|
||||
throw new ApiError(404, "MODEL_NOT_FOUND", "Model not found");
|
||||
}
|
||||
Object.assign(existing, input);
|
||||
return existing;
|
||||
});
|
||||
return model;
|
||||
});
|
||||
|
||||
app.delete("/api/v1/models/:id", { preHandler: requireAdmin }, async (request) => {
|
||||
const id = getIdParam(request);
|
||||
await app.db.mutate((data) => {
|
||||
const existing = data.models.find((item) => item.id === id);
|
||||
data.models = data.models.filter((item) => item.id !== id);
|
||||
if (existing) {
|
||||
const provider = data.providers.find((item) => item.id === existing.providerId);
|
||||
if (provider) {
|
||||
provider.modelCount = data.models.filter((item) => item.providerId === provider.id).length;
|
||||
}
|
||||
}
|
||||
});
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
app.get("/api/v1/user-api-keys", { preHandler: requireAuth }, async (request) => {
|
||||
const user = assertCurrentUser(request);
|
||||
const data = await app.db.read();
|
||||
return data.userApiKeys.filter((item) => item.userId === user.id).map(safeApiKey);
|
||||
});
|
||||
|
||||
app.post("/api/v1/user-api-keys", { preHandler: requireAuth }, async (request) => {
|
||||
const user = assertCurrentUser(request);
|
||||
const input = parseBody(CreateUserApiKeyRequestSchema, request);
|
||||
const now = new Date().toISOString();
|
||||
const apiKey = await app.db.mutate((data) => {
|
||||
if (!data.providers.some((item) => item.id === input.providerId)) {
|
||||
throw new ApiError(400, "PROVIDER_NOT_FOUND", "Provider not found");
|
||||
}
|
||||
const created = {
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
providerId: input.providerId,
|
||||
name: input.name,
|
||||
maskedKey: maskApiKey(input.apiKey),
|
||||
encryptedKey: encryptApiKey(input.apiKey),
|
||||
baseUrl: input.baseUrl,
|
||||
defaultModelId: input.defaultModelId,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
data.userApiKeys.push(created);
|
||||
return safeApiKey(created);
|
||||
});
|
||||
return apiKey;
|
||||
});
|
||||
|
||||
app.delete("/api/v1/user-api-keys/:id", { preHandler: requireAuth }, async (request) => {
|
||||
const user = assertCurrentUser(request);
|
||||
const id = getIdParam(request);
|
||||
await app.db.mutate((data) => {
|
||||
data.userApiKeys = data.userApiKeys.filter((item) => !(item.id === id && item.userId === user.id));
|
||||
});
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
app.post("/api/v1/user-api-keys/:id/test", { preHandler: requireAuth }, async (request) => {
|
||||
const user = assertCurrentUser(request);
|
||||
const id = getIdParam(request);
|
||||
const data = await app.db.read();
|
||||
const apiKey = data.userApiKeys.find((item) => item.id === id && item.userId === user.id);
|
||||
if (!apiKey) {
|
||||
throw new ApiError(404, "API_KEY_NOT_FOUND", "API key not found");
|
||||
}
|
||||
decryptApiKey(apiKey.encryptedKey);
|
||||
return { ok: true as const, providerId: apiKey.providerId, testedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
app.post(apiPaths.generations, { preHandler: requireAuth }, async (request) => {
|
||||
const user = assertCurrentUser(request);
|
||||
const input = parseBody(CreateGenerationRequestSchema, request);
|
||||
const { provider, model } = await ensureProviderAndModel(app, undefined, input.modelId);
|
||||
const now = new Date().toISOString();
|
||||
const generationGroup = await app.db.mutate((data) => {
|
||||
const groupId = crypto.randomUUID();
|
||||
const tasks = input.aspectRatios.map((aspectRatio) => makeTask(groupId, input, aspectRatio, now));
|
||||
const taskIds = {
|
||||
"16:9": tasks.find((task) => task.aspectRatio === "16:9")?.id,
|
||||
"9:16": tasks.find((task) => task.aspectRatio === "9:16")?.id
|
||||
};
|
||||
const created = {
|
||||
id: groupId,
|
||||
userId: user.id,
|
||||
providerId: provider.id,
|
||||
privacy: input.publishToGallery ? "public" : "private",
|
||||
status: "queued" as const,
|
||||
modelId: model.id,
|
||||
prompt: input.prompt,
|
||||
negativePrompt: input.negativePrompt,
|
||||
tasks,
|
||||
assets: makeGenerationAssets(taskIds, now),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
data.generationGroups.push(created);
|
||||
data.providerCallLogs.push({
|
||||
id: crypto.randomUUID(),
|
||||
taskId: created.id,
|
||||
providerId: provider.id,
|
||||
modelId: model.id,
|
||||
status: "success",
|
||||
latencyMs: 0,
|
||||
createdAt: now
|
||||
});
|
||||
return created;
|
||||
});
|
||||
return { generationGroup, pollingUrl: apiPaths.generationGroup(generationGroup.id) };
|
||||
});
|
||||
|
||||
app.get("/api/v1/generation-groups/:id", { preHandler: requireAuth }, async (request) => {
|
||||
const user = assertCurrentUser(request);
|
||||
const id = getIdParam(request);
|
||||
const data = await app.db.read();
|
||||
const generationGroup = data.generationGroups.find((item) => item.id === id);
|
||||
if (!generationGroup) {
|
||||
throw new ApiError(404, "GENERATION_GROUP_NOT_FOUND", "Generation group not found");
|
||||
}
|
||||
const isAdmin = user.roles.includes("admin") || user.roles.includes("super_admin");
|
||||
if (generationGroup.userId !== user.id && !isAdmin) {
|
||||
throw new ApiError(403, "FORBIDDEN", "Cannot access another user's generation group");
|
||||
}
|
||||
return generationGroup;
|
||||
});
|
||||
|
||||
app.get("/api/v1/admin/tasks", { preHandler: requireAdmin }, async () => {
|
||||
const data = await app.db.read();
|
||||
return data.generationGroups;
|
||||
});
|
||||
|
||||
app.get("/api/v1/admin/provider-logs", { preHandler: requireAdmin }, async () => {
|
||||
const data = await app.db.read();
|
||||
return data.providerCallLogs;
|
||||
});
|
||||
};
|
||||
42
apps/api/src/security.ts
Executable file
42
apps/api/src/security.ts
Executable file
@@ -0,0 +1,42 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const algorithm = "aes-256-gcm";
|
||||
|
||||
const getKey = (): Buffer => {
|
||||
const secret = process.env.API_KEY_ENCRYPTION_SECRET ?? process.env.JWT_SECRET ?? "wallmuse-dev-secret";
|
||||
return createHash("sha256").update(secret).digest();
|
||||
};
|
||||
|
||||
export const hashPassword = (password: string): Promise<string> => bcrypt.hash(password, 12);
|
||||
|
||||
export const verifyPassword = (password: string, hash: string): Promise<boolean> =>
|
||||
bcrypt.compare(password, hash);
|
||||
|
||||
export const encryptApiKey = (plainText: string): string => {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(algorithm, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [iv, tag, encrypted].map((part) => part.toString("base64url")).join(".");
|
||||
};
|
||||
|
||||
export const decryptApiKey = (payload: string): string => {
|
||||
const [ivText, tagText, encryptedText] = payload.split(".");
|
||||
if (!ivText || !tagText || !encryptedText) {
|
||||
throw new Error("Invalid encrypted API key payload");
|
||||
}
|
||||
const decipher = createDecipheriv(algorithm, getKey(), Buffer.from(ivText, "base64url"));
|
||||
decipher.setAuthTag(Buffer.from(tagText, "base64url"));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedText, "base64url")),
|
||||
decipher.final()
|
||||
]).toString("utf8");
|
||||
};
|
||||
|
||||
export const maskApiKey = (apiKey: string): string => {
|
||||
if (apiKey.length <= 10) {
|
||||
return `${apiKey.slice(0, 2)}****${apiKey.slice(-2)}`;
|
||||
}
|
||||
return `${apiKey.slice(0, 6)}****${apiKey.slice(-4)}`;
|
||||
};
|
||||
53
apps/api/src/server.ts
Executable file
53
apps/api/src/server.ts
Executable file
@@ -0,0 +1,53 @@
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import jwt from "@fastify/jwt";
|
||||
import sensible from "@fastify/sensible";
|
||||
import Fastify from "fastify";
|
||||
import { JsonWallMuseDb } from "@wallmuse/db";
|
||||
import { ApiError, sendError } from "./errors.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
export const buildServer = async () => {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? "info"
|
||||
}
|
||||
});
|
||||
|
||||
const db = JsonWallMuseDb.fromEnv();
|
||||
await db.init();
|
||||
app.decorate("db", db);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true
|
||||
});
|
||||
await app.register(cookie);
|
||||
await app.register(jwt, {
|
||||
secret: process.env.JWT_SECRET ?? "wallmuse-dev-jwt-secret"
|
||||
});
|
||||
await app.register(sensible);
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
if (error instanceof ApiError) {
|
||||
sendError(reply, error);
|
||||
return;
|
||||
}
|
||||
if ("validation" in error) {
|
||||
sendError(reply, new ApiError(400, "VALIDATION_ERROR", error.message));
|
||||
return;
|
||||
}
|
||||
app.log.error(error);
|
||||
sendError(reply, new ApiError(500, "INTERNAL_ERROR", "Internal server error"));
|
||||
});
|
||||
|
||||
await registerRoutes(app);
|
||||
return app;
|
||||
};
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const app = await buildServer();
|
||||
const host = process.env.API_HOST ?? "0.0.0.0";
|
||||
const port = Number(process.env.API_PORT ?? "4000");
|
||||
await app.listen({ host, port });
|
||||
}
|
||||
94
apps/api/src/smoke-test.ts
Executable file
94
apps/api/src/smoke-test.ts
Executable file
@@ -0,0 +1,94 @@
|
||||
import { buildServer } from "./server.js";
|
||||
|
||||
const app = await buildServer();
|
||||
|
||||
const request = async (
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
token?: string
|
||||
): Promise<{ statusCode: number; json: any }> => {
|
||||
const response = await app.inject({
|
||||
method,
|
||||
url,
|
||||
headers: {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
payload: body
|
||||
});
|
||||
let json: any;
|
||||
try {
|
||||
json = response.json();
|
||||
} catch {
|
||||
json = response.body;
|
||||
}
|
||||
if (response.statusCode >= 400) {
|
||||
throw new Error(`${method} ${url} failed: ${response.statusCode} ${response.body}`);
|
||||
}
|
||||
return { statusCode: response.statusCode, json };
|
||||
};
|
||||
|
||||
const suffix = Date.now();
|
||||
await request("GET", "/api/v1/health");
|
||||
const config = await request("GET", "/api/v1/app/config");
|
||||
const register = await request("POST", "/api/v1/auth/register", {
|
||||
email: `api-smoke-${suffix}@wallmuse.local`,
|
||||
password: "password123",
|
||||
name: "API Smoke"
|
||||
});
|
||||
const token = register.json.token as string;
|
||||
const me = await request("GET", "/api/v1/me", undefined, token);
|
||||
const providers = await request("GET", "/api/v1/providers");
|
||||
const providerId = providers.json[0].id as string;
|
||||
const models = await request("GET", `/api/v1/models?providerId=${providerId}`);
|
||||
const modelId = models.json[0].id as string;
|
||||
const apiKey = await request(
|
||||
"POST",
|
||||
"/api/v1/user-api-keys",
|
||||
{
|
||||
providerId,
|
||||
name: "Smoke key",
|
||||
apiKey: "sk-wallmuse-smoke-test-secret",
|
||||
defaultModelId: modelId
|
||||
},
|
||||
token
|
||||
);
|
||||
if (apiKey.json.encryptedKey || apiKey.json.maskedKey.includes("secret")) {
|
||||
throw new Error("API key response leaked secret material");
|
||||
}
|
||||
await request("POST", `/api/v1/user-api-keys/${apiKey.json.id}/test`, undefined, token);
|
||||
const generation = await request(
|
||||
"POST",
|
||||
"/api/v1/generations",
|
||||
{
|
||||
mode: "text_to_image",
|
||||
prompt: "futuristic city at sunrise, clean wallpaper composition",
|
||||
modelId,
|
||||
aspectRatios: ["16:9", "9:16"],
|
||||
resolution: "2k",
|
||||
quality: "standard",
|
||||
batchSize: 1,
|
||||
publishToGallery: false
|
||||
},
|
||||
token
|
||||
);
|
||||
await request("GET", `/api/v1/generation-groups/${generation.json.generationGroup.id}`, undefined, token);
|
||||
await request("GET", "/api/v1/admin/tasks", undefined, token);
|
||||
await request("GET", "/api/v1/admin/provider-logs", undefined, token);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
siteName: config.json.site.name,
|
||||
user: me.json.user.email,
|
||||
providerId,
|
||||
modelId,
|
||||
generationGroupId: generation.json.generationGroup.id
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
await app.close();
|
||||
14
apps/api/tsconfig.json
Executable file
14
apps/api/tsconfig.json
Executable file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../../packages/shared" },
|
||||
{ "path": "../../packages/db" },
|
||||
{ "path": "../../packages/api-client" }
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user