feat(portal-bff): openapi spec + scalar api reference UI (dev-only)
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:
@@ -6,16 +6,19 @@ module.exports = {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
// `jose` ships ESM-only; without an explicit transform exception
|
||||
// ts-jest skips node_modules and Jest fails parsing the import
|
||||
// statements. Listed by name rather than a broad pattern so a
|
||||
// future ESM-only dep is a conscious addition.
|
||||
// Several deps ship ESM-only; without an explicit transform
|
||||
// exception ts-jest skips node_modules and Jest fails parsing the
|
||||
// import statements. Listed by name rather than a broad pattern
|
||||
// so each new ESM-only dep is a conscious addition.
|
||||
//
|
||||
// The pattern walks pnpm's deep layout: any path under
|
||||
// `/node_modules/` is ignored UNLESS the path also contains
|
||||
// `jose` somewhere — that catches both the hoisted symlink at
|
||||
// `node_modules/jose/...` and the pnpm-internal real path at
|
||||
// `node_modules/.pnpm/jose@<version>/node_modules/jose/...`.
|
||||
transformIgnorePatterns: ['/node_modules/(?!.*jose)'],
|
||||
// `/node_modules/` is ignored UNLESS the path also contains one
|
||||
// of the listed package fragments — catches both the hoisted
|
||||
// symlink at `node_modules/<pkg>/...` and the pnpm-internal real
|
||||
// path at `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/...`.
|
||||
//
|
||||
// jose — JOSE primitives (signed-assertion strategy, PR #138).
|
||||
// @scalar/ — Scalar API reference UI + transitive ESM bits.
|
||||
transformIgnorePatterns: ['/node_modules/(?!.*(jose|@scalar/))'],
|
||||
coverageDirectory: '../../coverage/apps/portal-bff',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user