Initial WallMuse project

This commit is contained in:
fenglee
2026-05-09 09:12:41 +00:00
commit 3ea7d29827
91 changed files with 13136 additions and 0 deletions

25
apps/api/package.json Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}