From bff9e16dc0f6a4a95274d24824abab88007c371f Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 16 Jun 2026 15:29:48 +0200 Subject: [PATCH] refactor(config): rename config.swagger -> config.openapi (gates the OpenAPI spec, not Swagger-UI) --- MIGRATIONS.md | 21 ++++++- config/defaults/development.config.js | 6 +- config/defaults/production.config.js | 2 +- config/index.js | 2 +- lib/helpers/config.js | 4 +- lib/services/express.js | 20 +++---- lib/services/tests/express.docs.unit.tests.js | 8 +-- .../tests/express.docsEnvGate.unit.tests.js | 20 +++---- .../tests/express.errorroutes.unit.tests.js | 4 +- .../express.openapi-servers.unit.tests.js | 4 +- modules/core/tests/core.unit.tests.js | 60 +++++++++---------- 11 files changed, 84 insertions(+), 67 deletions(-) diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 9401e6e8d..b1fae563c 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -4,6 +4,23 @@ Breaking changes and upgrade notes for downstream projects. --- +## Config rename: `swagger` → `openapi` (2026-06-16) + +The mis-named `config.swagger` namespace is renamed to `config.openapi`: it gates the OpenAPI JSON spec served at `/api/spec.json`, and there is no Swagger-UI (the Redoc UI was decommissioned earlier). Pure rename, no behavior change. + +### What changed (this repo) + +- `config.swagger` → `config.openapi` (defaults in `config/defaults/development.config.js` + `production.config.js`). +- `config.swagger.publicInProd` → `config.openapi.public` (the `InProd` suffix is dropped — the flag means "serve the spec publicly"; still secure-by-default `public: false`). +- `config.files.swagger` glob key → `config.files.openapi` (`lib/helpers/config.js` + the `fileKeys` filter list in `config/index.js`). +- The gate in `lib/services/express.js` (`initApiSpec`) reads the new keys; internal log prefixes `[swagger]` → `[openapi]`. + +### Action required for downstream projects (`/update-stack`) + +- Rename the `swagger` block to `openapi` in `config/defaults/{project}.config.js` and rename `publicInProd` → `public` if set. No other action — the glob key + gate are devkit-owned stack files that arrive via `/update-stack`. + +--- + ## Referral grant — config-gated `invitation.accepted` listener in billing (2026-06-12) Standard referral reward (#3842, the real tracker behind the old in-code `TODO(#5)` refs). The `invitation.accepted` no-op seam in `billing.init.js` is now the **config-gated grant listener**: on every accepted invite it idempotently credits meter units to the **referrer**'s and **referee**'s organizations on the `BillingExtraBalance` ledger (`kind:'topup'`, `source:'referral'`, keys `referral::referrer|referee`, expiry like pack credits). @@ -1390,13 +1407,13 @@ Config belongs to the module that **semantically owns** the data, even if other | `uploads`, `sharp` | `uploads` | Uploads defines its own processing rules | | `organizations`, `roles`, `roleDescriptions`, `publicDomains` | `organizations` | Orgs defines its own structure | | `repos` | `home` | Home defines its own data sources | -| `app`, `swagger`, `api`, `db`, `log`, `cors`, `cookie`, `mailer`, `seedDB` | global | Pure infrastructure, no module owns them | +| `app`, `openapi`, `api`, `db`, `log`, `cors`, `cookie`, `mailer`, `seedDB` | global | Pure infrastructure, no module owns them | ### File layout ```text config/defaults/ - development.config.js ← infra only (app, swagger, api, db, log, csrf, cors, cookie, mailer, seedDB) + development.config.js ← infra only (app, openapi, api, db, log, csrf, cors, cookie, mailer, seedDB) production.config.js ← production overrides (standalone) test.config.js ← test overrides (standalone) myproject.config.js ← template for downstream projects diff --git a/config/defaults/development.config.js b/config/defaults/development.config.js index a658b50e9..acdd74e87 100644 --- a/config/defaults/development.config.js +++ b/config/defaults/development.config.js @@ -15,10 +15,10 @@ const config = { contact: 'contact@example.com', version: '', // app release version — overridden at build via DEVKIT_NODE_app_version (Layer 4 env override); getConfig falls back to package.json version }, - swagger: { + openapi: { enable: true, - // opt-in: serve the unauthenticated /api/spec.json in production-grade envs (public API docs); off by default - publicInProd: false, + // opt-in: serve the unauthenticated /api/spec.json publicly in production-grade envs (public API docs); off by default + public: false, }, docs: { // Grouping primitive for guide sections. See `public.docs.tree.js` JSDoc for full schema details. diff --git a/config/defaults/production.config.js b/config/defaults/production.config.js index fab586ef3..ba1094646 100644 --- a/config/defaults/production.config.js +++ b/config/defaults/production.config.js @@ -5,7 +5,7 @@ const config = { // Secure-by-default: keep the unauthenticated API docs surface off in production. // The runtime gate in lib/services/express.js (isProd) already prevents mounting // docs in any non-dev env; this flag makes the intent explicit at the config layer. - swagger: { + openapi: { enable: false, }, api: { diff --git a/config/index.js b/config/index.js index c3c9ea625..0a29d4e29 100644 --- a/config/index.js +++ b/config/index.js @@ -158,7 +158,7 @@ const initGlobalConfig = async () => { // Initialize global globbed files config = deepMerge(config, { files: await configHelper.initGlobalConfigFiles(assets) }); // Filter files by module activation (deactivated modules are excluded) - const fileKeys = ['swagger', 'guides', 'mongooseModels', 'preRoutes', 'routes', 'configs', 'policies']; + const fileKeys = ['openapi', 'guides', 'mongooseModels', 'preRoutes', 'routes', 'configs', 'policies']; for (const key of fileKeys) { if (config.files[key]) { config.files[key] = configHelper.filterByActivation(config.files[key], config); diff --git a/lib/helpers/config.js b/lib/helpers/config.js index 54bcd3acf..51ddd53eb 100644 --- a/lib/helpers/config.js +++ b/lib/helpers/config.js @@ -206,7 +206,7 @@ const filterByActivation = (files, config) => files.filter((file) => { /** * Initialize global configuration files by resolving asset glob patterns. * - * Globs each category of files (swagger YAML, markdown guides, mongoose + * Globs each category of files (OpenAPI YAML, markdown guides, mongoose * models, routes, configs, policies) from the provided asset patterns and * returns them as a flat `files` object that is later merged into the * runtime config and filtered by module activation. @@ -216,7 +216,7 @@ const filterByActivation = (files, config) => files.filter((file) => { */ const initGlobalConfigFiles = async (assets) => { const files = {}; // Appending files - files.swagger = await getGlobbedPaths(assets.allYaml); // Setting Globbed module yaml files + files.openapi = await getGlobbedPaths(assets.allYaml); // Setting Globbed module OpenAPI yaml files files.guides = await getGlobbedPaths(assets.allGuides); // Setting Globbed module markdown guide files files.mongooseModels = await getGlobbedPaths(assets.mongooseModels); // Setting Globbed mongoose model files files.preRoutes = await getGlobbedPaths(assets.preRoutes); // Setting Globbed pre-parser route files diff --git a/lib/services/express.js b/lib/services/express.js index bf98109f7..4933762d1 100644 --- a/lib/services/express.js +++ b/lib/services/express.js @@ -48,29 +48,29 @@ 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, + // config.openapi.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 + // config.openapi.public === 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'); + if (config.openapi.enable && (!configHelper.isProd() || config.openapi.public === true)) { + if (!config.files.openapi || config.files.openapi.length === 0) { + logger.warn('[openapi] no OpenAPI spec files configured — skipping API docs'); return; } // Merge all module OpenAPI YAML files into a single spec // Use mergeWith to concatenate arrays (tags, servers, security) instead of merging by index - const contents = config.files.swagger + const contents = config.files.openapi .map((filePath) => { try { const doc = YAML.load(fs.readFileSync(filePath).toString()); if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { - logger.warn(`[swagger] skipping ${filePath}: YAML did not parse to a plain object`); + logger.warn(`[openapi] skipping ${filePath}: YAML did not parse to a plain object`); return null; } return doc; } catch (err) { - throw new Error(`[swagger] failed to load ${filePath}: ${err.message}`, { cause: err }); + throw new Error(`[openapi] failed to load ${filePath}: ${err.message}`, { cause: err }); } }) .filter(Boolean); @@ -84,7 +84,7 @@ const initApiSpec = (app) => { ); if (Object.keys(spec).length === 0) { - logger.warn('[swagger] all YAML files were skipped — no valid spec to serve'); + logger.warn('[openapi] all YAML files were skipped — no valid spec to serve'); return; } @@ -114,7 +114,7 @@ const initApiSpec = (app) => { const guides = guidesHelper.loadGuides(config.files.guides || []); guidesHelper.mergeGuidesIntoSpec(spec, guides); if (guides.length > 0) { - logger.info(`[swagger] merged ${guides.length} markdown guide(s) into API reference`); + logger.info(`[openapi] merged ${guides.length} markdown guide(s) into API reference`); } /** diff --git a/lib/services/tests/express.docs.unit.tests.js b/lib/services/tests/express.docs.unit.tests.js index 9afe60245..ad3dc62d1 100644 --- a/lib/services/tests/express.docs.unit.tests.js +++ b/lib/services/tests/express.docs.unit.tests.js @@ -54,8 +54,8 @@ describe('express initApiSpec — core/doc/index.yml OpenAPI baseline (T13 Fix B }; const baseConfig = { - swagger: { enable: true }, - files: { swagger: ['/fake/core.yaml'], guides: [] }, + openapi: { enable: true }, + files: { openapi: ['/fake/core.yaml'], guides: [] }, app: { title: 'Test API', description: 'Test', url: 'https://example.com' }, domain: 'https://example.com', }; @@ -182,8 +182,8 @@ describe('express initApiSpec — OpenAPI JSON spec endpoint:', () => { }; const baseConfig = { - swagger: { enable: true }, - files: { swagger: ['/fake/swagger.yaml'], guides: ['/fake/modules/home/doc/guides/00-welcome.md'] }, + openapi: { enable: true }, + files: { openapi: ['/fake/openapi.yaml'], guides: ['/fake/modules/home/doc/guides/00-welcome.md'] }, app: { title: 'Test API', description: 'Test', url: 'https://example.com' }, domain: 'https://example.com', }; diff --git a/lib/services/tests/express.docsEnvGate.unit.tests.js b/lib/services/tests/express.docsEnvGate.unit.tests.js index 8d4df53b6..4aafd88d5 100644 --- a/lib/services/tests/express.docsEnvGate.unit.tests.js +++ b/lib/services/tests/express.docsEnvGate.unit.tests.js @@ -8,7 +8,7 @@ import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globa * (/api/spec.json) is mounted ONLY in dev-grade envs (development/test/local). * Under any production-grade env (the literal `production` OR a deployment env * label), the unauthenticated spec surface must NOT be mounted, even if - * config.swagger.enable is still truthy. This closes the env-gate defect where + * config.openapi.enable is still truthy. This closes the env-gate defect where * docs were exposed downstream because the gate keyed off the literal * `production`. The /api/docs Redoc UI was decommissioned, so it must never be * mounted in any env. @@ -23,8 +23,8 @@ describe('express initApiSpec — env gate (spec off in non-dev envs):', () => { }; const baseConfig = { - swagger: { enable: true }, - files: { swagger: ['/fake/swagger.yaml'], guides: [] }, + openapi: { enable: true }, + files: { openapi: ['/fake/openapi.yaml'], guides: [] }, app: { title: 'Test API', description: 'Test', url: 'https://example.com' }, domain: 'https://example.com', }; @@ -74,7 +74,7 @@ describe('express initApiSpec — env gate (spec off in non-dev envs):', () => { }); /** - * Run initApiSpec under a given NODE_ENV with config.swagger.enable left truthy. + * Run initApiSpec under a given NODE_ENV with config.openapi.enable left truthy. * @param {string} env - NODE_ENV value to set before init * @returns {Promise} the registered routes map */ @@ -100,7 +100,7 @@ describe('express initApiSpec — env gate (spec off in non-dev envs):', () => { expect(routes['/api/docs']).toBeUndefined(); }); - test('DOES mount the spec under development (config.swagger.enable === true)', async () => { + test('DOES mount the spec under development (config.openapi.enable === true)', async () => { const routes = await initUnderEnv('development'); expect(typeof routes['/api/spec.json']).toBe('function'); // The Redoc UI is gone — the spec endpoint is the only docs surface. @@ -119,10 +119,10 @@ describe('express initApiSpec — env gate (spec off in non-dev envs):', () => { expect(routes['/api/docs']).toBeUndefined(); }); - test('does NOT mount the spec in dev when config.swagger.enable is false', async () => { + test('does NOT mount the spec in dev when config.openapi.enable is false', async () => { process.env.NODE_ENV = 'development'; jest.unstable_mockModule('../../../config/index.js', () => ({ - default: { ...baseConfig, swagger: { enable: false } }, + default: { ...baseConfig, openapi: { enable: false } }, })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); @@ -131,15 +131,15 @@ describe('express initApiSpec — env gate (spec off in non-dev envs):', () => { 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, + test('DOES mount the spec in a production-grade env when config.openapi.public === true', async () => { + // Opt-in: a consumer with intentionally-public API docs sets public 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 } }, + default: { ...baseConfig, openapi: { enable: true, public: true } }, })); const { default: expressService } = await import('../express.js'); const app = buildMockApp(); diff --git a/lib/services/tests/express.errorroutes.unit.tests.js b/lib/services/tests/express.errorroutes.unit.tests.js index 97063fd1f..7f073577d 100644 --- a/lib/services/tests/express.errorroutes.unit.tests.js +++ b/lib/services/tests/express.errorroutes.unit.tests.js @@ -33,8 +33,8 @@ describe('express initErrorRoutes — prod error leak guard (#3726):', () => { csrf: {}, cors: { origin: [], credentials: false, optionsSuccessStatus: 200 }, trust: { proxy: false }, - swagger: { enable: false }, - files: { routes: [], configs: [], policies: [], preRoutes: [], swagger: [], guides: [] }, + openapi: { enable: false }, + files: { routes: [], configs: [], policies: [], preRoutes: [], openapi: [], guides: [] }, analytics: { posthog: { autoCapture: false } }, docs: {}, }, diff --git a/lib/services/tests/express.openapi-servers.unit.tests.js b/lib/services/tests/express.openapi-servers.unit.tests.js index 22ec11221..f86175d06 100644 --- a/lib/services/tests/express.openapi-servers.unit.tests.js +++ b/lib/services/tests/express.openapi-servers.unit.tests.js @@ -12,9 +12,9 @@ describe('lib/services/express — OpenAPI servers.url derivation:', () => { jest.unstable_mockModule('../../../config/index.js', () => ({ default: { domain: 'http://localhost:3000', - swagger: { enable: false }, + openapi: { enable: false }, app: { title: 'test', description: 'd' }, - files: { swagger: [], guides: [], routes: [], configs: [], policies: [], preRoutes: [] }, + files: { openapi: [], guides: [], routes: [], configs: [], policies: [], preRoutes: [] }, bodyParser: {}, cors: { origin: [], credentials: false }, csrf: {}, diff --git a/modules/core/tests/core.unit.tests.js b/modules/core/tests/core.unit.tests.js index a5d2e0706..36eb44827 100644 --- a/modules/core/tests/core.unit.tests.js +++ b/modules/core/tests/core.unit.tests.js @@ -492,22 +492,22 @@ describe('Core unit tests:', () => { describe('Express service', () => { describe('initApiSpec', () => { - let originalSwagger; + let originalOpenapi; let originalFiles; beforeEach(() => { - originalSwagger = config.swagger; + originalOpenapi = config.openapi; originalFiles = config.files; }); afterEach(() => { - config.swagger = originalSwagger; + config.openapi = originalOpenapi; config.files = originalFiles; }); - it('should register /api/spec.json but NOT the decommissioned /api/docs Redoc UI when swagger is enabled', () => { - config.swagger = { enable: true }; - config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')] }; + it('should register /api/spec.json but NOT the decommissioned /api/docs Redoc UI when the OpenAPI spec is enabled', () => { + config.openapi = { enable: true }; + config.files = { ...config.files, openapi:[path.join(process.cwd(), 'modules/core/doc/index.yml')] }; const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; @@ -519,8 +519,8 @@ describe('Core unit tests:', () => { }); it('should serve merged spec as JSON from /api/spec.json handler', () => { - config.swagger = { enable: true }; - config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')] }; + config.openapi = { enable: true }; + config.files = { ...config.files, openapi:[path.join(process.cwd(), 'modules/core/doc/index.yml')] }; const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; @@ -535,10 +535,10 @@ describe('Core unit tests:', () => { }); it('should merge multiple module YAML files and include paths from each', () => { - config.swagger = { enable: true }; + config.openapi = { enable: true }; config.files = { ...config.files, - swagger: [ + openapi: [ path.join(process.cwd(), 'modules/core/doc/index.yml'), path.join(process.cwd(), 'modules/tasks/doc/tasks.yml'), ], @@ -565,8 +565,8 @@ describe('Core unit tests:', () => { ); }); - it('should not register routes when swagger is disabled', () => { - config.swagger = { enable: false }; + it('should not register routes when the OpenAPI spec is disabled', () => { + config.openapi = { enable: false }; const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; @@ -575,9 +575,9 @@ describe('Core unit tests:', () => { expect(mockUse).not.toHaveBeenCalled(); }); - it('should warn and skip registration when swagger file list is empty', () => { - config.swagger = { enable: true }; - config.files = { ...config.files, swagger: [] }; + it('should warn and skip registration when the OpenAPI spec file list is empty', () => { + config.openapi = { enable: true }; + config.files = { ...config.files, openapi:[] }; const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; @@ -587,10 +587,10 @@ describe('Core unit tests:', () => { }); it('should throw with file path context when a YAML file fails to load', () => { - config.swagger = { enable: true }; - config.files = { ...config.files, swagger: ['/nonexistent/path/bad.yml'] }; + config.openapi = { enable: true }; + config.files = { ...config.files, openapi:['/nonexistent/path/bad.yml'] }; const mockApp = { get: jest.fn(), use: jest.fn() }; - expect(() => expressService.initApiSpec(mockApp)).toThrow('[swagger] failed to load /nonexistent/path/bad.yml'); + expect(() => expressService.initApiSpec(mockApp)).toThrow('[openapi] failed to load /nonexistent/path/bad.yml'); }); it('should skip YAML files that do not parse to a plain object and still register routes from valid ones', async () => { @@ -600,8 +600,8 @@ describe('Core unit tests:', () => { fsMod.writeFileSync(tmpFile, 'just a string value\n'); try { const validFile = path.join(process.cwd(), 'modules/core/doc/index.yml'); - config.swagger = { enable: true }; - config.files = { ...config.files, swagger: [tmpFile, validFile] }; + config.openapi = { enable: true }; + config.files = { ...config.files, openapi:[tmpFile, validFile] }; const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse }; @@ -619,10 +619,10 @@ describe('Core unit tests:', () => { const tmpGuide = path.join(tmpDir, 'sample.md'); fsMod.writeFileSync(tmpGuide, '# Ignored Heading\n\nSample guide body for unit test.\n'); try { - config.swagger = { enable: true }; + config.openapi = { enable: true }; config.files = { ...config.files, - swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')], + openapi: [path.join(process.cwd(), 'modules/core/doc/index.yml')], guides: [tmpGuide], }; const mockGet = jest.fn(); @@ -645,8 +645,8 @@ describe('Core unit tests:', () => { }); it('should inject info.title, info.description and servers from config', () => { - config.swagger = { enable: true }; - config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')], guides: [] }; + config.openapi = { enable: true }; + config.files = { ...config.files, openapi:[path.join(process.cwd(), 'modules/core/doc/index.yml')], guides: [] }; const originalDomain = config.domain; config.domain = 'https://api.example.test'; try { @@ -667,8 +667,8 @@ describe('Core unit tests:', () => { }); it('should fall back to localhost in servers when config.domain is empty', () => { - config.swagger = { enable: true }; - config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')], guides: [] }; + config.openapi = { enable: true }; + config.files = { ...config.files, openapi:[path.join(process.cwd(), 'modules/core/doc/index.yml')], guides: [] }; const originalDomain = config.domain; config.domain = ''; try { @@ -693,10 +693,10 @@ describe('Core unit tests:', () => { const tmpGuide = path.join(tmpDir, 'welcome.md'); fsMod.writeFileSync(tmpGuide, 'Welcome guide content for description merge.\n'); try { - config.swagger = { enable: true }; + config.openapi = { enable: true }; config.files = { ...config.files, - swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')], + openapi: [path.join(process.cwd(), 'modules/core/doc/index.yml')], guides: [tmpGuide], }; const mockGet = jest.fn(); @@ -722,8 +722,8 @@ describe('Core unit tests:', () => { const tmpFile = path.join('/tmp', `test-scalar-only-${Date.now()}.yml`); fsMod.writeFileSync(tmpFile, 'just a string value\n'); try { - config.swagger = { enable: true }; - config.files = { ...config.files, swagger: [tmpFile] }; + config.openapi = { enable: true }; + config.files = { ...config.files, openapi:[tmpFile] }; const mockGet = jest.fn(); const mockUse = jest.fn(); const mockApp = { get: mockGet, use: mockUse };