feat(portal-bff): openapi spec + scalar api reference UI (dev-only) #143
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-openapi-scalar"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds an OpenAPI 3 spec + a Scalar API Reference UI to
portal-bff, dev-only. The BFF previously had no way to see its HTTP surface short of grepping for@Get/@Post; this PR generates the spec from the existing Nest controllers via@nestjs/swaggerand renders it through Scalar — a modern alternative to the classic Swagger UI (single-page, fast, dark-mode native, better typography).What lands
Two new dev-only routes
GET /api/openapi.jsonGET /api/docsBoth routes are gated behind
process.env.NODE_ENV !== 'production'insetupOpenApi— production deployments don't need the docs surface, and publishing it would hand an attacker a curated map of every authenticated endpoint + every DTO shape. If a future ops use-case wants the spec in prod (internal gateway, contract testing), the gate is one line away from an opt-inOPENAPI_PUBLISH=trueenv knob.Core implementation —
apps/portal-bff/src/openapi/openapi.tsTwo exported helpers:
buildOpenApiDocument(app)— wraps Nest'sDocumentBuilder+SwaggerModule.createDocument. Sets title, description (mentions the CSRF caveat — see below), version, and registers two cookie security schemes:portal_sessionfor the user-portal surface (ADR-0009).portal_admin_sessionfor the admin-portal surface (ADR-0020).No
@ApiBearerAuthis declared — the BFF never exposes a bearer-auth surface (SPA never holds tokens per ADR-0009; downstream OBO tokens are server-side only per ADR-0014).setupOpenApi(app, globalPrefix)— short-circuits in production, otherwise binds the two routes via the Express adapter directly (app.getHttpAdapter().get(...)andapp.use(...)). The OpenAPI JSON is a static asset and Scalar is a vanilla Express middleware — wrapping either in a Nest controller would add zero value and an extra layer of indirection.Wired into bootstrap at
apps/portal-bff/src/main.ts:220, immediately after the JWKS endpoint mount and beforeapp.listen().Controllers decorated with
@ApiTags/@ApiOperation/@ApiCookieAuthAnnotations are cosmetic but make the spec actually browsable. Tag taxonomy:
AppControllerapp (scaffolding)HealthControllerhealthAuthControllerauth (user portal)portal_sessionon/me+/logoutAdminAuthControllerauth (admin portal)portal_admin_sessionon/me+/logoutAdminControlleradmin (self-test)portal_admin_sessionAdminAuditControlleradmin (audit log)portal_admin_sessionAdminUsersControlleradmin (user directory)portal_admin_session@ApiOperation({ summary: … })added on every route — populates the one-line description Scalar shows in its left-rail TOC.Deps + Jest
@nestjs/swagger ^11(matches the Nest 11 major already pinned) and@scalar/nestjs-api-referenceadded to the workspace root.jest.config.cts— widenedtransformIgnorePatternsfrom/node_modules/(?!.*jose)/to/node_modules/(?!.*(jose|@scalar/))/.@scalar/client-side-rendering(a transitive dep) ships ESM-only; without this widening the spec suite fails to load the module under ts-jest.Notes for the reviewer
portal_session/portal_admin_sessionkeeps the indicator semantically truthful —/api/auth/meand/api/admin/auth/melook identical otherwise.POST/PUT/PATCH/DELETE) requireX-CSRF-Tokenper ADR-0009. The header must be set manually in Scalar's "Try it" panel to the value of theportal_csrfcookie when exercising those routes. The spec description mentions it; auto-injecting the header from the cookie is a future polish.@nestjs/swaggeris the framework's own first-party tooling; Scalar is a thin UI on top of a standard OpenAPI 3 document. Both replaceable without touching the controllers (the@Api*annotations are spec-standard). Dev-only, no prod surface — doesn't cross any of the bars that warrant an ADR per CLAUDE.md.Test plan
apps/portal-bff/src/openapi/openapi.spec.ts— document shape (openapi version, title, version), both cookie schemes declared, smoke controller route captured inpaths, production short-circuit (no routes mounted, noapp.usecalled), dev mount (JSON at/api/openapi.jsonvia the HTTP adapter, Scalar UI at/api/docsviaapp.use).pnpm nx test portal-bff— 396 specs pass (was 391).pnpm exec nx affected -t format:check lint test build --base=origin/main— clean.pnpm nx serve portal-bff,curl /api/openapi.json | jq .inforeturns title + version, open/api/docsin a browser, every controller's routes visible under their tag, lock icons match the cookie scheme on guarded routes.What's next — light follow-ups
Not blocking this PR; mentioned so they're not lost:
X-CSRF-Tokenheader in Scalar from theportal_csrfcookie (custom Scalar config preset).@ApiOperationsummaries with multi-linedescriptions on the more involved routes (/api/admin/audit,/api/admin/users).@ApiPropertyonce the first contract-test consumer arrives — Nest can also pick them up automatically with the@nestjs/swaggerts-plugin if we wire it into the Nx build target. Deferred until the spec is consumed by tooling that benefits from the precision.Wires `@nestjs/swagger` for spec generation + `@scalar/nestjs-api- reference` for the UI, mounted in dev only. Closes the "no API visualization in dev" gap that was forcing curl + grep navigation of the controller tree. Routes (NODE_ENV !== 'production' only): - `GET /api/openapi.json` — raw OpenAPI 3 document. External tools (Bruno, Insomnia, Postman) import from this URL. Served via a plain Express GET handler — no DTO, no guard, no middleware to thread through; the spec is a static asset. - `GET /api/docs` — Scalar API Reference UI. Loads the spec from the JSON endpoint at render time. Clean, dark-mode-aware, searchable. Implementation - `apps/portal-bff/src/openapi/openapi.ts` exposes two functions: - `buildOpenApiDocument(app)` — wraps `SwaggerModule.create Document` with the project's title / version / two cookie auth schemes. Returns an `OpenAPIObject`. Public so the spec can be inspected in tests. - `setupOpenApi(app, globalPrefix)` — guards on `NODE_ENV`, mounts the two routes when allowed. - Two cookie security schemes declared at build time: `portal_session` and `portal_admin_session`. Controllers annotate via `@ApiCookieAuth(<name>)`; Scalar shows the lock icon per endpoint. - No bearer-auth scheme. The BFF never exposes a bearer surface — the SPA never holds tokens (ADR-0009), downstream OBO tokens are server-side only (ADR-0014). Production gating The setup function short-circuits on `NODE_ENV === 'production'`. Exposing the spec in prod would hand an attacker a curated map of every authenticated endpoint and every DTO shape — opt-in only, not default-on. A future `OPENAPI_PUBLISH=true` env knob can re-enable it for ops use-cases (internal gateway, partner integrations); kept out of v1 to avoid the YAGNI knob. Controllers decorated - `auth (user portal)` (AuthController): login / callback / me / logout, /me + /logout marked `@ApiCookieAuth('portal_session')`. - `auth (admin portal)` (AdminAuthController): same shape, admin cookie. - `admin (self-test)`, `admin (audit log)`, `admin (user directory)` — all tagged + `@ApiCookieAuth('portal_admin_session')`. - `health` (HealthController): tagged. - `app (scaffolding)` (AppController): tagged so the leftover root route doesn't pollute the "default" group. Each tagged controller method gets a one-line `@ApiOperation summary` so Scalar lists them readably. CSRF caveat documented in the API description Try-it on POST/PUT/PATCH/DELETE needs the `X-CSRF-Token` header echoed from the `portal_csrf` cookie (ADR-0009 §"Double-submit CSRF"). The description spells this out so an admin curling mutations from Scalar doesn't 403 mysteriously. v1 doesn't auto- inject the header; future polish if the pattern becomes common. Deps - `@nestjs/swagger@^12` added as a direct dep. - `@scalar/nestjs-api-reference@^1.1` added as a direct dep. Its transitive `@scalar/client-side-rendering` is ESM-only; `jest.config.cts`'s `transformIgnorePatterns` is widened to include `@scalar/` alongside the existing `jose` whitelist. Tests: +5 specs (document title + version + securitySchemes, smoke-controller path captured, prod short-circuit asserts no side-effect, dev mount asserts the two route bindings).