@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.
@@ -2,6 +2,97 @@ import { SessionSchema } from "@reactionary/core";
2
2
  import * as z from "zod";
3
3
  const SESSION_CACHE_KEY_PREFIX = "reactionary:ucp:session";
4
4
  const IDEMPOTENCY_CACHE_KEY_PREFIX = "reactionary:ucp:idempotency";
5
+ const CHECKOUT_SESSION_CACHE_KEY_PREFIX = "reactionary:ucp:checkout-session";
6
+ const RESOURCE_SESSION_CACHE_KEY_PREFIX = "reactionary:ucp:resource-session";
7
+ const ORDER_CACHE_KEY_PREFIX = "reactionary:ucp:order";
8
+ const UCPPostalAddressStateSchema = z.looseObject({
9
+ id: z.string().optional(),
10
+ first_name: z.string().optional(),
11
+ last_name: z.string().optional(),
12
+ street_address: z.string().optional(),
13
+ extended_address: z.string().optional(),
14
+ address_locality: z.string().optional(),
15
+ address_region: z.string().optional(),
16
+ address_country: z.string().optional(),
17
+ postal_code: z.string().optional(),
18
+ phone_number: z.string().optional()
19
+ });
20
+ const UCPConsentSourceSchema = z.enum(["business", "platform"]);
21
+ const UCPBuyerConsentSchema = z.record(z.string(), z.union([
22
+ z.boolean(),
23
+ z.looseObject({
24
+ granted: z.boolean(),
25
+ source: UCPConsentSourceSchema,
26
+ segments: z.record(z.string(), z.looseObject({
27
+ granted: z.boolean(),
28
+ source: UCPConsentSourceSchema
29
+ })).optional()
30
+ })
31
+ ]));
32
+ const UCPCheckoutSessionStateSchema = z.looseObject({
33
+ id: z.string(),
34
+ cartId: z.string(),
35
+ status: z.enum(["open", "complete_in_progress", "completed", "canceled"]),
36
+ buyer: z.looseObject({
37
+ first_name: z.string().optional(),
38
+ last_name: z.string().optional(),
39
+ email: z.string().optional(),
40
+ phone_number: z.string().optional(),
41
+ /** Round-tripped only; not persisted beyond the session yet. */
42
+ consent: UCPBuyerConsentSchema.optional()
43
+ }).optional(),
44
+ billingAddress: UCPPostalAddressStateSchema.optional(),
45
+ destination: UCPPostalAddressStateSchema.optional(),
46
+ selectedOptionId: z.string().optional(),
47
+ /** The selected fulfillment option's title, as last shown to the agent. */
48
+ selectedOptionTitle: z.string().optional(),
49
+ instrument: z.looseObject({
50
+ id: z.string(),
51
+ handler_id: z.string(),
52
+ type: z.string()
53
+ }).optional(),
54
+ lastTotal: z.number().optional(),
55
+ finalCheckoutId: z.string().optional(),
56
+ orderId: z.string().optional()
57
+ });
58
+ const UCPOrderLineItemReferenceSchema = z.object({
59
+ id: z.string(),
60
+ quantity: z.int()
61
+ });
62
+ const UCPFulfillmentEventSchema = z.looseObject({
63
+ id: z.string(),
64
+ occurred_at: z.iso.datetime({ offset: true }),
65
+ type: z.string(),
66
+ line_items: z.array(UCPOrderLineItemReferenceSchema.extend({ quantity: z.int().min(1) })),
67
+ tracking_number: z.string().optional(),
68
+ tracking_url: z.url().optional(),
69
+ carrier: z.string().optional(),
70
+ description: z.string().optional()
71
+ });
72
+ const UCPAdjustmentSchema = z.looseObject({
73
+ id: z.string(),
74
+ type: z.string(),
75
+ occurred_at: z.iso.datetime({ offset: true }),
76
+ status: z.enum(["pending", "completed", "failed"]),
77
+ line_items: z.array(UCPOrderLineItemReferenceSchema).optional(),
78
+ totals: z.array(z.looseObject({ type: z.string(), amount: z.int() })).optional(),
79
+ description: z.string().optional()
80
+ });
81
+ const UCPOrderStateSchema = z.looseObject({
82
+ id: z.string(),
83
+ checkoutSessionId: z.string(),
84
+ /** The UCP session that completed the checkout and owns the order. */
85
+ sessionId: z.string(),
86
+ /** The UCP-Agent profile of the platform that completed the checkout. */
87
+ agentProfile: z.string().optional(),
88
+ /** The order webhook URL from the platform's profile, resolved at placement. */
89
+ webhookUrl: z.string().optional(),
90
+ destination: UCPPostalAddressStateSchema.optional(),
91
+ fulfillmentTitle: z.string().optional(),
92
+ events: z.array(UCPFulfillmentEventSchema).default([]),
93
+ adjustments: z.array(UCPAdjustmentSchema).default([])
94
+ });
95
+ const UCPResourceSessionSchema = z.looseObject({ sessionId: z.string() });
5
96
  const UCPActionResponseSchema = z.looseObject({});
6
97
  const UCPIdempotencyRecordSchema = z.looseObject({
7
98
  action: z.string(),
@@ -45,6 +136,54 @@ class ReactionaryUCPSessionStore {
45
136
  }
46
137
  );
47
138
  }
139
+ async getCheckoutSession(checkoutSessionId) {
140
+ return await this.cache.get(
141
+ `${CHECKOUT_SESSION_CACHE_KEY_PREFIX}:${checkoutSessionId}`,
142
+ UCPCheckoutSessionStateSchema
143
+ ) ?? void 0;
144
+ }
145
+ async putCheckoutSession(state) {
146
+ const key = `${CHECKOUT_SESSION_CACHE_KEY_PREFIX}:${state.id}`;
147
+ await this.cache.invalidate([key]);
148
+ await this.cache.put(key, state, {
149
+ ttlSeconds: this.ttlSeconds,
150
+ dependencyIds: [key]
151
+ });
152
+ }
153
+ async getOrder(orderId) {
154
+ return await this.cache.get(
155
+ `${ORDER_CACHE_KEY_PREFIX}:${orderId}`,
156
+ UCPOrderStateSchema
157
+ ) ?? void 0;
158
+ }
159
+ async putOrder(state) {
160
+ const key = `${ORDER_CACHE_KEY_PREFIX}:${state.id}`;
161
+ await this.cache.invalidate([key]);
162
+ await this.cache.put(key, state, {
163
+ ttlSeconds: this.ttlSeconds,
164
+ dependencyIds: [key]
165
+ });
166
+ }
167
+ /**
168
+ * Binds a created resource (cart or checkout session) to the UCP session
169
+ * that owns its backend state. Agents address resources by id and rarely
170
+ * echo the UCP session header, yet backends scope carts to the session's
171
+ * (anonymous) identity, so later requests must resume the owning session.
172
+ */
173
+ async bindResource(resourceId, sessionId) {
174
+ await this.cache.put(
175
+ `${RESOURCE_SESSION_CACHE_KEY_PREFIX}:${resourceId}`,
176
+ { sessionId },
177
+ { ttlSeconds: this.ttlSeconds, dependencyIds: [] }
178
+ );
179
+ }
180
+ async getResourceSession(resourceId) {
181
+ const binding = await this.cache.get(
182
+ `${RESOURCE_SESSION_CACHE_KEY_PREFIX}:${resourceId}`,
183
+ UCPResourceSessionSchema
184
+ );
185
+ return binding?.sessionId;
186
+ }
48
187
  getCacheKey(sessionId) {
49
188
  return `${SESSION_CACHE_KEY_PREFIX}:${sessionId}`;
50
189
  }
@@ -59,5 +198,10 @@ class ReactionaryUCPSessionStore {
59
198
  }
60
199
  }
61
200
  export {
62
- ReactionaryUCPSessionStore
201
+ ReactionaryUCPSessionStore,
202
+ UCPAdjustmentSchema,
203
+ UCPBuyerConsentSchema,
204
+ UCPCheckoutSessionStateSchema,
205
+ UCPFulfillmentEventSchema,
206
+ UCPOrderStateSchema
63
207
  };
@@ -0,0 +1,72 @@
1
+ import { createHash, createPrivateKey, sign } from "node:crypto";
2
+ function toPublicSigningJwk(key) {
3
+ const { kty, crv, x, y } = key.privateKeyJwk;
4
+ return {
5
+ kid: key.kid,
6
+ ...kty ? { kty } : {},
7
+ ...crv ? { crv } : {},
8
+ ...x ? { x } : {},
9
+ ...y ? { y } : {},
10
+ use: "sig",
11
+ alg: getAlgorithm(key.privateKeyJwk).alg
12
+ };
13
+ }
14
+ function signRestRequest(request, key, created = Math.floor(Date.now() / 1e3)) {
15
+ const url = new URL(request.url);
16
+ const headers = {
17
+ ...request.headers,
18
+ "content-digest": `sha-256=:${createHash("sha256").update(request.body).digest("base64")}:`
19
+ };
20
+ const components = [
21
+ "@method",
22
+ "@authority",
23
+ "@path",
24
+ ...url.search ? ["@query"] : [],
25
+ ...["ucp-agent", "idempotency-key", "content-digest", "content-type"].filter((name) => headers[name] !== void 0)
26
+ ];
27
+ const signatureParams = `(${components.map((name) => `"${name}"`).join(" ")});created=${created};keyid="${key.kid}"`;
28
+ const signatureBase = [
29
+ ...components.map((name) => `"${name}": ${getComponentValue(name, request.method, url, headers)}`),
30
+ `"@signature-params": ${signatureParams}`
31
+ ].join("\n");
32
+ return {
33
+ "content-digest": headers["content-digest"],
34
+ "signature-input": `sig1=${signatureParams}`,
35
+ "signature": `sig1=:${createSignature(signatureBase, key).toString("base64")}:`
36
+ };
37
+ }
38
+ function getComponentValue(name, method, url, headers) {
39
+ switch (name) {
40
+ case "@method":
41
+ return method.toUpperCase();
42
+ case "@authority":
43
+ return url.host.toLowerCase();
44
+ case "@path":
45
+ return url.pathname;
46
+ case "@query":
47
+ return url.search;
48
+ default:
49
+ return (headers[name] ?? "").trim();
50
+ }
51
+ }
52
+ function createSignature(signatureBase, key) {
53
+ const privateKey = createPrivateKey({ key: key.privateKeyJwk, format: "jwk" });
54
+ const { hash } = getAlgorithm(key.privateKeyJwk);
55
+ return hash ? sign(hash, Buffer.from(signatureBase), { key: privateKey, dsaEncoding: "ieee-p1363" }) : sign(null, Buffer.from(signatureBase), privateKey);
56
+ }
57
+ function getAlgorithm(jwk) {
58
+ if (jwk.kty === "EC" && jwk.crv === "P-256") {
59
+ return { alg: "ES256", hash: "sha256" };
60
+ }
61
+ if (jwk.kty === "EC" && jwk.crv === "P-384") {
62
+ return { alg: "ES384", hash: "sha384" };
63
+ }
64
+ if (jwk.kty === "OKP" && jwk.crv === "Ed25519") {
65
+ return { alg: "EdDSA" };
66
+ }
67
+ throw new Error(`Unsupported UCP signing key: kty=${jwk.kty ?? "?"} crv=${jwk.crv ?? "?"}`);
68
+ }
69
+ export {
70
+ signRestRequest,
71
+ toPublicSigningJwk
72
+ };
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as z from "zod";
3
+ import { signRestRequest } from "./reactionary-ucp-signing.js";
4
+ const DEFAULT_RETRY_DELAYS_MS = [500, 2e3, 1e4, 6e4, 3e5];
5
+ const PROFILE_CACHE_TTL_MS = 5 * 60 * 1e3;
6
+ const REQUEST_TIMEOUT_MS = 5e3;
7
+ const ORDER_CAPABILITY = "dev.ucp.shopping.order";
8
+ const AgentProfileSchema = z.looseObject({
9
+ ucp: z.looseObject({
10
+ capabilities: z.record(z.string(), z.array(z.looseObject({
11
+ config: z.looseObject({ webhook_url: z.string().optional() }).optional()
12
+ }))).optional()
13
+ })
14
+ });
15
+ class ReactionaryUCPWebhooks {
16
+ constructor(options, businessProfileUrl) {
17
+ this.options = options;
18
+ this.businessProfileUrl = businessProfileUrl;
19
+ this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
20
+ this.fetchImpl = options.fetch ?? fetch;
21
+ }
22
+ options;
23
+ businessProfileUrl;
24
+ profileCache = /* @__PURE__ */ new Map();
25
+ retryDelaysMs;
26
+ fetchImpl;
27
+ warnedUnsigned = false;
28
+ /** The order webhook URL declared in a platform's profile, if any. */
29
+ async resolveOrderWebhookUrl(agentProfileUrl) {
30
+ const cached = this.profileCache.get(agentProfileUrl);
31
+ if (cached && cached.expiresAt > Date.now()) {
32
+ return cached.webhookUrl;
33
+ }
34
+ const webhookUrl = this.isAllowedUrl(agentProfileUrl) ? await this.fetchOrderWebhookUrl(agentProfileUrl) : void 0;
35
+ this.profileCache.set(agentProfileUrl, { webhookUrl, expiresAt: Date.now() + PROFILE_CACHE_TTL_MS });
36
+ return webhookUrl;
37
+ }
38
+ /**
39
+ * Sends an order event without waiting for it. Failed attempts are retried
40
+ * as the same event: same Webhook-Id, Webhook-Timestamp and body.
41
+ */
42
+ deliver(webhookUrl, order) {
43
+ if (!this.isAllowedUrl(webhookUrl)) {
44
+ return;
45
+ }
46
+ const webhookId = `evt_${randomUUID()}`;
47
+ const body = JSON.stringify(order);
48
+ const headers = {
49
+ "content-type": "application/json",
50
+ "webhook-id": webhookId,
51
+ "webhook-timestamp": String(Math.floor(Date.now() / 1e3)),
52
+ "ucp-agent": `profile="${this.businessProfileUrl}"`,
53
+ // Deliveries are state-changing POSTs; retries keep the key.
54
+ "idempotency-key": webhookId
55
+ };
56
+ if (this.options.signingKey) {
57
+ Object.assign(headers, signRestRequest({ method: "POST", url: webhookUrl, headers, body }, this.options.signingKey));
58
+ } else if (!this.warnedUnsigned) {
59
+ this.warnedUnsigned = true;
60
+ console.warn("UCP: order webhooks are sent unsigned; configure webhooks.signingKey (the order capability requires signed payloads).");
61
+ }
62
+ void this.attempt(webhookUrl, headers, body, 0);
63
+ }
64
+ async attempt(webhookUrl, headers, body, attempt) {
65
+ let failure;
66
+ try {
67
+ const response = await this.fetchImpl(webhookUrl, {
68
+ method: "POST",
69
+ headers,
70
+ body,
71
+ redirect: "manual",
72
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
73
+ });
74
+ if (response.ok) {
75
+ return;
76
+ }
77
+ failure = `HTTP ${response.status}`;
78
+ } catch (error) {
79
+ failure = error instanceof Error ? error.message : String(error);
80
+ }
81
+ const delay = this.retryDelaysMs[attempt];
82
+ if (delay === void 0) {
83
+ console.error(`UCP: giving up on webhook ${headers["webhook-id"]} to ${webhookUrl} after ${attempt + 1} attempts: ${failure}`);
84
+ return;
85
+ }
86
+ setTimeout(() => void this.attempt(webhookUrl, headers, body, attempt + 1), delay).unref();
87
+ }
88
+ async fetchOrderWebhookUrl(agentProfileUrl) {
89
+ try {
90
+ const response = await this.fetchImpl(agentProfileUrl, {
91
+ headers: { accept: "application/json" },
92
+ redirect: "manual",
93
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
94
+ });
95
+ const profile = response.ok ? AgentProfileSchema.safeParse(await response.json()) : void 0;
96
+ const capabilities = profile?.success ? profile.data.ucp.capabilities?.[ORDER_CAPABILITY] ?? [] : [];
97
+ return capabilities.find((capability) => capability.config?.webhook_url)?.config?.webhook_url;
98
+ } catch {
99
+ return void 0;
100
+ }
101
+ }
102
+ isAllowedUrl(value) {
103
+ try {
104
+ const { protocol } = new URL(value);
105
+ return protocol === "https:" || protocol === "http:" && Boolean(this.options.allowInsecureUrls);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+ }
111
+ export {
112
+ ReactionaryUCPWebhooks
113
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reactionary/ucp",
3
- "version": "0.10.35",
3
+ "version": "0.10.36",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "types": "./src/index.d.ts",
@@ -9,7 +9,7 @@
9
9
  "url": "git+https://github.com/Solteq/reactionary.git"
10
10
  },
11
11
  "dependencies": {
12
- "@reactionary/core": "0.10.35",
12
+ "@reactionary/core": "0.10.36",
13
13
  "zod": "4.1.9"
14
14
  },
15
15
  "devDependencies": {
package/src/index.d.ts CHANGED
@@ -1,2 +1,6 @@
1
1
  export * from './lib/reactionary-ucp-server.js';
2
2
  export * from './lib/reactionary-ucp-identity.js';
3
+ export * from './lib/reactionary-ucp-localization.js';
4
+ export { toPublicSigningJwk, type UCPSigningKey } from './lib/reactionary-ucp-signing.js';
5
+ export type { ReactionaryUCPWebhookOptions } from './lib/reactionary-ucp-webhooks.js';
6
+ export { DEFAULT_UCP_PAYMENT_AUTHORIZATION_WAIT, DEFAULT_UCP_PLACEHOLDER_EMAIL, type UCPPaymentAuthorizationWait, type UCPInventoryOptions, type UCPTestPaymentHandler, } from './lib/reactionary-ucp-checkout-session.js';
@@ -0,0 +1,91 @@
1
+ import type { Cart } from '@reactionary/core';
2
+ import type { ReactionaryUCPClient, UCPPaymentHandlers } from './reactionary-ucp-common.js';
3
+ import { type UCPCheckout, type UCPLineItem, type UCPPostalAddress } from './reactionary-ucp-mapping.js';
4
+ import { type ReactionaryUCPSessionStore } from './reactionary-ucp-session-store.js';
5
+ import type { components } from './ucp-shopping.openapi.js';
6
+ type UCPErrorResponse = components['schemas']['error_response'];
7
+ type UCPCheckoutResponse = components['schemas']['checkout_response'];
8
+ export type UCPCheckoutRequest = UCPCheckout & {
9
+ cart_id?: string;
10
+ };
11
+ export declare const DEFAULT_UCP_PLACEHOLDER_EMAIL = "pending@checkout.invalid";
12
+ /**
13
+ * How long completion waits for the placed checkout to become
14
+ * `readyForFinalization` — which implies an authorized payment, typically
15
+ * recorded on the backend by a PSP webhook — before answering
16
+ * `complete_in_progress`. A timeout of 0 disables waiting.
17
+ */
18
+ export interface UCPPaymentAuthorizationWait {
19
+ timeoutMs: number;
20
+ intervalMs: number;
21
+ }
22
+ export declare const DEFAULT_UCP_PAYMENT_AUTHORIZATION_WAIT: UCPPaymentAuthorizationWait;
23
+ /**
24
+ * A payment handler that exists only for testing. Agents complete with its
25
+ * instruments, and the payment is placed through a real advertised handler
26
+ * with a substitute credential (e.g. a PSP test token), so suites that
27
+ * hardcode a mock handler still exercise real payments.
28
+ */
29
+ export interface UCPTestPaymentHandler {
30
+ /** The handler id agents send, e.g. the UCP conformance suite's `mock_payment_handler`. */
31
+ id: string;
32
+ /** Id of the advertised handler the payment is placed through, e.g. `stripe`. */
33
+ delegateHandlerId: string;
34
+ /**
35
+ * Maps the agent's credential to one the delegate handler accepts.
36
+ * Returning undefined declines the payment.
37
+ */
38
+ resolveCredential(credential: unknown): unknown;
39
+ }
40
+ /**
41
+ * Line items are checked against the combined stock of these fulfillment
42
+ * centers. Items without inventory records are treated as untracked.
43
+ */
44
+ export interface UCPInventoryOptions {
45
+ fulfillmentCenterKeys: string[];
46
+ }
47
+ /**
48
+ * A UCP checkout session is a mutable, progressively filled resource, while a
49
+ * reactionary checkout is a frozen snapshot of a finished cart. Sessions are
50
+ * therefore UCP-owned state over a reactionary cart: views are priced with a
51
+ * transient reactionary checkout that is discarded again, and only completion
52
+ * creates the real checkout.
53
+ */
54
+ export interface UCPCheckoutSessionContext {
55
+ client: ReactionaryUCPClient;
56
+ store: ReactionaryUCPSessionStore;
57
+ sessionId: string;
58
+ paymentHandlers: UCPPaymentHandlers;
59
+ placeholderEmail: string;
60
+ paymentAuthorizationWait: UCPPaymentAuthorizationWait;
61
+ /** The merchant's site URL, used to build order permalinks. */
62
+ merchantUrl?: string;
63
+ /** See ReactionaryUCPServerOptions.anonymousOrderEmail. */
64
+ anonymousOrderEmail?: string;
65
+ /** See ReactionaryUCPServerOptions.testPaymentHandlers. */
66
+ testPaymentHandlers?: UCPTestPaymentHandler[];
67
+ /** See ReactionaryUCPServerOptions.acceptRawCardCredentials. */
68
+ acceptRawCardCredentials?: boolean;
69
+ /** See ReactionaryUCPServerOptions.inventory. */
70
+ inventory?: UCPInventoryOptions;
71
+ /** The requesting platform's UCP-Agent profile URL. */
72
+ agentProfile?: string;
73
+ /** Called once when a checkout completes into a recorded order. */
74
+ onOrderPlaced?(orderId: string): void;
75
+ /** Email of the session's registered identity, if logged in. */
76
+ getIdentityEmail(): Promise<string | undefined>;
77
+ createCart(lineItems: UCPLineItem[]): Promise<Cart | UCPErrorResponse>;
78
+ reconcileCart(cartId: string, lineItems: UCPLineItem[]): Promise<UCPErrorResponse | undefined>;
79
+ }
80
+ export declare function createCheckoutSession(context: UCPCheckoutSessionContext, body: UCPCheckoutRequest): Promise<UCPCheckoutResponse>;
81
+ export declare function getCheckoutSession(context: UCPCheckoutSessionContext, checkoutSessionId: string): Promise<UCPCheckoutResponse>;
82
+ export declare function updateCheckoutSession(context: UCPCheckoutSessionContext, checkoutSessionId: string, body: UCPCheckoutRequest): Promise<UCPCheckoutResponse>;
83
+ export declare function cancelCheckoutSession(context: UCPCheckoutSessionContext, checkoutSessionId: string): Promise<UCPCheckoutResponse>;
84
+ /**
85
+ * Creates the real checkout from the session state and finalizes it. When the
86
+ * backend is not ready yet — typically a payment awaiting the payment service
87
+ * provider's asynchronous authorization — the session reports
88
+ * `complete_in_progress`, and a repeated complete retries finalization.
89
+ */
90
+ export declare function completeCheckoutSession(context: UCPCheckoutSessionContext, checkoutSessionId: string, body: UCPCheckoutRequest): Promise<UCPCheckoutResponse>;
91
+ export type { UCPPostalAddress };
@@ -1,7 +1,10 @@
1
- import type { Cache, Cart, Checkout, Client, FacetValueIdentifier, Order, Product, ProductSearchResult, RequestContext, Result } from '@reactionary/core';
1
+ import type { Cache, Cart, Checkout, Client, FacetValueIdentifier, Inventory, Order, Product, ProductSearchResult, Profile, RequestContext, Result, ShippingMethod } from '@reactionary/core';
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import type { components } from './ucp-shopping.openapi.js';
4
4
  import type { ReactionaryUCPIdentityOptions } from './reactionary-ucp-identity.js';
5
+ import type { ReactionaryUCPLocalizationOptions } from './reactionary-ucp-localization.js';
6
+ import type { ReactionaryUCPWebhookOptions } from './reactionary-ucp-webhooks.js';
7
+ import type { UCPInventoryOptions, UCPPaymentAuthorizationWait, UCPTestPaymentHandler } from './reactionary-ucp-checkout-session.js';
5
8
  type UCPService = components['schemas']['base'];
6
9
  type UCPCapability = components['schemas']['$defs-base'];
7
10
  export type UCPPaymentHandler = components['schemas']['entity'] & {
@@ -42,16 +45,27 @@ export type ReactionaryUCPClient = object & {
42
45
  deleteCart: UCPMethod<Client['cart']['deleteCart'], void>;
43
46
  getById: UCPMethod<Client['cart']['getById'], Cart>;
44
47
  remove: UCPMethod<Client['cart']['remove'], Cart>;
48
+ applyCouponCode?: UCPMethod<Client['cart']['applyCouponCode'], Cart>;
49
+ removeCouponCode?: UCPMethod<Client['cart']['removeCouponCode'], Cart>;
45
50
  };
46
51
  checkout?: {
47
52
  addPaymentInstruction: UCPMethod<Client['checkout']['addPaymentInstruction'], Checkout>;
48
53
  finalizeCheckout: UCPMethod<Client['checkout']['finalizeCheckout'], Checkout>;
49
54
  getById: UCPMethod<Client['checkout']['getById'], Checkout>;
50
55
  initiateCheckoutForCart: UCPMethod<Client['checkout']['initiateCheckoutForCart'], Checkout>;
56
+ getAvailableShippingMethods?: UCPMethod<Client['checkout']['getAvailableShippingMethods'], ShippingMethod[]>;
57
+ setShippingAddress?: UCPMethod<Client['checkout']['setShippingAddress'], Checkout>;
58
+ setShippingInstruction?: UCPMethod<Client['checkout']['setShippingInstruction'], Checkout>;
51
59
  };
52
60
  order?: {
53
61
  getById: UCPMethod<Client['order']['getById'], Order>;
54
62
  };
63
+ inventory?: {
64
+ getBySKU: UCPMethod<Client['inventory']['getBySKU'], Inventory>;
65
+ };
66
+ profile?: {
67
+ getById: UCPMethod<Client['profile']['getById'], Profile>;
68
+ };
55
69
  };
56
70
  export type ReactionaryUCPClientFactory<TClient extends ReactionaryUCPClient = ReactionaryUCPClient> = (requestContext: RequestContext) => TClient;
57
71
  export interface ReactionaryUCPServerOptions {
@@ -59,6 +73,84 @@ export interface ReactionaryUCPServerOptions {
59
73
  sessionTtlSeconds?: number;
60
74
  profile?: ReactionaryUCPProfileOptions;
61
75
  identity?: ReactionaryUCPIdentityOptions;
76
+ /**
77
+ * Maps buyer signals (UCP `context.address_country`, `Accept-Language`) to a
78
+ * language context. Without it, every request uses the client factory's
79
+ * initial context. The negotiated context sticks to the UCP session.
80
+ */
81
+ localization?: ReactionaryUCPLocalizationOptions;
82
+ /**
83
+ * Email used to price transient checkouts before the buyer has supplied
84
+ * one (backends require an email to quote shipping and tax). Never used for
85
+ * the real checkout created on completion. Defaults to
86
+ * `pending@checkout.invalid` — `.invalid` is a reserved, undeliverable TLD.
87
+ */
88
+ placeholderEmail?: string;
89
+ /**
90
+ * Email used to place REAL orders when the agent never supplied a buyer
91
+ * email and no identity is logged in. Unset (the default), such completions
92
+ * answer `incomplete` asking for `$.buyer.email`. Setting it lets anonymous
93
+ * agent checkouts complete, but the buyer cannot be sent a receipt, which
94
+ * may be illegal in some jurisdictions — the server logs a prominent
95
+ * warning while it is set. Intended for conformance and test environments.
96
+ */
97
+ anonymousOrderEmail?: string;
98
+ /**
99
+ * How long checkout completion waits for the placed checkout to become
100
+ * `readyForFinalization` (i.e. its payment authorized, e.g. by a PSP
101
+ * webhook) before answering `complete_in_progress`. Defaults to 10s
102
+ * timeout, polled every 1s.
103
+ */
104
+ paymentAuthorizationWait?: Partial<UCPPaymentAuthorizationWait>;
105
+ /**
106
+ * Payment handlers that exist only for testing, e.g. the UCP conformance
107
+ * suite's hardcoded `mock_payment_handler`. Each is accepted on checkout
108
+ * sessions without being advertised, and its payments are placed through
109
+ * its delegate — an advertised handler — with the credential it resolves.
110
+ * The server logs a prominent warning while any is set. Intended for
111
+ * conformance and test environments.
112
+ */
113
+ testPaymentHandlers?: UCPTestPaymentHandler[];
114
+ /**
115
+ * Accepts raw card credentials (a card number, e.g. the `pan` credential)
116
+ * and forwards them to the backend's payment integration, which persists
117
+ * payment data — putting the deployment in PCI DSS scope. Off by default:
118
+ * such completions answer `payment_failed`, and platforms are expected to
119
+ * submit delegated tokens (UCP: credentials are opaque tokens, not raw
120
+ * PANs). Credentials resolved by a test payment handler are checked too.
121
+ */
122
+ acceptRawCardCredentials?: boolean;
123
+ /**
124
+ * Checks checkout session line items against these fulfillment centers'
125
+ * combined stock, reporting `out_of_stock` per line item. Unset (the
126
+ * default), stock is left to the backend.
127
+ */
128
+ inventory?: UCPInventoryOptions;
129
+ /**
130
+ * Accepts PUT /orders/{id} with fulfillment events and adjustments for
131
+ * orders placed through a UCP checkout session, and reports them on the
132
+ * order — a stand-in for the business posting order updates, as the UCP
133
+ * conformance suite expects. Not part of the UCP specification; any agent
134
+ * that can read an order could rewrite its history. The server logs a
135
+ * prominent warning while it is set. Intended for conformance and test
136
+ * environments.
137
+ */
138
+ testOrderUpdates?: boolean;
139
+ /**
140
+ * Sends order events (created, and every update) to the webhook URL the
141
+ * platform declares in its profile, as the order capability requires.
142
+ * Unset (the default), no webhooks are sent and agent profiles are never
143
+ * fetched.
144
+ */
145
+ webhooks?: ReactionaryUCPWebhookOptions;
146
+ /**
147
+ * Enables POST /testing/simulate-shipping/{orderId}, guarded by this value
148
+ * in the Simulation-Secret header: records a shipment of the order and
149
+ * sends the update webhook. Not part of the UCP specification. The server
150
+ * logs a prominent warning while it is set. Intended for conformance and
151
+ * test environments.
152
+ */
153
+ testSimulationSecret?: string;
62
154
  }
63
155
  export interface ReactionaryUCPProfileOptions {
64
156
  endpoint: string;
@@ -0,0 +1,24 @@
1
+ import type { Cart } from '@reactionary/core';
2
+ import type { ReactionaryUCPClient } from './reactionary-ucp-common.js';
3
+ import { type UCPMessage } from './reactionary-ucp-mapping.js';
4
+ /** The discount codes a request submits, if it submits any (`[]` clears them). */
5
+ export declare function getRequestedDiscountCodes(body: Record<string, unknown>): string[] | undefined;
6
+ /**
7
+ * Applies a submitted code set to the cart with replacement semantics: codes
8
+ * no longer submitted are removed, new ones are applied. Codes match
9
+ * case-insensitively; a backend that matches exactly is also tried with the
10
+ * upper-cased code. Rejected codes are reported as warnings.
11
+ */
12
+ export declare function applyDiscountCodes(client: ReactionaryUCPClient, cart: Cart, codes: string[]): Promise<{
13
+ cart: Cart;
14
+ messages: UCPMessage[];
15
+ }>;
16
+ /**
17
+ * The discount extension's view of the cart's promotions. Amounts come from
18
+ * the promotion when the backend reports them; otherwise a sole promotion is
19
+ * attributed the cart's whole discount, with per-line allocations when it is
20
+ * allocated to lines only.
21
+ */
22
+ export declare function toUcpDiscounts(cart: Cart): {
23
+ discounts: Record<string, unknown>;
24
+ };
@@ -9,4 +9,6 @@ export declare function jsonResponse(body: unknown, options?: {
9
9
  }): Response;
10
10
  export declare function toWebRequest(request: IncomingMessage): Promise<Request>;
11
11
  export declare function sendWebResponse(response: ServerResponse, webResponse: Response): Promise<void>;
12
+ /** Constant-time string comparison. */
13
+ export declare function secureEquals(left: string, right: string): boolean;
12
14
  export {};
@@ -0,0 +1,35 @@
1
+ import { type LanguageContext, type Session } from '@reactionary/core';
2
+ /**
3
+ * Maps a buyer signal — the UCP `context.address_country` of a request payload,
4
+ * or an `Accept-Language` language tag — to a language context. Mirrors the
5
+ * mapping frontends maintain today, since neither the core store model nor the
6
+ * providers carry a locale/currency catalogue yet.
7
+ */
8
+ export interface ReactionaryUCPLocalizationRule {
9
+ /** ISO 3166-1 alpha-2 country code matched against `context.address_country`, case-insensitive. */
10
+ country?: string;
11
+ /** BCP 47 language tag matched against `Accept-Language` tags, case-insensitive; `da` matches `da-DK`. */
12
+ language?: string;
13
+ languageContext: LanguageContext;
14
+ }
15
+ export interface ReactionaryUCPLocalizationOptions {
16
+ rules: ReactionaryUCPLocalizationRule[];
17
+ /** Used when no rule matches. Without it, the server's initial context stays in effect. */
18
+ fallback?: LanguageContext;
19
+ }
20
+ /**
21
+ * Default Nordic-oriented mapping: Sweden → SEK, Norway → NOK, Denmark → DKK,
22
+ * Finland → EUR, and English-speaking buyers → USD. Country signals match
23
+ * first; the language rules catch buyers whose requests only carry
24
+ * Accept-Language.
25
+ */
26
+ export declare const DEFAULT_UCP_LOCALIZATION_RULES: ReactionaryUCPLocalizationRule[];
27
+ /**
28
+ * Resolves the language context for a request. The result is persisted in the
29
+ * session and reused for every later request of that session: backends fix a
30
+ * cart's currency and locale at creation, so renegotiating mid-session would
31
+ * detach the context from the carts and checkouts the session already created.
32
+ */
33
+ export declare function resolveLanguageContext(request: Request, session: Session, options: ReactionaryUCPLocalizationOptions | undefined): Promise<LanguageContext | undefined>;
34
+ /** Returns the accepted language tags, highest quality first, `*` excluded. */
35
+ export declare function parseAcceptLanguage(header: string | null): string[];