@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "4.7.4",
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\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;
@@ -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
  }
@@ -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 } — base = tenant host; cookie transport
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, authHeaders:Record<string,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.