Skip to content

Latest commit

 

History

History

README.md

app-backend

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.

Scripts

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.

Environment

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.

The API contract chain

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 contract

Commit 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.

Notes on structure

  • auth/ — global AuthGuard (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 UnauthorizedException from 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 global ThrottlerGuard never 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.ts locks the behaviour in.