feat(spec,runtime,hono)!: 入站 rateLimit 从零建 seam —— 授权预算真正产生 429 (#4910, #4937) - #5006
Merged
Merged
Conversation
… 429 (#4910, #4937) `packages/spec` declared three `RateLimitConfig` embeddings with zero readers repo-wide, and `runtime/security/rate-limit.ts` held a token bucket with zero call sites whose comments described, in the present tense, an execution chain that did not exist. Neither half was broken; they were never connected, and both were documented as if they were. This builds the seam the 2026-08-03 adjudication specified: - new NARROW `server:` stack key (`security.rateLimit` + `trustProxy` only — the other seven `HttpServerConfigSchema` keys stay unreachable, #4938), strict from birth, rejecting an unusable budget at `defineStack`; - `createDispatcherPlugin({ rateLimit })` builds the limiter and installs it as global middleware in `init()`, so it gates every route the server mounts; - keyed by resolved principal, falling back to caller IP; forwarded headers honoured only under an explicit `trustProxy`; - counters in the kernel cache (ADR-0069 D2) via plugin-auth's lazy resolution, with an announced per-process fallback naming the consequence; - `IHttpServer.use()` made a real middleware seam — the Hono adapter passed `{}` for req/res and always called `next()`, so no middleware could ever act. Endpoint-level `rateLimit` stays knowingly unwired (#4936), registered as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
…iter can install in start() (#4910) Registering the rate-limit middleware in the dispatcher's `init()` meant reading the `http.server` registry while Phase 1 was still filling, drawing a terminal "no transport" conclusion from it, and recording that conclusion in a warn — the exact three-part shape `check:startup-registry-verdict` exists to stop (#4771). Cured structurally rather than tolerated. `HonoHttpServer` now mounts its chain runner via `installMiddlewareSeam()`, which `HonoServerPlugin.init()` calls at the very end — after CORS/Server-Timing (so a 429 still carries CORS headers) and before any route exists. `use()` appends to a chain that runner reads per request, so registration order stops deciding coverage, and the dispatcher can install the limiter in `start()` where "no http.server" is a settled fact. Covered both ways: a route mounted by an earlier plugin's start() is still gated (integration), and the adapter pins the negative case too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
…ound-ratelimit-seam
… four-step) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 5 package(s): 119 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
xuyushun441-sys
marked this pull request as ready for review
August 3, 2026 20:10
xuyushun441-sys
enabled auto-merge
August 3, 2026 20:10
This was referenced Aug 3, 2026
xuyushun441-sys
pushed a commit
that referenced
this pull request
Aug 4, 2026
Brings in #5003 (批 13), #5006, #4983, #4991, #4855, #4729, #4250. Ledger `ui/` section conflicted, as planned — 批 13 and 批 14 both edit it. Every row from both sides kept; nothing resolved in favour of a side: - classification table: ONE merged `no door` row (批 13 authored the class; 批 14 adds the positive-control requirement and the first file that SPLITS across it, ui/sharing.zod.ts). - triage table: structure from 批 13 (it split `responsive` out and grouped the five no-door files); 批 14's four measured verdicts overlaid. - remaining-strip map: `responsive` row deleted by 批 13, `action`/`report`/ `dataset`/`dashboard` rows deleted by 批 14 — all five by the reverse pin. Header and subtotal RECOMPUTED FROM THE SURVIVING ROWS, not decremented: 29+20+14+9+7+7+6+4+4+4+3+1+1+1 = 110. 批 13 wrote 119 (against a tree where 批 14's four rows still existed), 批 14 wrote 114 (against one where `responsive` still did); both were right against their own branch and both are wrong against the merge. The fifth and sixth instances of the failure the automation/ section documents, and the first where the two wrong numbers were both this line. Subtotal 88 of 110 authorable, 22 in the fourth class. check:strictness-ledger arbitrates and is green. os-regen four-step: merged (never rebased), refreshed install + spec build, reset all seven generator-owned paths to origin/main, regenerated wholesale. Delta vs origin/main is exactly one file — content/docs/references/ui/sharing.mdx — so the regeneration reproduced main's artifacts byte-identically and no sibling entry was dropped (#5006's RateLimitConfig/RateLimitConfigSchema and 批 13's responsive entries confirmed present). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
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 #4910
Fixes #4937
父单 #4686(「spec 两份 RateLimitConfig 全仓零 reader」)的入站半边;出站半边已由 #4911/#4947 摘除,两边文件面不相交。按 2026-08-03 维护者裁决(#4910 comment 5169379611)的四问组合执行:Q1=B / Q2=B / Q3=C / Q4=B,不再重议。
前提修正:这不是「接线」,是「从零建 seam」
立单假设 runtime 的 token bucket 已在服务 dispatcher 的 429 短路。上一轮开工核查推翻了它:
RateLimiter零调用点(除自己的单测),全仓入站路径没有任何 429,而rate-limit.ts的注释用现在时描述了这条不存在的执行链,连它让你去调的DispatcherPluginConfig.rateLimit字段都不存在(#4937)。所以本 PR 同时关掉两个单:两个半成品各自都不算坏,它们只是从未相连,而两边的文档都写得像已经相连。
作者写什么
server:是新的顶层 stack 键。此前无人声明得出来(全仓扫描:存量栈与示例应用零命中),所以没有激活语义 —— 不存在「昨天惰性、今天开始吃 429」的配置,因此不需要 ADR-0087 D3 semantic migration,也不需要 upgrade guide 条目。它刻意窄:只承载
security.rateLimit与trustProxy,因为只有这两个键有消费者。它不是九键的HttpServerConfigSchema—— 另外七个既无 reader 又无作者面,顺车挂上来等于一次性把七个死键变成可写(它们的存废仍归 #4938)。从出生即 strict(#4001 战役标准,走strictObject),拼错的预算带修正被打回,maxRequests: 0在defineStack就被拒。没有
server.port。 监听端口属于部署而非制品,objectstack serve -p已经拥有它;两个权威争一个数字,配置就退化成建议。优先级规则提前写进 schema describe 与文档,避免日后逐调用点重议:CLI 参数 >server:> 内置默认。发生什么
服务器路由的每一个入站请求(REST、dispatcher、service routes,全部)从令牌桶取token:
capacity = maxRequests,refillPerSec = maxRequests / (windowMs / 1000)。桶空 → 429 +Retry-After(由桶自己的retryAfterMs算出,所以告诉客户端的等待就是桶真正需要的等待)+ 标准错误信封(code: "RATE_LIMIT_EXCEEDED")。OPTIONS预检永不计量。桶按 已解析 principal 计,匿名流量回落到 调用方 IP(Q3=C)。该 IP 仅在
trustProxy: true显式声明时取转发头,否则取传输层对端地址。未声明时那些头就是攻击者输入:默认相信它,等于给任何人无限量的新桶(绕过),同时允许他冒充受害者把别人的桶顶爆(武器化)。计数落在 kernel
cache(ADR-0069 D2),复用 plugin-authcreateLazyCounterStore的消费时惰性解析范式,所以晚注册的 cache 插件照样被用上(#4772 的教训)。完全没有 cache 服务时回落到进程内存储,并一次性说清后果与补救:在共享 cache 到位之前,实际限额 = 声明预算 × 节点数,而部署看起来一切正常。顺带修好的:
IHttpServer.use()原本是个空壳建这个 seam 时发现,合约声明的中间件层在唯一的真实适配器里是 no-op:两条分支都把
{} as any当 req/res 传进去,然后无条件next()—— 中间件读不到请求、写不出响应、拒绝不了继续。没人发现,因为全仓零生产调用点。这就是 declared ≠ enforced 又下一层。现在它是真的:交付 method/path/query/headers 加传输层对端地址(
IHttpRequest.remoteAddress,新增),并且尊重短路。HonoHttpServer只挂一个 Hono 中间件(链执行器),use()往它按请求读取的链上追加;HonoServerPlugin.init()在末尾放置这个执行器 —— 在 CORS/Server-Timing 之后(429 才带得上 CORS 头,否则浏览器只看到不透明的网络错误),在任何路由之前(路由全在 Phase 2 挂载)。因此use()的调用时机不再决定覆盖范围,限流器得以在start()安装 —— 那里「本 kernel 没有 http.server」是既成事实而非 Phase 1 中途的猜测,避开 #4771 那类「启动期记录一个 boot 还能推翻的判决」。明确未接线(Q2=B)
ApiEndpointSchema.rateLimit与ApiEndpointRegistrationSchema.rateLimit保持 known-unwired,写下去仍然什么也不发生;本 PR 也不退役它们。声明式apis:整面的存废尚未裁决(#4936),此刻退役一个键,若日后 #4936 选「接上执行」就要把墓碑刨掉重来。已在DispatcherPluginConfig.rateLimit的代码注释、schema describe 与文档中显式登记为「已知未接线,由 #4936 跟踪」—— 是响亮的缺席,不是静默。逐键活性
security.rateLimit.enabledderiveBucketConfig—— false/缺省时完全不注册中间件security.rateLimit.maxRequestscapacitysecurity.rateLimit.windowMsrefillPerSectrustProxyresolveRateLimitKey的转发头分支无静默残留键。
测试
先证红后证绿。
dispatcher-plugin.rate-limit.integration.test.ts的第一个 suite 用同样的预算、同样的组合、不接线启动,断言永不出现 429 —— 那就是本 PR 之前的origin/main。不带这条对照,一句「观察到 429」可以来自任何地方。packages/runtime/src/dispatcher-plugin.rate-limit.integration.test.ts—— 真实 kernel + 真实 socket:RED 对照;429 +Retry-After+ 信封;本插件不拥有的路由也被闸住(证明是 server 级而非 dispatcher 级);更早启动的插件挂的路由也被闸住(@objectstack/rest的形状);principal 各自计桶;预检不计量;trustProxy两向分支。packages/runtime/src/security/inbound-rate-limit.test.ts—— 派生、key 形状、共享计数(两节点同一预算)、降级日志只出一次且带后果与补救、惰性解析。packages/plugins/plugin-hono-server/src/middleware-seam.test.ts—— 短路 / 透传 / 读请求三项能力,seam 位置的正反两面都钉住。packages/spec/src/system/stack-server.test.ts—— 作者面:穿过defineStack、只有两个键、七个未消费键各自带处方被拒、strict、零预算被拒。另跑绿:
check:liveness、check:strictness-ledger、check:exported-any、check:dual-source-exports、check:empty-state,以及仓级check:startup-registry-verdict、check:durability-log-level、check:init-service-contract、check:route-envelope、check:error-code-casing、check:service-providers、check:wildcard-fallthrough、check:doc-authoring、check:adr-anchors等 22 项。其他
.changeset/inbound-rate-limit-seam.md(spec/runtime/hono minor,auth/cli patch)。.changeset/pre.json开工时已确认仍是 mode:pre / tag:rc。content/docs/protocol/kernel/http-protocol.mdx的 Rate Limiting 一节原本描述X-RateLimit-*头与THROTTLED信封并注明是「intended contract」—— 那些我们不发。改写为真实行为,并明确点名未实现的部分。⛔ 未碰content/docs/releases/。spec-changes.json/protocol-upgrade-guide.md与 main 逐字节一致(批 11/12 条目完好),我方 delta 恰为 6 个 authorable key + 7 个导出,0 removed。composeStackssilently drops every non-array top-level key —api:today,server:as of #4910 #5005(未认领):composeStacks静默丢弃一切非数组顶层键 ——api:(含enforceProjectMembership这个 403 闸门)今天就在丢,server:从本 PR 起同样,合成语义需维护者裁一次。🤖 Generated with Claude Code
https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
Generated by Claude Code