Skip to content

Commit 81e2744

Browse files
os-zhuangclaude
andauthored
feat(runtime): 端点链接线 —— 策略 → 执行,兜底器全上下文 (#5129) (#5156)
派发步命中分支的 501 换成完整链:匹配 → 策略(E4)→ executeEndpointTarget(E5) → 响应映射。cacheTtl 的 Cache-Control 只并进成功答复,错误答复一律不带。 兜底器补三根线: - 每进程构建一次端点限流注册表(与 server 级限流器同一个 resolveCache); - 每请求喂完整 EndpointPolicyContext(headers / remoteAddress / resolveSessionPrincipalId / limiters / trustProxy / logger),并把 answer.headers 写到线上(429 的 Retry-After 此前被丢弃); - 匹配前用 HttpDispatcher.resolveRequestScope(自 dispatch() 原地抽出)解析本 请求的环境 / 身份 / driver,委派调用带调用方 ExecutionContext 运行。 多租户 host 解析不到环境时该步弃权(不写任何东西),不拿默认 kernel 作答。 现网行为零变更:非空 apis: 在 publish 仍被硬拒(E7 前不撤),整条链结构性不可达。 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd Co-authored-by: Claude <noreply@anthropic.com>
1 parent bcfebb0 commit 81e2744

7 files changed

Lines changed: 809 additions & 149 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
端点链接线:声明式 `apis:` 端点的派发步现在跑完整条链 —— 匹配 → 策略(`authRequired` / `rateLimit` / `cacheTtl`)→ 目标执行(`object_operation``/data` 同一个 `callData`,`flow` 走 automation 服务)。
6+
7+
兜底器(`dispatcher-plugin`)补齐三根一直缺的线:把完整的 `EndpointPolicyContext`(请求头、`remoteAddress`、与 server 级限流器同一个会话查询、每端点限流注册表、`trustProxy`)喂给派发步;把 `answer.headers` **写到线上**(此前 429 的 `Retry-After` 会被丢掉,客户端拿到一个不知道何时重试的 429);并在匹配前解析本请求的环境 / 身份(与 `dispatch()` 同一个 `HttpDispatcher.resolveRequestScope`),使委派调用带着调用方的 `ExecutionContext` 运行,而不是以 system 身份绕过 RLS。
8+
9+
`cacheTtl``Cache-Control` 只挂在**成功**答复上,任何错误答复都不带它。多租户 host 若无法把请求解析到某个环境,该步**弃权**(不写任何东西,保留传输层原本的 404),而不是拿默认 kernel 的数据来回答。
10+
11+
现网行为零变更:publish / validate 对非空 `apis:` 仍然硬拒(#5040 E7 前不撤),因此本链结构性不可达。

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

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
appEndpointMountPrefix,
2626
isAppEndpointPath,
2727
runAppEndpointStep,
28+
type AppEndpointExecutionInput,
2829
} from './api-endpoint-step.js';
2930
import {
3031
createEndpointRateLimiterRegistry,
@@ -123,7 +124,7 @@ describe('the step writes nothing unless a declaration owns the request', () =>
123124
});
124125
});
125126

126-
describe('a match answers 501 until the executor lands (#5040 E5)', () => {
127+
describe('a match with no wiring answers an honest 501', () => {
127128
it('reports NOT_IMPLEMENTED in the declared error envelope', async () => {
128129
const { service } = matcherFor([TASKS]);
129130
const answer = await step('/api/v1/apps/showcase/tasks', 'GET', service);
@@ -136,7 +137,7 @@ describe('a match answers 501 until the executor lands (#5040 E5)', () => {
136137
// Names the endpoint it matched and says plainly that nothing ran —
137138
// "matched but not executed" must never read as "executed and empty".
138139
expect(body.error.message).toContain('showcase_tasks');
139-
expect(body.error.message).toContain('not enabled');
140+
expect(body.error.message).toContain('no wiring');
140141
expect(String(body.error.hint)).toContain('#5040');
141142
});
142143

@@ -266,3 +267,130 @@ describe('the policy chain runs between the match and the answer', () => {
266267
expect([...entries.keys()]).toEqual([endpointBucketKey('showcase_tasks', 'principal:usr_7')]);
267268
});
268269
});
270+
271+
/**
272+
* Execution, wired to the far side of the policy chain (#5040 E5b / #5129).
273+
*
274+
* The delegation itself is `endpoint-executor.test.ts`'s subject; what is
275+
* asserted here is the JOIN — that a passing request reaches the executor with
276+
* the request's own coordinates and identity, that a denial never does, and
277+
* that `cacheTtl`'s header lands on a success and on nothing else.
278+
*/
279+
describe('execution runs on the far side of the policy chain', () => {
280+
const OPEN: ApiEndpoint = ApiEndpointSchema.parse({ ...TASKS, name: 'showcase_open', authRequired: false });
281+
282+
const limiters = () => createEndpointRateLimiterRegistry({ resolveCache: async () => undefined });
283+
284+
/** Records every delegated `callData` call and answers with a stub result. */
285+
function callDataSpy(result: unknown = { object: 'showcase_task', records: [], total: 0 }) {
286+
const calls: unknown[][] = [];
287+
return {
288+
calls,
289+
fn: async (...args: unknown[]) => { calls.push(args); return result; },
290+
};
291+
}
292+
293+
const wiredStep = (
294+
endpoints: ApiEndpoint[],
295+
execution: Partial<AppEndpointExecutionInput> & { deps: AppEndpointExecutionInput['deps'] },
296+
policy: Partial<EndpointPolicyContext> = {},
297+
method = 'GET',
298+
) => runAppEndpointStep({
299+
method,
300+
path: endpoints[0]!.path,
301+
prefix: '/api/v1',
302+
metadataService: matcherFor(endpoints).service as never,
303+
policy: { limiters: limiters(), ...policy },
304+
execution: {
305+
request: {
306+
method,
307+
path: endpoints[0]!.path,
308+
query: { limit: '5' },
309+
headers: { 'x-caller': 'integration' },
310+
body: undefined,
311+
},
312+
...execution,
313+
},
314+
});
315+
316+
it('delegates a passing request with the request\'s own identity envelope', async () => {
317+
const spy = callDataSpy();
318+
const executionContext = { userId: 'usr_7', isSystem: false } as never;
319+
const answer = await wiredStep([OPEN], {
320+
deps: { callData: spy.fn as never },
321+
executionContext,
322+
environmentId: 'env_1',
323+
dataDriver: { driver: true },
324+
});
325+
326+
expect(answer?.status).toBe(200);
327+
expect(answer?.body).toEqual({
328+
success: true,
329+
data: { object: 'showcase_task', records: [], total: 0 },
330+
meta: undefined,
331+
});
332+
// The identity envelope, the driver and the scope ride on the delegated
333+
// call — #5040 §4's red line, and the exact thing #4936's dead branch
334+
// dropped (it would have read as `system`, RLS bypassed).
335+
expect(spy.calls).toEqual([[
336+
'query',
337+
{ object: 'showcase_task', query: { limit: '5' } },
338+
{ driver: true },
339+
'env_1',
340+
executionContext,
341+
]]);
342+
});
343+
344+
it('puts the cacheTtl Cache-Control on a SUCCESS answer', async () => {
345+
const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 });
346+
const answer = await wiredStep([cached], { deps: { callData: callDataSpy().fn as never } });
347+
348+
expect(answer?.status).toBe(200);
349+
expect(answer?.headers).toEqual({ 'Cache-Control': 'private, max-age=30' });
350+
});
351+
352+
it('never puts it on an ERROR answer, however the failure arose', async () => {
353+
const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 });
354+
// A delegated pipeline that throws — the executor maps it to a 4xx/5xx
355+
// answer, and a client must not be told to reuse a failure for 30s.
356+
const answer = await wiredStep([cached], {
357+
deps: { callData: async () => { throw { statusCode: 404, message: 'no such object' }; } },
358+
});
359+
360+
expect(answer?.status).toBe(404);
361+
expect(answer?.headers).toBeUndefined();
362+
363+
// Same for a declaration this runtime does not execute (501 from the
364+
// executor's own `unsupported` arm, not from the no-wiring branch).
365+
const proxied = ApiEndpointSchema.parse({
366+
...OPEN, name: 'showcase_proxy', type: 'proxy', target: 'https://example.invalid', cacheTtl: 30,
367+
});
368+
const unsupported = await wiredStep([proxied], { deps: { callData: async () => ({}) } });
369+
expect(unsupported?.status).toBe(501);
370+
expect(unsupported?.headers).toBeUndefined();
371+
expect(String((unsupported!.body as { error: { message: string } }).error.message)).toContain('proxy');
372+
});
373+
374+
it('never reaches the executor when a policy denied the request', async () => {
375+
const spy = callDataSpy();
376+
// `TASKS` keeps the default `authRequired: true`; the caller is anonymous.
377+
const answer = await wiredStep([TASKS], { deps: { callData: spy.fn as never } });
378+
379+
expect(answer?.status).toBe(401);
380+
expect(spy.calls, 'the executor ran for a request the policy chain denied').toEqual([]);
381+
});
382+
383+
it('answers an honest 501 when a caller wired policies but no executor', async () => {
384+
const answer = await runAppEndpointStep({
385+
method: 'GET',
386+
path: OPEN.path,
387+
prefix: '/api/v1',
388+
metadataService: matcherFor([OPEN]).service as never,
389+
policy: { limiters: limiters() },
390+
});
391+
expect(answer?.status).toBe(501);
392+
const hint = String((answer!.body as { error: { hint: unknown } }).error.hint);
393+
expect(hint).toContain('enforced');
394+
expect(hint).toContain('no execution wiring');
395+
});
396+
});

0 commit comments

Comments
 (0)