Skip to content

feat(runtime): 端点策略键 —— authRequired / rateLimit / cacheTtl 接线(#5040 E4) - #5135

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-5091-endpoint-policy-keys
Aug 4, 2026
Merged

feat(runtime): 端点策略键 —— authRequired / rateLimit / cacheTtl 接线(#5040 E4)#5135
os-zhuang merged 3 commits into
mainfrom
claude/issue-5091-endpoint-policy-keys

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #5091
Part-of #5040(E 系列第 4 单)。前置 E2(ecc61ab)/ E3(2649ccb)已在 main。

排版注记:占位符一律写成 {…} 而非尖括号 —— GitHub 正文消毒器会把 < 后跟字母当 HTML 标签吞掉(本 PR 初版正文的 max-age=<ttl> 就被存成了 max-age=)。

做了什么

新增 packages/runtime/src/endpoint-policy.ts —— ApiEndpointSchema 三个策略键的唯一读取方;api-endpoint-step.ts 在「匹配命中」与「答复」之间插入策略链。三键全部复用既有原语,零发明:

由谁回答 与谁共用
authRequired shouldDenyAnonymous + ANONYMOUS_DENY_* 常量 /meta/ai/security(#2567#3963)
rateLimit deriveBucketConfig / resolveRateLimitKey / SharedTokenBucketLimiter server 级入站限流(#5006#4910 Q3=C / Q4=B)
cacheTtl 一个 Cache-Control 响应头,仅此而已 ——

链序:先限流、后鉴权(#5040 §3)

按设计原文执行,并把理由写进代码:最需要计量的流量(撞库、令牌喷洒、爬取)恰恰是会被答 401 的那批;先鉴权后限流等于让扫号者的每次尝试都绕开计量。因此被拒的请求确实消耗令牌 —— 这是预算的用途,不是它的漏洞。解析「谁在调用」与「据此设卡」是两件事:principal 先查一次(它给桶做键),再计量,再设卡,一次查询两处使用。

三键的具体裁断

  • authRequired:值到达时已由 schema 物化(缺省即 true),执行器读不到「未声明」这个中间态 —— 漏写得到安全默认,authRequired: false 是唯一开门方式且在 diff 里可 review。
    ⚠️ 请求 path 被刻意不传给 shouldDenyAnonymous:它的可选 path 参数会豁免控制面路径,而 isAuthGateAllowlisted任何含 /auth/ 段的路径都算作控制面。若传 path,应用只要把端点声明成 /api/v1/apps/{ns}/auth/callback 就能豁免自己的 authRequired: true —— 一个「声明期即可自助绕过平台默认拒绝」的口子。不传 = 声明路径根本够不到豁免。已配测试钉死。
  • rateLimit:桶键 apiep:{端点名}:{principal 或 ip}。命名空间前缀是两份预算互相独立的原因(server 级是全局底线,端点级是业务配额),共用键空间会让较小的那份被悄悄再砍一半。超限答与 server 级限流器逐字节相同的 429(同 message、同 details、同派生 code)+ Retry-After。armed 但不可用(maxRequests/windowMs ≤ 0)→ fail closed,报错点名端点。
  • cacheTtl:仅响应头语义(E4(#5040 执行器):策略键接线 —— authRequired / rateLimit / cacheTtl 全部复用既有原语,零新执行语义 #5091 已裁掉服务端缓存 —— 缓存需要失效故事,给一个只有四个词的词表发明失效语义正是「运行时方言」的起点)。正值 → private, max-age={秒数};private 是安全规则不是调优项,authRequired: false 的端点同样适用(任何响应都可能是按主体裁剪过的,共享缓存永远不该存);0/负值 → no-store(作者写了 0 就是说了话,让它等价于沉默正是本程序要消灭的静默 no-op);缺省 → 不发头;非 GET → 不发头 + warn 点名(GET-only 见设计 §3.3,publish 门在 E7)。

501 与头的关系

Cache-Control 描述的是尚不存在的成功响应体(执行是 E5),让客户端缓存一个 501 比什么都不说更糟 —— 所以它留在策略裁决对象上(verdict.responseHeaders)供执行器接线时应用,并在单测里直接断言这一暴露;Retry-After 则是拒绝本身的一部分,随答复走(AppEndpointStepAnswer.headers)。

结构上防「接了执行、忘了策略」

执行落点在策略链之后的分支里,而该分支在没有策略上下文时不可达 —— 后续接线要加执行调用,没有别的地方可放。

未接的一段(留给 #5129,已在该单评论里交接)

按 R3 文件面切分,本单不碰 dispatcher-plugin.ts,因此派发步目前尚未拿到策略上下文(headers / remoteAddress / principal 解析 / limiter 注册表),answer.headers 也还没被兜底器写出。这两处正是 #5129(E5b 接线收拢)的范围,交接契约已评论在该单。今日不构成任何缺口:没有策略上下文时的终态答复仍是 501 —— 没有任何请求会被未经策略地服务

结构性不可达 / 零现网行为变更

非空 apis: 在 publish/validate 仍被硬拒(E7 翻转前);派发步在未获得策略上下文时的答复与此前逐字节相同 —— E3 的全部否定断言(未命中、前缀外、405 + Allow、裸 404)原样通过。

验证

pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2
  Test Files  87 passed (87)
       Tests  1206 passed (1206)

pnpm --filter @objectstack/runtime typecheck            → tsc --noEmit,零输出
pnpm check:route-envelope                               → 8 route module(s): 7 conformant, 0 ratcheted, 1 exempt
                                                          Dispatcher domains — 16 audited, 0 ratcheted
pnpm --filter @objectstack/spec check:generated         → ✓ All 8 generated artifacts are up to date
pnpm exec eslint {本 PR 改动的 5 个文件} --no-inline-config → 零输出

新测试:endpoint-policy.test.ts(逐键 命中/未命中/边界:authRequired true-无会话 / true-有会话 / false、/auth/ 自助豁免防线、限流 未到/到点/超限 + Retry-After 值 + 端点间与调用者间桶隔离 + 转发头须 trustProxy + 计量先于鉴权 + 计数存储故障的有界 fail-open + 零预算 fail-closed、cacheTtl 有/无/0/负/小数/非 GET)+ api-endpoint-step.test.ts 的链路接线用例(拒绝短路不出 501、429 带头、通过才到 501、501 不带 Cache-Control)。endpoint-policy.ts 已按 E3 加 api-endpoint-step.ts 的同样方式加入 error-envelope conformance 的 MODULES 扫描清单。


🤖 Generated with Claude Code

https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd

…eTtl (#5091)

Wire the three policy keys `ApiEndpointSchema` declares, in the order #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` → #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 (#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 #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
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 6:08am

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)
  • content/docs/releases/v17.mdx (via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…teral (#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
@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 05:58
@os-zhuang
os-zhuang enabled auto-merge August 4, 2026 05:58
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 4, 2026
# Conflicts:
#	packages/runtime/src/error-envelope.conformance.test.ts
@os-zhuang
os-zhuang enabled auto-merge August 4, 2026 06:08
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit a70cd0a Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5091-endpoint-policy-keys branch August 4, 2026 06:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

E4(#5040 执行器):策略键接线 —— authRequired / rateLimit / cacheTtl 全部复用既有原语,零新执行语义

2 participants