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',
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user