ucpify 1.0.0

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.
@@ -0,0 +1,331 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UCPServer = void 0;
4
+ const uuid_1 = require("uuid");
5
+ const schema_1 = require("./schema");
6
+ // In-memory stores
7
+ const checkoutSessions = new Map();
8
+ const orders = new Map();
9
+ class UCPServer {
10
+ config;
11
+ itemsMap;
12
+ constructor(config) {
13
+ this.config = config;
14
+ this.itemsMap = new Map(config.items.map((item) => [item.id, item]));
15
+ }
16
+ // Generate UCP profile for /.well-known/ucp
17
+ getProfile() {
18
+ const paymentHandlers = {};
19
+ for (const handler of this.config.payment_handlers) {
20
+ paymentHandlers[handler.namespace] = [
21
+ {
22
+ id: handler.id,
23
+ version: schema_1.UCP_VERSION,
24
+ config: handler.config,
25
+ },
26
+ ];
27
+ }
28
+ return {
29
+ ucp: {
30
+ version: schema_1.UCP_VERSION,
31
+ services: {
32
+ 'dev.ucp.shopping': [
33
+ {
34
+ version: schema_1.UCP_VERSION,
35
+ spec: 'https://ucp.dev/specification/overview',
36
+ transport: 'rest',
37
+ endpoint: `${this.config.domain}/ucp/v1`,
38
+ schema: 'https://ucp.dev/services/shopping/rest.openapi.json',
39
+ },
40
+ ],
41
+ },
42
+ capabilities: {
43
+ 'dev.ucp.shopping.checkout': [
44
+ {
45
+ version: schema_1.UCP_VERSION,
46
+ spec: 'https://ucp.dev/specification/checkout',
47
+ schema: 'https://ucp.dev/schemas/shopping/checkout.json',
48
+ },
49
+ ],
50
+ 'dev.ucp.shopping.fulfillment': [
51
+ {
52
+ version: schema_1.UCP_VERSION,
53
+ spec: 'https://ucp.dev/specification/fulfillment',
54
+ schema: 'https://ucp.dev/schemas/shopping/fulfillment.json',
55
+ extends: 'dev.ucp.shopping.checkout',
56
+ },
57
+ ],
58
+ 'dev.ucp.shopping.order': [
59
+ {
60
+ version: schema_1.UCP_VERSION,
61
+ spec: 'https://ucp.dev/specification/order',
62
+ schema: 'https://ucp.dev/schemas/shopping/order.json',
63
+ },
64
+ ],
65
+ },
66
+ payment_handlers: paymentHandlers,
67
+ },
68
+ };
69
+ }
70
+ // Calculate totals for line items
71
+ calculateLineItemTotals(lineItems) {
72
+ const subtotal = lineItems.reduce((sum, li) => sum + li.item.price * li.quantity, 0);
73
+ const tax = Math.round(subtotal * this.config.tax_rate);
74
+ return [
75
+ { type: 'subtotal', amount: subtotal },
76
+ { type: 'tax', amount: tax },
77
+ { type: 'total', amount: subtotal + tax },
78
+ ];
79
+ }
80
+ // Validate checkout and generate messages
81
+ validateCheckout(session) {
82
+ const messages = [];
83
+ if (!session.buyer?.email) {
84
+ messages.push({
85
+ type: 'error',
86
+ code: 'missing',
87
+ path: '$.buyer.email',
88
+ content: 'Buyer email is required',
89
+ severity: 'recoverable',
90
+ });
91
+ }
92
+ if (!session.fulfillment?.methods?.[0]?.selected_destination_id) {
93
+ messages.push({
94
+ type: 'error',
95
+ code: 'missing',
96
+ path: '$.fulfillment.methods[0].selected_destination_id',
97
+ content: 'Shipping address is required',
98
+ severity: 'recoverable',
99
+ });
100
+ }
101
+ return messages;
102
+ }
103
+ // Create checkout session
104
+ createCheckout(data) {
105
+ const id = `chk_${(0, uuid_1.v4)().replace(/-/g, '').slice(0, 12)}`;
106
+ // Resolve items from catalog or use provided
107
+ const lineItems = data.line_items.map((li, idx) => {
108
+ const catalogItem = this.itemsMap.get(li.item.id);
109
+ const item = catalogItem || {
110
+ id: li.item.id,
111
+ title: li.item.title || 'Unknown Item',
112
+ price: li.item.price || 0,
113
+ };
114
+ const subtotal = item.price * li.quantity;
115
+ return {
116
+ id: li.id || `li_${idx + 1}`,
117
+ item,
118
+ quantity: li.quantity,
119
+ totals: [
120
+ { type: 'subtotal', amount: subtotal },
121
+ { type: 'total', amount: subtotal },
122
+ ],
123
+ };
124
+ });
125
+ const totals = this.calculateLineItemTotals(lineItems);
126
+ const messages = this.validateCheckout({ line_items: lineItems });
127
+ const paymentHandlers = {};
128
+ for (const handler of this.config.payment_handlers) {
129
+ paymentHandlers[handler.namespace] = [{ id: handler.id, version: schema_1.UCP_VERSION, config: handler.config }];
130
+ }
131
+ // Build links
132
+ const links = [];
133
+ if (this.config.terms_url) {
134
+ links.push({ type: 'terms_of_service', url: this.config.terms_url });
135
+ }
136
+ if (this.config.privacy_url) {
137
+ links.push({ type: 'privacy_policy', url: this.config.privacy_url });
138
+ }
139
+ // Set expiry (1 hour from now)
140
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
141
+ const session = {
142
+ ucp: {
143
+ version: schema_1.UCP_VERSION,
144
+ capabilities: {
145
+ 'dev.ucp.shopping.checkout': [{ version: schema_1.UCP_VERSION }],
146
+ },
147
+ payment_handlers: paymentHandlers,
148
+ },
149
+ id,
150
+ status: messages.length > 0 ? 'incomplete' : 'ready_for_complete',
151
+ messages: messages.length > 0 ? messages : undefined,
152
+ currency: this.config.currency,
153
+ line_items: lineItems,
154
+ totals,
155
+ links,
156
+ continue_url: `${this.config.domain}/checkout/${id}`,
157
+ expires_at: expiresAt,
158
+ };
159
+ checkoutSessions.set(id, session);
160
+ return session;
161
+ }
162
+ // Get checkout session
163
+ getCheckout(id) {
164
+ return checkoutSessions.get(id);
165
+ }
166
+ // Update checkout session
167
+ updateCheckout(id, data) {
168
+ const session = checkoutSessions.get(id);
169
+ if (!session)
170
+ return undefined;
171
+ // Update buyer
172
+ if (data.buyer) {
173
+ session.buyer = { ...session.buyer, ...data.buyer };
174
+ }
175
+ // Update line items
176
+ if (data.line_items) {
177
+ session.line_items = data.line_items.map((li, idx) => {
178
+ const catalogItem = this.itemsMap.get(li.item.id);
179
+ const item = catalogItem || {
180
+ id: li.item.id,
181
+ title: li.item.title || 'Unknown Item',
182
+ price: li.item.price || 0,
183
+ };
184
+ const subtotal = item.price * li.quantity;
185
+ return {
186
+ id: li.id || `li_${idx + 1}`,
187
+ item,
188
+ quantity: li.quantity,
189
+ totals: [
190
+ { type: 'subtotal', amount: subtotal },
191
+ { type: 'total', amount: subtotal },
192
+ ],
193
+ };
194
+ });
195
+ }
196
+ // Update fulfillment with shipping options
197
+ if (data.fulfillment) {
198
+ const methods = data.fulfillment.methods.map((method, mIdx) => {
199
+ const destinations = method.destinations.map((dest, dIdx) => ({
200
+ ...dest,
201
+ id: dest.id || `dest_${dIdx + 1}`,
202
+ }));
203
+ const groups = method.groups?.length
204
+ ? method.groups
205
+ : [
206
+ {
207
+ id: `group_${mIdx + 1}`,
208
+ line_item_ids: session.line_items.map((li) => li.id),
209
+ selected_option_id: this.config.shipping_options[0]?.id,
210
+ options: this.config.shipping_options.map((opt) => ({
211
+ id: opt.id,
212
+ title: opt.title,
213
+ description: opt.description || opt.estimated_days,
214
+ totals: [{ type: 'total', amount: opt.price }],
215
+ })),
216
+ },
217
+ ];
218
+ return {
219
+ id: method.id || `method_${mIdx + 1}`,
220
+ type: method.type,
221
+ line_item_ids: method.line_item_ids || session.line_items.map((li) => li.id),
222
+ selected_destination_id: method.selected_destination_id || destinations[0]?.id,
223
+ destinations,
224
+ groups,
225
+ };
226
+ });
227
+ session.fulfillment = { methods };
228
+ }
229
+ // Recalculate totals including shipping
230
+ let shippingTotal = 0;
231
+ if (session.fulfillment) {
232
+ for (const method of session.fulfillment.methods) {
233
+ for (const group of method.groups) {
234
+ const selectedOption = group.options.find((o) => o.id === group.selected_option_id);
235
+ if (selectedOption) {
236
+ const total = selectedOption.totals.find((t) => t.type === 'total');
237
+ shippingTotal += total?.amount || 0;
238
+ }
239
+ }
240
+ }
241
+ }
242
+ const subtotal = session.line_items.reduce((sum, li) => sum + li.item.price * li.quantity, 0);
243
+ const tax = Math.round(subtotal * this.config.tax_rate);
244
+ session.totals = [
245
+ { type: 'subtotal', amount: subtotal },
246
+ { type: 'shipping', amount: shippingTotal },
247
+ { type: 'tax', amount: tax },
248
+ { type: 'total', amount: subtotal + shippingTotal + tax },
249
+ ];
250
+ // Revalidate
251
+ const newMessages = this.validateCheckout(session);
252
+ session.messages = newMessages.length > 0 ? newMessages : undefined;
253
+ session.status = newMessages.length > 0 ? 'incomplete' : 'ready_for_complete';
254
+ checkoutSessions.set(id, session);
255
+ return session;
256
+ }
257
+ // Complete checkout (create order)
258
+ async completeCheckout(id) {
259
+ const session = checkoutSessions.get(id);
260
+ if (!session)
261
+ return { error: 'Checkout session not found' };
262
+ if (session.status !== 'ready_for_complete') {
263
+ return { error: 'Checkout is not ready for completion', messages: session.messages };
264
+ }
265
+ const orderId = `order_${(0, uuid_1.v4)().replace(/-/g, '').slice(0, 12)}`;
266
+ const order = {
267
+ ucp: {
268
+ version: schema_1.UCP_VERSION,
269
+ capabilities: {
270
+ 'dev.ucp.shopping.order': [{ version: schema_1.UCP_VERSION }],
271
+ },
272
+ },
273
+ id: orderId,
274
+ checkout_id: id,
275
+ permalink_url: `${this.config.domain}/orders/${orderId}`,
276
+ line_items: session.line_items,
277
+ buyer: session.buyer,
278
+ totals: session.totals,
279
+ fulfillment: session.fulfillment
280
+ ? {
281
+ expectations: session.fulfillment.methods.map((m, idx) => ({
282
+ id: `exp_${idx + 1}`,
283
+ line_items: m.line_item_ids.map((liId) => {
284
+ const li = session.line_items.find((l) => l.id === liId);
285
+ return { id: liId, quantity: li?.quantity || 1 };
286
+ }),
287
+ method_type: m.type,
288
+ destination: m.destinations.find((d) => d.id === m.selected_destination_id),
289
+ description: 'Arrives in 5-7 business days',
290
+ fulfillable_on: 'now',
291
+ })),
292
+ events: [],
293
+ }
294
+ : undefined,
295
+ created_at: new Date().toISOString(),
296
+ };
297
+ orders.set(orderId, order);
298
+ // Update session to completed state with order confirmation
299
+ session.status = 'completed';
300
+ session.order = {
301
+ id: orderId,
302
+ permalink_url: order.permalink_url,
303
+ };
304
+ delete session.continue_url;
305
+ delete session.messages;
306
+ checkoutSessions.set(id, session);
307
+ return session;
308
+ }
309
+ // Cancel checkout
310
+ cancelCheckout(id) {
311
+ const session = checkoutSessions.get(id);
312
+ if (!session)
313
+ return undefined;
314
+ session.status = 'canceled';
315
+ delete session.continue_url;
316
+ checkoutSessions.set(id, session);
317
+ return session;
318
+ }
319
+ // Get order
320
+ getOrder(id) {
321
+ return orders.get(id);
322
+ }
323
+ // List orders
324
+ listOrders() {
325
+ return Array.from(orders.values());
326
+ }
327
+ getConfig() {
328
+ return this.config;
329
+ }
330
+ }
331
+ exports.UCPServer = UCPServer;
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "My Store",
3
+ "domain": "http://localhost:3000",
4
+ "currency": "USD",
5
+ "terms_url": "https://example.com/terms",
6
+ "privacy_url": "https://example.com/privacy",
7
+ "tax_rate": 0.08,
8
+ "port": 3000,
9
+ "items": [
10
+ {
11
+ "id": "item_001",
12
+ "title": "Classic T-Shirt",
13
+ "description": "A comfortable cotton t-shirt",
14
+ "price": 2500,
15
+ "sku": "TSH-001"
16
+ },
17
+ {
18
+ "id": "item_002",
19
+ "title": "Premium Hoodie",
20
+ "description": "Warm and stylish hoodie",
21
+ "price": 5999,
22
+ "sku": "HOO-001"
23
+ }
24
+ ],
25
+ "shipping_options": [
26
+ {
27
+ "id": "standard",
28
+ "title": "Standard Shipping",
29
+ "description": "Arrives in 5-7 business days",
30
+ "price": 500,
31
+ "estimated_days": "5-7 business days"
32
+ },
33
+ {
34
+ "id": "express",
35
+ "title": "Express Shipping",
36
+ "description": "Arrives in 2-3 business days",
37
+ "price": 1500,
38
+ "estimated_days": "2-3 business days"
39
+ },
40
+ {
41
+ "id": "overnight",
42
+ "title": "Overnight Shipping",
43
+ "description": "Arrives next business day",
44
+ "price": 2999,
45
+ "estimated_days": "1 business day"
46
+ }
47
+ ],
48
+ "payment_handlers": [
49
+ {
50
+ "namespace": "com.stripe",
51
+ "id": "stripe_handler",
52
+ "config": {
53
+ "publishable_key": "pk_test_..."
54
+ }
55
+ },
56
+ {
57
+ "namespace": "com.paypal",
58
+ "id": "paypal_handler",
59
+ "config": {
60
+ "client_id": "your_paypal_client_id"
61
+ }
62
+ }
63
+ ]
64
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "ucpify",
3
+ "version": "1.0.0",
4
+ "description": "Generate UCP-compliant servers for merchants from a simple schema",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "bin": {
8
+ "ucpify": "dist/cli.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/cli.js",
13
+ "dev": "ts-node src/cli.ts",
14
+ "init": "ts-node src/cli.ts init",
15
+ "serve": "ts-node src/cli.ts serve",
16
+ "validate": "ts-node src/cli.ts validate",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "keywords": [
21
+ "ucp",
22
+ "commerce",
23
+ "checkout",
24
+ "agentic",
25
+ "ai-commerce"
26
+ ],
27
+ "author": "",
28
+ "license": "MIT",
29
+ "type": "commonjs",
30
+ "dependencies": {
31
+ "@paypal/paypal-server-sdk": "^2.2.0",
32
+ "better-sqlite3": "^12.6.2",
33
+ "commander": "^14.0.2",
34
+ "dotenv": "^17.2.3",
35
+ "express": "^5.2.1",
36
+ "express-rate-limit": "^8.2.1",
37
+ "pino": "^10.3.0",
38
+ "stripe": "^20.3.0",
39
+ "uuid": "^13.0.0",
40
+ "zod": "^4.3.6"
41
+ },
42
+ "devDependencies": {
43
+ "@types/better-sqlite3": "^7.6.13",
44
+ "@types/express": "^5.0.6",
45
+ "@types/node": "^25.1.0",
46
+ "@types/supertest": "^6.0.3",
47
+ "@types/uuid": "^10.0.0",
48
+ "supertest": "^7.2.2",
49
+ "ts-node": "^10.9.2",
50
+ "typescript": "^5.9.3",
51
+ "vitest": "^4.0.18"
52
+ }
53
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import request from 'supertest';
3
+ import { createExpressApp } from '../express-app';
4
+ import { MerchantConfig } from '../schema';
5
+
6
+ const testConfig: MerchantConfig = {
7
+ name: 'Test Store',
8
+ domain: 'https://test.example.com',
9
+ currency: 'USD',
10
+ port: 3000,
11
+ items: [
12
+ { id: 'item-1', title: 'Test Item', price: 1000 },
13
+ { id: 'item-2', title: 'Another Item', price: 2500 },
14
+ ],
15
+ shipping_options: [
16
+ { id: 'standard', title: 'Standard Shipping', price: 500, estimated_days: '5-7 days' },
17
+ ],
18
+ payment_handlers: [
19
+ { namespace: 'com.stripe', id: 'stripe-test' },
20
+ ],
21
+ tax_rate: 0.08,
22
+ };
23
+
24
+ describe('UCP Server API', () => {
25
+ const app = createExpressApp(testConfig, { useDb: false });
26
+
27
+ describe('GET /.well-known/ucp', () => {
28
+ it('returns UCP profile', async () => {
29
+ const res = await request(app).get('/.well-known/ucp');
30
+ expect(res.status).toBe(200);
31
+ expect(res.body.ucp.version).toBe('2026-01-11');
32
+ expect(res.body.ucp.services['dev.ucp.shopping']).toBeDefined();
33
+ });
34
+ });
35
+
36
+ describe('GET /health', () => {
37
+ it('returns health status', async () => {
38
+ const res = await request(app).get('/health');
39
+ expect(res.status).toBe(200);
40
+ expect(res.body.status).toBeDefined();
41
+ });
42
+ });
43
+
44
+ describe('POST /ucp/v1/checkout-sessions', () => {
45
+ it('creates a checkout session', async () => {
46
+ const res = await request(app)
47
+ .post('/ucp/v1/checkout-sessions')
48
+ .send({
49
+ line_items: [{ item: { id: 'item-1' }, quantity: 2 }],
50
+ });
51
+ expect(res.status).toBe(201);
52
+ expect(res.body.id).toBeDefined();
53
+ expect(res.body.status).toBe('incomplete');
54
+ expect(res.body.line_items).toHaveLength(1);
55
+ expect(res.body.line_items[0].quantity).toBe(2);
56
+ });
57
+
58
+ it('rejects invalid requests', async () => {
59
+ const res = await request(app)
60
+ .post('/ucp/v1/checkout-sessions')
61
+ .send({ line_items: [] }); // Empty array
62
+ expect(res.status).toBe(400);
63
+ expect(res.body.error).toBe('Validation failed');
64
+ });
65
+
66
+ it('rejects excessive quantity', async () => {
67
+ const res = await request(app)
68
+ .post('/ucp/v1/checkout-sessions')
69
+ .send({
70
+ line_items: [{ item: { id: 'item-1' }, quantity: 999 }],
71
+ });
72
+ expect(res.status).toBe(400);
73
+ });
74
+ });
75
+
76
+ describe('Checkout flow', () => {
77
+ let checkoutId: string;
78
+
79
+ it('creates checkout', async () => {
80
+ const res = await request(app)
81
+ .post('/ucp/v1/checkout-sessions')
82
+ .send({
83
+ line_items: [{ item: { id: 'item-1' }, quantity: 1 }],
84
+ });
85
+ checkoutId = res.body.id;
86
+ expect(res.body.status).toBe('incomplete');
87
+ });
88
+
89
+ it('updates with buyer and fulfillment', async () => {
90
+ const res = await request(app)
91
+ .put(`/ucp/v1/checkout-sessions/${checkoutId}`)
92
+ .send({
93
+ buyer: { email: 'test@example.com' },
94
+ fulfillment: {
95
+ methods: [{
96
+ type: 'shipping',
97
+ destinations: [{
98
+ street_address: '123 Main St',
99
+ address_locality: 'Anytown',
100
+ address_region: 'CA',
101
+ postal_code: '12345',
102
+ address_country: 'US',
103
+ }],
104
+ }],
105
+ },
106
+ });
107
+ expect(res.status).toBe(200);
108
+ expect(res.body.status).toBe('ready_for_complete');
109
+ });
110
+
111
+ it('completes checkout', async () => {
112
+ const res = await request(app)
113
+ .post(`/ucp/v1/checkout-sessions/${checkoutId}/complete`);
114
+ expect(res.status).toBe(201);
115
+ expect(res.body.status).toBe('completed');
116
+ expect(res.body.order).toBeDefined();
117
+ expect(res.body.order.id).toMatch(/^order_/);
118
+ });
119
+ });
120
+
121
+ describe('GET /ucp/v1/items', () => {
122
+ it('returns product catalog', async () => {
123
+ const res = await request(app).get('/ucp/v1/items');
124
+ expect(res.status).toBe(200);
125
+ expect(res.body).toHaveLength(2);
126
+ expect(res.body[0].id).toBe('item-1');
127
+ });
128
+ });
129
+ });