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
61 changes: 61 additions & 0 deletions .changeset/dev-plugin-production-hatch-brands.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion content/docs/plugins/packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0115-plugin-dev-assembly-not-stub-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion packages/plugins/plugin-dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
99 changes: 99 additions & 0 deletions packages/plugins/plugin-dev/src/dev-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading