diff --git a/.changeset/endpoint-mapping-keys.md b/.changeset/endpoint-mapping-keys.md new file mode 100644 index 0000000000..27f54ee4c9 --- /dev/null +++ b/.changeset/endpoint-mapping-keys.md @@ -0,0 +1,16 @@ +--- +'@objectstack/runtime': minor +--- + +**声明式端点的映射键:`inputMapping` / `outputMapping` 链内应用(#5040 E5c)** + +两个键此前被 `ApiEndpointSchema` 声明、被 runtime 读取零次:作者写了、publish 放行、端点跑起来映射什么也不做 —— 正是 #5040 要消灭的「解析通过然后什么也不发生」中间态,也是 ADR-0049 `declared ≠ enforced` 的教科书形状(对 AI 写的元数据尤其糟:静默忽略的键不产生任何信号)。新纯模块 `api-mapping.ts` 是它们的唯一读者,语义**只**来自冻结词表的 describe 文本,取其最小忠实解读: + +- **`inputMapping`(*Map Request Body to Internal Params*)**:`source` 按点路径读**请求体**,投影出目标入参;在策略链通过之后、委派之前应用,因此映射永远买不通 `authRequired` / `rateLimit`,而 `endpoint-executor` 保持纯委派、对映射无感知。词表只说 body,**query 不并入**(合并会凭空发明一条谁覆盖谁的优先级规则),query 照旧原样抵达管线。 +- **`outputMapping`(*Map Internal Result to Response Body*)**:只作用于**成功**答案的载荷(`{success, data, meta}` 的 `data`),包络逐字保留 —— 声明改不动 `success`,也就无法把失败装扮成数据。401 / 429 / 400 / 501 一律不重映射。 +- **映射是投影,不是合并**:结果只由声明的 `target` 组成,未声明的字段不随行。出站方向因此天然是一份 allow-list —— `apis` 是平台的对外面(ADR-0121 D3),默认泄漏内部字段不是可接受的缺省。 +- **`source` 解析不到 ⇒ `target` 不写**(映射是投影不是校验器);**无声明 ⇒ 逐字节直通、按引用原样传递**,未声明映射的端点与 E5b 的行为完全一致。 +- **无法服务的声明响亮拒绝**,不静默跳过、不半应用:`transform`(全仓无「transformation function name」注册表,发明它是沙箱裁决而非映射细节)、不可用路径(空串、空段 `a..b`、`__proto__` / `prototype` / `constructor`)、互撞的 `target`(同路径或一个写进另一个内部)—— 均为结构化 **501 NOT_IMPLEMENTED**(带处方,点名具体条目如 `inputMapping[1].transform`),与 `endpoint-executor` 的 `unsupported` 分支同类同形。`outputMapping` 的这道判定在**委派之前**做:投影坏掉的 `create` 不该先插入记录再拒绝作答。 +- 新模块已加入 `error-envelope.conformance.test.ts` 的源码扫描名单。 + +**现网行为零变更**:非空 `apis:` 在 publish / validate 仍被硬拒(E7 #5111 前不撤),整条端点链结构性不可达。上述「不支持子集」应由 E7 的 publish 门在作者写应用时就拒掉,本模块是运行期兜底,不是主关口。 diff --git a/packages/runtime/src/api-endpoint-step.test.ts b/packages/runtime/src/api-endpoint-step.test.ts index f49eaf1067..3863cb4003 100644 --- a/packages/runtime/src/api-endpoint-step.test.ts +++ b/packages/runtime/src/api-endpoint-step.test.ts @@ -394,3 +394,211 @@ describe('execution runs on the far side of the policy chain', () => { expect(hint).toContain('no execution wiring'); }); }); + +/** + * The mapping keys, joined to the chain (#5040 E5c / #5137). + * + * `api-mapping.test.ts` owns what a projection IS; what is asserted here is + * where it applies — that a mapped body is what the executor delegates, that a + * mapped result is what the caller receives, that an ERROR answer is never + * remapped whatever produced it, and that a declaration this runtime cannot + * serve is refused before the target runs rather than after. + */ +describe('the mapping keys apply on the two sides of the delegation', () => { + const CREATE: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'showcase_inquiries', + path: '/api/v1/apps/showcase/inquiries', + method: 'POST', + type: 'object_operation', + target: 'showcase_inquiry', + objectParams: { object: 'showcase_inquiry', operation: 'create' }, + authRequired: false, + }); + + const limiters = () => createEndpointRateLimiterRegistry({ resolveCache: async () => undefined }); + + function callDataSpy(result: unknown = { id: 'rec_1', name: 'Ada', internal_note: 'do not ship' }) { + const calls: unknown[][] = []; + return { calls, fn: async (...args: unknown[]) => { calls.push(args); return result; } }; + } + + const mappedStep = ( + endpoint: ApiEndpoint, + callData: unknown, + body: unknown = { firstName: 'Ada', secret: 'internal' }, + policy: Partial = {}, + ) => runAppEndpointStep({ + method: endpoint.method, + path: endpoint.path, + prefix: '/api/v1', + metadataService: matcherFor([endpoint]).service as never, + policy: { limiters: limiters(), ...policy }, + execution: { + request: { method: endpoint.method, path: endpoint.path, query: { trace: '1' }, body }, + deps: { callData: callData as never }, + }, + }); + + it('delegates the MAPPED body — the executor never sees the raw one', async () => { + const spy = callDataSpy(); + const mapped = ApiEndpointSchema.parse({ + ...CREATE, + inputMapping: [{ source: 'firstName', target: 'first_name' }], + }); + + const answer = await mappedStep(mapped, spy.fn); + + expect(answer?.status).toBe(201); + // `data` is the projection: the renamed field is there and the + // undeclared one is gone, delegated through the same `callData` shape + // `/data` uses. + expect(spy.calls).toEqual([['create', { object: 'showcase_inquiry', data: { first_name: 'Ada' } }, undefined, undefined, undefined]]); + }); + + it('leaves the query string alone — inputMapping maps the BODY', async () => { + // The vocabulary says "Map Request Body to Internal Params"; query + // parameters keep reaching the pipeline exactly as they did before. + const spy = callDataSpy({ records: [], total: 0 }); + const find = ApiEndpointSchema.parse({ + ...CREATE, + name: 'showcase_find', + method: 'GET', + objectParams: { object: 'showcase_inquiry', operation: 'find' }, + inputMapping: [{ source: 'firstName', target: 'first_name' }], + }); + + await mappedStep(find, spy.fn); + + expect((spy.calls[0]![1] as { query: unknown }).query).toEqual({ trace: '1' }); + }); + + it('delegates the caller\'s own body when no mapping is declared', async () => { + const spy = callDataSpy(); + const body = { firstName: 'Ada', secret: 'internal' }; + + await mappedStep(CREATE, spy.fn, body); + + // By reference: an endpoint that declares no mapping is served exactly + // as E5b served it, with no projection in between. + expect((spy.calls[0]![1] as { data: unknown }).data).toBe(body); + }); + + it('answers with the MAPPED result on a success', async () => { + const spy = callDataSpy(); + const mapped = ApiEndpointSchema.parse({ + ...CREATE, + outputMapping: [{ source: 'id', target: 'inquiry_id' }, { source: 'name', target: 'contact.name' }], + }); + + const answer = await mappedStep(mapped, spy.fn); + + expect(answer?.status).toBe(201); + expect(answer?.body).toEqual({ + success: true, + data: { inquiry_id: 'rec_1', contact: { name: 'Ada' } }, + meta: undefined, + }); + // The allow-list property, end to end: an internal field the pipeline + // returned and the declaration did not name never reaches the wire. + expect(JSON.stringify(answer?.body)).not.toContain('internal_note'); + }); + + it('keeps the cacheTtl header on a mapped success', async () => { + // `cacheTtl` is GET-only (#5040 §3.3), so this is a read endpoint: the + // point is that the two keys compose — the projection replaces the body + // and the policy verdict's header still rides with it. + const mapped = ApiEndpointSchema.parse({ + ...CREATE, + name: 'showcase_cached_map', + method: 'GET', + objectParams: { object: 'showcase_inquiry', operation: 'find' }, + cacheTtl: 30, + outputMapping: [{ source: 'total', target: 'count' }], + }); + + const answer = await mappedStep(mapped, callDataSpy({ records: [], total: 2 }).fn); + + expect(answer?.body).toEqual({ success: true, data: { count: 2 }, meta: undefined }); + expect(answer?.headers).toEqual({ 'Cache-Control': 'private, max-age=30' }); + }); + + it('never remaps an ERROR answer — a mapping must not disguise a failure', async () => { + const outputMapping = [{ source: 'id', target: 'inquiry_id' }]; + + // 401: denied by the policy chain, before execution. + const authed = ApiEndpointSchema.parse({ ...CREATE, name: 'showcase_authed', authRequired: true, outputMapping }); + const denied = await mappedStep(authed, callDataSpy().fn); + expect(denied?.status).toBe(401); + expect((denied!.body as { error: { code: string } }).error.code).toBe('UNAUTHENTICATED'); + + // 400: a delegated pipeline's own failure. + const failing = ApiEndpointSchema.parse({ ...CREATE, name: 'showcase_failing', outputMapping }); + const bad = await mappedStep(failing, async () => { throw { statusCode: 400, message: 'name is required' }; }); + expect(bad?.status).toBe(400); + expect((bad!.body as { error: { message: string } }).error.message).toBe('name is required'); + + // 501: a declaration this runtime does not execute. + const proxied = ApiEndpointSchema.parse({ + ...CREATE, name: 'showcase_proxy_map', type: 'proxy', target: 'https://example.invalid', outputMapping, + }); + const unsupported = await mappedStep(proxied, callDataSpy().fn); + expect(unsupported?.status).toBe(501); + expect((unsupported!.body as { error: { code: string } }).error.code).toBe('NOT_IMPLEMENTED'); + + // 429: the endpoint budget, spent. Every one of these bodies is the + // error envelope, untouched by the declared projection. + 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({ + ...CREATE, name: 'showcase_limited_map', outputMapping, + rateLimit: { enabled: true, windowMs: 1_000, maxRequests: 1 }, + }); + const policy = { limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => store }) }; + expect((await mappedStep(limited, callDataSpy().fn, undefined, policy))?.status).toBe(201); + const over = await mappedStep(limited, callDataSpy().fn, undefined, policy); + expect(over?.status).toBe(429); + + for (const answer of [denied, bad, unsupported, over]) { + expect(JSON.stringify(answer?.body)).not.toContain('inquiry_id'); + expect((answer!.body as { success: boolean }).success).toBe(false); + } + }); + + it('refuses a `transform` declaration at request time, without executing anything', async () => { + const spy = callDataSpy(); + const withTransform = ApiEndpointSchema.parse({ + ...CREATE, + inputMapping: [{ source: 'price', target: 'amount', transform: 'convertToInt' }], + }); + + const answer = await mappedStep(withTransform, spy.fn); + + expect(answer?.status).toBe(501); + const error = (answer!.body as { error: Record }).error; + expect(error.code).toBe('NOT_IMPLEMENTED'); + expect(String(error.message)).toContain('inputMapping[0].transform'); + expect(spy.calls, 'a refused declaration still reached the pipeline').toEqual([]); + // No `Cache-Control` on a refusal, for the same reason as any error. + expect(answer?.headers).toBeUndefined(); + }); + + it('refuses a broken outputMapping BEFORE the target runs, not after', async () => { + // The ordering that matters: a `create` with an unservable projection + // must not insert the record and then fail to answer with it. + const spy = callDataSpy(); + const broken = ApiEndpointSchema.parse({ + ...CREATE, + outputMapping: [{ source: 'id', target: 'a' }, { source: 'name', target: 'a.b' }], + }); + + const answer = await mappedStep(broken, spy.fn); + + expect(answer?.status).toBe(501); + expect(String((answer!.body as { error: { message: string } }).error.message)) + .toContain('outputMapping[1].target'); + expect(spy.calls, 'the record was created and then the answer was refused').toEqual([]); + }); +}); diff --git a/packages/runtime/src/api-endpoint-step.ts b/packages/runtime/src/api-endpoint-step.ts index 3b5d73cd26..73b79eef9c 100644 --- a/packages/runtime/src/api-endpoint-step.ts +++ b/packages/runtime/src/api-endpoint-step.ts @@ -42,10 +42,28 @@ * header describes a body the caller should be willing to reuse, and telling a * client to cache a 401 / 429 / 500 for a minute is worse than saying nothing. * - * What it does NOT do, so nobody reads more into it than is here: - * `inputMapping` / `outputMapping` (declared, still unread — #5040's E7 gate - * must not flip before they are, or the two keys sit in the "declared, legal, - * ignored" state this program exists to end). + * ## The mapping keys, and why they apply exactly here (#5040 E5c) + * + * `inputMapping` / `outputMapping` (`api-mapping.ts`) are applied by this + * module, on the two sides of the delegation: + * + * - **`inputMapping` after the policy pass, before delegation.** It projects + * the request the executor sees, so a mapping can never buy a caller past + * `authRequired` or the rate limiter — and `endpoint-executor.ts` stays a + * pure delegator that does not know mappings exist. + * - **`outputMapping` on the SUCCESS body only.** An error answer is never + * remapped: a projection that could reshape a 401 / 429 / 500 into data + * would be able to disguise a failure as a result, and no declaration should + * have that power. This is the same asymmetry `Cache-Control` has above, for + * the same reason. + * + * A declaration this runtime cannot serve (`transform`, an unusable path, + * colliding targets) is refused BEFORE the target runs — including + * `outputMapping`, which is validated pre-delegation so a broken projection + * cannot let a `create` insert a record and then fail to answer. With neither + * key declared, the request and the answer pass through byte for byte, by + * reference: an endpoint that declares no mapping is served exactly as E5b + * served it. */ import { DispatcherErrorCode } from '@objectstack/spec/api'; @@ -53,6 +71,11 @@ import type { ApiEndpointMatch, IMetadataService } from '@objectstack/spec/contr import type { ExecutionContext } from '@objectstack/spec/kernel'; import { apiErrorResponse } from './error-envelope.js'; import { applyEndpointPolicies, type EndpointPolicyContext } from './endpoint-policy.js'; +import { + applyInputMapping, + applyOutputMapping, + mappingDeclarationRejection, +} from './api-mapping.js'; import { buildEndpointExecutionContext, executeEndpointTarget, @@ -241,9 +264,26 @@ export async function runAppEndpointStep( } const { request, deps, executionContext, environmentId, dataDriver } = input.execution; + + // ── inputMapping: project the request the executor will see ────────── + // Nothing has been delegated yet, so a declaration this runtime cannot + // serve is refused before it can have an effect. With no declaration the + // caller's own request object rides on unchanged, by reference. + const mappedBody = applyInputMapping(match.endpoint, request.body); + if (!mappedBody.ok) return mappedBody.rejection; + const mappedRequest = mappedBody.value === request.body + ? request + : { ...request, body: mappedBody.value }; + + // `outputMapping` is judged HERE, not after the result arrives: a broken + // projection must not be able to let a `create` insert its record and then + // refuse to answer with it. + const outputRejection = mappingDeclarationRejection(match.endpoint, 'outputMapping'); + if (outputRejection) return outputRejection; + const answer = await executeEndpointTarget( buildEndpointExecutionContext({ - request, + request: mappedRequest, match, ...(executionContext !== undefined ? { executionContext } : {}), ...(environmentId !== undefined ? { environmentId } : {}), @@ -256,14 +296,26 @@ export async function runAppEndpointStep( // `executeEndpointTarget` never throws — a delegated failure is already an // error answer here — so the status is the whole test, and an endpoint whose // execution failed cannot hand the client a cache directive for the failure. + // `outputMapping` rides on exactly the same test, and for a stronger reason: + // a projection applied to an error body could disguise the failure as data. const isSuccess = answer.status < 400; + let body = answer.body; + if (isSuccess) { + const mapped = applyOutputMapping(match.endpoint, answer.body); + // Unreachable: the identical verdict was taken before delegation, above. + // Restated rather than asserted away, so a future reordering of these + // two lines cannot turn a refusal into a silently unmapped answer. + if (!mapped.ok) return mapped.rejection; + body = mapped.value; + } + const headers = { ...(answer.headers ?? {}), ...(isSuccess ? verdict.responseHeaders : {}), }; return { status: answer.status, - body: answer.body, + body, ...(Object.keys(headers).length > 0 ? { headers } : {}), }; } diff --git a/packages/runtime/src/api-mapping.test.ts b/packages/runtime/src/api-mapping.test.ts new file mode 100644 index 0000000000..fe92cb7d52 --- /dev/null +++ b/packages/runtime/src/api-mapping.test.ts @@ -0,0 +1,311 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The endpoint mapping keys in isolation (#5040 E5c / #5137). + * + * Two properties are load-bearing and every case here serves one of them: + * + * 1. **A declared mapping does exactly what the vocabulary says** — projects + * the declared `target` paths from the declared `source` paths, and nothing + * besides. The keys existed and were read by nothing before this; a + * half-implemented reading would be the same defect one layer down. + * 2. **A declaration this runtime cannot serve is REFUSED, in the declared + * error envelope, naming the entry** — never skipped, never partially + * applied. `transform` is the case #5137 was filed over, but an unusable + * path and colliding targets are the same category and answer identically. + * + * And the property that makes both safe to land before the E7 flip: with no + * declaration, the value that goes in is the value that comes out, by + * reference. + */ + +import { describe, it, expect } from 'vitest'; +import { ApiEndpointSchema, ApiErrorSchema, envelopeViolations, type ApiEndpoint } from '@objectstack/spec/api'; + +import { + applyInputMapping, + applyOutputMapping, + mappingDeclarationRejection, + type EndpointMappingOutcome, + type EndpointMappingRejection, +} from './api-mapping.js'; + +/** A declared endpoint in the ADR-0121 D1 shape, defaults materialized. */ +function endpointWith(overrides: Record = {}): ApiEndpoint { + return ApiEndpointSchema.parse({ + name: 'showcase_inquiries', + path: '/api/v1/apps/showcase/inquiries', + method: 'POST', + type: 'object_operation', + target: 'showcase_inquiry', + objectParams: { object: 'showcase_inquiry', operation: 'create' }, + ...overrides, + }); +} + +/** The value of a successful outcome — failing the test if it was a refusal. */ +function valueOf(outcome: EndpointMappingOutcome): T { + if (!outcome.ok) { + throw new Error(`expected a mapped value, got a refusal: ${JSON.stringify(outcome.rejection.body)}`); + } + return outcome.value; +} + +/** The refusal of a failed outcome — failing the test if it succeeded. */ +function rejectionOf(outcome: EndpointMappingOutcome): EndpointMappingRejection { + if (outcome.ok) throw new Error(`expected a refusal, got ${JSON.stringify(outcome.value)}`); + return outcome.rejection; +} + +/** + * Every refusal is the declared envelope, carries the semantic code, and says + * WHICH entry it is about. Asserted through the spec's own schemas rather than + * a local restatement, exactly as `error-envelope.conformance.test.ts` does. + */ +function expectRefusal(rejection: EndpointMappingRejection, ...mustMention: string[]) { + expect(rejection.status).toBe(501); + const body = rejection.body as { success: boolean; error: Record }; + expect(envelopeViolations(body)).toEqual([]); + expect(body.success).toBe(false); + expect(ApiErrorSchema.safeParse(body.error).success).toBe(true); + expect(body.error.code).toBe('NOT_IMPLEMENTED'); + expect(body.error.httpStatus).toBe(501); + for (const fragment of mustMention) { + expect(String(body.error.message) + String(body.error.hint)).toContain(fragment); + } + return body.error; +} + +describe('no declaration is byte-for-byte passthrough', () => { + it('returns the caller\'s own body BY REFERENCE when inputMapping is absent', () => { + const body = { first_name: 'Ada', nested: { x: 1 } }; + const outcome = applyInputMapping(endpointWith(), body); + // Reference identity, not deep equality: an endpoint that declares no + // mapping must be delegated exactly as E5b delegated it. + expect(valueOf(outcome)).toBe(body); + }); + + it('returns the success body BY REFERENCE when outputMapping is absent', () => { + const successBody = { success: true, data: { records: [], total: 0 }, meta: undefined }; + expect(valueOf(applyOutputMapping(endpointWith(), successBody))).toBe(successBody); + }); + + it('treats an empty array as no declaration — not as "project nothing"', () => { + const body = { keep: 'me' }; + const endpoint = endpointWith({ inputMapping: [], outputMapping: [] }); + expect(valueOf(applyInputMapping(endpoint, body))).toBe(body); + expect(valueOf(applyOutputMapping(endpoint, body))).toBe(body); + }); + + it('passes an undefined body through untouched', () => { + expect(valueOf(applyInputMapping(endpointWith(), undefined))).toBeUndefined(); + }); +}); + +describe('inputMapping projects the request body onto the internal params', () => { + it('renames a field, exactly as the vocabulary\'s own example does', () => { + const endpoint = endpointWith({ inputMapping: [{ source: 'firstName', target: 'first_name' }] }); + expect(valueOf(applyInputMapping(endpoint, { firstName: 'Ada' }))).toEqual({ first_name: 'Ada' }); + }); + + it('reads and writes dot paths on both sides', () => { + const endpoint = endpointWith({ + inputMapping: [ + { source: 'user.profile.email', target: 'contact.email' }, + { source: 'user.id', target: 'contact.owner_id' }, + ], + }); + const mapped = valueOf(applyInputMapping(endpoint, { + user: { id: 'usr_7', profile: { email: 'ada@example.com' } }, + })); + expect(mapped).toEqual({ contact: { email: 'ada@example.com', owner_id: 'usr_7' } }); + }); + + it('is a PROJECTION — an undeclared field does not ride along', () => { + const endpoint = endpointWith({ inputMapping: [{ source: 'keep', target: 'keep' }] }); + const mapped = valueOf(applyInputMapping(endpoint, { keep: 1, secret: 'internal', other: 2 })); + expect(mapped).toEqual({ keep: 1 }); + }); + + it('leaves a target UNSET when its source resolves to nothing', () => { + const endpoint = endpointWith({ + inputMapping: [ + { source: 'present', target: 'a' }, + { source: 'absent', target: 'b' }, + { source: 'deep.missing.path', target: 'c' }, + ], + }); + const mapped = valueOf(applyInputMapping(endpoint, { present: 1 })) as Record; + expect(mapped).toEqual({ a: 1 }); + // Absent, not `undefined`-valued: the key must not appear on the wire. + expect(Object.keys(mapped)).toEqual(['a']); + }); + + it('carries a null through — null is a value, absence is not', () => { + const endpoint = endpointWith({ inputMapping: [{ source: 'a', target: 'b' }] }); + expect(valueOf(applyInputMapping(endpoint, { a: null }))).toEqual({ b: null }); + }); + + it('addresses an array element by its numeric key', () => { + const endpoint = endpointWith({ inputMapping: [{ source: 'items.0.sku', target: 'sku' }] }); + expect(valueOf(applyInputMapping(endpoint, { items: [{ sku: 'A-1' }] }))).toEqual({ sku: 'A-1' }); + }); + + it('never reads an inherited member', () => { + // Own properties only: "the field is absent" and "the prototype has one + // of that name" must not answer the same. + const endpoint = endpointWith({ inputMapping: [{ source: 'toString', target: 'x' }] }); + expect(valueOf(applyInputMapping(endpoint, { a: 1 }))).toEqual({}); + }); + + it('projects nothing out of a body that is not an object', () => { + const endpoint = endpointWith({ inputMapping: [{ source: 'a', target: 'b' }] }); + expect(valueOf(applyInputMapping(endpoint, 'a string body'))).toEqual({}); + expect(valueOf(applyInputMapping(endpoint, undefined))).toEqual({}); + }); + + it('does not mutate the body it was given', () => { + const endpoint = endpointWith({ inputMapping: [{ source: 'a', target: 'nested.a' }] }); + const body = { a: 1 }; + applyInputMapping(endpoint, body); + expect(body).toEqual({ a: 1 }); + }); +}); + +describe('outputMapping projects the SUCCESS payload and preserves the envelope', () => { + it('maps `data` and leaves every other member of the envelope alone', () => { + const endpoint = endpointWith({ + outputMapping: [ + { source: 'total', target: 'count' }, + { source: 'records.0.name', target: 'first' }, + ], + }); + const successBody = { + success: true, + data: { records: [{ name: 'Ada', internal_note: 'do not ship' }], total: 1 }, + meta: undefined, + }; + expect(valueOf(applyOutputMapping(endpoint, successBody))).toEqual({ + success: true, + data: { count: 1, first: 'Ada' }, + meta: undefined, + }); + // The allow-list property: what the pipeline returned but the + // declaration did not name is gone. + expect(JSON.stringify(valueOf(applyOutputMapping(endpoint, successBody)))).not.toContain('internal_note'); + }); + + it('cannot rewrite the envelope itself', () => { + // A `target` naming an envelope member writes inside `data`, never over + // `success` — a declaration must not be able to claim an answer succeeded. + const endpoint = endpointWith({ outputMapping: [{ source: 'total', target: 'success' }] }); + const mapped = valueOf(applyOutputMapping(endpoint, { success: true, data: { total: 3 }, meta: undefined })); + expect(mapped).toEqual({ success: true, data: { success: 3 }, meta: undefined }); + }); + + it('projects the body itself when it carries no `data` member', () => { + const endpoint = endpointWith({ outputMapping: [{ source: 'a', target: 'b' }] }); + expect(valueOf(applyOutputMapping(endpoint, { a: 1, c: 2 }))).toEqual({ b: 1 }); + }); + + it('does not mutate the success body it was given', () => { + const endpoint = endpointWith({ outputMapping: [{ source: 'total', target: 'count' }] }); + const successBody = { success: true, data: { total: 1 }, meta: undefined }; + applyOutputMapping(endpoint, successBody); + expect(successBody.data).toEqual({ total: 1 }); + }); +}); + +describe('a declaration this runtime cannot serve is refused, never ignored', () => { + it('refuses `transform` — the key #5137 was filed over', () => { + const endpoint = endpointWith({ + inputMapping: [ + { source: 'firstName', target: 'first_name' }, + { source: 'price', target: 'amount', transform: 'convertToInt' }, + ], + }); + const error = expectRefusal( + rejectionOf(applyInputMapping(endpoint, { price: '3' })), + 'inputMapping[1].transform', 'convertToInt', 'showcase_inquiries', '#5040', + ); + // The prescription, not just the verdict: an author has to be told what + // to do instead, or the refusal is only half a signal. + expect(String(error.hint)).toContain('publish'); + }); + + it('refuses it on the output side too, and before anything is executed', () => { + const endpoint = endpointWith({ + outputMapping: [{ source: 'total', target: 'count', transform: 'formatNumber' }], + }); + expectRefusal(rejectionOf(applyOutputMapping(endpoint, { success: true, data: {}, meta: undefined })), + 'outputMapping[0].transform', 'formatNumber'); + // The same verdict is available WITHOUT a result to apply it to, which + // is what lets the step refuse before it delegates. + expectRefusal(mappingDeclarationRejection(endpoint, 'outputMapping')!, 'outputMapping[0].transform'); + }); + + it.each([ + ['an empty source', { source: '', target: 'a' }, 'inputMapping[0].source'], + ['an empty target', { source: 'a', target: '' }, 'inputMapping[0].target'], + ['an empty segment', { source: 'a..b', target: 'c' }, 'inputMapping[0].source'], + ['a trailing dot', { source: 'a', target: 'b.' }, 'inputMapping[0].target'], + ['a prototype key on the source', { source: '__proto__.x', target: 'a' }, 'inputMapping[0].source'], + ['a prototype key on the target', { source: 'a', target: 'constructor.x' }, 'inputMapping[0].target'], + ])('refuses %s', (_name, entry, mentions) => { + const endpoint = endpointWith({ inputMapping: [entry] }); + expectRefusal(rejectionOf(applyInputMapping(endpoint, { a: 1 })), mentions); + }); + + it('cannot be made to pollute Object.prototype', () => { + const endpoint = endpointWith({ inputMapping: [{ source: 'a', target: '__proto__.polluted' }] }); + expect(applyInputMapping(endpoint, { a: 'boom' }).ok).toBe(false); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('refuses two entries that write the same target', () => { + const endpoint = endpointWith({ + inputMapping: [{ source: 'a', target: 'x' }, { source: 'b', target: 'x' }], + }); + expectRefusal(rejectionOf(applyInputMapping(endpoint, { a: 1, b: 2 })), + 'inputMapping[1].target', 'inputMapping[0].target'); + }); + + it('refuses an entry that writes INSIDE another entry\'s target', () => { + // `x` then `x.y`: the second would silently discard the first. Either + // order is the same collision. + const nested = endpointWith({ + inputMapping: [{ source: 'a', target: 'x' }, { source: 'b', target: 'x.y' }], + }); + expectRefusal(rejectionOf(applyInputMapping(nested, { a: 1, b: 2 })), 'inputMapping[1].target'); + + const reversed = endpointWith({ + inputMapping: [{ source: 'b', target: 'x.y' }, { source: 'a', target: 'x' }], + }); + expectRefusal(rejectionOf(applyInputMapping(reversed, { a: 1, b: 2 })), 'inputMapping[1].target'); + }); + + it('allows sibling targets under one parent', () => { + const endpoint = endpointWith({ + inputMapping: [{ source: 'a', target: 'x.a' }, { source: 'b', target: 'x.b' }], + }); + expect(valueOf(applyInputMapping(endpoint, { a: 1, b: 2 }))).toEqual({ x: { a: 1, b: 2 } }); + }); +}); + +describe('the declaration verdict is per key and data-independent', () => { + it('is `undefined` when the key is absent or clean', () => { + expect(mappingDeclarationRejection(endpointWith(), 'inputMapping')).toBeUndefined(); + expect(mappingDeclarationRejection(endpointWith(), 'outputMapping')).toBeUndefined(); + const clean = endpointWith({ inputMapping: [{ source: 'a.b', target: 'c.d' }] }); + expect(mappingDeclarationRejection(clean, 'inputMapping')).toBeUndefined(); + }); + + it('judges each key on its own declaration', () => { + const endpoint = endpointWith({ + inputMapping: [{ source: 'a', target: 'b' }], + outputMapping: [{ source: 'c', target: 'd', transform: 'upper' }], + }); + expect(mappingDeclarationRejection(endpoint, 'inputMapping')).toBeUndefined(); + expectRefusal(mappingDeclarationRejection(endpoint, 'outputMapping')!, 'outputMapping[0].transform'); + }); +}); diff --git a/packages/runtime/src/api-mapping.ts b/packages/runtime/src/api-mapping.ts new file mode 100644 index 0000000000..cfaee722f5 --- /dev/null +++ b/packages/runtime/src/api-mapping.ts @@ -0,0 +1,372 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The MAPPING KEYS of a declarative `apis:` endpoint (#5040 E5c / #5137). + * + * `inputMapping` / `outputMapping` were declared by `ApiEndpointSchema` and read + * by nothing: an author could write them, publish would accept them, and the + * endpoint would run as if they were absent. That is the "parsed, then nothing + * happens" middle state #5040 exists to end and the textbook ADR-0049 + * `declared ≠ enforced` shape — and it is worst for AI-authored metadata, where + * a key that is silently ignored produces no signal at all and the mistake + * stays in the app. This module is the enforcement. + * + * ## The vocabulary is the whole specification + * + * The vocabulary is FROZEN (#5040), so what these keys mean is exactly what + * `packages/spec/src/api/endpoint.zod.ts` says and nothing more: + * + * | declaration | `.describe()` | + * |---|---| + * | `inputMapping` | *Map Request Body to Internal Params* | + * | `outputMapping` | *Map Internal Result to Response Body* | + * | `ApiMapping.source` | *Source field/path* | + * | `ApiMapping.target` | *Target field/path* | + * | `ApiMapping.transform` | *Transformation function name* | + * + * Five short sentences, and everything below is the MINIMAL faithful reading of + * them. Where the text is silent this module takes the least expressive option + * available and says so here, because the alternative — inventing expression + * power (a template language, JSONPath, wildcards, conditionals) — would put a + * dialect in the runtime that no contract declares and no publish gate can + * check. Each choice below is a place a later vocabulary decision can WIDEN + * without breaking a declaration that works today; none of them can be narrowed + * later, which is why the narrow reading is the safe one. + * + * ### 1. A mapping is a PROJECTION, not a merge + * + * The result is built from the declared `target` paths alone: an undeclared + * field of the source does not ride along. That is what "map A to B" says, it is + * what the vocabulary's own example does (`firstName` → `first_name`, + * `user.profile.email` → `contact.email` — `endpoint.test.ts`), and on the + * outbound side it makes `outputMapping` an allow-list, which is the property an + * external integration surface (ADR-0121 D3: `apis` = the platform's OUTWARD + * face) actually wants. A merge would leak every internal field the pipeline + * happened to return, forever, by default. + * + * ### 2. `source` reads the REQUEST BODY — not the query string + * + * `inputMapping` says *Map **Request Body** to Internal Params*, so the body is + * what `source` resolves against. #5137's suggested scope (and #5040 §3.4) + * floated `{...query, ...body}` instead; that is not implemented here, on + * purpose. Merging the two invents a precedence rule between them that no text + * states — and a silent precedence rule is exactly the sort of thing an author + * discovers by having a request behave differently than it reads. Query + * parameters keep reaching the executor untouched, as they always have; if the + * maintainer decides mapping should see them too, adding a source is a + * compatible widening of this reading, whereas removing one would break + * declarations. + * + * ### 3. `source` / `target` are dot-separated paths, and nothing else + * + * "field/path" is read as `a.b.c`: split on `.`, own properties only (so a path + * can never reach an inherited member), array indices addressed by their + * numeric key (`records.0.id`). No wildcards, no filters, no `$`-syntax, no + * escaping — a key that genuinely contains a dot is not addressable, which is a + * limit of the vocabulary rather than a licence to design one here. + * + * ### 4. A `source` that resolves to nothing leaves its `target` UNSET + * + * The mapping is a projection, not a validator: an absent optional field + * produces an absent target rather than an explicit `null`/`undefined` key or a + * rejected request. Inventing a required-ness rule here would be a second, + * weaker copy of validation that the target pipeline already performs with the + * object's own field metadata. + * + * ### 5. An absent (or empty) key is byte-for-byte passthrough + * + * No declaration ⇒ the very same value flows on, by reference. This is what + * keeps E5b's behavior unchanged for every endpoint that declares no mapping, + * and it is pinned by tests on both sides of the seam. + * + * ## What this module REFUSES, loudly + * + * A declaration it cannot serve gets a structured 501 `NOT_IMPLEMENTED` naming + * the exact entry and key — never a silent skip, never a "best effort" partial + * application. The status and the code deliberately match + * `endpoint-executor.ts`'s `unsupported` arm: this is the same category of + * answer (a declaration inside the frozen vocabulary that 17.x does not + * execute), and the caller is not at fault, so blaming the request with a 4xx + * would misreport whose defect it is. The refused set: + * + * - **`transform`** — there is no "transformation function name" registry + * anywhere in this repo, and inventing one is a sandboxing decision, not a + * mapping detail (the same reasoning that makes `stack.zod.ts`'s namesake a + * build-time failure, framework#2611). #5040 §3.4 keeps it rejected at + * publish; this is the runtime backstop for a declaration that reached the + * store some other way (a direct `metadata.register()`). + * - **an unusable path** — empty, an empty segment (`a..b`), or a JavaScript + * prototype key (`__proto__` / `prototype` / `constructor`), which must never + * be walkable on either side. + * - **colliding targets** — two entries writing the same path, or one writing + * INSIDE another's target, where the later would silently discard the + * earlier. + * + * Every one of those belongs in the same "unsupported subset" the E7 publish + * gate (#5111) rejects with a prescription, so an author meets the error while + * writing the app rather than while serving a request. This module is the + * backstop, not the primary gate — but it must never be the *silent* one. + * + * ## Pure by construction + * + * Functions of their arguments: no service lookup, no kernel, no I/O, no + * mutation of anything the caller passed in. The application point — after the + * policy pass, before delegation, and on a SUCCESS body only — lives in + * `api-endpoint-step.ts`, which is also where the rule that an error answer is + * never remapped is enforced (a mapping must not be able to dress a failure up + * as data). + */ + +import { DispatcherErrorCode } from '@objectstack/spec/api'; +import type { ApiEndpoint } from '@objectstack/spec/api'; +import { apiErrorResponse } from './error-envelope.js'; + +/** The two mapping keys, spelled as the vocabulary spells them. */ +export type EndpointMappingKey = 'inputMapping' | 'outputMapping'; + +/** One declared mapping entry (`ApiMappingSchema`). */ +interface ApiMappingEntry { + source: string; + target: string; + transform?: string; +} + +/** + * A structured refusal, in the shape the step writes straight to the wire — + * built by the one envelope builder, never assembled here. + */ +export interface EndpointMappingRejection { + status: number; + body: unknown; +} + +/** Either a mapped value, or the refusal to answer with. */ +export type EndpointMappingOutcome = + | { ok: true; value: T } + | { ok: false; rejection: EndpointMappingRejection }; + +/** + * Path segments this runtime refuses to walk or write, on either side of a + * mapping. `__proto__` and friends are how a data-driven `set` turns into + * prototype pollution; a declaration is not a trusted enough source to allow + * them (an app's metadata is increasingly AI-written), and no legitimate + * mapping needs them. + */ +const UNSAFE_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']); + +// ============================================================================ +// Paths +// ============================================================================ + +/** Split a declared path, or `undefined` when it is not a usable one. */ +function splitPath(path: string): string[] | undefined { + if (typeof path !== 'string' || path === '') return undefined; + const segments = path.split('.'); + for (const segment of segments) { + if (segment === '' || UNSAFE_PATH_SEGMENTS.has(segment)) return undefined; + } + return segments; +} + +/** + * Read a dot path off a value. + * + * Own properties only: a mapping must not be able to reach `toString` or any + * other inherited member, and "the field is absent" and "the prototype has one + * of that name" must not answer the same. + */ +function readPath(source: unknown, segments: string[]): unknown { + let current: unknown = source; + for (const segment of segments) { + if (current === null || typeof current !== 'object') return undefined; + if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined; + current = (current as Record)[segment]; + } + return current; +} + +/** + * Write a dot path into the projection under construction. + * + * Intermediate objects are created as needed. It never has to overwrite one it + * did not create: colliding targets are refused at declaration level + * ({@link mappingDeclarationRejection}), so the defensive branch below cannot + * be reached through this module's own entry points — it is there so a future + * caller cannot make silent data loss out of a partially built object. + */ +function writePath(target: Record, segments: string[], value: unknown): void { + let current = target; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]!; + const next = current[segment]; + if (next === null || typeof next !== 'object' || Array.isArray(next)) { + current[segment] = {}; + } + current = current[segment] as Record; + } + current[segments[segments.length - 1]!] = value; +} + +// ============================================================================ +// Declarations +// ============================================================================ + +/** The entries declared under one key — `[]` when the key is absent. */ +function entriesOf(endpoint: ApiEndpoint, key: EndpointMappingKey): ApiMappingEntry[] { + const declared = endpoint[key]; + return Array.isArray(declared) ? (declared as ApiMappingEntry[]) : []; +} + +/** The one refusal shape, so every branch here answers identically. */ +function reject(message: string, hint: string): EndpointMappingRejection { + return apiErrorResponse({ + code: DispatcherErrorCode.enum.NOT_IMPLEMENTED, + httpStatus: 501, + message, + extra: { hint }, + }); +} + +const PATH_HINT = + "`source` and `target` are dot-separated field paths ('user.profile.email'). An empty path, an empty " + + "segment ('a..b') and the JavaScript prototype keys (__proto__, prototype, constructor) are refused; " + + 'the publish gate rejects the same shapes (#5040 E7).'; + +/** + * Whether this runtime can serve a key's declaration AT ALL — data-independent, + * so the answer is the same for every request and can be taken BEFORE anything + * is executed. + * + * That ordering is the point for `outputMapping`: validating it only when the + * result arrives would let a `create` endpoint with a broken projection insert + * the record and THEN refuse to answer, which is the worst of both outcomes. + * The step therefore takes this verdict before it delegates. + * + * Returns `undefined` when the declaration is servable (including when there is + * none). + */ +export function mappingDeclarationRejection( + endpoint: ApiEndpoint, + key: EndpointMappingKey, +): EndpointMappingRejection | undefined { + const entries = entriesOf(endpoint, key); + const targets: Array<{ index: number; path: string; segments: string[] }> = []; + + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + const at = `${key}[${index}]`; + + if (entry.transform !== undefined) { + return reject( + `Endpoint '${endpoint.name}' declares ${at}.transform ('${entry.transform}'), which this runtime ` + + 'does not execute.', + 'A mapping entry moves and renames fields by dot path; there is no transformation-function ' + + "registry in this runtime, so `transform` is rejected at publish (#5040 §3.4, E7) rather than " + + 'parsed and ignored. Drop the key, or shape the value where it is produced.', + ); + } + + const sourceSegments = splitPath(entry.source); + if (!sourceSegments) { + return reject( + `Endpoint '${endpoint.name}' declares ${at}.source '${entry.source}', which is not a usable ` + + 'field path.', + PATH_HINT, + ); + } + + const targetSegments = splitPath(entry.target); + if (!targetSegments) { + return reject( + `Endpoint '${endpoint.name}' declares ${at}.target '${entry.target}', which is not a usable ` + + 'field path.', + PATH_HINT, + ); + } + + const collision = targets.find((seen) => isPathPrefix(seen.segments, targetSegments) + || isPathPrefix(targetSegments, seen.segments)); + if (collision) { + return reject( + `Endpoint '${endpoint.name}' declares ${at}.target '${entry.target}', which collides with ` + + `${key}[${collision.index}].target '${collision.path}'.`, + 'Two mapping entries cannot write the same target path, and neither can write inside the ' + + "other's — one of them would silently discard the other. Give each entry a distinct target.", + ); + } + + targets.push({ index, path: entry.target, segments: targetSegments }); + } + + return undefined; +} + +/** Whether `a` is `b` or an ancestor of it (`['a']` vs `['a','b']`). */ +function isPathPrefix(a: string[], b: string[]): boolean { + if (a.length > b.length) return false; + return a.every((segment, i) => b[i] === segment); +} + +// ============================================================================ +// Application +// ============================================================================ + +/** Project a source value through validated entries. */ +function project(entries: ApiMappingEntry[], source: unknown): Record { + const projected: Record = {}; + for (const entry of entries) { + const value = readPath(source, splitPath(entry.source)!); + // Absent source ⇒ absent target. See §4 of the module note: a mapping + // projects, it does not assert that a field was supplied. + if (value === undefined) continue; + writePath(projected, splitPath(entry.target)!, value); + } + return projected; +} + +/** + * `inputMapping` — the request body the target pipeline will see. + * + * With no declaration the caller's own body is returned BY REFERENCE, so a + * request to an endpoint that declares no mapping is delegated exactly as it + * was before this module existed. + */ +export function applyInputMapping(endpoint: ApiEndpoint, body: unknown): EndpointMappingOutcome { + const entries = entriesOf(endpoint, 'inputMapping'); + if (entries.length === 0) return { ok: true, value: body }; + + const rejection = mappingDeclarationRejection(endpoint, 'inputMapping'); + if (rejection) return { ok: false, rejection }; + + return { ok: true, value: project(entries, body) }; +} + +/** + * `outputMapping` — the response body for a SUCCESSFUL execution. + * + * Takes the success body as the executor built it and returns one with its + * PAYLOAD projected: `data` when the body is the standard `{ success, data, + * meta }` envelope (`successAnswer`, `endpoint-executor.ts`), the body itself + * otherwise. Every other member rides through untouched — a mapping projects + * the result, it can never rewrite the envelope, drop `success`, or make an + * answer that is not the declared shape. + * + * Applying this to an ERROR body is not prevented here but never happens: the + * caller applies it on success only (`api-endpoint-step.ts`), because a mapping + * that could reshape a failure into data would be able to hide it. + */ +export function applyOutputMapping(endpoint: ApiEndpoint, successBody: unknown): EndpointMappingOutcome { + const entries = entriesOf(endpoint, 'outputMapping'); + if (entries.length === 0) return { ok: true, value: successBody }; + + const rejection = mappingDeclarationRejection(endpoint, 'outputMapping'); + if (rejection) return { ok: false, rejection }; + + if (successBody !== null && typeof successBody === 'object' && !Array.isArray(successBody) + && Object.prototype.hasOwnProperty.call(successBody, 'data')) { + const envelope = successBody as Record; + return { ok: true, value: { ...envelope, data: project(entries, envelope.data) } }; + } + + return { ok: true, value: project(entries, successBody) }; +} diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts index 8c6fe46680..bc60ea0825 100644 --- a/packages/runtime/src/error-envelope.conformance.test.ts +++ b/packages/runtime/src/error-envelope.conformance.test.ts @@ -253,6 +253,12 @@ describe('#3842 — no dispatcher module may reintroduce the drift', () => { // dispatcher class, which is exactly the kind of second copy this scan // exists to keep honest. Listed the day it was written. './endpoint-executor.ts', + // [#5137] The endpoint MAPPING keys refuse a declaration this runtime + // cannot serve (`transform`, an unusable path, colliding targets) with a + // body of their own — an eighth way onto this wire surface, and one + // whose whole reason for existing is that the alternative was silence. + // Listed the day it was written. + './api-mapping.ts', ]; for (const file of MODULES) {