@reactionary/ucp 0.10.35 → 0.10.37

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.
@@ -1,5 +1,28 @@
1
- import { jsonResponse } from "./reactionary-ucp-http.js";
2
- async function handleRestRequest(request, client, path, sessionId, sessionStore, paymentHandlers = {}) {
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 () => jsonResponse(await handleCreateCart(client, body), { status: 201 })
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 () => jsonResponse(await handleCreateCheckout(client, body, paymentHandlers), { status: 201 })
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 jsonResponse(await handleGetCheckout(client, decodeURIComponent(checkoutMatch[1]), paymentHandlers));
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 () => jsonResponse(await handleUpdateCheckout(client, decodeURIComponent(checkoutMatch[1]), body, paymentHandlers))
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 () => jsonResponse(await handleCompleteCheckout(client, decodeURIComponent(checkoutCompleteMatch[1]), paymentHandlers))
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 () => jsonResponse(createUCPError("not_implemented", "Checkout cancellation is not represented by Reactionary checkout capabilities yet."), { status: 501 })
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 handleGetOrder(client, decodeURIComponent(orderMatch[1])));
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(sessionId, idempotencyKey);
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 in this session."), { status: 409 });
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(sessionId, idempotencyKey, {
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("cart_add_failed", `Unable to add item to cart: ${lineItem.item.id}`);
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({ cart: { key: cartId } });
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
- this.options.profile?.paymentHandlers
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
  }