@reactionary/ucp 0.0.1

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 ADDED
@@ -0,0 +1,154 @@
1
+ # @reactionary/ucp
2
+
3
+ Framework shell for exposing Reactionary through a Universal Commerce Protocol (UCP) HTTP surface.
4
+
5
+ This package exposes a thin Universal Commerce Protocol (UCP) HTTP surface on top of an instantiated Reactionary client. It serves the UCP discovery profile and a canonical Shopping REST surface.
6
+
7
+ ## Design goals
8
+
9
+ - Keep Reactionary core protocol-neutral.
10
+ - Accept a Reactionary client factory instead of a singleton client.
11
+ - Create a fresh `RequestContext` per request.
12
+ - Persist only `RequestContext.session` between requests for the same UCP session.
13
+ - Let host applications mount the handler in Next.js, Node, Express, or another HTTP framework.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { ReactionaryUCPServer } from '@reactionary/ucp';
19
+
20
+ const ucp = new ReactionaryUCPServer(
21
+ (requestContext) => createReactionaryClient({ contextOverrides: requestContext }),
22
+ {
23
+ profile: {
24
+ endpoint: 'https://shop.example.com/ucp',
25
+ merchant: {
26
+ name: 'Example shop',
27
+ url: 'https://shop.example.com',
28
+ contact: {
29
+ email: 'support@example.com',
30
+ },
31
+ },
32
+ keys: [
33
+ // Public verification JWKs only. Never put private keys in the profile.
34
+ ],
35
+ },
36
+ },
37
+ );
38
+
39
+ export const POST = (request: Request) => ucp.fetch(request);
40
+ export const GET = (request: Request) => ucp.fetch(request);
41
+ ```
42
+
43
+ For Node HTTP:
44
+
45
+ ```ts
46
+ import { createServer } from 'node:http';
47
+ import { ReactionaryUCPServer } from '@reactionary/ucp';
48
+
49
+ const ucp = new ReactionaryUCPServer(createClient);
50
+ const handler = ucp.toNodeHandler();
51
+
52
+ createServer((request, response) => {
53
+ void handler(request, response);
54
+ }).listen(3000);
55
+ ```
56
+
57
+ ## Session handling
58
+
59
+ The server uses the `ucp-session-id` header. If the request does not include one, the server creates a new session id and returns it in the response.
60
+
61
+ Session state is stored through the Reactionary `Cache` interface. The default is `MemoryCache`, which is useful for local development only.
62
+
63
+ ```ts
64
+ new ReactionaryUCPServer(createClient, {
65
+ sessionCache: redisCache,
66
+ sessionTtlSeconds: 60 * 60,
67
+ });
68
+ ```
69
+
70
+ ## Current behavior
71
+
72
+ - `GET /.well-known/ucp` returns a UCP discovery profile for the official Shopping REST service.
73
+ - Canonical Shopping REST endpoints are available below the configured `profile.endpoint` path.
74
+ - `OPTIONS` returns allowed methods.
75
+ - Unmatched routes return `404 NOT_FOUND`.
76
+
77
+ ## UCP discovery profile
78
+
79
+ The official discovery profile is served from `/.well-known/ucp`. The profile advertises:
80
+
81
+ - `ucp.version: "2026-08-25"`
82
+ - the `dev.ucp.shopping` REST service endpoint
83
+ - capabilities derived from the configured Reactionary client
84
+ - configured merchant metadata
85
+ - configured public keys
86
+ - configured payment handlers, if supplied
87
+
88
+ Capability advertisement is intentionally derived from the instantiated client:
89
+
90
+ | Advertised capability | Required Reactionary capability |
91
+ | --- | --- |
92
+ | `dev.ucp.shopping.catalog.search` | `product-search` |
93
+ | `dev.ucp.shopping.catalog.lookup` | `product` |
94
+ | `dev.ucp.shopping.cart` | `cart` |
95
+ | `dev.ucp.shopping.discount` | `cart.applyCouponCode` |
96
+ | `dev.ucp.shopping.checkout` | `checkout` |
97
+ | `dev.ucp.shopping.order` | `order` |
98
+
99
+ ## Canonical Shopping REST endpoints
100
+
101
+ The Shopping REST request and response types are generated from the official OpenAPI document:
102
+
103
+ ```bash
104
+ npx --yes -p typescript@5.9.2 -p openapi-typescript@7.10.1 \
105
+ openapi-typescript https://ucp.dev/2026-08-25/services/shopping/rest.openapi.json \
106
+ -o packages/ucp/src/lib/ucp-shopping.openapi.ts
107
+ ```
108
+
109
+ The first REST implementation maps the endpoints that fit Reactionary's current provider model:
110
+
111
+ | Endpoint | Reactionary mapping |
112
+ | --- | --- |
113
+ | `POST /catalog/search` | `product-search.queryByTerm` |
114
+ | `POST /catalog/lookup` | `product.getById` / `product.getBySKU` |
115
+ | `POST /catalog/product` | `product.getById` / `product.getBySKU` |
116
+ | `POST /carts` | `cart.createCart` plus `cart.add` for submitted line items |
117
+ | `GET /carts/{id}` | `cart.getById` |
118
+ | `PUT /carts/{id}` | Composes `cart.getById`, `cart.remove`, `cart.changeQuantity`, and `cart.add` to reconcile line items by SKU |
119
+ | `POST /carts/{id}/cancel` | `cart.deleteCart` |
120
+ | `POST /checkout-sessions` | `checkout.initiateCheckoutForCart`; if `line_items` are supplied without `cart_id`, a cart is created first |
121
+ | `GET /checkout-sessions/{id}` | `checkout.getById` |
122
+ | `PUT /checkout-sessions/{id}` | Composes `checkout.getById` and `checkout.addPaymentInstruction` for selected delegated payment instruments |
123
+ | `POST /checkout-sessions/{id}/complete` | `checkout.finalizeCheckout` |
124
+ | `GET /orders/{id}` | `order.getById` |
125
+
126
+ `POST /catalog/search` treats the UCP `pagination.cursor` as an opaque offset encoded as a base-10 string. When present, that offset is converted to Reactionary's page-based `paginationOptions.pageNumber` using the requested `pagination.limit` as `pageSize`. Search responses include `pagination.cursor` only when another page is available; the returned cursor is the next page boundary offset.
127
+
128
+ UCP catalog search filters are mapped to Reactionary search inputs where possible:
129
+
130
+ - `filters.categories[0]` is treated as a breadcrumb string separated by `>` and resolved through `product-search.createCategoryNavigationFilter` when available. The resolved value is passed as Reactionary `categoryFilter`.
131
+ - Additional category values are ignored for now because Reactionary search has a single `categoryFilter`; the response includes a warning message when this happens.
132
+ - `filters.price` is ignored for now because Reactionary product search has no provider-neutral price filter; the response includes a warning message.
133
+ - Other extension filter keys are passed through as Reactionary string filters in `key:value` form. Array values produce one filter string per item.
134
+
135
+ `PUT /carts/{id}` is implemented as a best-effort reconciliation against the current authoritative cart. It fetches the cart, removes existing SKUs absent from the request, changes quantities for existing SKUs, adds new SKUs, and returns the latest cart from the final mutation.
136
+
137
+ `PUT /checkout-sessions/{id}` is implemented as a constrained composed update. Today it applies selected delegated payment instruments through `checkout.addPaymentInstruction` and returns the updated checkout. UCP fields that do not yet have a Reactionary checkout mutation, such as buyer/contact replacement or full payment-instruction replacement, are intentionally ignored rather than represented as successful core updates.
138
+
139
+ `POST /checkout-sessions/{id}/cancel` is routed but currently returns a structured UCP error because checkout cancellation is not represented by Reactionary's current core checkout capabilities.
140
+
141
+ REST requests may include:
142
+
143
+ - `Request-Id`: echoed in the response header.
144
+ - `Idempotency-Key`: supported for mutating REST endpoints and stored in the UCP session cache. Reusing a key with a different mutation payload returns `409`.
145
+
146
+ ## Request IDs and idempotency
147
+
148
+ `Request-Id` is optional and echoed in the response header so agents can correlate calls and responses.
149
+
150
+ Mutating REST endpoints also support an optional `Idempotency-Key` header. When a mutating REST request is called with an `Idempotency-Key`, the response is cached in the current UCP session storage. Repeating the same mutating request with the same key and payload in the same session replays the cached response instead of invoking the provider again.
151
+
152
+ Reusing the same idempotency key for a different mutating REST route or payload in the same session returns `409 idempotency_key_conflict`.
153
+
154
+ Idempotency records use the same TTL and cache backend as UCP session state. For the intended agent flow, a session is expected to be short-lived and contain a small number of requests.
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./lib/reactionary-ucp-server.js";
@@ -0,0 +1,19 @@
1
+ import { BaseCapability } from "@reactionary/core";
2
+ function hasCapability(client, name) {
3
+ return Boolean(getCapability(client, name));
4
+ }
5
+ function getCapability(client, name) {
6
+ const capability = Object.values(client).find((value) => value instanceof BaseCapability && getCapabilityResourceName(value) === name);
7
+ return capability instanceof BaseCapability ? capability : void 0;
8
+ }
9
+ function getCapabilityResourceName(capability) {
10
+ const getResourceName = Reflect.get(capability, "getResourceName");
11
+ if (typeof getResourceName !== "function") {
12
+ return "";
13
+ }
14
+ return Reflect.apply(getResourceName, capability, []);
15
+ }
16
+ export {
17
+ getCapability,
18
+ hasCapability
19
+ };
File without changes
@@ -0,0 +1,79 @@
1
+ const UCP_SESSION_ID_HEADER = "ucp-session-id";
2
+ function getSessionId(request) {
3
+ return request.headers.get(UCP_SESSION_ID_HEADER) ?? void 0;
4
+ }
5
+ function getOrCreateSessionId(request) {
6
+ return getSessionId(request) ?? crypto.randomUUID();
7
+ }
8
+ function jsonResponse(body, options = {}) {
9
+ const headers = new Headers(options.headers);
10
+ headers.set("content-type", "application/json; charset=utf-8");
11
+ return new Response(
12
+ options.omitBody ? null : JSON.stringify(body),
13
+ {
14
+ status: options.status ?? 200,
15
+ headers
16
+ }
17
+ );
18
+ }
19
+ async function toWebRequest(request) {
20
+ const headers = toWebHeaders(request.headers);
21
+ const url = new URL(
22
+ request.url ?? "/",
23
+ `http://${request.headers.host ?? "localhost"}`
24
+ );
25
+ const body = await readNodeRequestBody(request);
26
+ return new Request(url, {
27
+ method: request.method,
28
+ headers,
29
+ body
30
+ });
31
+ }
32
+ function toWebHeaders(headers) {
33
+ const webHeaders = new Headers();
34
+ for (const [key, value] of Object.entries(headers)) {
35
+ if (value === void 0) {
36
+ continue;
37
+ }
38
+ if (Array.isArray(value)) {
39
+ for (const entry of value) {
40
+ webHeaders.append(key, entry);
41
+ }
42
+ continue;
43
+ }
44
+ webHeaders.set(key, value);
45
+ }
46
+ return webHeaders;
47
+ }
48
+ async function readNodeRequestBody(request) {
49
+ if (request.method === "GET" || request.method === "HEAD") {
50
+ return null;
51
+ }
52
+ const chunks = [];
53
+ for await (const chunk of request) {
54
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
55
+ }
56
+ if (chunks.length === 0) {
57
+ return null;
58
+ }
59
+ return Buffer.concat(chunks);
60
+ }
61
+ async function sendWebResponse(response, webResponse) {
62
+ response.writeHead(
63
+ webResponse.status,
64
+ Object.fromEntries(webResponse.headers.entries())
65
+ );
66
+ if (!webResponse.body) {
67
+ response.end();
68
+ return;
69
+ }
70
+ const body = Buffer.from(await webResponse.arrayBuffer());
71
+ response.end(body);
72
+ }
73
+ export {
74
+ UCP_SESSION_ID_HEADER,
75
+ getOrCreateSessionId,
76
+ jsonResponse,
77
+ sendWebResponse,
78
+ toWebRequest
79
+ };
@@ -0,0 +1,72 @@
1
+ import { getCapability, hasCapability } from "./reactionary-ucp-capabilities.js";
2
+ function getRequestRoute(request, profile) {
3
+ const url = new URL(request.url);
4
+ const endpointPath = profile ? new URL(profile.endpoint).pathname.replace(/\/$/, "") : "";
5
+ if (endpointPath && url.pathname.startsWith(`${endpointPath}/`)) {
6
+ return {
7
+ path: url.pathname.slice(endpointPath.length) || "/"
8
+ };
9
+ }
10
+ return {
11
+ path: url.pathname
12
+ };
13
+ }
14
+ function createUCPProfile(client, profile) {
15
+ const endpoint = profile?.endpoint ?? "http://localhost/ucp";
16
+ return {
17
+ ucp: {
18
+ version: "2026-08-25",
19
+ services: profile?.services ?? {
20
+ "dev.ucp.shopping": [
21
+ {
22
+ version: "2026-08-25",
23
+ transport: "rest",
24
+ endpoint
25
+ }
26
+ ]
27
+ },
28
+ capabilities: profile?.capabilities ?? createUcpCapabilities(client),
29
+ ...profile?.paymentHandlers ? { payment_handlers: profile.paymentHandlers } : {}
30
+ },
31
+ keys: profile?.keys ?? [],
32
+ merchant: profile?.merchant ?? {
33
+ name: "Reactionary",
34
+ url: endpoint,
35
+ contact: {
36
+ email: "support@example.com"
37
+ }
38
+ }
39
+ };
40
+ }
41
+ function createUcpCapabilities(client) {
42
+ const capabilities = {};
43
+ if (hasCapability(client, "product-search")) {
44
+ capabilities["dev.ucp.shopping.catalog.search"] = [createUcpCapability()];
45
+ }
46
+ if (hasCapability(client, "product")) {
47
+ capabilities["dev.ucp.shopping.catalog.lookup"] = [createUcpCapability()];
48
+ }
49
+ if (hasCapability(client, "cart")) {
50
+ capabilities["dev.ucp.shopping.cart"] = [createUcpCapability()];
51
+ const cart = getCapability(client, "cart");
52
+ if (cart && typeof Reflect.get(cart, "applyCouponCode") === "function") {
53
+ capabilities["dev.ucp.shopping.discount"] = [createUcpCapability()];
54
+ }
55
+ }
56
+ if (hasCapability(client, "checkout")) {
57
+ capabilities["dev.ucp.shopping.checkout"] = [createUcpCapability()];
58
+ }
59
+ if (hasCapability(client, "order")) {
60
+ capabilities["dev.ucp.shopping.order"] = [createUcpCapability()];
61
+ }
62
+ return capabilities;
63
+ }
64
+ function createUcpCapability() {
65
+ return {
66
+ version: "2026-08-25"
67
+ };
68
+ }
69
+ export {
70
+ createUCPProfile,
71
+ getRequestRoute
72
+ };