Canonical instructions for AI coding agents working in this project. Tool-neutral
and read by most agents (Cursor, Cline, Codex, Copilot, Gemini, …). CLAUDE.md
imports this file (@AGENTS.md) so Claude Code reads it too. Detailed,
topic-by-topic rules live in .clinerules/ and are linked below.
A backend built on @adaptivestone/framework
v5 (currently ^5.4.0): TypeScript-first, ESM-only, runs .ts sources natively
on Node ≥ 24 — or Bun ≥ 1.4, the certified second runtime — with no build
step. MongoDB + Redis backed; convention-based controllers and Mongoose models.
npm run dev— generate types + start the watch server (auto-fillsAUTH_SALT).npm run gen— regenerate*.routes.gen.ts+genTypes.d.ts. Run after changing aroutesgetter, a model, or config.npm run check:types—npm run genthentsc --noEmit.npm run routes— print the resolved route tree;npm run openapi— write the OpenAPI 3.1 contract to gitignoredopenapi.json.npm test— Node's built-in test runner;npm run test:ci— coverage thresholds and CI reports;npm run t— watch mode. CI runs the suite on Node 24.docker compose run --rm backend-bun— the same suite under Bun ≥ 1.4, the framework's certified second runtime (Docker only; nothing Bun-related runs on the host). → .clinerules/11-Testing.mdnpm run check/npm run check:fix— Biome lint + format.npm run cli migration/create -- --name=<name>— scaffold a migration;npm run cli migration/migrate— apply pending ones. Never hand-write a migration file. → .clinerules/10-Migrations.md- Docker:
docker compose up(Mongo replicaset + Redis + Mailpit). Most commands run inside thebackendcontainer — see .clinerules/02-DockerCommands.md.
- Don't guess framework behavior — read the docs first. Before non-trivial framework work (validation, file uploads, config, lifecycle, models), read the relevant section of the framework docs (
npm run docs:download→.clinerules/framework-docs.md, or https://framework.adaptivestone.com/). Reaching for an escape hatch (z.any(),as,@ts-ignore) means you don't know the API yet → read the docs, don't bypass. Don't assume existing code is correct — verify the pattern, especially after a dependency bump. - Types are generated — never hand-write request types. Type each handler with the generated
…Requesttype (PascalCased handler name, e.g.createPerson→CreatePersonRequest) from./<Controller>.routes.gen.ts.getModel(...)/getConfig(...)are typed viagenTypes.d.ts— noascasts. Gen files are gitignored, so a fresh clone is red until the firstnpm run gen. Never edit a*.routes.gen.tsby hand — regenerate. → .clinerules/03-ControllerPattern.md - Controllers extend
AbstractController; routes are a literalroutesgetter and middleware a literalstatic get middleware()Map. A simple initializedconstconfig read before the literal return is supported; loops, conditionals, mutable setup, computed keys, and dynamic route construction are not. The default middleware chain is[GetUserByToken, Auth](secure by default); override with[]to make a controller public. - Controller routing: route-bearing folder prefix + lowercased CLASS name. The framework autoloads every
.tsundersrc/controllers/and mounts each controller at/<folder>/<classname>lowercased (controllers/public/Impact.tswithclass Impact→/public/impact) — the FILENAME is irrelevant to routing (it only matters for overriding framework-internal controllers by file-name collision). A fully parenthesized folder is an organizational route group (also called a pathless route-group directory) and contributes no URL segment:controllers/(public)/PathlessRouteGroups.ts→/pathlessroutegroups; generated*.routes.gen.tsfiles stay beside the source. Use ordinary folders for real URL prefixes and(group)folders only for source organization. Groups are not namespaces: if two controllers collapse onto the same method/path, boot fails. Class names can't contain dashes, so a multi-word kebab-case leaf (/impact-surveys) requires agetHttpPath()override returning the full path. Name the file after the class (PascalCase). Never export the same controller from two files — autoloading registers both and double-mounts the routes. - Validation is Standard Schema. Use any Standard Schema validator as a route
request:/query:schema — this project uses zod; yup ≥1.7 / valibot / arktype work the same way. The schema's inferred output becomes the typedreq.appInfo.request/req.appInfo.query. Schema error messages should be i18n keys; the framework translates them through the request locale before returning the HTTP 400, so never catch/retranslate them in controllers. The standard test setup intentionally leaves this project's locale folder unloaded, which since framework 5.4 splits messages under test in two: keys this project authors (thevalidation.*schema messages) still surface as stable raw keys, while every message the framework emits — auth validation, the 401, the 404/500 sinks — now carries its English text as an in-codedefaultValueand renders as an English sentence. Assert raw keys for the first, English (or just status codes anderrorsfield names) for the second. A route without arequest:/query:schema leavesreq.appInfo.request/req.appInfo.queryundefined — the parser puts the parsed body (incl. multipart files) onreq.body. Validate uploaded files withimport { File } from '@adaptivestone/framework/types.js'+ an instanceof check (zod:z.instanceof(File)); a file field may arrive array-wrapped. Neverz.any()/asto dodge a type (YupFileis deprecated). - Never leak internal IDs in public URLs. Public/CDN-served assets (avatars, uploads) and any URL visible to other users must use opaque random keys (e.g. a
randomUUID()), never the user_idor any internal identifier in the path/filename. - Models extend
BaseModeland keepmodelSchema/schemaOptionsreadonly withas const. Use a privateGetModelTypeLiteFromSchemaalias only as thethiscontext while the class is unfinished; exportGetModelTypeFromClass<typeof Model>as the complete normal Mongoose model type. There is still one runtime schema, never a separately maintained schema interface. → .clinerules/05-TS.md - Responses follow the project envelope:
{ data, message?, errors?, total?/page?/limit? }. → .clinerules/01-ResponceType.md - Do not wrap handler bodies in try/catch — the framework handles errors centrally. → .clinerules/07-ErrorHandling.md
- ESM only (no CommonJS /
require) and i18n for every user-facing string — alwayst('key', { defaultValue: 'English text' }), nevert('key') || 'English text'(a missing key makest()return the truthy key itself, so the||branch is dead and the raw key ships). Framework 5.4 emits its own messages the same way, so a framework message never leaks a raw key; a key present insrc/locales/<lng>still wins, which is how this project rewords the auth 401 (middleware.auth.notLoggedIn).i18next+i18next-fs-backendare optional peers of the framework and therefore direct dependencies of this project — removing them makes the app silently English. → .clinerules/04-Esm.md, .clinerules/08-Internationalization.md - Config & env — never read
process.envin controllers/services. Env vars are read only insidesrc/config/<name>.tsfiles (e.g.config/http.ts); code consumes them viaapp.getConfig('<name>')(sync; typed bygenTypes.d.tsafternpm run gen— noascast). This keeps config tracked in one place. Env-specific, non-secret defaults go inconfig/<name>.<NODE_ENV>.ts(e.g.sample.production.ts), which the framework merges over the base config whenNODE_ENVmatches — prefer this to adding more env vars; keep only secrets in.env. → framework-docs "Environment Variables" / "NODE_ENV". - Rate-limit policy values belong in config. Declare named option objects under
src/config/rateLimiter.tspolicy, read the typed merged config withgetAppInstance().getConfig('rateLimiter'), and pass the selected object directly. For route-level middleware, put the config read in an initializedconstbefore the literal routes return and use[[RateLimiter, policy.personCreate]]; the framework accepts TypeScript's inferred parameter-pair shape withoutas const(since 5.2.2). Static middleware can read it at module scope. Both forms remain analyzable. Do not repeat points/duration in controllers or introduce string-based policy lookup. - Choose one process supervisor.
src/server.tsis the single-process entry for Docker, Kubernetes, systemd, and PM2.src/index.tsuses the framework's publicrunCluster()for a standalone multi-core host. Never combine both layers. Keep the server import inside therunClustercallback so the primary process supervises only and never constructs an application server. - Respect node:test lifecycle ordering. Root-level
before()hooks registered by separate modules may run concurrently. Before project setup reads config, models,appInstance, or the HTTP server, callawait ensureTestServerReady()from the framework test helpers; file-local setup should live inside the relevantdescribe(). Server options the production entry passes — notably thebootHttphook insrc/bootHttp.ts— reach the test server throughconfigureTestServer({ bootHttp })insrc/tests/configureServer.ts, a module the preload chain imports before the framework setup glue boots the server (a call after a preload's top-levelawaitis too late and throws). If a test usest.plan(n), every counted assertion must uset.assert.*. See.clinerules/11-Testing.md.
MongoDB (MONGO_DSN) and AUTH_SALT are required — the server fails fast
without them. npm run dev generates a salt automatically; otherwise run
npm run cli generateRandomBytes.
- Keep this repository an executable framework showcase. For every new stable, recommended public framework capability, add the smallest runnable example, a focused test where practical, and an entry in the README feature table; if an optional capability is intentionally not enabled, record that decision there. Do not leave the only example as commented pseudocode or agent-only guidance. CI must keep the route tree and OpenAPI commands runnable.
Detailed, topic-by-topic project rules live in .clinerules/ —
response format, Docker, controller pattern, ESM, TypeScript, private fields,
error handling, i18n, migrations, and testing. Read the relevant one before non-trivial work.
Cline reads .clinerules/ natively;
- Full docs: https://framework.adaptivestone.com/
- LLM-ready (whole site as one file): https://framework.adaptivestone.com/llm-context.md
npm run docs:downloadsaves the llm-context locally to.clinerules/framework-docs.md(gitignored).