Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/defaults/development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const config = {
},
swagger: {
enable: true,
// opt-in: serve the unauthenticated /api/spec.json in production-grade envs (public API docs); off by default
publicInProd: false,
},
docs: {
// Grouping primitive for guide sections. See `public.docs.tree.js` JSDoc for full schema details.
Expand Down
8 changes: 6 additions & 2 deletions lib/services/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ const initApiSpec = (app) => {
// Secure-by-default: the API spec surface (/api/spec.json) is UNAUTHENTICATED,
// so it is only mounted in dev-grade envs. Any production-grade env (the
// literal `production` OR a deployment env label) skips it even when
// config.swagger.enable is still truthy — opt-OUT by default in non-dev.
if (config.swagger.enable && !configHelper.isProd()) {
// config.swagger.enable is still truthy — opt-OUT by default in non-dev,
// UNLESS the consumer explicitly opts into serving it publicly via
// config.swagger.publicInProd === true (e.g. a project whose API docs are
// intentionally public). The opt-in narrows the gate, never widens it: enable
// must still be truthy.
if (config.swagger.enable && (!configHelper.isProd() || config.swagger.publicInProd === true)) {
if (!config.files.swagger || config.files.swagger.length === 0) {
logger.warn('[swagger] no swagger files configured — skipping API docs');
return;
Expand Down
23 changes: 23 additions & 0 deletions lib/services/tests/express.docsEnvGate.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,27 @@ describe('express initApiSpec — env gate (spec off in non-dev envs):', () => {
expect(app._routes['/api/spec.json']).toBeUndefined();
expect(app._routes['/api/docs']).toBeUndefined();
});

test('DOES mount the spec in a production-grade env when config.swagger.publicInProd === true', async () => {
// Opt-in: a consumer with intentionally-public API docs sets publicInProd true,
// so the unauthenticated spec is served even under a production-grade env.
process.env.NODE_ENV = 'production';
jest.unstable_mockModule('../../helpers/guides.js', () => ({
default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() },
}));
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { ...baseConfig, swagger: { enable: true, publicInProd: true } },
}));
const { default: expressService } = await import('../express.js');
const app = buildMockApp();
expressService.initApiSpec(app);
expect(typeof app._routes['/api/spec.json']).toBe('function');
// Redoc UI stays decommissioned — the opt-in only re-exposes the JSON spec.
expect(app._routes['/api/docs']).toBeUndefined();
// The opt-in path still merges the spec: the served JSON is the merged OpenAPI doc.
let served = null;
app._routes['/api/spec.json']({}, { json: (body) => { served = body; } });
expect(served.openapi).toBe('3.0.0');
expect(served.info.title).toBe('Test API');
});
});
Loading