fix(rest): 服务端闸门也过滤 areas[].navigation —— area 内导航项权限不再只是渲染层的礼貌 (#4722) - #4751
Merged
Conversation
…level tree (#4722) `filterAppForUser` is the server-side authority for app metadata visibility, but it walked only the app's top-level `navigation` tree: it returned early when `navigation` was absent and never read `item.areas` at all. An item gated inside an area was therefore enforced by the shell alone — the entry, and the `objectName` / `pageName` / `componentRef` it points at, still shipped in the `/meta` body, so reading the JSON (or poking client state) defeated the gate. The same `filterNav` now runs over every `areas[].navigation`. Reusing it is the point: one implementation means the two trees cannot drift on what `requiredPermissions` / `requiresService` mean. Both read paths are covered because both go through this function — the list `GET /meta/:type` and the single-item `GET /meta/:type/:name` (which already bypasses the shared cache for apps so this per-user filter can run). Collapse rule, taken from what the top-level tree already does to a `group`: an area emptied BY the gate is dropped (a bare label with nothing reachable under it has no consumer value), while an area authored empty is passed through — filtering reports what the caller may not see, it does not tidy the metadata. `resolveRegisteredServices` had to learn the same walk. It took one child list per node (`navigation` / `children` / `widgets`, first match wins) and never descended into `areas`, so a service named only inside an area went unprobed — and an unprobed name is absent from `registered`, which the gate reads as "service missing" and would strip a live entry. The probe set now covers exactly what the filter walks. Deliberately unchanged: `visible` (CEL) stays a client-side gate at every level — evaluating it server-side needs a bound `user` context this read layer does not have, and is its own change. The asymmetry is recorded in the code comment, in the liveness ledger, and in a characterisation pin. The retired AREA-level keys (#4651) are not revived; what is enforced is the item gate inside an area. The `areas is not walked` characterisation pin fails as its own comment predicted and is rewritten here, together with the `areas.navigation` note in `packages/spec/liveness/app.json` that asserted the same thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
…ter-app-areas-nav
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 3, 2026 02:58
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.
Fixes #4722
问题
filterAppForUser(packages/rest/src/rest-server.ts)是 app 元数据的服务端权威可见性闸门,但它只走顶层navigation树 ——if (!nav) return item提前返回,item.areas从头到尾没被读过。于是写在 area 内部导航项上的requiredPermissions/requiresService只有客户端NavigationRenderer会执行:该条目连同它的objectName/pageName/componentRef指向,照常出现在/meta响应体里。改一次前端状态、或者直接curl一下/meta的 JSON,就能看到本该被 gate 掉的条目。对 areas 型 app 而言,导航项级闸门此前不是服务端强制。改法
同一个
filterNav复用到每一棵areas[].navigation上 —— 不是第二份实现,所以两棵树对同一个键的语义不可能漂移(这是裁定第 2 问的要求)。列表GET /meta/:type与单项GET /meta/:type/:name两条路径都覆盖:两者都经过这个函数,且单项读对 app 类型本就绕过共享缓存,正是为了让这个 per-user 过滤能跑。同一提交修正了
resolveRegisteredServices的探测面。 它此前每个节点只取第一个命中的子数组(navigation/children/widgets三选一),不下钻areas。若不改,只在 area 内被引用的服务名根本不会被探测,而未探测的名字在闸门看来等同于「服务不存在」 —— 会把一个服务确实注册着的条目误剥离。探测面必须与过滤面完全一致,这不是顺手改,是本改动的正确性前提(有专门的 pin)。三问的落实
1. 空 area 返不返回 —— 实测到的顶层既有行为,照抄。
顶层
filterNav的分组处理(rest-server.ts第 1836-1854 行,本 PR 未改动这段逻辑,只是复用):只有当e.children是非空数组时才进入过滤分支;过滤后,若该条目type为 group 且孩子被滤光,continue掉它 —— 即剥离;否则带着过滤后的 children 保留。而children缺失或本来就是空数组的条目走 else 分支,原样保留。所以既有形状是:被闸门滤空的分组剥离,作者本来就写成空的分组保留。area 照此同形 —— 被 gate 滤空的 area 整个剥离(空壳标签没有消费价值),作者写成
navigation: []的 area 原样返回。过滤只负责报告调用方看不到什么,不负责整理元数据。这条区分有独立 pin(an area emptied BY the gate is dropped; an area authored empty is passed through)。2. 两棵树一致 ——
filterNav复用,零复制。area 内的嵌套 group 折叠行为也自动与顶层一致(有 pin 对照)。3.
visibleCEL 明确不做 —— 服务端求值需要绑定user上下文,不是这个读路径现有的能力。这个不对称写进了三处:filterAppForUser的文档注释、packages/spec/liveness/app.json的账本 note、以及rest.test.ts里一条新的 characterisation pin(钉住「服务端在任何层级都不求值visible」,并写明谁把 CEL 搬到服务端就该看到它红)。requiresObject同理仍是客户端。退役键未复活:#4651 移除的 area 级
visible/requiredPermissions一个都没回来。本次强制的是 area 里面的项级闸门 —— 这正是 #4651 留下的真缺口。已预期的钉,已按预案改写
rest.test.ts里characterises the boundary the #4651 prescription warns about: areas is not walked那条 —— 它的注释写着「谁让服务端开始走 areas 就该看到它红,然后连同账本 note 一起有意识地改写」。这就是那次改写:重写成AREA level: the item gate applies inside areas[] too,并同步改写packages/spec/liveness/app.json的areas.navigationnote(以及顶部_note里同一句已经变假的陈述、navigation.requiredPermissions与navigation.visible两条 note)。packages/spec/liveness/app.json这一个文件的账本 note —— 派发时开的单文件例外。packages/spec/**其余一行未改。留了一个尾巴,已单独立单
packages/spec/src/ui/app.zod.ts:662的AREA_REQUIRED_PERMISSIONS_RETIRED处方正文仍写着「the server does not walkareas」,packages/spec/src/ui/app.test.ts:1374还有一条断言把这个措辞钉死。改它是处方正文 + pin + spec 生成物的耦合改动,撞上本单的 spec 零改动约束,故按 Prime Directive #10 记为 #4749(未认领)。方向上它是「过度保守」而非不安全 —— 劝作者把敏感项挪到顶层树,这仍然可行 —— 但它是错的,而且是说给作者听的那一份。验证
新增/改写的测试先证明它们能红:把
rest-server.ts的改动 stash 掉后重跑,rest.test.ts:Tests 6 failed | 191 passed—— 4 条 AREA level + 2 条[#4722]requiresService/探测面meta-app-area-nav-gate.test.ts(新增,路由级):Tests 3 failed | 2 passed路由级那一份是验收标准的直接证据 —— 断言的是响应体字节:无权限调用方拿到的
/meta列表与单项 JSON 里,secret_forecast/secret_ops_page/secret_sys_user这些 gate 掉条目的指向一个都搜不到,同时有权限的调用方拿到完整树(不过度过滤)。其他:
pnpm --filter @objectstack/spec check:liveness✓(账本改动通过,evidence 行号已同步);eslint --no-inline-config三个文件干净;tsc --noEmit -p packages/rest错误数 67,与origin/main基线逐一相同(该包在 #4311 的 DEBT 账本里,无typecheck脚本);node scripts/check-type-check-coverage.mjsOK。已合入最新origin/main(#4742)后重装、重建、重跑,增量提交不与packages/rest/packages/spec重叠。消费方注意(changeset 已写清)
服务端响应形状收紧:无权限用户拿到的 app 元数据里,被 gate 的 area 内导航项不再出现,被滤空的 area 整个不见。任何依赖「服务端全量下发、客户端自己藏」的消费方需要改为信任服务端已过滤的树 —— 这正是本次收紧的目的。
🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny