Imported from sergiusz-x/carnotea (
apps/api/AGENTS.md). Install upstream withnpx skills add sergiusz-x/carnotea --skill api. Copyright stays with the author.
apps/api AGENTS.md
Area-specific rules for @carnotea/api. These override the root AGENTS.md
for any file under apps/api/.
Stack
- NestJS 11 with the Fastify adapter (
@nestjs/platform-fastify). Fastify is measurably faster than Express and our routes are small; the ticket (T-004) prefers it. If a future need forces Express, document why here. - SWC is the build compiler — see ADR-0010.
Dev and build go through
@nestjs/cli(nest start --watch/nest build), which reads.swcrc. Vitest transforms with native Oxc (Vite 8) viaoxc.decoratorinvitest.config.ts, which emits the decorator metadata NestJS DI needs — see ADR-0011. - nestjs-pino for structured JSON logging (
pino-prettyonly in non-prod). - @nestjs/config loads
process.env; the shape is validated by a Zod schema at boot (src/config/env.ts).
Day-to-day workflow
pnpm --filter @carnotea/api dev # nest start --watch on API_PORT (default 3001)
pnpm --filter @carnotea/api build # nest build → dist/
pnpm --filter @carnotea/api test # vitest run
pnpm --filter @carnotea/api typecheck # tsc --noEmit
Probes:
curl localhost:3001/healthz # {"status":"ok"}
curl localhost:3001/readyz # {"status":"ok","db":"ok"} or 503 when the DB is down
OpenAPI + Zod convention (T-005)
No endpoint ships without Zod-typed input and output. Every route must call
zodRoute(...) at module level so it appears in the generated OpenAPI document.
zodRoute helper
Located at src/lib/openapi/zod-route.ts, exported via src/lib/openapi/index.ts.
import { z } from 'zod';
import { zodRoute, ZodValidationPipe } from '../lib/openapi/index.js';
// 1. Register route + schemas (module-level, runs on import).
const createThingRoute = zodRoute({
method: 'post',
path: '/api/things', // OpenAPI path format — {param} not :param
operationId: 'createThing',
tags: ['Things'],
request: {
body: z.object({ name: z.string() }),
},
responses: {
'201': { description: 'Created', schema: z.object({ id: z.string() }) },
'400': { description: 'Invalid request body' },
},
});
// 2. Use ZodValidationPipe to validate the body at runtime.
@Controller()
export class ThingsController {
@Post('api/things')
create(@Body(new ZodValidationPipe(createThingRoute.request!.body!)) body: { name: string }) {
...
}
}
zodRoute is a plain function (no NestJS imports) so it works regardless of
the HTTP framework. ZodValidationPipe is the NestJS adapter — it throws
BadRequestException with { code: 'VALIDATION_ERROR', message, issues } on
invalid input. The issues shape matches ErrorResponseSchema from
@carnotea/shared.
Endpoints
curl localhost:3001/openapi.json # OpenAPI 3.1 document
curl localhost:3001/docs # Swagger UI
Auth (better-auth, T-006)
better-auth (ADR-0004) owns email/password auth. It is mounted and consumed like this:
- Handler:
AuthModulemounts better-auth's web handler at/api/auth/*on the underlying Fastify instance (an encapsulated plugin with a raw-body parser, so the rest of the API keeps default JSON parsing). The instance is built bycreateAuth(db, { secret, baseURL })insrc/auth/auth.tsand provided under theAUTHtoken. - Protected routes: every route under
/api/*except/api/auth/*requires an authenticated session. Enforce it with@UseGuards(AuthGuard)—AuthGuardreads the session via better-auth and populates a typedrequest.user({ id, email }), or throws401. Read it in a handler with the@CurrentUser()decorator. - Identity = ownership id.
vehicle_diary.users.idIS the better-auth user id (same UUID — seepackages/db/AGENTS.md), sorequest.user.idis the value to scope queries by; no profile lookup is needed to get the owner id. better-auth'sdatabaseHooks.user.create.afterhook mirrors each new auth user into the domainusersrow (idempotent).GET /api/mereturns that profile. - Env:
BETTER_AUTH_SECRETandBETTER_AUTH_URLare required (seesrc/config/env.tsand root.env.example).
Email (T-051)
Transactional emails (email verification, password reset) are sent via a thin nodemailer transport with bilingual templates.
Architecture — three layers in src/emails/:
| File | Role |
|---|---|
email.transport.ts |
Creates a nodemailer Transporter from env. SMTP if SMTP_HOST is set; Mailpit fallback in dev (localhost:1025); boot error in prod with no host. |
email.templates.ts |
Renders { subject, text, html } from inline i18next resources (locales/en.json, locales/pl.json). SupportedLocale = 'pl' | 'en'. |
email.service.ts |
Combines transport + templates. Exported as a factory createEmailService(deps). Errors are swallowed — send failures must not propagate to auth callbacks (enumeration-safety). |
Wiring: AuthModule.useFactory creates the transport and service, passes
emailService into createAuth(...). The auth callbacks look up the user's
localePref from vehicle_diary.users and pass the locale to the service.
Env vars (all in src/config/env.ts):
| Var | Default | Notes |
|---|---|---|
SMTP_HOST |
(absent) | Absent in dev → Mailpit; required in prod |
SMTP_PORT |
587 |
Use 465 for implicit TLS |
SMTP_USER |
(absent) | Optional (Mailpit needs none) |
SMTP_PASS |
(absent) | Optional |
EMAIL_FROM |
CarNotea <noreply@localhost> |
Sender shown to recipients |
EMAIL_REPLY_TO |
noreply@localhost |
Reply-to header |
Mailpit is included in docker-compose.yml. SMTP on port 1025, web inbox
at http://localhost:8025. No credentials needed.
Observability (T-018)
OpenTelemetry tracing is initialised in src/instrumentation.ts and preloaded
via --import ./dist/instrumentation.js before NestJS boot. This ensures
auto-instrumentation wraps HTTP, Fastify, and pg modules before any import.
- SDK:
@opentelemetry/sdk-nodewithgetNodeAutoInstrumentations(HTTP, pg) plus separateFastifyInstrumentationandPinoInstrumentationfor Fastify-specific spans and log↔trace correlation. - Log correlation: every pino log line includes
trace_idandspan_idautomatically viaPinoInstrumentation. - Default-off: all
OTEL_*env vars are optional in the Zod schema (src/config/env.ts). WhenOTEL_EXPORTER_OTLP_ENDPOINTis absent the SDK is never initialised — zero boot cost. - Exporter: standard OTLP HTTP exporter. Set
OTEL_EXPORTER_OTLP_ENDPOINTto your OTel collector or vendor endpoint. - Resource:
service.name=carnotea-api(overridable viaOTEL_SERVICE_NAME). - Graceful shutdown: SDK shutdown on SIGTERM / SIGINT.
See ADR-0013.
Resource modules (user-scoped CRUD)
vehicles/ (T-020) is the reference shape every owned resource copies:
- One module per resource (
<name>/<name>.module.ts+ controller + service), registered inapp.module.ts. The module importsAuthModuleso@UseGuards(AuthGuard)resolves;DbModuleis global. - Ownership = a single filtered query. Scope every read/write by
eq(<table>.userId, user.id)in theWHEREclause — never fetch-then-check. A missing row and another user's row are indistinguishable, so both return 404, never 403 — existence must not leak across users. - Error envelope. Throw NestJS exceptions with the shared
ApiErrorbody{ code, message, issues? }(e.g.new NotFoundException({ code: 'NOT_FOUND', message })) so every error matchesErrorResponseSchemalike theZodValidationPipe400 does. Register error responses inzodRoute(...)withschema: ErrorResponseSchema. - Unique conflicts → 409. Drizzle wraps the driver error in
DrizzleQueryError; thePostgresError(SQLSTATE23505,constraint_name) is on.cause. Map it to aConflictExceptionrather than letting a 500 leak. - Lookup codes. Contracts expose stable lookup codes (e.g.
fuelType); the service resolves them to the lookup table id on write and joins back on read.
Rules
- ESM only. Relative imports use explicit
.jsextensions (the package is"type": "module"and tsconfig usesNodeNext). SWC keeps specifiers as-is. - Validate env with Zod at boot. Add new variables to
src/config/env.tsand to root.env.examplein the same change. Missing/invalid env must fail startup with a clear message — never readprocess.envad hoc for config. - Inject the database via the
DBtoken, not by type. The provider insrc/db/db.module.tsbuilds it fromcreateDb(DATABASE_URL); inject with@Inject(DB) db: Db. - No
class-validator. Zod only, at the controller boundary (ADR-0003). - No
console.log. Use the injected pino logger. - NestJS module classes are empty by design;
no-extraneous-classis disabled for*.module.tsineslint.config.js. Do not add placeholder members to satisfy lint.
Out of scope here (own tickets)
- OAuth providers, 2FA, passkeys, password reset — follow-ups to T-006.
- Docker image for the API — T-014.