fix(plugin-dev,plugin-hono-server): retire the security dev stubs; refuse to load under NODE_ENV=production (#4093) - #4126
Merged
Conversation
…fuse 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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
… including the production refusal The drift check flagged `plugins/packages.mdx` for this change, and the entry was already wrong before it: "Metadata validation, schema introspection, debugging tools" describes none of what this plugin does. It composes the real plugins and fills unclaimed service slots with dev implementations — and as of this PR it refuses to initialize under NODE_ENV=production, which is a boot failure a reader of the package catalogue needs to know about before deploying. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B
os-zhuang
marked this pull request as ready for review
July 30, 2026 11:20
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
…e deletion absorbs its subset Main landed #4126 (the #4093 security-trio + production-guard subset) while this PR was in flight. Where they overlap, its shipped choices win: the escape hatch is OS_ALLOW_DEV_PLUGIN, the guard is a module-level assertNotProduction(), and empty security slots get one loud 'NOT enforced' boot warn — all ported into this branch's no-stub end state, which supersedes the rest of its plugin-dev diff (comments and stub-table edits inside deleted code). ADR-0115's reconciliation update gains the #4126/#4113 facts; the auth-stub deletion stands (the runtime /auth mock #4113 tracks is the same fabrication with or without it). Verified post-merge: plugin-dev 10/10, runtime 923/923, plugin-hono-server 132/132, eslint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEM82RNKyyyTNPYHK1VJJS
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
… the deliberately empty slots The drift check flagged plugins/packages.mdx for this change, and the #4126 sentence it carries was made stale by it — the never-stubbed list grew. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…lugins, empty slots stay empty (ADR-0115) (#4137) Executes ADR-0115 in full, absorbing the #4112 and #4126 subsets that landed mid-flight (their choices are canonical where they overlap: OS_ALLOW_DEV_PLUGIN, assertNotProduction(), the loud security warn). The entire stub table is deleted across Tiers A+B+C; service-storage and service-realtime are auto-wired as optional child plugins; docs converge on the assembly reality; the breaking changeset carries the full FROM→TO table per retired slot. Closes #4104. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEM82RNKyyyTNPYHK1VJJS
This was referenced Jul 30, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…login (#4113) (#4179) `domains/auth.ts` carried a `mockAuthFallback` that answered `POST /auth/sign-up/email`, `/register`, `/sign-in/email`, `/login`, `GET /get-session` and `POST /sign-out` with 200 and a fabricated user plus a 24-hour `mock_token_*` session — for ANY email and ANY password, which was never read. It shipped in `@objectstack/runtime`, not behind a dev-only plugin, and gated on nothing but an empty `auth` slot, so `os serve --preset minimal` and any embedder that mounts the dispatcher without plugin-auth served it. It was never a bypass: no session store backs that token, so `resolve-execution-context.ts` still resolved anonymous and `shouldDenyAnonymous` still denied data access. It was wrong in a different way — it told the client the one thing a server must never lie about, that it had authenticated someone, while discovery simultaneously reported `auth: unavailable` and advertised no `routes.auth`. Its stated justification ("MSW/browser-only environments") had no consumer in this repo or in objectui, whose auth tests mock at the HTTP client layer. The only things pinning it were nine tests asserting the mock itself. ADR-0115 retired this class of fabricating fallback inside plugin-dev. This was its last surviving member and the only one that shipped to production; the lineage before it (#3891's analytics shim, #4000's dev stub, the three in #4058/#4086, #4126's security trio) was retired the same way: deleted, not put behind a flag, which would keep the code path in every release and become the next thing nobody audits. 501, not 404, following `/i18n` — the nearest precedent in shape: a core capability, a dispatcher-owned domain, an optional plugin behind it, and a route discovery already declines to advertise when the slot is empty. The route IS mounted; what is missing is the implementation behind it, which is what 501 states and 404 would misdescribe. It also keeps faith with the one true observation the mock was built on — its comment said it existed to keep sign-in "from 404ing" — without the lie it answered that concern with. The message names the remedy. A wrong-shaped occupant (a service without the contract's `handleRequest`) takes the same 501, and that is the sharper case: the slot is FILLED, so discovery advertises `routes.auth` while the request used to get a fabricated session. The `randomUUID` helper goes with it: a CSPRNG built solely to mint mock session ids, dead the moment the mock is. `domains/auth.ts` drops 141 → 81 lines. Two things the deletion left behind, both caught after the fact: the route-envelope ledger's `handBuilt: 4` exemption for `auth.ts` drops to 0 — those four better-auth-shaped bodies were the MOCK's, not the bridge's, since the real service returns its own `Response` built nowhere in this file — and `content/docs/permissions/authentication.mdx`, which documented all six fabricating endpoints as a feature, now documents the 501 and points anyone who relied on it at mocking in the HTTP client layer or loading AuthPlugin (which needs no HTTP server). Tests replace the nine that pinned the mock: all six formerly-mocked paths parametrized so a partial re-introduction names itself, no `mock_`/user/session in the body, the remedy named, an identical answer for any credentials (a 501 that varied by password would be an oracle), a registered service still delegated to, and the wrong-shaped-occupant case. Verified: runtime 940, plugin-hono-server 149, plugin-auth 579, rest 512 pass; build 71/71; pnpm lint and all ten check:* scripts clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#4093 的 A 档第一步。接 #4082(step 1 分类)与 #4086(step 2 门)。
分类和门对其中三个都不够 —— 原因与 #3891 的 analytics shim 一样:标签是准确的,行为却仍然把它所替代的那个判断反了过来。
ADR-0076 D12 自己写的那条(从 #3891 学到的)是:fallback 可以降级功能,绝不能降级安全语义 —— 而它退役的那个 shim 只是丢了调用者的 RLS 作用域,这三个是直接回答"允许"。
packages/spec/src/contracts/security-service.ts:20从另一头说了同一件事:这三个句柄是 plugin-security 的实现内部、不属于任何契约面,而且收窄访问的回答必须 fail CLOSED —— "a consumer must never treat a thrown error or a deny filter as 'no restriction'"。由另一个包在这些名字下注册假实现,正是它的反面。触发条件不需要任何特殊配置:plugin-dev 通过与其他插件相同的可选 dynamic import 加载 SecurityPlugin,所以
@objectstack/plugin-security仅仅是没装,就足以把真实的 RBAC/RLS/脱敏换成 allow-all,背后只有一行warn。改了什么
1. 三个槽位不再被 stub 占住。 空槽正是生产环境没装 SecurityPlugin 时的状态,而消费者本来就能处理:强制执行发生在插件自己注册的 hook 里,唯一读取槽位的地方(
plugin-hono-server的/auth/me/permissions与/me/apps)都是防御式解析,且只在呈现层 fail-open —— 数据本身已经在读路径被强制过。启动日志现在会明说 RBAC / 行级安全 / 字段脱敏没有被强制执行 —— 一个悄悄回答"允许"的假实现从来不会说这句。(该告警只在
security启用但插件缺失时出现;显式services: { security: false }时保持安静。)2. plugin-dev 在
NODE_ENV=production下拒绝初始化。 它是一个已发布(非 private)的包,会为每个空闲核心槽位注册假实现(包括为从未发生的事报成功的那些),却自己没有任何环境判据 —— 一份带着new DevPlugin()的objectstack.config.ts进到生产部署,拿到的就是整套假台面,只有一行启动日志提示。直接让启动失败而不是安静降级:走到这一行的生产进程,其配置错误是任何运行时行为都无法补救的,而这些假实现恰恰属于"看起来能用"的那类。逃逸阀
OS_ALLOW_DEV_PLUGIN=1(按 Prime Directive #9 的OS_ALLOW_{X}形状),用于刻意的场景(模拟生产的 staging、固定该变量的 smoke test),并且在调用点上留下"有人做了这个选择"的痕迹。3. 顺带:
/auth/me/permissions解析security.permissions和metadata时改用它旁边三个 lookup 早就在用的 guarded 写法。空槽会让getService抛异常,原先落到外层 catch —— 响应体一样(fail-open),但每次 console 导航都记一条「/auth/me/permissions failed」,读起来像故障,而不是走下面那个刻意的!evaluator分支。准备删
authdev stub 时发现,dispatcher 的/auth域自带一个 mock 登录,而且它 —— 不是 dev stub —— 才是空槽时真正应答/auth/*的东西:POST /api/v1/auth/sign-in/email用任意邮箱 + 任意密码(密码根本没被读取)→ 200 + 伪造的 user +mock_token_*session + 24 小时有效期。它在packages/runtime里(跟着发行版走,不是 dev-only),判据只有"auth 槽位有没有服务"。不是越权(token 没有 session 存储支撑,身份解析仍返回匿名),但它对客户端伪造了认证成功 —— 而且discovery那边正确地报着auth: unavailable,两边直接矛盾。顺带确认:plugin-dev 的 auth stub 从来没被这个分支调用过 —— 域要求
.handler方法,stub 实现的是handleRequest。所以 #4093 A 档里的auth一项挂在 #4113 上,本 PR 不动它。刻意留在 #4093 的部分
data—— 删之前要先修http-dispatcher.ts的data: svcAvailable(routes.data, 'kernel')(没传 svc,任何占槽者都被报成available)。ui—— 删之前要先把routes.ui的判据从ui槽位改到真正服务/ui的protocol,否则会误伤一条实际能用的路由。auth—— 见上,挂在 dispatcher 的 /auth 域内置 mock 登录:无 auth 服务时任意邮箱+任意密码都返回 200 + 伪造 session token —— 在发行装配里,不是 dev-only #4113。验证
pnpm build71/71;eslint 干净NODE_ENV一律放行)security: false时槽位为空且安静;NODE_ENV=production下 init 抛错且没有注册任何东西,带逃逸阀则整套照常启动🤖 Generated with Claude Code
https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B
Generated by Claude Code