A request through the API
Follow one REST call from the browser to Postgres and back, through every layer Nest puts in the way.
We follow GET /api/repositories/ada/tools/issues?state=open as the web app makes it while rendering the issue list.
1. The web app makes the call
The page is a server component. It builds a typed client with apps/web/src/lib/api/server.ts › createServerClient, which forwards the browser's cookie and client address. The client's types come from openapi.json, so a wrong path or field is a TypeScript error in the web app.
2. Nest's setup
apps/api/src/app.setup.ts › configureApp is shared by main.ts and the e2e harness, so tests boot exactly what production boots:
bodyParser: falseat creation. Git bodies must reach the transport untouched; REST controllers get JSON through Better Auth's module configuration instead.ValidationPipe({ transform: true, whitelist: true }): query strings and bodies become DTO instances, and unknown fields are stripped.DomainErrorFilter: anyDomainErrorbecomes a JSON error with its status.setGlobalPrefix('/api'), except the git and OAuth routes (ADR 0001).
3. Authentication
AuthModule from @thallesp/nestjs-better-auth installs a global guard. By default every route requires a session. Controllers relax it per route:
@OptionalAuth(): a session is read if present. Public repositories are readable anonymously, so mostGETs carry this.@AllowAnonymous(): no session lookup at all.@Session() session: UserSessioninjects the session into the handler.
API keys do not authenticate Ghost's own /api. They authenticate git and the GitHub compatibility layer only (ADR 0040).
4. Controller → service
apps/api/src/resources/issues/issues.controller.ts maps HTTP to a service call and does nothing else: no queries, no decisions. Swagger decorators (@ApiOperation, @ApiOkResponse) describe the route for openapi.json.
5. Authorization
The service's first act is apps/api/src/services/git/repository-access/repository-access.service.ts › authorize. It finds the repository by owner name and slug, and the viewer's role, in one query, then calls decideAccess, which either returns the repository with viewerRole attached or throws:
| Situation | Error | Status |
|---|---|---|
| Anonymous, cannot read | AuthenticationRequiredError | 401 |
| Signed in, cannot read | RepositoryNotFoundError | 404, so a private repository's existence never leaks |
| Can read, role too low | RepositoryForbiddenError | 403 |
The Data and access chapter covers the ladder in depth.
6. The query
Lists paginate by keyset, not offset: apps/api/src/lib/db/keyset.ts › keyset turns a sort order into a WHERE (createdAt, id) < (...) and an opaque base64url cursor. One extra row is fetched to answer "is there another page" without counting.
Responses are selected in the shape the DTO needs. Code standard 2 forbids to<Format> helpers that rename fields after the fact; the query selects the right fields to begin with.
7. Errors on the way back
If anything throws a DomainError, apps/api/src/filters/domain-error/domain-error.filter.ts renders { statusCode, error, message, timestamp, path } with its status. GraphQL errors are left to Apollo, which renders GitHub's error shape. Anything else is a 500 and is logged. The web app reads message and shows it.
Try it
Open http://localhost:3001/reference while the API runs. That is the live OpenAPI reference (Scalar) generated from the same decorators. Find the issues list route, call it for a public repository without signing in, then for a private one: 401. Sign in in the browser (same origin cookie) and try again.