@tokenoftrust/cli 4.7.4 → 4.7.6
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 +1 -1
- package/src/commands/preview-token.mjs +4 -2
- package/src/declared-config.d.mts +3 -0
- package/src/declared-config.mjs +42 -1
- package/src/validate.mjs +19 -0
- package/src/viewer-session.mjs +31 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.6",
|
|
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;
|
|
@@ -22,6 +22,9 @@ export const DEFAULT_CURRENCY: "USD";
|
|
|
22
22
|
export const CURRENCY_CODES: readonly string[];
|
|
23
23
|
export function resolveStoreCurrency(declared: unknown): string;
|
|
24
24
|
export function currencyDeclarationProblems(currency: unknown): string[];
|
|
25
|
+
export const DEFAULT_TIMEZONE: "UTC";
|
|
26
|
+
export function resolveStoreTimeZone(declared: unknown): string;
|
|
27
|
+
export function timezoneDeclarationProblems(timezone: unknown): string[];
|
|
25
28
|
export const SHIPPING_KEYS: readonly string[];
|
|
26
29
|
export const SHIPPING_TIER_KEYS: readonly string[];
|
|
27
30
|
export const MAX_SHIPPING_AMOUNT: number;
|
package/src/declared-config.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
export const TOT_CONFIG_KEYS = [
|
|
21
21
|
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
22
|
-
"displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency", "shipping",
|
|
22
|
+
"displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency", "timezone", "shipping",
|
|
23
23
|
];
|
|
24
24
|
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
25
25
|
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
@@ -69,6 +69,42 @@ export function currencyDeclarationProblems(currency) {
|
|
|
69
69
|
: [`\`currency\` is ${JSON.stringify(currency)} — must be an uppercase ISO 4217 code such as "USD" or "CAD"`];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** The IANA time zone a store's days are counted in when `.tot/config.json` declares none. */
|
|
73
|
+
export const DEFAULT_TIMEZONE = "UTC";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What is wrong with a declared `timezone`, one sentence; `[]` when it is valid. A valid one is
|
|
77
|
+
* an IANA zone name the runtime's Intl knows, such as "America/Los_Angeles" or "UTC"; a numeric
|
|
78
|
+
* offset is not a zone, so "today" would drift across daylight-saving changes.
|
|
79
|
+
*
|
|
80
|
+
* @param {unknown} timezone
|
|
81
|
+
* @returns {string[]}
|
|
82
|
+
*/
|
|
83
|
+
export function timezoneDeclarationProblems(timezone) {
|
|
84
|
+
const named = typeof timezone === "string" && /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/.test(timezone);
|
|
85
|
+
if (named) {
|
|
86
|
+
try {
|
|
87
|
+
new Intl.DateTimeFormat("en-US", { timeZone: /** @type {string} */ (timezone) });
|
|
88
|
+
return [];
|
|
89
|
+
} catch {
|
|
90
|
+
/* not a zone the runtime knows */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [`\`timezone\` is ${JSON.stringify(timezone)} — must be an IANA time zone name such as "America/Los_Angeles" or "UTC"`];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The ONE place the time zone a store's days are counted in is resolved. A declared IANA zone
|
|
98
|
+
* wins; anything else (absent, wrong shape, unknown zone) is UTC. "Today", a daily total and an
|
|
99
|
+
* order's displayed time take the zone from here, never from the server's clock.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} declared
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function resolveStoreTimeZone(declared) {
|
|
105
|
+
return timezoneDeclarationProblems(declared).length === 0 ? /** @type {string} */ (declared) : DEFAULT_TIMEZONE;
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
|
|
73
109
|
/** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
|
|
74
110
|
export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimSeoTitles", "structuredData"]);
|
|
@@ -737,6 +773,11 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
737
773
|
out.push(mk(ERROR, "config-currency", file, `${problem} — the store sells in ${DEFAULT_CURRENCY} until it is fixed`));
|
|
738
774
|
}
|
|
739
775
|
}
|
|
776
|
+
if (config.timezone !== undefined) {
|
|
777
|
+
for (const problem of timezoneDeclarationProblems(config.timezone)) {
|
|
778
|
+
out.push(mk(ERROR, "config-timezone", file, `${problem} — the store counts its days in ${DEFAULT_TIMEZONE} until it is fixed`));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
740
781
|
if (config.seo !== undefined) {
|
|
741
782
|
for (const problem of seoDeclarationProblems(config.seo)) {
|
|
742
783
|
out.push(mk(ERROR, "config-seo", `${file} seo`, `${problem} — the whole \`seo\` declaration is ignored until it is fixed`));
|
package/src/validate.mjs
CHANGED
|
@@ -441,8 +441,11 @@ export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
|
|
|
441
441
|
"block-palette@5",
|
|
442
442
|
"block-palette@6",
|
|
443
443
|
"block-palette@7",
|
|
444
|
+
"block-palette@8",
|
|
444
445
|
]);
|
|
445
446
|
export const FEATURED_PRODUCT_PRESENTATIONS = Object.freeze(["grid", "carousel"]);
|
|
447
|
+
export const LATEST_POSTS_DEFAULT_LIMIT = 3;
|
|
448
|
+
export const LATEST_POSTS_MAX_LIMIT = 12;
|
|
446
449
|
const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
|
|
447
450
|
const KNOWN_BLOCKS = new Set([
|
|
448
451
|
"hero",
|
|
@@ -452,6 +455,7 @@ const KNOWN_BLOCKS = new Set([
|
|
|
452
455
|
"featured_products",
|
|
453
456
|
"editorial",
|
|
454
457
|
"newsletter",
|
|
458
|
+
"latest_posts",
|
|
455
459
|
"membership_band",
|
|
456
460
|
"marketing_hero",
|
|
457
461
|
"trust_bar",
|
|
@@ -736,6 +740,20 @@ function validateEditorial(block, file, at, out) {
|
|
|
736
740
|
}
|
|
737
741
|
}
|
|
738
742
|
|
|
743
|
+
function validateLatestPosts(block, file, at, out) {
|
|
744
|
+
if (block.limit !== undefined && (!Number.isInteger(block.limit) || block.limit < 1 || block.limit > LATEST_POSTS_MAX_LIMIT)) {
|
|
745
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a whole number from 1 to ${LATEST_POSTS_MAX_LIMIT}`));
|
|
746
|
+
}
|
|
747
|
+
for (const prop of ["eyebrow", "title", "tag", "ctaLabel", "ctaHref"]) {
|
|
748
|
+
if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
|
|
749
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (typeof block.ctaHref === "string" && block.ctaHref.trim() && !isSafeStorefrontHref(block.ctaHref)) {
|
|
753
|
+
out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
739
757
|
function validateHomeDoc(doc, file, tenantId) {
|
|
740
758
|
const out = [];
|
|
741
759
|
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
@@ -800,6 +818,7 @@ function validateHomeDoc(doc, file, tenantId) {
|
|
|
800
818
|
if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
|
|
801
819
|
if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
|
|
802
820
|
if (component === "faq") validateFaq(block, file, at, out);
|
|
821
|
+
if (component === "latest_posts") validateLatestPosts(block, file, at, out);
|
|
803
822
|
});
|
|
804
823
|
return out;
|
|
805
824
|
}
|
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.
|