diff --git a/protocol/languages/typescript/src/index.d.ts b/protocol/languages/typescript/src/index.d.ts index 303978c3c..00a97175f 100644 --- a/protocol/languages/typescript/src/index.d.ts +++ b/protocol/languages/typescript/src/index.d.ts @@ -7,3 +7,4 @@ export { Client, type DecodeErrorContext } from './client'; export { windowOpenSuccess, windowOpenRejected } from './window_open'; export { EmbeddedCheckoutProtocol } from './embedded_checkout_protocol'; export { ProtocolValidationError, type ProtocolValidationReason, } from './protocol_codec_runtime'; +export { decodeCheckoutSnapshot } from './protocol_codec_runtime'; diff --git a/protocol/languages/typescript/src/index.ts b/protocol/languages/typescript/src/index.ts index 9b8d3f592..4e8a75ffb 100644 --- a/protocol/languages/typescript/src/index.ts +++ b/protocol/languages/typescript/src/index.ts @@ -40,3 +40,4 @@ export { ProtocolValidationError, type ProtocolValidationReason, } from './protocol_codec_runtime'; +export {decodeCheckoutSnapshot} from './protocol_codec_runtime'; diff --git a/protocol/languages/typescript/src/protocol_codec_runtime.d.ts b/protocol/languages/typescript/src/protocol_codec_runtime.d.ts index b974ac8ac..f32305e9a 100644 --- a/protocol/languages/typescript/src/protocol_codec_runtime.d.ts +++ b/protocol/languages/typescript/src/protocol_codec_runtime.d.ts @@ -7,5 +7,7 @@ export declare class ProtocolValidationError extends TypeError { constructor(modelPath: string, reason: ProtocolValidationReason); } export declare function decodeProtocolObject(value: unknown, modelName: string): JSONRecord; +/** Decode Kit checkout fields using the generated schema, without protocol metadata. */ +export declare function decodeCheckoutSnapshot(value: unknown): JSONRecord; export declare function encodeProtocolObject(value: unknown, modelName: string): unknown; export {}; diff --git a/protocol/languages/typescript/src/protocol_codec_runtime.ts b/protocol/languages/typescript/src/protocol_codec_runtime.ts index 940336c98..929cde7ca 100644 --- a/protocol/languages/typescript/src/protocol_codec_runtime.ts +++ b/protocol/languages/typescript/src/protocol_codec_runtime.ts @@ -54,6 +54,19 @@ export function decodeProtocolObject( return walkObject(input, renameMap[modelName], 'decode', modelName) as JSONRecord; } +/** Decode Kit checkout fields using the generated schema, without protocol metadata. */ +export function decodeCheckoutSnapshot(value: unknown): JSONRecord { + const input = {...requireObject(value, 'Checkout')}; + delete input.ucp; + requireFields(input, (REQUIRED_FIELDS.Checkout ?? []).filter(field => field !== 'ucp'), 'Checkout'); + requireStringFields(input, ['currency', 'id', 'status'], 'Checkout'); + for (const field of ['line_items', 'links', 'totals']) { + if (!Array.isArray(input[field])) throw new TypeError('Invalid Checkout'); + } + requireNestedFields(input, 'Checkout'); + return walkObject(input, renameMap.Checkout, 'decode', 'Checkout') as JSONRecord; +} + export function encodeProtocolObject( value: unknown, modelName: string, @@ -79,8 +92,10 @@ function walkObject( const targetIndex = direction === 'decode' ? 1 : 0; const entryBySource = new Map(); + const sourceByTarget = new Map(); for (const entry of entries ?? []) { entryBySource.set(entry[sourceIndex], entry); + sourceByTarget.set(entry[targetIndex], entry[sourceIndex]); } const output: JSONRecord = {}; @@ -93,9 +108,15 @@ function walkObject( } const entry = entryBySource.get(key); if (entry) { - output[entry[targetIndex]] = walkChild(item, entry[2], direction); + setOwnProperty(output, entry[targetIndex], walkChild(item, entry[2], direction)); } else { - output[key] = item; + // Camel-case aliases are reserved for schema fields, even when the wire + // field is absent. Extensions must not overwrite typed checkout values. + const source = sourceByTarget.get(key); + if (direction === 'decode' && source !== undefined) { + throw new ProtocolValidationError(`${modelName}.${source}`, 'invalid_type'); + } + setOwnProperty(output, key, item); } } return output; @@ -133,11 +154,17 @@ function mapValues( ): JSONRecord { const output: JSONRecord = {}; for (const [key, item] of Object.entries(value)) { - output[key] = walkChild(item, child, direction); + setOwnProperty(output, key, walkChild(item, child, direction)); } return output; } +// Assignment invokes Object.prototype.__proto__; define an own data property +// instead so arbitrary extension and dictionary keys cannot change the prototype. +function setOwnProperty(output: JSONRecord, key: string, value: unknown): void { + Object.defineProperty(output, key, {value, enumerable: true, writable: true, configurable: true}); +} + function walkUnion( value: unknown, members: RenameChild[], diff --git a/protocol/languages/typescript/test/checkout-snapshot.test.ts b/protocol/languages/typescript/test/checkout-snapshot.test.ts new file mode 100644 index 000000000..8077ac628 --- /dev/null +++ b/protocol/languages/typescript/test/checkout-snapshot.test.ts @@ -0,0 +1,79 @@ +import {expect, test} from 'vitest'; +import {decodeCheckoutSnapshot} from '../src'; + +const snapshot = { + id: 'checkout-1', + currency: 'USD', + status: 'incomplete', + line_items: [], + links: [], + totals: [], +}; + +test('decodes Kit snapshots without protocol metadata', () => { + expect(decodeCheckoutSnapshot(snapshot)).toEqual({ + id: 'checkout-1', currency: 'USD', status: 'incomplete', + lineItems: [], links: [], totals: [], + }); + const withMetadata = {...snapshot, ucp: {version: 'ignored'}}; + expect(decodeCheckoutSnapshot(withMetadata)).not.toHaveProperty('ucp'); + expect(withMetadata).toHaveProperty('ucp.version', 'ignored'); +}); + +test('converts schema fields while retaining extension and dictionary keys', () => { + const decoded = decodeCheckoutSnapshot({ + ...snapshot, + buyer: {first_name: 'Test', merchant_field: {nested_key: true}}, + actions: {'com.example.verify': [{id: 'a1', config: {custom_key: true}}]}, + attribution: {source_name: 'sample'}, + signals: {buyer_signal: {custom_key: 1}}, + custom_extension: {line_items: ['unchanged']}, + policies: [{id: 'p1', type: 'return', applies_to: ['$.line_items[0]']}], + order: {id: 'order-1', permalink_url: 'https://example.test/orders/1'}, + }); + expect(decoded).toMatchObject({ + buyer: {firstName: 'Test', merchant_field: {nested_key: true}}, + actions: {'com.example.verify': [{id: 'a1', config: {custom_key: true}}]}, + attribution: {source_name: 'sample'}, + signals: {buyer_signal: {custom_key: 1}}, + custom_extension: {line_items: ['unchanged']}, + policies: [{appliesTo: ['$.line_items[0]']}], + order: {permalinkUrl: 'https://example.test/orders/1'}, + }); +}); + +test('treats undefined optional fields as absent', () => { + const decoded = decodeCheckoutSnapshot({...snapshot, order: undefined, fulfillment: undefined}); + expect(decoded).not.toHaveProperty('order'); + expect(decoded).not.toHaveProperty('fulfillment'); +}); + +test.each([ + null, [], {}, {...snapshot, id: 1}, {...snapshot, status: 2}, + {...snapshot, line_items: null}, {...snapshot, totals: undefined}, + {...snapshot, order: {id: 'order-1'}}, +])('rejects malformed snapshots', value => { + expect(() => decodeCheckoutSnapshot(value)).toThrow(TypeError); +}); + + +test('preserves __proto__ as an own extension without inheriting checkout fields', () => { + const extension = JSON.parse('{"__proto__":{"order":{"id":1}}}'); + const decoded = decodeCheckoutSnapshot({...snapshot, ...extension, buyer: extension}); + expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(decoded, '__proto__')).toBe(true); + expect(decoded.__proto__).toEqual({order: {id: 1}}); + expect(decoded).not.toHaveProperty('order'); + expect(Object.getPrototypeOf(decoded.buyer)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(decoded.buyer, '__proto__')).toBe(true); +}); + +test.each([ + {...snapshot, lineItems: 'extension'}, + {lineItems: 'extension', ...snapshot}, + {...snapshot, continueUrl: 123}, + {...snapshot, buyer: {first_name: 'Test', firstName: 123}}, + {...snapshot, order: {id: 'order-1', permalink_url: 'https://example.test', permalinkUrl: 123}}, +])('rejects extensions that occupy schema aliases regardless of key order', value => { + expect(() => decodeCheckoutSnapshot(value)).toThrow(TypeError); +}); diff --git a/protocol/languages/typescript/test/codec-runtime.test.ts b/protocol/languages/typescript/test/codec-runtime.test.ts index c008d0fbc..31e982065 100644 --- a/protocol/languages/typescript/test/codec-runtime.test.ts +++ b/protocol/languages/typescript/test/codec-runtime.test.ts @@ -252,3 +252,18 @@ function expectValidationError( }); } } + + +test('preserves __proto__ dictionary keys through decoding and encoding', () => { + const input = { + ...wire, + ucp: {version: '2026-01-11', payment_handlers: JSON.parse('{"__proto__":[{"available_instruments":[]}]}')}, + }; + const decoded = decodeProtocolObject(input, 'Checkout') as typeof input & { + ucp: {paymentHandlers: Record}; + }; + expect(Object.getPrototypeOf(decoded.ucp.paymentHandlers)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(decoded.ucp.paymentHandlers, '__proto__')).toBe(true); + expect(decoded.ucp.paymentHandlers.__proto__).toEqual([{availableInstruments: []}]); + expect(encodeProtocolObject(decoded, 'Checkout')).toEqual(input); +});