From df98d02077b491b9d8bfd4594a60b13629b0a491 Mon Sep 17 00:00:00 2001 From: Christian Johannesen Date: Wed, 28 Oct 2026 10:16:06 +0100 Subject: [PATCH] Implement M1 deterministic genetics and validated catalog API --- apps/server/Dockerfile | 3 +- apps/server/package.json | 4 +- apps/server/src/app.ts | 55 ++++--- apps/server/src/genetics-routes.test.ts | 35 +++++ apps/server/src/genetics-routes.ts | 26 ++++ apps/server/src/seed.ts | 18 +-- apps/web/Dockerfile | 2 +- docs/API_AND_DATA.md | 4 + docs/GENETICS_SPEC.md | 10 ++ docs/PROGRESS.md | 11 +- package-lock.json | 14 +- package.json | 9 +- packages/genetics/package.json | 11 +- packages/genetics/src/catalog.ts | 112 ++++++++++++++ packages/genetics/src/genetics.test.ts | 186 +++++++++++++++++++++++ packages/genetics/src/index.ts | 192 +++++++++++++++++++++++- packages/genetics/src/rng.ts | 12 ++ packages/genetics/tsconfig.json | 3 +- packages/renderer/package.json | 7 +- packages/renderer/tsconfig.json | 3 +- packages/rules/package.json | 7 +- packages/rules/src/index.ts | 55 ++++++- packages/rules/tsconfig.json | 3 +- packages/shared/package.json | 11 +- packages/shared/src/index.ts | 24 ++- packages/shared/tsconfig.json | 3 +- 26 files changed, 769 insertions(+), 51 deletions(-) create mode 100644 apps/server/src/genetics-routes.test.ts create mode 100644 apps/server/src/genetics-routes.ts create mode 100644 packages/genetics/src/catalog.ts create mode 100644 packages/genetics/src/genetics.test.ts create mode 100644 packages/genetics/src/rng.ts 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: ["