diff --git a/.changeset/endpoint-policy-keys.md b/.changeset/endpoint-policy-keys.md new file mode 100644 index 0000000000..d596ffe9a9 --- /dev/null +++ b/.changeset/endpoint-policy-keys.md @@ -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=`(`private` 是安全规则:任何响应都可能是按主体裁剪过的);`0`/负值 → `no-store`;缺省 → 不发头;非 GET → 不发头并 warn 点名。 + +链序按 #5040 §3:**先限流、后鉴权**、再算缓存头 —— 凭据爆破本就是匿名流量,先答 401 会让扫号者完全绕开计量。 + +**结构性不可达、零现网行为变更**:非空 `apis:` 在 publish/validate 仍被硬拒(E7 翻转前),且派发步在未获得策略上下文时的答复与此前逐字节相同。 diff --git a/packages/runtime/src/api-endpoint-step.test.ts b/packages/runtime/src/api-endpoint-step.test.ts index efcf67ca32..73c99d3a5a 100644 --- a/packages/runtime/src/api-endpoint-step.test.ts +++ b/packages/runtime/src/api-endpoint-step.test.ts @@ -18,6 +18,7 @@ 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, @@ -25,6 +26,11 @@ import { 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({ @@ -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 { + const entries = new Map(); + const store: CounterStore = { + get: async (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 }; + 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> = []; + const entries = new Map(); + const store: CounterStore = { + get: async (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')]); + }); }); diff --git a/packages/runtime/src/api-endpoint-step.ts b/packages/runtime/src/api-endpoint-step.ts index f47890e24c..c98a2cecf3 100644 --- a/packages/runtime/src/api-endpoint-step.ts +++ b/packages/runtime/src/api-endpoint-step.ts @@ -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 @@ -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; } export interface AppEndpointStepInput { @@ -98,6 +121,19 @@ export interface AppEndpointStepInput { * depend on its landing). */ metadataService: Pick | 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; } /** @@ -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 }, }); } diff --git a/packages/runtime/src/endpoint-policy.test.ts b/packages/runtime/src/endpoint-policy.test.ts new file mode 100644 index 0000000000..f3bd052a3b --- /dev/null +++ b/packages/runtime/src/endpoint-policy.test.ts @@ -0,0 +1,357 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The endpoint policy keys, one key at a time (#5040 E4 / #5091). + * + * Three keys, and for each of them the three cases that matter: declared and + * hit, declared and not hit, and the boundary (`authRequired: false`, a budget + * that is present but disarmed, `cacheTtl: 0`). A policy key that is only ever + * tested in its "allow" direction is indistinguishable from a key nobody read. + * + * Everything is driven with stubs — a counter store, a principal resolver, a + * clock. That is not a convenience: the module takes its dependencies + * explicitly precisely so the decision can be tested without booting a kernel, + * and so the bucket KEYS (the part a security review cares about) are + * observable rather than inferred. + */ + +import { describe, it, expect } from 'vitest'; +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import type { CounterStore } from '@objectstack/plugin-auth'; + +import { + applyEndpointPolicies, + computeCacheControl, + createEndpointRateLimiterRegistry, + endpointBucketKey, + ENDPOINT_BUCKET_PREFIX, + type EndpointPolicyContext, + type EndpointPolicyVerdict, +} from './endpoint-policy.js'; + +/** A declared endpoint with defaults materialized, exactly as the matcher hands it over. */ +function declare(overrides: Record = {}): ApiEndpoint { + return ApiEndpointSchema.parse({ + name: 'showcase_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + ...overrides, + }); +} + +/** An observable counter store — the bucket keyspace is the thing under test. */ +function fakeCache() { + const entries = new Map(); + const store: CounterStore = { + get: async (key: string) => entries.get(key) as T | undefined, + set: async (key: string, value: unknown) => { entries.set(key, value); }, + }; + return { store, entries, keys: () => [...entries.keys()] }; +} + +interface Harness { + context: EndpointPolicyContext; + keys: () => string[]; + tick: (ms: number) => void; +} + +function harness(options: { principalId?: string; remoteAddress?: string; headers?: Record } = {}): Harness { + const cache = fakeCache(); + let now = 1_700_000_000_000; + const limiters = createEndpointRateLimiterRegistry({ + resolveCache: async () => cache.store, + now: () => now, + }); + return { + context: { + limiters, + ...(options.headers ? { headers: options.headers } : {}), + ...(options.remoteAddress ? { remoteAddress: options.remoteAddress } : {}), + resolvePrincipalId: async () => options.principalId, + }, + keys: cache.keys, + tick: (ms: number) => { now += ms; }, + }; +} + +const run = (endpoint: ApiEndpoint, h: Harness, method = 'GET'): Promise => + applyEndpointPolicies({ ...h.context, endpoint, method }); + +// ───────────────────────────────────────────────────────────────────────────── +describe('authRequired — the default is the safe one, and it is materialized', () => { + it('denies an anonymous caller with the platform 401, when the key is omitted', async () => { + // Omitted in the source, `true` after parse: the executor never sees an + // "unspecified" state, so forgetting the key cannot open an endpoint. + const endpoint = declare(); + expect(endpoint.authRequired).toBe(true); + + const verdict = await run(endpoint, harness({})); + expect(verdict.verdict).toBe('deny'); + if (verdict.verdict !== 'deny') return; + expect(verdict.status).toBe(401); + // The SAME 401 every other seam answers (#2567) — not a second dialect. + expect(verdict.body.error.code).toBe('UNAUTHENTICATED'); + expect(verdict.body.error.message).toBe('Authentication is required to access this endpoint.'); + expect(verdict.body.error.httpStatus).toBe(401); + expect(verdict.body.success).toBe(false); + }); + + it('lets an authenticated caller through, and reports who they are', async () => { + const verdict = await run(declare({ authRequired: true }), harness({ principalId: 'usr_7' })); + expect(verdict).toEqual({ verdict: 'pass', principalId: 'usr_7', responseHeaders: {} }); + }); + + it('lets an anonymous caller through when the endpoint declares `authRequired: false`', async () => { + const verdict = await run(declare({ authRequired: false }), harness({})); + expect(verdict).toEqual({ verdict: 'pass', responseHeaders: {} }); + }); + + it('treats an auth lookup that throws as anonymous — a hiccup is a 401, never an outage', async () => { + const h = harness({}); + h.context.resolvePrincipalId = async () => { throw new Error('auth service down'); }; + const verdict = await run(declare(), h); + expect(verdict.verdict).toBe('deny'); + if (verdict.verdict !== 'deny') return; + expect(verdict.status).toBe(401); + }); + + it('never lets a declared path exempt itself through the control-plane allowlist', async () => { + // `isAuthGateAllowlisted` treats any path containing an `/auth/` segment + // as control plane. If the request path were passed to + // `shouldDenyAnonymous`, an app could open its own endpoint by NAMING it + // `.../auth/...` — an authoring-time bypass of the platform default + // deny. The path is not passed; this pins that it stays that way. + const endpoint = declare({ name: 'sneaky', path: '/api/v1/apps/showcase/auth/callback' }); + const verdict = await run(endpoint, harness({})); + expect(verdict.verdict).toBe('deny'); + if (verdict.verdict !== 'deny') return; + expect(verdict.status).toBe(401); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +describe('rateLimit — #5006 primitives, an endpoint-scoped keyspace', () => { + /** Two requests per second, so the boundary is reachable in a test. */ + const limited = (overrides: Record = {}) => declare({ + authRequired: false, + rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 2 }, + ...overrides, + }); + + it('does nothing at all when the key is absent', async () => { + const h = harness({ principalId: 'usr_7' }); + for (let i = 0; i < 20; i++) { + expect((await run(declare(), h)).verdict).toBe('pass'); + } + expect(h.keys(), 'an endpoint with no `rateLimit` touched the counter store').toEqual([]); + }); + + it('does nothing when the budget is present but not armed (`enabled` defaults to false)', async () => { + // The schema default is `enabled: false`, so a block written without it + // is a DISARMED budget. Honouring that literally is the declared = + // enforced reading; #5111 carries the publish gate that stops an author + // from writing one by accident. + const endpoint = declare({ authRequired: false, rateLimit: { windowMs: 1_000, maxRequests: 1 } }); + expect(endpoint.rateLimit?.enabled).toBe(false); + + const h = harness({}); + for (let i = 0; i < 5; i++) expect((await run(endpoint, h)).verdict).toBe('pass'); + expect(h.keys()).toEqual([]); + }); + + it('admits up to the budget, then answers the 429 with a usable Retry-After', async () => { + const h = harness({ principalId: 'usr_7' }); + const endpoint = limited(); + + expect((await run(endpoint, h)).verdict).toBe('pass'); // 1st — at budget + expect((await run(endpoint, h)).verdict).toBe('pass'); // 2nd — at budget + const over = await run(endpoint, h); // 3rd — over + + expect(over.verdict).toBe('deny'); + if (over.verdict !== 'deny') return; + expect(over.status).toBe(429); + // Byte-identical to the server-level limiter's 429 (#5006): one 429 on + // this wire surface, whichever budget produced it. + expect(over.body.error.code).toBe('RATE_LIMIT_EXCEEDED'); + expect(over.body.error.message).toBe('Rate limit exceeded. Retry after the interval in the Retry-After header.'); + expect(over.headers?.['Retry-After']).toBe('1'); + expect((over.body.error.details as { retryAfterSeconds: number }).retryAfterSeconds).toBe(1); + expect(typeof (over.body.error.details as { resetAt: string }).resetAt).toBe('string'); + + // And the budget really is a budget: it refills. + h.tick(1_000); + expect((await run(endpoint, h)).verdict).toBe('pass'); + }); + + it('keys the bucket by endpoint × caller, under its own namespace', async () => { + const h = harness({ principalId: 'usr_7' }); + await run(limited(), h); + expect(h.keys()).toEqual([endpointBucketKey('showcase_tasks', 'principal:usr_7')]); + expect(h.keys()[0]).toBe(`${ENDPOINT_BUCKET_PREFIX}showcase_tasks:principal:usr_7`); + // NOT the bare `principal:usr_7` the server-level limiter counts in — + // the two budgets are independent, and sharing a keyspace would make the + // smaller one silently smaller still. + expect(h.keys()).not.toContain('principal:usr_7'); + }); + + it('gives two endpoints separate budgets', async () => { + const h = harness({ principalId: 'usr_7' }); + const a = limited(); + const b = limited({ name: 'showcase_reports', path: '/api/v1/apps/showcase/reports' }); + + expect((await run(a, h)).verdict).toBe('pass'); + expect((await run(a, h)).verdict).toBe('pass'); + expect((await run(a, h)).verdict).toBe('deny'); + // `b` is untouched by `a` exhausting itself. + expect((await run(b, h)).verdict).toBe('pass'); + expect((await run(b, h)).verdict).toBe('pass'); + expect((await run(b, h)).verdict).toBe('deny'); + expect(h.keys().sort()).toEqual([ + endpointBucketKey('showcase_reports', 'principal:usr_7'), + endpointBucketKey('showcase_tasks', 'principal:usr_7'), + ]); + }); + + it('gives two callers separate budgets, and keys anonymous traffic by peer address', async () => { + const endpoint = limited(); + const cache = fakeCache(); + let now = 1_700_000_000_000; + const limiters = createEndpointRateLimiterRegistry({ resolveCache: async () => cache.store, now: () => now }); + const as = (ctx: Partial) => + applyEndpointPolicies({ limiters, ...ctx, endpoint, method: 'GET' }); + + const alice = { resolvePrincipalId: async () => 'usr_alice' }; + const anon = { remoteAddress: '203.0.113.9' }; + + expect((await as(alice)).verdict).toBe('pass'); + expect((await as(alice)).verdict).toBe('pass'); + expect((await as(alice)).verdict).toBe('deny'); + // A different session is not throttled by Alice's spending… + expect((await as({ resolvePrincipalId: async () => 'usr_bob' })).verdict).toBe('pass'); + // …and neither is an anonymous caller, who keys by address. + expect((await as(anon)).verdict).toBe('pass'); + expect(cache.keys()).toContain(endpointBucketKey('showcase_tasks', 'ip:203.0.113.9')); + }); + + it('never keys off a forwarded header unless `trustProxy` was declared', async () => { + // Untrusted, `X-Forwarded-For` is attacker input: honouring it lets + // anyone mint a fresh bucket per request. Q3=C, inherited whole from + // `resolveRateLimitKey` rather than re-decided here. + const endpoint = limited(); + const forged = { 'x-forwarded-for': '198.51.100.1' }; + + const untrusted = harness({ headers: forged, remoteAddress: '203.0.113.9' }); + await run(endpoint, untrusted); + expect(untrusted.keys()).toEqual([endpointBucketKey('showcase_tasks', 'ip:203.0.113.9')]); + + const trusted = harness({ headers: forged, remoteAddress: '203.0.113.9' }); + trusted.context.trustProxy = true; + await run(endpoint, trusted); + expect(trusted.keys()).toEqual([endpointBucketKey('showcase_tasks', 'ip:198.51.100.1')]); + }); + + it('meters BEFORE it gates, so a 401 storm still spends the budget (#5040 §3)', async () => { + // The order that matters most and is easiest to get wrong: credential + // stuffing is anonymous traffic against an `authRequired` endpoint. If + // the 401 came first, none of it would ever reach the meter. + const endpoint = limited({ authRequired: true }); + const h = harness({ remoteAddress: '203.0.113.9' }); + + const first = await run(endpoint, h); + expect(first.verdict).toBe('deny'); + if (first.verdict === 'deny') expect(first.status).toBe(401); + expect(h.keys()).toEqual([endpointBucketKey('showcase_tasks', 'ip:203.0.113.9')]); + + await run(endpoint, h); + const third = await run(endpoint, h); + expect(third.verdict).toBe('deny'); + if (third.verdict !== 'deny') return; + expect(third.status, 'the anonymous caller was never metered').toBe(429); + }); + + it('serves the request when the counter store itself is broken — bounded fail-open', async () => { + const limiters = createEndpointRateLimiterRegistry({ + resolveCache: async () => ({ + get: async () => { throw new Error('cache backend down'); }, + set: async () => { throw new Error('cache backend down'); }, + }), + }); + const verdict = await applyEndpointPolicies({ + limiters, endpoint: limited(), method: 'GET', + }); + // Same trade #5006 makes: a cache outage is a louder problem than an + // unmetered request, and it must not take the API down. + expect(verdict.verdict).toBe('pass'); + }); + + it('refuses to serve an armed budget it cannot honour, naming the endpoint', async () => { + const h = harness({}); + const zeroBudget = declare({ authRequired: false, rateLimit: { enabled: true, maxRequests: 0 } }); + await expect(run(zeroBudget, h)).rejects.toThrow(/showcase_tasks/); + // Fail CLOSED: the alternative is quietly serving traffic the author + // asked to be metered. E7's publish gate is where this should be met. + await expect(run(zeroBudget, h)).rejects.toThrow(/rateLimit/); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +describe('cacheTtl — response-header semantics only', () => { + it('says nothing when the key is absent', async () => { + expect(computeCacheControl(declare(), 'GET')).toBeUndefined(); + const verdict = await run(declare({ authRequired: false }), harness({})); + expect(verdict).toEqual({ verdict: 'pass', responseHeaders: {} }); + }); + + it('sets `private, max-age=` for a positive ttl', async () => { + const verdict = await run(declare({ authRequired: false, cacheTtl: 30 }), harness({})); + expect(verdict.verdict).toBe('pass'); + if (verdict.verdict !== 'pass') return; + expect(verdict.responseHeaders).toEqual({ 'Cache-Control': 'private, max-age=30' }); + }); + + it('is `private` even on an anonymous endpoint — a shared cache must never hold a per-caller answer', () => { + expect(computeCacheControl({ name: 'e', cacheTtl: 60 }, 'GET')).toBe('private, max-age=60'); + }); + + it('reads 0 as "do not cache" rather than as silence', async () => { + // The boundary #5091 asks for. `cacheTtl: 0` is a sentence the author + // wrote; answering it identically to an absent key would make writing it + // a no-op, which is the failure mode this program exists to remove. + expect(computeCacheControl({ name: 'e', cacheTtl: 0 }, 'GET')).toBe('no-store'); + const verdict = await run(declare({ authRequired: false, cacheTtl: 0 }), harness({})); + expect(verdict.verdict).toBe('pass'); + if (verdict.verdict !== 'pass') return; + expect(verdict.responseHeaders).toEqual({ 'Cache-Control': 'no-store' }); + }); + + it('truncates a fractional ttl rather than emitting a fractional max-age', () => { + expect(computeCacheControl({ name: 'e', cacheTtl: 30.7 }, 'GET')).toBe('private, max-age=30'); + }); + + it('refuses to invent a meaning for a negative ttl', () => { + expect(computeCacheControl({ name: 'e', cacheTtl: -5 }, 'GET')).toBe('no-store'); + }); + + it('sends no header on a non-GET endpoint, and says so out loud', () => { + const warnings: string[] = []; + const header = computeCacheControl({ name: 'purge', cacheTtl: 30 }, 'POST', { + warn: (m: string) => { warnings.push(m); }, + }); + expect(header).toBeUndefined(); + expect(warnings.join('\n')).toContain("endpoint 'purge'"); + expect(warnings.join('\n')).toContain('GET-only'); + }); + + it('is not computed for a denied request', async () => { + // Nothing to cache, nothing to say: the denial carries its own headers + // (`Retry-After`) and no cache directive at all. + const denied = await run(declare({ cacheTtl: 30 }), harness({})); + expect(denied.verdict).toBe('deny'); + if (denied.verdict !== 'deny') return; + expect(denied.headers).toBeUndefined(); + expect(JSON.stringify(denied.body)).not.toContain('max-age'); + }); +}); diff --git a/packages/runtime/src/endpoint-policy.ts b/packages/runtime/src/endpoint-policy.ts new file mode 100644 index 0000000000..7c0225a48c --- /dev/null +++ b/packages/runtime/src/endpoint-policy.ts @@ -0,0 +1,368 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The POLICY KEYS of a declarative `apis:` endpoint — `authRequired`, + * `rateLimit`, `cacheTtl` (#5040 E4). + * + * ## What this is, and what it deliberately is not + * + * `ApiEndpointSchema` declares three policy keys. Until now every one of them + * parsed and did nothing — the #4686 shape this whole program exists to end. + * This module is their single reader. It invents NOTHING: each key is answered + * with the mechanism the platform already uses for that question, so a declared + * endpoint cannot drift from the surface next door. + * + * | key | answered by | shared with | + * |---|---|---| + * | `authRequired` | `shouldDenyAnonymous` + the `ANONYMOUS_DENY_*` constants | `/meta`, `/ai`, `/security` (#2567, #3963) | + * | `rateLimit` | `deriveBucketConfig` / `resolveRateLimitKey` / `SharedTokenBucketLimiter` | the server-level inbound limiter (#5006, #4910 Q3=C / Q4=B) | + * | `cacheTtl` | a `Cache-Control` response header, nothing more | — | + * + * `cacheTtl` is header semantics ONLY. #5091 narrowed the design's original + * server-side cache out of scope on purpose: a cache needs an invalidation + * story, and inventing one for a key whose vocabulary says four words + * ("Response cache TTL in seconds") is how a runtime dialect is born. A header + * is the part of the meaning the vocabulary actually fixes. + * + * ## Why this is a pure function over explicit deps + * + * No service lookup happens in here — the caller passes what it resolved. That + * keeps the whole policy chain testable against stubs (every case below is), and + * it keeps the "which kernel / which environment" question where it already + * lives, in the dispatch seam. + * + * ## Order: rate limit BEFORE auth. This is not an accident. + * + * #5040 §3 fixes the order as `rateLimit → authRequired → cacheTtl`, and the + * rationale is worth restating where the code is: the traffic that most needs + * metering — credential stuffing, token spraying, scraping — is exactly the + * traffic that will be answered 401. Gating first and metering second would let + * a scanner make unlimited attempts for free, because none of them would ever + * reach the meter. So a denied request DOES spend a token. That is the point of + * the budget, not a leak in it. + * + * Resolving WHO is calling is not the same act as gating on it: the principal is + * looked up first (it keys the bucket — a real session must not share a bucket + * with anonymous traffic from the same address), then the meter runs, then the + * gate. One lookup, used twice. + * + * ## Structurally unreachable today + * + * A non-empty `apis:` is rejected at publish / validate until the #5040 E7 flip, + * so nothing here can be observed by a deployment. The tests drive it directly, + * as #5040 §5 prescribes for every E-series unit landing before the flip. + */ + +import { + ANONYMOUS_DENY_CODE, + ANONYMOUS_DENY_MESSAGE, + ANONYMOUS_DENY_STATUS, + shouldDenyAnonymous, +} from '@objectstack/core'; +import { createLazyCounterStore, type CounterStore } from '@objectstack/plugin-auth'; +import type { ApiEndpoint } from '@objectstack/spec/api'; + +import { apiErrorResponse, type ApiErrorEnvelope } from './error-envelope.js'; +import { + deriveBucketConfig, + resolveRateLimitKey, + SharedTokenBucketLimiter, + type RateLimitLogger, +} from './security/inbound-rate-limit.js'; + +/** Headers as every adapter delivers them. */ +export type HeaderBag = Record; + +/** + * The bucket-key namespace for endpoint-level budgets. + * + * The server-level limiter (#5006) keys its buckets `principal:…` / `ip:…` with + * no prefix. Prefixing here is what makes the two budgets INDEPENDENT rather + * than one budget counted twice: the server-level middleware is the deployment's + * global floor, an endpoint's `rateLimit` is that endpoint's own business quota, + * and a request that passes through both spends one token in each. Sharing a + * keyspace would silently halve whichever budget was smaller. + */ +export const ENDPOINT_BUCKET_PREFIX = 'apiep:'; + +/** Bucket key for one endpoint × one caller. Spelled once; asserted in tests. */ +export function endpointBucketKey(endpointName: string, callerKey: string): string { + return `${ENDPOINT_BUCKET_PREFIX}${endpointName}:${callerKey}`; +} + +/** + * Per-endpoint limiters over ONE shared counter store. + * + * A limiter is a bucket CONFIG plus a store handle, so one per endpoint is the + * right granularity — the per-caller keyspace lives inside the store, not here. + * The cache key includes the derived config, so re-publishing an endpoint with a + * different budget yields a different limiter without needing an invalidation + * hook to be wired and remembered; a stale entry cannot outlive its declaration + * because it is no longer reachable by key. + */ +export interface EndpointRateLimiterRegistry { + /** + * The limiter for this endpoint, or `null` when the endpoint declares no + * `rateLimit` or declares it with `enabled: false` (the schema default). + * + * Throws when the endpoint declares an ARMED budget that cannot be honoured + * (`maxRequests` / `windowMs` ≤ 0). Failing closed is deliberate: the + * alternative is serving traffic that the author asked to be metered and + * which silently is not. #5040 E7's publish gate is where an author should + * meet this, with a prescription, before anything is deployed. + */ + limiterFor(endpoint: Pick): SharedTokenBucketLimiter | null; +} + +export interface EndpointRateLimiterRegistryOptions { + /** Resolve the kernel `cache` service. Called per consume — ADR-0069 D2 / #4772. */ + resolveCache: () => Promise; + logger?: RateLimitLogger; + /** Injectable clock — tests only. */ + now?: () => number; +} + +export function createEndpointRateLimiterRegistry( + opts: EndpointRateLimiterRegistryOptions, +): EndpointRateLimiterRegistry { + // ONE store handle for every endpoint bucket: the degraded-mode warning + // ("no shared cache, so the effective limit is budget × nodes") is announced + // once per process rather than once per declared endpoint. + const resolveStore = createLazyCounterStore({ + resolveCache: opts.resolveCache, + ...(opts.logger ? { logger: opts.logger as { info?(m: string): void; warn?(m: string): void } } : {}), + subject: 'declarative endpoint rate-limit buckets', + degradedImpact: + 'Until a shared cache is registered each node meters endpoint budgets on its own, so the effective ' + + 'limit is the declared budget MULTIPLIED BY the number of nodes, and nothing about the deployment ' + + 'will look wrong.', + logPrefix: '[dispatcher]', + }); + + const limiters = new Map(); + + return { + limiterFor(endpoint) { + let config; + try { + config = deriveBucketConfig(endpoint.rateLimit); + } catch (err) { + // `deriveBucketConfig`'s message names `server.security.rateLimit` + // — right rule, wrong noun for an endpoint declaration. Rethrown + // with the authoring surface the reader actually edits, rather + // than duplicating the validation itself. + throw new Error( + `Endpoint '${endpoint.name}' declares an unusable \`rateLimit\`: ${(err as Error).message} ` + + '(the same rule as the server-level budget; write it on the endpoint, or drop `enabled: true` ' + + 'to leave the endpoint unmetered).', + ); + } + if (!config) return null; + + const cacheKey = `${endpoint.name} ${config.capacity} ${config.refillPerSec}`; + let limiter = limiters.get(cacheKey); + if (!limiter) { + limiter = new SharedTokenBucketLimiter(config, resolveStore, opts.now ?? Date.now); + limiters.set(cacheKey, limiter); + } + return limiter; + }, + }; +} + +/** Everything the policy chain needs that it cannot derive from the endpoint. */ +export interface EndpointPolicyContext { + /** Request headers, as the transport reports them. */ + headers?: HeaderBag; + /** The transport's peer address (`IHttpRequest.remoteAddress`). */ + remoteAddress?: string; + /** + * Resolve the caller's principal id from headers — the same + * `resolveSessionPrincipalId` lookup the server-level limiter and the + * dispatcher's own route mounts use. `undefined` means anonymous, which is a + * normal outcome, never an error. + */ + resolvePrincipalId?: (headers: HeaderBag) => Promise; + /** Per-endpoint limiters. Required: an armed budget with nowhere to count is a wiring bug, not a config. */ + limiters: EndpointRateLimiterRegistry; + /** `server.trustProxy` — believe forwarded headers when keying by address. */ + trustProxy?: boolean; + logger?: RateLimitLogger; +} + +export interface EndpointPolicyInput extends EndpointPolicyContext { + /** The matched endpoint, ALREADY parsed — `authRequired` is materialized. */ + endpoint: ApiEndpoint; + /** Request method as the transport reports it. */ + method: string; +} + +export type EndpointPolicyVerdict = + | { + verdict: 'pass'; + /** Who the request was attributed to, so the executor need not ask twice. */ + principalId?: string; + /** + * Headers for the endpoint's eventual SUCCESS answer — today only + * `Cache-Control`, from `cacheTtl`. Handed back rather than applied + * because the thing being described (the response body) does not exist + * yet: execution lands with #5040 E5, and telling a client to cache a + * 501 for a minute would be worse than saying nothing. + */ + responseHeaders: Record; + } + | { + verdict: 'deny'; + status: number; + body: { success: false; error: ApiErrorEnvelope }; + /** Headers that are part of the denial (`Retry-After` on a 429). */ + headers?: Record; + }; + +/** + * `cacheTtl` → the `Cache-Control` header for a successful response. + * + * The vocabulary fixes the unit (seconds) and nothing else, so the rest is + * stated here, tested, and documented rather than left to a reader's guess: + * + * - **absent** → no header at all. The runtime says nothing about caching, as + * it does for every other surface today. "Nothing declared" must not become + * an opinion nobody wrote. + * - **> 0** → `private, max-age=`. `private` is a SECURITY rule, not a + * tuning choice, and it holds even for `authRequired: false` endpoints: any + * response can be RLS-trimmed for whoever happens to be authenticated, so a + * shared cache must never store one and hand it to somebody else. (The + * design's per-principal cache key, #5040 §3.3, is the same rule one layer + * down; with no server-side cache this is where it survives.) + * - **0 or negative** → `no-store`. An author who writes `cacheTtl: 0` said + * something; making it identical to saying nothing is exactly the silent + * no-op this program exists to remove. (E7's publish gate should reject a + * NEGATIVE ttl outright — noted on #5111 — but the runtime still has to + * answer coherently if one arrives.) + * - **non-GET** → no header, plus a `warn` naming the endpoint. `cacheTtl` is + * GET-only (#5040 §3.3) and E7 rejects the combination at publish; until + * then the runtime refuses to invent a meaning for it, and says so out loud + * instead of dropping it silently. + */ +export function computeCacheControl( + endpoint: Pick, + method: string, + logger?: RateLimitLogger, +): string | undefined { + const ttl = endpoint.cacheTtl; + if (ttl === undefined || ttl === null) return undefined; + + if (method.toUpperCase() !== 'GET') { + logger?.warn?.( + `[dispatcher] endpoint '${endpoint.name}' declares \`cacheTtl\` on a ${method.toUpperCase()} endpoint. ` + + '`cacheTtl` is GET-only (#5040 §3.3) and no Cache-Control header will be sent. Remove the key, or ' + + 'declare the endpoint as GET.', + ); + return undefined; + } + + if (!Number.isFinite(ttl) || ttl <= 0) return 'no-store'; + return `private, max-age=${Math.floor(ttl)}`; +} + +/** The 401 every seam on this platform answers — same code, same message, same envelope. */ +function anonymousDenial(): EndpointPolicyVerdict { + const { status, body } = apiErrorResponse({ + code: ANONYMOUS_DENY_CODE, + httpStatus: ANONYMOUS_DENY_STATUS, + message: ANONYMOUS_DENY_MESSAGE, + }); + return { verdict: 'deny', status, body }; +} + +/** + * Run the policy chain for one matched endpoint. + * + * Returns `pass` (with the headers the eventual answer should carry) or the + * `deny` answer to write. It never executes anything: the target runs after + * this, and only after this. + */ +export async function applyEndpointPolicies(input: EndpointPolicyInput): Promise { + const { endpoint, method, limiters, logger } = input; + + // ── ⓪ WHO is calling ──────────────────────────────────────────────── + // A lookup, not a gate. It keys the bucket in ① and answers the question + // in ②; asking twice is how two seams start disagreeing about who counts as + // authenticated (the reason `resolveSessionPrincipalId` is a module). + let principalId: string | undefined; + if (input.resolvePrincipalId) { + try { + principalId = await input.resolvePrincipalId(input.headers ?? {}); + } catch { + // Unresolvable identity is ANONYMOUS, not an error — same rule as + // the server-level limiter. `authRequired` below still denies it, so + // an auth hiccup costs a caller a 401, never an outage. + principalId = undefined; + } + } + + // ── ① rateLimit ───────────────────────────────────────────────────── + const limiter = limiters.limiterFor(endpoint); + if (limiter) { + const { key } = resolveRateLimitKey({ + ...(principalId ? { principalId } : {}), + headers: input.headers ?? {}, + ...(input.remoteAddress ? { remoteAddress: input.remoteAddress } : {}), + trustProxy: input.trustProxy === true, + }); + + let decision; + try { + decision = await limiter.consume(endpointBucketKey(endpoint.name, key)); + } catch { + // The one fail-open, bounded exactly as #5006 bounds it: a counter + // store that throws means the cache backend is erroring, which is a + // louder problem than an unmetered request — and taking the API down + // with it would be the wrong trade. + decision = undefined; + } + + if (decision && !decision.allowed) { + const retryAfterSec = Math.max(1, Math.ceil(decision.retryAfterMs / 1000)); + // Same body, same message, same details as the server-level 429 + // (`createInboundRateLimitMiddleware`) — one 429 on this wire + // surface, whichever budget produced it. No explicit `code`: it is + // derived from the status by `standardErrorCodeForHttpStatus`, + // exactly as it is there. + const { status, body } = apiErrorResponse({ + message: 'Rate limit exceeded. Retry after the interval in the Retry-After header.', + httpStatus: 429, + details: { retryAfterSeconds: retryAfterSec, resetAt: new Date(decision.resetAt).toISOString() }, + }); + return { verdict: 'deny', status, body, headers: { 'Retry-After': String(retryAfterSec) } }; + } + } + + // ── ② authRequired ────────────────────────────────────────────────── + // The key arrives MATERIALIZED (`ApiEndpointSchema` defaults it to `true`), + // so there is no "omitted" state to read here: forgetting the key gets the + // safe answer, and `authRequired: false` — the only way to open an endpoint + // — is a visible line in a diff. + // + // ⚠️ The request PATH is deliberately not passed to `shouldDenyAnonymous`. + // Its optional path argument exempts control-plane paths, and + // `isAuthGateAllowlisted` treats ANY path containing an `/auth/` segment as + // one. An app-declared `/api/v1/apps//auth/callback` would therefore + // exempt itself from its own `authRequired: true` — an authoring-time + // bypass of the platform's default deny. Omitting the argument means no + // exemption can be reached from a declared path at all. + if (endpoint.authRequired !== false) { + if (shouldDenyAnonymous({ userId: principalId, isSystem: false, method })) { + return anonymousDenial(); + } + } + + // ── ③ cacheTtl ────────────────────────────────────────────────────── + const cacheControl = computeCacheControl(endpoint, method, logger); + + return { + verdict: 'pass', + ...(principalId ? { principalId } : {}), + responseHeaders: cacheControl ? { 'Cache-Control': cacheControl } : {}, + }; +} diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts index e22ddfff38..8c6fe46680 100644 --- a/packages/runtime/src/error-envelope.conformance.test.ts +++ b/packages/runtime/src/error-envelope.conformance.test.ts @@ -241,6 +241,13 @@ describe('#3842 — no dispatcher module may reintroduce the drift', () => { // the first that answers a request no route matched. Listed the day it // was written, for the same reason the line above was. './api-endpoint-step.ts', + // [#5091] The endpoint POLICY keys write the 401 and the 429 that a + // declared endpoint answers — a seventh way onto this wire surface. + // Both bodies deliberately restate an existing seam's (the platform + // anonymous-deny, the server-level limiter's 429), which is exactly the + // situation where a hand-rolled copy is tempting; the scan is what makes + // "restate" mean "call the same builder". + './endpoint-policy.ts', // [#5092] The endpoint executor maps a delegated pipeline's failure onto // this wire surface — a restatement of `errorFromThrown` outside the // dispatcher class, which is exactly the kind of second copy this scan