@commercelayer/agent-cli 0.8.0 → 1.0.0-dev.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.
- package/README.md +95 -215
- package/dist/argv.js +214 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +123 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +211 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +173 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +78 -0
- package/dist/http.js +117 -0
- package/dist/login.js +133 -0
- package/dist/resources.js +41 -0
- package/dist/schema.js +152 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +189 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -366
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -230
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -360
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
package/dist/api/stub/theme.js
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
// A stand-in for the hosted theme the store Worker renders on every store without a
|
|
2
|
-
// custom storefront: the same settings in the same places (accent, logo or wordmark,
|
|
3
|
-
// hero image or band, tagline), the products by top-level category, a page per product
|
|
4
|
-
// at /products/<sku>. Prices come from what is applied; "Buy" opens the cart permalink.
|
|
5
|
-
// A product without an image shows its tile, and an unset hero the generated one, both
|
|
6
|
-
// drawn at /tiles/ (renderTile) as the store host does.
|
|
7
|
-
import { baseLanguage, cookieLocale, COOKIE, localeOfCountry, localesOf, texts } from './locale.js';
|
|
8
|
-
export const esc = (t) => String(t ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
9
|
-
/** Black or white, whichever reads better on the accent. */
|
|
10
|
-
function onAccent(hex) {
|
|
11
|
-
const h = hex.length === 4 ? hex.slice(1).replace(/./g, (c) => c + c) : hex.slice(1);
|
|
12
|
-
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
13
|
-
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.179 ? '#000000' : '#ffffff';
|
|
14
|
-
}
|
|
15
|
-
/** The selector's strings, in the store's base language; other languages fall back to en, like the store. */
|
|
16
|
-
const UI = {
|
|
17
|
-
en: { choose: 'Choose your country', shipping: (c) => `Shipping to ${c}?` },
|
|
18
|
-
it: { choose: 'Scegli il tuo paese', shipping: (c) => `Spedisci in ${c}?` },
|
|
19
|
-
fr: { choose: 'Choisissez votre pays', shipping: (c) => `Livraison en ${c} ?` },
|
|
20
|
-
de: { choose: 'Wählen Sie Ihr Land', shipping: (c) => `Versand nach ${c}?` },
|
|
21
|
-
es: { choose: 'Elige tu país', shipping: (c) => `¿Envío a ${c}?` },
|
|
22
|
-
};
|
|
23
|
-
const countryName = (cc, ll) => {
|
|
24
|
-
try {
|
|
25
|
-
return new Intl.DisplayNames([ll], { type: 'region' }).of(cc) ?? cc;
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return cc;
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
const tag = (l) => `${l.language}-${l.country}`;
|
|
32
|
-
const alternates = (locales, rest) => [...locales.map((l) => `<link rel="alternate" hreflang="${tag(l)}" href="/${l.path}${rest === '/' ? '/' : rest}">`), '<link rel="alternate" hreflang="x-default" href="/">'].join('\n');
|
|
33
|
-
/**
|
|
34
|
-
* The hosted theme's routes. With locales (two or more countries, or one): the selector at /,
|
|
35
|
-
* the pages under /<cc>-<ll>/, a cookie that remembers the choice, unprefixed product and
|
|
36
|
-
* category paths sent to the remembered or first locale. Without (not applied since locales
|
|
37
|
-
* exist): the site as it was. null when the path is not the theme's.
|
|
38
|
-
*/
|
|
39
|
-
export function renderTheme(r, path, search, headers = {}) {
|
|
40
|
-
const locales = localesOf(r);
|
|
41
|
-
if (!locales.length)
|
|
42
|
-
return renderPage(r, path, search, null);
|
|
43
|
-
const redirect = (to, extra = {}) => ({ status: 302, headers: { location: to, 'cache-control': 'no-store', ...extra }, body: '' });
|
|
44
|
-
if (path === '/') {
|
|
45
|
-
if (locales.length === 1)
|
|
46
|
-
return redirect(`/${locales[0].path}/`);
|
|
47
|
-
const chosen = cookieLocale(r, headers.cookie);
|
|
48
|
-
if (chosen && !search.has('change'))
|
|
49
|
-
return redirect(`/${chosen.path}/`, { vary: 'Cookie' });
|
|
50
|
-
const page = selector(r, locales, headers['cf-ipcountry']);
|
|
51
|
-
page.headers.vary = 'Cookie';
|
|
52
|
-
return page;
|
|
53
|
-
}
|
|
54
|
-
const m = path.match(/^\/([a-z]{2})-([a-z]{2})(\/.*)?$/);
|
|
55
|
-
if (m) {
|
|
56
|
-
const l = localeOfCountry(r, m[1]);
|
|
57
|
-
if (!l)
|
|
58
|
-
return null;
|
|
59
|
-
const rest = m[3] ?? '/';
|
|
60
|
-
if (l.language !== m[2])
|
|
61
|
-
return redirect(`/${l.path}${rest === '/' ? '/' : rest}`);
|
|
62
|
-
const page = renderPage(r, rest, search, l);
|
|
63
|
-
if (page)
|
|
64
|
-
page.headers['set-cookie'] = `${COOKIE}=${l.path}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`;
|
|
65
|
-
return page;
|
|
66
|
-
}
|
|
67
|
-
if (/^\/(products|categories)\//.test(path))
|
|
68
|
-
return redirect(`/${(cookieLocale(r, headers.cookie) ?? locales[0]).path}${path}`, { vary: 'Cookie' });
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
function selector(r, locales, ipCountry) {
|
|
72
|
-
const base = baseLanguage(r);
|
|
73
|
-
const ui = UI[base] ?? UI.en;
|
|
74
|
-
const markets = Object.values(r.applied?.markets ?? {});
|
|
75
|
-
const near = ipCountry ? localeOfCountry(r, ipCountry) : undefined;
|
|
76
|
-
const link = (l) => `<a href="/${l.path}/" hreflang="${tag(l)}" lang="${l.language}">${esc(countryName(l.country, base))}</a>`;
|
|
77
|
-
const body = `<main class="selector"><h1>${esc(ui.choose)}</h1>${near ? `<p class="suggestion"><a href="/${near.path}/">${esc(ui.shipping(countryName(near.country, base)))}</a></p>` : ''}${markets
|
|
78
|
-
.map((mk) => {
|
|
79
|
-
const ls = locales.filter((l) => l.market === mk.code);
|
|
80
|
-
return ls.length ? `<section><h2>${esc(mk.name)} (${esc(mk.currency)})</h2><ul>${ls.map((l) => `<li>${link(l)}</li>`).join('')}</ul></section>` : '';
|
|
81
|
-
})
|
|
82
|
-
.join('')}</main>`;
|
|
83
|
-
return page(r, { lang: base, title: r.name ?? r.org, head: alternates(locales, '/'), header: '', body, description: ui.choose });
|
|
84
|
-
}
|
|
85
|
-
function page(r, o) {
|
|
86
|
-
const theme = r.theme ?? {};
|
|
87
|
-
const color = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(theme.color ?? '') ? theme.color : '#2f5d50';
|
|
88
|
-
const name = r.name ?? r.org;
|
|
89
|
-
const badge = theme.badge ? ` <span class="badge">${esc(theme.badge)}</span>` : '';
|
|
90
|
-
const logo = (theme.logo ? `<img class="logo" src="/${esc(theme.logo.replace(/^\//, ''))}" alt="${esc(name)}">` : `<span class="wordmark">${esc(name)}</span>`) + badge;
|
|
91
|
-
const html = `<!doctype html>
|
|
92
|
-
<html lang="${esc(o.lang)}">
|
|
93
|
-
<head>
|
|
94
|
-
<meta charset="utf-8">
|
|
95
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
96
|
-
<title>${esc(o.title)}</title>
|
|
97
|
-
<meta name="description" content="${esc(o.description)}">
|
|
98
|
-
${o.head ? `${o.head}\n` : ''}<!-- c11r stub: a stand-in for the hosted theme. -->
|
|
99
|
-
<style>:root{--accent:${color};--on-accent:${onAccent(color)}}body{margin:0;font-family:system-ui,sans-serif;color:#16181d}header{display:flex;justify-content:space-between;padding:16px 24px}.logo{max-height:40px}.badge{margin-left:8px;padding:2px 8px;border-radius:999px;background:var(--accent);color:var(--on-accent);font-size:12px;vertical-align:middle}.hero{padding:64px 24px;background-size:cover}.band{background:var(--accent);color:var(--on-accent)}.grid{display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}.buy{background:var(--accent);color:var(--on-accent);padding:8px 14px}main{padding:24px}</style>
|
|
100
|
-
</head>
|
|
101
|
-
<body>
|
|
102
|
-
<header><a href="${o.home ?? '/'}">${logo}</a>${o.header}</header>
|
|
103
|
-
${o.body}
|
|
104
|
-
</body>
|
|
105
|
-
</html>
|
|
106
|
-
`;
|
|
107
|
-
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: html };
|
|
108
|
-
}
|
|
109
|
-
/** The home page and the product pages, for a locale (prefixed paths, its market, its language) or for none (the site as it was). */
|
|
110
|
-
function renderPage(r, path, search, locale) {
|
|
111
|
-
const products = Object.values(r.applied?.products ?? {});
|
|
112
|
-
const markets = Object.values(r.applied?.markets ?? {});
|
|
113
|
-
const theme = r.theme ?? {};
|
|
114
|
-
const name = r.name ?? r.org;
|
|
115
|
-
const base = baseLanguage(r);
|
|
116
|
-
const ll = locale?.language ?? base;
|
|
117
|
-
const market = locale ? r.applied?.markets[locale.market] : (markets.find((m) => m.code === search.get('market')) ?? markets[0]);
|
|
118
|
-
const prefix = locale ? `/${locale.path}` : '';
|
|
119
|
-
const keep = !locale && market && market !== markets[0] ? `?market=${encodeURIComponent(market.code)}` : '';
|
|
120
|
-
// Under a locale, prices as that country writes them (14,50 € on /it-it/); the site as it was keeps EUR 14.50.
|
|
121
|
-
const money = (amount, currency) => (locale ? new Intl.NumberFormat(tag(locale), { style: 'currency', currency }).format(amount) : `${currency} ${amount.toFixed(2)}`);
|
|
122
|
-
const price = (p) => (market && p.prices[market.currency] !== undefined ? money(p.prices[market.currency], market.currency) : '');
|
|
123
|
-
const tagline = theme.tagline ?? `Ships to ${markets.flatMap((m) => m.countries).join(', ')}`;
|
|
124
|
-
const img = (url) => (/^https?:/.test(url) ? url : `/${url}`);
|
|
125
|
-
const lang = ll === base ? '' : `?lang=${ll}`;
|
|
126
|
-
const image = (p) => p.image_url ? `<img src="${esc(img(p.image_url))}" alt="${esc(texts(p, ll).name)}">` : `<span class="placeholder"><img src="/tiles/${esc(encodeURIComponent(p.sku))}.svg${lang}" alt="${esc(texts(p, ll).name)}" loading="lazy"></span>`;
|
|
127
|
-
const card = (p) => `<article class="product"><a href="${prefix}/products/${encodeURIComponent(p.sku)}${keep}">${image(p)}<h3>${esc(texts(p, ll).name)}</h3></a><p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></article>`;
|
|
128
|
-
let title = name;
|
|
129
|
-
let body;
|
|
130
|
-
const m = path.match(/^\/products\/([^/]+)$/);
|
|
131
|
-
if (path === '/') {
|
|
132
|
-
const groups = new Map();
|
|
133
|
-
for (const p of products) {
|
|
134
|
-
const top = texts(p, ll).category?.split(' > ')[0] ?? 'Other';
|
|
135
|
-
groups.set(top, [...(groups.get(top) ?? []), p]);
|
|
136
|
-
}
|
|
137
|
-
const hero = `<section class="hero" style="background-image:url('${esc(theme.hero ? img(theme.hero) : '/tiles/hero.svg')}')">`;
|
|
138
|
-
body = `${hero}<h1>${esc(name)}</h1><p class="tagline">${esc(tagline)}</p></section><main>${[...groups].map(([k, ps]) => `<section><h2>${esc(k)}</h2><div class="grid">${ps.map(card).join('')}</div></section>`).join('')}</main>`;
|
|
139
|
-
}
|
|
140
|
-
else if (m) {
|
|
141
|
-
const p = r.applied?.products[decodeURIComponent(m[1])];
|
|
142
|
-
if (!p)
|
|
143
|
-
return null;
|
|
144
|
-
const t = texts(p, ll);
|
|
145
|
-
title = `${t.name} · ${name}`;
|
|
146
|
-
body = `<main class="product-page">${image(p)}<div>${t.category ? `<p class="category">${esc(t.category)}</p>` : ''}<h1>${esc(t.name)}</h1>${t.description ? `<p>${esc(t.description)}</p>` : ''}<p class="price">${esc(price(p))}</p><a class="buy" href="/buy/${encodeURIComponent(p.sku)}:1">Buy</a></div></main>`;
|
|
147
|
-
}
|
|
148
|
-
else
|
|
149
|
-
return null;
|
|
150
|
-
const header = locale
|
|
151
|
-
? localesOf(r).length > 1
|
|
152
|
-
? `<nav><a class="country" href="/?change">${esc(countryName(locale.country, ll))} · ${esc(market?.currency ?? '')}</a></nav>`
|
|
153
|
-
: ''
|
|
154
|
-
: `<nav>${markets.map((x) => `<a href="?market=${encodeURIComponent(x.code)}">${esc(x.name)}</a>`).join(' ')}</nav>`;
|
|
155
|
-
return page(r, { lang: locale ? tag(locale) : 'en', title, head: locale ? alternates(localesOf(r), path) : '', header, body, description: tagline, home: `${prefix}/${keep}` });
|
|
156
|
-
}
|
|
157
|
-
const xml = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
158
|
-
/** A 1×1 PNG: the stand-in for a generated PNG tile. */
|
|
159
|
-
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkqF9fDwAEAQG/jvkFNQAAAABJRU5ErkJggg==', 'base64');
|
|
160
|
-
/** /tiles/<sku>.svg, /tiles/<sku>.png and /tiles/hero.svg: a plain stand-in for the store host's generated tiles. */
|
|
161
|
-
export function renderTile(r, path, search) {
|
|
162
|
-
const png = path.match(/^\/tiles\/([^/]+)\.png$/);
|
|
163
|
-
if (png) {
|
|
164
|
-
let sku;
|
|
165
|
-
try {
|
|
166
|
-
sku = decodeURIComponent(png[1]);
|
|
167
|
-
}
|
|
168
|
-
catch {
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
if (!r.applied?.products[sku])
|
|
172
|
-
return null;
|
|
173
|
-
return { status: 200, headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' }, body: PNG };
|
|
174
|
-
}
|
|
175
|
-
const m = path.match(/^\/tiles\/([^/]+)\.svg$/);
|
|
176
|
-
if (!m)
|
|
177
|
-
return null;
|
|
178
|
-
const color = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(r.theme?.color ?? '') ? r.theme.color : '#2f5d50';
|
|
179
|
-
const headers = { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' };
|
|
180
|
-
let svg;
|
|
181
|
-
if (m[1] === 'hero')
|
|
182
|
-
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="900" viewBox="0 0 2400 900"><rect width="2400" height="900" fill="${color}"/><circle cx="1900" cy="450" r="360" fill="#ffffff" fill-opacity="0.12"/></svg>`;
|
|
183
|
-
else {
|
|
184
|
-
let sku;
|
|
185
|
-
try {
|
|
186
|
-
sku = decodeURIComponent(m[1]);
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
const p = r.applied?.products[sku];
|
|
192
|
-
if (!p)
|
|
193
|
-
return null;
|
|
194
|
-
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 1200 1200"><rect width="1200" height="1200" fill="${color}" fill-opacity="0.08"/><text x="600" y="600" text-anchor="middle" font-family="system-ui, sans-serif" font-size="72" fill="${color}">${xml(texts(p, search?.get('lang') ?? '').name.slice(0, 60))}</text></svg>`;
|
|
195
|
-
}
|
|
196
|
-
return { status: 200, headers, body: svg };
|
|
197
|
-
}
|
package/dist/api/stub/ucp.js
DELETED
|
@@ -1,377 +0,0 @@
|
|
|
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
|
-
import { esc, renderTheme, renderTile } from './theme.js';
|
|
12
|
-
import { ucpLanguage } from './locale.js';
|
|
13
|
-
const SCOPES = ['dev.ucp.shopping.order:read', 'dev.ucp.shopping.order:manage'];
|
|
14
|
-
const OUR_CAPABILITIES = [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking', 'dev.ucp.shopping.permalink'];
|
|
15
|
-
// ---------- profile
|
|
16
|
-
export function profile(r, origin) {
|
|
17
|
-
const service = (schema) => [{ version: UCP_VERSION, spec: `${UCP}/specification/overview`, transport: 'rest', schema: `${UCP}/services/${schema}/rest.openapi.json`, endpoint: `${origin}/ucp/v1` }];
|
|
18
|
-
const capabilities = {};
|
|
19
|
-
for (const name of OUR_CAPABILITIES) {
|
|
20
|
-
const extra = {};
|
|
21
|
-
if (name === 'dev.ucp.common.identity_linking')
|
|
22
|
-
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.' } }])) };
|
|
23
|
-
if (name === 'dev.ucp.shopping.permalink')
|
|
24
|
-
extra.config = { endpoint: `${origin}/buy` };
|
|
25
|
-
if (name === 'dev.ucp.shopping.fulfillment')
|
|
26
|
-
extra.config = { method_combinations: [['shipping']] };
|
|
27
|
-
capabilities[name] = [capabilityEntry(name, extra)];
|
|
28
|
-
}
|
|
29
|
-
return {
|
|
30
|
-
ucp: {
|
|
31
|
-
version: UCP_VERSION,
|
|
32
|
-
services: { 'dev.ucp.shopping': service('shopping'), 'dev.ucp.common': service('common') },
|
|
33
|
-
capabilities,
|
|
34
|
-
payment_handlers: {
|
|
35
|
-
[SANDBOX_HANDLER]: [{ id: SANDBOX_HANDLER_ID, version: UCP_VERSION, spec: SANDBOX_HANDLER_SPEC, schema: SANDBOX_HANDLER_SCHEMA, available_instruments: [{ type: 'card' }], config: { environment: 'sandbox' } }],
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
keys: [r.ucp.public_jwk],
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
// ---------- negotiation
|
|
42
|
-
/** 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. */
|
|
43
|
-
function negotiate(platformUrl) {
|
|
44
|
-
let theirs = null;
|
|
45
|
-
if (platformUrl === AGENT_PROFILE_URL)
|
|
46
|
-
theirs = agentProfile();
|
|
47
|
-
const ours = new Set(OUR_CAPABILITIES);
|
|
48
|
-
if (!theirs)
|
|
49
|
-
return { shared: ours, webhookUrl: null };
|
|
50
|
-
const shared = new Set();
|
|
51
|
-
const caps = theirs.ucp.capabilities ?? {};
|
|
52
|
-
for (const name of ours)
|
|
53
|
-
if ((caps[name] ?? []).some((e) => e.version === UCP_VERSION))
|
|
54
|
-
shared.add(name);
|
|
55
|
-
let changed = true;
|
|
56
|
-
while (changed) {
|
|
57
|
-
changed = false;
|
|
58
|
-
for (const [ext, parents] of Object.entries(EXTENSIONS)) {
|
|
59
|
-
if (shared.has(ext) && !parents.some((p) => shared.has(p))) {
|
|
60
|
-
shared.delete(ext);
|
|
61
|
-
changed = true;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
const webhookUrl = caps['dev.ucp.shopping.order']?.[0]?.config?.webhook_url ?? null;
|
|
66
|
-
return { shared, webhookUrl };
|
|
67
|
-
}
|
|
68
|
-
// ---------- the REST service
|
|
69
|
-
const protocolError = (status, code, content, headers = {}) => json(status, { code, content }, headers);
|
|
70
|
-
function withUcpHeaders(r, res) {
|
|
71
|
-
const digest = contentDigest(res.body.toString());
|
|
72
|
-
const values = { '@status': String(res.status), 'content-digest': digest, 'content-type': res.headers['content-type'] ?? 'application/json' };
|
|
73
|
-
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);
|
|
74
|
-
return { ...res, headers: { ...res.headers, 'content-digest': digest, ...sig } };
|
|
75
|
-
}
|
|
76
|
-
function handleService(req, r, origin, path) {
|
|
77
|
-
// 1. Who is calling: UCP-Agent names the platform profile; Request-Id is required.
|
|
78
|
-
const agent = req.headers['ucp-agent']?.match(/profile="(https:\/\/[^"]+)"/);
|
|
79
|
-
if (!agent)
|
|
80
|
-
return protocolError(400, 'invalid_profile_url', 'UCP-Agent must be `profile="https://..."`, an HTTPS URL of the platform profile.');
|
|
81
|
-
if (!req.headers['request-id'])
|
|
82
|
-
return protocolError(400, 'missing_request_id', 'Request-Id is required on every request.');
|
|
83
|
-
const { shared, webhookUrl } = negotiate(agent[1]);
|
|
84
|
-
const ctx = { record: r, origin, platform: agent[1], shared, webhookUrl, bearer: bearerOf(req) };
|
|
85
|
-
// 2. Body integrity and idempotency.
|
|
86
|
-
const body = req.body ?? '';
|
|
87
|
-
if (req.headers['content-digest'] && req.headers['content-digest'] !== contentDigest(body))
|
|
88
|
-
return protocolError(400, 'digest_mismatch', 'Content-Digest does not match the body.');
|
|
89
|
-
let parsed = {};
|
|
90
|
-
if (body) {
|
|
91
|
-
try {
|
|
92
|
-
parsed = JSON.parse(body);
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
return protocolError(400, 'invalid_json', 'The body is not valid JSON.');
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
const mutating = req.method !== 'GET' && !path.startsWith('/catalog/') && path !== '/ask';
|
|
99
|
-
const key = req.headers['idempotency-key'];
|
|
100
|
-
const digest = contentDigest(body);
|
|
101
|
-
if (mutating) {
|
|
102
|
-
if (!key)
|
|
103
|
-
return protocolError(400, 'missing_idempotency_key', 'Idempotency-Key is required on operations that change state.');
|
|
104
|
-
const seen = r.ucp.idempotency[`${req.method} ${path} ${key}`];
|
|
105
|
-
if (seen) {
|
|
106
|
-
if (seen.digest !== digest)
|
|
107
|
-
return protocolError(409, 'idempotency_key_reused', 'Idempotency-Key was already used with a different body.');
|
|
108
|
-
return { status: seen.status, headers: { ...seen.headers, 'idempotent-replayed': 'true' }, body: seen.body };
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
// 3. The operation, in the language the platform asked for (or the buyer's country's, or the base).
|
|
112
|
-
ctx.language = ucpLanguage(r, parsed?.context, req.headers['accept-language']);
|
|
113
|
-
ctx.country = typeof parsed?.context?.address_country === 'string' ? parsed.context.address_country : undefined;
|
|
114
|
-
const res = dispatch(ctx, req.method, path, parsed);
|
|
115
|
-
res.headers['content-language'] = ctx.language;
|
|
116
|
-
if (mutating && key)
|
|
117
|
-
r.ucp.idempotency[`${req.method} ${path} ${key}`] = { digest, status: res.status, headers: res.headers, body: res.body.toString() };
|
|
118
|
-
return res;
|
|
119
|
-
}
|
|
120
|
-
function dispatch(ctx, method, path, body) {
|
|
121
|
-
const r = ctx.record;
|
|
122
|
-
const out = (status, data) => withUcpHeaders(r, json(status, strip(data)));
|
|
123
|
-
const needs = (cap) => (ctx.shared.has(cap) ? null : out(200, shop.errorResponse(ctx, 'checkout', 'capabilities_incompatible', `${cap} is not shared with this platform.`)));
|
|
124
|
-
let m;
|
|
125
|
-
if (method === 'POST' && path === '/catalog/search')
|
|
126
|
-
return needs('dev.ucp.shopping.catalog.search') ?? out(200, shop.searchCatalog(ctx, body));
|
|
127
|
-
if (method === 'POST' && path === '/catalog/lookup')
|
|
128
|
-
return needs('dev.ucp.shopping.catalog.lookup') ?? out(200, shop.lookupCatalog(ctx, body));
|
|
129
|
-
if (method === 'POST' && path === '/catalog/product')
|
|
130
|
-
return needs('dev.ucp.shopping.catalog.lookup') ?? out(200, shop.getProduct(ctx, body));
|
|
131
|
-
if (method === 'POST' && path === '/carts') {
|
|
132
|
-
const blocked = needs('dev.ucp.shopping.cart');
|
|
133
|
-
if (blocked)
|
|
134
|
-
return blocked;
|
|
135
|
-
const created = shop.createCart(ctx, body);
|
|
136
|
-
return out(created.status, created.body);
|
|
137
|
-
}
|
|
138
|
-
if ((m = path.match(/^\/carts\/([^/]+)$/))) {
|
|
139
|
-
if (method === 'GET')
|
|
140
|
-
return needs('dev.ucp.shopping.cart') ?? out(200, shop.getCart(ctx, m[1]));
|
|
141
|
-
if (method === 'PUT')
|
|
142
|
-
return needs('dev.ucp.shopping.cart') ?? out(200, shop.updateCart(ctx, m[1], body));
|
|
143
|
-
}
|
|
144
|
-
if (method === 'POST' && (m = path.match(/^\/carts\/([^/]+)\/cancel$/)))
|
|
145
|
-
return needs('dev.ucp.shopping.cart') ?? out(200, shop.cancelCart(ctx, m[1]));
|
|
146
|
-
if (method === 'POST' && path === '/checkout-sessions') {
|
|
147
|
-
const blocked = needs('dev.ucp.shopping.checkout');
|
|
148
|
-
if (blocked)
|
|
149
|
-
return blocked;
|
|
150
|
-
const created = shop.createCheckout(ctx, body);
|
|
151
|
-
return out(created.status, created.body);
|
|
152
|
-
}
|
|
153
|
-
if ((m = path.match(/^\/checkout-sessions\/([^/]+)$/))) {
|
|
154
|
-
if (method === 'GET')
|
|
155
|
-
return needs('dev.ucp.shopping.checkout') ?? out(200, shop.getCheckout(ctx, m[1]));
|
|
156
|
-
if (method === 'PUT')
|
|
157
|
-
return needs('dev.ucp.shopping.checkout') ?? out(200, shop.updateCheckout(ctx, m[1], body));
|
|
158
|
-
}
|
|
159
|
-
if (method === 'POST' && (m = path.match(/^\/checkout-sessions\/([^/]+)\/complete$/)))
|
|
160
|
-
return needs('dev.ucp.shopping.checkout') ?? out(200, shop.completeCheckout(ctx, m[1], body));
|
|
161
|
-
if (method === 'POST' && (m = path.match(/^\/checkout-sessions\/([^/]+)\/cancel$/)))
|
|
162
|
-
return needs('dev.ucp.shopping.checkout') ?? out(200, shop.cancelCheckout(ctx, m[1]));
|
|
163
|
-
if (method === 'GET' && (m = path.match(/^\/orders\/([^/]+)$/))) {
|
|
164
|
-
const blocked = needs('dev.ucp.shopping.order');
|
|
165
|
-
if (blocked)
|
|
166
|
-
return blocked;
|
|
167
|
-
const order = shop.getOrder(ctx, m[1]);
|
|
168
|
-
if (!order)
|
|
169
|
-
return out(200, shop.errorResponse(ctx, 'order', 'not_found', `No order ${m[1]}.`));
|
|
170
|
-
const auth = authorize(ctx, order);
|
|
171
|
-
if (auth)
|
|
172
|
-
return auth;
|
|
173
|
-
return out(200, order);
|
|
174
|
-
}
|
|
175
|
-
if (method === 'POST' && path === '/ask')
|
|
176
|
-
return needs('dev.ucp.common.ask') ?? out(200, shop.ask(ctx, body));
|
|
177
|
-
return protocolError(404, 'not_found', `No operation ${method} ${path} in this service.`);
|
|
178
|
-
}
|
|
179
|
-
/** Orders are private: the platform that placed one, or a buyer token with the order scope, may read it. */
|
|
180
|
-
function authorize(ctx, order) {
|
|
181
|
-
const r = ctx.record;
|
|
182
|
-
const issuer = ctx.origin;
|
|
183
|
-
const bearer = ctx.bearer;
|
|
184
|
-
if (bearer) {
|
|
185
|
-
const t = r.ucp.tokens[bearer];
|
|
186
|
-
if (!t || new Date(t.expires_at) < new Date()) {
|
|
187
|
-
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"` });
|
|
188
|
-
}
|
|
189
|
-
if (!t.scope.split(' ').some((s) => SCOPES.includes(s))) {
|
|
190
|
-
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]}"` });
|
|
191
|
-
}
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
if (order._platform === ctx.platform)
|
|
195
|
-
return null;
|
|
196
|
-
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}"` });
|
|
197
|
-
}
|
|
198
|
-
// ---------- OAuth 2.0 for identity linking (RFC 8414 metadata, PKCE, client credentials)
|
|
199
|
-
function oauthMetadata(origin) {
|
|
200
|
-
return {
|
|
201
|
-
issuer: origin,
|
|
202
|
-
authorization_endpoint: `${origin}/oauth/authorize`,
|
|
203
|
-
token_endpoint: `${origin}/oauth/token`,
|
|
204
|
-
revocation_endpoint: `${origin}/oauth/revoke`,
|
|
205
|
-
jwks_uri: `${origin}/oauth/jwks`,
|
|
206
|
-
scopes_supported: SCOPES,
|
|
207
|
-
response_types_supported: ['code'],
|
|
208
|
-
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
|
|
209
|
-
code_challenge_methods_supported: ['S256'],
|
|
210
|
-
token_endpoint_auth_methods_supported: ['none', 'client_secret_basic'],
|
|
211
|
-
authorization_response_iss_parameter_supported: true,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
function form(body) {
|
|
215
|
-
return Object.fromEntries(new URLSearchParams(body ?? ''));
|
|
216
|
-
}
|
|
217
|
-
function handleOauth(req, r, origin, path) {
|
|
218
|
-
const u = r.ucp;
|
|
219
|
-
const oauthError = (error, description, status = 400) => json(status, { error, error_description: description });
|
|
220
|
-
if (path === '/.well-known/oauth-authorization-server' && req.method === 'GET')
|
|
221
|
-
return json(200, oauthMetadata(origin), { 'cache-control': 'public, max-age=300' });
|
|
222
|
-
if (path === '/oauth/jwks' && req.method === 'GET')
|
|
223
|
-
return json(200, { keys: [u.public_jwk] });
|
|
224
|
-
if (path === '/oauth/authorize' && req.method === 'GET') {
|
|
225
|
-
const q = Object.fromEntries(req.url.searchParams);
|
|
226
|
-
if (q.response_type !== 'code' || !q.client_id || !q.redirect_uri || !q.code_challenge || q.code_challenge_method !== 'S256') {
|
|
227
|
-
return oauthError('invalid_request', 'response_type=code, client_id, redirect_uri, code_challenge and code_challenge_method=S256 are required.');
|
|
228
|
-
}
|
|
229
|
-
// A sandbox has no users: the buyer is signed in and consents at once.
|
|
230
|
-
const code = crypto.randomBytes(16).toString('hex');
|
|
231
|
-
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() };
|
|
232
|
-
const location = new URL(q.redirect_uri);
|
|
233
|
-
location.searchParams.set('code', code);
|
|
234
|
-
if (q.state)
|
|
235
|
-
location.searchParams.set('state', q.state);
|
|
236
|
-
location.searchParams.set('iss', origin);
|
|
237
|
-
return { status: 302, headers: { location: location.toString(), 'cache-control': 'no-store' }, body: '' };
|
|
238
|
-
}
|
|
239
|
-
if (path === '/oauth/token' && req.method === 'POST') {
|
|
240
|
-
const f = form(req.body);
|
|
241
|
-
let scope = f.scope ?? SCOPES[0];
|
|
242
|
-
let client = f.client_id ?? 'anonymous';
|
|
243
|
-
if (f.grant_type === 'authorization_code') {
|
|
244
|
-
const c = u.codes[f.code ?? ''];
|
|
245
|
-
if (!c || new Date(c.expires_at) < new Date())
|
|
246
|
-
return oauthError('invalid_grant', 'Unknown or expired code.');
|
|
247
|
-
if (c.redirect_uri !== f.redirect_uri || c.client_id !== f.client_id)
|
|
248
|
-
return oauthError('invalid_grant', 'redirect_uri or client_id do not match the authorization request.');
|
|
249
|
-
const challenge = crypto.createHash('sha256').update(f.code_verifier ?? '').digest('base64url');
|
|
250
|
-
if (challenge !== c.code_challenge)
|
|
251
|
-
return oauthError('invalid_grant', 'PKCE verification failed.');
|
|
252
|
-
delete u.codes[f.code];
|
|
253
|
-
scope = c.scope;
|
|
254
|
-
client = c.client_id;
|
|
255
|
-
}
|
|
256
|
-
else if (f.grant_type !== 'client_credentials')
|
|
257
|
-
return oauthError('unsupported_grant_type', 'Use authorization_code or client_credentials.');
|
|
258
|
-
const token = `at_${crypto.randomBytes(16).toString('hex')}`;
|
|
259
|
-
u.tokens[token] = { client_id: client, scope, expires_at: new Date(Date.now() + 3600_000).toISOString() };
|
|
260
|
-
return json(200, { access_token: token, token_type: 'Bearer', expires_in: 3600, scope }, { 'cache-control': 'no-store' });
|
|
261
|
-
}
|
|
262
|
-
if (path === '/oauth/revoke' && req.method === 'POST') {
|
|
263
|
-
delete u.tokens[form(req.body).token ?? ''];
|
|
264
|
-
return { status: 200, headers: {}, body: '' };
|
|
265
|
-
}
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
268
|
-
// ---------- permalink, pages, storefront
|
|
269
|
-
const html = (status, title, body, headers = {}) => ({
|
|
270
|
-
status,
|
|
271
|
-
headers: { 'content-type': 'text/html; charset=utf-8', ...headers },
|
|
272
|
-
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`,
|
|
273
|
-
});
|
|
274
|
-
function handlePermalink(req, r, origin, path) {
|
|
275
|
-
const safe = { 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' };
|
|
276
|
-
const items = path.replace(/^\/buy\/?/, '');
|
|
277
|
-
const lines = [];
|
|
278
|
-
if (items) {
|
|
279
|
-
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)) {
|
|
280
|
-
return { status: 303, headers: { location: `${origin}/`, ...safe }, body: '' };
|
|
281
|
-
}
|
|
282
|
-
for (const part of items.split(',')) {
|
|
283
|
-
const [token, qty] = part.split(':');
|
|
284
|
-
const itemId = token.startsWith('~') ? Buffer.from(token.slice(1), 'base64url').toString('utf8') : token;
|
|
285
|
-
lines.push({ item: { id: itemId }, quantity: Number(qty) });
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
const body = { line_items: lines };
|
|
289
|
-
for (const [k, v] of req.url.searchParams) {
|
|
290
|
-
if (k === 'continue_to')
|
|
291
|
-
continue;
|
|
292
|
-
const pointer = k.replace(/^\/+/, '').split('/');
|
|
293
|
-
if (pointer[0] === 'context' || pointer[0] === 'buyer')
|
|
294
|
-
(body[pointer[0]] ??= {})[pointer[1]] = v;
|
|
295
|
-
if (pointer[0] === 'discounts' && pointer[1] === 'codes')
|
|
296
|
-
((body.discounts ??= {}).codes ??= []).push(v);
|
|
297
|
-
}
|
|
298
|
-
const ctx = { record: r, origin, platform: 'permalink', shared: new Set(OUR_CAPABILITIES) };
|
|
299
|
-
const created = lines.length ? shop.createCart(ctx, body) : null;
|
|
300
|
-
const cartId = created?.body?.id;
|
|
301
|
-
let target = `${origin}/`;
|
|
302
|
-
const cont = req.url.searchParams.get('continue_to');
|
|
303
|
-
if (cont && /^\/(?!\/)[^\s\\]*$/.test(cont))
|
|
304
|
-
target = new URL(cont, origin).toString();
|
|
305
|
-
else if (cartId)
|
|
306
|
-
target = `${origin}/carts/${cartId}`;
|
|
307
|
-
return { status: 303, headers: { location: target, ...safe }, body: '' };
|
|
308
|
-
}
|
|
309
|
-
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', gif: 'image/gif', avif: 'image/avif', ico: 'image/x-icon', txt: 'text/plain' };
|
|
310
|
-
function serveStorefront(r, path, search, headers) {
|
|
311
|
-
const files = r.ucp.files;
|
|
312
|
-
// /tiles/ is the store host's own, in front of a custom storefront too: unknown tiles are a 404.
|
|
313
|
-
if (path.startsWith('/tiles/'))
|
|
314
|
-
return renderTile(r, path, search) ?? html(404, 'Not found', `<p>No ${path} on this store.</p>`);
|
|
315
|
-
// Agent-only: no pages for humans beyond what UCP needs; the images stay, UCP's media point at them.
|
|
316
|
-
const agentOnly = r.storefront_mode === 'agent-only';
|
|
317
|
-
// Links shared while the store was hosted land on the agent page.
|
|
318
|
-
if (agentOnly && /^(\/[a-z]{2}-[a-z]{2})?\/(products|categories)\/./.test(path))
|
|
319
|
-
return { status: 302, headers: { location: '/' }, body: '' };
|
|
320
|
-
if (agentOnly && path === '/')
|
|
321
|
-
return html(200, r.name ?? r.org, `<p>${esc(r.name ?? r.org)} sells to shopping agents over UCP: <a href="/.well-known/ucp">/.well-known/ucp</a>.</p>`);
|
|
322
|
-
// A custom storefront (an uploaded index.html) serves every path; otherwise the theme renders its routes.
|
|
323
|
-
if (!agentOnly && files['index.html'] === undefined) {
|
|
324
|
-
const page = renderTheme(r, path, search, headers);
|
|
325
|
-
if (page)
|
|
326
|
-
return page;
|
|
327
|
-
}
|
|
328
|
-
// /img/ is the store's images (uploaded by apply), whatever is in front of them.
|
|
329
|
-
const asset = r.asset_files?.[path.replace(/^\//, '')];
|
|
330
|
-
if (asset !== undefined) {
|
|
331
|
-
const ext = path.split('.').pop() ?? '';
|
|
332
|
-
return { status: 200, headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' }, body: Buffer.from(asset, 'base64').toString('binary') };
|
|
333
|
-
}
|
|
334
|
-
if (agentOnly)
|
|
335
|
-
return html(404, 'Not found', `<p>This store has no pages for ${esc(path)}: it sells over UCP.</p>`);
|
|
336
|
-
const candidates = [path.replace(/^\//, ''), `${path.replace(/^\/|\/$/g, '')}/index.html`.replace(/^\//, ''), ...(path === '/' ? ['index.html'] : [])];
|
|
337
|
-
for (const c of candidates) {
|
|
338
|
-
if (files[c] !== undefined) {
|
|
339
|
-
const ext = c.split('.').pop() ?? '';
|
|
340
|
-
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') };
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return html(404, 'Not found', `<p>No ${path} in this storefront.</p>`);
|
|
344
|
-
}
|
|
345
|
-
// ---------- the host
|
|
346
|
-
export function handleUcp(req, r) {
|
|
347
|
-
const origin = `https://${req.url.hostname}`;
|
|
348
|
-
const path = req.url.pathname.replace(/\/+$/, '') || '/';
|
|
349
|
-
if (path === '/.well-known/ucp' && req.method === 'GET')
|
|
350
|
-
return json(200, profile(r, origin), { 'cache-control': 'public, max-age=300' });
|
|
351
|
-
const oauth = handleOauth(req, r, origin, path);
|
|
352
|
-
if (oauth)
|
|
353
|
-
return oauth;
|
|
354
|
-
if (path.startsWith('/ucp/v1/') || path === '/ucp/v1') {
|
|
355
|
-
const res = handleService({ ...req, headers: { ...req.headers } }, r, origin, path.slice('/ucp/v1'.length) || '/');
|
|
356
|
-
return res;
|
|
357
|
-
}
|
|
358
|
-
if (path === '/buy' || path.startsWith('/buy/'))
|
|
359
|
-
return handlePermalink(req, r, origin, path);
|
|
360
|
-
let m;
|
|
361
|
-
if ((m = path.match(/^\/policies\/(privacy|terms|returns)$/))) {
|
|
362
|
-
const titles = { privacy: 'Privacy policy', terms: 'Terms of service', returns: 'Returns' };
|
|
363
|
-
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>`);
|
|
364
|
-
}
|
|
365
|
-
if ((m = path.match(/^\/(carts|checkout-sessions|orders)\/([^/]+)$/))) {
|
|
366
|
-
const kind = m[1] === 'carts' ? 'cart' : m[1] === 'orders' ? 'order' : 'checkout';
|
|
367
|
-
const found = kind === 'cart' ? r.ucp.carts[m[2]] : kind === 'order' ? r.ucp.orders[m[2]] : r.ucp.checkouts[m[2]];
|
|
368
|
-
if (!found)
|
|
369
|
-
return html(404, 'Not found', `<p>No ${kind} ${m[2]}.</p>`);
|
|
370
|
-
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>`);
|
|
371
|
-
}
|
|
372
|
-
return serveStorefront(r, path, req.url.searchParams, req.headers);
|
|
373
|
-
}
|
|
374
|
-
/** Lets the service read the bearer token for order authorization. */
|
|
375
|
-
export function bearerOf(req) {
|
|
376
|
-
return req.headers.authorization?.replace(/^Bearer /i, '');
|
|
377
|
-
}
|
package/dist/api/transport.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
import { USER_AGENT } from '../version.js';
|
|
9
|
-
export const DEFAULT_API_URL = 'https://api.commercelayer.ai';
|
|
10
|
-
export const apiUrl = () => process.env.C11R_API_URL ?? DEFAULT_API_URL;
|
|
11
|
-
/** Where a request goes: to a local API, other hosts through its /-/<host>/ prefix. */
|
|
12
|
-
function targetOf(url) {
|
|
13
|
-
const base = apiUrl();
|
|
14
|
-
const baseUrl = new URL(base);
|
|
15
|
-
const targetUrl = new URL(url);
|
|
16
|
-
if (base !== DEFAULT_API_URL && targetUrl.host !== baseUrl.host && !url.startsWith(base)) {
|
|
17
|
-
return `${base.replace(/\/$/, '')}/-/${targetUrl.host}${targetUrl.pathname}${targetUrl.search}`;
|
|
18
|
-
}
|
|
19
|
-
return url;
|
|
20
|
-
}
|
|
21
|
-
export async function send(method, url, headers = {}, body) {
|
|
22
|
-
const base = apiUrl();
|
|
23
|
-
const lower = {};
|
|
24
|
-
for (const [k, v] of Object.entries(headers))
|
|
25
|
-
lower[k.toLowerCase()] = v;
|
|
26
|
-
// On every request, to the API and to stores: the API tells an outdated CLI so (cli_outdated).
|
|
27
|
-
lower['user-agent'] ??= USER_AGENT;
|
|
28
|
-
if (body !== undefined && !lower['content-type'])
|
|
29
|
-
lower['content-type'] = 'application/json';
|
|
30
|
-
if (base === 'stub') {
|
|
31
|
-
const r = handle({ method, url: new URL(url), headers: lower, body });
|
|
32
|
-
return { ...r, body: r.body.toString() };
|
|
33
|
-
}
|
|
34
|
-
const target = targetOf(url);
|
|
35
|
-
let res;
|
|
36
|
-
try {
|
|
37
|
-
// Never follow redirects: UCP forbids it for profiles, and the 302/303 of
|
|
38
|
-
// /oauth/authorize and the permalink are the answer, not a hop.
|
|
39
|
-
res = await fetch(target, { method, headers: lower, body, redirect: 'manual' });
|
|
40
|
-
}
|
|
41
|
-
catch (e) {
|
|
42
|
-
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".');
|
|
43
|
-
}
|
|
44
|
-
const resHeaders = {};
|
|
45
|
-
res.headers.forEach((v, k) => (resHeaders[k] = v));
|
|
46
|
-
return { status: res.status, headers: resHeaders, body: await res.text() };
|
|
47
|
-
}
|
|
48
|
-
/** A file over GET, as bytes, within timeoutMs; redirects are followed (images sit behind CDNs). Throws on failure. */
|
|
49
|
-
export async function download(url, timeoutMs) {
|
|
50
|
-
const headers = { 'user-agent': USER_AGENT, accept: 'image/*' };
|
|
51
|
-
if (apiUrl() === 'stub') {
|
|
52
|
-
const r = handle({ method: 'GET', url: new URL(url), headers });
|
|
53
|
-
return { status: r.status, type: r.headers['content-type'] ?? '', bytes: Buffer.from(r.body) };
|
|
54
|
-
}
|
|
55
|
-
const res = await fetch(targetOf(url), { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
56
|
-
return { status: res.status, type: res.headers.get('content-type') ?? '', bytes: Buffer.from(await res.arrayBuffer()) };
|
|
57
|
-
}
|
package/dist/api/types.js
DELETED