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' }],
|
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
// `jose` ships ESM-only; without an explicit transform exception
|
// Several deps ship ESM-only; without an explicit transform
|
||||||
// ts-jest skips node_modules and Jest fails parsing the import
|
// exception ts-jest skips node_modules and Jest fails parsing the
|
||||||
// statements. Listed by name rather than a broad pattern so a
|
// import statements. Listed by name rather than a broad pattern
|
||||||
// future ESM-only dep is a conscious addition.
|
// so each new ESM-only dep is a conscious addition.
|
||||||
//
|
//
|
||||||
// The pattern walks pnpm's deep layout: any path under
|
// The pattern walks pnpm's deep layout: any path under
|
||||||
// `/node_modules/` is ignored UNLESS the path also contains
|
// `/node_modules/` is ignored UNLESS the path also contains one
|
||||||
// `jose` somewhere — that catches both the hoisted symlink at
|
// of the listed package fragments — catches both the hoisted
|
||||||
// `node_modules/jose/...` and the pnpm-internal real path at
|
// symlink at `node_modules/<pkg>/...` and the pnpm-internal real
|
||||||
// `node_modules/.pnpm/jose@<version>/node_modules/jose/...`.
|
// path at `node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/...`.
|
||||||
transformIgnorePatterns: ['/node_modules/(?!.*jose)'],
|
//
|
||||||
|
// 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',
|
coverageDirectory: '../../coverage/apps/portal-bff',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Query, Req } from '@nestjs/common';
|
import { Controller, Get, Query, Req } from '@nestjs/common';
|
||||||
|
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
import { AdminAuditQueryDto } from './audit-query.dto';
|
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
|
* with a tighter freshness here without touching this code's
|
||||||
* shape — that's exactly why the decorator was designed-in.
|
* shape — that's exactly why the decorator was designed-in.
|
||||||
*/
|
*/
|
||||||
|
@ApiTags('admin (audit log)')
|
||||||
|
@ApiCookieAuth('portal_admin_session')
|
||||||
@Controller('admin/audit')
|
@Controller('admin/audit')
|
||||||
@RequireAdmin()
|
@RequireAdmin()
|
||||||
export class AdminAuditController {
|
export class AdminAuditController {
|
||||||
@@ -35,6 +38,9 @@ export class AdminAuditController {
|
|||||||
private readonly audit: AuditWriter,
|
private readonly audit: AuditWriter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Paginated audit-log query — emits `admin.audit.query` on every call',
|
||||||
|
})
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Req() req: Request, @Query() filters: AdminAuditQueryDto): Promise<AdminAuditPage> {
|
async list(@Req() req: Request, @Query() filters: AdminAuditQueryDto): Promise<AdminAuditPage> {
|
||||||
const page = await this.auditReader.findEvents(filters);
|
const page = await this.auditReader.findEvents(filters);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
|
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 type { Request, Response } from 'express';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
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
|
* the session that those guards check against. Sub-controllers
|
||||||
* (admin business routes) layer the guards on top.
|
* (admin business routes) layer the guards on top.
|
||||||
*/
|
*/
|
||||||
|
@ApiTags('auth (admin portal)')
|
||||||
@Controller('admin/auth')
|
@Controller('admin/auth')
|
||||||
export class AdminAuthController {
|
export class AdminAuthController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -47,6 +49,7 @@ export class AdminAuthController {
|
|||||||
private readonly sessionEstablisher: SessionEstablisher,
|
private readonly sessionEstablisher: SessionEstablisher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Start the admin OIDC Auth Code + PKCE flow (302 to Entra)' })
|
||||||
@Get('login')
|
@Get('login')
|
||||||
async login(@Res() res: Response): Promise<void> {
|
async login(@Res() res: Response): Promise<void> {
|
||||||
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
||||||
@@ -56,6 +59,9 @@ export class AdminAuthController {
|
|||||||
res.redirect(302, authUrl);
|
res.redirect(302, authUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Entra callback for the admin surface — establishes the admin session',
|
||||||
|
})
|
||||||
@Get('callback')
|
@Get('callback')
|
||||||
async callback(
|
async callback(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@@ -127,6 +133,8 @@ export class AdminAuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Current admin session payload (includes `roles`)' })
|
||||||
|
@ApiCookieAuth('portal_admin_session')
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@Req() req: Request, @Res() res: Response): void {
|
me(@Req() req: Request, @Res() res: Response): void {
|
||||||
const user = req.session.user;
|
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')
|
@Get('logout')
|
||||||
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Query, Req } from '@nestjs/common';
|
import { Controller, Get, Query, Req } from '@nestjs/common';
|
||||||
|
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
import { AuditWriter } from '../audit/audit.service';
|
||||||
import { AdminUsersReader, type AdminUsersPage } from './admin-users-reader.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
|
* (filters → service → audit emit → response) follows the same
|
||||||
* pattern across admin modules.
|
* pattern across admin modules.
|
||||||
*/
|
*/
|
||||||
|
@ApiTags('admin (user directory)')
|
||||||
|
@ApiCookieAuth('portal_admin_session')
|
||||||
@Controller('admin/users')
|
@Controller('admin/users')
|
||||||
@RequireAdmin()
|
@RequireAdmin()
|
||||||
export class AdminUsersController {
|
export class AdminUsersController {
|
||||||
@@ -23,6 +26,9 @@ export class AdminUsersController {
|
|||||||
private readonly audit: AuditWriter,
|
private readonly audit: AuditWriter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Paginated user-directory query — emits `admin.users.query` on every call',
|
||||||
|
})
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Req() req: Request, @Query() filters: AdminUsersQueryDto): Promise<AdminUsersPage> {
|
async list(@Req() req: Request, @Query() filters: AdminUsersQueryDto): Promise<AdminUsersPage> {
|
||||||
const page = await this.usersReader.findUsers(filters);
|
const page = await this.usersReader.findUsers(filters);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Req } from '@nestjs/common';
|
import { Controller, Get, Req } from '@nestjs/common';
|
||||||
|
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { RequireAdmin } from './require-admin.decorator';
|
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
|
* session → 200 + the public user payload) before any real admin
|
||||||
* route ships.
|
* route ships.
|
||||||
*/
|
*/
|
||||||
|
@ApiTags('admin (self-test)')
|
||||||
|
@ApiCookieAuth('portal_admin_session')
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@RequireAdmin()
|
@RequireAdmin()
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
|
@ApiOperation({ summary: 'Self-test endpoint — current admin session payload + roles' })
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@Req() req: Request) {
|
me(@Req() req: Request) {
|
||||||
// `AdminRoleGuard` has already established that `req.session.user`
|
// `AdminRoleGuard` has already established that `req.session.user`
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@ApiTags('app (scaffolding)')
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
|
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 type { Request, Response } from 'express';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { AuditWriter } from '../audit/audit.service';
|
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
|
* redirects to Entra's RP-initiated logout endpoint so the user is
|
||||||
* signed out at the IdP too.
|
* signed out at the IdP too.
|
||||||
*/
|
*/
|
||||||
|
@ApiTags('auth (user portal)')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -44,6 +46,9 @@ export class AuthController {
|
|||||||
* server-side state. That keeps `/login` stateless and friendly
|
* server-side state. That keeps `/login` stateless and friendly
|
||||||
* to horizontal scaling once the BFF runs more than one instance.
|
* 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')
|
@Get('login')
|
||||||
async login(@Res() res: Response): Promise<void> {
|
async login(@Res() res: Response): Promise<void> {
|
||||||
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
|
||||||
@@ -72,6 +77,9 @@ export class AuthController {
|
|||||||
* specific message. The pre-auth cookie is cleared on every
|
* specific message. The pre-auth cookie is cleared on every
|
||||||
* exit path: it is single-use by design.
|
* exit path: it is single-use by design.
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Entra callback — completes the OIDC exchange, establishes the session, 302 to SPA',
|
||||||
|
})
|
||||||
@Get('callback')
|
@Get('callback')
|
||||||
async callback(
|
async callback(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@@ -146,6 +154,8 @@ export class AuthController {
|
|||||||
* the browser's session cookie was either absent, expired, or
|
* the browser's session cookie was either absent, expired, or
|
||||||
* pointed at a Redis key that no longer exists.
|
* pointed at a Redis key that no longer exists.
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'Current session payload (curated public view)' })
|
||||||
|
@ApiCookieAuth('portal_session')
|
||||||
@Get('me')
|
@Get('me')
|
||||||
me(@Req() req: Request, @Res() res: Response): void {
|
me(@Req() req: Request, @Res() res: Response): void {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -176,6 +186,8 @@ export class AuthController {
|
|||||||
* SameSite=Lax (subresource requests don't carry the cookie); a
|
* SameSite=Lax (subresource requests don't carry the cookie); a
|
||||||
* dedicated CSRF middleware lands with phase-2 security.
|
* dedicated CSRF middleware lands with phase-2 security.
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ summary: 'RP-initiated logout — destroys session + 302 to Entra logout' })
|
||||||
|
@ApiCookieAuth('portal_session')
|
||||||
@Get('logout')
|
@Get('logout')
|
||||||
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liveness endpoint. Returns 200 OK with minimal process metadata.
|
* 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,
|
* dependency that has a readiness story to tell (Postgres pool warm,
|
||||||
* Redis connection established, etc.).
|
* Redis connection established, etc.).
|
||||||
*/
|
*/
|
||||||
|
@ApiTags('health')
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
private readonly startedAt = Date.now();
|
private readonly startedAt = Date.now();
|
||||||
|
|
||||||
|
@ApiOperation({ summary: 'Liveness probe (no backing-service checks)' })
|
||||||
@Get()
|
@Get()
|
||||||
liveness(): { status: 'ok'; uptimeSeconds: number; service: string; version: string } {
|
liveness(): { status: 'ok'; uptimeSeconds: number; service: string; version: string } {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { createRateLimitMiddleware, readRateLimitConfig } from './security/rate-
|
|||||||
import { CSRF_MIDDLEWARE } from './security/security.token';
|
import { CSRF_MIDDLEWARE } from './security/security.token';
|
||||||
import { StructuredErrorFilter } from './security/structured-error.filter';
|
import { StructuredErrorFilter } from './security/structured-error.filter';
|
||||||
import { JwksPublisher } from './downstream/jwks.publisher';
|
import { JwksPublisher } from './downstream/jwks.publisher';
|
||||||
|
import { setupOpenApi } from './openapi/openapi';
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import {
|
import {
|
||||||
ADMIN_SESSION_MIDDLEWARE,
|
ADMIN_SESSION_MIDDLEWARE,
|
||||||
@@ -213,6 +214,11 @@ async function bootstrap() {
|
|||||||
res.json(jwksPublisher.jwks());
|
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;
|
const port = process.env['PORT'] ?? 3000;
|
||||||
await app.listen(port);
|
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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@
|
|||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/swagger": "^11.4.3",
|
||||||
"@opentelemetry/api": "^1.9.1",
|
"@opentelemetry/api": "^1.9.1",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.217.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.217.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.217.0",
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.217.0",
|
||||||
@@ -147,6 +148,7 @@
|
|||||||
"@opentelemetry/sdk-trace-web": "^2.7.1",
|
"@opentelemetry/sdk-trace-web": "^2.7.1",
|
||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
|
"@scalar/nestjs-api-reference": "^1.1.14",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
|||||||
Generated
+130
@@ -55,6 +55,9 @@ importers:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.0
|
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)
|
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':
|
'@opentelemetry/api':
|
||||||
specifier: ^1.9.1
|
specifier: ^1.9.1
|
||||||
version: 1.9.1
|
version: 1.9.1
|
||||||
@@ -109,6 +112,9 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^6.19.3
|
specifier: ^6.19.3
|
||||||
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.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:
|
axios:
|
||||||
specifier: '>=1.15.2'
|
specifier: '>=1.15.2'
|
||||||
version: 1.16.0
|
version: 1.16.0
|
||||||
@@ -2276,6 +2282,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0':
|
||||||
|
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.26.0':
|
'@modelcontextprotocol/sdk@1.26.0':
|
||||||
resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
|
resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2582,6 +2591,19 @@ packages:
|
|||||||
'@nestjs/websockets':
|
'@nestjs/websockets':
|
||||||
optional: true
|
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':
|
'@nestjs/platform-express@11.1.19':
|
||||||
resolution: {integrity: sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==}
|
resolution: {integrity: sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2597,6 +2619,23 @@ packages:
|
|||||||
prettier:
|
prettier:
|
||||||
optional: true
|
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':
|
'@nestjs/testing@11.1.19':
|
||||||
resolution: {integrity: sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ==}
|
resolution: {integrity: sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4066,6 +4105,25 @@ packages:
|
|||||||
webpack-hot-middleware:
|
webpack-hot-middleware:
|
||||||
optional: true
|
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':
|
'@schematics/angular@13.3.11':
|
||||||
resolution: {integrity: sha512-imKBnKYEse0SBVELZO/753nkpt3eEgpjrYkB+AFWF9YfO/4RGnYXDHoH8CFkzxPH9QQCgNrmsVFNiYGS+P/S1A==}
|
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'}
|
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}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanoid@5.1.11:
|
||||||
|
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
|
||||||
|
engines: {node: ^18 || >=20}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
napi-postinstall@0.3.4:
|
napi-postinstall@0.3.4:
|
||||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
@@ -9482,6 +9545,9 @@ packages:
|
|||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
swagger-ui-dist@5.32.6:
|
||||||
|
resolution: {integrity: sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==}
|
||||||
|
|
||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
@@ -9497,6 +9563,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
|
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
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:
|
tailwindcss@4.3.0:
|
||||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||||
|
|
||||||
@@ -9766,6 +9836,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
type-fest@5.6.0:
|
||||||
|
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -12729,6 +12803,8 @@ snapshots:
|
|||||||
|
|
||||||
'@lukeed/csprng@1.1.0': {}
|
'@lukeed/csprng@1.1.0': {}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0': {}
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)':
|
'@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hono/node-server': 1.19.14(hono@4.12.18)
|
'@hono/node-server': 1.19.14(hono@4.12.18)
|
||||||
@@ -13085,6 +13161,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
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/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)':
|
'@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:
|
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)
|
'@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:
|
transitivePeerDependencies:
|
||||||
- chokidar
|
- 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)':
|
'@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:
|
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)
|
'@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
|
error-stack-parser: 2.1.4
|
||||||
react-refresh: 0.18.0
|
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)':
|
'@schematics/angular@13.3.11(chokidar@5.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/core': 13.3.11(chokidar@5.0.0)
|
'@angular-devkit/core': 13.3.11(chokidar@5.0.0)
|
||||||
@@ -19259,6 +19377,8 @@ snapshots:
|
|||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
|
nanoid@5.1.11: {}
|
||||||
|
|
||||||
napi-postinstall@0.3.4: {}
|
napi-postinstall@0.3.4: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
@@ -21149,6 +21269,10 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sax: 1.6.0
|
sax: 1.6.0
|
||||||
|
|
||||||
|
swagger-ui-dist@5.32.6:
|
||||||
|
dependencies:
|
||||||
|
'@scarf/scarf': 1.4.0
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
sync-child-process@1.0.2:
|
sync-child-process@1.0.2:
|
||||||
@@ -21161,6 +21285,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@pkgr/core': 0.2.9
|
'@pkgr/core': 0.2.9
|
||||||
|
|
||||||
|
tagged-tag@1.0.0: {}
|
||||||
|
|
||||||
tailwindcss@4.3.0: {}
|
tailwindcss@4.3.0: {}
|
||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
@@ -21443,6 +21569,10 @@ snapshots:
|
|||||||
|
|
||||||
type-fest@4.41.0: {}
|
type-fest@4.41.0: {}
|
||||||
|
|
||||||
|
type-fest@5.6.0:
|
||||||
|
dependencies:
|
||||||
|
tagged-tag: 1.0.0
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
media-typer: 0.3.0
|
media-typer: 0.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user