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
+12 -9
View File
@@ -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',
};
@@ -1,4 +1,5 @@
import { Controller, Get, Query, Req } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { AdminAuditQueryDto } from './audit-query.dto';
@@ -27,6 +28,8 @@ import { RequireAdmin } from './require-admin.decorator';
* with a tighter freshness here without touching this code's
* shape — that's exactly why the decorator was designed-in.
*/
@ApiTags('admin (audit log)')
@ApiCookieAuth('portal_admin_session')
@Controller('admin/audit')
@RequireAdmin()
export class AdminAuditController {
@@ -35,6 +38,9 @@ export class AdminAuditController {
private readonly audit: AuditWriter,
) {}
@ApiOperation({
summary: 'Paginated audit-log query — emits `admin.audit.query` on every call',
})
@Get()
async list(@Req() req: Request, @Query() filters: AdminAuditQueryDto): Promise<AdminAuditPage> {
const page = await this.auditReader.findEvents(filters);
@@ -1,4 +1,5 @@
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { AuditWriter } from '../audit/audit.service';
@@ -37,6 +38,7 @@ import { adminSessionCookieName } from '../session/admin-session-cookie';
* the session that those guards check against. Sub-controllers
* (admin business routes) layer the guards on top.
*/
@ApiTags('auth (admin portal)')
@Controller('admin/auth')
export class AdminAuthController {
constructor(
@@ -47,6 +49,7 @@ export class AdminAuthController {
private readonly sessionEstablisher: SessionEstablisher,
) {}
@ApiOperation({ summary: 'Start the admin OIDC Auth Code + PKCE flow (302 to Entra)' })
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
@@ -56,6 +59,9 @@ export class AdminAuthController {
res.redirect(302, authUrl);
}
@ApiOperation({
summary: 'Entra callback for the admin surface — establishes the admin session',
})
@Get('callback')
async callback(
@Req() req: Request,
@@ -127,6 +133,8 @@ export class AdminAuthController {
}
}
@ApiOperation({ summary: 'Current admin session payload (includes `roles`)' })
@ApiCookieAuth('portal_admin_session')
@Get('me')
me(@Req() req: Request, @Res() res: Response): void {
const user = req.session.user;
@@ -148,6 +156,8 @@ export class AdminAuthController {
});
}
@ApiOperation({ summary: 'RP-initiated admin logout — destroys admin session' })
@ApiCookieAuth('portal_admin_session')
@Get('logout')
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
const user = req.session.user;
@@ -1,4 +1,5 @@
import { Controller, Get, Query, Req } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AuditWriter } from '../audit/audit.service';
import { AdminUsersReader, type AdminUsersPage } from './admin-users-reader.service';
@@ -15,6 +16,8 @@ import { AdminUsersQueryDto } from './users-query.dto';
* (filters → service → audit emit → response) follows the same
* pattern across admin modules.
*/
@ApiTags('admin (user directory)')
@ApiCookieAuth('portal_admin_session')
@Controller('admin/users')
@RequireAdmin()
export class AdminUsersController {
@@ -23,6 +26,9 @@ export class AdminUsersController {
private readonly audit: AuditWriter,
) {}
@ApiOperation({
summary: 'Paginated user-directory query — emits `admin.users.query` on every call',
})
@Get()
async list(@Req() req: Request, @Query() filters: AdminUsersQueryDto): Promise<AdminUsersPage> {
const page = await this.usersReader.findUsers(filters);
@@ -1,4 +1,5 @@
import { Controller, Get, Req } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { RequireAdmin } from './require-admin.decorator';
@@ -17,9 +18,12 @@ import { RequireAdmin } from './require-admin.decorator';
* session → 200 + the public user payload) before any real admin
* route ships.
*/
@ApiTags('admin (self-test)')
@ApiCookieAuth('portal_admin_session')
@Controller('admin')
@RequireAdmin()
export class AdminController {
@ApiOperation({ summary: 'Self-test endpoint — current admin session payload + roles' })
@Get('me')
me(@Req() req: Request) {
// `AdminRoleGuard` has already established that `req.session.user`
@@ -1,6 +1,8 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AppService } from './app.service';
@ApiTags('app (scaffolding)')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@@ -1,4 +1,5 @@
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { AuditWriter } from '../audit/audit.service';
@@ -24,6 +25,7 @@ import { SessionEstablisher } from './session-establisher.service';
* redirects to Entra's RP-initiated logout endpoint so the user is
* signed out at the IdP too.
*/
@ApiTags('auth (user portal)')
@Controller('auth')
export class AuthController {
constructor(
@@ -44,6 +46,9 @@ export class AuthController {
* server-side state. That keeps `/login` stateless and friendly
* to horizontal scaling once the BFF runs more than one instance.
*/
@ApiOperation({
summary: 'Start the OIDC Auth Code + PKCE flow (302 to Entra)',
})
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
@@ -72,6 +77,9 @@ export class AuthController {
* specific message. The pre-auth cookie is cleared on every
* exit path: it is single-use by design.
*/
@ApiOperation({
summary: 'Entra callback — completes the OIDC exchange, establishes the session, 302 to SPA',
})
@Get('callback')
async callback(
@Req() req: Request,
@@ -146,6 +154,8 @@ export class AuthController {
* the browser's session cookie was either absent, expired, or
* pointed at a Redis key that no longer exists.
*/
@ApiOperation({ summary: 'Current session payload (curated public view)' })
@ApiCookieAuth('portal_session')
@Get('me')
me(@Req() req: Request, @Res() res: Response): void {
const user = req.session.user;
@@ -176,6 +186,8 @@ export class AuthController {
* SameSite=Lax (subresource requests don't carry the cookie); a
* dedicated CSRF middleware lands with phase-2 security.
*/
@ApiOperation({ summary: 'RP-initiated logout — destroys session + 302 to Entra logout' })
@ApiCookieAuth('portal_session')
@Get('logout')
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
const user = req.session.user;
@@ -1,4 +1,5 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
/**
* Liveness endpoint. Returns 200 OK with minimal process metadata.
@@ -10,10 +11,12 @@ import { Controller, Get } from '@nestjs/common';
* dependency that has a readiness story to tell (Postgres pool warm,
* Redis connection established, etc.).
*/
@ApiTags('health')
@Controller('health')
export class HealthController {
private readonly startedAt = Date.now();
@ApiOperation({ summary: 'Liveness probe (no backing-service checks)' })
@Get()
liveness(): { status: 'ok'; uptimeSeconds: number; service: string; version: string } {
return {
+6
View File
@@ -22,6 +22,7 @@ import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-
import { CSRF_MIDDLEWARE } from './security/security.token';
import { StructuredErrorFilter } from './security/structured-error.filter';
import { JwksPublisher } from './downstream/jwks.publisher';
import { setupOpenApi } from './openapi/openapi';
import type { NextFunction, Request, Response } from 'express';
import {
ADMIN_SESSION_MIDDLEWARE,
@@ -213,6 +214,11 @@ async function bootstrap() {
res.json(jwksPublisher.jwks());
});
// OpenAPI spec + Scalar API Reference UI, dev-only. Mounted at
// `/${globalPrefix}/openapi.json` and `/${globalPrefix}/docs`
// respectively — the function short-circuits in production.
setupOpenApi(app, globalPrefix);
const port = process.env['PORT'] ?? 3000;
await app.listen(port);
+101
View File
@@ -0,0 +1,101 @@
import { Controller, Get } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import {
ADMIN_SESSION_COOKIE_AUTH,
USER_SESSION_COOKIE_AUTH,
buildOpenApiDocument,
setupOpenApi,
} from './openapi';
// Minimal controller to feed SwaggerModule.createDocument — the
// document builder needs a non-empty route table to produce a spec
// it considers valid.
@Controller('smoke')
class SmokeController {
@Get()
hello(): { ok: boolean } {
return { ok: true };
}
}
async function bootApp(): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
controllers: [SmokeController],
}).compile();
return moduleRef.createNestApplication({ logger: false });
}
describe('buildOpenApiDocument', () => {
it('returns a well-shaped OpenAPI 3 document with title + version', async () => {
const app = await bootApp();
const doc = buildOpenApiDocument(app);
expect(doc.openapi).toMatch(/^3\./);
expect(doc.info.title).toBe('APF Portal BFF API');
expect(doc.info.version).toBe('0.0.0');
await app.close();
});
it('declares both cookie-auth schemes (user + admin)', async () => {
const app = await bootApp();
const doc = buildOpenApiDocument(app);
const schemes = doc.components?.securitySchemes;
expect(schemes?.[USER_SESSION_COOKIE_AUTH]).toMatchObject({
type: 'apiKey',
in: 'cookie',
name: USER_SESSION_COOKIE_AUTH,
});
expect(schemes?.[ADMIN_SESSION_COOKIE_AUTH]).toMatchObject({
type: 'apiKey',
in: 'cookie',
name: ADMIN_SESSION_COOKIE_AUTH,
});
await app.close();
});
it('captures the smoke controller route in the paths map', async () => {
const app = await bootApp();
const doc = buildOpenApiDocument(app);
expect(doc.paths['/smoke']).toBeDefined();
expect(doc.paths['/smoke']?.get).toBeDefined();
await app.close();
});
});
describe('setupOpenApi', () => {
const originalNodeEnv = process.env['NODE_ENV'];
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env['NODE_ENV'];
} else {
process.env['NODE_ENV'] = originalNodeEnv;
}
});
it('short-circuits in production — no routes mounted, no spec built', async () => {
process.env['NODE_ENV'] = 'production';
const httpAdapter = { get: jest.fn() };
const app = {
getHttpAdapter: jest.fn().mockReturnValue(httpAdapter),
use: jest.fn(),
} as unknown as INestApplication;
setupOpenApi(app, 'api');
expect(httpAdapter.get).not.toHaveBeenCalled();
expect(app.use).not.toHaveBeenCalled();
});
it('mounts the JSON spec + Scalar UI in dev', async () => {
process.env['NODE_ENV'] = 'development';
const app = await bootApp();
const httpGetSpy = jest.spyOn(app.getHttpAdapter(), 'get');
const useSpy = jest.spyOn(app, 'use');
setupOpenApi(app, 'api');
// JSON spec at /api/openapi.json (via the HTTP adapter directly,
// bypassing Nest's router).
expect(httpGetSpy).toHaveBeenCalledWith('/api/openapi.json', expect.any(Function));
// Scalar UI mounted under /api/docs.
expect(useSpy).toHaveBeenCalledWith('/api/docs', expect.any(Function));
await app.close();
});
});
+97
View File
@@ -0,0 +1,97 @@
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger';
import { apiReference } from '@scalar/nestjs-api-reference';
import type { Request, Response } from 'express';
/**
* Cookie names declared as security schemes. Match the production
* cookie names — Scalar's "Try it" panel offers to send them and
* the SPA-side cookies already flow on same-origin calls.
*/
export const USER_SESSION_COOKIE_AUTH = 'portal_session';
export const ADMIN_SESSION_COOKIE_AUTH = 'portal_admin_session';
/**
* Builds the OpenAPI document from the running Nest application by
* traversing the controller/DTO graph (Nest's standard
* `SwaggerModule.createDocument`). Two cookie-auth schemes are
* registered up front so endpoints can reference them via
* `@ApiCookieAuth(name)`:
*
* - `portal_session` — user-portal surface (ADR-0009).
* - `portal_admin_session` — admin-portal surface (ADR-0020).
*
* No `@ApiBearerAuth` — the BFF never exposes a bearer-auth surface
* (per ADR-0009 the SPA never holds tokens; downstream OBO tokens
* are server-side only per ADR-0014).
*/
export function buildOpenApiDocument(app: INestApplication): OpenAPIObject {
const config = new DocumentBuilder()
.setTitle('APF Portal BFF API')
.setDescription(
'OpenAPI specification of the portal-bff HTTP surface. Auto-generated from ' +
'Nest controllers + class-validator DTOs. Mutating routes (POST / PUT / PATCH / ' +
'DELETE) require the `X-CSRF-Token` header per ADR-0009 §"Double-submit CSRF" — ' +
'set it manually in Scalar to the value of the `portal_csrf` cookie when ' +
'exercising those routes from this page.',
)
.setVersion('0.0.0')
.addCookieAuth(
USER_SESSION_COOKIE_AUTH,
{ type: 'apiKey', in: 'cookie', name: USER_SESSION_COOKIE_AUTH },
USER_SESSION_COOKIE_AUTH,
)
.addCookieAuth(
ADMIN_SESSION_COOKIE_AUTH,
{ type: 'apiKey', in: 'cookie', name: ADMIN_SESSION_COOKIE_AUTH },
ADMIN_SESSION_COOKIE_AUTH,
)
.build();
return SwaggerModule.createDocument(app, config);
}
/**
* Mounts the OpenAPI spec + the Scalar API Reference UI on the BFF.
*
* Routes:
* - `GET /<globalPrefix>/openapi.json` — the raw OpenAPI 3 spec.
* External tools (Bruno, Insomnia, Postman) import from here.
* - `GET /<globalPrefix>/docs` — Scalar API Reference UI page,
* loads the spec from the JSON endpoint at render time.
*
* **Dev-only by default.** Production deployments do not need a
* docs surface served from the BFF itself — and exposing it would
* give an attacker a curated map of every authenticated endpoint
* + every DTO shape. Skipped entirely when `NODE_ENV === 'production'`.
* If a future ops use-case wants the spec in prod (e.g. an internal
* gateway), we can add an explicit `OPENAPI_PUBLISH=true` env knob
* — opt-in, not default-on.
*/
export function setupOpenApi(app: INestApplication, globalPrefix: string): void {
if (process.env['NODE_ENV'] === 'production') {
return;
}
const document = buildOpenApiDocument(app);
// Serve the JSON spec via a plain Express handler. Bypasses Nest's
// router because the URL is a static asset — no DTO, no guard,
// no middleware interaction worth threading through.
const httpAdapter = app.getHttpAdapter();
httpAdapter.get(`/${globalPrefix}/openapi.json`, (_req: Request, res: Response) => {
res.json(document);
});
// Scalar API Reference UI. Same Express-level mount — its handler
// emits a static HTML page that fetches the spec from the URL
// above at render time. Lock the operationTitleSource to `path`
// so endpoints sort + display by their URL (more useful for an
// admin-API surface where the path encodes hierarchy).
app.use(
`/${globalPrefix}/docs`,
apiReference({
url: `/${globalPrefix}/openapi.json`,
operationTitleSource: 'path',
}),
);
}