@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
|
@@ -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.
|
|
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.
|
|
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[];
|