fix(metadata,spec): publish 门兜底 —— publishPackage 接门 + 匹配器装载期拒绝(#5040 E7b) - #5203
Merged
Conversation
#5040 E7b) E7 (#5111) hung the five per-endpoint `apis:` gates on `ObjectStackDefinitionSchema`, covering every path that parses a STACK. #5189 proved a stored `api` item need never have been part of one: `MetadataManager.publishPackage`, a direct `metadata.register()` and a Studio metadata write each mint one item at a time and saw no gate at all. Three of the five gates degrade safely when bypassed — the executor answers a structured 501 naming the item, and a path outside the `apps/<namespace>/` carve-out matches nothing. ADR-0121 D6 has NO runtime counterpart: the runtime honours `authRequired: false` faithfully and `deriveBucketConfig` returns null for a budget whose `enabled` is not `true`, so the bypass minted an anonymous, zero-quota execution entry point — the exact shape D6 forbids. Two doors, one judge: - publish — `publishPackage` runs `validateApiEndpointDeclarations` over the package's `api` items and fails the publish on its existing `validationErrors` surface, naming each endpoint and the key to fix. NOT governed by `options.validate`: an opt-out on a security gate is the bypass this closes. An item that does not satisfy `ApiEndpointSchema` fails here too — an unparsed shape cannot be gated, and the matcher refuses it at load anyway. - load — `buildEndpointIndex` re-applies the identity-free subset (supported subset, mapping, policy/D6) to every parsed item via the new `identityFreeEndpointGateFailure`, which delegates to the SAME `firstFailure` the full gate runs. A declaration that never passed publish is EXCLUDED and named at `error` level, so the bypass answers 404 with a loud log instead of answering anonymously and unmetered. The namespace and uniqueness gates are deliberately not applied there: both need a stack identity a stored row does not carry, and deriving a namespace from the very path being judged would be circular. Identity at publish: `MetadataManager` indexes items by `packageId` and carries no manifest, so it cannot prove a namespace. New additive option `publishPackage(id, { namespace })` lets a caller holding the manifest supply the explicit `manifest.namespace`; without it the namespace gate fails and the package's `api` items do not publish — the rule, not a limitation. It will NOT infer one from the items being judged: an author-supplied value would make the D1/D2 carve-out gate vacuous. Packages declaring no `api` items are untouched. `packages/spec/src/api/index.ts` now exports the gate module's four public names (it was package-internal in #5111, whose only consumer was one file away); `api-surface.json` moves by exactly those four on `./api`. Test fixtures in `endpoint-matcher.test.ts` / `metadata-manager-match-endpoint.test.ts` gained the `objectParams` E7's target gate requires — without it they describe an endpoint the runtime would 501 on, which the load gate now (correctly) excludes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 108 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 4, 2026 09:10
os-zhuang
enabled auto-merge
August 4, 2026 09:10
os-zhuang
marked this pull request as draft
August 4, 2026 09:12
auto-merge was automatically disabled
August 4, 2026 09:12
Pull request was converted to draft
…lish-gate-backstop
os-zhuang
marked this pull request as ready for review
August 4, 2026 09:17
os-zhuang
enabled auto-merge
August 4, 2026 09:17
This was referenced Aug 4, 2026
Open
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Aug 4, 2026
…-ai#5040 E8) (objectstack-ai#5230) * test(e2e,showcase): 执行器收官验收 —— showcase 端点回迁 + 真实 boot e2e (objectstack-ai#5040 E8) objectstack-ai#4936 把 showcase 的两条 `apis:` 声明注释掉,理由是当时端点面全链路零执行:没有 挂载、没有匹配器、每个键(包括 `authRequired`)解析通过而不生效。E1–E7 建成执行器 并把整面硬拒收窄为逐端点 publish 门之后,那条理由不复存在。本单把两条声明按原意 恢复 —— 同名、同 target、同 `authRequired`、同 `cacheTtl` —— 只做 ADR-0121 D1 要求的一处修改:路径迁入本应用的命名空间保留区 `/api/v1/apps/showcase/…`。 - examples/app-showcase:恢复 TaskFeedEndpoint / InquiryPurgeEndpoint;coverage 的 `apis` 从 waived 翻回 demonstrated(理由改写为「由真实 boot 测量」而不是 「声明即证明」—— 这正是 objectstack-ai#4936 抓到的那类假覆盖);gap-fill 的反向断言改回正向, 并新增命名空间与「零匿名面」两条静态钉子。 - packages/qa/dogfood:两个真实 boot e2e。showcase 一份走真实 artifact 摄入路径 (MetadataPlugin + local-file),证明匹配命中执行、find 的 data 与内建 /data 路由逐字节相同、匿名 401、cacheTtl 只随成功答案、挂载点下未声明路径与挂载点外 的裸 404 完全一致、/meta/api 与 /openapi.json 描述的正是挂载的东西;fixture 一份补上 ADR-0121 D6 的匿名分支 —— 省略 authRequired 拒绝匿名、显式 false 服务 匿名、已装配预算耗尽后 429 且 Retry-After 真的在线上。 RED-first:两份断言先于 showcase 回迁写就并运行,pre-restore 状态下 9 条失败(全部 是 objectstack-ai#4936 记录的那个裸 404),回迁后 14 + 7 全绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd * test(e2e): 直写拒绝探针接上 E7b,fixture 用 ApiEndpointInput 表达「省略 authRequired」 E7b(objectstack-ai#5189/objectstack-ai#5203)在 main 上落地之后,「一次绕过 publish 的直写不会变成活路由」 从待办 TODO 变成可以真实断言的事:往 store 里直接写一条 `authRequired: false` 且没有已装配预算的 api 条目(D6 最危险的那个形状),两种调用者都必须拿到 404 —— 不是「因为匿名所以 401」,而是压根没有这条路由。 同时把 fixture 里那条「故意省略 authRequired」的声明改标 `ApiEndpointInput`: `ApiEndpoint` 是 schema 的输出类型,`.default(true)` 已被物化,该键在上面是必填, 所以用它做注解的 TS 作者根本写不出升级指南推荐的那个安全形状(另立 objectstack-ai#5227 记录)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --------- Co-authored-by: Claude <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.
Fixes #5189
Part-of #5040(E7b)。前置 PR #5188(
d21c001)已落 main,门函数在。事实
E7(#5111)把五道逐端点门挂在
ObjectStackDefinitionSchema上,覆盖了所有解析 stack 的入径。#5189 证实:一条存量api条目根本不必属于任何 stack ——MetadataManager.publishPackage、直接metadata.register()、Studio 元数据写接口,三条路各自逐条造条目,一道门都没经过。绕过后的后果按门分化:
apps/{namespace}/carve-out 之外的路径干脆匹配不上;authRequired: false,而deriveBucketConfig对enabled非true的预算返回null、不计量。于是绕过铸出一个匿名可达、零配额的执行入口 —— 正是 D6 要防的形状。处置:两道门,一个判据
采用 issue 建议的 A + C,关键在于两层跑的是同一个门函数,不产生第二套判据。
1. Publish 层 ——
publishPackage对包内api条目跑validateApiEndpointDeclarations,失败即在它既有的validationErrors出口上让整次发布失败(发布本就是原子的),逐条点名端点与待修的 key。这一遍不受
options.validate管辖:其余检查都是尽力而为的质量检查,关掉是便利;这道门决定的是「一个对外可达、可能匿名的执行入口是否诞生」,而 D6 没有运行时对偶接得住漏网的。给安全门加开关,就是本单要关掉的那个绕过。不满足
ApiEndpointSchema的条目也在此失败 —— 这不是额外严格,而是前置条件:没解析出来的形状无从过门,而且它在装载期也会被匹配器的响亮跳过拒之门外。2. Load 层(纵深防御) ——
buildEndpointIndex对每条已解析条目再跑一遍免身份子集(不支持子集 / 映射 / policy 含 D6),经新增的identityFreeEndpointGateFailure委托给全量门同一个firstFailure。未过门的条目被排除出索引,并以与解析失败跳过相同的error级别点名。绕过因此从「匿名零配额可执行」变成「404 + 一条响亮日志」—— 安全的失败姿态。刻意不对称,值得写明:命名空间门与唯一性门不在装载期施加。前者需要
manifest.namespace,而一条存量行不携带 manifest —— 从「正被审判的那条 path」反推命名空间是循环论证;后者是 per-stack 规则,而下方既有的重复认领裁决已在全库范围覆盖同一问题。身份:publishPackage 知道命名空间吗?
不知道。
MetadataManager只按packageId索引条目,不存在 manifest 概念(全文件无manifest一词)。因此新增加性选项
publishPackage(id, { namespace }):持有 manifest 的调用方传入显式manifest.namespace,即可跑全量门;不传则命名空间门失败,该包的api条目发不出去 —— 这是规则本身,不是待绕开的限制(一次无法证明命名空间的发布,不该在某个命名空间下铸出 URL)。它不会从被审判的条目里反推命名空间:条目上的
namespace是作者填的数据,不是身份,采信它会让 D1/D2 的 carve-out 门形同虚设(作者只需把命名空间填成自己 path 已经用的那个)。不含api条目的包完全不受影响(该遍直接早返回)。契约面
packages/spec/src/api/index.ts导出门模块的四个公共名(#5111 时它是包内私有,因为唯一消费者就在隔壁文件;#5189 证明 stack schema 不是唯一的门)。api-surface.json恰好只在./api上多这四项:门模块自身的判定逻辑一字未改,只加了一个委托给同一
firstFailure的加性导出。测试夹具的一处修正
endpoint-matcher.test.ts/metadata-manager-match-endpoint.test.ts的基础夹具补上了 E7 target 门要求的objectParams。没有它,那个夹具描述的是一条运行时会答 501 的端点 —— 装载期门(正确地)把它排除,于是它作为夹具也就没了意义。同理,preserves an explicit authRequired: false一例补上了已装配的预算:D6 之后,那是「authRequired: false能原样往返」仍然可观测的唯一形状。验证
@objectstack/metadata的tsc --noEmit原始报错全部落在既有测试文件的 TS2835(无扩展名相对导入,既有债),本 PR 新增/改动的源文件与新测试文件 0 error。新增测试:
packages/metadata/src/publish-endpoint-gate.test.ts,15 例)—— D6 无限流被拒并点名端点+key;rateLimit存在但未装配(enabled默认false)被拒;装配后放行;validate: false关不掉;其余四门同行;命名空间缺失时只报一次并带本路径自己的补救;不从条目反推命名空间;发布信封metadata与裸文档两种存储形状都读得对;无api条目的包零影响;一条坏端点让整包发布失败、好条目仍是 draft。endpoint-matcher.test.ts+8 例,metadata-manager-match-endpoint.test.ts+2 例)—— D6 违规条目被排除、日志响亮、匹配器 miss;装配预算后可服务;其余免身份门同样排除;命名空间门不施加(并写明为何);未过门条目不从合法的重复认领者手里抢走路由;既有的响亮跳过与重复认领测试全绿。apis-publish-gates.test.ts+6 例)—— 免身份子集与全量门的一致性,以及两处不对称(命名空间、唯一性)的正反两面。变更集
.changeset/endpoint-publish-gate-backstop.md——@objectstack/specminor(新导出)+@objectstack/metadataminor(新namespace选项 + 发布/装载行为收紧)。未触碰content/docs/releases/。越范围发现(已另行归档,不在本 PR 内修)
#5206 ——
api根本不在DEFAULT_METADATA_TYPE_REGISTRY/BUILTIN_METADATA_TYPE_SCHEMAS里,所以metadata-protocol的saveMetaItem对api条目一道校验都不跑(比 #5189 正文推断的「只按ApiEndpointSchema校验」还弱一层);同时publishPackageDrafts(Studio「全部发布」的真实入径)也没有第一道门。本 PR 的第二层已经兜住了它的安全后果(未过门条目一律不进索引),因此 #5206 降级为「拒绝得晚、而非拒绝不了」+「活类型没有契约」,不再是安全洞。文件面在packages/metadata-protocol+packages/spec/src/kernel,与本单互斥。🤖 Generated with Claude Code
https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd