@tokenoftrust/storefront-runner 2.2.99 → 2.2.100

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.
@@ -55,6 +55,7 @@ export const CAPTURABLE_PROBLEM_CODES: readonly string[] = Object.freeze([
55
55
  "not-ready",
56
56
  "delivery-undeliverable",
57
57
  "checks-blocked",
58
+ "compliance-review",
58
59
  ]);
59
60
 
60
61
  /** A captured event, after the allowlist has had its say. Every variant is closed-vocabulary. */
@@ -62,6 +62,7 @@ function severityFor(code: string | null | undefined): DiagnosticReport["severit
62
62
  case "bundle-unavailable":
63
63
  return "critical";
64
64
  case "checks-blocked":
65
+ case "compliance-review":
65
66
  case "needs-confirmation":
66
67
  case "not-ready":
67
68
  return "medium";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.99",
3
+ "version": "2.2.100",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -1,12 +1,18 @@
1
1
  /**
2
- * The `.tot/config.json` keys the platform reads, and the refusal of everything
3
- * else. Shared by `tot validate` (src/validate.mjs) and the monorepo/dev-loop
4
- * validator (scripts/tenant/validate.mjs), so both refuse the same keys.
2
+ * The `.tot/config.json` keys the platform reads, and what happens to everything else.
3
+ * Shared by `tot validate` (src/validate.mjs), the monorepo/dev-loop validator
4
+ * (scripts/tenant/validate.mjs), the hosted reconcile, and the publication validator
5
+ * (scripts/publish/validate-bundle.mjs), so every surface says the same thing.
6
+ *
7
+ * An unknown key is the store owner's to decide about: it is reported as a WARNING the
8
+ * owner must approve before publication, naming the nearest key the platform does read
9
+ * and what will not render because of it. A wrong-shaped value of a known key (a
10
+ * `siteType` that is not a site type, a `compliance` that is not an object) stays an
11
+ * error: there is no reading of it the platform could act on.
5
12
  *
6
13
  * Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
7
- * to npm on its own); test/declared-config-parity.test.mjs pins the two together, so
8
- * a field the platform honors can never be refused here, and a key no surface reads
9
- * can never pass. Dependency-free: the storefront runner copies this file as-is.
14
+ * to npm on its own); test/declared-config-parity.test.mjs pins the two together.
15
+ * Dependency-free: the storefront runner copies this file as-is.
10
16
  */
11
17
 
12
18
  export const TOT_CONFIG_KEYS = [
@@ -24,7 +30,33 @@ export const FEATURE_KEYS = [
24
30
  ];
25
31
  export const SITE_TYPES = ["commerce", "marketing"];
26
32
 
33
+ /** What each compliance key puts on the page — the thing that is missing when it is. */
34
+ export const COMPLIANCE_NOTICES = {
35
+ ruleProfile: "checkout compliance rules",
36
+ minAge: "age gate",
37
+ nicotineWarning: "FDA nicotine warning",
38
+ shippingRestriction: "shipping-restriction notice",
39
+ pactAct: "PACT Act notice",
40
+ adultSignature: "adult-signature notice",
41
+ prop65: "California Prop 65 warning",
42
+ purchaseLimit: "purchase-limit notice",
43
+ stateEligibility: "state-eligibility notice",
44
+ exciseTax: "excise-tax notice",
45
+ };
46
+
47
+ /**
48
+ * The compliance keys that each turn on a mandated duty in the platform's obligation
49
+ * model (packages/public-runtime/src/compliance-obligations.ts). Dropping one between
50
+ * the live revision and the one being published removes a mandated notice from the
51
+ * site — a decision the owner must make explicitly.
52
+ */
53
+ export const MANDATED_NOTICE_KEYS = [
54
+ "minAge", "nicotineWarning", "pactAct", "adultSignature", "prop65",
55
+ "stateEligibility", "shippingRestriction", "exciseTax",
56
+ ];
57
+
27
58
  const ERROR = "error";
59
+ const WARN = "warn";
28
60
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
61
 
30
62
  /** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
@@ -32,43 +64,150 @@ function mk(level, rule, file, message, fix) {
32
64
  return { level, rule, file, message, fix };
33
65
  }
34
66
 
67
+ /** Case-insensitive edit distance. */
68
+ function distance(a, b) {
69
+ const s = a.toLowerCase();
70
+ const t = b.toLowerCase();
71
+ const row = Array.from({ length: t.length + 1 }, (_, j) => j);
72
+ for (let i = 1; i <= s.length; i++) {
73
+ let prev = row[0];
74
+ row[0] = i;
75
+ for (let j = 1; j <= t.length; j++) {
76
+ const cur = row[j];
77
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (s[i - 1] === t[j - 1] ? 0 : 1));
78
+ prev = cur;
79
+ }
80
+ }
81
+ return row[t.length];
82
+ }
83
+
35
84
  /**
36
- * Refuse what the platform would silently ignore: an unknown top-level key, an
37
- * unknown `compliance`/`features` key, or a declared field of the wrong shape. Each
38
- * one passes JSON parsing and renders nothing — on a regulated store that is a
39
- * mandated notice that never appears.
85
+ * The known key an unknown one most plausibly meant, or null when none is close.
86
+ * @param {string} key
87
+ * @param {readonly string[]} known
88
+ * @returns {string | null}
89
+ */
90
+ export function nearestKey(key, known) {
91
+ let best = null;
92
+ let bestDistance = Infinity;
93
+ for (const candidate of known) {
94
+ const d = distance(key, candidate);
95
+ if (d < bestDistance) {
96
+ best = candidate;
97
+ bestDistance = d;
98
+ }
99
+ }
100
+ const lower = key.toLowerCase();
101
+ const near = best !== null && (bestDistance <= Math.max(2, Math.floor(best.length / 3)) ||
102
+ best.toLowerCase().startsWith(lower) || lower.startsWith(best.toLowerCase()));
103
+ return near ? best : null;
104
+ }
105
+
106
+ /**
107
+ * Every key in a `.tot/config.json` the platform does not read, with the key it most
108
+ * plausibly meant and the consequence. `path` is dotted (`compliance.nicotineWarnings`).
109
+ * @param {Record<string, any>} config
110
+ * @returns {{ path: string, suggestion: string | null, consequence: string }[]}
111
+ */
112
+ export function unknownConfigKeys(config) {
113
+ const out = [];
114
+ for (const key of Object.keys(config)) {
115
+ if (TOT_CONFIG_KEYS.includes(key)) continue;
116
+ const suggestion = RENAMED_CONFIG_KEYS[/** @type {keyof typeof RENAMED_CONFIG_KEYS} */ (key)] ??
117
+ nearestKey(key, TOT_CONFIG_KEYS);
118
+ out.push({ path: key, suggestion, consequence: "the platform never reads it" });
119
+ }
120
+ /** @type {Array<[string, string[]]>} */
121
+ const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
122
+ for (const [field, known] of objectFields) {
123
+ const value = config[field];
124
+ if (!isObject(value)) continue;
125
+ for (const key of Object.keys(value)) {
126
+ if (known.includes(key)) continue;
127
+ const suggestion = nearestKey(key, known);
128
+ const notice = field === "compliance" && suggestion && !(suggestion in value)
129
+ ? COMPLIANCE_NOTICES[/** @type {keyof typeof COMPLIANCE_NOTICES} */ (suggestion)]
130
+ : null;
131
+ out.push({
132
+ path: `${field}.${key}`,
133
+ suggestion: suggestion ? `${field}.${suggestion}` : null,
134
+ consequence: notice
135
+ ? `this setting does nothing — your ${notice} will NOT render`
136
+ : "this setting does nothing",
137
+ });
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /** The one-line, owner-facing sentence for an unknown key. */
144
+ function unknownKeyMessage({ path, suggestion, consequence }) {
145
+ return `\`${path}\` is not a .tot/config.json key${suggestion ? ` — did you mean \`${suggestion}\`?` : ""} ` +
146
+ `${consequence.charAt(0).toUpperCase()}${consequence.slice(1)}.`;
147
+ }
148
+
149
+ /**
150
+ * The mandated notices the live revision declared that this one no longer does.
151
+ * @param {Record<string, any> | null | undefined} previous the live revision's `.tot/config.json`
152
+ * @param {Record<string, any> | null | undefined} current this revision's `.tot/config.json`
153
+ * @returns {{ key: string, notice: string }[]}
154
+ */
155
+ export function droppedMandatedNotices(previous, current) {
156
+ const before = isObject(previous?.compliance) ? previous.compliance : {};
157
+ const now = isObject(current?.compliance) ? current.compliance : {};
158
+ return MANDATED_NOTICE_KEYS
159
+ .filter((key) => Boolean(before[key]) && !now[key])
160
+ .map((key) => ({ key, notice: COMPLIANCE_NOTICES[/** @type {keyof typeof COMPLIANCE_NOTICES} */ (key)] }));
161
+ }
162
+
163
+ /**
164
+ * The publication validator's findings for a revision's declaration: an owner-approvable
165
+ * `CONFIG:` finding per unknown key and a `COMPLIANCE:` finding per mandated notice the
166
+ * live site shows and this revision drops. Both are QUALITY findings
167
+ * (scripts/publish/lib/finding-severity.mjs): publication halts until the owner accepts
168
+ * them through "Accept issue & continue".
169
+ * @param {Record<string, any> | null | undefined} current
170
+ * @param {Record<string, any> | null | undefined} previous
171
+ * @returns {string[]}
172
+ */
173
+ export function declarationFindings(current, previous) {
174
+ const out = [];
175
+ if (isObject(current)) {
176
+ for (const unknown of unknownConfigKeys(current)) out.push(`CONFIG: ${unknownKeyMessage(unknown)}`);
177
+ }
178
+ for (const { key, notice } of droppedMandatedNotices(previous, current)) {
179
+ out.push(
180
+ `COMPLIANCE: the live site shows the ${notice} (\`compliance.${key}\`) and this revision no longer ` +
181
+ `declares it — publishing removes a mandated notice from the store.`,
182
+ );
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * The validator's report on a `.tot/config.json`: a WARNING per unknown key (owner-approved
189
+ * before publication), an error per wrong-shaped value of a known key.
40
190
  * @param {Record<string, any>} config parsed `.tot/config.json` (an object)
41
191
  * @param {string} [file]
42
192
  * @returns {Finding[]}
43
193
  */
44
194
  export function validateDeclaredConfig(config, file = ".tot/config.json") {
45
195
  const out = [];
46
- for (const key of Object.keys(config)) {
47
- if (!TOT_CONFIG_KEYS.includes(key)) {
48
- const renamed = RENAMED_CONFIG_KEYS[/** @type {keyof typeof RENAMED_CONFIG_KEYS} */ (key)];
49
- out.push(mk(ERROR, "config-unknown-key", file,
50
- `\`${key}\` is not a .tot/config.json key — the platform never reads it`,
51
- renamed ? `use \`${renamed}\`` : `known keys: ${TOT_CONFIG_KEYS.join(", ")}`));
52
- }
196
+ for (const unknown of unknownConfigKeys(config)) {
197
+ const nested = unknown.path.includes(".");
198
+ out.push(mk(WARN, nested ? `config-${unknown.path.split(".")[0]}-unknown-key` : "config-unknown-key",
199
+ nested ? `${file} ${unknown.path.split(".")[0]}` : file,
200
+ unknownKeyMessage(unknown),
201
+ unknown.suggestion
202
+ ? `rename it to \`${unknown.suggestion}\`, or keep it and approve the warning when you publish`
203
+ : "remove it, or keep it and approve the warning when you publish"));
53
204
  }
54
205
  if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
55
206
  out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
56
207
  }
57
- /** @type {Array<[string, string[]]>} */
58
- const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
59
- for (const [field, known] of objectFields) {
60
- const value = config[field];
61
- if (value === undefined) continue;
62
- if (!isObject(value)) {
208
+ for (const field of ["compliance", "features"]) {
209
+ if (config[field] !== undefined && !isObject(config[field])) {
63
210
  out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
64
- continue;
65
- }
66
- for (const key of Object.keys(value)) {
67
- if (!known.includes(key)) {
68
- out.push(mk(ERROR, `config-${field}-unknown-key`, `${file} ${field}`,
69
- `\`${field}.${key}\` is not a ${field} key — nothing renders from it`,
70
- `known keys: ${known.join(", ")}`));
71
- }
72
211
  }
73
212
  }
74
213
  if (config.capabilities !== undefined && !isObject(config.capabilities)) {
@@ -271,6 +271,7 @@ function validateCapabilitiesDoc(doc, file, config) {
271
271
  return [mk(ERROR, "capabilities-root", file, "capabilities must be a JSON object")];
272
272
  }
273
273
  for (const [key, value] of Object.entries(doc)) {
274
+ if (key === "$schema") continue; // an editor's schema hint, which the devkit tells stores to add
274
275
  if (!CAPABILITY_KEYS.has(key)) {
275
276
  out.push(mk(ERROR, "capability-unknown", file, `unknown capability "${key}"`));
276
277
  continue;