Skip to content

Commit 159c98c

Browse files
committed
fix(plugin-dev,plugin-hono-server): retire the security dev stubs; refuse to load under NODE_ENV=production (#4093)
#4058 step 1 classified plugin-dev's fakes honestly and step 2 made the dispatcher gate on that classification. Neither is enough for three of them, for the same reason honest labelling was not enough for the analytics shim (#3891): the label was accurate while the behaviour still inverted the decision it stood in for. security.permissions → checkObjectPermission() { return true; } // allow-all security.rls → compileFilter() { return null; } // no predicate security.fieldMasker → maskResults(r) { return r; } // unmasked ADR-0076 D12's own line, learned from #3891, is that a fallback may degrade features but NEVER security semantics — and the shim it retired merely dropped the caller's RLS scoping, where these answered "allowed" outright. `spec/src/contracts/security-service.ts` states it from the other side: these three are plugin-security's implementation internals, and access-narrowing answers must fail CLOSED, so "a consumer must never treat a thrown error or a deny filter as 'no restriction'". A fake registered under those names by a different package is precisely that. Triggering it required nothing exotic: plugin-dev loads SecurityPlugin through the same optional dynamic import as everything else, so `@objectstack/ plugin-security` merely not being installed swapped real RBAC/RLS/masking for allow-all behind one `warn` line. The slots now stay empty — what production has without SecurityPlugin, and what the consumers already handle: enforcement lives inside the plugin's own registered hooks, and the only reader of a slot (plugin-hono-server's `/auth/me/permissions` and `/me/apps`) resolves it defensively and fails open on *presentation* only, over data the read path already enforced. The boot log now states plainly that RBAC, RLS and field masking are unenforced, which a fake quietly answering "yes" never did. Second half, from the same finding: plugin-dev now REFUSES to initialize under `NODE_ENV=production`. It is a published package (not private) that registers fakes for every unclaimed core slot, including ones that report success for work they never did, and it had no environment check of its own — an objectstack.config.ts carrying `new DevPlugin()` into a production deploy got the whole slate with only a boot log to say so. Failing the boot is the right response: such a process is misconfigured in a way no runtime behaviour can make safe. `OS_ALLOW_DEV_PLUGIN=1` (the `OS_ALLOW_{X}` escape-hatch shape, Prime Directive #9) overrides it for the deliberate cases. Also: `/auth/me/permissions` resolves `security.permissions` and `metadata` through the same guarded lookup its three sibling lookups already used. An unregistered slot makes `getService` throw, which landed in the outer catch — the same fail-open body, but logged as "/auth/me/permissions failed" on every console navigation instead of taking the deliberate `!evaluator` branch below. Out of scope, filed as #4113 and blocking the `auth` half of #4093's A tier: the dispatcher's `/auth` domain carries its OWN mock fallback, and it — not the dev stub — is what answers `/auth/*` when the slot is empty. It returns 200 with a fabricated user and a 24h session token for any email and any password (the password is never read), it lives in `packages/runtime` rather than in an opt-in dev plugin, and it gates on nothing but "no auth service registered". plugin-dev's auth stub was never even reached by it: the domain requires a `.handler` method and the stub implements `handleRequest`. Verified: plugin-dev 12, plugin-hono-server 132, plugin-security 677, runtime 923, objectql 1183 pass; build 71/71; eslint clean. End-to-end against a real boot: with plugin-security installed the real three services register and the new warning stays silent; with security disabled the slots are empty and silent; under NODE_ENV=production init throws and registers nothing, and the escape hatch boots the full slate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B
1 parent bb192c4 commit 159c98c

6 files changed

Lines changed: 242 additions & 46 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-dev': minor
3+
'@objectstack/plugin-hono-server': patch
4+
---
5+
6+
Retire the three `security.*` dev stubs, and refuse to load `plugin-dev` under `NODE_ENV=production` (#4093).
7+
8+
**The security stubs are gone.** When `@objectstack/plugin-security` was not installed, `plugin-dev` filled its three slots with fakes that inverted the decision each stood in for: `security.permissions.checkObjectPermission()` returned `true` for everything, `security.rls.compileFilter()` returned `null` so no row-level predicate was applied, and `security.fieldMasker.maskResults()` returned rows unmasked. ADR-0076 D12's rule — learned from the analytics shim it retired in #3891 — is that a fallback may degrade features, **never security semantics**; `packages/spec/src/contracts/security-service.ts` says the same from the other side (these three are plugin-security's internals, and access-narrowing answers must fail CLOSED). Since `plugin-dev` loads SecurityPlugin through the same optional dynamic import as everything else, the package merely being absent was enough to swap real RBAC/RLS/masking for allow-all behind a single `warn` line.
9+
10+
The slots now stay empty — which is what production has without SecurityPlugin, and what every consumer already handles — and the boot log states plainly that RBAC, row-level security and field masking are not being enforced.
11+
12+
**`plugin-dev` now refuses to initialize under `NODE_ENV=production`.** It is a published package that registers development fakes for every unclaimed core service slot, including ones that report success for work they never did, and it had no environment check of its own: an `objectstack.config.ts` carrying `new DevPlugin()` into a production deploy got the whole fake slate with only a boot log to say so. `init()` now throws there. Set `OS_ALLOW_DEV_PLUGIN=1` if you deliberately want the dev slate under a production `NODE_ENV` (a staging box mimicking prod, a smoke test that pins the variable).
13+
14+
FROM → TO: a stack that relied on the dev security stubs was not being protected by them — it was being told everything was allowed. Install `@objectstack/plugin-security` to enforce RBAC/RLS/masking, or accept the empty slots (unchanged behaviour on every path that already handled an absent SecurityPlugin). A production process that loaded `plugin-dev` must now either drop it and install the real services, or opt in explicitly with `OS_ALLOW_DEV_PLUGIN=1`.
15+
16+
Also: `plugin-hono-server`'s `/auth/me/permissions` resolves `security.permissions` and `metadata` through the same guarded lookup its three sibling lookups already used. An unregistered slot makes `getService` throw, which previously landed in the outer catch — the same fail-open response body, but logged as "/auth/me/permissions failed" on every console navigation instead of taking the deliberate `!evaluator` branch.

docs/adr/0076-objectql-core-tiering.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ Decision: each capability plugin registers its routes as a **normalized handler*
132132
**Root cause of agents being misled.** Several plugins register stub / dev / fallback services under canonical names, and the discovery builder reports *any* present service as fully real: `runtime/http-dispatcher.ts`'s `svcAvailable` hardcodes `{ enabled: true, status: 'available', handlerReady: true }` for every registered service — it **ignores stub markers** (its own comment even says "handlerReady:false … may be served by a stub", but the code never computes it). So `discovery.services.*` claims capabilities that are only stubbed, and consumers (AI agents, the console) trust them. A dev AI stub advertised this way has already confused an agent.
133133

134134
**Inventory of current fakes / mis-reports:**
135-
- `plugin-dev` registers ~8 dev stubs — `storage` / `search` / `automation` / `graphql` / `analytics` / `realtime` / `notification` / `ai` (they already carry a `_dev: true` marker that nothing respects). *(Update #4000: the `analytics` one is **gone** — after the fallback's retirement (#3891/#3989) it was the last thing refilling that slot, and refilling it re-created the retired shape in dev: the dispatcher gated on service presence, so the stub was called like an engine and answered 200 with fabricated rows. The remaining stubs are unchanged, and the class-wide question they raise is tracked separately — see conclusion 3 below.)* *(Update #4058 step 1: the blanket `_dev: true` is **gone** too. Every dev implementation now carries the standard `__serviceInfo`, classified in one reviewable table (`DEV_STUB_SELF_INFO`) by what it actually is — `degraded` for the ones that really work with reduced capability (`file-storage` / `search` / `metadata` / `workflow` / `realtime`, plus the wrapped kernel fallbacks), `stub` for the ones whose answer is fabricated (`ai` / `automation` / `notification` / `data` / `auth` / `security.*`). The single marker made those two indistinguishable and declared the working ones fake, which is why "adopt the dispatcher gate everywhere?" could not be answered. The gates themselves are untouched — that is #4058 step 2.)*
135+
- `plugin-dev` registers ~8 dev stubs — `storage` / `search` / `automation` / `graphql` / `analytics` / `realtime` / `notification` / `ai` (they already carry a `_dev: true` marker that nothing respects). *(Update #4000: the `analytics` one is **gone** — after the fallback's retirement (#3891/#3989) it was the last thing refilling that slot, and refilling it re-created the retired shape in dev: the dispatcher gated on service presence, so the stub was called like an engine and answered 200 with fabricated rows. The remaining stubs are unchanged, and the class-wide question they raise is tracked separately — see conclusion 3 below.)* *(Update #4058 step 1: the blanket `_dev: true` is **gone** too. Every dev implementation now carries the standard `__serviceInfo`, classified in one reviewable table (`DEV_STUB_SELF_INFO`) by what it actually is — `degraded` for the ones that really work with reduced capability (`file-storage` / `search` / `metadata` / `workflow` / `realtime`, plus the wrapped kernel fallbacks), `stub` for the ones whose answer is fabricated (`ai` / `automation` / `notification` / `data` / `auth` / `security.*`). The single marker made those two indistinguishable and declared the working ones fake, which is why "adopt the dispatcher gate everywhere?" could not be answered. The gates themselves are untouched — that is #4058 step 2.)* *(Update #4093: three of those `stub` entries are **gone**, not relabelled — `security.permissions` / `security.rls` / `security.fieldMasker`. Classifying them honestly was necessary but not sufficient, for the same reason honest labelling was not enough for the analytics shim (#3891): the label was accurate while the behaviour still inverted the decision it stood in for — `checkObjectPermission()` returned `true` for everything, `compileFilter()` returned `null` so no row predicate applied, `maskResults()` returned rows unmasked. That is this decision's own line — a fallback may degrade features, never security semantics — and `spec/src/contracts/security-service.ts` states it from the other side: these three are plugin-security's internals, and access-narrowing answers must fail CLOSED. `plugin-dev` loads SecurityPlugin through the same optional dynamic import as everything else, so the package merely being absent swapped real RBAC/RLS/masking for allow-all behind one `warn`. The slots now stay empty (what production has without SecurityPlugin), the boot log states that nothing is enforcing them, and `plugin-dev` additionally refuses to initialize at all under `NODE_ENV=production` (`OS_ALLOW_DEV_PLUGIN=1` to override) — it is a published package that had no environment check of its own. Whether the remaining fabricating stubs (`data`, `auth`, `ui`) should occupy slots at all is still #4093; `auth` in particular is blocked on #4113, since the dispatcher's own `/auth` mock fallback — not the dev stub — is what actually answers `/auth/*` when the slot is empty.)*
136136
- `ObjectQLPlugin` registers a ~66-line `analytics` **fallback** (the D10 note — deliberate, but it reports as fully available).
137137
- `http-dispatcher.ts` `svcAvailable` — the hardcode above.
138138
- *(Added by #4058: the **kernel's own** fallbacks — `createMemoryCache` / `Queue` / `Job` / `I18n` / `Metadata`, auto-registered by ObjectKernel — were missing from this inventory and were the worst case in it: they carried `_fallback: true`, a marker **no** reader recognized (not even `readServiceSelfInfo`), so both discovery builders reported them as fully `available`. They now self-describe as `degraded` with `handlerReady: false` where no HTTP surface exists (`cache` / `queue` / `job`). The lone duck-typed consumer of `_fallback`/`_dev` — an i18n diagnostic in `app-plugin.ts` — reads `readServiceSelfInfo` instead.)*

packages/plugins/plugin-dev/README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,25 @@ plugins: [
7575
| REST API | `@objectstack/rest` | Auto-generated CRUD + metadata endpoints |
7676
| Dispatcher | `@objectstack/runtime` | Auth routes, GraphQL, analytics, packages, storage |
7777

78+
### ⛔ Local development only
79+
80+
`DevPlugin.init()` **throws under `NODE_ENV=production`**. It fills unclaimed service slots with fakes — some of which report success for work they never did — so a production process must not load it. Remove it from that deployment's plugin list and install the real services. `OS_ALLOW_DEV_PLUGIN=1` overrides the refusal if you deliberately want the dev slate under a production `NODE_ENV` (a staging box mimicking prod, a smoke test that pins the variable).
81+
7882
### Dev stubs (in-memory / no-op)
7983

80-
Any core kernel service not provided by a real plugin is automatically registered as a dev stub. This ensures the **full kernel service map** is populated and features like UI permissions, automation, etc. don't crash:
84+
Most core kernel services not provided by a real plugin are registered as a dev stub, so the kernel service map is populated and callers get correct return types instead of `undefined`. Each one declares what kind of fake it is (`__serviceInfo`, ADR-0076 D12) — consumers, the dispatcher included, gate on that:
85+
86+
| Class | Slots | Meaning |
87+
|:---|:---|:---|
88+
| `degraded` | `cache`, `queue`, `job`, `file-storage`, `search`, `realtime`, `i18n`, `workflow`, `metadata` | Really does the work, in memory only. Served normally over HTTP. |
89+
| `stub` | `data`, `auth`, plus `ui` (placeholder with no implementation) | Fabricates its answer. Reported as a stub in discovery, and every dispatcher-owned domain answers it exactly as it answers an empty slot. |
90+
91+
**Never stubbed** — these slots stay empty on purpose, which is what production has when the real plugin isn't installed:
8192

82-
`cache`, `queue`, `job`, `file-storage`, `search`, `automation`, `graphql`, `analytics`, `realtime`, `notification`, `ai`, `i18n`, `ui`, `workflow`, `security.permissions`, `security.rls`, `security.fieldMasker`
93+
- `analytics` (#4000). Install `@objectstack/service-analytics` (it runs an InMemory strategy).
94+
- `security.permissions`, `security.rls`, `security.fieldMasker` (#4093). The former stubs answered "allowed" for every permission check, compiled no row-level filter, and returned rows unmasked — inverting the decisions they stood in for. ADR-0076 D12's rule is that a fallback may degrade features, **never security semantics**. Without `@objectstack/plugin-security` nothing enforces RBAC, RLS or field masking, and the boot log says so rather than a fake quietly saying yes.
8395

84-
All services are **optional** — if a peer package isn't installed, it is silently skipped and a stub takes its place.
96+
All services are **optional** — if a peer package isn't installed it is skipped, and for the slots above a stub takes its place.
8597

8698
## API Endpoints (when all services enabled)
8799

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

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,9 @@ describe('DevPlugin', () => {
257257
// Really do the work, just less of it — their answers are true answers.
258258
const DEGRADED = ['cache', 'queue', 'job', 'file-storage', 'search', 'realtime', 'i18n', 'workflow', 'metadata'];
259259
// Fabricate the answer — must never be mistaken for a capability.
260-
const STUB = ['automation', 'notification', 'ai', 'data', 'auth',
261-
'security.permissions', 'security.rls', 'security.fieldMasker'];
260+
// (`security.*` are no longer in this list because they are no longer
261+
// registered at all — see the dedicated test below, #4093.)
262+
const STUB = ['automation', 'notification', 'ai', 'data', 'auth'];
262263

263264
for (const name of DEGRADED) {
264265
const info = readServiceSelfInfo(registeredServices.get(name));
@@ -289,6 +290,105 @@ describe('DevPlugin', () => {
289290
expect(registeredServices.has('analytics')).toBe(false);
290291
});
291292

293+
// [#4093] The one thing a fallback may never fake. ADR-0076 D12, from #3891:
294+
// "a fallback may degrade features, never security semantics" — and these
295+
// three did not merely degrade, they inverted. `checkObjectPermission()`
296+
// answered `true` for everything, `compileFilter()` returned `null` so no
297+
// row-level predicate applied, `maskResults()` returned rows unmasked. The
298+
// contract calls them plugin-security's internals and requires
299+
// access-narrowing answers to fail CLOSED; a fake registered by another
300+
// package under those names is the opposite. The slots stay empty.
301+
it('registers NO security sub-service stub, and says so loudly', async () => {
302+
const registered = new Map<string, any>();
303+
const ctx: any = {
304+
logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
305+
getService: vi.fn().mockImplementation((name: string) => {
306+
if (registered.has(name)) return registered.get(name);
307+
throw new Error('not found');
308+
}),
309+
getServices: vi.fn().mockReturnValue(new Map()),
310+
registerService: vi.fn().mockImplementation((n: string, s: any) => { registered.set(n, s); }),
311+
hook: vi.fn(),
312+
trigger: vi.fn(),
313+
getKernel: vi.fn(),
314+
};
315+
316+
// `security` left ENABLED — plugin-security is mocked as missing at the top
317+
// of this file, so this is exactly the boot that used to get allow-all.
318+
await new DevPlugin({
319+
seedAdminUser: false,
320+
services: { objectql: false, driver: false, setup: false, server: false, rest: false, dispatcher: false },
321+
}).init(ctx);
322+
323+
for (const slot of ['security.permissions', 'security.rls', 'security.fieldMasker']) {
324+
expect(registered.has(slot), `${slot} must NOT be registered`).toBe(false);
325+
}
326+
327+
// "Nothing is enforcing RBAC/RLS/masking" is not a silent condition.
328+
const warned = ctx.logger.warn.mock.calls.find(
329+
(call: any[]) => typeof call[0] === 'string' && call[0].includes('No security services'),
330+
);
331+
expect(warned, 'must warn that security is unenforced').toBeDefined();
332+
expect(warned[0]).toContain('plugin-security');
333+
});
334+
335+
// [#4093] The package is published, has no environment check of its own, and
336+
// registers fakes that report success for work they never did. A production
337+
// process that loads it is misconfigured in a way no runtime behaviour can
338+
// make safe, so refuse the boot instead of degrading quietly.
339+
describe('production guard', () => {
340+
const makeCtx = () => ({
341+
logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
342+
getService: vi.fn().mockImplementation(() => { throw new Error('not found'); }),
343+
getServices: vi.fn().mockReturnValue(new Map()),
344+
registerService: vi.fn(),
345+
hook: vi.fn(),
346+
trigger: vi.fn(),
347+
getKernel: vi.fn(),
348+
}) as any;
349+
350+
// Restored per-case: these tests mutate process.env deliberately.
351+
const withEnv = async (env: Record<string, string | undefined>, run: () => Promise<void>) => {
352+
const saved: Record<string, string | undefined> = {};
353+
for (const [k, v] of Object.entries(env)) {
354+
saved[k] = process.env[k];
355+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
356+
}
357+
try { await run(); } finally {
358+
for (const [k, v] of Object.entries(saved)) {
359+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
360+
}
361+
}
362+
};
363+
364+
it('refuses to initialize under NODE_ENV=production', async () => {
365+
await withEnv({ NODE_ENV: 'production', OS_ALLOW_DEV_PLUGIN: undefined }, async () => {
366+
const ctx = makeCtx();
367+
await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).rejects.toThrow(/NODE_ENV=production/);
368+
// And nothing was registered before the refusal.
369+
expect(ctx.registerService).not.toHaveBeenCalled();
370+
});
371+
});
372+
373+
it('names the escape hatch in the refusal, and honours it', async () => {
374+
await withEnv({ NODE_ENV: 'production', OS_ALLOW_DEV_PLUGIN: undefined }, async () => {
375+
await expect(new DevPlugin({ seedAdminUser: false }).init(makeCtx()))
376+
.rejects.toThrow(/OS_ALLOW_DEV_PLUGIN=1/);
377+
});
378+
await withEnv({ NODE_ENV: 'production', OS_ALLOW_DEV_PLUGIN: '1' }, async () => {
379+
await expect(new DevPlugin({ seedAdminUser: false }).init(makeCtx())).resolves.not.toThrow();
380+
});
381+
});
382+
383+
it('stays out of the way for every other NODE_ENV', async () => {
384+
for (const value of ['development', 'test', undefined]) {
385+
await withEnv({ NODE_ENV: value, OS_ALLOW_DEV_PLUGIN: undefined }, async () => {
386+
await expect(new DevPlugin({ seedAdminUser: false }).init(makeCtx())).resolves.not.toThrow();
387+
});
388+
}
389+
});
390+
});
391+
292392
it('should skip disabled services', async () => {
293393
const ctx: any = {
294394
logger: {

0 commit comments

Comments
 (0)