@tokenoftrust/cli 4.7.2 → 4.7.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "4.7.2",
3
+ "version": "4.7.4",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
19
19
  import { dirname, join } from "node:path";
20
+ import { parseSitemapChildPathV1 } from "./sitemap-index.mjs";
20
21
 
21
22
  /** Where `tot clone` puts each mounted space, relative to the root checkout: `spaces/<spaceId>/`. */
22
23
  export const SPACES_DIR = "spaces";
@@ -26,7 +27,9 @@ export const TOPOLOGY_VERSION = 1;
26
27
 
27
28
  /**
28
29
  * Platform-owned paths no space serves (packages/public-runtime PUBLICATION_SPACE_RESERVED_MOUNTS;
29
- * the ownership parity test holds the two equal).
30
+ * the ownership parity test holds the two equal). Child sitemaps (`/sitemap-<group>-<page>.xml`) are
31
+ * platform-owned too, recognized by `parseSitemapChildPathV1` (a byte-identical copy of
32
+ * packages/public-runtime/src/sitemap-index.mjs, pinned by test/sitemap-index-copy.test.mjs).
30
33
  */
31
34
  export const PLATFORM_PATHS = Object.freeze([
32
35
  "/.well-known", "/__evidence", "/__preview", "/_a", "/_astro", "/_style-guide", "/admin", "/api",
@@ -167,7 +170,9 @@ export function owningSpace(topology, urlPath) {
167
170
  const path = normalizeRequestPath(urlPath);
168
171
  if (path === null) return null;
169
172
  const folded = path.toLowerCase();
170
- if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded))) return { space: null, via: "platform" };
173
+ if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded)) || parseSitemapChildPathV1(folded) !== null) {
174
+ return { space: null, via: "platform" };
175
+ }
171
176
  /** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
172
177
  let best = null;
173
178
  for (const space of topology.spaces) {
@@ -0,0 +1,157 @@
1
+ // @ts-check
2
+ /**
3
+ * The tenant sitemap contract, shared by the Worker that renders it and the publish tooling
4
+ * that materializes and validates it on both serving substrates.
5
+ *
6
+ * `/sitemap.xml` is a `<sitemapindex>`. Its entries are typed, paged child sitemaps at
7
+ * `/sitemap-<group>-<page>.xml`, each a `<urlset>` of at most {@link SITEMAP_MAX_URLS_PER_CHILD_V1}
8
+ * URLs. A child path ends in `.xml`, so S3/CloudFront and the Cloudflare static bundle both serve
9
+ * it as a plain file with no rewrite, and the materializer writes it at the same bundle path.
10
+ *
11
+ * Pure: no I/O, no platform imports.
12
+ */
13
+
14
+ /** @typedef {{loc: string, lastmod?: string}} SitemapUrl */
15
+
16
+ /** The one discovery entry point robots.txt advertises. */
17
+ export const SITEMAP_INDEX_PATH_V1 = "/sitemap.xml";
18
+
19
+ /**
20
+ * URLs per child sitemap. The protocol allows 50,000; 5,000 keeps each child a small, fast,
21
+ * individually cacheable document and matches the paging large commerce platforms use.
22
+ */
23
+ export const SITEMAP_MAX_URLS_PER_CHILD_V1 = 5000;
24
+
25
+ /** Child sitemap groups, in the order the index lists them. */
26
+ export const SITEMAP_GROUPS_V1 = Object.freeze(["pages", "collections", "products", "blogs", "build", "spaces"]);
27
+
28
+ const CHILD_PATH = /^\/sitemap-([a-z]+)-([1-9][0-9]{0,5})\.xml$/;
29
+
30
+ /**
31
+ * `/sitemap-<group>-<page>.xml` for a declared group and a 1-based page.
32
+ * @param {string} group @param {number} page @returns {string}
33
+ */
34
+ export function sitemapChildPathV1(group, page) {
35
+ if (!SITEMAP_GROUPS_V1.includes(group)) throw new Error(`unknown sitemap group ${JSON.stringify(group)}`);
36
+ if (!Number.isInteger(page) || page < 1) throw new Error(`sitemap page must be a positive integer, got ${page}`);
37
+ return `/sitemap-${group}-${page}.xml`;
38
+ }
39
+
40
+ /**
41
+ * The group and page a child sitemap path names, or null when it names none.
42
+ * @param {unknown} path @returns {{group: string, page: number} | null}
43
+ */
44
+ export function parseSitemapChildPathV1(path) {
45
+ if (typeof path !== "string") return null;
46
+ const match = CHILD_PATH.exec(path);
47
+ const group = match?.[1];
48
+ if (!match || group === undefined || !SITEMAP_GROUPS_V1.includes(group)) return null;
49
+ return { group, page: Number(match[2]) };
50
+ }
51
+
52
+ /** True for `/sitemap.xml` and for every well-formed child sitemap path. @param {string} path */
53
+ export function isSitemapDocumentPathV1(path) {
54
+ return path === SITEMAP_INDEX_PATH_V1 || parseSitemapChildPathV1(path) !== null;
55
+ }
56
+
57
+ /**
58
+ * Page each group into children of at most {@link SITEMAP_MAX_URLS_PER_CHILD_V1} URLs, in
59
+ * {@link SITEMAP_GROUPS_V1} order. An empty group yields no child.
60
+ * @param {ReadonlyArray<{group: string, urls: readonly SitemapUrl[]}>} groups
61
+ * @param {number} [maxPerChild]
62
+ * @returns {{group: string, page: number, path: string, urls: SitemapUrl[]}[]}
63
+ */
64
+ export function chunkSitemapGroupsV1(groups, maxPerChild = SITEMAP_MAX_URLS_PER_CHILD_V1) {
65
+ /** @type {Map<string, SitemapUrl[]>} */
66
+ const byGroup = new Map();
67
+ for (const { group, urls } of groups) {
68
+ if (!SITEMAP_GROUPS_V1.includes(group)) throw new Error(`unknown sitemap group ${JSON.stringify(group)}`);
69
+ byGroup.set(group, [...(byGroup.get(group) ?? []), ...urls]);
70
+ }
71
+ /** @type {{group: string, page: number, path: string, urls: SitemapUrl[]}[]} */
72
+ const children = [];
73
+ for (const group of SITEMAP_GROUPS_V1) {
74
+ const urls = byGroup.get(group) ?? [];
75
+ for (let start = 0, page = 1; start < urls.length; start += maxPerChild, page += 1) {
76
+ children.push({ group, page, path: sitemapChildPathV1(group, page), urls: urls.slice(start, start + maxPerChild) });
77
+ }
78
+ }
79
+ return children;
80
+ }
81
+
82
+ /** @param {string} value */
83
+ function escapeXml(value) {
84
+ return String(value)
85
+ .replace(/&/g, "&amp;")
86
+ .replace(/</g, "&lt;")
87
+ .replace(/>/g, "&gt;")
88
+ .replace(/"/g, "&quot;")
89
+ .replace(/'/g, "&apos;");
90
+ }
91
+
92
+ /**
93
+ * The newest `lastmod` among `urls`, spelled as its source spelled it; undefined when none parses.
94
+ * @param {readonly SitemapUrl[]} urls @returns {string | undefined}
95
+ */
96
+ export function newestLastmodV1(urls) {
97
+ /** @type {string | undefined} */
98
+ let newest;
99
+ let newestTime = -Infinity;
100
+ for (const { lastmod } of urls) {
101
+ const time = lastmod ? Date.parse(lastmod) : NaN;
102
+ if (Number.isFinite(time) && time > newestTime) {
103
+ newest = lastmod;
104
+ newestTime = time;
105
+ }
106
+ }
107
+ return newest;
108
+ }
109
+
110
+ /** A `<urlset>` document. Locs are written exactly as given. @param {readonly SitemapUrl[]} urls */
111
+ export function renderSitemapUrlsetV1(urls) {
112
+ const rows = urls.map((u) =>
113
+ ` <url>\n <loc>${escapeXml(u.loc)}</loc>${u.lastmod ? `\n <lastmod>${escapeXml(u.lastmod)}</lastmod>` : ""}\n </url>`);
114
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join("\n")}\n</urlset>\n`;
115
+ }
116
+
117
+ /**
118
+ * A `<sitemapindex>` naming each child at `base` + its path. `base` is the tenant's canonical
119
+ * base without a trailing slash, path prefix included, so every loc keeps the prefix the page
120
+ * locs carry. Each entry's lastmod is the newest lastmod inside that child, when it has one.
121
+ * @param {string} base @param {readonly {path: string, urls: readonly SitemapUrl[]}[]} children
122
+ */
123
+ export function renderSitemapIndexV1(base, children) {
124
+ const root = base.replace(/\/+$/, "");
125
+ const rows = children.map((child) => {
126
+ const lastmod = newestLastmodV1(child.urls);
127
+ return ` <sitemap>\n <loc>${escapeXml(`${root}${child.path}`)}</loc>${lastmod ? `\n <lastmod>${escapeXml(lastmod)}</lastmod>` : ""}\n </sitemap>`;
128
+ });
129
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join("\n")}\n</sitemapindex>\n`;
130
+ }
131
+
132
+ /** True when `xml` is a `<sitemapindex>` rather than a `<urlset>`. @param {string} xml */
133
+ export function isSitemapIndexXmlV1(xml) {
134
+ return /<sitemapindex[\s>]/.test(xml);
135
+ }
136
+
137
+ /**
138
+ * The child sitemap locs a `<sitemapindex>` names, exactly as spelled; empty for a `<urlset>`.
139
+ * @param {string} xml @returns {string[]}
140
+ */
141
+ export function sitemapIndexLocsV1(xml) {
142
+ if (!isSitemapIndexXmlV1(xml)) return [];
143
+ return [...xml.matchAll(/<sitemap>\s*<loc>([^<]*)<\/loc>/g)].map((match) => (match[1] ?? "").trim());
144
+ }
145
+
146
+ /**
147
+ * Page URLs a sitemap lists, following an index to its children. `readChild` returns a child's
148
+ * body, or null when it cannot be read (which contributes nothing).
149
+ * @param {string} xml @param {(loc: string) => Promise<string | null>} readChild @returns {Promise<number>}
150
+ */
151
+ export async function countSitemapUrlsV1(xml, readChild) {
152
+ /** @param {string} body */
153
+ const urls = (body) => (body.match(/<url>/g) ?? []).length;
154
+ if (!isSitemapIndexXmlV1(xml)) return urls(xml);
155
+ const bodies = await Promise.all(sitemapIndexLocsV1(xml).map(readChild));
156
+ return bodies.reduce((count, body) => count + urls(body ?? ""), 0);
157
+ }
@@ -54,6 +54,29 @@ function originOf(url) {
54
54
  }
55
55
  }
56
56
 
57
+ /** The hosted storefront Worker's own public origins (`[env.preview]`). Only a
58
+ * storefront called at one of these EXACT origins may steer the CLI to present a
59
+ * session for a different MCP issuer: anything reached through `--url` /
60
+ * `TOT_STOREFRONT_URL` is user input, and a hostile one could otherwise name the
61
+ * prod issuer and collect that session. */
62
+ export const FIRST_PARTY_STOREFRONT_ORIGINS = Object.freeze(new Set([
63
+ DEFAULT_STOREFRONT_ORIGIN,
64
+ "https://storefront.tokenoftrust.workers.dev",
65
+ ]));
66
+
67
+ /** The Token of Trust MCP issuers a first-party storefront may name. Exact origins. */
68
+ export const TOKEN_OF_TRUST_MCP_ORIGINS = Object.freeze(new Set([
69
+ "https://mcp.tokenoftrust.com",
70
+ "https://mcp.qa.tokenoftrust.com",
71
+ ]));
72
+
73
+ /** True only when BOTH sides are declared first-party, by exact origin. */
74
+ function mayPresentSessionFor(storefrontBase, issuer) {
75
+ const storefront = originOf(storefrontBase);
76
+ return Boolean(storefront && issuer &&
77
+ FIRST_PARTY_STOREFRONT_ORIGINS.has(storefront) && TOKEN_OF_TRUST_MCP_ORIGINS.has(issuer));
78
+ }
79
+
57
80
  /**
58
81
  * Turn a non-2xx `/api/dev/cli-session` response into one clean, actionable
59
82
  * `{ message, hint }` — the ONE place this CLI interprets that endpoint's refusal
@@ -91,9 +114,13 @@ export function describeCliSessionRefusal(status, body, signedInMcpUrl) {
91
114
  if (error === "unresolved_token" && trustedMcp) {
92
115
  const signedInOrigin = originOf(signedInMcpUrl);
93
116
  if (signedInOrigin && signedInOrigin !== trustedMcp) {
117
+ // `TOT_SELECTED_MCP_URL=… tot login` adds a session for the storefront's MCP
118
+ // WITHOUT rebinding this checkout (`tot login --mcp` inside a checkout would
119
+ // move every command there to the other tier). Once that session exists,
120
+ // `resolveViewerTransport` uses it for the hosted storefront on its own.
94
121
  return {
95
- message: `you're signed in to ${signedInOrigin}, but this store trusts ${trustedMcp}.`,
96
- hint: `run \`tot login --mcp ${trustedMcp}\` (or unset TOT_MCP_URL), then re-run.`,
122
+ message: `you're signed in to ${signedInOrigin}, but the hosted storefront trusts ${trustedMcp}.`,
123
+ hint: `sign in to it once with \`TOT_SELECTED_MCP_URL=${trustedMcp} tot login\` (this checkout stays on ${signedInOrigin}), then re-run.`,
97
124
  };
98
125
  }
99
126
  }
@@ -150,42 +177,48 @@ export async function resolveViewerTransport({
150
177
  }
151
178
 
152
179
  const base = tenantBase(tenant, storefrontUrl);
153
- let res;
154
- try {
155
- // `content-type: application/json` is REQUIRED, not decoration. Astro's origin
156
- // check rejects any unsafe-method request that is cross-origin AND either
157
- // form-like (`application/x-www-form-urlencoded`, `multipart/form-data`,
158
- // `text/plain`) or carries no content-type at all. Node's `fetch` sends no
159
- // `Origin`, so every request from here is "cross-origin" — declaring JSON is what
160
- // takes it out of the form-POST class the check guards. It is an honest
161
- // declaration, not a bypass: a browser cannot send `application/json`
162
- // cross-origin without a CORS preflight this worker never answers, so the
163
- // anti-CSRF property is preserved exactly. Spoofing an `Origin` header instead
164
- // would assert a browser context that does not exist and protect nothing.
165
- vlog(`POST ${base}/api/dev/cli-session`);
166
- res = await fetchImpl(`${base}/api/dev/cli-session`, {
167
- method: "POST",
168
- headers: {
169
- authorization: `Bearer ${dev.token}`,
170
- "content-type": "application/json",
171
- },
172
- body: "{}",
173
- });
174
- } catch (e) {
175
- return {
176
- ok: false,
177
- message: `couldn't reach ${base} to start a session: ${e?.message || e}`,
178
- hint: "check the --tenant domain / your network, then re-run.",
179
- };
180
+ const first = await mintSession(base, dev.token, fetchImpl, vlog);
181
+ if (!first.ok) return first.refusal;
182
+ /** @type {any} */
183
+ let res = first.response;
184
+
185
+ // The hosted storefront trusts ONE MCP issuer, independent of the tier this
186
+ // checkout is bound to (a QA-registry store is still served by the shared
187
+ // storefront). When it names a different issuer, the session to present is the
188
+ // one for THAT issuer — the checkout's own binding is for the registry, not for
189
+ // this transport. Use it when the developer holds one; otherwise fall through
190
+ // to the refusal, which says how to get one without rebinding the checkout.
191
+ if (res.status === 401) {
192
+ const body = await readJson(res);
193
+ const trustedMcp = typeof body?.trustedMcp === "string" ? body.trustedMcp : null;
194
+ const signedInOrigin = originOf(dev.mcpUrl);
195
+ if (
196
+ body?.error === "unresolved_token" &&
197
+ mayPresentSessionFor(base, trustedMcp) &&
198
+ signedInOrigin && signedInOrigin !== trustedMcp
199
+ ) {
200
+ let trustedDev = null;
201
+ try {
202
+ trustedDev = await resolveDev(null, { ...env, TOT_SELECTED_MCP_URL: trustedMcp }, { fetchImpl });
203
+ } catch {
204
+ /* no usable session for the storefront's issuer — report the mismatch below */
205
+ }
206
+ if (trustedDev?.token && originOf(trustedDev.mcpUrl) === trustedMcp) {
207
+ vlog(`cli-session: retrying with your ${trustedMcp} session`);
208
+ const retry = await mintSession(base, trustedDev.token, fetchImpl, vlog);
209
+ if (!retry.ok) return retry.refusal;
210
+ res = retry.response;
211
+ dev = trustedDev;
212
+ } else {
213
+ res = replayJson(res, body);
214
+ }
215
+ } else {
216
+ res = replayJson(res, body);
217
+ }
180
218
  }
181
- vlog(`cli-session → HTTP ${res.status}`);
219
+
182
220
  if (res.status === 401 || res.status === 502 || res.status === 503) {
183
- let body = null;
184
- try {
185
- body = await res.json();
186
- } catch {
187
- /* non-JSON error body — describeCliSessionRefusal falls back cleanly */
188
- }
221
+ const body = await readJson(res);
189
222
  if (body?.error || body?.code) vlog(`cli-session error: ${[body.error, body.code].filter(Boolean).join(" / ")}`);
190
223
  const { message, hint } = describeCliSessionRefusal(res.status, body, dev.mcpUrl);
191
224
  return { ok: false, message, hint };
@@ -218,3 +251,64 @@ export async function resolveViewerTransport({
218
251
  },
219
252
  };
220
253
  }
254
+
255
+ /** The refusal body, or null when the body is not JSON (describeCliSessionRefusal
256
+ * falls back cleanly). Any other failure — a body already consumed, a stream
257
+ * error — is a bug or a fault, not "no body", and propagates.
258
+ * @param {{ json: () => Promise<any> }} res */
259
+ async function readJson(res) {
260
+ try {
261
+ return await res.json();
262
+ } catch (e) {
263
+ if (!(e instanceof SyntaxError)) throw e;
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /** A response whose body was already read, re-readable once more. */
269
+ function replayJson(res, body) {
270
+ return { ok: res.ok, status: res.status, headers: res.headers, json: async () => body };
271
+ }
272
+
273
+ /**
274
+ * POST the bearer to `<base>/api/dev/cli-session`.
275
+ * @returns {Promise<{ ok: true, response: any } | { ok: false, refusal: { ok: false, message: string, hint: string } }>}
276
+ */
277
+ async function mintSession(base, token, fetchImpl, vlog) {
278
+ let res;
279
+ try {
280
+ // `content-type: application/json` is REQUIRED, not decoration. Astro's origin
281
+ // check rejects any unsafe-method request that is cross-origin AND either
282
+ // form-like (`application/x-www-form-urlencoded`, `multipart/form-data`,
283
+ // `text/plain`) or carries no content-type at all. Node's `fetch` sends no
284
+ // `Origin`, so every request from here is "cross-origin" — declaring JSON is what
285
+ // takes it out of the form-POST class the check guards. It is an honest
286
+ // declaration, not a bypass: a browser cannot send `application/json`
287
+ // cross-origin without a CORS preflight this worker never answers, so the
288
+ // anti-CSRF property is preserved exactly. Spoofing an `Origin` header instead
289
+ // would assert a browser context that does not exist and protect nothing.
290
+ vlog(`POST ${base}/api/dev/cli-session`);
291
+ res = await fetchImpl(`${base}/api/dev/cli-session`, {
292
+ method: "POST",
293
+ headers: {
294
+ authorization: `Bearer ${token}`,
295
+ "content-type": "application/json",
296
+ },
297
+ body: "{}",
298
+ // Never follow a redirect with the bearer attached: a 3xx is a refusal here
299
+ // (non-2xx), so the token only ever reaches the origin it was addressed to.
300
+ redirect: "manual",
301
+ });
302
+ } catch (e) {
303
+ return {
304
+ ok: false,
305
+ refusal: {
306
+ ok: false,
307
+ message: `couldn't reach ${base} to start a session: ${e?.message || e}`,
308
+ hint: "check the --tenant domain / your network, then re-run.",
309
+ },
310
+ };
311
+ }
312
+ vlog(`cli-session → HTTP ${res.status}`);
313
+ return { ok: true, response: res };
314
+ }