feat(portal-bff): openapi spec + scalar api reference UI (dev-only) #143

Merged
julien merged 1 commits from feat/portal-bff-openapi-scalar into main 2026-05-14 20:52:04 +02:00
Owner

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/swagger and 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

Route What it serves
GET /api/openapi.json Raw OpenAPI 3 document. External tools (Bruno / Insomnia / Postman) import from here.
GET /api/docs Scalar API Reference HTML page. Loads the JSON spec at render time and renders the full endpoint catalogue with a "Try it" panel.

Both routes are gated behind process.env.NODE_ENV !== 'production' in setupOpenApi — 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-in OPENAPI_PUBLISH=true env knob.

Core implementation — apps/portal-bff/src/openapi/openapi.ts

Two exported helpers:

  • buildOpenApiDocument(app) — wraps Nest's DocumentBuilder + SwaggerModule.createDocument. Sets title, description (mentions the CSRF caveat — see below), version, and registers two cookie security schemes:

    • portal_session for the user-portal surface (ADR-0009).
    • portal_admin_session for the admin-portal surface (ADR-0020).
      No @ApiBearerAuth is 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(...) and app.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 before app.listen().

Controllers decorated with @ApiTags / @ApiOperation / @ApiCookieAuth

Annotations are cosmetic but make the spec actually browsable. Tag taxonomy:

Controller Tag Security
AppController app (scaffolding)
HealthController health
AuthController auth (user portal) portal_session on /me + /logout
AdminAuthController auth (admin portal) portal_admin_session on /me + /logout
AdminController admin (self-test) class-level portal_admin_session
AdminAuditController admin (audit log) class-level portal_admin_session
AdminUsersController admin (user directory) class-level 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-reference added to the workspace root.
  • jest.config.cts — widened transformIgnorePatterns from /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

  • Why two cookie schemes rather than one? Scalar renders a per-endpoint lock icon driven by the security scheme name. Splitting portal_session / portal_admin_session keeps the indicator semantically truthful — /api/auth/me and /api/admin/auth/me look identical otherwise.
  • CSRF caveat. Mutating routes (POST / PUT / PATCH / DELETE) require X-CSRF-Token per ADR-0009. The header must be set manually in Scalar's "Try it" panel to the value of the portal_csrf cookie when exercising those routes. The spec description mentions it; auto-injecting the header from the cookie is a future polish.
  • No ADR for this. @nestjs/swagger is 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.
  • Express-layer routing. Same pattern as the JWKS endpoint (#139): the OpenAPI JSON is a static asset and Scalar a vanilla Express handler, so wiring through Nest's router adds no value.

Test plan

  • 5 new specs in apps/portal-bff/src/openapi/openapi.spec.ts — document shape (openapi version, title, version), both cookie schemes declared, smoke controller route captured in paths, production short-circuit (no routes mounted, no app.use called), dev mount (JSON at /api/openapi.json via the HTTP adapter, Scalar UI at /api/docs via app.use).
  • pnpm nx test portal-bff396 specs pass (was 391).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean.
  • Manual dev smoke: pnpm nx serve portal-bff, curl /api/openapi.json | jq .info returns title + version, open /api/docs in 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:

  • Auto-inject the X-CSRF-Token header in Scalar from the portal_csrf cookie (custom Scalar config preset).
  • Promote @ApiOperation summaries with multi-line descriptions on the more involved routes (/api/admin/audit, /api/admin/users).
  • Annotate DTOs with @ApiProperty once the first contract-test consumer arrives — Nest can also pick them up automatically with the @nestjs/swagger ts-plugin if we wire it into the Nx build target. Deferred until the spec is consumed by tooling that benefits from the precision.
## Summary Adds an OpenAPI 3 spec + a [Scalar API Reference](https://scalar.com/) 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/swagger`](https://docs.nestjs.com/openapi/introduction) and 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 | Route | What it serves | | --- | --- | | `GET /api/openapi.json` | Raw OpenAPI 3 document. External tools (Bruno / Insomnia / Postman) import from here. | | `GET /api/docs` | Scalar API Reference HTML page. Loads the JSON spec at render time and renders the full endpoint catalogue with a "Try it" panel. | Both routes are gated behind `process.env.NODE_ENV !== 'production'` in [`setupOpenApi`](apps/portal-bff/src/openapi/openapi.ts) — 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-in `OPENAPI_PUBLISH=true` env knob. ### Core implementation — [`apps/portal-bff/src/openapi/openapi.ts`](apps/portal-bff/src/openapi/openapi.ts) Two exported helpers: - **`buildOpenApiDocument(app)`** — wraps Nest's `DocumentBuilder` + `SwaggerModule.createDocument`. Sets title, description (mentions the CSRF caveat — see below), version, and registers **two** cookie security schemes: - `portal_session` for the user-portal surface ([ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md)). - `portal_admin_session` for the admin-portal surface ([ADR-0020](docs/decisions/0020-portal-admin-app.md)). No `@ApiBearerAuth` is 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(...)` and `app.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`](apps/portal-bff/src/main.ts#L220), immediately after the JWKS endpoint mount and before `app.listen()`. ### Controllers decorated with `@ApiTags` / `@ApiOperation` / `@ApiCookieAuth` Annotations are cosmetic but make the spec actually browsable. Tag taxonomy: | Controller | Tag | Security | | --- | --- | --- | | [`AppController`](apps/portal-bff/src/app/app.controller.ts) | `app (scaffolding)` | — | | [`HealthController`](apps/portal-bff/src/health/health.controller.ts) | `health` | — | | [`AuthController`](apps/portal-bff/src/auth/auth.controller.ts) | `auth (user portal)` | `portal_session` on `/me` + `/logout` | | [`AdminAuthController`](apps/portal-bff/src/admin/admin-auth.controller.ts) | `auth (admin portal)` | `portal_admin_session` on `/me` + `/logout` | | [`AdminController`](apps/portal-bff/src/admin/admin.controller.ts) | `admin (self-test)` | class-level `portal_admin_session` | | [`AdminAuditController`](apps/portal-bff/src/admin/admin-audit.controller.ts) | `admin (audit log)` | class-level `portal_admin_session` | | [`AdminUsersController`](apps/portal-bff/src/admin/admin-users.controller.ts) | `admin (user directory)` | class-level `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-reference` added to the workspace root. - [`jest.config.cts`](apps/portal-bff/jest.config.cts) — widened `transformIgnorePatterns` from `/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 - **Why two cookie schemes rather than one?** Scalar renders a per-endpoint lock icon driven by the security scheme name. Splitting `portal_session` / `portal_admin_session` keeps the indicator semantically truthful — `/api/auth/me` and `/api/admin/auth/me` look identical otherwise. - **CSRF caveat.** Mutating routes (`POST` / `PUT` / `PATCH` / `DELETE`) require `X-CSRF-Token` per [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md). The header must be set manually in Scalar's "Try it" panel to the value of the `portal_csrf` cookie when exercising those routes. The spec description mentions it; auto-injecting the header from the cookie is a future polish. - **No ADR for this.** `@nestjs/swagger` is 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](CLAUDE.md). - **Express-layer routing.** Same pattern as the JWKS endpoint (#139): the OpenAPI JSON is a static asset and Scalar a vanilla Express handler, so wiring through Nest's router adds no value. ## Test plan - [x] **5 new specs** in [`apps/portal-bff/src/openapi/openapi.spec.ts`](apps/portal-bff/src/openapi/openapi.spec.ts) — document shape (openapi version, title, version), both cookie schemes declared, smoke controller route captured in `paths`, production short-circuit (no routes mounted, no `app.use` called), dev mount (JSON at `/api/openapi.json` via the HTTP adapter, Scalar UI at `/api/docs` via `app.use`). - [x] `pnpm nx test portal-bff` — **396 specs pass** (was 391). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Manual dev smoke: `pnpm nx serve portal-bff`, `curl /api/openapi.json | jq .info` returns title + version, open `/api/docs` in 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: - Auto-inject the `X-CSRF-Token` header in Scalar from the `portal_csrf` cookie (custom Scalar config preset). - Promote `@ApiOperation` summaries with multi-line `description`s on the more involved routes (`/api/admin/audit`, `/api/admin/users`). - Annotate DTOs with `@ApiProperty` once the first contract-test consumer arrives — Nest can also pick them up automatically with the `@nestjs/swagger` ts-plugin if we wire it into the Nx build target. Deferred until the spec is consumed by tooling that benefits from the precision.
julien added 1 commit 2026-05-14 20:51:49 +02:00
feat(portal-bff): openapi spec + scalar api reference UI (dev-only)
CI / scan (pull_request) Successful in 2m46s
CI / commits (pull_request) Successful in 2m45s
CI / check (pull_request) Successful in 4m45s
CI / a11y (pull_request) Successful in 1m54s
CI / perf (pull_request) Successful in 5m10s
19001192f0
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).
julien merged commit 1513ad327c into main 2026-05-14 20:52:04 +02:00
julien deleted branch feat/portal-bff-openapi-scalar 2026-05-14 20:52:06 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#143