@commercelayer/agent-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ // Buying from a store the way any shopping agent would: discover, search, cart,
2
+ // checkout, address, pay with the sandbox handler, read the order back.
3
+ import { fail } from '../output.js';
4
+ import { discover, replay, shopping, UcpError } from './client.js';
5
+ import { SANDBOX_CREDENTIAL_TYPE, SANDBOX_HANDLER_ID, SANDBOX_TOKEN_DECLINED, SANDBOX_TOKEN_SUCCESS } from './platform.js';
6
+ export const TEST_BUYER = { first_name: 'Test', last_name: 'Buyer', email: 'buyer@example.com', phone_number: '+10000000000' };
7
+ export function testAddress(country) {
8
+ return { id: 'dest_1', first_name: 'Test', last_name: 'Buyer', street_address: '1 Test Street', address_locality: 'Testville', postal_code: '00000', address_country: country };
9
+ }
10
+ export function sandboxInstrument(decline = false) {
11
+ return {
12
+ id: 'pi_1',
13
+ handler_id: SANDBOX_HANDLER_ID,
14
+ type: 'card',
15
+ selected: true,
16
+ display: { brand: 'visa', last_digits: '4242', expiry_month: 12, expiry_year: 2030 },
17
+ credential: { type: SANDBOX_CREDENTIAL_TYPE, token: decline ? SANDBOX_TOKEN_DECLINED : SANDBOX_TOKEN_SUCCESS, expiry: new Date(Date.now() + 1800_000).toISOString() },
18
+ };
19
+ }
20
+ export const TEST_SIGNALS = { 'dev.ucp.buyer_ip': '203.0.113.1', 'dev.ucp.user_agent': 'c11r' };
21
+ function note(steps, step, call, result) {
22
+ steps.push({ step, request: `${call.method} ${new URL(call.url).pathname}`, status: call.status, result, ...(call.signature ? { signature: call.signature } : {}) });
23
+ return call.body;
24
+ }
25
+ /** Country the product ships to: from the catalog's fulfillment data, else from the caller. */
26
+ function shipTo(variant, wanted) {
27
+ if (wanted)
28
+ return wanted.toUpperCase();
29
+ const plain = variant.fulfillment?.methods?.[0]?.description?.plain ?? '';
30
+ const m = plain.match(/Ships to ([A-Z]{2})/);
31
+ return m ? m[1] : 'US';
32
+ }
33
+ export async function buy(origin, opts = {}) {
34
+ const steps = [];
35
+ const store = await discover(origin);
36
+ steps.push({ step: 'discover', request: 'GET /.well-known/ucp', status: 200, result: `UCP ${store.profile.ucp.version}, ${store.capabilities.size} shared capabilities` });
37
+ try {
38
+ const search = note(steps, 'search', await shopping.search(store, { query: opts.sku ?? '', context: opts.country ? { address_country: opts.country.toUpperCase() } : {} }), '');
39
+ steps[steps.length - 1].result = `${search.products.length} products`;
40
+ const product = opts.sku ? search.products.find((p) => p.variants.some((v) => v.id === opts.sku || v.sku === opts.sku)) : search.products[0];
41
+ if (!product)
42
+ fail('no_product', opts.sku ? `The catalog has no product ${opts.sku}.` : 'The catalog is empty.', 'Add products to .commercelayer/store.yml or .commercelayer/products/ and run `c11r apply`.');
43
+ const variant = opts.sku ? product.variants.find((v) => v.id === opts.sku || v.sku === opts.sku) : product.variants[0];
44
+ const country = shipTo(variant, opts.country);
45
+ const quantity = opts.quantity ?? 1;
46
+ const context = { address_country: country, currency: variant.price.currency };
47
+ const lineItems = [{ item: { id: variant.id }, quantity }];
48
+ const discounts = opts.code ? { discounts: { codes: [opts.code] } } : {};
49
+ let create;
50
+ if (opts.viaCart && store.capabilities.has('dev.ucp.shopping.cart')) {
51
+ const cart = note(steps, 'cart', await shopping.createCart(store, { line_items: lineItems, context, ...discounts }), '');
52
+ steps[steps.length - 1].result = `cart ${cart.id}, ${cart.totals.at(-1).amount} ${cart.currency}`;
53
+ create = { cart_id: cart.id, buyer: TEST_BUYER, context };
54
+ }
55
+ else
56
+ create = { line_items: lineItems, buyer: TEST_BUYER, context, ...discounts };
57
+ let checkout = note(steps, 'checkout', await shopping.createCheckout(store, create), '');
58
+ steps[steps.length - 1].result = `checkout ${checkout.id} ${checkout.status}`;
59
+ const update = replay(checkout);
60
+ update.buyer = { ...TEST_BUYER, ...(checkout.buyer?.consent ? { consent: checkout.buyer.consent } : {}) };
61
+ if (update.fulfillment) {
62
+ update.fulfillment.methods[0].destinations = [testAddress(country)];
63
+ update.fulfillment.methods[0].selected_destination_id = 'dest_1';
64
+ }
65
+ checkout = note(steps, 'address', await shopping.updateCheckout(store, checkout.id, update), '');
66
+ steps[steps.length - 1].result = `${checkout.status}, total ${checkout.totals.at(-1).amount} ${checkout.currency}`;
67
+ if (checkout.status !== 'ready_for_complete') {
68
+ const why = (checkout.messages ?? []).filter((m) => m.type === 'error').map((m) => `${m.code} at ${m.path}: ${m.content}`).join('; ');
69
+ fail('checkout_not_ready', `The checkout is ${checkout.status}: ${why}`, 'Fix the store (markets, countries, prices) and run the command again.');
70
+ }
71
+ const completeCall = await shopping.completeCheckout(store, checkout.id, { payment: { instruments: [sandboxInstrument(opts.decline)] }, signals: TEST_SIGNALS });
72
+ checkout = note(steps, 'pay', completeCall, '');
73
+ steps[steps.length - 1].result = checkout.status === 'completed' ? `completed, order ${checkout.order.id}` : `${checkout.status}: ${(checkout.messages ?? []).map((m) => m.code).join(', ')}`;
74
+ let order = null;
75
+ if (checkout.order) {
76
+ order = note(steps, 'order', await shopping.getOrder(store, checkout.order.id), '');
77
+ steps[steps.length - 1].result = `${order.label}, ${order.line_items.length} line item(s), ${order.totals.at(-1).amount} ${order.currency}`;
78
+ }
79
+ return { store, steps, checkout, order };
80
+ }
81
+ catch (e) {
82
+ if (e instanceof UcpError) {
83
+ steps.push({ step: 'failed', request: `${e.call.method} ${new URL(e.call.url).pathname}`, status: e.call.status, result: e.message });
84
+ fail('ucp_error', e.message, `The store answered in a way the buyer could not continue from. Steps so far: ${steps.map((s) => `${s.step} (${s.result})`).join(' → ')}. Run \`c11r ucp check\` for the full conformance report.`);
85
+ }
86
+ throw e;
87
+ }
88
+ }
@@ -0,0 +1,355 @@
1
+ // Walks every UCP capability a store advertises and reports what passes. This is
2
+ // what the ucp skill's "done when" uses: a store that passes can be bought from
3
+ // by any shopping agent, not only by this CLI.
4
+ import crypto from 'node:crypto';
5
+ import { send } from '../api/transport.js';
6
+ import { sandboxInstrument, TEST_BUYER, testAddress } from './buy.js';
7
+ import { discover, replay, request, shopping } from './client.js';
8
+ import { EXTENSIONS, SHOPPING_CAPABILITIES, UCP_VERSION } from './platform.js';
9
+ class Fail extends Error {
10
+ }
11
+ const must = (cond, detail) => {
12
+ if (!cond)
13
+ throw new Fail(detail);
14
+ };
15
+ const sum = (totals) => totals.filter((t) => t.type !== 'total').reduce((s, t) => s + t.amount, 0);
16
+ const total = (totals) => totals.find((t) => t.type === 'total')?.amount;
17
+ export async function check(origin) {
18
+ const checks = [];
19
+ let store = null;
20
+ const run = async (capability, name, fn) => {
21
+ try {
22
+ checks.push({ capability, name, pass: true, detail: await fn() });
23
+ }
24
+ catch (e) {
25
+ checks.push({ capability, name, pass: false, detail: e instanceof Error ? e.message : String(e) });
26
+ }
27
+ };
28
+ // ---- profile
29
+ await run('profile', 'served at /.well-known/ucp', async () => {
30
+ const res = await send('GET', `${origin}/.well-known/ucp`, { accept: 'application/json' });
31
+ must(res.status === 200, `answered ${res.status}`);
32
+ must(/public/.test(res.headers['cache-control'] ?? '') && /max-age=(\d+)/.test(res.headers['cache-control'] ?? '') && Number(res.headers['cache-control'].match(/max-age=(\d+)/)[1]) >= 60, `Cache-Control must be public with max-age >= 60; got "${res.headers['cache-control']}"`);
33
+ const p = JSON.parse(res.body);
34
+ must(/^\d{4}-\d{2}-\d{2}$/.test(p.ucp?.version), `ucp.version must be a date; got ${p.ucp?.version}`);
35
+ must(p.ucp.services && p.ucp.payment_handlers, 'ucp.services and ucp.payment_handlers must be present');
36
+ for (const [name, entries] of Object.entries({ ...p.ucp.services, ...p.ucp.capabilities })) {
37
+ if (!name.startsWith('dev.ucp.'))
38
+ continue;
39
+ for (const e of entries)
40
+ must(e.version === p.ucp.version, `${name} declares version ${e.version}, profile is ${p.ucp.version}`);
41
+ }
42
+ must(Array.isArray(p.keys) && p.keys.some((k) => k.kid && k.kty === 'EC' && k.crv === 'P-256' && !k.d), 'keys[] must hold a public P-256 key with a kid and no private members');
43
+ return `UCP ${p.ucp.version}, ${Object.keys(p.ucp.capabilities ?? {}).length} capabilities, ${Object.keys(p.ucp.payment_handlers).length} payment handler(s)`;
44
+ });
45
+ await run('profile', 'negotiates with this CLI', async () => {
46
+ store = await discover(origin);
47
+ const missing = SHOPPING_CAPABILITIES.filter((c) => !store.capabilities.has(c));
48
+ must(missing.length === 0, `not shared: ${missing.join(', ')}`);
49
+ const exts = Object.keys(EXTENSIONS).filter((e) => store.capabilities.has(e));
50
+ return `REST at ${store.endpoint}; extensions: ${exts.map((e) => e.split('.').pop()).join(', ') || 'none'}`;
51
+ });
52
+ if (!store)
53
+ return finish(origin, checks);
54
+ const s = store;
55
+ // ---- transport rules
56
+ await run('transport', 'rejects a request without UCP-Agent', async () => {
57
+ const c = await request(s, 'POST', `${s.endpoint}/catalog/search`, { query: '' }, { omit: ['ucp-agent'] });
58
+ must(c.status === 400 && c.body?.code === 'invalid_profile_url', `expected 400 invalid_profile_url, got ${c.status} ${c.body?.code ?? ''}`);
59
+ return '400 invalid_profile_url';
60
+ });
61
+ await run('transport', 'rejects a request without Request-Id', async () => {
62
+ const c = await request(s, 'POST', `${s.endpoint}/catalog/search`, { query: '' }, { omit: ['request-id'] });
63
+ must(c.status === 400, `expected 400, got ${c.status}`);
64
+ return `400 ${c.body?.code ?? ''}`;
65
+ });
66
+ await run('transport', 'requires Idempotency-Key on state changes', async () => {
67
+ const c = await request(s, 'POST', `${s.endpoint}/carts`, { line_items: [] }, { omit: ['idempotency-key'] });
68
+ must(c.status === 400, `expected 400, got ${c.status}`);
69
+ return `400 ${c.body?.code ?? ''}`;
70
+ });
71
+ await run('transport', 'verifies Content-Digest', async () => {
72
+ const res = await send('POST', `${s.endpoint}/catalog/search`, { 'ucp-agent': 'profile="https://example.com/p"', 'request-id': crypto.randomUUID(), 'content-digest': 'sha-256=:AAAA:', 'content-type': 'application/json' }, '{}');
73
+ must(res.status === 400, `expected 400 digest_mismatch, got ${res.status}`);
74
+ return '400 digest_mismatch';
75
+ });
76
+ // ---- catalog
77
+ let variant = null;
78
+ let country = 'US';
79
+ await run('dev.ucp.shopping.catalog.search', 'search returns well-formed products', async () => {
80
+ const c = await shopping.search(s, { query: '' });
81
+ must(c.body.ucp?.version === UCP_VERSION && c.body.ucp.capabilities?.['dev.ucp.shopping.catalog.search'], 'ucp envelope must carry version and the search capability');
82
+ must(Array.isArray(c.body.products) && c.body.products.length > 0, 'no products; apply a store with products first');
83
+ must(c.body.pagination?.has_next_page !== undefined, 'pagination.has_next_page is required');
84
+ for (const p of c.body.products) {
85
+ must(p.id && p.title && p.description?.plain && p.price_range?.min?.amount !== undefined && p.variants?.length, `product ${p.id ?? '?'} misses a required field`);
86
+ must(Number.isInteger(p.variants[0].price?.amount) && /^[A-Z]{3}$/.test(p.variants[0].price?.currency), `variant ${p.variants[0].id} price must be integer minor units with a currency`);
87
+ }
88
+ variant = c.body.products[0].variants[0];
89
+ const m = String(variant.fulfillment?.methods?.[0]?.description?.plain ?? '').match(/Ships to ([A-Z]{2})/);
90
+ if (m)
91
+ country = m[1];
92
+ return `${c.body.products.length} products, first variant ${variant.id} at ${variant.price.amount} ${variant.price.currency}`;
93
+ });
94
+ if (!variant)
95
+ return finish(origin, checks);
96
+ const v = variant;
97
+ await run('dev.ucp.shopping.catalog.search', 'search filters by query and price', async () => {
98
+ const c = await shopping.search(s, { query: 'zzz-no-such-product-zzz' });
99
+ must(c.body.products.length === 0, 'an unmatched query must return no products');
100
+ const p = await shopping.search(s, { filters: { price: { max: v.price.amount } }, context: { currency: v.price.currency } });
101
+ must(p.body.products.some((x) => x.variants[0].id === v.id), 'a price filter including the variant must return it');
102
+ return 'query and price filters apply';
103
+ });
104
+ await run('dev.ucp.shopping.catalog.lookup', 'lookup by variant id carries inputs', async () => {
105
+ const c = await shopping.lookup(s, { ids: [v.id, v.id, 'no-such-id'] });
106
+ const found = c.body.products.flatMap((p) => p.variants).find((x) => x.id === v.id);
107
+ must(found, `variant ${v.id} not returned`);
108
+ must(found.inputs?.some((i) => i.id === v.id && i.match === 'exact'), 'returned variant must list inputs with match exact');
109
+ must(c.body.products.filter((p) => p.variants.some((x) => x.id === v.id)).length === 1, 'duplicate ids must be deduplicated');
110
+ return `inputs ok, unknown id reported as ${c.body.messages?.[0]?.code ?? 'nothing'}`;
111
+ });
112
+ await run('dev.ucp.shopping.catalog.lookup', 'get product by id, not_found for unknown', async () => {
113
+ const c = await shopping.product(s, { id: v.id });
114
+ must(c.body.product?.variants?.[0]?.id === v.id, 'product must be returned with the variant first');
115
+ const miss = await request(s, 'POST', `${s.endpoint}/catalog/product`, { id: 'no-such-id' });
116
+ must(miss.status === 200 && miss.body?.ucp?.status === 'error' && miss.body.messages?.[0]?.code === 'not_found', 'unknown id must be a 200 error_response with not_found');
117
+ return 'ok';
118
+ });
119
+ // ---- cart
120
+ const context = { address_country: country, currency: v.price.currency };
121
+ let cartId = null;
122
+ await run('dev.ucp.shopping.cart', 'create, get, update, totals', async () => {
123
+ const c = await shopping.createCart(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context });
124
+ must(c.status === 201, `create must answer 201, got ${c.status}`);
125
+ const cart = c.body;
126
+ must(cart.id && cart.currency && cart.line_items?.length === 1 && cart.totals, 'cart misses id, currency, line_items or totals');
127
+ must(cart.line_items[0].item.price === v.price.amount, 'line item price must match the catalog');
128
+ must(total(cart.totals) === sum(cart.totals), 'totals: sum of non-total entries must equal total');
129
+ cartId = cart.id;
130
+ const got = await shopping.getCart(s, cart.id);
131
+ must(got.body.id === cart.id, 'get cart must return it');
132
+ const up = await shopping.updateCart(s, cart.id, { line_items: [{ id: cart.line_items[0].id, item: { id: v.id }, quantity: 2 }], context });
133
+ must(up.body.line_items[0].quantity === 2 && total(up.body.totals) === v.price.amount * 2, 'update must replace lines and recompute totals');
134
+ return `cart ${cart.id}, total ${total(up.body.totals)} ${cart.currency} for 2`;
135
+ });
136
+ if (s.capabilities.has('dev.ucp.shopping.discount')) {
137
+ await run('dev.ucp.shopping.discount', 'codes apply and invalid codes warn', async () => {
138
+ const c = await shopping.createCart(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context, discounts: { codes: ['SANDBOX10', 'NOPE'] } });
139
+ const cart = c.body;
140
+ must(cart.discounts?.applied?.length === 1 && cart.discounts.applied[0].amount > 0, 'SANDBOX10 must be applied with a positive amount');
141
+ must(cart.totals.some((t) => t.type === 'discount' && t.amount < 0), 'a negative discount total must appear');
142
+ must(cart.messages?.some((m) => m.type === 'warning' && m.code === 'discount_code_invalid' && m.path === '$.discounts.codes[1]'), 'NOPE must produce a discount_code_invalid warning at its path');
143
+ must(total(cart.totals) === sum(cart.totals), 'totals identity must hold with discounts');
144
+ return `${cart.discounts.applied[0].title}: ${cart.discounts.applied[0].amount} off`;
145
+ });
146
+ }
147
+ await run('dev.ucp.shopping.cart', 'cancel returns the cart, then not_found', async () => {
148
+ const c = await shopping.createCart(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context });
149
+ const canceled = await shopping.cancelCart(s, c.body.id);
150
+ must(canceled.body.id === c.body.id, 'cancel must return the cart');
151
+ const gone = await request(s, 'GET', `${s.endpoint}/carts/${c.body.id}`);
152
+ must(gone.body?.ucp?.status === 'error' && gone.body.messages?.[0]?.code === 'not_found', 'a canceled cart must be not_found');
153
+ return 'ok';
154
+ });
155
+ // ---- checkout
156
+ let ready = null;
157
+ await run('dev.ucp.shopping.checkout', 'create is incomplete with recoverable messages', async () => {
158
+ const c = await shopping.createCheckout(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context });
159
+ must(c.status === 201, `create must answer 201, got ${c.status}`);
160
+ const ck = c.body;
161
+ must(ck.status === 'incomplete', `expected incomplete, got ${ck.status}`);
162
+ must(ck.ucp?.payment_handlers && Object.keys(ck.ucp.payment_handlers).length, 'checkout responses must carry ucp.payment_handlers');
163
+ must(Array.isArray(ck.links) && ck.links.some((l) => l.type === 'privacy_policy') && ck.links.some((l) => l.type === 'terms_of_service'), 'links must include privacy_policy and terms_of_service');
164
+ must(ck.messages?.every((m) => m.type !== 'error' || m.severity === 'recoverable'), 'errors on a fresh checkout must be recoverable');
165
+ must(/^https:\/\//.test(ck.continue_url ?? ''), 'continue_url must be an absolute HTTPS URL');
166
+ ready = ck;
167
+ return `${ck.id}: ${(ck.messages ?? []).map((m) => m.code).join(', ')}`;
168
+ });
169
+ if (!ready)
170
+ return finish(origin, checks);
171
+ let ck = ready;
172
+ await run('dev.ucp.shopping.checkout', 'rejects an update without line_items', async () => {
173
+ const c = await shopping.updateCheckout(s, ck.id, { buyer: TEST_BUYER });
174
+ must(c.body.messages?.some((m) => m.type === 'error'), 'update without line_items must be refused with an error message');
175
+ return 'refused';
176
+ });
177
+ if (s.capabilities.has('dev.ucp.shopping.fulfillment')) {
178
+ await run('dev.ucp.shopping.fulfillment', 'address selection yields options and the fulfillment total', async () => {
179
+ const u = replay(ck);
180
+ u.buyer = TEST_BUYER;
181
+ u.fulfillment.methods[0].destinations = [testAddress(country)];
182
+ u.fulfillment.methods[0].selected_destination_id = 'dest_1';
183
+ const c = await shopping.updateCheckout(s, ck.id, u);
184
+ ck = c.body;
185
+ const method = ck.fulfillment?.methods?.[0];
186
+ must(method?.selected_destination_id === 'dest_1' && method.destinations?.length === 1 && method.destinations[0].type === 'shipping_address', 'the selected destination must be echoed with type shipping_address');
187
+ must(method.groups?.[0]?.options?.length && method.groups[0].selected_option_id, 'a group with options and a selected option is expected');
188
+ must(ck.totals.some((t) => t.type === 'fulfillment'), 'a fulfillment total is expected');
189
+ must(ck.status === 'ready_for_complete', `expected ready_for_complete, got ${ck.status}: ${(ck.messages ?? []).map((m) => m.code).join(', ')}`);
190
+ return `${method.groups[0].options.map((o) => `${o.title} ${o.totals[0].amount}`).join(', ')}`;
191
+ });
192
+ await run('dev.ucp.shopping.fulfillment', 'an undeliverable country is refused', async () => {
193
+ const u = replay(ck);
194
+ u.fulfillment.methods[0].destinations = [testAddress('AQ')];
195
+ u.fulfillment.methods[0].selected_destination_id = 'dest_1';
196
+ const c = await shopping.updateCheckout(s, ck.id, u);
197
+ must(c.body.status === 'incomplete' && c.body.messages?.some((m) => m.code === 'address_undeliverable'), 'Antarctica must give address_undeliverable');
198
+ u.fulfillment.methods[0].destinations = [testAddress(country)];
199
+ ck = (await shopping.updateCheckout(s, ck.id, u)).body;
200
+ must(ck.status === 'ready_for_complete', 'the checkout must recover after a valid address');
201
+ return 'address_undeliverable, then recovered';
202
+ });
203
+ }
204
+ else {
205
+ const u = replay(ck);
206
+ u.buyer = TEST_BUYER;
207
+ ck = (await shopping.updateCheckout(s, ck.id, u)).body;
208
+ }
209
+ if (s.capabilities.has('dev.ucp.shopping.buyer_consent')) {
210
+ await run('dev.ucp.shopping.buyer_consent', 'consent is echoed with descriptions', async () => {
211
+ const u = replay(ck);
212
+ u.buyer = { ...TEST_BUYER, consent: { 'dev.ucp.consent.marketing': { granted: false, source: 'platform' } } };
213
+ const c = await shopping.updateCheckout(s, ck.id, u);
214
+ ck = c.body;
215
+ const given = ck.buyer?.consent?.['dev.ucp.consent.marketing'];
216
+ must(given && given.granted === false && given.source === 'platform' && given.description, 'consent must come back with granted, source and description');
217
+ return given.description;
218
+ });
219
+ }
220
+ await run('dev.ucp.shopping.checkout', 'totals identity and ready_for_complete', async () => {
221
+ must(ck.status === 'ready_for_complete', `status is ${ck.status}`);
222
+ must(ck.totals.filter((t) => t.type === 'subtotal').length === 1 && ck.totals.filter((t) => t.type === 'total').length === 1, 'exactly one subtotal and one total');
223
+ must(total(ck.totals) === sum(ck.totals), `sum of entries ${sum(ck.totals)} must equal total ${total(ck.totals)}`);
224
+ return `total ${total(ck.totals)} ${ck.currency}`;
225
+ });
226
+ await run('ai.commercelayer.sandbox', 'a declined token fails recoverably', async () => {
227
+ const c = await shopping.completeCheckout(s, ck.id, { payment: { instruments: [sandboxInstrument(true)] } });
228
+ must(c.body.status !== 'completed' && c.body.messages?.some((m) => m.code === 'payment_failed' && m.severity === 'recoverable'), 'expected payment_failed, recoverable');
229
+ return `payment_failed, status ${c.body.status}`;
230
+ });
231
+ await run('ai.commercelayer.sandbox', 'an unknown handler is refused', async () => {
232
+ const pi = { ...sandboxInstrument(), handler_id: 'nope' };
233
+ const c = await shopping.completeCheckout(s, ck.id, { payment: { instruments: [pi] } });
234
+ must(c.body.status !== 'completed' && c.body.messages?.some((m) => m.code === 'payment_failed'), 'expected payment_failed');
235
+ return 'payment_failed';
236
+ });
237
+ let completeCall = null;
238
+ let orderId = null;
239
+ const key = crypto.randomUUID();
240
+ const completeBody = { payment: { instruments: [sandboxInstrument()] }, signals: { 'dev.ucp.buyer_ip': '203.0.113.1', 'dev.ucp.user_agent': 'c11r' } };
241
+ await run('dev.ucp.shopping.checkout', 'complete with the sandbox token places the order', async () => {
242
+ completeCall = await shopping.completeCheckout(s, ck.id, completeBody, { idempotencyKey: key });
243
+ const done = completeCall.body;
244
+ must(done.status === 'completed', `expected completed, got ${done.status}: ${(done.messages ?? []).map((m) => m.code).join(', ')}`);
245
+ must(done.order?.id && /^https:\/\//.test(done.order.permalink_url ?? ''), 'order.id and order.permalink_url are required');
246
+ must(done.continue_url === undefined && done.expires_at === undefined, 'completed checkouts should omit continue_url and expires_at');
247
+ must(JSON.stringify(done.payment ?? {}).includes('"token"') === false, 'the credential token must not be echoed');
248
+ orderId = done.order.id;
249
+ return `order ${done.order.id} ${done.order.label ?? ''}`;
250
+ });
251
+ await run('signatures', 'the completion response is signed and verifies', async () => {
252
+ must(completeCall, 'no completion');
253
+ must(completeCall.digest === 'verified', `Content-Digest ${completeCall.digest ?? 'missing'}`);
254
+ must(completeCall.signature === 'verified', `Signature ${completeCall.signature ?? 'missing'}`);
255
+ return 'ES256 over @status, content-digest, content-type';
256
+ });
257
+ await run('transport', 'the same Idempotency-Key replays, a different body conflicts', async () => {
258
+ const again = await request(s, 'POST', `${s.endpoint}/checkout-sessions/${ck.id}/complete`, completeBody, { idempotencyKey: key });
259
+ must(again.status === completeCall.status && JSON.stringify(again.body) === JSON.stringify(completeCall.body), 'the replay must return the same response');
260
+ const other = await request(s, 'POST', `${s.endpoint}/checkout-sessions/${ck.id}/complete`, { ...completeBody, attribution: { x: '1' } }, { idempotencyKey: key });
261
+ must(other.status === 409, `expected 409, got ${other.status}`);
262
+ return 'replayed, then 409';
263
+ });
264
+ await run('dev.ucp.shopping.checkout', 'a completed checkout is immutable; a fresh one cancels', async () => {
265
+ const c = await shopping.cancelCheckout(s, ck.id);
266
+ must(c.body.status === 'completed' && c.body.messages?.some((m) => m.severity === 'unrecoverable'), 'cancel after completion must be refused as unrecoverable');
267
+ const fresh = await shopping.createCheckout(s, { line_items: [{ item: { id: v.id }, quantity: 1 }], context });
268
+ const canceled = await shopping.cancelCheckout(s, fresh.body.id);
269
+ must(canceled.body.status === 'canceled', 'a fresh checkout must cancel');
270
+ return 'ok';
271
+ });
272
+ if (cartId) {
273
+ await run('dev.ucp.shopping.cart', 'checkout from cart_id is idempotent', async () => {
274
+ const a = await shopping.createCheckout(s, { cart_id: cartId, buyer: TEST_BUYER });
275
+ must(a.body.line_items?.length === 1 && a.body.line_items[0].quantity === 2, 'the checkout must take the cart lines');
276
+ const b = await shopping.createCheckout(s, { cart_id: cartId });
277
+ must(b.body.id === a.body.id, 'a second create for the same cart must return the same checkout');
278
+ return `checkout ${a.body.id} from cart ${cartId}`;
279
+ });
280
+ }
281
+ // ---- order
282
+ if (orderId) {
283
+ const oid = orderId;
284
+ await run('dev.ucp.shopping.order', 'GET /orders/{id} returns the order entity', async () => {
285
+ const c = await shopping.getOrder(s, oid);
286
+ const o = c.body;
287
+ must(o.id === oid && o.checkout_id === ck.id && o.permalink_url && o.currency === ck.currency, 'id, checkout_id, permalink_url, currency');
288
+ must(o.line_items?.[0]?.quantity?.total !== undefined && o.line_items[0].quantity.fulfilled !== undefined && o.line_items[0].status === 'processing', 'order line items need quantity {total, fulfilled} and status');
289
+ must(o.fulfillment && Array.isArray(o.fulfillment.expectations ?? []) && Array.isArray(o.fulfillment.events ?? []), 'fulfillment with expectations and events');
290
+ must(total(o.totals) === total(ck.totals), 'order totals must match the checkout');
291
+ const miss = await request(s, 'GET', `${s.endpoint}/orders/no-such-order`);
292
+ must(miss.body?.ucp?.status === 'error' && miss.body.messages?.[0]?.code === 'not_found', 'unknown order must be not_found');
293
+ return `${o.label ?? o.id}, ${o.fulfillment.expectations?.length ?? 0} expectation(s)`;
294
+ });
295
+ await run('dev.ucp.shopping.order', 'orders are private: a bad token is challenged', async () => {
296
+ const c = await request(s, 'GET', `${s.endpoint}/orders/${oid}`, undefined, { bearer: 'not-a-token' });
297
+ must(c.status === 401 && /^Bearer realm=/.test(c.headers['www-authenticate'] ?? ''), `expected 401 with WWW-Authenticate: Bearer, got ${c.status}`);
298
+ must(c.body?.messages?.[0]?.code === 'identity_required', 'the body must carry identity_required');
299
+ return '401 identity_required';
300
+ });
301
+ }
302
+ // ---- identity linking
303
+ if (s.capabilities.has('dev.ucp.common.identity_linking')) {
304
+ await run('dev.ucp.common.identity_linking', 'OAuth metadata, PKCE and token exchange', async () => {
305
+ const meta = await send('GET', `${origin}/.well-known/oauth-authorization-server`, {});
306
+ must(meta.status === 200, `metadata answered ${meta.status}`);
307
+ const m = JSON.parse(meta.body);
308
+ for (const f of ['issuer', 'authorization_endpoint', 'token_endpoint', 'revocation_endpoint', 'jwks_uri', 'scopes_supported', 'code_challenge_methods_supported'])
309
+ must(m[f], `metadata misses ${f}`);
310
+ must(m.code_challenge_methods_supported.includes('S256') && m.authorization_response_iss_parameter_supported === true, 'S256 and iss support are required');
311
+ must(m.scopes_supported.includes('dev.ucp.shopping.order:read'), 'scopes_supported must list dev.ucp.shopping.order:read');
312
+ const verifier = crypto.randomBytes(32).toString('base64url');
313
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
314
+ const authz = new URL(m.authorization_endpoint);
315
+ authz.search = new URLSearchParams({ response_type: 'code', client_id: 'c11r-check', redirect_uri: 'https://commercelayer.ai/ucp/agent-cli/callback', scope: 'dev.ucp.shopping.order:read', state: 'xyz', code_challenge: challenge, code_challenge_method: 'S256' }).toString();
316
+ const a = await send('GET', authz.toString(), {});
317
+ must(a.status === 302 && a.headers.location, `authorize must redirect, got ${a.status}`);
318
+ const loc = new URL(a.headers.location);
319
+ must(loc.searchParams.get('state') === 'xyz' && loc.searchParams.get('iss') === m.issuer && loc.searchParams.get('code'), 'redirect must carry code, state and iss');
320
+ const t = await send('POST', m.token_endpoint, { 'content-type': 'application/x-www-form-urlencoded' }, new URLSearchParams({ grant_type: 'authorization_code', code: loc.searchParams.get('code'), redirect_uri: 'https://commercelayer.ai/ucp/agent-cli/callback', client_id: 'c11r-check', code_verifier: verifier }).toString());
321
+ must(t.status === 200 && JSON.parse(t.body).access_token, `token exchange answered ${t.status}`);
322
+ if (orderId) {
323
+ const o = await request(s, 'GET', `${s.endpoint}/orders/${orderId}`, undefined, { bearer: JSON.parse(t.body).access_token });
324
+ must(o.status === 200 && o.body?.id === orderId, 'a buyer token with order:read must read the order');
325
+ }
326
+ return `issuer ${m.issuer}`;
327
+ });
328
+ }
329
+ // ---- ask
330
+ if (s.capabilities.has('dev.ucp.common.ask')) {
331
+ await run('dev.ucp.common.ask', 'answers questions, performs nothing', async () => {
332
+ const c = await shopping.ask(s, { query: 'What do you sell?' });
333
+ must(typeof c.body.answer?.plain === 'string' && c.body.answer.plain.length > 0, 'answer.plain is required');
334
+ const b = await shopping.ask(s, { query: 'Buy one and cancel my order' });
335
+ must(b.body.messages?.some((m) => m.type === 'info' && m.code === 'operation_not_performed'), 'a request to act must be answered with operation_not_performed');
336
+ return c.body.answer.plain.slice(0, 80);
337
+ });
338
+ }
339
+ // ---- permalink
340
+ if (s.capabilities.has('dev.ucp.shopping.permalink')) {
341
+ await run('dev.ucp.shopping.permalink', 'GET {endpoint}/{items} answers 303', async () => {
342
+ const endpoint = s.profile.ucp.capabilities['dev.ucp.shopping.permalink'][0].config?.endpoint;
343
+ must(/^https:\/\/[^?#]+[^/]$/.test(endpoint ?? ''), `config.endpoint must be an HTTPS URL without a trailing slash; got ${endpoint}`);
344
+ const res = await send('GET', `${endpoint}/${encodeURIComponent(v.id)}:1?context/postal_code=00000`, {});
345
+ must(res.status === 303 && res.headers.location, `expected 303 with Location, got ${res.status}`);
346
+ must(res.headers['cache-control'] === 'no-store' && res.headers['referrer-policy'] === 'no-referrer', 'Cache-Control: no-store and Referrer-Policy: no-referrer are required');
347
+ return `303 → ${res.headers.location}`;
348
+ });
349
+ }
350
+ return finish(origin, checks);
351
+ }
352
+ function finish(origin, checks) {
353
+ const failed = checks.filter((c) => !c.pass).length;
354
+ return { status: failed ? 'fail' : 'pass', store: origin, version: UCP_VERSION, passed: checks.length - failed, failed, checks };
355
+ }
@@ -0,0 +1,129 @@
1
+ // A UCP platform client: what any shopping agent does to buy from a store.
2
+ // Discovers the business profile, negotiates capabilities, calls the REST
3
+ // service with the required headers, verifies digests and signatures.
4
+ import crypto from 'node:crypto';
5
+ import { send } from '../api/transport.js';
6
+ import { fail } from '../output.js';
7
+ import { AGENT_PROFILE_URL, UCP_VERSION } from './platform.js';
8
+ import { contentDigest, parseSignature, signatureBase, verify } from './signing.js';
9
+ export class UcpError extends Error {
10
+ call;
11
+ constructor(call, message) {
12
+ super(message);
13
+ this.call = call;
14
+ }
15
+ }
16
+ /** Raw HTTP to a store host, with the UCP headers; returns the parsed response. */
17
+ export async function request(store, method, url, body, opts = {}) {
18
+ const raw = body === undefined ? undefined : JSON.stringify(body);
19
+ const headers = { accept: 'application/json' };
20
+ const omit = new Set(opts.omit ?? []);
21
+ if (!omit.has('ucp-agent'))
22
+ headers['ucp-agent'] = `profile="${AGENT_PROFILE_URL}"`;
23
+ if (!omit.has('request-id'))
24
+ headers['request-id'] = crypto.randomUUID();
25
+ if ((method === 'POST' || method === 'PUT') && !omit.has('idempotency-key') && !/\/catalog\/|\/ask$/.test(url)) {
26
+ headers['idempotency-key'] = opts.idempotencyKey ?? crypto.randomUUID();
27
+ }
28
+ if (raw !== undefined)
29
+ headers['content-digest'] = contentDigest(raw);
30
+ if (opts.bearer)
31
+ headers.authorization = `Bearer ${opts.bearer}`;
32
+ const res = await send(method, url, headers, raw);
33
+ return inspect(store.keys, method, url, res);
34
+ }
35
+ function inspect(keys, method, url, res) {
36
+ let body = null;
37
+ try {
38
+ body = res.body ? JSON.parse(res.body) : null;
39
+ }
40
+ catch {
41
+ body = null;
42
+ }
43
+ const call = { method, url, status: res.status, headers: res.headers, body };
44
+ if (res.headers['content-digest'])
45
+ call.digest = res.headers['content-digest'] === contentDigest(res.body) ? 'verified' : 'mismatch';
46
+ const sig = parseSignature(res.headers['signature-input'], res.headers.signature);
47
+ if (sig) {
48
+ const key = keys.find((k) => k.kid === sig.params.keyid);
49
+ if (!key)
50
+ call.signature = 'unknown_key';
51
+ else {
52
+ const values = { '@status': String(res.status), 'content-digest': res.headers['content-digest'] ?? '', 'content-type': res.headers['content-type'] ?? '' };
53
+ call.signature = verify(signatureBase(values, sig.params), sig.signature, key) ? 'verified' : 'invalid';
54
+ }
55
+ }
56
+ return call;
57
+ }
58
+ /** Fetch /.well-known/ucp and work out what we can do with this store. */
59
+ export async function discover(origin) {
60
+ const res = await send('GET', `${origin}/.well-known/ucp`, { accept: 'application/json' });
61
+ if (res.status !== 200)
62
+ fail('ucp_profile_unreachable', `${origin}/.well-known/ucp answered ${res.status}.`, 'The store does not speak UCP, or the sandbox expired. Run `c11r status`.');
63
+ let profile;
64
+ try {
65
+ profile = JSON.parse(res.body);
66
+ }
67
+ catch {
68
+ fail('ucp_profile_malformed', `${origin}/.well-known/ucp is not JSON.`, 'Report it at https://commercelayer.ai/support with the output of `c11r status`.');
69
+ }
70
+ const ucp = profile.ucp ?? {};
71
+ if (ucp.version !== UCP_VERSION) {
72
+ fail('ucp_version_unsupported', `The store speaks UCP ${ucp.version}; this CLI speaks ${UCP_VERSION}.`, 'Update the CLI: npx -y @commercelayer/agent-cli@latest.');
73
+ }
74
+ const rest = (name) => (ucp.services?.[name] ?? []).find((s) => s.transport === 'rest')?.endpoint ?? null;
75
+ const endpoint = rest('dev.ucp.shopping');
76
+ if (!endpoint)
77
+ fail('ucp_no_rest_service', 'The store profile has no REST dev.ucp.shopping service.', 'Report it at https://commercelayer.ai/support.');
78
+ const capabilities = new Set();
79
+ for (const [name, entries] of Object.entries(ucp.capabilities ?? {})) {
80
+ if (entries.some((e) => e.version === UCP_VERSION))
81
+ capabilities.add(name);
82
+ }
83
+ return { origin, profile, endpoint, commonEndpoint: rest('dev.ucp.common'), capabilities, keys: profile.keys ?? [] };
84
+ }
85
+ /** A call that must come back with a UCP resource; throws UcpError otherwise. */
86
+ async function expectResource(call, what) {
87
+ const c = await call;
88
+ if (c.status >= 400)
89
+ throw new UcpError(c, `${what}: the store answered ${c.status}${c.body?.code ? ` ${c.body.code}` : ''}.`);
90
+ if (c.body?.ucp?.status === 'error')
91
+ throw new UcpError(c, `${what}: ${c.body.messages?.map((m) => `${m.code}: ${m.content}`).join('; ')}`);
92
+ return c;
93
+ }
94
+ export const shopping = {
95
+ search: (s, body) => expectResource(request(s, 'POST', `${s.endpoint}/catalog/search`, body), 'catalog search'),
96
+ lookup: (s, body) => expectResource(request(s, 'POST', `${s.endpoint}/catalog/lookup`, body), 'catalog lookup'),
97
+ product: (s, body) => expectResource(request(s, 'POST', `${s.endpoint}/catalog/product`, body), 'get product'),
98
+ createCart: (s, body, o) => expectResource(request(s, 'POST', `${s.endpoint}/carts`, body, o), 'create cart'),
99
+ getCart: (s, id) => expectResource(request(s, 'GET', `${s.endpoint}/carts/${id}`), 'get cart'),
100
+ updateCart: (s, id, body) => expectResource(request(s, 'PUT', `${s.endpoint}/carts/${id}`, body), 'update cart'),
101
+ cancelCart: (s, id) => expectResource(request(s, 'POST', `${s.endpoint}/carts/${id}/cancel`, {}), 'cancel cart'),
102
+ createCheckout: (s, body, o) => expectResource(request(s, 'POST', `${s.endpoint}/checkout-sessions`, body, o), 'create checkout'),
103
+ getCheckout: (s, id) => expectResource(request(s, 'GET', `${s.endpoint}/checkout-sessions/${id}`), 'get checkout'),
104
+ updateCheckout: (s, id, body) => expectResource(request(s, 'PUT', `${s.endpoint}/checkout-sessions/${id}`, body), 'update checkout'),
105
+ completeCheckout: (s, id, body, o) => expectResource(request(s, 'POST', `${s.endpoint}/checkout-sessions/${id}/complete`, body, o), 'complete checkout'),
106
+ cancelCheckout: (s, id) => expectResource(request(s, 'POST', `${s.endpoint}/checkout-sessions/${id}/cancel`, {}), 'cancel checkout'),
107
+ getOrder: (s, id, o) => expectResource(request(s, 'GET', `${s.endpoint}/orders/${id}`, undefined, o), 'get order'),
108
+ ask: (s, body) => expectResource(request(s, 'POST', `${s.commonEndpoint ?? s.endpoint}/ask`, body), 'ask'),
109
+ };
110
+ /** The update payload for a checkout: everything the platform may write, re-sent whole. */
111
+ export function replay(checkout) {
112
+ const out = { line_items: checkout.line_items.map((l) => ({ id: l.id, item: { id: l.item.id }, quantity: l.quantity })) };
113
+ for (const k of ['buyer', 'context', 'signals', 'attribution', 'discounts'])
114
+ if (checkout[k] !== undefined)
115
+ out[k] = k === 'discounts' ? { codes: checkout.discounts.codes ?? [] } : checkout[k];
116
+ if (checkout.fulfillment?.methods) {
117
+ out.fulfillment = {
118
+ methods: checkout.fulfillment.methods.map((m) => ({
119
+ id: m.id,
120
+ type: m.type,
121
+ line_item_ids: m.line_item_ids,
122
+ destinations: m.destinations,
123
+ selected_destination_id: m.selected_destination_id ?? null,
124
+ groups: (m.groups ?? []).map((g) => ({ id: g.id, selected_option_id: g.selected_option_id ?? null })),
125
+ })),
126
+ };
127
+ }
128
+ return out;
129
+ }