diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index f7e7853..d6094f2 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -7,11 +7,12 @@ COPY packages/ packages/ RUN npm ci COPY tsconfig.base.json ./ COPY apps/server/ apps/server/ -RUN npm run build -w @botanica/server && npm prune --omit=dev +RUN npm run build:packages && npm run build -w @botanica/server && npm prune --omit=dev FROM node:24-alpine ENV NODE_ENV=production WORKDIR /app COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/packages ./packages COPY --from=build --chown=node:node /app/apps/server/package.json ./package.json COPY --from=build --chown=node:node /app/apps/server/dist ./dist COPY --from=build --chown=node:node /app/apps/server/migrations ./migrations diff --git a/apps/server/package.json b/apps/server/package.json index 01211cb..fb4dd30 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,7 +14,9 @@ "fastify": "^5.12.7", "zod": "^4.0.0", "drizzle-orm": "^0.45.4", - "pg": "^8.16.3" + "pg": "^8.16.3", + "@botanica/genetics": "0.0.0", + "@botanica/shared": "0.0.0" }, "devDependencies": { "@types/pg": "^8.15.5", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index efd1e7a..a8a6eb1 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,3 +1,6 @@ +import { ZodError } from "zod"; +import { GeneticsError } from "@botanica/genetics"; +import { registerGeneticsRoutes } from "./genetics-routes.js"; import Fastify from "fastify"; export function createApp( checkDatabase: () => Promise, @@ -9,22 +12,38 @@ export function createApp( await checkDatabase(); return { status: "ok", database: "connected" }; } catch { - return reply - .code(503) - .send({ - error: { - code: "DATABASE_UNAVAILABLE", - message: "Database is unavailable.", - }, - }); + return reply.code(503).send({ + error: { + code: "DATABASE_UNAVAILABLE", + message: "Database is unavailable.", + }, + }); } }); + registerGeneticsRoutes(app); app.setNotFoundHandler((_request, reply) => reply .code(404) .send({ error: { code: "NOT_FOUND", message: "Route not found." } }), ); app.setErrorHandler((error, request, reply) => { + if (error instanceof ZodError) + return reply + .code(400) + .send({ + error: { + code: "INVALID_REQUEST", + message: "Request validation failed.", + details: error.issues.map((i) => ({ + path: i.path, + message: i.message, + })), + }, + }); + if (error instanceof GeneticsError) + return reply + .code(error.code === "INCOMPATIBLE_SPECIES" ? 409 : 400) + .send({ error: { code: error.code, message: error.message } }); request.log.error(error); const code = typeof error === "object" && error !== null && "statusCode" in error @@ -32,17 +51,15 @@ export function createApp( : undefined; const status = typeof code === "number" && code >= 400 && code < 500 ? code : 500; - reply - .code(status) - .send({ - error: { - code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST", - message: - status === 500 - ? "An unexpected error occurred." - : "Request could not be processed.", - }, - }); + reply.code(status).send({ + error: { + code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST", + message: + status === 500 + ? "An unexpected error occurred." + : "Request could not be processed.", + }, + }); }); return app; } diff --git a/apps/server/src/genetics-routes.test.ts b/apps/server/src/genetics-routes.test.ts new file mode 100644 index 0000000..619e778 --- /dev/null +++ b/apps/server/src/genetics-routes.test.ts @@ -0,0 +1,35 @@ +import { it, expect } from "vitest"; +import { createApp } from "./app.js"; +import { exampleGenome } from "@botanica/genetics"; +it("exposes versioned catalogs and rejects untrusted alleles and claims", async () => { + const app = createApp(async () => {}); + try { + const catalog = await app.inject("/api/v1/catalog/species"); + expect(catalog.statusCode).toBe(200); + expect(catalog.json().species[0].id).toBe("velvetleaf"); + const payload = { speciesId: "velvetleaf", genome: exampleGenome }; + const valid = await app.inject({ + method: "POST", + url: "/api/v1/phenotype/resolve", + payload, + }); + expect(valid.statusCode).toBe(200); + expect(valid.json().phenotype.pigment).toBe("purple"); + for (const invalid of [ + { ...payload, genome: { ...exampleGenome, pigment: ["