Files
apf_portal/package.json
T
julien 1513ad327c
CI / scan (push) Successful in 2m5s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m33s
CI / a11y (push) Successful in 1m22s
CI / perf (push) Successful in 3m34s
feat(portal-bff): openapi spec + scalar api reference UI (dev-only) (#143)
## 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.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #143
2026-05-14 20:52:03 +02:00

172 lines
5.5 KiB
JSON

{
"name": "apf-portal",
"version": "0.0.0",
"license": "MIT",
"packageManager": "pnpm@10.33.4",
"scripts": {
"prepare": "husky",
"ci:check": "pnpm exec nx affected -t format:check lint test build",
"ci:audit": "pnpm audit --audit-level=moderate",
"ci:commits": "pnpm exec commitlint --from ${COMMIT_LINT_FROM:-origin/main} --to HEAD --verbose",
"ci:gzip-budgets": "node scripts/check-gzip-budgets.mjs",
"ci:perf": "pnpm exec nx build portal-shell --configuration=production && pnpm ci:gzip-budgets && pnpm exec lhci autorun --config=./lighthouserc.js"
},
"private": true,
"lint-staged": {
"!(pnpm-lock).{ts,tsx,js,jsx,html,scss,css,md,json,yml,yaml}": [
"prettier --write"
]
},
"pnpm": {
"onlyBuiltDependencies": [
"@nestjs/core",
"@parcel/watcher",
"@prisma/client",
"@prisma/engines",
"@swc/core",
"esbuild",
"less",
"lmdb",
"msgpackr-extract",
"nx",
"prisma",
"unrs-resolver"
],
"overrides": {
"axios@<1.15.2": ">=1.15.2",
"@angular-devkit/core>ajv@<8.18.0": ">=8.18.0",
"brace-expansion@<5.0.5": ">=5.0.5",
"follow-redirects@<1.16.0": ">=1.16.0",
"ip-address@<10.1.1": ">=10.1.1",
"protobufjs@<8.0.2": ">=8.0.2",
"tmp@<0.2.4": ">=0.2.4",
"yaml@<2.8.3": ">=2.8.3",
"@types/express": "^5.0.6"
}
},
"prisma": {
"schema": "apps/portal-bff/prisma/schema.prisma"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "~2.5.0",
"@analogjs/vitest-angular": "~2.5.0",
"@angular-devkit/core": "~21.2.0",
"@angular-devkit/schematics": "~21.2.0",
"@angular/build": "~21.2.0",
"@angular/cli": "~21.2.0",
"@angular/compiler-cli": "~21.2.0",
"@angular/language-service": "~21.2.0",
"@commitlint/cli": "^20.5.3",
"@commitlint/config-conventional": "^20.5.3",
"@eslint/js": "^9.8.0",
"@lhci/cli": "^0.15.1",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@nx/angular": "^22.7.1",
"@nx/devkit": "22.7.1",
"@nx/eslint": "^22.7.1",
"@nx/eslint-plugin": "22.7.1",
"@nx/jest": "22.7.1",
"@nx/js": "22.7.1",
"@nx/nest": "^22.7.1",
"@nx/node": "22.7.1",
"@nx/playwright": "22.7.1",
"@nx/vite": "^22.7.1",
"@nx/vitest": "22.7.1",
"@nx/web": "22.7.1",
"@nx/webpack": "22.7.1",
"@oxc-project/runtime": "^0.129.0",
"@playwright/test": "^1.36.0",
"@schematics/angular": "~21.2.0",
"@swc-node/register": "1.11.1",
"@swc/core": "1.15.33",
"@swc/helpers": "0.5.21",
"@tailwindcss/postcss": "^4.2.4",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/jest": "^30.0.0",
"@types/node": "24.12.4",
"@typescript-eslint/utils": "^8.40.0",
"@vitest/coverage-v8": "~4.1.0",
"@vitest/ui": "~4.1.0",
"angular-eslint": "^21.2.0",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-playwright": "^1.6.2",
"husky": "^9.1.7",
"jest": "^30.0.2",
"jest-environment-node": "^30.0.2",
"jest-util": "^30.0.2",
"jsdom": "^29.0.0",
"jsonc-eslint-parser": "^2.1.0",
"lint-staged": "^17.0.0",
"nx": "22.7.1",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.12",
"prettier": "^3.8.1",
"prisma": "^6.19.3",
"tailwindcss": "^4.2.4",
"ts-jest": "^29.4.0",
"ts-node": "10.9.2",
"tslib": "^2.3.0",
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vite": "^8.0.0",
"vitest": "^4.0.8",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@angular/cdk": "~21.2.10",
"@angular/common": "~21.2.0",
"@angular/compiler": "~21.2.0",
"@angular/core": "~21.2.0",
"@angular/forms": "~21.2.0",
"@angular/localize": "~21.2.12",
"@angular/platform-browser": "~21.2.0",
"@angular/router": "~21.2.0",
"@azure/msal-node": "^5.2.1",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.3",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.217.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.217.0",
"@opentelemetry/instrumentation": "^0.217.0",
"@opentelemetry/instrumentation-document-load": "^0.62.0",
"@opentelemetry/instrumentation-express": "^0.65.0",
"@opentelemetry/instrumentation-fetch": "^0.217.0",
"@opentelemetry/instrumentation-http": "^0.217.0",
"@opentelemetry/instrumentation-ioredis": "^0.65.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.63.0",
"@opentelemetry/instrumentation-pg": "^0.69.0",
"@opentelemetry/instrumentation-pino": "^0.63.0",
"@opentelemetry/instrumentation-user-interaction": "^0.61.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-node": "^0.217.0",
"@opentelemetry/sdk-trace-web": "^2.7.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@prisma/client": "^6.19.3",
"@scalar/nestjs-api-reference": "^1.1.14",
"axios": "^1.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"connect-redis": "^9.0.0",
"cookie-parser": "^1.4.7",
"express-rate-limit": "^8.5.1",
"express-session": "^1.19.0",
"helmet": "^8.1.0",
"ioredis": "^5.10.1",
"jose": "^6.2.3",
"lucide-angular": "^1.0.0",
"nestjs-cls": "^6.2.0",
"nestjs-pino": "^4.6.1",
"nestjs-prisma": "^0.27.0",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "~7.8.0"
}
}