diff --git a/.changeset/discovery-node-env-unset-production.md b/.changeset/discovery-node-env-unset-production.md new file mode 100644 index 0000000000..67b1e9e2a1 --- /dev/null +++ b/.changeset/discovery-node-env-unset-production.md @@ -0,0 +1,44 @@ +--- +"@objectstack/runtime": minor +"@objectstack/cli": minor +--- + +fix(runtime,cli): 未设置 `NODE_ENV` 时 `/discovery` 不再自称 `development`,统一按 `production` 解读 (#5673) + +同一个「宿主没有设置 `NODE_ENV`」的事实,仓里原本有两套相反的默认:`os start` 未设时强制 +`NODE_ENV='production'`(`start.ts:248`),`os serve` 与 `os doctor` 按 +`NODE_ENV || 'production'` 解析 `.env*` 级联,而 `/discovery` 的 `environment` 字段 +直接把缺省读成 `development`。 + +**为什么这个方向的错报是危险的那个。** `environment` 是**机器可读面**上的字段,客户端 +拿它回答「我在不在生产环境」,并可能据此不显示生产警示、放宽破坏性操作的二次确认。一个 +忘记设 `NODE_ENV` 的真实生产部署,过去会拿到 `development` —— 两种错法里代价更高的那种。 +按 maintainer 2026-08-06 裁定,缺省统一收敛到保守值 `production`。 + +**迁移说明(行为变更,请对照自己的部署方式读)** + +- **生产部署忘设 `NODE_ENV`**:`/discovery` 的 `environment` 由 `development` 变为 + `production`。这正是本次修复的目标 —— 报的是实情,不需要任何动作。 +- **本地开发**:不受影响。`os dev` 会 spawn `serve --dev`,而 `serve` 在 `--dev` 且 + `NODE_ENV` 未设时就地设 `NODE_ENV='development'`(`serve.ts:490-491`),所以 + `pnpm dev` / `pnpm dev:showcase` / `dev:crm` / `dev:todo` 链路上 `NODE_ENV` 早已是 + 显式的 `development`,`/discovery` 仍报 `development`。没有任何脚本因此改动。 +- **需要 `development` 却不走 `os dev` 的场景**(裸 `os serve`、以库形式内嵌运行时、 + 自建容器入口):现在必须显式 `NODE_ENV=development`。这是本次唯一需要动手的一类。 +- **已设置的合法拼法一律不变**:`production`/`prod` → `production`, + `staging`/`sandbox` → `sandbox`,`development`/`dev`/`test` → `development`。 +- **无法识别的拼法处置不回退**:`qa`、`preview`、`uat` 这类**设了但认不出**的值仍然 + 降级为 `development`,#4828 的「绝不凭猜测宣称 production」保持原样。缺省不是猜测, + 是宿主选择不说 —— 两条是不同的规则,本次只动前者。 + +**`os doctor` 新增一行提示。** `NODE_ENV` 未设时报 +`NODE_ENV Not set — this environment is being treated as production`(warning,不影响 +退出码),`--verbose` 展开显式设置的两条命令。已设置的环境完全没有这一行,报告与从前 +逐字一致。统一默认让缺省变得**安全**,但也让「疏忽」和「有意的生产部署」变得无法区分; +这一行是唯一能把两者分开的地方。 + +**已知残留(已另开 #5936 跟进,本次不动)。** `/discovery` 有两个生产者。本次改的是 +`@objectstack/runtime` 的 `HttpDispatcher.getDiscoveryInfo()`;经 `@objectstack/rest` +暴露的 `MetadataProtocol.getDiscovery()`(`packages/metadata-protocol`)把真实缺省原样 +递给共享映射函数,该函数对缺省仍返回 `development`。裁定把落点限定在 runtime 侧、把 +`packages/metadata-protocol` 标为跨域文件面,所以此处如实记录而非顺手绕过。 diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 165d15a09c..dc3c97423c 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -175,7 +175,30 @@ than advertised verbatim (#4828): | `staging` | `sandbox` — pre-production and production-like | | `development`, `dev` | `development` | | `test` | `development` — an ephemeral developer-class run | -| unset / anything else | `development` — never claims production on a guess | +| **unset** (absent, or `NODE_ENV=`) | `production` — the conservative reading of "the host did not say" (#5673) | +| anything else | `development` — never claims production on a guess | + +The last two rows answer two different questions and were one row until #5673. **Unset is +not a spelling**, it is the absence of one, and the rest of the platform already read that +absence as production: `os start` forces `NODE_ENV=production` when it is unset, and both +`os serve` and `os doctor` resolve the `.env*` cascade for `NODE_ENV || production`. This +field is machine-readable — a client uses it to decide whether it is talking to production +— so a real production deployment whose operator forgot the variable must not be told +`development`. **An unrecognised spelling** (`qa`, `preview`, `uat`) is a different case: it +is a guess, and this field never claims production on a guess. + +Local development is unaffected: `os dev` runs `serve --dev`, which sets +`NODE_ENV=development` in-process before the runtime loads. Anything that boots the runtime +*without* `os dev` — a bare `os serve`, an embedded host, a hand-written container entry +point — must now set `NODE_ENV=development` explicitly to keep being advertised as such. + + +**The `@objectstack/rest`-served `/api/v1/discovery` document still reads an unset +`NODE_ENV` as `development`.** #5673 landed on the dispatcher producer only; the second +producer is tracked in [#5936](https://github.com/objectstack-ai/objectstack/issues/5936). +Until it lands, the two documents agree on every *set* value and differ only when nothing +is set. + **Retired in protocol 17 (#4828):** this document used to carry a top-level `features` diff --git a/packages/cli/src/commands/doctor-node-env-default.test.ts b/packages/cli/src/commands/doctor-node-env-default.test.ts new file mode 100644 index 0000000000..829a602816 --- /dev/null +++ b/packages/cli/src/commands/doctor-node-env-default.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os doctor`'s unset-`NODE_ENV` row (#5673). + * + * ── What #5673 was ─────────────────────────────────────────────────────── + * + * One fact — "the operator never set `NODE_ENV`" — had two opposite readings in + * this repo. `os start` forced `production` (`start.ts:248`), `os serve` and + * `doctorNodeEnv()` derived `NODE_ENV || 'production'`, and the `/discovery` + * `environment` field advertised `development`. The maintainer's 2026-08-06 + * ruling unified them on `production` — the conservative answer, because a + * client reads `environment` to decide whether it is talking to production and + * the dangerous direction of error is claiming `development` on a real one. + * + * Unifying the readings makes the default SAFE. It does not make it VISIBLE: + * afterwards an oversight and a deliberate production deployment produce + * byte-identical reports. The second half of the ruling — this file's subject — + * asked doctor to say the default out loud instead of leaving it documented. + * + * ── What this file pins, and what it deliberately does not ─────────────── + * + * It pins the ROW: that it exists exactly when the variable is unset, that it + * says both halves of the sentence ("treated as production" + "set it + * explicitly"), and that it is a `warning` rather than an `error`. The severity + * is load-bearing and not cosmetic: doctor's display loop derives `hasErrors` + * from this field and `hasErrors` is what calls `process.exit(1)`. An unset + * `NODE_ENV` must not fail anyone's health check. + * + * It does NOT re-pin `doctorNodeEnv()`'s own derivation — that is + * `doctor-env-provenance.test.ts`'s subject and was already correct before + * #5673 (it is the alignment TARGET, not a thing this issue changed). What is + * pinned here instead is the CORRESPONDENCE: the row appears exactly when + * `doctorNodeEnv()` fell back to its default, so the row cannot start claiming + * a default that was not taken. + * + * The `/discovery` half of the same ruling lives in + * `packages/runtime/src/discovery-schema-conformance.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import Doctor, { nodeEnvCheck, doctorNodeEnv } from './doctor.js'; + +/** `packages/cli` — the oclif root the real command is loaded against below. */ +const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +/** + * `chalk` may or may not emit SGR codes depending on TTY detection. + * + * The escape is written as `\x1b`, never as the byte itself: one raw control + * character makes grep treat the whole file as binary, and a test file no + * `git grep` can find stops being maintained (#4890 / #5157). + */ +const SGR = /\x1b\[[0-9;]*m/g; +const plain = (s: string) => s.replace(SGR, ''); + +describe('[#5673] nodeEnvCheck — the row, and when it exists', () => { + it('produces a finding when NODE_ENV is unset', () => { + const finding = nodeEnvCheck({} as NodeJS.ProcessEnv); + expect(finding).toBeDefined(); + expect(finding!.name).toBe('NODE_ENV'); + }); + + it('produces NOTHING for every environment that set the variable', () => { + for (const value of ['production', 'development', 'test', 'staging', 'sandbox', 'qa']) { + expect(nodeEnvCheck({ NODE_ENV: value } as NodeJS.ProcessEnv), value).toBeUndefined(); + } + }); + + it("treats `NODE_ENV=` as unset — the same collapse doctorNodeEnv() makes", () => { + // `doctorNodeEnv({ NODE_ENV: '' })` is already `production` (pinned in + // doctor-env-provenance.test.ts). If this check disagreed, doctor would + // resolve the cascade for a default it then refused to mention. + expect(nodeEnvCheck({ NODE_ENV: '' } as NodeJS.ProcessEnv)).toBeDefined(); + }); + + it('appears exactly when doctorNodeEnv() fell back to its default', () => { + // The correspondence, not a restatement of either function. The row claims + // "a default was taken"; this is the assertion that the claim is true for + // every input, including the ones where NODE_ENV is set to the default's own + // value (`production` — set explicitly, so no default was taken, so no row). + const cases: Array = [ + {}, + { NODE_ENV: '' }, + { NODE_ENV: 'production' }, + { NODE_ENV: 'development' }, + { NODE_ENV: 'test' }, + ] as NodeJS.ProcessEnv[]; + + for (const env of cases) { + const defaulted = !env.NODE_ENV; + expect(nodeEnvCheck(env) !== undefined, JSON.stringify(env)).toBe(defaulted); + // …and when it was taken, the value taken really is `production`. + if (defaulted) expect(doctorNodeEnv(env)).toBe('production'); + } + }); + + it('is a WARNING — the severity that leaves doctor exiting 0', () => { + // `status` is what doctor's display loop turns into `hasErrors` / + // `hasWarnings`, and `hasErrors` is the only path to `process.exit(1)`. + // Nothing here is broken: the environment starts, in the mode the row names. + expect(nodeEnvCheck({} as NodeJS.ProcessEnv)!.status).toBe('warning'); + }); + + it('says BOTH halves: what is happening now, and what to do about it', () => { + const finding = nodeEnvCheck({} as NodeJS.ProcessEnv)!; + const text = plain(`${finding.message}\n${finding.fix ?? ''}`); + + // Half one — the fact. Without this the row is a nag with no content. + expect(finding.message).toContain('production'); + expect(text).toMatch(/not set/i); + + // Half two — the way out, spelled as the two commands an operator can type. + expect(text).toContain('NODE_ENV=production'); + expect(text).toContain('NODE_ENV=development'); + + // …and the one thing a reader would otherwise get wrong: this variable + // cannot be supplied by a `.env*` file, because it selects which of them + // load. Doctor's whole environment block is about `.env*` provenance, so + // omitting this invites exactly the wrong fix. + expect(text).toContain('.env*'); + }); +}); + +describe('[#5673] os doctor, end to end — the row reaches the report', () => { + /** + * `node_modules/` exists in the temp cwd on purpose — without it doctor's + * `Dependencies` check is itself an `error` and exits 1 on its own, which + * would make these assertions pass (or fail) for a reason having nothing to + * do with this change. Same trap PR #5390 wrote down. + */ + let tmp: string; + let cwdSpy: ReturnType; + let savedNodeEnv: string | undefined; + + beforeEach(() => { + savedNodeEnv = process.env.NODE_ENV; + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'os-doctor-5673-')); + fs.mkdirSync(path.join(tmp, 'node_modules')); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp); + }); + + afterEach(() => { + if (savedNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = savedNodeEnv; + cwdSpy.mockRestore(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + async function runDoctor(argv: string[] = []): Promise<{ out: string; exitCode: number | undefined }> { + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { + logs.push(a.join(' ')); + }); + let exitCode: number | undefined; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCode = code; + throw new Error(`__PROCESS_EXIT__:${code}`); + }) as never); + + try { + await Doctor.run(argv, { root: CLI_ROOT }); + } catch (err) { + if (!(err instanceof Error) || !err.message.startsWith('__PROCESS_EXIT__')) throw err; + } finally { + logSpy.mockRestore(); + exitSpy.mockRestore(); + } + return { out: plain(logs.join('\n')), exitCode }; + } + + // A real `Doctor.run()` takes seconds (it shells out to `git --version`, + // walks the workspace and loads config), so every case here carries an + // explicit timeout instead of racing vitest's 5s default. + const E2E_TIMEOUT = 60_000; + + it('prints the row when NODE_ENV is unset, and stays exit 0', async () => { + delete process.env.NODE_ENV; + + const run = await runDoctor(); + + expect(run.out).toContain('NODE_ENV'); + expect(run.out).toContain('treated as production'); + // A warning, so the run still calls the environment functional and never + // reaches `process.exit(1)`. + expect(run.exitCode).toBeUndefined(); + expect(run.out).toContain('Environment is functional'); + }, E2E_TIMEOUT); + + // Both directions, because "no row" has to hold for the environment that + // agrees with the default as well as for the one that contradicts it: the row + // reports that a DEFAULT was taken, not that the mode is production. + it.each(['development', 'production'])( + 'says nothing about NODE_ENV once the operator set it (NODE_ENV=%s)', + async (value) => { + process.env.NODE_ENV = value; + + const run = await runDoctor(); + + // The whole sentence is absent, not merely softened: a configured + // environment's report is what it was before #5673. + expect(run.out).not.toContain('treated as production'); + expect(run.exitCode).toBeUndefined(); + }, + E2E_TIMEOUT, + ); + + it('routes through the shared renderer — `fix` detail appears only under --verbose', async () => { + delete process.env.NODE_ENV; + + // #5403's rule: a warning's detail is optional reading, an error's is not. + // This row is a warning, so its `fix` must be hidden until asked for — the + // proof that it went through `renderHealthCheckResult` rather than growing + // a second, flagless format of its own. + const quiet = await runDoctor(); + expect(quiet.out).not.toContain('NODE_ENV=development'); + + const verbose = await runDoctor(['--verbose']); + expect(verbose.out).toContain('NODE_ENV=development'); + expect(verbose.out).toContain('NODE_ENV=production'); + }, E2E_TIMEOUT); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 4ed26843c2..4f3da9bee0 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -159,6 +159,70 @@ export function doctorNodeEnv(env: NodeJS.ProcessEnv = process.env): string { return env.NODE_ENV || 'production'; } +/** + * Say out loud that `NODE_ENV` is unset — and that the whole stack is therefore + * treating this environment as **production** (#5673). + * + * `undefined` when the variable is set, so a configured environment gets no row + * at all. That is the same shape the tenancy-posture finding has: a value doctor + * is happy with is not a finding, and doctor's output for every explicitly + * configured environment is unchanged by this addition. + * + * ── Why the unset case deserves a row ──────────────────────────────────── + * + * `production` is the conservative default and, since #5673, every reader + * agrees on it: `os start` forces `NODE_ENV='production'` when unset + * (`start.ts:248`), `os serve` resolves its `.env*` cascade for + * `NODE_ENV || 'production'` (`serve.ts:532-533`), {@link doctorNodeEnv} is the + * same expression, and the `/discovery` `environment` field now advertises + * `production` too (`packages/runtime/src/http-dispatcher.ts`). + * + * Agreement is what makes the default SAFE. It is not what makes it VISIBLE. + * An operator who never set the variable cannot tell an intended production + * deployment from an oversight, and those two want opposite follow-ups — one is + * finished, the other is a local shell about to be told it is production. The + * maintainer's 2026-08-06 ruling on #5673 asked for exactly this: the default + * state should be loud, not merely documented. + * + * A `warning`, never an `error`. Nothing is broken: the environment starts, and + * it starts in the mode this row names. `error` is what turns doctor's summary + * into `process.exit(1)`, and an unset `NODE_ENV` must not fail a health check. + * + * ── Deliberately NOT in `DOCTOR_ENV_INPUTS`, and not read through the overlay ─ + * + * That list is for variables whose value doctor resolves through the `.env*` + * cascade. `NODE_ENV` is the one variable that cannot come from a file: it + * SELECTS the cascade (`.env.production` vs `.env.development`), so `os serve` + * reads it from the process before any file is loaded and a `NODE_ENV=` line + * inside a `.env` never reaches this decision. Attributing it to a file would + * report something the runtime does not do — see {@link doctorNodeEnv}'s note. + * + * "Unset" here is `!env.NODE_ENV`, character for character the condition under + * which {@link doctorNodeEnv} falls back to its default. The row therefore + * appears exactly when the default was taken, which is the only claim it makes. + */ +export function nodeEnvCheck(env: NodeJS.ProcessEnv = process.env): HealthCheckResult | undefined { + if (env.NODE_ENV) return undefined; + + return { + name: 'NODE_ENV', + status: 'warning', + message: 'Not set — this environment is being treated as production', + fix: + 'Set it explicitly so the mode is a decision rather than a default:\n' + + ' • production deployment → NODE_ENV=production (what `os start` already forces)\n' + + ' • local development → NODE_ENV=development (what `os dev` already sets)\n' + + ' Unset reads as production everywhere: `os serve` and `os doctor` resolve the\n' + + ' `.env*` cascade for node_env=production, and the /discovery `environment` field\n' + + ' advertises "production" (#5673). That is the safe direction — a client asking\n' + + ' "am I talking to production?" is never told "development" by an omission — but\n' + + ' it also makes an oversight look identical to a deliberate production deployment,\n' + + ' and this row is the only place the difference is visible.\n' + + ' NODE_ENV cannot be supplied by a `.env*` file: it SELECTS which of those files\n' + + ' load, so it is read from the process before any of them.', + }; +} + /** * Read — without loading — the `.env*` files `os serve` would load from `cwd`. * @@ -1560,6 +1624,16 @@ export default class Doctor extends Command { // change every such verdict has four possible sources. results.push(environmentSourcesCheck(dotenvReading)); + // #5673 — the mode the row above was resolved FOR, when nobody chose it. + // Placed immediately after the sources row because it answers the question + // that row raises: `node_env=production` appears there whether the operator + // set NODE_ENV or not, and only this row tells the two apart. Only the unset + // case produces a row; a configured environment's report is unchanged. + const nodeEnvFinding = nodeEnvCheck(); + if (nodeEnvFinding) { + results.push(nodeEnvFinding); + } + // #5382 — the posture verdict resolved at the top of `run()`, reported here // among the other environment facts. Only an unrecognized value produces a // row: a valid posture is not a finding, and doctor's output for every diff --git a/packages/runtime/src/discovery-schema-conformance.test.ts b/packages/runtime/src/discovery-schema-conformance.test.ts index 3dd104b5c5..429a87cc3c 100644 --- a/packages/runtime/src/discovery-schema-conformance.test.ts +++ b/packages/runtime/src/discovery-schema-conformance.test.ts @@ -272,6 +272,50 @@ describe('[#4828] getDiscoveryInfo() conforms to DiscoverySchema', () => { // …and the whole body still satisfies the schema with that value in place. expect(DiscoverySchema.safeParse(info).success).toBe(true); }); + + // [#5673] The pin for this issue, and the reason it is driven through the + // REAL producer rather than through `resolveDiscoveryEnvironment` alone: + // the UNSET default is decided at THIS call site (`getEnv`'s second + // argument), so a green mapper test in `packages/spec` cannot see it. The + // whole setup is deleting the variable — that is precisely the state of a + // production deployment whose operator never set it. + // + // Reverse verification, direction predicted BEFORE running: restore the old + // `getEnv('NODE_ENV', 'development')` and these two cases go RED (they read + // `development`), while every `it.each` row above stays green — the old + // default was only ever consulted when NODE_ENV was absent, so nothing that + // sets it can detect the change. Measured both ways. + it.each([ + ['unset', undefined], + // `getEnv` collapses `''` to its default (`process.env[key] || default`), + // so `NODE_ENV=` is the same absence as never exporting it — and the same + // absence `doctorNodeEnv()` and `os serve` already read as production. + ['empty', ''], + ])('NODE_ENV %s advertises production — never development (#5673)', async (_label, raw) => { + if (raw === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = raw; + + const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); + + expect(info.environment).toBe('production'); + expect(DiscoverySchema.safeParse(info).success).toBe(true); + }); + + // [#5673] The other half, stated as the invariant it is: absence claims + // production, a spelling this repo does not recognise never does. These are + // two different rules and #5673 deliberately moved only the first — #4828's + // "never CLAIM production on a guess" is untouched, and this case is the + // guard against a later simplification collapsing them back into one. + it.each(['qa', 'preview', 'uat', 'nonsense'])( + 'NODE_ENV=%s is an unrecognised spelling — still development, never production (#4828)', + async (raw) => { + process.env.NODE_ENV = raw; + + const info: any = await dispatcher.getDiscoveryInfo('/api/v1'); + + expect(info.environment).toBe('development'); + }, + ); }); it('emits the canonical `name`, never the deprecated `apiName` alias', async () => { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index aa5f500780..711922d078 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1291,9 +1291,44 @@ export class HttpDispatcher { // `getEnv('NODE_ENV', 'development')` raw — so `NODE_ENV=test` (what // vitest sets) or `staging` advertised a value outside the declared // enum on a machine-readable surface. The mapping table and the - // reasoning per row live with the enum, in `@objectstack/spec/api`, - // so both discovery producers answer identically. - environment: resolveDiscoveryEnvironment(getEnv('NODE_ENV', 'development')), + // reasoning per row live with the enum, in `@objectstack/spec/api`. + // + // [#5673] The DEFAULT — what this producer says when the host set no + // `NODE_ENV` at all — flipped from `development` to `production` per + // the maintainer's 2026-08-06 ruling. Two facts made the old default + // the wrong one: + // + // • Every other reader of the same absence already said + // `production`. `os start` forces `NODE_ENV='production'` when + // unset (`packages/cli/src/commands/start.ts:248`), `os serve` + // resolves its `.env*` cascade for `NODE_ENV || 'production'` + // (`serve.ts:532-533`), and `os doctor` derives the identical + // expression (`doctor.ts` `doctorNodeEnv()`). Discovery was the + // one surface reading that absence the other way. + // • `environment` is a MACHINE-READABLE field: a client reads it to + // answer "am I talking to production?" and may skip production + // warnings or loosen a destructive action's confirmation on the + // answer. Of the two ways to be wrong here, claiming + // `development` on a real production deployment whose operator + // forgot the variable is the dangerous one. + // + // #4828's rule is untouched and is a DIFFERENT rule: a value that IS + // set but is not a spelling this repo recognises (`qa`, `preview`) + // still degrades to `development` inside the mapper, so nothing here + // ever CLAIMS production on a guess. Absence is not a guess — it is + // the host declining to say, and the conservative answer to that is + // `production`. + // + // The default is passed as `getEnv`'s second argument rather than + // moved into `resolveDiscoveryEnvironment` because the mapper lives + // in `@objectstack/spec`, which this issue's ruling put out of scope. + // Consequence, stated rather than hidden: the second discovery + // producer (`getDiscovery()` in `@objectstack/metadata-protocol`, + // served by `@objectstack/rest`) passes a genuinely-absent + // `NODE_ENV` straight into the mapper and therefore still answers + // `development` for the unset case. Filed as a follow-up (#5936); + // do not "fix" it by re-defaulting a consumer somewhere else. + environment: resolveDiscoveryEnvironment(getEnv('NODE_ENV', 'production')), routes, // [#4828] `endpoints` (a verbatim duplicate of `routes`, commented // "Alias for backward compatibility with some clients") and the