@commercelayer/agent-cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +98 -2
- package/dist/api/client.js +39 -0
- package/dist/api/server.js +26 -0
- package/dist/api/stub/db.js +37 -0
- package/dist/api/stub/http.js +18 -0
- package/dist/api/stub/index.js +27 -0
- package/dist/api/stub/management.js +86 -0
- package/dist/api/stub/shopping.js +543 -0
- package/dist/api/stub/ucp.js +350 -0
- package/dist/api/transport.js +37 -0
- package/dist/api/types.js +4 -0
- package/dist/bin.js +83 -0
- package/dist/commands.js +201 -0
- package/dist/files.js +47 -0
- package/dist/output.js +18 -0
- package/dist/products.js +128 -0
- package/dist/store.js +172 -0
- package/dist/storefront.js +73 -0
- package/dist/ucp/buy.js +88 -0
- package/dist/ucp/check.js +355 -0
- package/dist/ucp/client.js +129 -0
- package/dist/ucp/platform.js +90 -0
- package/dist/ucp/signing.js +67 -0
- package/package.json +43 -4
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Everything a store host serves: the UCP profile and REST service, the OAuth
|
|
2
|
+
// surface for identity linking, the permalink, the policy and continue pages,
|
|
3
|
+
// and the published storefront. This is the spec of the Worker behind
|
|
4
|
+
// *.sandbox.commercelayer.store.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { agentProfile, AGENT_PROFILE_URL, capabilityEntry, EXTENSIONS, SANDBOX_HANDLER, SANDBOX_HANDLER_ID, SANDBOX_HANDLER_SCHEMA, SANDBOX_HANDLER_SPEC, SHOPPING_CAPABILITIES, UCP, UCP_VERSION } from '../../ucp/platform.js';
|
|
7
|
+
import { contentDigest, signHeaders } from '../../ucp/signing.js';
|
|
8
|
+
import { strip } from './db.js';
|
|
9
|
+
import { json } from './http.js';
|
|
10
|
+
import * as shop from './shopping.js';
|
|
11
|
+
const SCOPES = ['dev.ucp.shopping.order:read', 'dev.ucp.shopping.order:manage'];
|
|
12
|
+
const OUR_CAPABILITIES = [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking', 'dev.ucp.shopping.permalink'];
|
|
13
|
+
// ---------- profile
|
|
14
|
+
export function profile(r, origin) {
|
|
15
|
+
const service = (schema) => [{ version: UCP_VERSION, spec: `${UCP}/specification/overview`, transport: 'rest', schema: `${UCP}/services/${schema}/rest.openapi.json`, endpoint: `${origin}/ucp/v1` }];
|
|
16
|
+
const capabilities = {};
|
|
17
|
+
for (const name of OUR_CAPABILITIES) {
|
|
18
|
+
const extra = {};
|
|
19
|
+
if (name === 'dev.ucp.common.identity_linking')
|
|
20
|
+
extra.config = { scopes: Object.fromEntries(SCOPES.map((s) => [s, { description: { plain: s.endsWith(':read') ? 'View your orders and their status.' : 'Cancel or return your orders.' } }])) };
|
|
21
|
+
if (name === 'dev.ucp.shopping.permalink')
|
|
22
|
+
extra.config = { endpoint: `${origin}/buy` };
|
|
23
|
+
if (name === 'dev.ucp.shopping.fulfillment')
|
|
24
|
+
extra.config = { method_combinations: [['shipping']] };
|
|
25
|
+
capabilities[name] = [capabilityEntry(name, extra)];
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
ucp: {
|
|
29
|
+
version: UCP_VERSION,
|
|
30
|
+
services: { 'dev.ucp.shopping': service('shopping'), 'dev.ucp.common': service('common') },
|
|
31
|
+
capabilities,
|
|
32
|
+
payment_handlers: {
|
|
33
|
+
[SANDBOX_HANDLER]: [{ id: SANDBOX_HANDLER_ID, version: UCP_VERSION, spec: SANDBOX_HANDLER_SPEC, schema: SANDBOX_HANDLER_SCHEMA, available_instruments: [{ type: 'card' }], config: { environment: 'sandbox' } }],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
keys: [r.ucp.public_jwk],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// ---------- negotiation
|
|
40
|
+
/** What the platform behind a UCP-Agent URL supports. The stub knows this CLI's profile; any other HTTPS platform is taken at face value. */
|
|
41
|
+
function negotiate(platformUrl) {
|
|
42
|
+
let theirs = null;
|
|
43
|
+
if (platformUrl === AGENT_PROFILE_URL)
|
|
44
|
+
theirs = agentProfile();
|
|
45
|
+
const ours = new Set(OUR_CAPABILITIES);
|
|
46
|
+
if (!theirs)
|
|
47
|
+
return { shared: ours, webhookUrl: null };
|
|
48
|
+
const shared = new Set();
|
|
49
|
+
const caps = theirs.ucp.capabilities ?? {};
|
|
50
|
+
for (const name of ours)
|
|
51
|
+
if ((caps[name] ?? []).some((e) => e.version === UCP_VERSION))
|
|
52
|
+
shared.add(name);
|
|
53
|
+
let changed = true;
|
|
54
|
+
while (changed) {
|
|
55
|
+
changed = false;
|
|
56
|
+
for (const [ext, parents] of Object.entries(EXTENSIONS)) {
|
|
57
|
+
if (shared.has(ext) && !parents.some((p) => shared.has(p))) {
|
|
58
|
+
shared.delete(ext);
|
|
59
|
+
changed = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const webhookUrl = caps['dev.ucp.shopping.order']?.[0]?.config?.webhook_url ?? null;
|
|
64
|
+
return { shared, webhookUrl };
|
|
65
|
+
}
|
|
66
|
+
// ---------- the REST service
|
|
67
|
+
const protocolError = (status, code, content, headers = {}) => json(status, { code, content }, headers);
|
|
68
|
+
function withUcpHeaders(r, res) {
|
|
69
|
+
const digest = contentDigest(res.body);
|
|
70
|
+
const values = { '@status': String(res.status), 'content-digest': digest, 'content-type': res.headers['content-type'] ?? 'application/json' };
|
|
71
|
+
const sig = signHeaders('sig1', values, { components: ['@status', 'content-digest', 'content-type'], keyid: r.ucp.kid, created: Math.floor(Date.now() / 1000) }, r.ucp.private_jwk);
|
|
72
|
+
return { ...res, headers: { ...res.headers, 'content-digest': digest, ...sig } };
|
|
73
|
+
}
|
|
74
|
+
function handleService(req, r, origin, path) {
|
|
75
|
+
// 1. Who is calling: UCP-Agent names the platform profile; Request-Id is required.
|
|
76
|
+
const agent = req.headers['ucp-agent']?.match(/profile="(https:\/\/[^"]+)"/);
|
|
77
|
+
if (!agent)
|
|
78
|
+
return protocolError(400, 'invalid_profile_url', 'UCP-Agent must be `profile="https://..."`, an HTTPS URL of the platform profile.');
|
|
79
|
+
if (!req.headers['request-id'])
|
|
80
|
+
return protocolError(400, 'missing_request_id', 'Request-Id is required on every request.');
|
|
81
|
+
const { shared, webhookUrl } = negotiate(agent[1]);
|
|
82
|
+
const ctx = { record: r, origin, platform: agent[1], shared, webhookUrl, bearer: bearerOf(req) };
|
|
83
|
+
// 2. Body integrity and idempotency.
|
|
84
|
+
const body = req.body ?? '';
|
|
85
|
+
if (req.headers['content-digest'] && req.headers['content-digest'] !== contentDigest(body))
|
|
86
|
+
return protocolError(400, 'digest_mismatch', 'Content-Digest does not match the body.');
|
|
87
|
+
let parsed = {};
|
|
88
|
+
if (body) {
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(body);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return protocolError(400, 'invalid_json', 'The body is not valid JSON.');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const mutating = req.method !== 'GET' && !path.startsWith('/catalog/') && path !== '/ask';
|
|
97
|
+
const key = req.headers['idempotency-key'];
|
|
98
|
+
const digest = contentDigest(body);
|
|
99
|
+
if (mutating) {
|
|
100
|
+
if (!key)
|
|
101
|
+
return protocolError(400, 'missing_idempotency_key', 'Idempotency-Key is required on operations that change state.');
|
|
102
|
+
const seen = r.ucp.idempotency[`${req.method} ${path} ${key}`];
|
|
103
|
+
if (seen) {
|
|
104
|
+
if (seen.digest !== digest)
|
|
105
|
+
return protocolError(409, 'idempotency_key_reused', 'Idempotency-Key was already used with a different body.');
|
|
106
|
+
return { status: seen.status, headers: { ...seen.headers, 'idempotent-replayed': 'true' }, body: seen.body };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 3. The operation.
|
|
110
|
+
const res = dispatch(ctx, req.method, path, parsed);
|
|
111
|
+
if (mutating && key)
|
|
112
|
+
r.ucp.idempotency[`${req.method} ${path} ${key}`] = { digest, status: res.status, headers: res.headers, body: res.body };
|
|
113
|
+
return res;
|
|
114
|
+
}
|
|
115
|
+
function dispatch(ctx, method, path, body) {
|
|
116
|
+
const r = ctx.record;
|
|
117
|
+
const out = (status, data) => withUcpHeaders(r, json(status, strip(data)));
|
|
118
|
+
const needs = (cap) => (ctx.shared.has(cap) ? null : out(200, shop.errorResponse(ctx, 'checkout', 'capabilities_incompatible', `${cap} is not shared with this platform.`)));
|
|
119
|
+
let m;
|
|
120
|
+
if (method === 'POST' && path === '/catalog/search')
|
|
121
|
+
return needs('dev.ucp.shopping.catalog.search') ?? out(200, shop.searchCatalog(ctx, body));
|
|
122
|
+
if (method === 'POST' && path === '/catalog/lookup')
|
|
123
|
+
return needs('dev.ucp.shopping.catalog.lookup') ?? out(200, shop.lookupCatalog(ctx, body));
|
|
124
|
+
if (method === 'POST' && path === '/catalog/product')
|
|
125
|
+
return needs('dev.ucp.shopping.catalog.lookup') ?? out(200, shop.getProduct(ctx, body));
|
|
126
|
+
if (method === 'POST' && path === '/carts') {
|
|
127
|
+
const blocked = needs('dev.ucp.shopping.cart');
|
|
128
|
+
if (blocked)
|
|
129
|
+
return blocked;
|
|
130
|
+
const created = shop.createCart(ctx, body);
|
|
131
|
+
return out(created.status, created.body);
|
|
132
|
+
}
|
|
133
|
+
if ((m = path.match(/^\/carts\/([^/]+)$/))) {
|
|
134
|
+
if (method === 'GET')
|
|
135
|
+
return needs('dev.ucp.shopping.cart') ?? out(200, shop.getCart(ctx, m[1]));
|
|
136
|
+
if (method === 'PUT')
|
|
137
|
+
return needs('dev.ucp.shopping.cart') ?? out(200, shop.updateCart(ctx, m[1], body));
|
|
138
|
+
}
|
|
139
|
+
if (method === 'POST' && (m = path.match(/^\/carts\/([^/]+)\/cancel$/)))
|
|
140
|
+
return needs('dev.ucp.shopping.cart') ?? out(200, shop.cancelCart(ctx, m[1]));
|
|
141
|
+
if (method === 'POST' && path === '/checkout-sessions') {
|
|
142
|
+
const blocked = needs('dev.ucp.shopping.checkout');
|
|
143
|
+
if (blocked)
|
|
144
|
+
return blocked;
|
|
145
|
+
const created = shop.createCheckout(ctx, body);
|
|
146
|
+
return out(created.status, created.body);
|
|
147
|
+
}
|
|
148
|
+
if ((m = path.match(/^\/checkout-sessions\/([^/]+)$/))) {
|
|
149
|
+
if (method === 'GET')
|
|
150
|
+
return needs('dev.ucp.shopping.checkout') ?? out(200, shop.getCheckout(ctx, m[1]));
|
|
151
|
+
if (method === 'PUT')
|
|
152
|
+
return needs('dev.ucp.shopping.checkout') ?? out(200, shop.updateCheckout(ctx, m[1], body));
|
|
153
|
+
}
|
|
154
|
+
if (method === 'POST' && (m = path.match(/^\/checkout-sessions\/([^/]+)\/complete$/)))
|
|
155
|
+
return needs('dev.ucp.shopping.checkout') ?? out(200, shop.completeCheckout(ctx, m[1], body));
|
|
156
|
+
if (method === 'POST' && (m = path.match(/^\/checkout-sessions\/([^/]+)\/cancel$/)))
|
|
157
|
+
return needs('dev.ucp.shopping.checkout') ?? out(200, shop.cancelCheckout(ctx, m[1]));
|
|
158
|
+
if (method === 'GET' && (m = path.match(/^\/orders\/([^/]+)$/))) {
|
|
159
|
+
const blocked = needs('dev.ucp.shopping.order');
|
|
160
|
+
if (blocked)
|
|
161
|
+
return blocked;
|
|
162
|
+
const order = shop.getOrder(ctx, m[1]);
|
|
163
|
+
if (!order)
|
|
164
|
+
return out(200, shop.errorResponse(ctx, 'order', 'not_found', `No order ${m[1]}.`));
|
|
165
|
+
const auth = authorize(ctx, order);
|
|
166
|
+
if (auth)
|
|
167
|
+
return auth;
|
|
168
|
+
return out(200, order);
|
|
169
|
+
}
|
|
170
|
+
if (method === 'POST' && path === '/ask')
|
|
171
|
+
return needs('dev.ucp.common.ask') ?? out(200, shop.ask(ctx, body));
|
|
172
|
+
return protocolError(404, 'not_found', `No operation ${method} ${path} in this service.`);
|
|
173
|
+
}
|
|
174
|
+
/** Orders are private: the platform that placed one, or a buyer token with the order scope, may read it. */
|
|
175
|
+
function authorize(ctx, order) {
|
|
176
|
+
const r = ctx.record;
|
|
177
|
+
const issuer = ctx.origin;
|
|
178
|
+
const bearer = ctx.bearer;
|
|
179
|
+
if (bearer) {
|
|
180
|
+
const t = r.ucp.tokens[bearer];
|
|
181
|
+
if (!t || new Date(t.expires_at) < new Date()) {
|
|
182
|
+
return json(401, { ucp: shop.envelope(ctx, 'order', 'error'), messages: [{ type: 'error', code: 'identity_required', content: 'The token is invalid or expired.', severity: 'requires_buyer_review' }] }, { 'www-authenticate': `Bearer realm="${issuer}", error="invalid_token"` });
|
|
183
|
+
}
|
|
184
|
+
if (!t.scope.split(' ').some((s) => SCOPES.includes(s))) {
|
|
185
|
+
return json(403, { ucp: shop.envelope(ctx, 'order', 'error'), messages: [{ type: 'error', code: 'insufficient_scope', content: `Reading orders needs ${SCOPES[0]}.`, severity: 'requires_buyer_review' }] }, { 'www-authenticate': `Bearer realm="${issuer}", error="insufficient_scope", scope="${SCOPES[0]}"` });
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
if (order._platform === ctx.platform)
|
|
190
|
+
return null;
|
|
191
|
+
return json(401, { ucp: shop.envelope(ctx, 'order', 'error'), messages: [{ type: 'error', code: 'identity_required', content: 'Sign in to see this order.', severity: 'requires_buyer_review' }] }, { 'www-authenticate': `Bearer realm="${issuer}"` });
|
|
192
|
+
}
|
|
193
|
+
// ---------- OAuth 2.0 for identity linking (RFC 8414 metadata, PKCE, client credentials)
|
|
194
|
+
function oauthMetadata(origin) {
|
|
195
|
+
return {
|
|
196
|
+
issuer: origin,
|
|
197
|
+
authorization_endpoint: `${origin}/oauth/authorize`,
|
|
198
|
+
token_endpoint: `${origin}/oauth/token`,
|
|
199
|
+
revocation_endpoint: `${origin}/oauth/revoke`,
|
|
200
|
+
jwks_uri: `${origin}/oauth/jwks`,
|
|
201
|
+
scopes_supported: SCOPES,
|
|
202
|
+
response_types_supported: ['code'],
|
|
203
|
+
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
|
|
204
|
+
code_challenge_methods_supported: ['S256'],
|
|
205
|
+
token_endpoint_auth_methods_supported: ['none', 'client_secret_basic'],
|
|
206
|
+
authorization_response_iss_parameter_supported: true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function form(body) {
|
|
210
|
+
return Object.fromEntries(new URLSearchParams(body ?? ''));
|
|
211
|
+
}
|
|
212
|
+
function handleOauth(req, r, origin, path) {
|
|
213
|
+
const u = r.ucp;
|
|
214
|
+
const oauthError = (error, description, status = 400) => json(status, { error, error_description: description });
|
|
215
|
+
if (path === '/.well-known/oauth-authorization-server' && req.method === 'GET')
|
|
216
|
+
return json(200, oauthMetadata(origin), { 'cache-control': 'public, max-age=300' });
|
|
217
|
+
if (path === '/oauth/jwks' && req.method === 'GET')
|
|
218
|
+
return json(200, { keys: [u.public_jwk] });
|
|
219
|
+
if (path === '/oauth/authorize' && req.method === 'GET') {
|
|
220
|
+
const q = Object.fromEntries(req.url.searchParams);
|
|
221
|
+
if (q.response_type !== 'code' || !q.client_id || !q.redirect_uri || !q.code_challenge || q.code_challenge_method !== 'S256') {
|
|
222
|
+
return oauthError('invalid_request', 'response_type=code, client_id, redirect_uri, code_challenge and code_challenge_method=S256 are required.');
|
|
223
|
+
}
|
|
224
|
+
// A sandbox has no users: the buyer is signed in and consents at once.
|
|
225
|
+
const code = crypto.randomBytes(16).toString('hex');
|
|
226
|
+
u.codes[code] = { client_id: q.client_id, redirect_uri: q.redirect_uri, scope: q.scope ?? SCOPES[0], code_challenge: q.code_challenge, expires_at: new Date(Date.now() + 600_000).toISOString() };
|
|
227
|
+
const location = new URL(q.redirect_uri);
|
|
228
|
+
location.searchParams.set('code', code);
|
|
229
|
+
if (q.state)
|
|
230
|
+
location.searchParams.set('state', q.state);
|
|
231
|
+
location.searchParams.set('iss', origin);
|
|
232
|
+
return { status: 302, headers: { location: location.toString(), 'cache-control': 'no-store' }, body: '' };
|
|
233
|
+
}
|
|
234
|
+
if (path === '/oauth/token' && req.method === 'POST') {
|
|
235
|
+
const f = form(req.body);
|
|
236
|
+
let scope = f.scope ?? SCOPES[0];
|
|
237
|
+
let client = f.client_id ?? 'anonymous';
|
|
238
|
+
if (f.grant_type === 'authorization_code') {
|
|
239
|
+
const c = u.codes[f.code ?? ''];
|
|
240
|
+
if (!c || new Date(c.expires_at) < new Date())
|
|
241
|
+
return oauthError('invalid_grant', 'Unknown or expired code.');
|
|
242
|
+
if (c.redirect_uri !== f.redirect_uri || c.client_id !== f.client_id)
|
|
243
|
+
return oauthError('invalid_grant', 'redirect_uri or client_id do not match the authorization request.');
|
|
244
|
+
const challenge = crypto.createHash('sha256').update(f.code_verifier ?? '').digest('base64url');
|
|
245
|
+
if (challenge !== c.code_challenge)
|
|
246
|
+
return oauthError('invalid_grant', 'PKCE verification failed.');
|
|
247
|
+
delete u.codes[f.code];
|
|
248
|
+
scope = c.scope;
|
|
249
|
+
client = c.client_id;
|
|
250
|
+
}
|
|
251
|
+
else if (f.grant_type !== 'client_credentials')
|
|
252
|
+
return oauthError('unsupported_grant_type', 'Use authorization_code or client_credentials.');
|
|
253
|
+
const token = `at_${crypto.randomBytes(16).toString('hex')}`;
|
|
254
|
+
u.tokens[token] = { client_id: client, scope, expires_at: new Date(Date.now() + 3600_000).toISOString() };
|
|
255
|
+
return json(200, { access_token: token, token_type: 'Bearer', expires_in: 3600, scope }, { 'cache-control': 'no-store' });
|
|
256
|
+
}
|
|
257
|
+
if (path === '/oauth/revoke' && req.method === 'POST') {
|
|
258
|
+
delete u.tokens[form(req.body).token ?? ''];
|
|
259
|
+
return { status: 200, headers: {}, body: '' };
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
// ---------- permalink, pages, storefront
|
|
264
|
+
const html = (status, title, body, headers = {}) => ({
|
|
265
|
+
status,
|
|
266
|
+
headers: { 'content-type': 'text/html; charset=utf-8', ...headers },
|
|
267
|
+
body: `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body><main><h1>${title}</h1>${body}</main></body></html>\n`,
|
|
268
|
+
});
|
|
269
|
+
function handlePermalink(req, r, origin, path) {
|
|
270
|
+
const safe = { 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' };
|
|
271
|
+
const items = path.replace(/^\/buy\/?/, '');
|
|
272
|
+
const lines = [];
|
|
273
|
+
if (items) {
|
|
274
|
+
if (!/^(?:[A-Za-z0-9._-]+|~[A-Za-z0-9_-]+):[1-9][0-9]*(?:,(?:[A-Za-z0-9._-]+|~[A-Za-z0-9_-]+):[1-9][0-9]*)*$/.test(items)) {
|
|
275
|
+
return { status: 303, headers: { location: `${origin}/`, ...safe }, body: '' };
|
|
276
|
+
}
|
|
277
|
+
for (const part of items.split(',')) {
|
|
278
|
+
const [token, qty] = part.split(':');
|
|
279
|
+
const itemId = token.startsWith('~') ? Buffer.from(token.slice(1), 'base64url').toString('utf8') : token;
|
|
280
|
+
lines.push({ item: { id: itemId }, quantity: Number(qty) });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const body = { line_items: lines };
|
|
284
|
+
for (const [k, v] of req.url.searchParams) {
|
|
285
|
+
if (k === 'continue_to')
|
|
286
|
+
continue;
|
|
287
|
+
const pointer = k.replace(/^\/+/, '').split('/');
|
|
288
|
+
if (pointer[0] === 'context' || pointer[0] === 'buyer')
|
|
289
|
+
(body[pointer[0]] ??= {})[pointer[1]] = v;
|
|
290
|
+
if (pointer[0] === 'discounts' && pointer[1] === 'codes')
|
|
291
|
+
((body.discounts ??= {}).codes ??= []).push(v);
|
|
292
|
+
}
|
|
293
|
+
const ctx = { record: r, origin, platform: 'permalink', shared: new Set(OUR_CAPABILITIES) };
|
|
294
|
+
const created = lines.length ? shop.createCart(ctx, body) : null;
|
|
295
|
+
const cartId = created?.body?.id;
|
|
296
|
+
let target = `${origin}/`;
|
|
297
|
+
const cont = req.url.searchParams.get('continue_to');
|
|
298
|
+
if (cont && /^\/(?!\/)[^\s\\]*$/.test(cont))
|
|
299
|
+
target = new URL(cont, origin).toString();
|
|
300
|
+
else if (cartId)
|
|
301
|
+
target = `${origin}/carts/${cartId}`;
|
|
302
|
+
return { status: 303, headers: { location: target, ...safe }, body: '' };
|
|
303
|
+
}
|
|
304
|
+
const MIME = { html: 'text/html; charset=utf-8', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', json: 'application/json', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', ico: 'image/x-icon', txt: 'text/plain' };
|
|
305
|
+
function serveStorefront(r, path) {
|
|
306
|
+
const files = r.ucp.files;
|
|
307
|
+
const candidates = [path.replace(/^\//, ''), `${path.replace(/^\/|\/$/g, '')}/index.html`.replace(/^\//, ''), ...(path === '/' ? ['index.html'] : [])];
|
|
308
|
+
for (const c of candidates) {
|
|
309
|
+
if (files[c] !== undefined) {
|
|
310
|
+
const ext = c.split('.').pop() ?? '';
|
|
311
|
+
return { status: 200, headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' }, body: Buffer.from(files[c], 'base64').toString(ext in MIME && !MIME[ext].startsWith('image') ? 'utf8' : 'binary') };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (Object.keys(files).length === 0)
|
|
315
|
+
return html(200, r.org, '<p>Nothing published yet. Run <code>c11r storefront publish</code>.</p>');
|
|
316
|
+
return html(404, 'Not found', `<p>No ${path} in this storefront.</p>`);
|
|
317
|
+
}
|
|
318
|
+
// ---------- the host
|
|
319
|
+
export function handleUcp(req, r) {
|
|
320
|
+
const origin = `https://${req.url.hostname}`;
|
|
321
|
+
const path = req.url.pathname.replace(/\/+$/, '') || '/';
|
|
322
|
+
if (path === '/.well-known/ucp' && req.method === 'GET')
|
|
323
|
+
return json(200, profile(r, origin), { 'cache-control': 'public, max-age=300' });
|
|
324
|
+
const oauth = handleOauth(req, r, origin, path);
|
|
325
|
+
if (oauth)
|
|
326
|
+
return oauth;
|
|
327
|
+
if (path.startsWith('/ucp/v1/') || path === '/ucp/v1') {
|
|
328
|
+
const res = handleService({ ...req, headers: { ...req.headers } }, r, origin, path.slice('/ucp/v1'.length) || '/');
|
|
329
|
+
return res;
|
|
330
|
+
}
|
|
331
|
+
if (path === '/buy' || path.startsWith('/buy/'))
|
|
332
|
+
return handlePermalink(req, r, origin, path);
|
|
333
|
+
let m;
|
|
334
|
+
if ((m = path.match(/^\/policies\/(privacy|terms|returns)$/))) {
|
|
335
|
+
const titles = { privacy: 'Privacy policy', terms: 'Terms of service', returns: 'Returns' };
|
|
336
|
+
return html(200, titles[m[1]], `<p>Default sandbox ${titles[m[1]].toLowerCase()} for ${r.org}. Set <code>policies</code> in store.yml to use your own.</p>`);
|
|
337
|
+
}
|
|
338
|
+
if ((m = path.match(/^\/(carts|checkout-sessions|orders)\/([^/]+)$/))) {
|
|
339
|
+
const kind = m[1] === 'carts' ? 'cart' : m[1] === 'orders' ? 'order' : 'checkout';
|
|
340
|
+
const found = kind === 'cart' ? r.ucp.carts[m[2]] : kind === 'order' ? r.ucp.orders[m[2]] : r.ucp.checkouts[m[2]];
|
|
341
|
+
if (!found)
|
|
342
|
+
return html(404, 'Not found', `<p>No ${kind} ${m[2]}.</p>`);
|
|
343
|
+
return html(200, `${kind === 'order' ? `Order ${found.label}` : `Your ${kind}`}`, `<p>${kind === 'order' ? 'Thank you. ' : ''}${found.line_items.length} line item(s), ${found.currency} ${(found.totals.at(-1).amount / 100).toFixed(2)}.</p>`);
|
|
344
|
+
}
|
|
345
|
+
return serveStorefront(r, path);
|
|
346
|
+
}
|
|
347
|
+
/** Lets the service read the bearer token for order authorization. */
|
|
348
|
+
export function bearerOf(req) {
|
|
349
|
+
return req.headers.authorization?.replace(/^Bearer /i, '');
|
|
350
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One way to send HTTP requests, for both the management client and the UCP buyer:
|
|
2
|
+
// over the network, or in-process to the stub when C11R_API_URL=stub.
|
|
3
|
+
// When the API URL points at a local server, requests to other hosts (the store
|
|
4
|
+
// hosts) are rewritten to that server's /-/<host>/ prefix, so one local process
|
|
5
|
+
// stands in for the whole platform.
|
|
6
|
+
import { fail } from '../output.js';
|
|
7
|
+
import { handle } from './stub/index.js';
|
|
8
|
+
export const DEFAULT_API_URL = 'https://api.commercelayer.ai';
|
|
9
|
+
export const apiUrl = () => process.env.C11R_API_URL ?? DEFAULT_API_URL;
|
|
10
|
+
export async function send(method, url, headers = {}, body) {
|
|
11
|
+
const base = apiUrl();
|
|
12
|
+
const lower = {};
|
|
13
|
+
for (const [k, v] of Object.entries(headers))
|
|
14
|
+
lower[k.toLowerCase()] = v;
|
|
15
|
+
if (body !== undefined && !lower['content-type'])
|
|
16
|
+
lower['content-type'] = 'application/json';
|
|
17
|
+
if (base === 'stub')
|
|
18
|
+
return handle({ method, url: new URL(url), headers: lower, body });
|
|
19
|
+
let target = url;
|
|
20
|
+
const baseUrl = new URL(base);
|
|
21
|
+
const targetUrl = new URL(url);
|
|
22
|
+
if (base !== DEFAULT_API_URL && targetUrl.host !== baseUrl.host && !url.startsWith(base)) {
|
|
23
|
+
target = `${base.replace(/\/$/, '')}/-/${targetUrl.host}${targetUrl.pathname}${targetUrl.search}`;
|
|
24
|
+
}
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
// Never follow redirects: UCP forbids it for profiles, and the 302/303 of
|
|
28
|
+
// /oauth/authorize and the permalink are the answer, not a hop.
|
|
29
|
+
res = await fetch(target, { method, headers: lower, body, redirect: 'manual' });
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
fail('api_unreachable', `Could not reach ${new URL(target).origin}: ${e.message}`, 'Check the network. If you are developing the CLI, run `npm run stub-api` and set C11R_API_URL=http://localhost:4747, or set it to "stub".');
|
|
33
|
+
}
|
|
34
|
+
const resHeaders = {};
|
|
35
|
+
res.headers.forEach((v, k) => (resHeaders[k] = v));
|
|
36
|
+
return { status: res.status, headers: resHeaders, body: await res.text() };
|
|
37
|
+
}
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// c11r: the Commerce Layer CLI for coding agents (package @commercelayer/agent-cli).
|
|
3
|
+
// Arguments are a command, optional subcommand, --flags and --option values.
|
|
4
|
+
// Output is always JSON; exit code 1 on an error or a failed check.
|
|
5
|
+
import * as cmd from './commands.js';
|
|
6
|
+
import { CliError, print } from './output.js';
|
|
7
|
+
const VALUE_OPTIONS = new Set(['--sku', '--quantity', '--country', '--code']);
|
|
8
|
+
const argv = process.argv.slice(2);
|
|
9
|
+
const flags = new Set();
|
|
10
|
+
const values = {};
|
|
11
|
+
const args = [];
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const a = argv[i];
|
|
14
|
+
if (!a.startsWith('--')) {
|
|
15
|
+
args.push(a);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const eq = a.indexOf('=');
|
|
19
|
+
if (eq > 0)
|
|
20
|
+
values[a.slice(2, eq)] = a.slice(eq + 1);
|
|
21
|
+
else if (VALUE_OPTIONS.has(a) && i + 1 < argv.length && !argv[i + 1].startsWith('--'))
|
|
22
|
+
values[a.slice(2)] = argv[++i];
|
|
23
|
+
else
|
|
24
|
+
flags.add(a);
|
|
25
|
+
}
|
|
26
|
+
const [name, sub] = args;
|
|
27
|
+
async function run() {
|
|
28
|
+
if (flags.has('--version') || name === 'version')
|
|
29
|
+
return { version: cmd.VERSION };
|
|
30
|
+
switch (name) {
|
|
31
|
+
case undefined:
|
|
32
|
+
case 'help':
|
|
33
|
+
return cmd.help(args.slice(1).join(' ') || undefined);
|
|
34
|
+
case 'init':
|
|
35
|
+
return cmd.init();
|
|
36
|
+
case 'plan':
|
|
37
|
+
return cmd.plan();
|
|
38
|
+
case 'apply':
|
|
39
|
+
return cmd.apply(flags);
|
|
40
|
+
case 'status':
|
|
41
|
+
return cmd.status();
|
|
42
|
+
case 'claim':
|
|
43
|
+
return cmd.claim();
|
|
44
|
+
case 'storefront':
|
|
45
|
+
if (sub === 'init')
|
|
46
|
+
return cmd.storefrontInit(flags);
|
|
47
|
+
if (sub === 'publish')
|
|
48
|
+
return cmd.storefrontPublish();
|
|
49
|
+
throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init` or `c11r storefront publish`.');
|
|
50
|
+
case 'orders':
|
|
51
|
+
if (sub === 'test')
|
|
52
|
+
return cmd.ordersTest(flags, values);
|
|
53
|
+
if (sub === 'list')
|
|
54
|
+
return cmd.ordersList();
|
|
55
|
+
if (sub === 'show')
|
|
56
|
+
return cmd.ordersShow(args[2]);
|
|
57
|
+
throw new CliError('unknown_command', `There is no command "orders ${sub ?? ''}".`, 'Use `c11r orders test`, `orders list` or `orders show <id>`.');
|
|
58
|
+
case 'ucp':
|
|
59
|
+
if (sub === 'profile')
|
|
60
|
+
return cmd.ucpProfile(flags);
|
|
61
|
+
if (sub === 'check')
|
|
62
|
+
return cmd.ucpCheck();
|
|
63
|
+
throw new CliError('unknown_command', `There is no command "ucp ${sub ?? ''}".`, 'Use `c11r ucp profile` or `c11r ucp check`.');
|
|
64
|
+
default:
|
|
65
|
+
throw new CliError('unknown_command', `There is no command "${name}".`, `Run \`c11r help\`. Commands: ${Object.keys(cmd.HELP).join(', ')}.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
run()
|
|
69
|
+
.then((result) => {
|
|
70
|
+
print(result);
|
|
71
|
+
if (result?.status === 'fail')
|
|
72
|
+
process.exit(1);
|
|
73
|
+
})
|
|
74
|
+
.catch((e) => {
|
|
75
|
+
if (e instanceof CliError) {
|
|
76
|
+
print({ error: { code: e.code, message: e.message, hint: e.hint } });
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
80
|
+
print({ error: { code: 'internal', message, hint: 'This is a bug in the CLI. Report it at https://commercelayer.ai/support with this output.' } });
|
|
81
|
+
}
|
|
82
|
+
process.exit(1);
|
|
83
|
+
});
|