fix(rest): :type 段每 handler 归一一次,复数拼写不再绕过 §6.7 audience 门禁 (#6241) - #6348
Conversation
…al spelling cannot skip the §6.7 audience gate (#6241) The single-item metadata read's cached branch excluded `doc` / `book` by comparing the RAW `:type` path segment against singular literals. The route serves both spellings and Prime Directive #3 makes the plural one canonical, so `GET /api/v1/meta/books/:name` did not match the exclusion, took the cached branch, and the ADR-0046 §6.7 audience gate — which lives in the uncached branch — never ran. `enableCache` defaults to true, so the failing path was the default one, and the failure was fail-open: a `{ permissionSet }`-gated book was served in full to a signed-in caller holding no set. This is #3984 recurring in the same file eight days later, so the fix takes the structural form #3984 already ruled rather than correcting two literals: the handler normalizes once at the top (`metaType`) and every gate below reads that local. The cache exclusion and the §6.7 gate now share one predicate (`isAudienceGatedType`), so they cannot drift apart. Also adds `check:meta-type-normalized` — an AST-based guard (comments invisible, so the file's own post-mortems still quote the bad pattern) refusing any raw `:type` comparison, switch discriminant or membership test in packages/rest/src. Zero exemptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31189600460 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Queue steward — re-queued as-is (队列管家原样重投). Ledger row: cross-repo common table → "GitHub Actions runner lost / npm registry 5xx / network timeout (infrastructure jitter, unrelated to the diff)" ⇒ known environment jitter ⇒ re-queue unchanged. No code change is being asked of the lane, and this seat changed nothing on the PR beyond re-arming auto-merge: no rerun, no ready/draft flip, no label or claim change. Full signature (taken from the complete job archive, ⛔ not the tail — SKILL note 7)Run 1. 2. Why this is diff-independent (the ledger row's defining property)
The bot triage comment above lists the failing step as "日志不可读,点进 job 看" (log unreadable); the archive above is that log. State readings
Nothing here changes the fix's scope, so per note 2 this hit is not appended to any flaky issue — plain hit-counting does not earn a timeline entry. Generated by Claude Code |
Fixes #6241
GET /api/v1/meta/books/:name(复数,PD #3 的规范拼写)整条绕过 ADR-0046 §6.7 的audience 授权:单条元数据读取 handler 的缓存分支进入条件,对
doc/book的排除写的是字面量比较,而 §6.7 门禁写在
else(非缓存)分支里。enableCache默认true,所以出问题的那条路径就是默认路径。这是 fail-open——错误的结果是把门控文档发出去,
不是拒服务。
前提重验(实读
origin/main@026101660,rebase 后含 #6303,逐条成立)rest-server.ts:4316… && req.params.type !== 'doc' && req.params.type !== 'book'rest-server.ts:4461起(audienceGatedType局部量)if里 app / dashboard 是归一化比较:4252isAppType、:4315isDashboardTypeif内)PLURAL_TO_SINGULAR含 docs/booksspec/src/shared/metadata-collection.zod.ts:144-145docs: 'doc'/books: 'book'enableCache默认 truemetadata块即取默认排除不是顺手写的整洁,是被字面量比较辜负的安全不变量——条件正上方的注释一直写着:
这句话在位,而它下面那行放行了复数拼写。
修法:按 #3984 已裁的结构形式,不是在两个错的旁边加第三个对的
#3984 裁的是「每个 handler 顶部归一一次,后续所有闸门都用归一后的值」。本 PR 就按这个形状做:
一个额外的收敛值得单说:缓存排除与 §6.7 门禁现在读同一个谓词
isAudienceGatedType(
:4371的排除、:4516的门禁)。绕开缓存的唯一理由就是让那道门禁可达,所以「哪些类型绕缓存」和「哪些类型受 audience 门禁」本就该是同一个事实;以前它们是两处独立书写的清单,可以各自漂移——
这次复发正是漂移的产物。将来第三个受门禁的类型只需改一处,两个站点同时跟上。
残余
req.params.type比较清点(全文件,逐条处置)改前全仓
rest-server.ts里的字面量比较只有:4316那两处(其余按类型的闸门在 #3984 时已归一)。改后全文件零处代码级
params.type ===/!==比较,node scripts/check-meta-type-normalized.mjs实测确认。handler 内剩下 5 处
req.params.type,全部是透传,不是判断,逐条给出不改的理由::4272getMetaItemLayered({ type: … })protocol.ts多处PLURAL_TO_SINGULAR[type] ?? type):4386getMetaItemCached({ type: … }):4445translateMetaEnvelope(req, …)(缓存分支):4454getMetaItem({ type: … }):4579translateMetaEnvelope(req, …)(非缓存分支):4445判据是:REST 层不得拿未归一的值做判断;把原始拼写交给自己负责归一的下游(protocol)是正确的,
Prime Directive #12 的方向也在这边——不在消费侧加宽容,而是让归一发生在拥有该职责的那一层。
陷阱与取舍:复数 doc/book 失去 ETag 快路径
归一化后
docs/books复数读取从缓存分支移到非缓存分支(单数拼写一直如此),于是这两类响应不再带
ETag/Cache-Control,条件请求也不再答304。与 #5881 对dashboard的取舍同源,理由更硬:
ETag 是对未过滤文档的哈希,per-caller 的门禁结果根本进不了这个验证器;
getMetaItemCached本就委托给getMetaItem,服务端两条路做的是同样的工作,让出的只有 304 省下的响应体字节。
其它元数据类型不受影响(阳性对照见下)。
既有测试重判:零个需要改,但原因值得写下来
全仓扫过没有任何既有用例钉住「复数 doc/book 走缓存」。不是因为覆盖到了,而是因为看不见:
meta-audience-plural.test.ts(#3984 的钉子)与meta-item-envelope.test.ts的 protocol double都不提供
getMetaItemCached,于是每次读取都落到非缓存分支——那条分支上门禁一直是好的,测试也就一直是绿的。#3984 的钉子测的是真实缺陷,却测在一个默认部署根本走不到的分支上。
这与 #5881 对 dashboard 的假绿是同一个形状,本 PR 的新用例正是补这个洞:新 describe 用
提供两条读取的 double(默认部署的样子)。
packages/cli/packages/lint里命中books的两个文件已实读,是 manifest 字段名与 lint fixture,不消费本 handler,无需扫动。
反向验证(先预测方向,再跑)
预测:恢复字面量比较后,复数钉子转红;单数钉子保持绿(字面量对单数仍然成立);
非门控类型的阳性对照保持绿;「未被认领的 doc 仍可读」这条会绿,但绿得没有信息——
它两种实现下都是 200。预计 3 红 / 7 绿。
实测(
vitest run src/meta-audience-plural.test.ts):3 failed | 7 passed,方向与逐条身份都吻合。第三条的输出是这次最有说服力的证据:它直接显示门控 book 是带着对未过滤文档的共享 ETag
发出去的——正是注释里那句 "a shared ETag would leak gated content across viewers" 描述的机制。
验证后已还原,复跑全绿。
门禁自身也做了同向验证:恢复字面量后
check:meta-type-normalized报 2 处(
rest-server.ts:4345 [comparison] req.params.type !== 'doc'/!== 'book'),exit 1;还原后 OK。搭载的 check:* 门禁(分诊第 2 条建议)
scripts/check-meta-type-normalized.mjs+package.json一条 script + ESLint job 内一步。代价确实小,所以同 PR 搭载。三点设计说明:
rest-server.ts的多段 JSDoc 引用了坏模式原文(
req.params.type === 'book')来解释历史,文本扫描会把复盘记录本身判红,逼下一个写复盘的人把话说拧。AST 看不见注释,守卫与史料因此可以共存(self-test 里专门钉了这一条)。
===/!==/==/!=两侧、switch判别式、includes/has/indexOf成员测试。不覆盖先赋值给局部再比较(需要数据流分析)——脚本头部明写,不含糊其辞。
透传不算判断,不报(理由同上表)。
packages/rest/src最后一处裸比较,所以这道门禁从零起步,不是从 ratchet 起步。
史实(查证后才写)
分诊留了个未断言的问题:缓存分支早于还是晚于 #3984 的修复。本 PR 不陈述该结论——
git log/git blame未做到足以下断言的程度,而按纪律「不确证就不写」。它不影响修法。验证
packages/rest无typecheck脚本(type-check-coverage 的 DEBT 条目,记errors: 2),故手工跑 tsc 对基线。顺带发现(不在本 PR 修,另立单)
同一批 handler 把原始拼写透传给
translateMetaItem,而isTranslatableMetaType查的是TRANSLATABLE_METADATA_TYPES—— 一个只含单数的集合(view/action/object/app/dashboard/page)。于是规范的复数拼写拿不到本地化。实测(真
RestServer.translateMetaItem,zh-CN bundle):同属 #3984 的「拼写敏感」家族,但不是授权问题(是 i18n,观感级),且落点跨三个 handler 加一个共享
helper——修在本 PR 里既越界又只会修一半。已另立单跟踪,本 PR 保持透传原样。
Generated by Claude Code