@reactionary/acp 0.10.42 → 0.10.44
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/README.md +52 -57
- package/index.js +11 -0
- package/lib/acp-discounts.js +113 -0
- package/lib/acp-idempotency.js +119 -0
- package/lib/acp-mcp.js +206 -0
- package/lib/acp-payment-handlers.js +38 -0
- package/lib/acp-schemas.js +240 -70
- package/lib/acp-webhooks.js +72 -0
- package/lib/reactionary-acp-server.js +1156 -253
- package/package.json +3 -3
- package/src/index.d.ts +4 -0
- package/src/lib/acp-discounts.d.ts +45 -0
- package/src/lib/acp-idempotency.d.ts +23 -0
- package/src/lib/acp-mcp.d.ts +21 -0
- package/src/lib/acp-payment-handlers.d.ts +64 -0
- package/src/lib/acp-schemas.d.ts +766 -209
- package/src/lib/acp-webhooks.d.ts +48 -0
- package/src/lib/reactionary-acp-server.d.ts +343 -39
package/README.md
CHANGED
|
@@ -8,51 +8,48 @@ The package keeps Reactionary core protocol-neutral: host applications mount thi
|
|
|
8
8
|
|
|
9
9
|
The adapter implements the merchant-hosted ACP checkout endpoints:
|
|
10
10
|
|
|
11
|
-
- `GET /product_feeds/{id}/products`
|
|
12
11
|
- `POST /checkout_sessions`
|
|
13
12
|
- `POST /checkout_sessions/{checkout_session_id}`
|
|
14
13
|
- `GET /checkout_sessions/{checkout_session_id}`
|
|
15
14
|
- `POST /checkout_sessions/{checkout_session_id}/complete`
|
|
16
15
|
- `POST /checkout_sessions/{checkout_session_id}/cancel`
|
|
17
16
|
|
|
17
|
+
and the cart capability:
|
|
18
|
+
|
|
19
|
+
- `POST /carts`
|
|
20
|
+
- `GET /carts/{id}`
|
|
21
|
+
- `PUT /carts/{id}` (full replacement of line items)
|
|
22
|
+
- `POST /carts/{id}/cancel`
|
|
23
|
+
|
|
24
|
+
Carts are pre-checkout baskets over a backend cart: no status (canceled or expired carts answer 404), no payment and no capability negotiation; totals are estimates. They expire after `checkoutSessionTtlSeconds`, refreshed on update, and are visible only to the agent that created them.
|
|
25
|
+
|
|
18
26
|
It also serves a readiness document from `GET` / `HEAD`.
|
|
19
27
|
|
|
20
28
|
If the adapter is mounted under `/acp`, both `/checkout_sessions/...` and `/acp/checkout_sessions/...` paths are understood. Set `basePath` when mounting somewhere else.
|
|
21
29
|
|
|
22
|
-
##
|
|
23
|
-
|
|
24
|
-
`GET /.well-known/acp.json` (alias `/.well-known/acp`) serves the ACP discovery document with `Cache-Control: public, max-age=3600`. It advertises the `checkout` service, plus `feeds` when `productFeed` is configured. `api_base_url` defaults to the request origin plus `basePath` (default `/acp`); override it and other fields with the `discovery` option. The host application must route the well-known path to this handler. Payment handlers are not part of ACP discovery; they are negotiated per checkout session.
|
|
30
|
+
## Protocol version
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
The adapter implements ACP API version `2026-04-17` only. Checkout requests must send `API-Version: 2026-04-17`; requests without it, or with another version, are rejected with `400` and code `missing_api_version` / `unsupported_api_version`, listing `supported_versions`.
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
## MCP transport
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
With `mcp: true`, the adapter also serves the ACP MCP binding at `{basePath}/mcp`: JSON-RPC 2.0 over Streamable HTTP (JSON responses) with `initialize`, `ping`, `tools/list` and `tools/call` for the five checkout tools (`create_`, `get_`, `update_`, `complete_`, `cancel_checkout_session`). Tool arguments are `{ meta, id?, payload? }`: `meta` maps to the protocol headers (`api_version` is required), `id` to the path and `payload` to the REST body; the MCP connection's `Authorization` applies to every call. Results are the REST bodies as-is (the ACP binding's mapping); errors are `-32000` with the ACP `Error` in `data`, `-32602` for malformed arguments. Tool calls without `meta.idempotency_key` get a fresh key. Discovery then lists `mcp` in `transports`.
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
## Authentication
|
|
33
39
|
|
|
34
|
-
|
|
35
|
-
- `search` — a `ProductSearchIdentifierSchema`-compatible search object passed to `productSearch.queryByTerm`.
|
|
36
|
-
- `productUrlBase` — either a base URL that the product slug is resolved against, or a URL template supporting `{lang}` and `{slug}` placeholders.
|
|
40
|
+
ACP requires agents to authenticate with `Authorization: Bearer <token>`. Configure `authenticate` with a function returning the calling agent (or `undefined` for 401); `createBearerTokenAuthenticator({ agentId: token })` covers static per-agent tokens, and request-signature checks can be added in the same hook. A checkout session is visible only to the agent that created it — others get 404. Discovery and the readiness document stay public. Without `authenticate` the checkout endpoints are open and a warning is logged.
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
## Idempotency
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
{
|
|
42
|
-
"target_country": "FI",
|
|
43
|
-
"products": []
|
|
44
|
-
}
|
|
45
|
-
```
|
|
44
|
+
Every checkout `POST` must carry an `Idempotency-Key` (at most 255 characters), scoped to the authenticated agent and endpoint. Repeating a request with the same key and a semantically equal JSON body returns the stored response with `Idempotent-Replayed: true` and no side effects; a different body answers `422 idempotency_conflict`, and a key still being processed `409 idempotency_in_flight` with `Retry-After`. Missing keys answer `400 idempotency_key_required`. Responses are kept for `idempotencyTtlSeconds` (at least 24 hours) in `sessionCache`; server errors are never stored. Concurrent requests are serialized per instance; across instances the in-flight marker in the shared cache is best effort, as the `Cache` interface has no atomic insert.
|
|
46
45
|
|
|
47
|
-
|
|
46
|
+
## Discovery
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
`GET /.well-known/acp.json` (alias `/.well-known/acp`) serves the ACP discovery document with `Cache-Control: public, max-age=3600`. It advertises protocol version `2026-04-17` and the `checkout` and `carts` services (the services enum is closed per version: `checkout`, `orders`, `delegate_payment`, `carts`), the `intervention_types` of the `interventions` option, and the extensions the server implements. `api_base_url` defaults to the request origin plus `basePath` (default `/acp`); override it and other fields with the `discovery` option. The host application must route the well-known path to this handler. Payment handlers are not part of ACP discovery; they are negotiated per checkout session.
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
curl "https://example.com/acp/product_feeds/default/products?format=jsonl" > products.jsonl
|
|
53
|
-
```
|
|
50
|
+
## Product feeds
|
|
54
51
|
|
|
55
|
-
|
|
52
|
+
ACP feeds are **pushed** by the merchant to the agent's Feed API (2026-04-17): the agent hosts `POST /feeds` and `GET`/`PATCH /feeds/{id}/products`, and never calls the merchant for feeds, so this server has no feed endpoints. Publish feeds from a scheduled job with the `@reactionary/feeds` CLI (`reactionary-feeds publish-acp`), configured with the Feed API URL, API key and feed ids from the agent's onboarding — see "Publishing ACP feeds to an agent" in the `@reactionary/feeds` README. `ReactionaryACPFeedPublisher` is re-exported here for programmatic use.
|
|
56
53
|
|
|
57
54
|
## Required Reactionary capabilities
|
|
58
55
|
|
|
@@ -67,15 +64,10 @@ Required operations:
|
|
|
67
64
|
- `checkout.getById`
|
|
68
65
|
- `checkout.setShippingAddress`
|
|
69
66
|
- `checkout.getAvailableShippingMethods`
|
|
70
|
-
- `checkout.getAvailablePaymentMethods`
|
|
71
67
|
- `checkout.setShippingInstruction`
|
|
72
68
|
- `checkout.addPaymentInstruction`
|
|
73
69
|
- `checkout.finalizeCheckout`
|
|
74
|
-
- `productSearch.queryByTerm`
|
|
75
70
|
- `product.getBySKU`
|
|
76
|
-
- `price.getListPrice`
|
|
77
|
-
- `price.getCustomerPrice`
|
|
78
|
-
- `inventory.getBySKU`
|
|
79
71
|
|
|
80
72
|
This is deliberate because Reactionary clients can be built with different capability sets. A partially capable client should fail fast instead of advertising ACP checkout.
|
|
81
73
|
|
|
@@ -122,40 +114,43 @@ new ReactionaryACPServer(createClient, {
|
|
|
122
114
|
sessionCache: redisCache,
|
|
123
115
|
sessionTtlSeconds: 60 * 60,
|
|
124
116
|
checkoutSessionTtlSeconds: 60 * 60,
|
|
125
|
-
productFeed: {
|
|
126
|
-
feeds: {
|
|
127
|
-
finnish: {
|
|
128
|
-
languageContext: {
|
|
129
|
-
locale: 'fi-FI',
|
|
130
|
-
currencyCode: 'EUR',
|
|
131
|
-
},
|
|
132
|
-
search: {
|
|
133
|
-
term: '',
|
|
134
|
-
facets: [],
|
|
135
|
-
filters: ['market:fi'],
|
|
136
|
-
paginationOptions: {
|
|
137
|
-
pageNumber: 1,
|
|
138
|
-
pageSize: 50,
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
productUrlBase: 'https://shop.example/{lang}/products/{slug}',
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
117
|
});
|
|
146
118
|
```
|
|
147
119
|
|
|
120
|
+
## Specification conformance
|
|
121
|
+
|
|
122
|
+
`acp-spec-conformance.spec.ts` validates every response shape the adapter produces — checkout sessions in each state (including `complete_in_progress`, `authentication_required`, declined, canceled and completed with order), errors, discovery, carts and webhook orders — against the official ACP 2026-04-17 JSON Schemas vendored in `src/lib/__fixtures__`, includes negative controls, and replays the specification's example requests. The ACP e2e suite validates the live backend's responses against the same schemas, and `@reactionary/feeds` validates feed products and metadata against the Feed schema.
|
|
123
|
+
|
|
148
124
|
## Mapping notes
|
|
149
125
|
|
|
150
|
-
- ACP item `id` maps to Reactionary `ProductVariantIdentifier.sku`.
|
|
126
|
+
- ACP item `id` maps to Reactionary `ProductVariantIdentifier.sku`. Requests send `line_items`; an item's optional `quantity` (decimal, default 1) is outside the 2026-04-17 `Item` schema but accepted as the checkout RFC sends it, and repeated ids add up.
|
|
127
|
+
- The buyer follows the 2026-04-17 `Buyer` schema (`email` required; names, phone, company, loyalty and tax exemption optional). Buyer updates are merged into what the session already holds.
|
|
128
|
+
- `fulfillment_details` (contact and nested address) is the shipping address; its email and phone stand in for the buyer's. `selected_fulfillment_options` picks the backend shipping method (one per checkout, so the first selection applies to all line items). `null` clears either field.
|
|
129
|
+
- Create requests must declare the agent's `capabilities`. Every session response returns the negotiated `capabilities`: `interventions.supported` is the intersection of the agent's declaration with the `interventions` server option (none by default), plus the seller's `required` interventions and `enforcement`. A required intervention the agent lacks, enforced `always`, blocks the session with an `intervention_required` message.
|
|
130
|
+
- Responses echo `Request-Id`, and `Idempotency-Key` on POSTs. When `discovery.supportedLocales` is set, requests are localized to the best match of their `Accept-Language` (exact tag, else same language); other locales are ignored.
|
|
131
|
+
- Sessions report what is still needed in `messages[]`: `missing` buyer email, shipping address or fulfillment option, an `invalid` (unavailable) fulfillment option, and — with the `inventory` option's fulfillment centers — `out_of_stock` line items. Each carries its JSONPath `param` and a `resolution`; any error keeps the session `not_ready_for_payment`, and completing such a session answers 400 with the first blocking message.
|
|
132
|
+
- Completion waits up to `paymentAuthorizationWait` for the backend to authorize the payment. If it is still pending, the session answers `complete_in_progress` without an order and with an info message; retrieving the session (or completing again with a new `Idempotency-Key`) reports `completed` with the order once the backend has it. `completed` always carries the order.
|
|
133
|
+
- Completed sessions carry `order { type, id, checkout_session_id, permalink_url }`; `permalink_url` comes from the `orderPermalinkUrl` template (`{orderId}` placeholder). Without it the permalink is omitted and a warning is logged.
|
|
134
|
+
- When the backend offers no fulfillment option for an address, the session reports `region_restricted` and cannot become payable — unless `requireFulfillment: false` (stores selling only goods that need no shipping).
|
|
135
|
+
- 3D Secure: with the `authentication` option, `getMetadata(...)` decides per completion whether issuer authentication is needed. If so, completion answers `authentication_required` with `authentication_metadata`; completing again without `authentication_result` is `400 requires_3ds`, an unsuccessful outcome is not authorized (`payment_declined` message), and a successful one is passed to the backend as protocol data `acp_authentication_result`. Updating the session drops a pending authentication.
|
|
136
|
+
- Discount extension: when the client supports `cart.applyCouponCode`/`removeCouponCode` and the agent declares `discount` in `capabilities.extensions`, sessions activate the extension. `discounts.codes` (or the deprecated `coupons`) replace the submitted codes (case-insensitive, `[]` clears); responses carry `discounts { codes, applied, rejected }` and a `discount_code_invalid` warning per rejected code. Discovery lists the `discount` extension.
|
|
137
|
+
- Affiliate attribution: `affiliate_attribution` (provider plus token or publisher id, flat metadata up to 20 keys/4KB) is accepted on create (first touch) and complete (last touch, first claim wins). Once the order exists, both are passed to the `onOrderAttribution` hook; they are never returned, sent in webhooks or logged.
|
|
138
|
+
- Marketing consent: the `marketingConsent` option lists channels offered on every open session (`marketing_consent_options`, with `is_subscribed` from `isSubscribed`). Decisions in the complete request's `marketing_consents` go to `onConsents` once the order exists — only offered channels with a resolvable contact (email: buyer, then fulfillment email; sms/whatsapp: phone). Omitted channels keep their state.
|
|
139
|
+
- Cancel accepts an optional `intent_trace` (reason code, summary up to 500 characters, flat metadata). Unknown reason codes count as `other`; the trace is kept on the session and passed to the `onIntentTrace` hook but never returned to agents.
|
|
140
|
+
- Fulfillment types: backend shipping methods are `shipping` options unless `classifyFulfillmentOption(method)` classifies them as `digital`, `pickup` (with `location`, `pickup_type`, `ready_by`, `pickup_by`) or `local_delivery` (with `delivery_window`, `service_area`); selections and order webhooks follow the classification.
|
|
141
|
+
- Sessions report `locale` and `timezone` (from the create request, else the request context locale), `created_at`, `updated_at`, and while open `expires_at` and — with the `continueUrl` template (`{checkoutSessionId}`) — `continue_url`.
|
|
142
|
+
- A declined payment answers the session (200) still `ready_for_payment`, with a `payment_declined` error message, so the agent can retry with another instrument; the declined backend checkout is discarded.
|
|
143
|
+
- The create request's `currency` sets the request context currency for the session's lifetime.
|
|
151
144
|
- ACP amounts are returned as integer minor units.
|
|
152
|
-
-
|
|
153
|
-
-
|
|
154
|
-
-
|
|
155
|
-
-
|
|
156
|
-
- Fulfillment options are sourced from `checkout.getAvailableShippingMethods`.
|
|
157
|
-
- Payment
|
|
145
|
+
- Line items report `item.id` (the SKU), `quantity`, `unit_amount` and a `totals[]` breakdown (`items_base_amount`, `discount`, `subtotal`, `total`), plus `name`, `description`, `images`, `product_id`, `sku` and `variant_options` from `product.getBySKU`. Per-line tax is not known to the cart and is not reported.
|
|
146
|
+
- Feed variant `price` comes from `price.getCustomerPrice` (active customer/global campaign prices, falling back to list prices in providers), `list_price` from `price.getListPrice` when it is higher, and availability from `inventory.getBySKU`.
|
|
147
|
+
- Completion takes `payment_data { handler_id, instrument { type, credential { type, token } }, billing_address? }`. The handler must be one advertised in `paymentHandlers`; the credential token is passed to the backend as payment-instruction protocol data `delegated_payment_token` (with `delegated_payment_provider` = the handler's PSP, plus `acp_payment_handler_id`, `acp_payment_instrument_type`, `acp_payment_credential_type`). `billing_address` becomes the checkout's billing address. Raw card credentials are refused unless `acceptRawCardCredentials` is set; purchase-order payments are not supported.
|
|
148
|
+
- The `links` option lists policy links (`terms_of_use`, `privacy_policy`, `return_policy`, `shipping_policy`, `contact_us`, `about_us`, `faq`, `support`, with an optional `title`) returned on every session.
|
|
149
|
+
- Fulfillment options are sourced from `checkout.getAvailableShippingMethods` as `shipping` options with a distinct `title`, the delivery estimate as `description`, the `carrier`, and the cost as `totals[]`.
|
|
150
|
+
- Payment handlers are configured with the `paymentHandlers` option and advertised in `capabilities.payment.handlers`; `createTokenizedCardHandler({ psp, merchantId })` builds the reference `dev.acp.tokenized.card` handler. Each handler maps to the backend payment method its payments are placed with (default: method `card`, name and processor = the handler's PSP). Handler configs must carry `merchant_id` and `psp`; without handlers, `capabilities.payment` is omitted.
|
|
158
151
|
|
|
159
152
|
Use `@reactionary/feeds` directly when you want Google Merchant, sitemap XML, or PriceRunner outputs from the same feed definitions.
|
|
160
153
|
|
|
161
|
-
|
|
154
|
+
## Order webhooks
|
|
155
|
+
|
|
156
|
+
With `webhooks: { endpoints: [{ url, secret, agentId? }] }`, orders placed through a checkout session are announced to the creating agent's receiver (`order_create`), and `server.notifyOrderUpdated(orderId)` sends `order_update` with the order's current state — call it when your OMS changes the order (ships, cancels). Events carry the full ACP `Order` (status, line items with ordered/current/fulfilled quantities, the shipping fulfillment, totals) and are signed with `Merchant-Signature: t=<unix>,v1=<HMAC-SHA256 hex of "t.body">` using the endpoint secret (`signWebhookPayload` builds the header). Failed deliveries are retried in memory (0.5s … 5m). Webhooks need the client's `order.getById`, and discovery then advertises the `orders` service.
|
package/index.js
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
1
|
export * from "./lib/acp-schemas.js";
|
|
2
2
|
export * from "./lib/reactionary-acp-server.js";
|
|
3
|
+
export * from "./lib/acp-payment-handlers.js";
|
|
4
|
+
import {
|
|
5
|
+
ReactionaryACPFeedPublisher,
|
|
6
|
+
createACPFeedPublisherFromConfig
|
|
7
|
+
} from "@reactionary/feeds";
|
|
8
|
+
export * from "./lib/acp-webhooks.js";
|
|
9
|
+
export * from "./lib/acp-discounts.js";
|
|
10
|
+
export {
|
|
11
|
+
ReactionaryACPFeedPublisher,
|
|
12
|
+
createACPFeedPublisherFromConfig
|
|
13
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
const ACP_DISCOUNT_EXTENSION = {
|
|
3
|
+
name: "discount",
|
|
4
|
+
extends: [
|
|
5
|
+
"$.CheckoutSessionCreateRequest.discounts",
|
|
6
|
+
"$.CheckoutSessionUpdateRequest.discounts",
|
|
7
|
+
"$.CheckoutSession.discounts"
|
|
8
|
+
]
|
|
9
|
+
};
|
|
10
|
+
const ACPDiscountsRequestSchema = z.object({
|
|
11
|
+
codes: z.array(z.string().min(1)).optional()
|
|
12
|
+
});
|
|
13
|
+
const ACPRejectedDiscountSchema = z.object({
|
|
14
|
+
code: z.string(),
|
|
15
|
+
reason: z.string(),
|
|
16
|
+
message: z.string().optional()
|
|
17
|
+
});
|
|
18
|
+
function getRequestedDiscountCodes(discounts, coupons) {
|
|
19
|
+
return discounts?.codes ?? coupons;
|
|
20
|
+
}
|
|
21
|
+
async function applyDiscountCodes(cartCapability, cart, codes) {
|
|
22
|
+
const { applyCouponCode, removeCouponCode } = cartCapability;
|
|
23
|
+
if (!applyCouponCode || !removeCouponCode) {
|
|
24
|
+
return { cart, rejected: codes.map(rejectedCode) };
|
|
25
|
+
}
|
|
26
|
+
let current = cart;
|
|
27
|
+
const requested = new Set(codes.map((code) => code.toUpperCase()));
|
|
28
|
+
for (const code of getAppliedCodes(current)) {
|
|
29
|
+
if (!requested.has(code.toUpperCase())) {
|
|
30
|
+
const removed = await cartCapability.removeCouponCode?.({ cart: current.identifier, couponCode: code });
|
|
31
|
+
if (removed?.success) {
|
|
32
|
+
current = removed.value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const rejected = [];
|
|
37
|
+
const seen = /* @__PURE__ */ new Set();
|
|
38
|
+
for (const code of codes) {
|
|
39
|
+
const normalized = code.toUpperCase();
|
|
40
|
+
if (seen.has(normalized)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
seen.add(normalized);
|
|
44
|
+
if (getAppliedCodes(current).some((candidate) => candidate.toUpperCase() === normalized)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const applied = await applyCode(cartCapability, current, code);
|
|
48
|
+
if (applied) {
|
|
49
|
+
current = applied;
|
|
50
|
+
} else {
|
|
51
|
+
rejected.push(rejectedCode(code));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { cart: current, rejected };
|
|
55
|
+
}
|
|
56
|
+
async function applyCode(cartCapability, cart, code) {
|
|
57
|
+
for (const candidate of /* @__PURE__ */ new Set([code, code.toUpperCase()])) {
|
|
58
|
+
const result = await cartCapability.applyCouponCode?.({ cart: cart.identifier, couponCode: candidate });
|
|
59
|
+
if (result?.success) {
|
|
60
|
+
return result.value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
function toACPDiscounts(cart, codes, rejected, toMinorUnits) {
|
|
66
|
+
const promotions = cart.appliedPromotions;
|
|
67
|
+
const lineDiscounts = cart.items.map((item) => Math.abs(toMinorUnits(item.price.totalDiscount)));
|
|
68
|
+
const itemsDiscount = lineDiscounts.reduce((sum, amount) => sum + amount, 0);
|
|
69
|
+
const totalDiscount = Math.max(Math.abs(toMinorUnits(cart.price.totalDiscount)), itemsDiscount);
|
|
70
|
+
const soleLineAllocated = promotions.length === 1 && totalDiscount === itemsDiscount && itemsDiscount > 0;
|
|
71
|
+
return {
|
|
72
|
+
codes,
|
|
73
|
+
applied: promotions.map((promotion, index) => {
|
|
74
|
+
const amount = promotion.amount ? Math.abs(toMinorUnits(promotion.amount)) : promotions.length === 1 ? totalDiscount : 0;
|
|
75
|
+
const name = promotion.name || promotion.code || "Discount";
|
|
76
|
+
return {
|
|
77
|
+
id: `discount_${index + 1}`,
|
|
78
|
+
...promotion.isCouponCode && promotion.code ? { code: promotion.code } : {},
|
|
79
|
+
coupon: {
|
|
80
|
+
id: promotion.code || `promotion_${index + 1}`,
|
|
81
|
+
name,
|
|
82
|
+
amount_off: amount,
|
|
83
|
+
currency: cart.price.grandTotal.currency.toLowerCase()
|
|
84
|
+
},
|
|
85
|
+
amount,
|
|
86
|
+
automatic: !promotion.isCouponCode,
|
|
87
|
+
...soleLineAllocated ? {
|
|
88
|
+
method: "each",
|
|
89
|
+
allocations: lineDiscounts.flatMap((lineAmount, lineIndex) => lineAmount > 0 ? [{ path: `$.line_items[${lineIndex}]`, amount: lineAmount }] : [])
|
|
90
|
+
} : {}
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
rejected
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function getAppliedCodes(cart) {
|
|
97
|
+
return cart.appliedPromotions.filter((promotion) => promotion.isCouponCode && promotion.code).map((promotion) => promotion.code);
|
|
98
|
+
}
|
|
99
|
+
function rejectedCode(code) {
|
|
100
|
+
return {
|
|
101
|
+
code,
|
|
102
|
+
reason: "discount_code_invalid",
|
|
103
|
+
message: `Discount code '${code}' could not be applied.`
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export {
|
|
107
|
+
ACPDiscountsRequestSchema,
|
|
108
|
+
ACPRejectedDiscountSchema,
|
|
109
|
+
ACP_DISCOUNT_EXTENSION,
|
|
110
|
+
applyDiscountCodes,
|
|
111
|
+
getRequestedDiscountCodes,
|
|
112
|
+
toACPDiscounts
|
|
113
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
const IDEMPOTENCY_CACHE_KEY_PREFIX = "reactionary:acp:idempotency";
|
|
4
|
+
const IDEMPOTENCY_KEY_MAX_LENGTH = 255;
|
|
5
|
+
const ACP_MIN_IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60;
|
|
6
|
+
const IN_FLIGHT_TTL_SECONDS = 60;
|
|
7
|
+
const RETRY_AFTER_SECONDS = 1;
|
|
8
|
+
const ACPIdempotencyRecordSchema = z.looseObject({
|
|
9
|
+
fingerprint: z.string(),
|
|
10
|
+
inFlight: z.boolean(),
|
|
11
|
+
status: z.number().optional(),
|
|
12
|
+
body: z.string().optional()
|
|
13
|
+
});
|
|
14
|
+
class ACPIdempotency {
|
|
15
|
+
constructor(cache, ttlSeconds) {
|
|
16
|
+
this.cache = cache;
|
|
17
|
+
this.ttlSeconds = ttlSeconds;
|
|
18
|
+
}
|
|
19
|
+
cache;
|
|
20
|
+
ttlSeconds;
|
|
21
|
+
/** Fingerprints of the requests in flight on this instance, by cache key. */
|
|
22
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
23
|
+
async run(request, scope, handle) {
|
|
24
|
+
const idempotencyKey = request.headers.get("idempotency-key");
|
|
25
|
+
if (!idempotencyKey) {
|
|
26
|
+
return idempotencyError(400, "idempotency_key_required", "Idempotency-Key header is required on all POST requests");
|
|
27
|
+
}
|
|
28
|
+
if (idempotencyKey.length > IDEMPOTENCY_KEY_MAX_LENGTH) {
|
|
29
|
+
return idempotencyError(400, "invalid", `Idempotency-Key must be at most ${IDEMPOTENCY_KEY_MAX_LENGTH} characters`);
|
|
30
|
+
}
|
|
31
|
+
const cacheKey = `${IDEMPOTENCY_CACHE_KEY_PREFIX}:${hash(`${scope}
|
|
32
|
+
${new URL(request.url).pathname}
|
|
33
|
+
${idempotencyKey}`)}`;
|
|
34
|
+
const fingerprint = await getBodyFingerprint(request);
|
|
35
|
+
const inFlightFingerprint = this.inFlight.get(cacheKey);
|
|
36
|
+
if (inFlightFingerprint !== void 0) {
|
|
37
|
+
return replay({ fingerprint: inFlightFingerprint, inFlight: true }, fingerprint);
|
|
38
|
+
}
|
|
39
|
+
this.inFlight.set(cacheKey, fingerprint);
|
|
40
|
+
try {
|
|
41
|
+
const existing = await this.cache.get(cacheKey, ACPIdempotencyRecordSchema);
|
|
42
|
+
if (existing) {
|
|
43
|
+
return replay(existing, fingerprint);
|
|
44
|
+
}
|
|
45
|
+
await this.put(cacheKey, { fingerprint, inFlight: true }, IN_FLIGHT_TTL_SECONDS);
|
|
46
|
+
const response = await handle();
|
|
47
|
+
if (response.status >= 500) {
|
|
48
|
+
await this.cache.invalidate([cacheKey]);
|
|
49
|
+
return response;
|
|
50
|
+
}
|
|
51
|
+
await this.put(cacheKey, {
|
|
52
|
+
fingerprint,
|
|
53
|
+
inFlight: false,
|
|
54
|
+
status: response.status,
|
|
55
|
+
body: await response.clone().text()
|
|
56
|
+
}, this.ttlSeconds);
|
|
57
|
+
return response;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
await this.cache.invalidate([cacheKey]);
|
|
60
|
+
throw error;
|
|
61
|
+
} finally {
|
|
62
|
+
this.inFlight.delete(cacheKey);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async put(cacheKey, record, ttlSeconds) {
|
|
66
|
+
await this.cache.invalidate([cacheKey]);
|
|
67
|
+
await this.cache.put(cacheKey, record, { ttlSeconds, dependencyIds: [cacheKey] });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function replay(record, fingerprint) {
|
|
71
|
+
if (record.fingerprint !== fingerprint) {
|
|
72
|
+
return idempotencyError(422, "idempotency_conflict", "Idempotency-Key has already been used with a different request body");
|
|
73
|
+
}
|
|
74
|
+
if (record.inFlight) {
|
|
75
|
+
return idempotencyError(
|
|
76
|
+
409,
|
|
77
|
+
"idempotency_in_flight",
|
|
78
|
+
"A request with this Idempotency-Key is currently being processed",
|
|
79
|
+
{ "retry-after": String(RETRY_AFTER_SECONDS) }
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return new Response(record.body ?? null, {
|
|
83
|
+
status: record.status ?? 200,
|
|
84
|
+
headers: {
|
|
85
|
+
"content-type": "application/json; charset=utf-8",
|
|
86
|
+
"idempotent-replayed": "true"
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function idempotencyError(status, code, message, headers = {}) {
|
|
91
|
+
return new Response(JSON.stringify({ type: "invalid_request", code, message }), {
|
|
92
|
+
status,
|
|
93
|
+
headers: { "content-type": "application/json; charset=utf-8", ...headers }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function getBodyFingerprint(request) {
|
|
97
|
+
const text = await request.clone().text();
|
|
98
|
+
try {
|
|
99
|
+
return hash(canonicalize(JSON.parse(text)));
|
|
100
|
+
} catch {
|
|
101
|
+
return hash(text);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function canonicalize(value) {
|
|
105
|
+
if (Array.isArray(value)) {
|
|
106
|
+
return `[${value.map(canonicalize).join(",")}]`;
|
|
107
|
+
}
|
|
108
|
+
if (typeof value === "object" && value !== null) {
|
|
109
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(Reflect.get(value, key))}`).join(",")}}`;
|
|
110
|
+
}
|
|
111
|
+
return JSON.stringify(value);
|
|
112
|
+
}
|
|
113
|
+
function hash(value) {
|
|
114
|
+
return createHash("sha256").update(value).digest("hex");
|
|
115
|
+
}
|
|
116
|
+
export {
|
|
117
|
+
ACPIdempotency,
|
|
118
|
+
ACP_MIN_IDEMPOTENCY_TTL_SECONDS
|
|
119
|
+
};
|
package/lib/acp-mcp.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import {
|
|
3
|
+
ACPCancelCheckoutSessionRequestSchema,
|
|
4
|
+
ACPCompleteCheckoutSessionRequestSchema,
|
|
5
|
+
ACPCreateCheckoutSessionRequestSchema,
|
|
6
|
+
ACPUpdateCheckoutSessionRequestSchema
|
|
7
|
+
} from "./acp-schemas.js";
|
|
8
|
+
const MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
9
|
+
const JSON_RPC_INVALID_REQUEST = -32600;
|
|
10
|
+
const JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
11
|
+
const JSON_RPC_INVALID_PARAMS = -32602;
|
|
12
|
+
const JSON_RPC_PARSE_ERROR = -32700;
|
|
13
|
+
const JSON_RPC_ACP_ERROR = -32e3;
|
|
14
|
+
const ACPMcpMetaSchema = z.looseObject({
|
|
15
|
+
api_version: z.string(),
|
|
16
|
+
idempotency_key: z.string().max(255).optional(),
|
|
17
|
+
request_id: z.string().optional(),
|
|
18
|
+
user_agent: z.string().optional(),
|
|
19
|
+
accept_language: z.string().optional(),
|
|
20
|
+
signature: z.string().optional(),
|
|
21
|
+
timestamp: z.string().optional()
|
|
22
|
+
});
|
|
23
|
+
const META_HEADERS = {
|
|
24
|
+
api_version: "api-version",
|
|
25
|
+
idempotency_key: "idempotency-key",
|
|
26
|
+
request_id: "request-id",
|
|
27
|
+
user_agent: "user-agent",
|
|
28
|
+
accept_language: "accept-language",
|
|
29
|
+
signature: "signature",
|
|
30
|
+
timestamp: "timestamp"
|
|
31
|
+
};
|
|
32
|
+
const ACP_MCP_TOOLS = [
|
|
33
|
+
{
|
|
34
|
+
name: "create_checkout_session",
|
|
35
|
+
description: "Create a checkout session from line items, currency and capabilities. Maps to POST /checkout_sessions.",
|
|
36
|
+
method: "POST",
|
|
37
|
+
path: () => "/checkout_sessions",
|
|
38
|
+
needsId: false,
|
|
39
|
+
payload: { schema: ACPCreateCheckoutSessionRequestSchema, required: true }
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "get_checkout_session",
|
|
43
|
+
description: "Retrieve the current state of a checkout session. Maps to GET /checkout_sessions/{id}.",
|
|
44
|
+
method: "GET",
|
|
45
|
+
path: (id) => `/checkout_sessions/${encodeURIComponent(id)}`,
|
|
46
|
+
needsId: true
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "update_checkout_session",
|
|
50
|
+
description: "Update items, fulfillment details or selected options. Maps to POST /checkout_sessions/{id}.",
|
|
51
|
+
method: "POST",
|
|
52
|
+
path: (id) => `/checkout_sessions/${encodeURIComponent(id)}`,
|
|
53
|
+
needsId: true,
|
|
54
|
+
payload: { schema: ACPUpdateCheckoutSessionRequestSchema, required: true }
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "complete_checkout_session",
|
|
58
|
+
description: "Submit payment and finalize the order. Maps to POST /checkout_sessions/{id}/complete.",
|
|
59
|
+
method: "POST",
|
|
60
|
+
path: (id) => `/checkout_sessions/${encodeURIComponent(id)}/complete`,
|
|
61
|
+
needsId: true,
|
|
62
|
+
payload: { schema: ACPCompleteCheckoutSessionRequestSchema, required: true }
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "cancel_checkout_session",
|
|
66
|
+
description: "Cancel a checkout session, optionally with an intent trace. Maps to POST /checkout_sessions/{id}/cancel.",
|
|
67
|
+
method: "POST",
|
|
68
|
+
path: (id) => `/checkout_sessions/${encodeURIComponent(id)}/cancel`,
|
|
69
|
+
needsId: true,
|
|
70
|
+
payload: { schema: ACPCancelCheckoutSessionRequestSchema, required: false }
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
const JsonRpcRequestSchema = z.looseObject({
|
|
74
|
+
jsonrpc: z.literal("2.0"),
|
|
75
|
+
id: z.union([z.string(), z.number(), z.null()]).optional(),
|
|
76
|
+
method: z.string(),
|
|
77
|
+
params: z.unknown().optional()
|
|
78
|
+
});
|
|
79
|
+
const ToolCallParamsSchema = z.looseObject({
|
|
80
|
+
name: z.string(),
|
|
81
|
+
arguments: z.looseObject({}).optional()
|
|
82
|
+
});
|
|
83
|
+
class ACPMcpEndpoint {
|
|
84
|
+
constructor(serverInfo, rest) {
|
|
85
|
+
this.serverInfo = serverInfo;
|
|
86
|
+
this.rest = rest;
|
|
87
|
+
}
|
|
88
|
+
serverInfo;
|
|
89
|
+
rest;
|
|
90
|
+
async handle(request, restBaseUrl) {
|
|
91
|
+
if (request.method !== "POST") {
|
|
92
|
+
return new Response(null, { status: 405, headers: { allow: "POST" } });
|
|
93
|
+
}
|
|
94
|
+
let body;
|
|
95
|
+
try {
|
|
96
|
+
body = await request.json();
|
|
97
|
+
} catch {
|
|
98
|
+
return jsonRpcResponse(null, { error: { code: JSON_RPC_PARSE_ERROR, message: "Parse error" } });
|
|
99
|
+
}
|
|
100
|
+
const message = JsonRpcRequestSchema.safeParse(body);
|
|
101
|
+
if (!message.success) {
|
|
102
|
+
return jsonRpcResponse(null, { error: { code: JSON_RPC_INVALID_REQUEST, message: "Invalid Request" } });
|
|
103
|
+
}
|
|
104
|
+
if (message.data.id === void 0) {
|
|
105
|
+
return new Response(null, { status: 202 });
|
|
106
|
+
}
|
|
107
|
+
const id = message.data.id;
|
|
108
|
+
switch (message.data.method) {
|
|
109
|
+
case "initialize":
|
|
110
|
+
return jsonRpcResponse(id, {
|
|
111
|
+
result: {
|
|
112
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
113
|
+
capabilities: { tools: {} },
|
|
114
|
+
serverInfo: this.serverInfo
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
case "ping":
|
|
118
|
+
return jsonRpcResponse(id, { result: {} });
|
|
119
|
+
case "tools/list":
|
|
120
|
+
return jsonRpcResponse(id, { result: { tools: ACP_MCP_TOOLS.map(toToolDefinition) } });
|
|
121
|
+
case "tools/call":
|
|
122
|
+
return jsonRpcResponse(id, await this.callTool(message.data.params, request, restBaseUrl));
|
|
123
|
+
default:
|
|
124
|
+
return jsonRpcResponse(id, { error: { code: JSON_RPC_METHOD_NOT_FOUND, message: `Method not found: ${message.data.method}` } });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async callTool(params, mcpRequest, restBaseUrl) {
|
|
128
|
+
const call = ToolCallParamsSchema.safeParse(params);
|
|
129
|
+
const tool = call.success ? ACP_MCP_TOOLS.find((candidate) => candidate.name === call.data.name) : void 0;
|
|
130
|
+
if (!call.success || !tool) {
|
|
131
|
+
return { error: { code: JSON_RPC_INVALID_PARAMS, message: `Unknown tool: ${call.success ? call.data.name : ""}` } };
|
|
132
|
+
}
|
|
133
|
+
const args = call.data.arguments ?? {};
|
|
134
|
+
const meta = ACPMcpMetaSchema.safeParse(args["meta"]);
|
|
135
|
+
const resourceId = args["id"];
|
|
136
|
+
const payload = args["payload"];
|
|
137
|
+
if (!meta.success) {
|
|
138
|
+
return { error: { code: JSON_RPC_INVALID_PARAMS, message: "meta.api_version is required" } };
|
|
139
|
+
}
|
|
140
|
+
if (tool.needsId && (typeof resourceId !== "string" || resourceId.length === 0)) {
|
|
141
|
+
return { error: { code: JSON_RPC_INVALID_PARAMS, message: "id is required" } };
|
|
142
|
+
}
|
|
143
|
+
if (tool.payload?.required && (typeof payload !== "object" || payload === null)) {
|
|
144
|
+
return { error: { code: JSON_RPC_INVALID_PARAMS, message: "payload is required" } };
|
|
145
|
+
}
|
|
146
|
+
const headers = new Headers({ accept: "application/json" });
|
|
147
|
+
const authorization = mcpRequest.headers.get("authorization");
|
|
148
|
+
if (authorization) {
|
|
149
|
+
headers.set("authorization", authorization);
|
|
150
|
+
}
|
|
151
|
+
for (const [field, header] of Object.entries(META_HEADERS)) {
|
|
152
|
+
const value = meta.data[field];
|
|
153
|
+
if (typeof value === "string") {
|
|
154
|
+
headers.set(header, value);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (tool.method === "POST" && !headers.has("idempotency-key")) {
|
|
158
|
+
headers.set("idempotency-key", crypto.randomUUID());
|
|
159
|
+
}
|
|
160
|
+
const hasBody = tool.method === "POST" && payload !== void 0;
|
|
161
|
+
if (hasBody) {
|
|
162
|
+
headers.set("content-type", "application/json");
|
|
163
|
+
}
|
|
164
|
+
const response = await this.rest(new Request(`${restBaseUrl}${tool.path(typeof resourceId === "string" ? resourceId : "")}`, {
|
|
165
|
+
method: tool.method,
|
|
166
|
+
headers,
|
|
167
|
+
...hasBody ? { body: JSON.stringify(payload) } : {}
|
|
168
|
+
}));
|
|
169
|
+
const text = await response.text();
|
|
170
|
+
const result = text ? JSON.parse(text) : {};
|
|
171
|
+
if (response.ok) {
|
|
172
|
+
return { result };
|
|
173
|
+
}
|
|
174
|
+
const message = typeof result === "object" && result !== null && typeof Reflect.get(result, "message") === "string" ? String(Reflect.get(result, "message")) : `HTTP ${response.status}`;
|
|
175
|
+
return { error: { code: JSON_RPC_ACP_ERROR, message, data: result } };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function toToolDefinition(tool) {
|
|
179
|
+
const properties = {
|
|
180
|
+
meta: z.toJSONSchema(ACPMcpMetaSchema, { io: "input", unrepresentable: "any" })
|
|
181
|
+
};
|
|
182
|
+
const required = ["meta"];
|
|
183
|
+
if (tool.needsId) {
|
|
184
|
+
properties["id"] = { type: "string", description: "Checkout session id" };
|
|
185
|
+
required.push("id");
|
|
186
|
+
}
|
|
187
|
+
if (tool.payload) {
|
|
188
|
+
properties["payload"] = z.toJSONSchema(tool.payload.schema, { io: "input", unrepresentable: "any" });
|
|
189
|
+
if (tool.payload.required) {
|
|
190
|
+
required.push("payload");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
name: tool.name,
|
|
195
|
+
description: tool.description,
|
|
196
|
+
inputSchema: { type: "object", properties, required }
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function jsonRpcResponse(id, outcome) {
|
|
200
|
+
return new Response(JSON.stringify({ jsonrpc: "2.0", id, ...outcome }), {
|
|
201
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
export {
|
|
205
|
+
ACPMcpEndpoint
|
|
206
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const TOKENIZED_CARD_VERSION = "2026-01-22";
|
|
2
|
+
function createTokenizedCardHandler(options) {
|
|
3
|
+
return {
|
|
4
|
+
handler: {
|
|
5
|
+
id: options.id ?? "card_tokenized",
|
|
6
|
+
name: "dev.acp.tokenized.card",
|
|
7
|
+
...options.displayName ? { display_name: options.displayName } : {},
|
|
8
|
+
version: TOKENIZED_CARD_VERSION,
|
|
9
|
+
spec: "https://acp.dev/handlers/tokenized.card",
|
|
10
|
+
requires_delegate_payment: true,
|
|
11
|
+
requires_pci_compliance: false,
|
|
12
|
+
psp: options.psp,
|
|
13
|
+
config_schema: "https://acp.dev/schemas/handlers/tokenized.card/config.json",
|
|
14
|
+
instrument_schemas: ["https://acp.dev/schemas/handlers/tokenized.card/instrument.json"],
|
|
15
|
+
config: {
|
|
16
|
+
...options.config,
|
|
17
|
+
merchant_id: options.merchantId,
|
|
18
|
+
psp: options.psp,
|
|
19
|
+
...options.acceptedBrands ? { accepted_brands: options.acceptedBrands } : {},
|
|
20
|
+
...options.acceptedFundingTypes ? { accepted_funding_types: options.acceptedFundingTypes } : {},
|
|
21
|
+
...options.supports3ds !== void 0 ? { supports_3ds: options.supports3ds } : {}
|
|
22
|
+
},
|
|
23
|
+
...options.displayOrder !== void 0 ? { display_order: options.displayOrder } : {}
|
|
24
|
+
},
|
|
25
|
+
...options.paymentMethod ? { paymentMethod: options.paymentMethod } : {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function getHandlerPaymentMethod(option) {
|
|
29
|
+
return option.paymentMethod ?? {
|
|
30
|
+
method: "card",
|
|
31
|
+
name: option.handler.psp,
|
|
32
|
+
paymentProcessor: option.handler.psp
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
createTokenizedCardHandler,
|
|
37
|
+
getHandlerPaymentMethod
|
|
38
|
+
};
|