Skip to content

Commit 2a37694

Browse files
os-zhuangclaude
andauthored
fix(plugin-dev,types): the production escape hatch stops being silent (#3900) (#4308)
* 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. 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjTEKP96798WKamk2VCkAp * docs(plugins): the packages entry names the branded override, not just the hatch (#3900) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjTEKP96798WKamk2VCkAp --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 873ecd0 commit 2a37694

8 files changed

Lines changed: 335 additions & 12 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/plugin-dev": patch
3+
"@objectstack/types": minor
4+
---
5+
6+
fix(plugin-dev,types): the production escape hatch stops being silent (#3900)
7+
8+
`DevPlugin.init()` refuses to run under `NODE_ENV=production` (ADR-0115 D6), and
9+
`OS_ALLOW_DEV_PLUGIN` overrides that refusal. As shipped, the override returned
10+
early with **no output at all**: the process ran the development assembly while
11+
every log line and the ready banner read like an ordinary production start.
12+
13+
That reproduces, one level up, the defect the guard exists to close. The guard's
14+
own precedent says so — `OS_ALLOW_DEGRADED_TENANCY` boots degraded *and brands
15+
it everywhere an operator looks*, and `OS_ALLOW_DRIVER_CONNECT_FAILURE`'s
16+
contract is "logged loudly at startup". An escape hatch that says nothing leaves
17+
the operator's only evidence of a degraded state in an env var they may not have
18+
set themselves.
19+
20+
**The override now brands itself, twice.** A warning at `init()` — emitted
21+
before any assembly work, so it survives an assembly step that later throws —
22+
and a repeat on the ready banner, which is the surface an operator actually
23+
reads:
24+
25+
```
26+
⚠ DEV ASSEMBLY UNDER NODE_ENV=production (OS_ALLOW_DEV_PLUGIN is set) — the boot
27+
guard was explicitly overridden. This process is running the DEVELOPMENT
28+
assembly, which is not hardened for production traffic (ADR-0115 D6).
29+
• Auth secret is the default published inside @objectstack/plugin-dev. It is
30+
public, so anyone can mint a session this stack accepts. Pass `authSecret`
31+
explicitly.
32+
• Data goes to the in-memory driver with persistence disabled — every record
33+
is lost when this process exits.
34+
```
35+
36+
Only hazards that are live for *that* configuration are named: the secret line
37+
is suppressed when the operator passed their own `authSecret`, and the driver
38+
line when the `driver` toggle is off. The dev-admin seed is deliberately absent
39+
`plugin-auth`'s `maybeSeedDevAdmin` is hard-gated to
40+
`NODE_ENV === 'development'` and cannot fire on this path, so warning about it
41+
would spend the attention the real hazards need.
42+
43+
**New export — `resolveAllowDevPlugin()` (`@objectstack/types`).** The flag moves
44+
off a bare `process.env['OS_ALLOW_DEV_PLUGIN'] === '1'` and joins the
45+
`OS_ALLOW_*` family's shared truthy vocabulary, next to
46+
`resolveAllowDegradedTenancy` / `resolveAllowDriverConnectFailure`.
47+
48+
FROM → TO for operators: `OS_ALLOW_DEV_PLUGIN=1` keeps working unchanged.
49+
`OS_ALLOW_DEV_PLUGIN=true` (and `on` / `yes`, case-insensitive, surrounding
50+
whitespace ignored) **now takes effect** where the strict comparison previously
51+
ignored it and failed the boot. That is a widening, in the direction an operator
52+
setting the flag already intended; falsy and unrecognised values still refuse to
53+
boot, and unset still means "fail fast". If you were relying on
54+
`OS_ALLOW_DEV_PLUGIN=true` being inert as a way to keep the guard armed, unset
55+
the variable instead.
56+
57+
No change to the refusal path, which this issue re-verified end to end:
58+
`kernel.use()` only registers, `initPluginWithTimeout` does not catch,
59+
`bootstrap()` rethrows, and `os serve`'s outer handler prints the message and
60+
exits `1`. The `throw` is genuinely fatal here, so it needs none of the
61+
`process.exit(1)` the tenancy guard required for sitting inside a broad `catch`.

content/docs/plugins/packages.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
347347

348348
**Development Assembly Plugin** — one plugin that wires the real platform stack for zero-config local development.
349349

350-
- **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)
350+
- **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)
351351
- **When to use**: Zero-config local development and playgrounds
352352
- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/plugin-dev/README.md)
353353

docs/adr/0115-plugin-dev-assembly-not-stub-table.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ Precision, for the record: the `auth` stub is **dead code on the real identity p
9090

9191
**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).
9292

93+
*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`.
94+
9395
**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.
9496

9597
### What deliberately stays

packages/plugins/plugin-dev/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,23 @@ To use a capability locally, install its real service — e.g. `@objectstack/ser
8181

8282
## Production guard
8383

84-
`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`.
84+
`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`.
85+
86+
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`).
87+
88+
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:
89+
90+
```
91+
⚠ DEV ASSEMBLY UNDER NODE_ENV=production (OS_ALLOW_DEV_PLUGIN is set) — the boot guard was
92+
explicitly overridden. This process is running the DEVELOPMENT assembly, which is not
93+
hardened for production traffic (ADR-0115 D6).
94+
• Auth secret is the default published inside @objectstack/plugin-dev. It is public, so
95+
anyone can mint a session this stack accepts. Pass `authSecret` explicitly.
96+
• Data goes to the in-memory driver with persistence disabled — every record is lost
97+
when this process exits.
98+
```
99+
100+
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.
85101

86102
## API Endpoints (when all services enabled)
87103

packages/plugins/plugin-dev/src/dev-plugin.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,105 @@ describe('DevPlugin', () => {
147147
const { ctx } = mockCtx();
148148
await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).resolves.not.toThrow();
149149
});
150+
151+
// [#3900] The hatch used to return silently: the dev assembly booted under
152+
// a production NODE_ENV and every log line read like an ordinary start.
153+
// An escape hatch that says nothing rebuilds the hole the guard closed.
154+
it('brands the override in the boot log, naming the live hazards', async () => {
155+
process.env.NODE_ENV = 'production';
156+
process.env.OS_ALLOW_DEV_PLUGIN = '1';
157+
158+
const { ctx } = mockCtx();
159+
await new DevPlugin({ seedAdminUser: false }).init(ctx);
160+
161+
const warns = ctx.logger.warn.mock.calls
162+
.map((call: any[]) => call[0])
163+
.filter((line: any) => typeof line === 'string');
164+
165+
expect(warns.some((l: string) => l.includes('DEV ASSEMBLY UNDER NODE_ENV=production'))).toBe(true);
166+
expect(warns.some((l: string) => l.includes('OS_ALLOW_DEV_PLUGIN'))).toBe(true);
167+
// The default auth secret is published in the package — anyone holding it
168+
// can mint a session, so it is named rather than merely implied.
169+
expect(warns.some((l: string) => l.includes('Auth secret is the default published'))).toBe(true);
170+
expect(warns.some((l: string) => l.includes('persistence disabled'))).toBe(true);
171+
});
172+
173+
it('does not claim the published-secret hazard when authSecret was overridden', async () => {
174+
process.env.NODE_ENV = 'production';
175+
process.env.OS_ALLOW_DEV_PLUGIN = '1';
176+
177+
const { ctx } = mockCtx();
178+
await new DevPlugin({ seedAdminUser: false, authSecret: 'operator-supplied-secret' }).init(ctx);
179+
180+
const warns = ctx.logger.warn.mock.calls
181+
.map((call: any[]) => call[0])
182+
.filter((line: any) => typeof line === 'string');
183+
184+
// Still branded — the assembly is the hazard, not just its secret.
185+
expect(warns.some((l: string) => l.includes('DEV ASSEMBLY UNDER NODE_ENV=production'))).toBe(true);
186+
// …but a warning that is false for this deployment is not printed.
187+
expect(warns.some((l: string) => l.includes('Auth secret is the default published'))).toBe(false);
188+
});
189+
190+
it('repeats the brand on the ready banner, not only in the init log', async () => {
191+
process.env.NODE_ENV = 'production';
192+
process.env.OS_ALLOW_DEV_PLUGIN = '1';
193+
194+
const { ctx } = mockCtx();
195+
const plugin = new DevPlugin({ seedAdminUser: false });
196+
await plugin.init(ctx);
197+
ctx.logger.warn.mockClear();
198+
await plugin.start(ctx);
199+
200+
const warns = ctx.logger.warn.mock.calls
201+
.map((call: any[]) => call[0])
202+
.filter((line: any) => typeof line === 'string');
203+
expect(warns.some((l: string) => l.includes('NOT a production stack'))).toBe(true);
204+
});
205+
206+
// The branding must not fire on the path everyone actually uses, or it is
207+
// noise that trains operators to ignore the real thing.
208+
it('says nothing about the override on an ordinary dev boot', async () => {
209+
process.env.NODE_ENV = 'development';
210+
delete process.env.OS_ALLOW_DEV_PLUGIN;
211+
212+
const { ctx } = mockCtx();
213+
const plugin = new DevPlugin({ seedAdminUser: false });
214+
await plugin.init(ctx);
215+
await plugin.start(ctx);
216+
217+
const warns = ctx.logger.warn.mock.calls
218+
.map((call: any[]) => call[0])
219+
.filter((line: any) => typeof line === 'string');
220+
expect(warns.some((l: string) => l.includes('DEV ASSEMBLY'))).toBe(false);
221+
expect(warns.some((l: string) => l.includes('NOT a production stack'))).toBe(false);
222+
});
223+
224+
// The hatch shares the `OS_ALLOW_*` truthy vocabulary
225+
// (`resolveAllowDevPlugin`). The strict `=== '1'` it replaced failed CLOSED
226+
// on `=true` — safe, but it reads to an operator as a broken flag.
227+
it.each(['true', 'TRUE', 'on', 'yes', ' 1 '])(
228+
'accepts OS_ALLOW_DEV_PLUGIN=%j like every other OS_ALLOW_* hatch',
229+
async (value) => {
230+
process.env.NODE_ENV = 'production';
231+
process.env.OS_ALLOW_DEV_PLUGIN = value;
232+
233+
const { ctx } = mockCtx();
234+
await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).resolves.not.toThrow();
235+
},
236+
);
237+
238+
it.each(['0', 'false', 'off', 'no', ''])(
239+
'still refuses on a falsy OS_ALLOW_DEV_PLUGIN=%j',
240+
async (value) => {
241+
process.env.NODE_ENV = 'production';
242+
process.env.OS_ALLOW_DEV_PLUGIN = value;
243+
244+
const { ctx } = mockCtx();
245+
await expect(new DevPlugin({ seedAdminUser: false }).init(ctx))
246+
.rejects.toThrow(/NODE_ENV=production/);
247+
},
248+
);
150249
});
151250

152251
// [ADR-0115 D4] The slots the retired stubs used to fake are filled by the

0 commit comments

Comments
 (0)