Skip to content

fix(plugin-dev,plugin-hono-server): retire the security dev stubs; refuse to load under NODE_ENV=production (#4093) - #4126

Merged
os-zhuang merged 2 commits into
mainfrom
claude/dispatcher-handler-ready-1odd28
Jul 30, 2026
Merged

fix(plugin-dev,plugin-hono-server): retire the security dev stubs; refuse to load under NODE_ENV=production (#4093)#4126
os-zhuang merged 2 commits into
mainfrom
claude/dispatcher-handler-ready-1odd28

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

#4093 的 A 档第一步。接 #4082(step 1 分类)与 #4086(step 2 门)。

分类和门对其中三个都不够 —— 原因与 #3891 的 analytics shim 一样:标签是准确的,行为却仍然把它所替代的那个判断反了过来。

security.permissions → checkObjectPermission() { return true; }   // allow-all
security.rls         → compileFilter()        { return null; }    // 无行过滤
security.fieldMasker → maskResults(r)         { return r; }       // 不脱敏

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 #9OS_ALLOW_{X} 形状),用于刻意的场景(模拟生产的 staging、固定该变量的 smoke test),并且在调用点上留下"有人做了这个选择"的痕迹。

3. 顺带: /auth/me/permissions 解析 security.permissionsmetadata 时改用它旁边三个 lookup 早就在用的 guarded 写法。空槽会让 getService 抛异常,原先落到外层 catch —— 响应体一样(fail-open),但每次 console 导航都记一条「/auth/me/permissions failed」,读起来像故障,而不是走下面那个刻意的 !evaluator 分支。

⚠️ 期间发现的更严重问题:#4113(不在本 PR 修,但请优先看)

准备删 auth dev stub 时发现,dispatcher 的 /auth 域自带一个 mock 登录,而且它 —— 不是 dev stub —— 才是空槽时真正应答 /auth/* 的东西:

// packages/runtime/src/domains/auth.ts
if (authService && typeof authService.handler === 'function') {  }
// 2. Mock fallback for MSW/test environments when no auth service is registered
return mockAuthFallback(normalizedPath, method, body);

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 的部分

验证

  • plugin-dev 12 / plugin-hono-server 132 / plugin-security 677 / runtime 923 / objectql 1183 全过;pnpm build 71/71;eslint 干净
  • 新增测试:三个槽位不被注册 + 必须告警(plugin-security 被 mock 成缺失,正是过去拿到 allow-all 的那种启动);生产护栏的三种情形(拒绝且注册前就拒绝、错误信息点名逃逸阀且逃逸阀生效、其他 NODE_ENV 一律放行)
  • 端到端真实启动:装了 plugin-security 时三个真实服务正常注册且新告警保持安静;security: false 时槽位为空且安静;NODE_ENV=production 下 init 抛错且没有注册任何东西,带逃逸阀则整套照常启动

🤖 Generated with Claude Code

https://claude.ai/code/session_018UNGqBQcdJ2RYHtWgntJ9B


Generated by Claude Code

…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
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 10:33am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-dev, @objectstack/plugin-hono-server.

6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/your-first-project.mdx (via @objectstack/plugin-hono-server)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-hono-server)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-hono-server)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-dev, @objectstack/plugin-hono-server)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-hono-server)
  • content/docs/releases/v16.mdx (via @objectstack/plugin-hono-server)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

… 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
os-zhuang marked this pull request as ready for review July 30, 2026 11:20
@os-zhuang
os-zhuang merged commit 4dc14cc into main Jul 30, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/dispatcher-handler-ready-1odd28 branch 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
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants