@reactionary/ucp 0.10.35 → 0.10.36
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.
- package/index.js +11 -0
- package/lib/reactionary-ucp-checkout-session.js +603 -0
- package/lib/reactionary-ucp-discounts.js +88 -0
- package/lib/reactionary-ucp-http.js +7 -0
- package/lib/reactionary-ucp-identity.js +2 -7
- package/lib/reactionary-ucp-localization.js +92 -0
- package/lib/reactionary-ucp-mapping.js +182 -0
- package/lib/reactionary-ucp-order.js +152 -0
- package/lib/reactionary-ucp-profile.js +11 -4
- package/lib/reactionary-ucp-rest.js +135 -225
- package/lib/reactionary-ucp-server.js +123 -4
- package/lib/reactionary-ucp-session-store.js +145 -1
- package/lib/reactionary-ucp-signing.js +72 -0
- package/lib/reactionary-ucp-webhooks.js +113 -0
- package/package.json +2 -2
- package/src/index.d.ts +4 -0
- package/src/lib/reactionary-ucp-checkout-session.d.ts +91 -0
- package/src/lib/reactionary-ucp-common.d.ts +93 -1
- package/src/lib/reactionary-ucp-discounts.d.ts +24 -0
- package/src/lib/reactionary-ucp-http.d.ts +2 -0
- package/src/lib/reactionary-ucp-localization.d.ts +35 -0
- package/src/lib/reactionary-ucp-mapping.d.ts +41 -0
- package/src/lib/reactionary-ucp-order.d.ts +77 -0
- package/src/lib/reactionary-ucp-rest.d.ts +27 -1
- package/src/lib/reactionary-ucp-server.d.ts +14 -0
- package/src/lib/reactionary-ucp-session-store.d.ts +191 -0
- package/src/lib/reactionary-ucp-signing.d.ts +27 -0
- package/src/lib/reactionary-ucp-webhooks.d.ts +48 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { jsonResponse, secureEquals } from "./reactionary-ucp-http.js";
|
|
3
|
+
import { getOrder, notifyOrderPlaced, simulateShipping, UCPOrderUpdateSchema, updateOrder } from "./reactionary-ucp-order.js";
|
|
4
|
+
import {
|
|
5
|
+
cancelCheckoutSession,
|
|
6
|
+
completeCheckoutSession,
|
|
7
|
+
createCheckoutSession,
|
|
8
|
+
getCheckoutSession,
|
|
9
|
+
updateCheckoutSession
|
|
10
|
+
} from "./reactionary-ucp-checkout-session.js";
|
|
11
|
+
import {
|
|
12
|
+
createUCPError,
|
|
13
|
+
createUcpSuccessMetadata,
|
|
14
|
+
createUcpWarning,
|
|
15
|
+
getMoneyCurrency,
|
|
16
|
+
toUcpCartLineItem,
|
|
17
|
+
toUcpCostTotals
|
|
18
|
+
} from "./reactionary-ucp-mapping.js";
|
|
19
|
+
async function handleRestRequest(request, client, path, sessionId, sessionStore, options) {
|
|
20
|
+
const orderContext = { client, store: sessionStore, merchantUrl: options.merchantUrl, webhooks: options.webhooks };
|
|
21
|
+
const checkoutContext = createCheckoutSessionContext(client, sessionId, sessionStore, options, (orderId) => {
|
|
22
|
+
notifyOrderPlaced(orderContext, orderId).catch((error) => {
|
|
23
|
+
console.error(`UCP: failed to send the order created webhook for ${orderId}`, error);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
3
26
|
if (request.method === "POST" && path === "/catalog/search") {
|
|
4
27
|
return jsonResponse(await handleCatalogSearch(client, await parseJsonBody(request)));
|
|
5
28
|
}
|
|
@@ -17,7 +40,13 @@ async function handleRestRequest(request, client, path, sessionId, sessionStore,
|
|
|
17
40
|
"REST POST /carts",
|
|
18
41
|
body,
|
|
19
42
|
sessionStore,
|
|
20
|
-
async () =>
|
|
43
|
+
async () => {
|
|
44
|
+
const cart = await handleCreateCart(client, body);
|
|
45
|
+
if (isUcpCart(cart)) {
|
|
46
|
+
await sessionStore.bindResource(cart.id, sessionId);
|
|
47
|
+
}
|
|
48
|
+
return createdResponse(cart);
|
|
49
|
+
}
|
|
21
50
|
);
|
|
22
51
|
}
|
|
23
52
|
const cartMatch = /^\/carts\/([^/]+)$/.exec(path);
|
|
@@ -54,12 +83,12 @@ async function handleRestRequest(request, client, path, sessionId, sessionStore,
|
|
|
54
83
|
"REST POST /checkout-sessions",
|
|
55
84
|
body,
|
|
56
85
|
sessionStore,
|
|
57
|
-
async () =>
|
|
86
|
+
async () => createdResponse(await createCheckoutSession(checkoutContext, body))
|
|
58
87
|
);
|
|
59
88
|
}
|
|
60
89
|
const checkoutMatch = /^\/checkout-sessions\/([^/]+)$/.exec(path);
|
|
61
90
|
if (checkoutMatch && request.method === "GET") {
|
|
62
|
-
return
|
|
91
|
+
return checkoutResponse(await getCheckoutSession(checkoutContext, decodeURIComponent(checkoutMatch[1])));
|
|
63
92
|
}
|
|
64
93
|
if (checkoutMatch && request.method === "PUT") {
|
|
65
94
|
const body = await parseJsonBody(request);
|
|
@@ -69,18 +98,19 @@ async function handleRestRequest(request, client, path, sessionId, sessionStore,
|
|
|
69
98
|
`REST PUT /checkout-sessions/${decodeURIComponent(checkoutMatch[1])}`,
|
|
70
99
|
body,
|
|
71
100
|
sessionStore,
|
|
72
|
-
async () =>
|
|
101
|
+
async () => checkoutResponse(await updateCheckoutSession(checkoutContext, decodeURIComponent(checkoutMatch[1]), body))
|
|
73
102
|
);
|
|
74
103
|
}
|
|
75
104
|
const checkoutCompleteMatch = /^\/checkout-sessions\/([^/]+)\/complete$/.exec(path);
|
|
76
105
|
if (checkoutCompleteMatch && request.method === "POST") {
|
|
106
|
+
const body = await parseJsonBody(request);
|
|
77
107
|
return withRestIdempotency(
|
|
78
108
|
request,
|
|
79
109
|
sessionId,
|
|
80
110
|
`REST POST /checkout-sessions/${decodeURIComponent(checkoutCompleteMatch[1])}/complete`,
|
|
81
|
-
|
|
111
|
+
body,
|
|
82
112
|
sessionStore,
|
|
83
|
-
async () =>
|
|
113
|
+
async () => checkoutResponse(await completeCheckoutSession(checkoutContext, decodeURIComponent(checkoutCompleteMatch[1]), body))
|
|
84
114
|
);
|
|
85
115
|
}
|
|
86
116
|
const checkoutCancelMatch = /^\/checkout-sessions\/([^/]+)\/cancel$/.exec(path);
|
|
@@ -91,31 +121,114 @@ async function handleRestRequest(request, client, path, sessionId, sessionStore,
|
|
|
91
121
|
`REST POST /checkout-sessions/${decodeURIComponent(checkoutCancelMatch[1])}/cancel`,
|
|
92
122
|
{},
|
|
93
123
|
sessionStore,
|
|
94
|
-
async () =>
|
|
124
|
+
async () => checkoutResponse(await cancelCheckoutSession(checkoutContext, decodeURIComponent(checkoutCancelMatch[1])))
|
|
95
125
|
);
|
|
96
126
|
}
|
|
97
127
|
const orderMatch = /^\/orders\/([^/]+)$/.exec(path);
|
|
98
128
|
if (orderMatch && request.method === "GET") {
|
|
99
|
-
return jsonResponse(await
|
|
129
|
+
return jsonResponse(await getOrder(orderContext, decodeURIComponent(orderMatch[1])));
|
|
130
|
+
}
|
|
131
|
+
if (orderMatch && request.method === "PUT" && options.testOrderUpdates) {
|
|
132
|
+
const update = UCPOrderUpdateSchema.safeParse(await parseJsonBody(request));
|
|
133
|
+
if (!update.success) {
|
|
134
|
+
throw new UCPHttpError(422, createUCPError("invalid_request", `Invalid order update: ${z.prettifyError(update.error)}`));
|
|
135
|
+
}
|
|
136
|
+
return jsonResponse(await updateOrder(orderContext, decodeURIComponent(orderMatch[1]), update.data));
|
|
137
|
+
}
|
|
138
|
+
const simulateShippingMatch = /^\/testing\/simulate-shipping\/([^/]+)$/.exec(path);
|
|
139
|
+
if (simulateShippingMatch && request.method === "POST" && options.testSimulationSecret) {
|
|
140
|
+
if (!secureEquals(request.headers.get("Simulation-Secret") ?? "", options.testSimulationSecret)) {
|
|
141
|
+
throw new UCPHttpError(403, createUCPError("forbidden", "A valid Simulation-Secret header is required."));
|
|
142
|
+
}
|
|
143
|
+
return jsonResponse(await simulateShipping(orderContext, decodeURIComponent(simulateShippingMatch[1])));
|
|
100
144
|
}
|
|
101
145
|
return void 0;
|
|
102
146
|
}
|
|
147
|
+
function createCheckoutSessionContext(client, sessionId, sessionStore, options, onOrderPlaced) {
|
|
148
|
+
let identityEmail;
|
|
149
|
+
return {
|
|
150
|
+
client,
|
|
151
|
+
store: sessionStore,
|
|
152
|
+
sessionId,
|
|
153
|
+
paymentHandlers: options.paymentHandlers,
|
|
154
|
+
placeholderEmail: options.placeholderEmail,
|
|
155
|
+
paymentAuthorizationWait: options.paymentAuthorizationWait,
|
|
156
|
+
merchantUrl: options.merchantUrl,
|
|
157
|
+
anonymousOrderEmail: options.anonymousOrderEmail,
|
|
158
|
+
testPaymentHandlers: options.testPaymentHandlers,
|
|
159
|
+
acceptRawCardCredentials: options.acceptRawCardCredentials,
|
|
160
|
+
inventory: options.inventory,
|
|
161
|
+
agentProfile: options.agentProfile,
|
|
162
|
+
onOrderPlaced,
|
|
163
|
+
getIdentityEmail() {
|
|
164
|
+
identityEmail ??= getRegisteredIdentityEmail(client, options.identity);
|
|
165
|
+
return identityEmail;
|
|
166
|
+
},
|
|
167
|
+
async createCart(lineItems) {
|
|
168
|
+
const created = await handleCreateCart(client, {
|
|
169
|
+
id: "",
|
|
170
|
+
line_items: lineItems,
|
|
171
|
+
currency: "",
|
|
172
|
+
totals: [],
|
|
173
|
+
ucp: createUcpSuccessMetadata()
|
|
174
|
+
});
|
|
175
|
+
if (!isUcpCart(created)) {
|
|
176
|
+
return created;
|
|
177
|
+
}
|
|
178
|
+
return await getReactionaryCart(client, created.id) ?? createUCPError("not_found", `Cart was not found: ${created.id}`);
|
|
179
|
+
},
|
|
180
|
+
async reconcileCart(cartId, lineItems) {
|
|
181
|
+
const updated = await handleUpdateCart(client, cartId, {
|
|
182
|
+
id: cartId,
|
|
183
|
+
line_items: lineItems,
|
|
184
|
+
currency: "",
|
|
185
|
+
totals: [],
|
|
186
|
+
ucp: createUcpSuccessMetadata()
|
|
187
|
+
});
|
|
188
|
+
return isUcpCart(updated) ? void 0 : updated;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async function getRegisteredIdentityEmail(client, identity) {
|
|
193
|
+
if (identity.type !== "Registered" || !client.profile) {
|
|
194
|
+
return void 0;
|
|
195
|
+
}
|
|
196
|
+
const profile = await client.profile.getById({ identifier: identity.id });
|
|
197
|
+
return profile.success && profile.value.email ? profile.value.email : void 0;
|
|
198
|
+
}
|
|
199
|
+
function getAgentProfile(request) {
|
|
200
|
+
const agent = request.headers.get("UCP-Agent");
|
|
201
|
+
const profile = agent ? /profile="([^"]*)"/.exec(agent)?.[1] : void 0;
|
|
202
|
+
return profile ? `agent:${profile}` : void 0;
|
|
203
|
+
}
|
|
204
|
+
const CHECKOUT_ERROR_STATUSES = {
|
|
205
|
+
not_found: 404,
|
|
206
|
+
checkout_not_modifiable: 409
|
|
207
|
+
};
|
|
208
|
+
function checkoutResponse(body) {
|
|
209
|
+
const code = body.ucp.status === "error" ? body.messages?.[0]?.code : void 0;
|
|
210
|
+
return jsonResponse(body, { status: code && CHECKOUT_ERROR_STATUSES[code] || 200 });
|
|
211
|
+
}
|
|
212
|
+
function createdResponse(body) {
|
|
213
|
+
return jsonResponse(body, { status: body.ucp.status === "error" ? 200 : 201 });
|
|
214
|
+
}
|
|
103
215
|
async function withRestIdempotency(request, sessionId, action, payload, sessionStore, createResponse) {
|
|
104
216
|
const idempotencyKey = request.headers.get("Idempotency-Key");
|
|
105
217
|
if (!idempotencyKey) {
|
|
106
218
|
return createResponse();
|
|
107
219
|
}
|
|
220
|
+
const scope = getAgentProfile(request) ?? sessionId;
|
|
108
221
|
const fingerprint = JSON.stringify(payload);
|
|
109
|
-
const cached = await sessionStore.getIdempotencyRecord(
|
|
222
|
+
const cached = await sessionStore.getIdempotencyRecord(scope, idempotencyKey);
|
|
110
223
|
if (cached) {
|
|
111
224
|
if (cached.action !== action || cached.fingerprint !== fingerprint) {
|
|
112
|
-
return jsonResponse(createUCPError("idempotency_key_conflict", "The supplied Idempotency-Key was already used for a different UCP REST mutation
|
|
225
|
+
return jsonResponse(createUCPError("idempotency_key_conflict", "The supplied Idempotency-Key was already used for a different UCP REST mutation."), { status: 409 });
|
|
113
226
|
}
|
|
114
227
|
return jsonResponse(cached.response, { status: cached.status ?? 200 });
|
|
115
228
|
}
|
|
116
229
|
const response = await createResponse();
|
|
117
230
|
const responseBody = await response.clone().json();
|
|
118
|
-
await sessionStore.putIdempotencyRecord(
|
|
231
|
+
await sessionStore.putIdempotencyRecord(scope, idempotencyKey, {
|
|
119
232
|
action,
|
|
120
233
|
fingerprint,
|
|
121
234
|
status: response.status,
|
|
@@ -212,7 +325,11 @@ async function handleCreateCart(client, body) {
|
|
|
212
325
|
quantity: lineItem.quantity
|
|
213
326
|
});
|
|
214
327
|
if (!addResult.success) {
|
|
215
|
-
return createUCPError(
|
|
328
|
+
return createUCPError(
|
|
329
|
+
"item_unavailable",
|
|
330
|
+
`Item is not available for purchase: ${lineItem.item.id}`,
|
|
331
|
+
`$.line_items[${body["line_items"].indexOf(lineItem)}]`
|
|
332
|
+
);
|
|
216
333
|
}
|
|
217
334
|
cart = addResult.value;
|
|
218
335
|
}
|
|
@@ -291,103 +408,14 @@ async function handleCancelCart(client, cartId) {
|
|
|
291
408
|
return createUCPError("not_available", "Cart capability is not available.");
|
|
292
409
|
}
|
|
293
410
|
const current = await client.cart.getById({ cart: { key: cartId } });
|
|
294
|
-
const deleted = await client.cart.deleteCart({
|
|
411
|
+
const deleted = await client.cart.deleteCart({
|
|
412
|
+
cart: current.success ? current.value.identifier : { key: cartId }
|
|
413
|
+
});
|
|
295
414
|
if (!deleted.success) {
|
|
296
415
|
return createUCPError("cart_cancel_failed", `Unable to cancel cart: ${cartId}`);
|
|
297
416
|
}
|
|
298
417
|
return current.success ? toUcpCart(current.value) : createEmptyUcpCart(cartId);
|
|
299
418
|
}
|
|
300
|
-
async function handleCreateCheckout(client, body, paymentHandlers) {
|
|
301
|
-
if (!client.checkout) {
|
|
302
|
-
return createUCPError("not_available", "Checkout capability is not available.");
|
|
303
|
-
}
|
|
304
|
-
const cart = body.cart_id ? await getReactionaryCart(client, body.cart_id) : await createCartForCheckout(client, body);
|
|
305
|
-
if (!cart) {
|
|
306
|
-
return createUCPError("invalid_request", "A checkout session requires cart_id or line_items.");
|
|
307
|
-
}
|
|
308
|
-
const result = await client.checkout.initiateCheckoutForCart({ cart });
|
|
309
|
-
if (!result.success) {
|
|
310
|
-
return createUCPError("checkout_create_failed", "Checkout session creation failed.");
|
|
311
|
-
}
|
|
312
|
-
return toUcpCheckout(result.value, paymentHandlers);
|
|
313
|
-
}
|
|
314
|
-
async function handleGetCheckout(client, checkoutId, paymentHandlers) {
|
|
315
|
-
if (!client.checkout) {
|
|
316
|
-
return createUCPError("not_available", "Checkout capability is not available.");
|
|
317
|
-
}
|
|
318
|
-
const result = await client.checkout.getById({ identifier: { key: checkoutId } });
|
|
319
|
-
if (!result.success) {
|
|
320
|
-
return createUCPError("not_found", `Checkout was not found: ${checkoutId}`);
|
|
321
|
-
}
|
|
322
|
-
return toUcpCheckout(result.value, paymentHandlers);
|
|
323
|
-
}
|
|
324
|
-
async function handleUpdateCheckout(client, checkoutId, body, paymentHandlers) {
|
|
325
|
-
if (!client.checkout) {
|
|
326
|
-
return createUCPError("not_available", "Checkout capability is not available.");
|
|
327
|
-
}
|
|
328
|
-
const current = await client.checkout.getById({ identifier: { key: checkoutId } });
|
|
329
|
-
if (!current.success) {
|
|
330
|
-
return createUCPError("not_found", `Checkout was not found: ${checkoutId}`);
|
|
331
|
-
}
|
|
332
|
-
let checkout = current.value;
|
|
333
|
-
const selectedInstrument = body.payment?.instruments?.find((instrument) => instrument.selected);
|
|
334
|
-
if (selectedInstrument) {
|
|
335
|
-
const paymentResult = await client.checkout.addPaymentInstruction({
|
|
336
|
-
checkout: checkout.identifier,
|
|
337
|
-
paymentInstruction: {
|
|
338
|
-
amount: checkout.price.grandTotal,
|
|
339
|
-
paymentMethod: {
|
|
340
|
-
method: selectedInstrument.type,
|
|
341
|
-
name: selectedInstrument.id,
|
|
342
|
-
paymentProcessor: selectedInstrument.handler_id
|
|
343
|
-
},
|
|
344
|
-
protocolData: [
|
|
345
|
-
{ key: "ucp_payment_instrument_id", value: selectedInstrument.id },
|
|
346
|
-
{ key: "ucp_payment_handler_id", value: selectedInstrument.handler_id },
|
|
347
|
-
{ key: "ucp_payment_instrument_type", value: selectedInstrument.type }
|
|
348
|
-
]
|
|
349
|
-
}
|
|
350
|
-
});
|
|
351
|
-
if (!paymentResult.success) {
|
|
352
|
-
return createUCPError("checkout_update_failed", "Unable to add selected payment instruction.");
|
|
353
|
-
}
|
|
354
|
-
checkout = paymentResult.value;
|
|
355
|
-
}
|
|
356
|
-
return toUcpCheckout(checkout, paymentHandlers);
|
|
357
|
-
}
|
|
358
|
-
async function handleCompleteCheckout(client, checkoutId, paymentHandlers) {
|
|
359
|
-
if (!client.checkout) {
|
|
360
|
-
return createUCPError("not_available", "Checkout capability is not available.");
|
|
361
|
-
}
|
|
362
|
-
const result = await client.checkout.finalizeCheckout({ checkout: { key: checkoutId } });
|
|
363
|
-
if (!result.success) {
|
|
364
|
-
return createUCPError("checkout_complete_failed", "Checkout completion failed.");
|
|
365
|
-
}
|
|
366
|
-
return toUcpCheckout(result.value, paymentHandlers, "completed");
|
|
367
|
-
}
|
|
368
|
-
async function handleGetOrder(client, orderId) {
|
|
369
|
-
if (!client.order) {
|
|
370
|
-
return createUCPError("not_available", "Order capability is not available.");
|
|
371
|
-
}
|
|
372
|
-
const result = await client.order.getById({ order: { key: orderId } });
|
|
373
|
-
if (!result.success) {
|
|
374
|
-
return createUCPError("not_found", `Order was not found: ${orderId}`);
|
|
375
|
-
}
|
|
376
|
-
return {
|
|
377
|
-
ucp: createUcpSuccessMetadata(),
|
|
378
|
-
id: result.value.identifier.key,
|
|
379
|
-
checkout_id: "",
|
|
380
|
-
permalink_url: "",
|
|
381
|
-
line_items: [],
|
|
382
|
-
currency: getMoneyCurrency(result.value.price?.grandTotal),
|
|
383
|
-
totals: toUcpCostTotals(result.value.price),
|
|
384
|
-
fulfillment: {
|
|
385
|
-
expectations: [],
|
|
386
|
-
events: []
|
|
387
|
-
},
|
|
388
|
-
messages: []
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
419
|
async function getReactionaryCart(client, cartId) {
|
|
392
420
|
if (!client.cart) {
|
|
393
421
|
return void 0;
|
|
@@ -395,19 +423,6 @@ async function getReactionaryCart(client, cartId) {
|
|
|
395
423
|
const result = await client.cart.getById({ cart: { key: cartId } });
|
|
396
424
|
return result.success ? result.value : void 0;
|
|
397
425
|
}
|
|
398
|
-
async function createCartForCheckout(client, checkout) {
|
|
399
|
-
if (!checkout.line_items?.length) {
|
|
400
|
-
return void 0;
|
|
401
|
-
}
|
|
402
|
-
const cartResponse = await handleCreateCart(client, {
|
|
403
|
-
id: "",
|
|
404
|
-
line_items: checkout.line_items,
|
|
405
|
-
currency: checkout.currency,
|
|
406
|
-
totals: checkout.totals,
|
|
407
|
-
ucp: createUcpSuccessMetadata()
|
|
408
|
-
});
|
|
409
|
-
return isUcpCart(cartResponse) ? getReactionaryCart(client, cartResponse.id) : void 0;
|
|
410
|
-
}
|
|
411
426
|
async function getReactionaryProduct(client, id) {
|
|
412
427
|
if (!client.product) {
|
|
413
428
|
return void 0;
|
|
@@ -487,7 +502,7 @@ function toUcpCart(cart) {
|
|
|
487
502
|
id: cart.identifier.key,
|
|
488
503
|
line_items: cart.items.map(toUcpCartLineItem),
|
|
489
504
|
currency: getMoneyCurrency(cart.price?.grandTotal),
|
|
490
|
-
totals: toUcpCostTotals(cart.price),
|
|
505
|
+
totals: toUcpCostTotals(cart.price, cart.items),
|
|
491
506
|
ucp: createUcpSuccessMetadata()
|
|
492
507
|
};
|
|
493
508
|
}
|
|
@@ -503,120 +518,15 @@ function createEmptyUcpCart(cartId) {
|
|
|
503
518
|
function isUcpCart(response) {
|
|
504
519
|
return "id" in response && "line_items" in response && "currency" in response;
|
|
505
520
|
}
|
|
506
|
-
function toUcpCartLineItem(lineItem) {
|
|
507
|
-
const sku = lineItem.variant.sku || lineItem.identifier.key;
|
|
508
|
-
return {
|
|
509
|
-
id: lineItem.identifier.key,
|
|
510
|
-
item: {
|
|
511
|
-
id: sku,
|
|
512
|
-
title: sku,
|
|
513
|
-
price: getMoneyValue(lineItem.price.unitPrice)
|
|
514
|
-
},
|
|
515
|
-
quantity: lineItem.quantity,
|
|
516
|
-
totals: toUcpTotals(lineItem.price.totalPrice)
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
function toUcpCheckout(checkout, paymentHandlers, status) {
|
|
520
|
-
return {
|
|
521
|
-
id: checkout.identifier.key,
|
|
522
|
-
status: status ?? (checkout.readyForFinalization ? "ready_for_complete" : "incomplete"),
|
|
523
|
-
line_items: checkout.items.map(toUcpCartLineItem),
|
|
524
|
-
currency: getMoneyCurrency(checkout.price?.grandTotal),
|
|
525
|
-
totals: toUcpCostTotals(checkout.price),
|
|
526
|
-
links: [],
|
|
527
|
-
ucp: createUcpCheckoutSuccessMetadata(paymentHandlers)
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
521
|
function toUcpDescription(text) {
|
|
531
522
|
return text ? { plain: text } : { plain: "" };
|
|
532
523
|
}
|
|
533
|
-
function toUcpCostTotals(price) {
|
|
534
|
-
const totals = [
|
|
535
|
-
{ type: "subtotal", amount: getMoneyValue(price.totalProductPrice) }
|
|
536
|
-
];
|
|
537
|
-
const optionalTotals = [
|
|
538
|
-
["discount", -Math.abs(getMoneyValue(price.totalDiscount))],
|
|
539
|
-
["fulfillment", getMoneyValue(price.totalShipping)],
|
|
540
|
-
["tax", getMoneyValue(price.totalTax)],
|
|
541
|
-
["fee", getMoneyValue(price.totalSurcharge)]
|
|
542
|
-
];
|
|
543
|
-
for (const [type, amount] of optionalTotals) {
|
|
544
|
-
if (amount !== 0) {
|
|
545
|
-
totals.push({ type, amount });
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
totals.push({ type: "total", amount: getMoneyValue(price.grandTotal) });
|
|
549
|
-
return totals;
|
|
550
|
-
}
|
|
551
|
-
function toUcpTotals(amount) {
|
|
552
|
-
return [
|
|
553
|
-
{
|
|
554
|
-
type: "total",
|
|
555
|
-
amount: getMoneyValue(amount)
|
|
556
|
-
}
|
|
557
|
-
];
|
|
558
|
-
}
|
|
559
|
-
function getMoneyValue(amount) {
|
|
560
|
-
return amount.value;
|
|
561
|
-
}
|
|
562
|
-
function getMoneyCurrency(amount) {
|
|
563
|
-
return amount.currency;
|
|
564
|
-
}
|
|
565
524
|
function createUcpPrice(amount = 0, currency = "EUR") {
|
|
566
525
|
return {
|
|
567
526
|
amount,
|
|
568
527
|
currency
|
|
569
528
|
};
|
|
570
529
|
}
|
|
571
|
-
function createUcpSuccessMetadata() {
|
|
572
|
-
return {
|
|
573
|
-
version: "2026-08-25",
|
|
574
|
-
status: "success"
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
function createUcpCheckoutSuccessMetadata(paymentHandlers) {
|
|
578
|
-
const candidate = paymentHandlers;
|
|
579
|
-
return {
|
|
580
|
-
version: "2026-08-25",
|
|
581
|
-
status: "success",
|
|
582
|
-
payment_handlers: isUcpCheckoutPaymentHandlers(candidate) ? candidate : {}
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
function isUcpCheckoutPaymentHandlers(value) {
|
|
586
|
-
return typeof value === "object" && value !== null && Object.values(value).every(
|
|
587
|
-
(handlers) => Array.isArray(handlers) && handlers.every(
|
|
588
|
-
(handler) => typeof handler === "object" && handler !== null && typeof Reflect.get(handler, "version") === "string"
|
|
589
|
-
)
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
function createUCPError(code, message) {
|
|
593
|
-
return {
|
|
594
|
-
ucp: {
|
|
595
|
-
version: "2026-08-25",
|
|
596
|
-
status: "error"
|
|
597
|
-
},
|
|
598
|
-
messages: [
|
|
599
|
-
{
|
|
600
|
-
message: {
|
|
601
|
-
type: "error",
|
|
602
|
-
content_type: "plain",
|
|
603
|
-
content: message,
|
|
604
|
-
code
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
]
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
function createUcpWarning(code, content, path) {
|
|
611
|
-
return {
|
|
612
|
-
type: "warning",
|
|
613
|
-
code,
|
|
614
|
-
path,
|
|
615
|
-
content,
|
|
616
|
-
content_type: "plain",
|
|
617
|
-
presentation: "notice"
|
|
618
|
-
};
|
|
619
|
-
}
|
|
620
530
|
function getLimit(body) {
|
|
621
531
|
return body.pagination?.limit ?? 10;
|
|
622
532
|
}
|
|
@@ -8,9 +8,16 @@ import {
|
|
|
8
8
|
} from "./reactionary-ucp-common.js";
|
|
9
9
|
import { getOrCreateSessionId, jsonResponse, sendWebResponse, toWebRequest, UCP_SESSION_ID_HEADER } from "./reactionary-ucp-http.js";
|
|
10
10
|
import { ReactionaryUCPIdentity } from "./reactionary-ucp-identity.js";
|
|
11
|
+
import { resolveLanguageContext } from "./reactionary-ucp-localization.js";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_UCP_PAYMENT_AUTHORIZATION_WAIT,
|
|
14
|
+
DEFAULT_UCP_PLACEHOLDER_EMAIL
|
|
15
|
+
} from "./reactionary-ucp-checkout-session.js";
|
|
16
|
+
import { createUCPError, UCP_VERSION } from "./reactionary-ucp-mapping.js";
|
|
11
17
|
import { createUCPProfile, getRequestRoute } from "./reactionary-ucp-profile.js";
|
|
12
18
|
import { handleRestRequest, UCPHttpError } from "./reactionary-ucp-rest.js";
|
|
13
19
|
import { ReactionaryUCPSessionStore } from "./reactionary-ucp-session-store.js";
|
|
20
|
+
import { ReactionaryUCPWebhooks } from "./reactionary-ucp-webhooks.js";
|
|
14
21
|
class ReactionaryUCPServer {
|
|
15
22
|
constructor(clientFactory, options = {}) {
|
|
16
23
|
this.clientFactory = clientFactory;
|
|
@@ -23,10 +30,50 @@ class ReactionaryUCPServer {
|
|
|
23
30
|
if (this.options.identity) {
|
|
24
31
|
this.identity = new ReactionaryUCPIdentity(this.options.identity, cache);
|
|
25
32
|
}
|
|
33
|
+
if (this.options.anonymousOrderEmail) {
|
|
34
|
+
console.warn(
|
|
35
|
+
"\n############################################################################\n# UCP: anonymousOrderEmail is set. Checkouts WITHOUT a buyer email will #\n# place REAL orders under this address. The buyer cannot be sent a #\n# receipt, which may make such purchases illegal in some jurisdictions. #\n# Use for conformance/test environments only. #\n############################################################################"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (this.options.testPaymentHandlers?.length) {
|
|
39
|
+
assertTestPaymentHandlerDelegates(this.options.testPaymentHandlers, this.options.profile?.paymentHandlers ?? {});
|
|
40
|
+
console.warn(
|
|
41
|
+
`
|
|
42
|
+
############################################################################
|
|
43
|
+
# UCP: testPaymentHandlers is set. Checkouts paying with a test handler #
|
|
44
|
+
# are charged through its REAL delegate handler with substitute #
|
|
45
|
+
# credentials. Use for conformance/test environments only. #
|
|
46
|
+
############################################################################
|
|
47
|
+
Test payment handlers: ${this.options.testPaymentHandlers.map((handler) => `${handler.id} -> ${handler.delegateHandlerId}`).join(", ")}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (this.options.webhooks) {
|
|
51
|
+
const signingKey = this.options.webhooks.signingKey;
|
|
52
|
+
if (signingKey && !this.options.profile?.keys.some((key) => key["kid"] === signingKey.kid)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`UCP webhook signing key '${signingKey.kid}' is not published in profile.keys; add toPublicSigningJwk(signingKey).`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
this.webhooks = new ReactionaryUCPWebhooks(
|
|
58
|
+
this.options.webhooks,
|
|
59
|
+
new URL("/.well-known/ucp", this.options.profile?.endpoint ?? "http://localhost/ucp").toString()
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (this.options.testSimulationSecret) {
|
|
63
|
+
console.warn(
|
|
64
|
+
"\n############################################################################\n# UCP: testSimulationSecret is set. POST /testing/simulate-shipping/{id} #\n# marks orders as shipped for anyone holding the secret. Not part of the #\n# UCP specification. Use for conformance/test environments only. #\n############################################################################"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (this.options.testOrderUpdates) {
|
|
68
|
+
console.warn(
|
|
69
|
+
"\n############################################################################\n# UCP: testOrderUpdates is set. Agents can PUT fulfillment events and #\n# adjustments (e.g. refunds) onto the orders they placed. Not part of the #\n# UCP specification. Use for conformance/test environments only. #\n############################################################################"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
26
72
|
}
|
|
27
73
|
clientFactory;
|
|
28
74
|
options;
|
|
29
75
|
sessionStore;
|
|
76
|
+
webhooks;
|
|
30
77
|
identity;
|
|
31
78
|
async fetch(request) {
|
|
32
79
|
const route = getRequestRoute(request, this.options.profile);
|
|
@@ -41,14 +88,22 @@ class ReactionaryUCPServer {
|
|
|
41
88
|
);
|
|
42
89
|
}
|
|
43
90
|
async handleFetch(request) {
|
|
44
|
-
const sessionId = getOrCreateSessionId(request);
|
|
45
91
|
const bearerResolution = await this.identity?.resolveBearer(request);
|
|
46
92
|
if (bearerResolution instanceof Response) {
|
|
47
93
|
return bearerResolution;
|
|
48
94
|
}
|
|
95
|
+
const sessionId = bearerResolution ? getOrCreateSessionId(request) : await this.resolveSessionId(request);
|
|
49
96
|
const requestContext = await this.createRequestContext(sessionId, bearerResolution);
|
|
97
|
+
const negotiatedLanguageContext = await resolveLanguageContext(
|
|
98
|
+
request,
|
|
99
|
+
requestContext.session,
|
|
100
|
+
this.options.localization
|
|
101
|
+
);
|
|
102
|
+
if (negotiatedLanguageContext) {
|
|
103
|
+
requestContext.languageContext = negotiatedLanguageContext;
|
|
104
|
+
}
|
|
50
105
|
const client = this.clientFactory(requestContext);
|
|
51
|
-
const response = await this.handleRequest(request, client, sessionId, bearerResolution);
|
|
106
|
+
const response = await this.handleRequest(request, client, requestContext, sessionId, bearerResolution);
|
|
52
107
|
if (bearerResolution) {
|
|
53
108
|
await this.identity?.persistBearerSession(bearerResolution, requestContext.session);
|
|
54
109
|
} else {
|
|
@@ -76,6 +131,28 @@ class ReactionaryUCPServer {
|
|
|
76
131
|
close() {
|
|
77
132
|
return Promise.resolve();
|
|
78
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Requests addressing a cart or checkout session resume the session that
|
|
136
|
+
* created it: agents rarely echo the UCP session header, but backends scope
|
|
137
|
+
* carts to that session's (anonymous) identity.
|
|
138
|
+
*/
|
|
139
|
+
async resolveSessionId(request) {
|
|
140
|
+
const route = getRequestRoute(request, this.options.profile);
|
|
141
|
+
const resourceId = /^\/(?:carts|checkout-sessions)\/([^/]+)/.exec(route.path)?.[1];
|
|
142
|
+
const orderId = /^\/(?:orders|testing\/simulate-shipping)\/([^/]+)/.exec(route.path)?.[1];
|
|
143
|
+
const boundSessionId = resourceId ? await this.sessionStore.getResourceSession(decodeURIComponent(resourceId)) : orderId ? await this.resolveOrderSessionId(request, decodeURIComponent(orderId)) : void 0;
|
|
144
|
+
return boundSessionId ?? getOrCreateSessionId(request);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Orders resume the session that placed them only for the platform that
|
|
148
|
+
* completed the checkout (order capability: the business verifies the
|
|
149
|
+
* requesting platform created the checkout). Any other request runs in its
|
|
150
|
+
* own session, where the backend does not find the order.
|
|
151
|
+
*/
|
|
152
|
+
async resolveOrderSessionId(request, orderId) {
|
|
153
|
+
const order = await this.sessionStore.getOrder(orderId);
|
|
154
|
+
return order?.agentProfile && order.agentProfile === getAgentProfile(request) ? order.sessionId : void 0;
|
|
155
|
+
}
|
|
79
156
|
async createRequestContext(sessionId, bearerResolution) {
|
|
80
157
|
const requestContext = createInitialRequestContext();
|
|
81
158
|
if (bearerResolution) {
|
|
@@ -88,7 +165,7 @@ class ReactionaryUCPServer {
|
|
|
88
165
|
}
|
|
89
166
|
return requestContext;
|
|
90
167
|
}
|
|
91
|
-
async handleRequest(request, client, sessionId, bearerResolution) {
|
|
168
|
+
async handleRequest(request, client, requestContext, sessionId, bearerResolution) {
|
|
92
169
|
const route = getRequestRoute(request, this.options.profile);
|
|
93
170
|
if (request.method === "OPTIONS") {
|
|
94
171
|
return new Response(null, {
|
|
@@ -111,6 +188,13 @@ class ReactionaryUCPServer {
|
|
|
111
188
|
return accessResponse;
|
|
112
189
|
}
|
|
113
190
|
}
|
|
191
|
+
const agentVersion = getAgentVersion(request);
|
|
192
|
+
if (agentVersion && agentVersion > UCP_VERSION) {
|
|
193
|
+
return jsonResponse(
|
|
194
|
+
createUCPError("version_unsupported", `UCP version ${agentVersion} is not supported; this business supports ${UCP_VERSION}.`),
|
|
195
|
+
{ status: 422 }
|
|
196
|
+
);
|
|
197
|
+
}
|
|
114
198
|
try {
|
|
115
199
|
const restResponse = await handleRestRequest(
|
|
116
200
|
request,
|
|
@@ -118,7 +202,24 @@ class ReactionaryUCPServer {
|
|
|
118
202
|
route.path,
|
|
119
203
|
sessionId,
|
|
120
204
|
this.sessionStore,
|
|
121
|
-
|
|
205
|
+
{
|
|
206
|
+
paymentHandlers: this.options.profile?.paymentHandlers ?? {},
|
|
207
|
+
placeholderEmail: this.options.placeholderEmail ?? DEFAULT_UCP_PLACEHOLDER_EMAIL,
|
|
208
|
+
paymentAuthorizationWait: {
|
|
209
|
+
...DEFAULT_UCP_PAYMENT_AUTHORIZATION_WAIT,
|
|
210
|
+
...this.options.paymentAuthorizationWait
|
|
211
|
+
},
|
|
212
|
+
identity: requestContext.session.identityContext.identity,
|
|
213
|
+
merchantUrl: this.options.profile?.merchant?.url,
|
|
214
|
+
anonymousOrderEmail: this.options.anonymousOrderEmail,
|
|
215
|
+
testPaymentHandlers: this.options.testPaymentHandlers,
|
|
216
|
+
acceptRawCardCredentials: this.options.acceptRawCardCredentials,
|
|
217
|
+
inventory: this.options.inventory,
|
|
218
|
+
testOrderUpdates: this.options.testOrderUpdates,
|
|
219
|
+
testSimulationSecret: this.options.testSimulationSecret,
|
|
220
|
+
webhooks: this.webhooks,
|
|
221
|
+
agentProfile: getAgentProfile(request)
|
|
222
|
+
}
|
|
122
223
|
);
|
|
123
224
|
if (restResponse) {
|
|
124
225
|
return restResponse;
|
|
@@ -140,6 +241,24 @@ class ReactionaryUCPServer {
|
|
|
140
241
|
});
|
|
141
242
|
}
|
|
142
243
|
}
|
|
244
|
+
function assertTestPaymentHandlerDelegates(testHandlers, paymentHandlers) {
|
|
245
|
+
const advertised = new Set(Object.values(paymentHandlers).flat().map((handler) => handler.id));
|
|
246
|
+
for (const handler of testHandlers) {
|
|
247
|
+
if (!advertised.has(handler.delegateHandlerId)) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`UCP test payment handler '${handler.id}' delegates to '${handler.delegateHandlerId}', which is not an advertised payment handler.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function getAgentProfile(request) {
|
|
255
|
+
const agent = request.headers.get("UCP-Agent");
|
|
256
|
+
return agent ? /profile="([^"]*)"/.exec(agent)?.[1] : void 0;
|
|
257
|
+
}
|
|
258
|
+
function getAgentVersion(request) {
|
|
259
|
+
const agent = request.headers.get("UCP-Agent");
|
|
260
|
+
return agent ? /version="([^"]*)"/.exec(agent)?.[1] : void 0;
|
|
261
|
+
}
|
|
143
262
|
function getUcpOperationPath(path) {
|
|
144
263
|
return path.replace(/^\/carts\/[^/]+/, "/carts/{id}").replace(/^\/checkout-sessions\/[^/]+/, "/checkout-sessions/{id}").replace(/^\/orders\/[^/]+/, "/orders/{id}");
|
|
145
264
|
}
|