NestJS API for Languages Learner. Serves /api/*, verifies Supabase JWTs, and executes
every database query through a client bound to the caller's token so that Row Level Security
decides what the caller can see.
apps/web proxies /api/* to this service, so browsers normally never talk to it directly.
Run from the monorepo root with pnpm --filter app-backend <script>.
| Script | Description |
|---|---|
start:dev |
Watch-mode development server |
start / start:prod |
Run once / run the compiled build (dist/main.js) |
build |
Compile to dist/ (uses tsconfig.build.json) |
test:unit |
Vitest in watch mode |
test:unit:ci |
Vitest, single run |
lint / typecheck |
ESLint (with --fix) / tsc --noEmit |
generate-types |
Regenerate Supabase database types |
generate:api-schemas |
Regenerate the OpenAPI schema and its TS types |
pnpm dev from the repository root starts this service in Docker
(docker-compose.dev.yml) and apps/web natively on the host.
pnpm dev:logs follows this container; pnpm dev:build rebuilds it after a dependency change.
Swagger UI is served at http://localhost:3001/api/docs.
Variables live in the monorepo-root .env (see .env.example). They are
validated at startup by src/config/env.validation.ts — the process exits with a readable
message rather than failing on the first request.
| Variable | Required | Description |
|---|---|---|
SUPABASE_PROJECT_URL |
yes | Supabase project URL |
SUPABASE_ANON_KEY |
yes | Anon key — see the note below |
PORT |
no | Defaults to 3001 |
CORS_ORIGINS |
no | Comma-separated allowlist, defaults to the dev host |
THROTTLE_TTL |
no | Rate limit window in ms, defaults to 60000 |
THROTTLE_LIMIT |
no | Requests per window per IP, defaults to 120 |
The backend intentionally has no service role key. Its only shared Supabase client exists
to call auth.getUser for token verification, which the anon key can do. Everything else uses
SupabaseService.getClientForUser(token). Introducing a service role key here would silently
disable RLS for every request.
In production (Yandex Serverless Container) these variables are injected at runtime via the
container revision's --environment, not baked into the image — see
.github/workflows/deploy.yml. The image ships a .env
built from .env.example, but @nestjs/config does not override variables already present in
process.env, so the runtime values win. The GitHub secret SUPABASE_PROJECT_KEY maps onto the
backend's SUPABASE_ANON_KEY.
apps/web reads the same two variables — the SSR server directly, the browser through
window.CLIENT — so one --environment flag configures all three consumers and a rotated key
needs no rebuild.
Types are generated, never hand-written. Response DTOs are the source of truth:
apps/backend DTOs
→ Swagger document (generate:api-schemas, boots the app with GENERATE_API_SCHEMA=true)
→ packages/api/src/schemas/openapi.json
→ packages/api/src/schemas/api.ts (openapi-typescript)
→ packages/api SDK (hand-written wrapper in src/sdk/)
→ apps/web
After changing any DTO or handler return type:
pnpm --filter app-backend generate:api-schemas # needs a valid root .env
pnpm typecheck # apps/web sees the new contractCommit the regenerated packages/api/src/schemas/* along with the DTO change — skipping this
step leaves apps/web typechecking against a stale contract.
Handlers declare explicit return types (Promise<FetchWordsResponse> and friends) so that a
service whose shape drifts from its Swagger DTO fails tsc instead of silently shipping a
response the generated client does not expect.
-
auth/— globalAuthGuard(opt out per route with@Public()), the@CurrentUser()param decorator, and the shared bearer-token helper. -
supabase/—SupabaseService, the only place Supabase clients are created. -
common/filters/— global exception filter mapping Postgres/PostgREST error codes to HTTP statuses; unmapped errors are logged in full and returned as a generic 500. -
Providers are deliberately singletons. An earlier request-scoped Supabase client threw
UnauthorizedExceptionfrom its factory when the request carried no bearer token. Nest resolves a request-scoped provider graph before running guards, so that throw short-circuited the whole chain: the globalThrottlerGuardnever executed, and unauthenticated floods — the exact case rate limiting exists for — were never counted. The 401 looked correct, which is why nothing surfaced it.Two rules follow: keep request scope out of this graph, and never authenticate inside a provider factory — that belongs in a guard.
test/rate-limit.e2e.test.tslocks the behaviour in.