Skip to content

Commit a70cd0a

Browse files
os-zhuangclaudeos-zhuang
authored
feat(runtime): 端点策略键 —— authRequired / rateLimit / cacheTtl 接线(#5040 E4) (#5135)
* feat(runtime): endpoint policy keys — authRequired / rateLimit / cacheTtl (#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 * fix(runtime): strip two NUL bytes from endpoint-policy.ts cacheKey literal (#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>
1 parent 277eb36 commit a70cd0a

6 files changed

Lines changed: 951 additions & 9 deletions

File tree

.changeset/endpoint-policy-keys.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
声明式端点的策略键接线:`authRequired` / `rateLimit` / `cacheTtl`(#5040 E4)
6+
7+
新增 `packages/runtime/src/endpoint-policy.ts` —— `ApiEndpointSchema` 三个策略键的唯一读取方,并接入端点派发步(匹配命中 → 策略链 → 答复)。三个键全部复用既有原语,零发明:
8+
9+
- `authRequired`:复用 `shouldDenyAnonymous``ANONYMOUS_DENY_*` 常量,未认证得到与 `/meta``/ai``/security` 完全相同的 401 包络。默认值由 schema 物化(缺省即 `true`),执行器读不到「未声明」这个中间态;`authRequired: false` 是唯一的开门方式,且在 diff 中可见。
10+
- `rateLimit`:复用 #5006`deriveBucketConfig` / `resolveRateLimitKey` / `SharedTokenBucketLimiter`,桶键为 `apiep:<端点名>:<主体或 IP>` —— 与 server 级预算各自独立计量,互不侵蚀。超限答与 server 级限流器逐字节一致的 429 + `Retry-After`
11+
- `cacheTtl`:仅响应头语义(不实现服务端缓存,#5091 已裁)。正值 → `Cache-Control: private, max-age=<ttl>`(`private` 是安全规则:任何响应都可能是按主体裁剪过的);`0`/负值 → `no-store`;缺省 → 不发头;非 GET → 不发头并 warn 点名。
12+
13+
链序按 #5040 §3:**先限流、后鉴权**、再算缓存头 —— 凭据爆破本就是匿名流量,先答 401 会让扫号者完全绕开计量。
14+
15+
**结构性不可达、零现网行为变更**:非空 `apis:` 在 publish/validate 仍被硬拒(E7 翻转前),且派发步在未获得策略上下文时的答复与此前逐字节相同。

packages/runtime/src/api-endpoint-step.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,19 @@
1818
import { describe, it, expect } from 'vitest';
1919
import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api';
2020
import type { ApiEndpointMatch } from '@objectstack/spec/contracts';
21+
import type { CounterStore } from '@objectstack/plugin-auth';
2122

2223
import {
2324
APP_ENDPOINT_SEGMENT,
2425
appEndpointMountPrefix,
2526
isAppEndpointPath,
2627
runAppEndpointStep,
2728
} from './api-endpoint-step.js';
29+
import {
30+
createEndpointRateLimiterRegistry,
31+
endpointBucketKey,
32+
type EndpointPolicyContext,
33+
} from './endpoint-policy.js';
2834

2935
/** A declared endpoint in the ADR-0121 D1 shape, defaults materialized. */
3036
const TASKS: ApiEndpoint = ApiEndpointSchema.parse({
@@ -142,4 +148,121 @@ describe('a match answers 501 until the executor lands (#5040 E5)', () => {
142148
// is how two spellings of "the same path" start to disagree.
143149
expect(calls).toEqual([{ path: '/api/v1/apps/showcase/tasks', method: 'GET' }]);
144150
});
151+
152+
it('names the keys it did NOT evaluate when no policy context was threaded', async () => {
153+
// Truthfulness of the report is the point: this seam's whole job today
154+
// is telling an operator what did and did not happen.
155+
const { service } = matcherFor([TASKS]);
156+
const answer = await step('/api/v1/apps/showcase/tasks', 'GET', service);
157+
const hint = String((answer!.body as { error: { hint: unknown } }).error.hint);
158+
expect(hint).toContain('not evaluated');
159+
expect(hint).toContain('authRequired');
160+
expect(answer!.headers).toBeUndefined();
161+
});
162+
});
163+
164+
/**
165+
* The policy chain, seen from the step (#5040 E4 / #5091).
166+
*
167+
* The module-level cases live in `endpoint-policy.test.ts`; what is asserted
168+
* here is the WIRING — that the chain runs between the match and the answer,
169+
* that a denial short-circuits (no 501, no execution slot reached), and that a
170+
* pass still ends in the 501 until E5 lands.
171+
*/
172+
describe('the policy chain runs between the match and the answer', () => {
173+
/** An endpoint that is open to anonymous callers unless a case says otherwise. */
174+
const OPEN: ApiEndpoint = ApiEndpointSchema.parse({
175+
...TASKS, name: 'showcase_open', authRequired: false,
176+
});
177+
178+
function policyContext(overrides: Partial<EndpointPolicyContext> = {}): EndpointPolicyContext {
179+
const entries = new Map<string, unknown>();
180+
const store: CounterStore = {
181+
get: async <T,>(key: string) => entries.get(key) as T | undefined,
182+
set: async (key: string, value: unknown) => { entries.set(key, value); },
183+
};
184+
return {
185+
limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => store }),
186+
...overrides,
187+
};
188+
}
189+
190+
const policedStep = (endpoints: ApiEndpoint[], policy: EndpointPolicyContext, method = 'GET') =>
191+
runAppEndpointStep({
192+
method,
193+
path: endpoints[0]!.path,
194+
prefix: '/api/v1',
195+
metadataService: matcherFor(endpoints).service as never,
196+
policy,
197+
});
198+
199+
it('answers 401 instead of 501 when the endpoint requires auth and the caller has none', async () => {
200+
const answer = await policedStep([TASKS], policyContext());
201+
expect(answer?.status).toBe(401);
202+
const body = answer!.body as { success: boolean; error: Record<string, unknown> };
203+
expect(body.error.code).toBe('UNAUTHENTICATED');
204+
// The 501 is NOT also emitted: a denial is the answer, not a stage.
205+
expect(JSON.stringify(body)).not.toContain('NOT_IMPLEMENTED');
206+
});
207+
208+
it('answers 429 with the Retry-After header once the endpoint budget is spent', async () => {
209+
const limited = ApiEndpointSchema.parse({
210+
...OPEN, name: 'showcase_limited', rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 1 },
211+
});
212+
const policy = policyContext();
213+
214+
expect((await policedStep([limited], policy))?.status).toBe(501); // within budget
215+
const over = await policedStep([limited], policy);
216+
217+
expect(over?.status).toBe(429);
218+
// The header rides on the ANSWER, so the transport writes it with the
219+
// body — a 429 whose Retry-After got lost tells a client nothing.
220+
expect(over?.headers).toEqual({ 'Retry-After': '1' });
221+
expect((over!.body as { error: { code: string } }).error.code).toBe('RATE_LIMIT_EXCEEDED');
222+
});
223+
224+
it('reaches the 501 only after the chain passed, and says so', async () => {
225+
const answer = await policedStep([OPEN], policyContext());
226+
expect(answer?.status).toBe(501);
227+
const hint = String((answer!.body as { error: { hint: unknown } }).error.hint);
228+
expect(hint).toContain('enforced');
229+
expect(hint).toContain('#5040');
230+
});
231+
232+
it('never puts the cacheTtl header on the 501 — but the verdict still carries it', async () => {
233+
// Exposure, not application: `Cache-Control` describes a successful body
234+
// that does not exist yet (execution is E5), and telling a client to
235+
// cache a 501 for 30s would be worse than saying nothing. The header
236+
// lives on the policy verdict, which is what the executor will read —
237+
// asserted directly in `endpoint-policy.test.ts`.
238+
const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 });
239+
const answer = await policedStep([cached], policyContext());
240+
expect(answer?.status).toBe(501);
241+
expect(answer?.headers).toBeUndefined();
242+
});
243+
244+
it('resolves the caller once and keys the endpoint bucket with it', async () => {
245+
const seen: Array<Record<string, unknown>> = [];
246+
const entries = new Map<string, unknown>();
247+
const store: CounterStore = {
248+
get: async <T,>(k: string) => entries.get(k) as T | undefined,
249+
set: async (k: string, v: unknown) => { entries.set(k, v); },
250+
};
251+
const limited = ApiEndpointSchema.parse({
252+
...TASKS, name: 'showcase_tasks', rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 5 },
253+
});
254+
255+
const answer = await policedStep([limited], {
256+
limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => store }),
257+
headers: { cookie: 'session=abc' },
258+
remoteAddress: '203.0.113.9',
259+
resolvePrincipalId: async (headers) => { seen.push(headers); return 'usr_7'; },
260+
});
261+
262+
// Authenticated, so the 401 gate passes and the bucket keys by principal
263+
// rather than by address — one lookup serving both.
264+
expect(answer?.status).toBe(501);
265+
expect(seen).toEqual([{ cookie: 'session=abc' }]);
266+
expect([...entries.keys()]).toEqual([endpointBucketKey('showcase_tasks', 'principal:usr_7')]);
267+
});
145268
});

packages/runtime/src/api-endpoint-step.ts

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,28 @@
2727
* drive `matchEndpoint` through a stub, exactly as #5040 §5 prescribes for
2828
* every E-series unit that lands before the flip.
2929
*
30+
* ## The policy chain (#5040 E4) now runs between the match and the answer
31+
*
32+
* `authRequired` / `rateLimit` / `cacheTtl` are enforced by
33+
* {@link applyEndpointPolicies}, in the order #5040 §3 fixes, whenever the
34+
* caller supplies a {@link EndpointPolicyContext}. A denial (401 / 429) is the
35+
* answer; a pass still ends in the 501 below, because the thing that would run
36+
* the endpoint is E5.
37+
*
38+
* That ordering is structural, not stylistic: execution lands INSIDE the
39+
* post-policy branch, which is unreachable without a policy context. Wiring an
40+
* executor without wiring policies is therefore not something a future change
41+
* can do by forgetting — it would have nowhere to put the call.
42+
*
3043
* What it does NOT do yet, so nobody reads more into it than is here:
31-
* `rateLimit`, `authRequired`, `cacheTtl`, `inputMapping` / `outputMapping`
32-
* (E4) and target execution — `object_operation` via `callData`, `flow` via the
33-
* automation service (E5). Those insert BETWEEN the match and the answer, in
34-
* the order #5040 §3 fixes.
44+
* `inputMapping` / `outputMapping` and target execution — `object_operation`
45+
* via `callData`, `flow` via the automation service (E5).
3546
*/
3647

3748
import { DispatcherErrorCode } from '@objectstack/spec/api';
3849
import type { ApiEndpointMatch, IMetadataService } from '@objectstack/spec/contracts';
3950
import { apiErrorResponse } from './error-envelope.js';
51+
import { applyEndpointPolicies, type EndpointPolicyContext } from './endpoint-policy.js';
4052

4153
/**
4254
* The platform's single reserved carve-out segment for app-declared endpoints
@@ -81,6 +93,17 @@ export function isAppEndpointPath(path: string, runtimePrefix: string): boolean
8193
export interface AppEndpointStepAnswer {
8294
status: number;
8395
body: unknown;
96+
/**
97+
* Headers that are part of THIS answer and must be written with it — today
98+
* only `Retry-After` on a rate-limit denial, where the header carries the
99+
* one piece of information the client needs to behave.
100+
*
101+
* Note what is NOT here: the `Cache-Control` computed from `cacheTtl`. It
102+
* describes a successful response body that does not exist yet, and telling
103+
* a client to cache a 501 would be worse than saying nothing. It stays on
104+
* the policy verdict until execution lands (#5040 E5).
105+
*/
106+
headers?: Record<string, string>;
84107
}
85108

86109
export interface AppEndpointStepInput {
@@ -98,6 +121,19 @@ export interface AppEndpointStepInput {
98121
* depend on its landing).
99122
*/
100123
metadataService: Pick<IMetadataService, 'matchEndpoint'> | undefined;
124+
/**
125+
* Request context + services for the policy chain (#5040 E4): the caller's
126+
* headers and peer address, the principal lookup, the endpoint limiter
127+
* registry, `trustProxy`.
128+
*
129+
* Optional ONLY because the dispatch seam that calls this step does not
130+
* thread it yet — that plumbing lands with the executor wiring (#5040 E5),
131+
* which is the same change that needs the request body and the environment
132+
* anyway. Omitting it does not open anything: the terminal answer without a
133+
* policy context is the 501 below, so no request can be SERVED unpoliced,
134+
* and execution can only be added on the far side of the chain.
135+
*/
136+
policy?: EndpointPolicyContext;
101137
}
102138

103139
/**
@@ -130,16 +166,52 @@ export async function runAppEndpointStep(
130166
const match: ApiEndpointMatch | undefined = await metadataService.matchEndpoint({ path, method });
131167
if (!match) return undefined;
132168

169+
if (!input.policy) {
170+
// No policy context threaded yet (see `AppEndpointStepInput.policy`).
171+
// The answer is the same 501 this seam has always given, and the hint
172+
// says which keys were NOT evaluated — a report that is wrong about
173+
// what ran is worse than no report.
174+
return notImplemented(match, method, path,
175+
'The mounting seam is in place; execution (target dispatch, mappings) lands with #5040 E5. This '
176+
+ 'request reached the step without a policy context, so authRequired / rateLimit / cacheTtl were '
177+
+ 'not evaluated — nothing was served either. Until the E7 flip a non-empty `apis:` is rejected at '
178+
+ 'publish, so no reachable deployment can produce this answer.');
179+
}
180+
181+
const verdict = await applyEndpointPolicies({ ...input.policy, endpoint: match.endpoint, method });
182+
if (verdict.verdict === 'deny') {
183+
return {
184+
status: verdict.status,
185+
body: verdict.body,
186+
...(verdict.headers ? { headers: verdict.headers } : {}),
187+
};
188+
}
189+
190+
// ── Everything past this line has been through the policy chain ──────
191+
// This is where target execution lands (#5040 E5), and it is the ONLY place
192+
// it can land: the branch is unreachable without a policy context, and the
193+
// deny above short-circuits before it. `verdict.responseHeaders` carries the
194+
// `Cache-Control` that the executor's success answer should apply — it is
195+
// deliberately not applied to the 501 (see `AppEndpointStepAnswer.headers`).
196+
return notImplemented(match, method, path,
197+
'Policies (authRequired / rateLimit / cacheTtl) were enforced and this request passed them; target '
198+
+ 'execution lands with #5040 E5. Until the E7 flip a non-empty `apis:` is rejected at publish, so no '
199+
+ 'reachable deployment can produce this answer.');
200+
}
201+
202+
/** The one 501 body this step answers with, whichever branch produced it. */
203+
function notImplemented(
204+
match: ApiEndpointMatch,
205+
method: string,
206+
path: string,
207+
hint: string,
208+
): AppEndpointStepAnswer {
133209
return apiErrorResponse({
134210
code: DispatcherErrorCode.enum.NOT_IMPLEMENTED,
135211
httpStatus: 501,
136212
message:
137213
`Declarative endpoint '${match.endpoint.name}' claims ${method} ${path}, but the endpoint `
138214
+ 'executor is not enabled in this build. It lands in 17.x (#5040).',
139-
extra: {
140-
hint: 'The mounting seam is in place; execution (target dispatch, authRequired / rateLimit / '
141-
+ 'cacheTtl / mappings) lands with #5040 E4–E5. Until then a non-empty `apis:` is rejected '
142-
+ 'at publish, so no reachable deployment can produce this answer.',
143-
},
215+
extra: { hint },
144216
});
145217
}

0 commit comments

Comments
 (0)