From 606c73bec2d530ddf815467f9cbf4597a6baad26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 05:04:42 +0000 Subject: [PATCH 1/2] fix(plugin-dev,types): the production escape hatch stops being silent (#3900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DevPlugin.init()` refuses to run under `NODE_ENV=production` (ADR-0115 D6), and `OS_ALLOW_DEV_PLUGIN` overrides that refusal. As shipped, the override returned early with no output at all: the process ran the development assembly while every log line and the ready banner read like an ordinary production start. That reproduces, one level up, the defect the guard exists to close. The guard's own precedent says so — `OS_ALLOW_DEGRADED_TENANCY` boots degraded and brands it everywhere an operator looks, and `OS_ALLOW_DRIVER_CONNECT_FAILURE`'s contract is "logged loudly at startup". An escape hatch that says nothing leaves the operator's only evidence of a degraded state in an env var they may not have set themselves. The override now brands itself twice: a warning at `init()`, emitted before any assembly work so it survives an assembly step that later throws, and a repeat on the ready banner. Only hazards live for that configuration are named — the auth secret published inside the npm package (suppressed when the operator passed their own `authSecret`) and the in-memory driver with persistence off (suppressed when the `driver` toggle is off). The dev-admin seed is deliberately absent: `plugin-auth`'s `maybeSeedDevAdmin` is hard-gated to `NODE_ENV === 'development'` and cannot fire on this path, so warning about it would spend the attention the real hazards need. The flag also moves off a bare `process.env[…] === '1'` onto `resolveAllowDevPlugin()` in `@objectstack/types`, joining the `OS_ALLOW_*` family's shared truthy vocabulary next to `resolveAllowDegradedTenancy` / `resolveAllowDriverConnectFailure`. The strict comparison failed CLOSED on `OS_ALLOW_DEV_PLUGIN=true` — safe, but it reads to an operator as a broken flag. No change to the refusal path, which this issue re-verified end to end: `kernel.use()` only registers, `initPluginWithTimeout` does not catch, `bootstrap()` rethrows, and `os serve`'s outer handler prints the message and exits 1. The throw is genuinely fatal here, so it needs none of the `process.exit(1)` the tenancy guard required for sitting inside a broad catch. Verified against a real `LiteKernel` boot in all three states: refuses without the hatch, boots branded (init + banner) with it, and stays silent on an ordinary dev boot. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TjTEKP96798WKamk2VCkAp --- .../dev-plugin-production-hatch-brands.md | 61 ++++++++++ ...0115-plugin-dev-assembly-not-stub-table.md | 2 + packages/plugins/plugin-dev/README.md | 18 ++- .../plugins/plugin-dev/src/dev-plugin.test.ts | 99 +++++++++++++++ packages/plugins/plugin-dev/src/dev-plugin.ts | 115 ++++++++++++++++-- packages/types/src/env.test.ts | 28 +++++ packages/types/src/env.ts | 22 ++++ 7 files changed, 334 insertions(+), 11 deletions(-) create mode 100644 .changeset/dev-plugin-production-hatch-brands.md diff --git a/.changeset/dev-plugin-production-hatch-brands.md b/.changeset/dev-plugin-production-hatch-brands.md new file mode 100644 index 0000000000..c79ec55d67 --- /dev/null +++ b/.changeset/dev-plugin-production-hatch-brands.md @@ -0,0 +1,61 @@ +--- +"@objectstack/plugin-dev": patch +"@objectstack/types": minor +--- + +fix(plugin-dev,types): the production escape hatch stops being silent (#3900) + +`DevPlugin.init()` refuses to run under `NODE_ENV=production` (ADR-0115 D6), and +`OS_ALLOW_DEV_PLUGIN` overrides that refusal. As shipped, the override returned +early with **no output at all**: the process ran the development assembly while +every log line and the ready banner read like an ordinary production start. + +That reproduces, one level up, the defect the guard exists to close. The guard's +own precedent says so — `OS_ALLOW_DEGRADED_TENANCY` boots degraded *and brands +it everywhere an operator looks*, and `OS_ALLOW_DRIVER_CONNECT_FAILURE`'s +contract is "logged loudly at startup". An escape hatch that says nothing leaves +the operator's only evidence of a degraded state in an env var they may not have +set themselves. + +**The override now brands itself, twice.** A warning at `init()` — emitted +before any assembly work, so it survives an assembly step that later throws — +and a repeat on the ready banner, which is the surface an operator actually +reads: + +``` +⚠ DEV ASSEMBLY UNDER NODE_ENV=production (OS_ALLOW_DEV_PLUGIN is set) — the boot + guard was explicitly overridden. This process is running the DEVELOPMENT + assembly, which is not hardened for production traffic (ADR-0115 D6). + • Auth secret is the default published inside @objectstack/plugin-dev. It is + public, so anyone can mint a session this stack accepts. Pass `authSecret` + explicitly. + • Data goes to the in-memory driver with persistence disabled — every record + is lost when this process exits. +``` + +Only hazards that are live for *that* configuration are named: the secret line +is suppressed when the operator passed their own `authSecret`, and the driver +line when the `driver` toggle is off. The dev-admin seed is deliberately absent +— `plugin-auth`'s `maybeSeedDevAdmin` is hard-gated to +`NODE_ENV === 'development'` and cannot fire on this path, so warning about it +would spend the attention the real hazards need. + +**New export — `resolveAllowDevPlugin()` (`@objectstack/types`).** The flag moves +off a bare `process.env['OS_ALLOW_DEV_PLUGIN'] === '1'` and joins the +`OS_ALLOW_*` family's shared truthy vocabulary, next to +`resolveAllowDegradedTenancy` / `resolveAllowDriverConnectFailure`. + +FROM → TO for operators: `OS_ALLOW_DEV_PLUGIN=1` keeps working unchanged. +`OS_ALLOW_DEV_PLUGIN=true` (and `on` / `yes`, case-insensitive, surrounding +whitespace ignored) **now takes effect** where the strict comparison previously +ignored it and failed the boot. That is a widening, in the direction an operator +setting the flag already intended; falsy and unrecognised values still refuse to +boot, and unset still means "fail fast". If you were relying on +`OS_ALLOW_DEV_PLUGIN=true` being inert as a way to keep the guard armed, unset +the variable instead. + +No change to the refusal path, which this issue re-verified end to end: +`kernel.use()` only registers, `initPluginWithTimeout` does not catch, +`bootstrap()` rethrows, and `os serve`'s outer handler prints the message and +exits `1`. The `throw` is genuinely fatal here, so it needs none of the +`process.exit(1)` the tenancy guard required for sitting inside a broad `catch`. diff --git a/docs/adr/0115-plugin-dev-assembly-not-stub-table.md b/docs/adr/0115-plugin-dev-assembly-not-stub-table.md index 5b8cb2394f..3aee1ae3fa 100644 --- a/docs/adr/0115-plugin-dev-assembly-not-stub-table.md +++ b/docs/adr/0115-plugin-dev-assembly-not-stub-table.md @@ -90,6 +90,8 @@ Precision, for the record: the `auth` stub is **dead code on the real identity p **D6 — plugin-dev grows a production guard, in the same change as Tier A.** `DevPlugin.init` throws when `NODE_ENV === 'production'`, escape hatch `OS_ALLOW_DEV_PLUGIN=1` (Prime Directive #9 `OS_ALLOW_*` shape — deliberately scary; the shipped name, landed by #4126's subset). Deleting the fakes removes the security-semantics hazard; the guard covers what deletion cannot: the plugin still assembles a stack around a well-known default auth secret and a seeded dev admin, which no production deployment should get by accident. It does not ship as a separate earlier fix because Tier A itself waits for nothing (resolves open question 3 — the "maybe it shouldn't wait" concern was about the security stubs, and D2 removes them rather than gating them). +*Amendment (2026-07-31, [#3900](https://github.com/objectstack-ai/objectstack/issues/3900)) — **the hatch brands, and shares the family's vocabulary.*** As first shipped by #4126 the guard was complete in its refusal and silent in its permission: `OS_ALLOW_DEV_PLUGIN` returned early with no output, so a process that took the hatch ran the development assembly while every log line and banner read like an ordinary production start. That reproduces one level up the exact defect the guard exists to close — the operator's only evidence of a degraded state is the env var they may not have set themselves — and it is the one thing the flag's own precedent forbids: `OS_ALLOW_DEGRADED_TENANCY` boots degraded *and brands it everywhere an operator looks* (`serve.ts`), and `resolveAllowDriverConnectFailure`'s contract is "logged loudly at startup". The override now warns at `init()` (before any assembly work, so it survives a later throw) and again on the ready banner, naming only the hazards live for that configuration: the auth secret published inside the npm package (suppressed when the operator passed their own), and the in-memory driver with persistence off. The dev-admin seed is deliberately excluded — `plugin-auth`'s `maybeSeedDevAdmin` is hard-gated to `NODE_ENV === 'development'` and cannot fire on this path, and a warning about a non-event spends the attention the real ones need. The flag also moves from a bare `process.env[…] === '1'` to `resolveAllowDevPlugin()` in `@objectstack/types`, joining the `OS_ALLOW_*` family's truthy vocabulary (`1`/`true`/`on`/`yes`): the strict comparison failed CLOSED on `OS_ALLOW_DEV_PLUGIN=true`, which is safe but reads to an operator as a broken flag. #3900 also re-verified the refusal path itself — `kernel.use()` only registers, `initPluginWithTimeout` does not catch, `bootstrap()` rethrows, and `os serve`'s outer handler exits `1` — so the `throw` is genuinely fatal here and needs none of the `process.exit(1)` that the tenancy guard required for sitting inside serve's broad `catch`. + **D7 — The descriptions converge on what the plugin is.** `content/docs/plugins/packages.mdx` and the plugin README/class doc describe an assembly plugin ("auto-wires ObjectQL + driver-memory + auth + security + HTTP server + REST + dispatcher + app metadata for local development"); the "Stub services" section and the "simulating all 17+ kernel services" claim are removed. Docs describing the retired design are ADR-0078's silent lie in prose form. ### What deliberately stays diff --git a/packages/plugins/plugin-dev/README.md b/packages/plugins/plugin-dev/README.md index 2e8135615e..2c63de0858 100644 --- a/packages/plugins/plugin-dev/README.md +++ b/packages/plugins/plugin-dev/README.md @@ -81,7 +81,23 @@ To use a capability locally, install its real service — e.g. `@objectstack/ser ## Production guard -`init()` throws when `NODE_ENV === 'production'`: the assembly is built around a well-known default auth secret and a seeded dev admin. If you really mean it, set `OS_ALLOW_DEV_PLUGIN=1`. +`init()` throws when `NODE_ENV === 'production'`: the assembly is built around a well-known default auth secret and a seeded dev admin. Nothing swallows the throw on a real boot path — `os serve` prints the message and exits `1`. + +If you really mean it (a staging box that pins `NODE_ENV=production`, a smoke test), set `OS_ALLOW_DEV_PLUGIN` to a truthy value (`1` / `true` / `on` / `yes`). + +Taking that hatch is never silent. The boot log names the hazards that are actually live for your configuration, and the ready banner repeats the brand, so a process running the dev assembly cannot look like an ordinary production start: + +``` +⚠ DEV ASSEMBLY UNDER NODE_ENV=production (OS_ALLOW_DEV_PLUGIN is set) — the boot guard was + explicitly overridden. This process is running the DEVELOPMENT assembly, which is not + hardened for production traffic (ADR-0115 D6). + • Auth secret is the default published inside @objectstack/plugin-dev. It is public, so + anyone can mint a session this stack accepts. Pass `authSecret` explicitly. + • Data goes to the in-memory driver with persistence disabled — every record is lost + when this process exits. +``` + +The dev-admin seed is deliberately *not* on that list: `plugin-auth`'s seeding is hard-gated to `NODE_ENV === 'development'`, so it cannot fire on this path. ## API Endpoints (when all services enabled) diff --git a/packages/plugins/plugin-dev/src/dev-plugin.test.ts b/packages/plugins/plugin-dev/src/dev-plugin.test.ts index db2b259dc1..a59d89a05f 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.test.ts @@ -147,6 +147,105 @@ describe('DevPlugin', () => { const { ctx } = mockCtx(); await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).resolves.not.toThrow(); }); + + // [#3900] The hatch used to return silently: the dev assembly booted under + // a production NODE_ENV and every log line read like an ordinary start. + // An escape hatch that says nothing rebuilds the hole the guard closed. + it('brands the override in the boot log, naming the live hazards', async () => { + process.env.NODE_ENV = 'production'; + process.env.OS_ALLOW_DEV_PLUGIN = '1'; + + const { ctx } = mockCtx(); + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const warns = ctx.logger.warn.mock.calls + .map((call: any[]) => call[0]) + .filter((line: any) => typeof line === 'string'); + + expect(warns.some((l: string) => l.includes('DEV ASSEMBLY UNDER NODE_ENV=production'))).toBe(true); + expect(warns.some((l: string) => l.includes('OS_ALLOW_DEV_PLUGIN'))).toBe(true); + // The default auth secret is published in the package — anyone holding it + // can mint a session, so it is named rather than merely implied. + expect(warns.some((l: string) => l.includes('Auth secret is the default published'))).toBe(true); + expect(warns.some((l: string) => l.includes('persistence disabled'))).toBe(true); + }); + + it('does not claim the published-secret hazard when authSecret was overridden', async () => { + process.env.NODE_ENV = 'production'; + process.env.OS_ALLOW_DEV_PLUGIN = '1'; + + const { ctx } = mockCtx(); + await new DevPlugin({ seedAdminUser: false, authSecret: 'operator-supplied-secret' }).init(ctx); + + const warns = ctx.logger.warn.mock.calls + .map((call: any[]) => call[0]) + .filter((line: any) => typeof line === 'string'); + + // Still branded — the assembly is the hazard, not just its secret. + expect(warns.some((l: string) => l.includes('DEV ASSEMBLY UNDER NODE_ENV=production'))).toBe(true); + // …but a warning that is false for this deployment is not printed. + expect(warns.some((l: string) => l.includes('Auth secret is the default published'))).toBe(false); + }); + + it('repeats the brand on the ready banner, not only in the init log', async () => { + process.env.NODE_ENV = 'production'; + process.env.OS_ALLOW_DEV_PLUGIN = '1'; + + const { ctx } = mockCtx(); + const plugin = new DevPlugin({ seedAdminUser: false }); + await plugin.init(ctx); + ctx.logger.warn.mockClear(); + await plugin.start(ctx); + + const warns = ctx.logger.warn.mock.calls + .map((call: any[]) => call[0]) + .filter((line: any) => typeof line === 'string'); + expect(warns.some((l: string) => l.includes('NOT a production stack'))).toBe(true); + }); + + // The branding must not fire on the path everyone actually uses, or it is + // noise that trains operators to ignore the real thing. + it('says nothing about the override on an ordinary dev boot', async () => { + process.env.NODE_ENV = 'development'; + delete process.env.OS_ALLOW_DEV_PLUGIN; + + const { ctx } = mockCtx(); + const plugin = new DevPlugin({ seedAdminUser: false }); + await plugin.init(ctx); + await plugin.start(ctx); + + const warns = ctx.logger.warn.mock.calls + .map((call: any[]) => call[0]) + .filter((line: any) => typeof line === 'string'); + expect(warns.some((l: string) => l.includes('DEV ASSEMBLY'))).toBe(false); + expect(warns.some((l: string) => l.includes('NOT a production stack'))).toBe(false); + }); + + // The hatch shares the `OS_ALLOW_*` truthy vocabulary + // (`resolveAllowDevPlugin`). The strict `=== '1'` it replaced failed CLOSED + // on `=true` — safe, but it reads to an operator as a broken flag. + it.each(['true', 'TRUE', 'on', 'yes', ' 1 '])( + 'accepts OS_ALLOW_DEV_PLUGIN=%j like every other OS_ALLOW_* hatch', + async (value) => { + process.env.NODE_ENV = 'production'; + process.env.OS_ALLOW_DEV_PLUGIN = value; + + const { ctx } = mockCtx(); + await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).resolves.not.toThrow(); + }, + ); + + it.each(['0', 'false', 'off', 'no', ''])( + 'still refuses on a falsy OS_ALLOW_DEV_PLUGIN=%j', + async (value) => { + process.env.NODE_ENV = 'production'; + process.env.OS_ALLOW_DEV_PLUGIN = value; + + const { ctx } = mockCtx(); + await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)) + .rejects.toThrow(/NODE_ENV=production/); + }, + ); }); // [ADR-0115 D4] The slots the retired stubs used to fake are filled by the diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 530cace75e..c4001ed842 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext } from '@objectstack/core'; -import { resolveMultiOrgEnabled } from '@objectstack/types'; +import { resolveAllowDevPlugin, resolveMultiOrgEnabled } from '@objectstack/types'; /** * Dev Plugin Options @@ -82,9 +82,21 @@ export interface DevPluginOptions { /** * Escape hatch for {@link assertNotProduction} — deliberately ungrouped and * scary-looking per the `OS_ALLOW_{X}` convention (AGENTS.md Prime Directive #9). + * Parsed by `resolveAllowDevPlugin()` so it shares the family's truthy + * vocabulary (`1`/`true`/`on`/`yes`) rather than its own strict `=== '1'`. */ const ALLOW_IN_PRODUCTION_ENV = 'OS_ALLOW_DEV_PLUGIN' as const; +/** + * The default dev auth secret. + * + * Named rather than inlined at its one use site because the production-override + * branding has to be able to say whether the operator is still running on it + * (#3900): a constant shipped inside a public npm package is not a secret, and + * anyone holding it can mint a session this stack will accept. + */ +const DEV_AUTH_SECRET = 'objectstack-dev-secret-DO-NOT-USE-IN-PRODUCTION!!'; + /** * [ADR-0115 D6] Refuse to initialize under `NODE_ENV=production`. * @@ -95,10 +107,21 @@ const ALLOW_IN_PRODUCTION_ENV = 'OS_ALLOW_DEV_PLUGIN' as const; * The escape hatch covers the deliberate cases (a staging box mimicking prod, * a smoke test that pins `NODE_ENV`), and says at the call site that someone * chose this. + * + * The throw is not swallowed on any real boot path: `kernel.use()` only + * registers, `initPluginWithTimeout` does not catch, and `bootstrap()` rethrows + * — so `os serve`'s outer handler prints the message and exits 1. (That is why + * this can stay a `throw` where `OS_ALLOW_DEGRADED_TENANCY` needed + * `process.exit(1)`: that guard sits inside serve's broad AuthPlugin `catch`, + * this one does not.) + * + * @returns `true` when the guard WOULD have refused and the escape hatch + * overrode it. Callers MUST brand that state rather than merely proceed — + * see {@link productionOverrideWarnings}. */ -function assertNotProduction(): void { - if (process.env.NODE_ENV !== 'production') return; - if (process.env[ALLOW_IN_PRODUCTION_ENV] === '1') return; +function assertNotProduction(): boolean { + if (process.env.NODE_ENV !== 'production') return false; + if (resolveAllowDevPlugin()) return true; throw new Error( '@objectstack/plugin-dev refuses to initialize with NODE_ENV=production. ' + 'It assembles a development stack around a well-known default auth secret and a seeded ' @@ -108,6 +131,45 @@ function assertNotProduction(): void { ); } +/** + * Boot-log branding for an overridden production guard (#3900). + * + * An escape hatch that returns silently re-creates, one level up, the exact + * failure the guard exists to prevent: the process runs the development + * assembly while every log line and banner reads like an ordinary production + * start, so the one fact an operator needs is the one fact nothing says. + * `OS_ALLOW_DEGRADED_TENANCY` (`cli/src/commands/serve.ts`) sets the shape this + * follows — boot when explicitly told to, then brand the degraded state + * everywhere an operator looks. + * + * Only hazards that are actually live get named. The dev-admin seed is + * deliberately NOT among them: `plugin-auth`'s `maybeSeedDevAdmin` is + * hard-gated to `NODE_ENV === 'development'`, so it cannot fire on this path, + * and warning about it would spend the operator's attention on a non-event. + */ +function productionOverrideWarnings( + opts: { defaultSecret: boolean; driverEnabled: boolean }, +): string[] { + const lines = [ + ` ⚠ DEV ASSEMBLY UNDER NODE_ENV=production (${ALLOW_IN_PRODUCTION_ENV} is set) — the boot ` + + 'guard was explicitly overridden. This process is running the DEVELOPMENT assembly, which ' + + 'is not hardened for production traffic (ADR-0115 D6).', + ]; + if (opts.defaultSecret) { + lines.push( + ' • Auth secret is the default published inside @objectstack/plugin-dev. It is public, so ' + + 'anyone can mint a session this stack accepts. Pass `authSecret` explicitly.', + ); + } + if (opts.driverEnabled) { + lines.push( + ' • Data goes to the in-memory driver with persistence disabled — every record is lost ' + + 'when this process exits.', + ); + } + return lines; +} + /** * Development Assembly Plugin for ObjectStack * @@ -169,8 +231,13 @@ function assertNotProduction(): void { * * `init()` refuses to run when `NODE_ENV === 'production'`: the assembly is * built around a well-known default auth secret and a seeded dev admin. - * Escape hatch for the rare deliberate case: - * `OS_ALLOW_DEV_PLUGIN=1`. + * Escape hatch for the rare deliberate case: `OS_ALLOW_DEV_PLUGIN=1`. + * + * Taking the escape hatch is never silent (#3900). The boot log names the + * live hazards — a published default auth secret, an in-memory driver with + * persistence off — and the ready banner repeats the brand, so a process + * running the dev assembly under a production `NODE_ENV` cannot look like an + * ordinary production start. */ export class DevPlugin implements Plugin { name = 'com.objectstack.plugin.dev'; @@ -183,11 +250,18 @@ export class DevPlugin implements Plugin { private childPlugins: Plugin[] = []; + /** + * Set when {@link assertNotProduction} was overridden by + * `OS_ALLOW_DEV_PLUGIN`. Carried from `init()` to `start()` so the ready + * banner carries the same brand as the boot log (#3900). + */ + private productionOverride = false; + constructor(options: DevPluginOptions = {}) { this.options = { port: 3000, seedAdminUser: true, - authSecret: 'objectstack-dev-secret-DO-NOT-USE-IN-PRODUCTION!!', + authSecret: DEV_AUTH_SECRET, verbose: true, ...options, authBaseUrl: options.authBaseUrl ?? `http://localhost:${options.port ?? 3000}`, @@ -202,12 +276,24 @@ export class DevPlugin implements Plugin { * if a package isn't installed the service is silently skipped. */ async init(ctx: PluginContext): Promise { - assertNotProduction(); - - ctx.logger.info('🚀 DevPlugin initializing — assembling the development stack'); + this.productionOverride = assertNotProduction(); const enabled = (name: string) => this.options.services?.[name] !== false; + // Brand the override BEFORE any assembly work, so the warning survives an + // assembly step that later throws — a boot that died under an overridden + // guard is exactly when the operator most needs to know the guard was off. + if (this.productionOverride) { + for (const line of productionOverrideWarnings({ + defaultSecret: this.options.authSecret === DEV_AUTH_SECRET, + driverEnabled: enabled('driver'), + })) { + ctx.logger.warn(line); + } + } + + ctx.logger.info('🚀 DevPlugin initializing — assembling the development stack'); + // 1. ObjectQL Engine (data layer + metadata service) if (enabled('objectql')) { try { @@ -504,6 +590,15 @@ export class DevPlugin implements Plugin { ctx.logger.info('─────────────────────────────────────────'); ctx.logger.info('🟢 ObjectStack Dev Server ready'); ctx.logger.info(` http://localhost:${this.options.port}`); + // The banner is the one surface an operator reliably reads, so the + // overridden guard is branded here too and not only in the init log it + // scrolled past ten seconds ago (#3900). + if (this.productionOverride) { + ctx.logger.warn( + ` ⚠ DEV ASSEMBLY under NODE_ENV=production (${ALLOW_IN_PRODUCTION_ENV} is set) — ` + + 'this is NOT a production stack', + ); + } ctx.logger.info(''); ctx.logger.info(' API: /api/v1/data/:object'); ctx.logger.info(' Metadata: /api/v1/meta/:type/:name'); diff --git a/packages/types/src/env.test.ts b/packages/types/src/env.test.ts index 66c7e9ad7f..c857526258 100644 --- a/packages/types/src/env.test.ts +++ b/packages/types/src/env.test.ts @@ -6,6 +6,7 @@ import { collectConfiguredLocales, readEnvWithDeprecation, resolveAllowDegradedTenancy, + resolveAllowDevPlugin, resolveSearchPinyinEnabled, resolveSandboxTimeoutMs, isMcpServerEnabled, @@ -136,6 +137,33 @@ describe('resolveAllowDegradedTenancy (ADR-0093 D5)', () => { }); }); +describe('resolveAllowDevPlugin (ADR-0115 D6, #3900)', () => { + const original = process.env.OS_ALLOW_DEV_PLUGIN; + afterEach(() => { + if (original === undefined) delete process.env.OS_ALLOW_DEV_PLUGIN; + else process.env.OS_ALLOW_DEV_PLUGIN = original; + }); + + it('defaults OFF (unset → the production guard refuses)', () => { + delete process.env.OS_ALLOW_DEV_PLUGIN; + expect(resolveAllowDevPlugin()).toBe(false); + }); + + it('accepts the same truthy vocabulary as every other OS_ALLOW_* hatch', () => { + for (const v of ['1', 'true', 'TRUE', 'on', 'Yes', ' 1 ']) { + process.env.OS_ALLOW_DEV_PLUGIN = v; + expect(resolveAllowDevPlugin()).toBe(true); + } + }); + + it('treats anything else as off', () => { + for (const v of ['0', 'false', 'off', 'no', '', 'maybe']) { + process.env.OS_ALLOW_DEV_PLUGIN = v; + expect(resolveAllowDevPlugin()).toBe(false); + } + }); +}); + describe('resolveSearchPinyinEnabled (#2486)', () => { const original = process.env.OS_SEARCH_PINYIN_ENABLED; afterEach(() => { diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index be3633cc2d..e3e2cfb902 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -191,6 +191,28 @@ export function resolveAllowDriverConnectFailure(): boolean { return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase()); } +/** + * Escape hatch for plugin-dev's production boot guard (ADR-0115 D6, #3900). + * + * `DevPlugin.init()` refuses to run under `NODE_ENV=production`: the stack it + * assembles is built around an auth secret published inside the npm package and + * an in-memory driver with persistence off, neither of which a production + * deployment should acquire by accident. Setting this to a truthy value + * (`true`/`1`/`on`/`yes`, case-insensitive) boots anyway, in an explicitly + * degraded state that is branded in the boot log and on the ready banner. + * Defaults OFF — an unset flag means "fail fast". + * + * Lives here rather than as a bare `process.env[…] === '1'` inside plugin-dev so + * that the whole `OS_ALLOW_*` family answers to one truthy vocabulary: the + * strict `=== '1'` it replaced fails CLOSED on `OS_ALLOW_DEV_PLUGIN=true`, which + * is safe but reads to an operator as the flag being broken. + */ +export function resolveAllowDevPlugin(): boolean { + const raw = readEnvWithDeprecation('OS_ALLOW_DEV_PLUGIN', [], { silent: true }); + if (raw == null) return false; + return ['1', 'true', 'on', 'yes'].includes(String(raw).trim().toLowerCase()); +} + /** * SINGLE decision point for "is the MCP HTTP surface (`/api/v1/mcp`) on?". * From b29e88796377e4202005a3b42c8ebc500720583a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 05:09:36 +0000 Subject: [PATCH 2/2] docs(plugins): the packages entry names the branded override, not just the hatch (#3900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs-drift check flagged `content/docs/plugins/packages.mdx` as referencing the changed code. Its plugin-dev line described the guard's refusal and the escape hatch's existence, which is still true, but read as "the hatch turns the guard off" — the half this change replaced. It now says the override brands itself. `@objectstack/types`'s entry needed nothing: `resolveAllowDevPlugin` falls under the "degraded-boot helpers" it already lists. `releases/v17.mdx`'s only plugin-dev mention is the retired `DevPluginConfigSchema` cluster, unrelated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TjTEKP96798WKamk2VCkAp --- content/docs/plugins/packages.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index 7b6ca62e8d..b62b0884d1 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -347,7 +347,7 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern **Development Assembly Plugin** — one plugin that wires the real platform stack for zero-config local development. -- **Features**: Auto-assembles ObjectQL + in-memory driver + auth + security + Hono server + REST + dispatcher + app metadata, plus optional real services when installed (storage, realtime, i18n); registers no stubs — a slot no plugin fills stays empty, as in production (ADR-0115); refuses to boot with `NODE_ENV=production` (`OS_ALLOW_DEV_PLUGIN` escape hatch) +- **Features**: Auto-assembles ObjectQL + in-memory driver + auth + security + Hono server + REST + dispatcher + app metadata, plus optional real services when installed (storage, realtime, i18n); registers no stubs — a slot no plugin fills stays empty, as in production (ADR-0115); refuses to boot with `NODE_ENV=production` (`OS_ALLOW_DEV_PLUGIN` escape hatch, which brands the override in the boot log and on the ready banner instead of overriding silently) - **When to use**: Zero-config local development and playgrounds - **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/plugin-dev/README.md)