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 was referenced Aug 4, 2026
This was referenced Aug 4, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Aug 4, 2026
…stack-ai#4936, objectstack-ai#4939) (objectstack-ai#5065) * feat(spec,core,runtime)!: reject non-empty `apis:` loudly; retire the ApiRegistry family (objectstack-ai#4936, objectstack-ai#4939) The declarative `apis:` surface was zero-execution end to end while reporting perfect health. Metadata loaded fine — `GET /api/v1/meta/api` returned every declared endpoint with every key — but no route was ever mounted for a declared path, so a request died at Hono's `notFound` (a bare 404, not the dispatcher's semantic one), and the `handleApiEndpoint` branch behind it called a `matchEndpoint` method no implementation in this repo has ever provided. Every key on `ApiEndpointSchema` was therefore declared != enforced, `authRequired` included — a security semantic that parsed green and gated nothing. Per the maintainer verdict (2026-08-04, objectstack-ai#4936), this takes the third route: keep the vocabulary, refuse the authoring. - spec: a non-empty `apis:` is rejected on `ObjectStackDefinitionSchema` — the one choke point `defineStack`, metadata artifact ingestion, `os validate`, the lint scorer and `EnvironmentArtifactSchema` all run through, so no path can forget to check. The rejection carries its own prescription and names objectstack-ai#5040 (the executor) as the live tracker. Empty/absent still pass. `ApiEndpointSchema` itself is untouched: retiring an industry-stable endpoint shape would only mean re-introducing it identically later. - runtime: `handleApiEndpoint`, its now-orphaned private `callData` delegate (tsc TS6133 found it) and the `/__api-endpoint` ledger + legacy-prefix entries are deleted, so the absence is loud instead of grep-able dead code. - spec/core (objectstack-ai#4939): the second, unrelated endpoint declaration shape retires whole — `ApiEndpointRegistration`/`ApiRegistry`/`ApiRegistryEntry` and their value schemas (12 JSON-Schema defs, 67 authorable keys), the ~500-line `ApiRegistry` service, `createApiRegistryPlugin`, and hono's unread `useApiRegistry` option. It was composed only in `packages/core/examples/`, so `requiredPermissions` promised gateway enforcement no gateway performed. `ConflictResolutionStrategy` survives, moved to `api/router.zod` — two independent ratchets (spec sync-retirement, objectui parity) pin it. - showcase: declares no endpoints (both definitions preserved, commented, with the rationale); the coverage manifest's "demonstrated ... executed by the runtime dispatcher" claim is corrected to a waiver — it was the exact advertise-what-you-do-not-deliver claim Prime Directive objectstack-ai#10 forbids. - the objectstack-ai#4910/objectstack-ai#5006 endpoint-level `rateLimit` tracking pointers now name objectstack-ai#5040, since objectstack-ai#4936 closes here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd * chore(changeset): runtime bump is major — `handleApiEndpoint` was a public method `HttpDispatcher.handleApiEndpoint()` carried no `private` modifier, so deleting it removes a symbol from `@objectstack/runtime`'s public surface even though the method returned `{ handled: false }` on every call it ever received. Record it as breaking with that nuance stated, rather than letting a `minor` imply the symbol survived. Also drops the `@objectstack/client` entry: that change is a comment in a `.test.ts`, which never ships. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd * style(spec): keep the schema's own doc comments adjacent to the schema The APIS_NO_EXECUTOR_GUIDANCE const landed between the two doc-comment blocks that both belong to ObjectStackDefinitionSchema, orphaning the first. Move the const above them — no behaviour change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd * test(spec): make the tracking-pointer pin assert what its name promises The test claimed the prescription does not name objectstack-ai#4936 as the tracker but only asserted objectstack-ai#5040 was present — true even with a stale issues/4936 link beside it. Assert the set of issue URLs in the message is exactly {5040}. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd * docs(spec,showcase): the prescription must not over-promise — ADR-0121 D1 renames paths on restore ADR-0121 (accepted 2026-08-04, after this branch opened) namespaces endpoint paths as `<runtime-prefix>/apps/<namespace>/<subpath>`. The rejection message said definitions "stay valid"; that is true of every key except `path`, so it is now stated precisely, with the FROM -> TO. The showcase's commented endpoints carry the same note — they would be rejected under D1 if uncommented verbatim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --------- Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Aug 4, 2026
…stack-ai#5040 E4) (objectstack-ai#5135) * feat(runtime): endpoint policy keys — authRequired / rateLimit / cacheTtl (objectstack-ai#5091) Wire the three policy keys `ApiEndpointSchema` declares, in the order objectstack-ai#5040 §3 fixes (rateLimit → authRequired → cacheTtl), reusing existing primitives only: - `authRequired` → `shouldDenyAnonymous` + the `ANONYMOUS_DENY_*` constants, so a declared endpoint answers the same 401 as `/meta`, `/ai` and `/security`. The key arrives materialized (schema default `true`), so there is no "omitted" state a consumer could read differently. - `rateLimit` → objectstack-ai#5006's `deriveBucketConfig` / `resolveRateLimitKey` / `SharedTokenBucketLimiter` over the shared counter store, keyed `apiep:<name>:<principal|ip>` so the endpoint budget and the server-level budget are independent rather than one budget counted twice. Over limit answers the server limiter's own 429 body plus `Retry-After`. - `cacheTtl` → response-header semantics only (objectstack-ai#5091 ruled out a server-side cache): `private, max-age=<ttl>` for a positive ttl, `no-store` for 0 or negative, nothing when absent, nothing + a warn on a non-GET endpoint. Metering runs BEFORE the auth gate on purpose: credential stuffing is anonymous traffic against an `authRequired` endpoint, and gating first would let a scanner make unlimited attempts none of which ever reach the meter. The dispatch step runs the chain between the match and its 501, and target execution can only land on the far side of the chain — the branch is unreachable without a policy context, so wiring an executor without wiring policies is not something a later change can do by forgetting. Structurally unreachable and zero live behavior change: a non-empty `apis:` is still rejected at publish until the objectstack-ai#5040 E7 flip, and the step's answer without a policy context is byte-identical to today's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd * fix(runtime): strip two NUL bytes from endpoint-policy.ts cacheKey literal (objectstack-ai#5091) 分隔符误写为 \x00:git 判文件为二进制,ESLint / check:nul-bytes 红。 替换为空格,registry 内部键行为等价。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: os-zhuang <support@objectstack.ai>
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