Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/endpoint-policy-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/runtime": minor
---

声明式端点的策略键接线:`authRequired` / `rateLimit` / `cacheTtl`(#5040 E4)

新增 `packages/runtime/src/endpoint-policy.ts` —— `ApiEndpointSchema` 三个策略键的唯一读取方,并接入端点派发步(匹配命中 → 策略链 → 答复)。三个键全部复用既有原语,零发明:

- `authRequired`:复用 `shouldDenyAnonymous` 与 `ANONYMOUS_DENY_*` 常量,未认证得到与 `/meta`、`/ai`、`/security` 完全相同的 401 包络。默认值由 schema 物化(缺省即 `true`),执行器读不到「未声明」这个中间态;`authRequired: false` 是唯一的开门方式,且在 diff 中可见。
- `rateLimit`:复用 #5006 的 `deriveBucketConfig` / `resolveRateLimitKey` / `SharedTokenBucketLimiter`,桶键为 `apiep:<端点名>:<主体或 IP>` —— 与 server 级预算各自独立计量,互不侵蚀。超限答与 server 级限流器逐字节一致的 429 + `Retry-After`。
- `cacheTtl`:仅响应头语义(不实现服务端缓存,#5091 已裁)。正值 → `Cache-Control: private, max-age=<ttl>`(`private` 是安全规则:任何响应都可能是按主体裁剪过的);`0`/负值 → `no-store`;缺省 → 不发头;非 GET → 不发头并 warn 点名。

链序按 #5040 §3:**先限流、后鉴权**、再算缓存头 —— 凭据爆破本就是匿名流量,先答 401 会让扫号者完全绕开计量。

**结构性不可达、零现网行为变更**:非空 `apis:` 在 publish/validate 仍被硬拒(E7 翻转前),且派发步在未获得策略上下文时的答复与此前逐字节相同。
123 changes: 123 additions & 0 deletions packages/runtime/src/api-endpoint-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@
import { describe, it, expect } from 'vitest';
import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api';
import type { ApiEndpointMatch } from '@objectstack/spec/contracts';
import type { CounterStore } from '@objectstack/plugin-auth';

import {
APP_ENDPOINT_SEGMENT,
appEndpointMountPrefix,
isAppEndpointPath,
runAppEndpointStep,
} from './api-endpoint-step.js';
import {
createEndpointRateLimiterRegistry,
endpointBucketKey,
type EndpointPolicyContext,
} from './endpoint-policy.js';

/** A declared endpoint in the ADR-0121 D1 shape, defaults materialized. */
const TASKS: ApiEndpoint = ApiEndpointSchema.parse({
Expand Down Expand Up @@ -142,4 +148,121 @@ describe('a match answers 501 until the executor lands (#5040 E5)', () => {
// is how two spellings of "the same path" start to disagree.
expect(calls).toEqual([{ path: '/api/v1/apps/showcase/tasks', method: 'GET' }]);
});

it('names the keys it did NOT evaluate when no policy context was threaded', async () => {
// Truthfulness of the report is the point: this seam's whole job today
// is telling an operator what did and did not happen.
const { service } = matcherFor([TASKS]);
const answer = await step('/api/v1/apps/showcase/tasks', 'GET', service);
const hint = String((answer!.body as { error: { hint: unknown } }).error.hint);
expect(hint).toContain('not evaluated');
expect(hint).toContain('authRequired');
expect(answer!.headers).toBeUndefined();
});
});

/**
* The policy chain, seen from the step (#5040 E4 / #5091).
*
* The module-level cases live in `endpoint-policy.test.ts`; what is asserted
* here is the WIRING — that the chain runs between the match and the answer,
* that a denial short-circuits (no 501, no execution slot reached), and that a
* pass still ends in the 501 until E5 lands.
*/
describe('the policy chain runs between the match and the answer', () => {
/** An endpoint that is open to anonymous callers unless a case says otherwise. */
const OPEN: ApiEndpoint = ApiEndpointSchema.parse({
...TASKS, name: 'showcase_open', authRequired: false,
});

function policyContext(overrides: Partial<EndpointPolicyContext> = {}): EndpointPolicyContext {
const entries = new Map<string, unknown>();
const store: CounterStore = {
get: async <T,>(key: string) => entries.get(key) as T | undefined,
set: async (key: string, value: unknown) => { entries.set(key, value); },
};
return {
limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => store }),
...overrides,
};
}

const policedStep = (endpoints: ApiEndpoint[], policy: EndpointPolicyContext, method = 'GET') =>
runAppEndpointStep({
method,
path: endpoints[0]!.path,
prefix: '/api/v1',
metadataService: matcherFor(endpoints).service as never,
policy,
});

it('answers 401 instead of 501 when the endpoint requires auth and the caller has none', async () => {
const answer = await policedStep([TASKS], policyContext());
expect(answer?.status).toBe(401);
const body = answer!.body as { success: boolean; error: Record<string, unknown> };
expect(body.error.code).toBe('UNAUTHENTICATED');
// The 501 is NOT also emitted: a denial is the answer, not a stage.
expect(JSON.stringify(body)).not.toContain('NOT_IMPLEMENTED');
});

it('answers 429 with the Retry-After header once the endpoint budget is spent', async () => {
const limited = ApiEndpointSchema.parse({
...OPEN, name: 'showcase_limited', rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 1 },
});
const policy = policyContext();

expect((await policedStep([limited], policy))?.status).toBe(501); // within budget
const over = await policedStep([limited], policy);

expect(over?.status).toBe(429);
// The header rides on the ANSWER, so the transport writes it with the
// body — a 429 whose Retry-After got lost tells a client nothing.
expect(over?.headers).toEqual({ 'Retry-After': '1' });
expect((over!.body as { error: { code: string } }).error.code).toBe('RATE_LIMIT_EXCEEDED');
});

it('reaches the 501 only after the chain passed, and says so', async () => {
const answer = await policedStep([OPEN], policyContext());
expect(answer?.status).toBe(501);
const hint = String((answer!.body as { error: { hint: unknown } }).error.hint);
expect(hint).toContain('enforced');
expect(hint).toContain('#5040');
});

it('never puts the cacheTtl header on the 501 — but the verdict still carries it', async () => {
// Exposure, not application: `Cache-Control` describes a successful body
// that does not exist yet (execution is E5), and telling a client to
// cache a 501 for 30s would be worse than saying nothing. The header
// lives on the policy verdict, which is what the executor will read —
// asserted directly in `endpoint-policy.test.ts`.
const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 });
const answer = await policedStep([cached], policyContext());
expect(answer?.status).toBe(501);
expect(answer?.headers).toBeUndefined();
});

it('resolves the caller once and keys the endpoint bucket with it', async () => {
const seen: Array<Record<string, unknown>> = [];
const entries = new Map<string, unknown>();
const store: CounterStore = {
get: async <T,>(k: string) => entries.get(k) as T | undefined,
set: async (k: string, v: unknown) => { entries.set(k, v); },
};
const limited = ApiEndpointSchema.parse({
...TASKS, name: 'showcase_tasks', rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 5 },
});

const answer = await policedStep([limited], {
limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => store }),
headers: { cookie: 'session=abc' },
remoteAddress: '203.0.113.9',
resolvePrincipalId: async (headers) => { seen.push(headers); return 'usr_7'; },
});

// Authenticated, so the 401 gate passes and the bucket keys by principal
// rather than by address — one lookup serving both.
expect(answer?.status).toBe(501);
expect(seen).toEqual([{ cookie: 'session=abc' }]);
expect([...entries.keys()]).toEqual([endpointBucketKey('showcase_tasks', 'principal:usr_7')]);
});
});
90 changes: 81 additions & 9 deletions packages/runtime/src/api-endpoint-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,28 @@
* drive `matchEndpoint` through a stub, exactly as #5040 §5 prescribes for
* every E-series unit that lands before the flip.
*
* ## The policy chain (#5040 E4) now runs between the match and the answer
*
* `authRequired` / `rateLimit` / `cacheTtl` are enforced by
* {@link applyEndpointPolicies}, in the order #5040 §3 fixes, whenever the
* caller supplies a {@link EndpointPolicyContext}. A denial (401 / 429) is the
* answer; a pass still ends in the 501 below, because the thing that would run
* the endpoint is E5.
*
* That ordering is structural, not stylistic: execution lands INSIDE the
* post-policy branch, which is unreachable without a policy context. Wiring an
* executor without wiring policies is therefore not something a future change
* can do by forgetting — it would have nowhere to put the call.
*
* What it does NOT do yet, so nobody reads more into it than is here:
* `rateLimit`, `authRequired`, `cacheTtl`, `inputMapping` / `outputMapping`
* (E4) and target execution — `object_operation` via `callData`, `flow` via the
* automation service (E5). Those insert BETWEEN the match and the answer, in
* the order #5040 §3 fixes.
* `inputMapping` / `outputMapping` and target execution — `object_operation`
* via `callData`, `flow` via the automation service (E5).
*/

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

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

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

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

if (!input.policy) {
// No policy context threaded yet (see `AppEndpointStepInput.policy`).
// The answer is the same 501 this seam has always given, and the hint
// says which keys were NOT evaluated — a report that is wrong about
// what ran is worse than no report.
return notImplemented(match, method, path,
'The mounting seam is in place; execution (target dispatch, mappings) lands with #5040 E5. This '
+ 'request reached the step without a policy context, so authRequired / rateLimit / cacheTtl were '
+ 'not evaluated — nothing was served either. Until the E7 flip a non-empty `apis:` is rejected at '
+ 'publish, so no reachable deployment can produce this answer.');
}

const verdict = await applyEndpointPolicies({ ...input.policy, endpoint: match.endpoint, method });
if (verdict.verdict === 'deny') {
return {
status: verdict.status,
body: verdict.body,
...(verdict.headers ? { headers: verdict.headers } : {}),
};
}

// ── Everything past this line has been through the policy chain ──────
// This is where target execution lands (#5040 E5), and it is the ONLY place
// it can land: the branch is unreachable without a policy context, and the
// deny above short-circuits before it. `verdict.responseHeaders` carries the
// `Cache-Control` that the executor's success answer should apply — it is
// deliberately not applied to the 501 (see `AppEndpointStepAnswer.headers`).
return notImplemented(match, method, path,
'Policies (authRequired / rateLimit / cacheTtl) were enforced and this request passed them; target '
+ 'execution lands with #5040 E5. Until the E7 flip a non-empty `apis:` is rejected at publish, so no '
+ 'reachable deployment can produce this answer.');
}

/** The one 501 body this step answers with, whichever branch produced it. */
function notImplemented(
match: ApiEndpointMatch,
method: string,
path: string,
hint: string,
): AppEndpointStepAnswer {
return apiErrorResponse({
code: DispatcherErrorCode.enum.NOT_IMPLEMENTED,
httpStatus: 501,
message:
`Declarative endpoint '${match.endpoint.name}' claims ${method} ${path}, but the endpoint `
+ 'executor is not enabled in this build. It lands in 17.x (#5040).',
extra: {
hint: 'The mounting seam is in place; execution (target dispatch, authRequired / rateLimit / '
+ 'cacheTtl / mappings) lands with #5040 E4–E5. Until then a non-empty `apis:` is rejected '
+ 'at publish, so no reachable deployment can produce this answer.',
},
extra: { hint },
});
}
Loading
Loading