@tokenoftrust/cli 4.7.3 → 4.7.5
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.
|
|
3
|
+
"version": "4.7.5",
|
|
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",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `--json` prints `{ token, exp, ttlSeconds, tenantId, previewVersion }` instead.
|
|
14
14
|
* Treat the token like a password: anyone holding it sees the unpublished site.
|
|
15
15
|
*/
|
|
16
|
-
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
16
|
+
import { resolveViewerTransport, describeIdentity } from "../viewer-session.mjs";
|
|
17
17
|
import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
|
|
18
18
|
|
|
19
19
|
const MAX_TTL_SECONDS = 24 * 3600;
|
|
@@ -115,8 +115,10 @@ export async function run(argv, ctx, deps = {}) {
|
|
|
115
115
|
/* non-JSON error body — this endpoint answers 401/403 as plain text */
|
|
116
116
|
}
|
|
117
117
|
if (res.status === 401 || res.status === 403) {
|
|
118
|
+
const checked = describeIdentity(transport.identity);
|
|
118
119
|
err(
|
|
119
|
-
`✗ your account can't view ${tenant}'s preview.\n
|
|
120
|
+
`✗ your account can't view ${tenant}'s preview.\n` +
|
|
121
|
+
` checked: ${checked ?? "your `tot login` session"} at ${transport.base}\n\n` +
|
|
120
122
|
` → next: ${refusalHint(res.status, missingGrantsOf(data))}.`,
|
|
121
123
|
);
|
|
122
124
|
return 1;
|
|
@@ -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))
|
|
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, "&")
|
|
86
|
+
.replace(/</g, "<")
|
|
87
|
+
.replace(/>/g, ">")
|
|
88
|
+
.replace(/"/g, """)
|
|
89
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/src/viewer-session.mjs
CHANGED
|
@@ -133,7 +133,8 @@ export function describeCliSessionRefusal(status, body, signedInMcpUrl) {
|
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
135
|
* Resolve a viewer-session transport for `tenant`. Returns
|
|
136
|
-
* { ok:true, base, authHeaders }
|
|
136
|
+
* { ok:true, base, identity, authHeaders } — base = tenant host; cookie transport;
|
|
137
|
+
* identity = { account (masked), mcp origin }
|
|
137
138
|
* { ok:false, message, hint } — a clean, actionable refusal
|
|
138
139
|
* Never throws.
|
|
139
140
|
*
|
|
@@ -144,7 +145,8 @@ export function describeCliSessionRefusal(status, body, signedInMcpUrl) {
|
|
|
144
145
|
* storefrontUrl?:string|null, resolveDev?:typeof resolveDeveloperSession,
|
|
145
146
|
* verbose?:boolean, log?:(s:string)=>void }} params
|
|
146
147
|
* @returns {Promise<
|
|
147
|
-
* { ok:true, base:string,
|
|
148
|
+
* { ok:true, base:string, identity:{ account:string|null, mcp:string|null },
|
|
149
|
+
* authHeaders:Record<string,string> } |
|
|
148
150
|
* { ok:false, message:string, hint:string }
|
|
149
151
|
* >}
|
|
150
152
|
*/
|
|
@@ -231,6 +233,7 @@ export async function resolveViewerTransport({
|
|
|
231
233
|
};
|
|
232
234
|
}
|
|
233
235
|
|
|
236
|
+
const minted = await readJson(res);
|
|
234
237
|
const cookie = parseSessionCookie(res.headers.get("set-cookie"));
|
|
235
238
|
if (!cookie) {
|
|
236
239
|
return {
|
|
@@ -243,6 +246,10 @@ export async function resolveViewerTransport({
|
|
|
243
246
|
return {
|
|
244
247
|
ok: true,
|
|
245
248
|
base,
|
|
249
|
+
identity: {
|
|
250
|
+
account: maskAccount(typeof minted?.email === "string" ? minted.email : null),
|
|
251
|
+
mcp: originOf(dev.mcpUrl),
|
|
252
|
+
},
|
|
246
253
|
authHeaders: {
|
|
247
254
|
cookie: `${SESSION_COOKIE}=${cookie}`,
|
|
248
255
|
// Matches the admin browser client; the server IGNORES this hint and re-reads
|
|
@@ -252,6 +259,28 @@ export async function resolveViewerTransport({
|
|
|
252
259
|
};
|
|
253
260
|
}
|
|
254
261
|
|
|
262
|
+
/** A recognizable stand-in for the signed-in account: first character and domain
|
|
263
|
+
* (`d…@example.com`), never the full address.
|
|
264
|
+
* @param {string|null} email @returns {string|null} */
|
|
265
|
+
export function maskAccount(email) {
|
|
266
|
+
const at = email ? email.lastIndexOf("@") : -1;
|
|
267
|
+
if (!email || at < 1) return null;
|
|
268
|
+
return `${email[0]}…${email.slice(at)}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Which identity and tier the store checked, for a refusal line:
|
|
273
|
+
* `d…@example.com via https://mcp.tokenoftrust.com`.
|
|
274
|
+
* @param {{ account?: string|null, mcp?: string|null }|null|undefined} identity
|
|
275
|
+
* @returns {string|null}
|
|
276
|
+
*/
|
|
277
|
+
export function describeIdentity(identity) {
|
|
278
|
+
const account = identity?.account || null;
|
|
279
|
+
const mcp = identity?.mcp || null;
|
|
280
|
+
if (account && mcp) return `${account} via ${mcp}`;
|
|
281
|
+
return account || (mcp ? `your ${mcp} sign-in` : null);
|
|
282
|
+
}
|
|
283
|
+
|
|
255
284
|
/** The refusal body, or null when the body is not JSON (describeCliSessionRefusal
|
|
256
285
|
* falls back cleanly). Any other failure — a body already consumed, a stream
|
|
257
286
|
* error — is a bug or a fault, not "no body", and propagates.
|