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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions protocol/languages/typescript/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions protocol/languages/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ export {
ProtocolValidationError,
type ProtocolValidationReason,
} from './protocol_codec_runtime';
export {decodeCheckoutSnapshot} from './protocol_codec_runtime';
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
33 changes: 30 additions & 3 deletions protocol/languages/typescript/src/protocol_codec_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
markmur marked this conversation as resolved.
Comment thread
markmur marked this conversation as resolved.
}

export function encodeProtocolObject(
value: unknown,
modelName: string,
Expand All @@ -79,8 +92,10 @@ function walkObject(
const targetIndex = direction === 'decode' ? 1 : 0;

const entryBySource = new Map<string, RenameEntry>();
const sourceByTarget = new Map<string, string>();
for (const entry of entries ?? []) {
entryBySource.set(entry[sourceIndex], entry);
sourceByTarget.set(entry[targetIndex], entry[sourceIndex]);
}

const output: JSONRecord = {};
Expand All @@ -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;
Expand Down Expand Up @@ -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[],
Expand Down
79 changes: 79 additions & 0 deletions protocol/languages/typescript/test/checkout-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
15 changes: 15 additions & 0 deletions protocol/languages/typescript/test/codec-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>};
};
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);
});
Loading