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
21 changes: 19 additions & 2 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<invitationId>:referrer|referee`, expiry like pack credits).
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions config/defaults/development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion config/defaults/production.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions lib/helpers/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
20 changes: 10 additions & 10 deletions lib/services/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down Expand Up @@ -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`);
}

/**
Expand Down
8 changes: 4 additions & 4 deletions lib/services/tests/express.docs.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down Expand Up @@ -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',
};
Expand Down
20 changes: 10 additions & 10 deletions lib/services/tests/express.docsEnvGate.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
};
Expand Down Expand Up @@ -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<object>} the registered routes map
*/
Expand All @@ -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.
Expand All @@ -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();
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions lib/services/tests/express.errorroutes.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
},
Expand Down
4 changes: 2 additions & 2 deletions lib/services/tests/express.openapi-servers.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
Loading
Loading