From 19001192f0929de31085a8010c977bf9cc301535 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 14 May 2026 20:37:49 +0200 Subject: [PATCH] feat(portal-bff): openapi spec + scalar api reference UI (dev-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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()`; 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). --- apps/portal-bff/jest.config.cts | 21 +-- .../src/admin/admin-audit.controller.ts | 6 + .../src/admin/admin-auth.controller.ts | 10 ++ .../src/admin/admin-users.controller.ts | 6 + apps/portal-bff/src/admin/admin.controller.ts | 4 + apps/portal-bff/src/app/app.controller.ts | 2 + apps/portal-bff/src/auth/auth.controller.ts | 12 ++ .../src/health/health.controller.ts | 3 + apps/portal-bff/src/main.ts | 6 + apps/portal-bff/src/openapi/openapi.spec.ts | 101 ++++++++++++++ apps/portal-bff/src/openapi/openapi.ts | 97 +++++++++++++ package.json | 2 + pnpm-lock.yaml | 130 ++++++++++++++++++ 13 files changed, 391 insertions(+), 9 deletions(-) create mode 100644 apps/portal-bff/src/openapi/openapi.spec.ts create mode 100644 apps/portal-bff/src/openapi/openapi.ts diff --git a/apps/portal-bff/jest.config.cts b/apps/portal-bff/jest.config.cts index a810b06..a5c3465 100644 --- a/apps/portal-bff/jest.config.cts +++ b/apps/portal-bff/jest.config.cts @@ -6,16 +6,19 @@ module.exports = { '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/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@/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//...` and the pnpm-internal real + // path at `node_modules/.pnpm/@/node_modules//...`. + // + // 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', }; diff --git a/apps/portal-bff/src/admin/admin-audit.controller.ts b/apps/portal-bff/src/admin/admin-audit.controller.ts index 2047ec1..73b5107 100644 --- a/apps/portal-bff/src/admin/admin-audit.controller.ts +++ b/apps/portal-bff/src/admin/admin-audit.controller.ts @@ -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 { const page = await this.auditReader.findEvents(filters); diff --git a/apps/portal-bff/src/admin/admin-auth.controller.ts b/apps/portal-bff/src/admin/admin-auth.controller.ts index 518a54c..7de5275 100644 --- a/apps/portal-bff/src/admin/admin-auth.controller.ts +++ b/apps/portal-bff/src/admin/admin-auth.controller.ts @@ -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 { 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 { const user = req.session.user; diff --git a/apps/portal-bff/src/admin/admin-users.controller.ts b/apps/portal-bff/src/admin/admin-users.controller.ts index e0941a8..ff08ec5 100644 --- a/apps/portal-bff/src/admin/admin-users.controller.ts +++ b/apps/portal-bff/src/admin/admin-users.controller.ts @@ -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 { const page = await this.usersReader.findUsers(filters); diff --git a/apps/portal-bff/src/admin/admin.controller.ts b/apps/portal-bff/src/admin/admin.controller.ts index 14fdf8d..f666ddd 100644 --- a/apps/portal-bff/src/admin/admin.controller.ts +++ b/apps/portal-bff/src/admin/admin.controller.ts @@ -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` diff --git a/apps/portal-bff/src/app/app.controller.ts b/apps/portal-bff/src/app/app.controller.ts index aa4a3dd..e2a210f 100644 --- a/apps/portal-bff/src/app/app.controller.ts +++ b/apps/portal-bff/src/app/app.controller.ts @@ -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) {} diff --git a/apps/portal-bff/src/auth/auth.controller.ts b/apps/portal-bff/src/auth/auth.controller.ts index 7bb8cda..ac698cb 100644 --- a/apps/portal-bff/src/auth/auth.controller.ts +++ b/apps/portal-bff/src/auth/auth.controller.ts @@ -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 { 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 { const user = req.session.user; diff --git a/apps/portal-bff/src/health/health.controller.ts b/apps/portal-bff/src/health/health.controller.ts index 7882ce2..f1dbca5 100644 --- a/apps/portal-bff/src/health/health.controller.ts +++ b/apps/portal-bff/src/health/health.controller.ts @@ -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 { diff --git a/apps/portal-bff/src/main.ts b/apps/portal-bff/src/main.ts index 2b3fee9..abbc33b 100644 --- a/apps/portal-bff/src/main.ts +++ b/apps/portal-bff/src/main.ts @@ -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); diff --git a/apps/portal-bff/src/openapi/openapi.spec.ts b/apps/portal-bff/src/openapi/openapi.spec.ts new file mode 100644 index 0000000..c9fc8e1 --- /dev/null +++ b/apps/portal-bff/src/openapi/openapi.spec.ts @@ -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 { + 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(); + }); +}); diff --git a/apps/portal-bff/src/openapi/openapi.ts b/apps/portal-bff/src/openapi/openapi.ts new file mode 100644 index 0000000..cc7b52e --- /dev/null +++ b/apps/portal-bff/src/openapi/openapi.ts @@ -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 //openapi.json` — the raw OpenAPI 3 spec. + * External tools (Bruno, Insomnia, Postman) import from here. + * - `GET //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', + }), + ); +} diff --git a/package.json b/package.json index 2ec2972..3980450 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "@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", @@ -147,6 +148,7 @@ "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66c8d6c..cf91efb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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