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

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).
This commit is contained in:
Julien Gautier
2026-05-14 20:37:49 +02:00
parent e90e88ec45
commit 19001192f0
13 changed files with 391 additions and 9 deletions
+130
View File
@@ -55,6 +55,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.0.0
version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)
'@nestjs/swagger':
specifier: ^11.4.3
version: 11.4.3(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
'@opentelemetry/api':
specifier: ^1.9.1
version: 1.9.1
@@ -109,6 +112,9 @@ importers:
'@prisma/client':
specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
'@scalar/nestjs-api-reference':
specifier: ^1.1.14
version: 1.1.14
axios:
specifier: '>=1.15.2'
version: 1.16.0
@@ -2276,6 +2282,9 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@microsoft/tsdoc@0.16.0':
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
'@modelcontextprotocol/sdk@1.26.0':
resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
engines: {node: '>=18'}
@@ -2582,6 +2591,19 @@ packages:
'@nestjs/websockets':
optional: true
'@nestjs/mapped-types@2.1.1':
resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
class-transformer: ^0.4.0 || ^0.5.0
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/platform-express@11.1.19':
resolution: {integrity: sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==}
peerDependencies:
@@ -2597,6 +2619,23 @@ packages:
prettier:
optional: true
'@nestjs/swagger@11.4.3':
resolution: {integrity: sha512-LR4BuOj+iBFzhGRnNP0OHjmrPXliDEjrmniXtLsfLDIELjkuUXYCTGjZMqgDdOY+QSabeF59LndaDzOOe+vMmw==}
peerDependencies:
'@fastify/static': ^8.0.0 || ^9.0.0
'@nestjs/common': ^11.0.1
'@nestjs/core': ^11.0.1
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
'@fastify/static':
optional: true
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/testing@11.1.19':
resolution: {integrity: sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ==}
peerDependencies:
@@ -4066,6 +4105,25 @@ packages:
webpack-hot-middleware:
optional: true
'@scalar/client-side-rendering@0.1.7':
resolution: {integrity: sha512-IDzjKF93jrOljlvKBsLHXT1FPWgz56jFrMPC+iLihREp1qH8wF92mG8Zpakw8cURkEuw5WijRk0xNBP2moGyuw==}
engines: {node: '>=22'}
'@scalar/helpers@0.6.0':
resolution: {integrity: sha512-pfSamAgBxqFeE8IpEG6uGkHlnPhY1CLeOTttV9+vKQbrBk5b7vvyTsUXv0Hz4kNU1TFrxcTTPE+Akn5S+jlTtQ==}
engines: {node: '>=22'}
'@scalar/nestjs-api-reference@1.1.14':
resolution: {integrity: sha512-Z+vsx//upRzkEv3QQjky+TOmOrBNJpNOJtRh+219SjPL02vTG+sPJlm6HeNJLuFAyP8iagptHqVK0RoUUDsRIw==}
engines: {node: '>=22'}
'@scalar/types@0.9.6':
resolution: {integrity: sha512-UaCQQcscFTJdxZREE8KhUdSJgaDlc44TZbmWcZffs4m1hzqOvEI7lEBS13iBpLq7/cxUXFgyJdecywvNqJ0PkA==}
engines: {node: '>=22'}
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@schematics/angular@13.3.11':
resolution: {integrity: sha512-imKBnKYEse0SBVELZO/753nkpt3eEgpjrYkB+AFWF9YfO/4RGnYXDHoH8CFkzxPH9QQCgNrmsVFNiYGS+P/S1A==}
engines: {node: ^12.20.0 || ^14.15.0 || >=16.10.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@@ -7887,6 +7945,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.11:
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
engines: {node: ^18 || >=20}
hasBin: true
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -9482,6 +9545,9 @@ packages:
engines: {node: '>=16'}
hasBin: true
swagger-ui-dist@5.32.6:
resolution: {integrity: sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -9497,6 +9563,10 @@ packages:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
@@ -9766,6 +9836,10 @@ packages:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
type-fest@5.6.0:
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
engines: {node: '>=20'}
type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@@ -12729,6 +12803,8 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
'@microsoft/tsdoc@0.16.0': {}
'@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.18)
@@ -13085,6 +13161,14 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
'@nestjs/platform-express@11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)':
dependencies:
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -13110,6 +13194,21 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
js-yaml: 4.1.1
lodash: 4.18.1
path-to-regexp: 8.4.2
reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.6
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
'@nestjs/testing@11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-express@11.1.19)':
dependencies:
'@nestjs/common': 11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -14838,6 +14937,25 @@ snapshots:
error-stack-parser: 2.1.4
react-refresh: 0.18.0
'@scalar/client-side-rendering@0.1.7':
dependencies:
'@scalar/types': 0.9.6
'@scalar/helpers@0.6.0': {}
'@scalar/nestjs-api-reference@1.1.14':
dependencies:
'@scalar/client-side-rendering': 0.1.7
'@scalar/types@0.9.6':
dependencies:
'@scalar/helpers': 0.6.0
nanoid: 5.1.11
type-fest: 5.6.0
zod: 4.3.6
'@scarf/scarf@1.4.0': {}
'@schematics/angular@13.3.11(chokidar@5.0.0)':
dependencies:
'@angular-devkit/core': 13.3.11(chokidar@5.0.0)
@@ -19259,6 +19377,8 @@ snapshots:
nanoid@3.3.12: {}
nanoid@5.1.11: {}
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -21149,6 +21269,10 @@ snapshots:
picocolors: 1.1.1
sax: 1.6.0
swagger-ui-dist@5.32.6:
dependencies:
'@scarf/scarf': 1.4.0
symbol-tree@3.2.4: {}
sync-child-process@1.0.2:
@@ -21161,6 +21285,8 @@ snapshots:
dependencies:
'@pkgr/core': 0.2.9
tagged-tag@1.0.0: {}
tailwindcss@4.3.0: {}
tapable@2.3.0: {}
@@ -21443,6 +21569,10 @@ snapshots:
type-fest@4.41.0: {}
type-fest@5.6.0:
dependencies:
tagged-tag: 1.0.0
type-is@1.6.18:
dependencies:
media-typer: 0.3.0