@tokenoftrust/storefront-runner 2.0.41 → 2.0.43

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.
Files changed (148) hide show
  1. package/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
  2. package/apps/storefront/package.json +1 -0
  3. package/apps/storefront/platform-reconcile-tenants.json +3 -0
  4. package/apps/storefront/public/favicon.ico +0 -0
  5. package/apps/storefront/public/shared/commerce-chrome.css +147 -16
  6. package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
  7. package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
  8. package/apps/storefront/src/components/Img.astro +6 -5
  9. package/apps/storefront/src/components/ProductCard.astro +6 -4
  10. package/apps/storefront/src/components/Seo.astro +2 -1
  11. package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
  12. package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
  13. package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
  14. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
  15. package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
  16. package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
  17. package/apps/storefront/src/components/home/Hero.astro +156 -4
  18. package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
  19. package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
  20. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
  21. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
  22. package/apps/storefront/src/components/marketing/Faq.astro +9 -2
  23. package/apps/storefront/src/config/devTenantSeed.ts +1 -1
  24. package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
  25. package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
  26. package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
  27. package/apps/storefront/src/config/tenants.ts +22 -0
  28. package/apps/storefront/src/layouts/Layout.astro +69 -44
  29. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
  31. package/apps/storefront/src/lib/asyncCss.ts +23 -0
  32. package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
  33. package/apps/storefront/src/lib/basePath.ts +70 -9
  34. package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
  35. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  36. package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
  37. package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
  38. package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
  39. package/apps/storefront/src/lib/diag/capture.ts +161 -0
  40. package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
  41. package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
  42. package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
  43. package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
  44. package/apps/storefront/src/lib/diag/reference.ts +148 -0
  45. package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
  46. package/apps/storefront/src/lib/diag/report.ts +109 -0
  47. package/apps/storefront/src/lib/diag/session.ts +93 -0
  48. package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
  49. package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
  50. package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
  51. package/apps/storefront/src/lib/imageAttrs.ts +9 -6
  52. package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
  53. package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
  54. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
  55. package/apps/storefront/src/lib/jsonScript.ts +13 -0
  56. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
  57. package/apps/storefront/src/lib/previewReload.ts +53 -9
  58. package/apps/storefront/src/lib/rawChrome.ts +148 -19
  59. package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
  60. package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
  61. package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
  62. package/apps/storefront/src/lib/tenantRouting.ts +15 -4
  63. package/apps/storefront/src/lib/tot/syncState.ts +21 -0
  64. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
  65. package/apps/storefront/src/middleware/controlPlane.ts +9 -5
  66. package/apps/storefront/src/middleware/index.ts +13 -1
  67. package/apps/storefront/src/middleware/renderChain.ts +54 -11
  68. package/apps/storefront/src/middleware/routeBranch.ts +58 -12
  69. package/apps/storefront/src/middleware/staticBundle.ts +37 -3
  70. package/apps/storefront/src/pages/[...slug].astro +6 -2
  71. package/apps/storefront/src/pages/api/request-access.ts +5 -4
  72. package/apps/storefront/src/pages/blog/[slug].astro +11 -1
  73. package/apps/storefront/src/pages/cart.ts +23 -0
  74. package/apps/storefront/src/pages/index.astro +64 -85
  75. package/apps/storefront/src/pages/products/[handle].astro +8 -6
  76. package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
  77. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
  78. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
  79. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
  80. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
  81. package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
  82. package/apps/storefront/src/styles/fonts.css +7 -0
  83. package/apps/storefront/src/styles/global.css +397 -3
  84. package/package.json +4 -1
  85. package/packages/cli/src/visual-parity.mjs +321 -0
  86. package/packages/public-runtime/package.json +7 -0
  87. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  88. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  89. package/packages/public-runtime/src/chrome.ts +44 -7
  90. package/packages/public-runtime/src/csp.ts +12 -1
  91. package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
  92. package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
  93. package/packages/public-runtime/src/extension-contract.ts +20 -6
  94. package/packages/public-runtime/src/image-dependencies.ts +1438 -0
  95. package/packages/public-runtime/src/index.ts +8 -0
  96. package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
  97. package/packages/public-runtime/src/integration/consent-config.ts +9 -0
  98. package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
  99. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  100. package/packages/public-runtime/src/integration/index.ts +1 -0
  101. package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
  102. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  103. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  104. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  105. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
  107. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
  108. package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
  109. package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
  110. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  111. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  112. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  113. package/packages/public-runtime/src/publication-space.ts +24 -3
  114. package/packages/public-runtime/src/static-bundle.ts +145 -26
  115. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  116. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  117. package/packages/public-runtime/src/tenant.ts +59 -1
  118. package/pnpm-lock.runner.yaml +19 -0
  119. package/pnpm-workspace.yaml +4 -0
  120. package/scripts/dev/git-status.mjs +1 -1
  121. package/scripts/dev/publish.mjs +6 -0
  122. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  123. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  124. package/scripts/tenant/validate.mjs +171 -8
  125. package/apps/storefront/src/lib/cfImage.ts +0 -55
  126. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  127. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  128. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  129. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  144. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  145. package/tenants/home/public/js/storefront.js +0 -129
  146. package/tenants/home/public/logo-wordmark.png +0 -0
  147. package/tenants/home/public/pages/storefront.css +0 -235
  148. package/tenants/home/theme.json +0 -6
@@ -0,0 +1,111 @@
1
+ /**
2
+ * TENANT-WIDE DEBUG WINDOW — a merchant switches debug capture on from their admin console and it
3
+ * applies to EVERY session on that tenant until it expires.
4
+ *
5
+ * WHY THIS EXISTS ALONGSIDE THE PER-SESSION TOGGLE. The console toggle only helps someone who can
6
+ * already reproduce the problem in their own browser. The reports that are hardest to act on are the
7
+ * ones where they cannot: it happens to a colleague, on a phone, once a day, or only to whoever
8
+ * happens to hit it next. A window lets the merchant arm capture and then go and reproduce it
9
+ * however it actually occurs.
10
+ *
11
+ * IT EXPIRES, AND THAT IS THE DESIGN. Capture that can be left on forever eventually is, and then it
12
+ * is collecting on a tenant nobody is debugging — which is both a cost and a liability on a regulated
13
+ * storefront. An expiry means the safe state is reached by doing nothing, which is the only kind of
14
+ * safe state that survives people being busy. There is deliberately no "on indefinitely".
15
+ *
16
+ * Still gated by stage. A window has no effect on `live` — the resolver refuses there regardless of
17
+ * what any record says, so an armed window cannot follow a tenant onto their public site.
18
+ */
19
+
20
+ /** A merchant-armed capture window. Absent/expired ⇒ tenant-wide capture is off. */
21
+ export interface TenantDebugWindow {
22
+ /** When the window stops applying — ISO 8601, UTC. */
23
+ enabledUntil: string;
24
+ /** When it was armed, for the admin surface to show "turned on 2 hours ago". */
25
+ enabledAt: string;
26
+ /** Who armed it, for the audit trail. An opaque actor id, never a name or email. */
27
+ enabledBy?: string;
28
+ }
29
+
30
+ /** The default a merchant gets when they just click the toggle: long enough to reproduce something
31
+ * that happens a few times a day, short enough that forgetting is not a problem. */
32
+ export const DEFAULT_DEBUG_WINDOW_MS = 24 * 60 * 60 * 1000;
33
+
34
+ /** The shortest window worth arming — below this the merchant cannot get to the other tab in time. */
35
+ export const MIN_DEBUG_WINDOW_MS = 15 * 60 * 1000;
36
+
37
+ /**
38
+ * The longest window anyone may arm. A ceiling rather than a preference: the whole value of an
39
+ * expiry is that it is not negotiable, and "just set it to a year" is how an expiry becomes
40
+ * decoration. A merchant who genuinely needs longer re-arms it, which is a deliberate act that
41
+ * leaves a fresh audit entry.
42
+ */
43
+ export const MAX_DEBUG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
44
+
45
+ /** Clamp a requested duration into the permitted range. Invalid input falls back to the default
46
+ * rather than refusing — a bad number must not leave the merchant unable to turn capture on. */
47
+ export function clampDebugWindowMs(requestedMs: unknown): number {
48
+ if (typeof requestedMs !== "number" || !Number.isFinite(requestedMs) || requestedMs <= 0) {
49
+ return DEFAULT_DEBUG_WINDOW_MS;
50
+ }
51
+ return Math.min(MAX_DEBUG_WINDOW_MS, Math.max(MIN_DEBUG_WINDOW_MS, Math.round(requestedMs)));
52
+ }
53
+
54
+ /**
55
+ * Arm a window. Duration is a parameter with a default, so the admin surface can offer choices
56
+ * (an hour, a day, a week) without any further change here.
57
+ */
58
+ export function openDebugWindow(args: {
59
+ now?: Date;
60
+ durationMs?: number;
61
+ enabledBy?: string;
62
+ }): TenantDebugWindow {
63
+ const now = args.now ?? new Date();
64
+ const durationMs = clampDebugWindowMs(args.durationMs);
65
+ const window: TenantDebugWindow = {
66
+ enabledAt: now.toISOString(),
67
+ enabledUntil: new Date(now.getTime() + durationMs).toISOString(),
68
+ };
69
+ if (args.enabledBy) window.enabledBy = args.enabledBy;
70
+ return window;
71
+ }
72
+
73
+ /**
74
+ * Is a window currently in force?
75
+ *
76
+ * Fails closed on anything it cannot read: absent, malformed, or an unparseable timestamp all mean
77
+ * off. A record we cannot interpret must never be treated as permission to capture.
78
+ */
79
+ export function isDebugWindowActive(
80
+ window: TenantDebugWindow | null | undefined,
81
+ now: Date = new Date(),
82
+ ): boolean {
83
+ if (!window || typeof window.enabledUntil !== "string") return false;
84
+ const until = Date.parse(window.enabledUntil);
85
+ if (!Number.isFinite(until)) return false;
86
+ return until > now.getTime();
87
+ }
88
+
89
+ /** Milliseconds left on a window, or 0 when it is not active. For the admin surface's countdown. */
90
+ export function debugWindowRemainingMs(
91
+ window: TenantDebugWindow | null | undefined,
92
+ now: Date = new Date(),
93
+ ): number {
94
+ if (!isDebugWindowActive(window, now)) return 0;
95
+ return Date.parse(window!.enabledUntil) - now.getTime();
96
+ }
97
+
98
+ /** A merchant-facing summary of the window's state — "Debug logging is on for another 3 hours". */
99
+ export function describeDebugWindow(
100
+ window: TenantDebugWindow | null | undefined,
101
+ now: Date = new Date(),
102
+ ): string {
103
+ const remaining = debugWindowRemainingMs(window, now);
104
+ if (remaining <= 0) return "Debug logging is off for this store.";
105
+ const hours = Math.floor(remaining / (60 * 60 * 1000));
106
+ if (hours >= 1) {
107
+ return `Debug logging is on for this store for another ${hours} hour${hours === 1 ? "" : "s"}.`;
108
+ }
109
+ const minutes = Math.max(1, Math.round(remaining / (60 * 1000)));
110
+ return `Debug logging is on for this store for another ${minutes} minute${minutes === 1 ? "" : "s"}.`;
111
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The DIAGNOSTIC REFERENCE — one short, self-describing handle a tenant can quote when something
3
+ * goes wrong, and the key every diagnostic surface correlates on.
4
+ *
5
+ * WHY A NEW ID WHEN WE ALREADY MINT SEVERAL. We do not lack correlation ids — `dispatchId`,
6
+ * `operationId`, `runUrl` and the activity rows all exist and all work. What none of them is, is
7
+ * QUOTABLE. The publish dialog's support reference today is the raw dispatch id
8
+ * (`dsp:rcpt:tenant.com:preview:run-<uuid>:5:<uuid>`): correct, unambiguous, and unusable by a human
9
+ * who has to read it off a screen, type it into a message, or say it on a call. So a tenant hitting
10
+ * a problem either pastes something they cannot check, or — as actually happened — opens an agent
11
+ * session to file a report on their behalf.
12
+ *
13
+ * This is a KEY, NOT A CONTAINER. It carries no diagnostic data of its own and replaces none of the
14
+ * ids above; it resolves TO them. Nothing moves, and the existing per-surface redaction rules keep
15
+ * deciding who may see what.
16
+ *
17
+ * FORMAT: `TOT-<stage><YYMMDD>-<token>` — e.g. `TOT-P260923-K7M4QX`.
18
+ *
19
+ * stage `P` preview / `L` live. Which substrate this happened on, before opening anything —
20
+ * the first question asked of every report, answered by the reference itself.
21
+ * YYMMDD the UTC day. Makes retention self-evident: a reference older than the window cannot
22
+ * resolve, and reads as old rather than as broken.
23
+ * token 6 chars of Crockford base32, an alphabet that omits I, L, O and U by construction. The
24
+ * first three cannot be misread as 1/1/0 on a screen or over a call, and the absence of U
25
+ * is what keeps a random token from spelling something unfortunate in front of a merchant.
26
+ *
27
+ * Case-insensitive on the way in, canonical upper on the way out, because it WILL be retyped.
28
+ */
29
+
30
+ /** Stages a reference can be minted for, and the letter each contributes. */
31
+ export type DiagnosticStage = "preview" | "live";
32
+
33
+ const STAGE_LETTER: Readonly<Record<DiagnosticStage, string>> = Object.freeze({
34
+ preview: "P",
35
+ live: "L",
36
+ });
37
+
38
+ const LETTER_STAGE: Readonly<Record<string, DiagnosticStage>> = Object.freeze({
39
+ P: "preview",
40
+ L: "live",
41
+ });
42
+
43
+ /**
44
+ * Crockford base32 — 32 symbols that already omit I, L, O and U, which is exactly why it is the
45
+ * right alphabet here rather than something to be filtered after the fact. 6 chars gives 32^6 ≈
46
+ * 1.07 billion, ample for a handle scoped to one stage and one day.
47
+ */
48
+ const TOKEN_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
49
+
50
+ /** How many token characters a reference carries. */
51
+ const TOKEN_LENGTH = 6;
52
+
53
+ /** The canonical shape, anchored — used to validate anything arriving from a URL or a paste. */
54
+ const REFERENCE_RE = new RegExp(`^TOT-([PL])(\\d{6})-([${TOKEN_ALPHABET}]{${TOKEN_LENGTH}})$`);
55
+
56
+ /** The parts a reference encodes, recovered from the string itself. */
57
+ export interface ParsedDiagnosticReference {
58
+ /** Which substrate the problem occurred on. */
59
+ stage: DiagnosticStage;
60
+ /** The UTC day, as `YYMMDD` — exactly the characters in the reference, not a re-derivation. */
61
+ day: string;
62
+ /** The random token, canonical upper-case. */
63
+ token: string;
64
+ /** The whole reference, canonicalised. */
65
+ reference: string;
66
+ }
67
+
68
+ /** `YYMMDD` in UTC. Deliberately UTC: a reference must mean the same day to a reporter and to us. */
69
+ function utcDay(now: Date): string {
70
+ const yy = String(now.getUTCFullYear() % 100).padStart(2, "0");
71
+ const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
72
+ const dd = String(now.getUTCDate()).padStart(2, "0");
73
+ return `${yy}${mm}${dd}`;
74
+ }
75
+
76
+ /**
77
+ * Mint a reference for a problem that just occurred.
78
+ *
79
+ * `randomBytes` is injected so this stays pure and testable; real callers omit it and get
80
+ * `crypto.getRandomValues`, which exists on Workers and in the browser.
81
+ */
82
+ export function mintDiagnosticReference(args: {
83
+ stage: DiagnosticStage;
84
+ /** When it happened. Injected so a test is not at the mercy of the clock. */
85
+ now?: Date;
86
+ /** Fill a byte array with randomness. Injected for tests; defaults to WebCrypto. */
87
+ randomBytes?: (n: number) => Uint8Array;
88
+ }): string {
89
+ const now = args.now ?? new Date();
90
+ return `TOT-${STAGE_LETTER[args.stage]}${utcDay(now)}-${mintToken(TOKEN_LENGTH, args.randomBytes)}`;
91
+ }
92
+
93
+ /**
94
+ * `length` characters of the reading-safe alphabet. Exported so every human-quotable id in the
95
+ * diagnostics surface — the reference, the session id — draws from ONE alphabet. A second id that
96
+ * quietly used a different one would reintroduce exactly the characters this alphabet exists to
97
+ * keep out, on the id nobody thought to check.
98
+ */
99
+ export function mintToken(length: number, randomBytes?: (n: number) => Uint8Array): string {
100
+ const fill = randomBytes ?? ((n: number) => crypto.getRandomValues(new Uint8Array(n)));
101
+ // 256 is a whole multiple of 32, so a plain modulo is already uniform here and `limit` is 256 —
102
+ // nothing is ever discarded. It is written as rejection sampling anyway so that changing the
103
+ // alphabet to a non-power-of-two length stays correct instead of silently skewing the token.
104
+ const limit = Math.floor(256 / TOKEN_ALPHABET.length) * TOKEN_ALPHABET.length;
105
+ let token = "";
106
+ while (token.length < length) {
107
+ for (const byte of fill(length * 2)) {
108
+ if (token.length >= length) break;
109
+ if (byte >= limit) continue;
110
+ token += TOKEN_ALPHABET[byte % TOKEN_ALPHABET.length];
111
+ }
112
+ }
113
+ return token;
114
+ }
115
+
116
+ /**
117
+ * Parse a reference a human supplied — from a URL, a pasted message, a screenshot they retyped.
118
+ * Tolerant of case and surrounding whitespace, strict about everything else: an id that is not
119
+ * exactly this shape is not one of ours, and saying so beats a lookup that mysteriously finds
120
+ * nothing.
121
+ *
122
+ * Returns null rather than throwing — every caller here is handling someone's typo, not an
123
+ * exceptional condition.
124
+ */
125
+ export function parseDiagnosticReference(raw: string | null | undefined): ParsedDiagnosticReference | null {
126
+ if (!raw) return null;
127
+ const canonical = raw.trim().toUpperCase();
128
+ const m = REFERENCE_RE.exec(canonical);
129
+ if (!m) return null;
130
+ const [, letter, day, token] = m;
131
+ const stage = LETTER_STAGE[letter!];
132
+ if (!stage) return null;
133
+ return { stage, day: day!, token: token!, reference: canonical };
134
+ }
135
+
136
+ /** Is this a well-formed reference? Sugar over {@link parseDiagnosticReference} for guard clauses. */
137
+ export function isDiagnosticReference(raw: string | null | undefined): boolean {
138
+ return parseDiagnosticReference(raw) !== null;
139
+ }
140
+
141
+ /**
142
+ * The path a reference resolves at. Kept here, beside the format, because the WHOLE POINT of the
143
+ * reference is that holding it is enough to reach the diagnostics — so the code and the door it
144
+ * opens must never drift apart in two files.
145
+ */
146
+ export function diagnosticPathFor(reference: string): string {
147
+ return `/diag/${reference.trim().toUpperCase()}`;
148
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Where a diagnostic reference resolves TO.
3
+ *
4
+ * The reference is a key (see `reference.ts`); this is the record it opens. Without it `/diag/<ref>`
5
+ * has nothing to show and the code is just a nicer-looking dispatch id — so this is the module that
6
+ * makes the whole contract true rather than merely well-formed.
7
+ *
8
+ * WHAT IT HOLDS, AND WHAT IT DELIBERATELY DOES NOT. Only the correlation ids we minted plus the
9
+ * merchant-facing text the surface was already showing. No request bodies, no error messages, no
10
+ * tenant content. A record is read by whoever holds the code, and the code travels in screenshots
11
+ * and support threads — so its contents must be safe wherever the code itself is safe.
12
+ *
13
+ * TTL, NOT FOREVER. A reference encodes its own date, so an expired one reads as old rather than as
14
+ * broken. Thirty days is long enough that a merchant who reports something a fortnight later still
15
+ * gets an answer, and short enough that this never becomes an archive of everything that ever went
16
+ * wrong on every tenant.
17
+ */
18
+ import type { DiagnosticStage } from "./reference";
19
+
20
+ /** How long a record outlives the problem it describes. */
21
+ export const DIAGNOSTIC_RECORD_TTL_SECONDS = 30 * 24 * 60 * 60;
22
+
23
+ /** What `/diag/<ref>` shows, and what a report quotes. */
24
+ export interface DiagnosticRecord {
25
+ reference: string;
26
+ /** Which substrate — matches the letter in the reference itself. */
27
+ stage: DiagnosticStage;
28
+ /** The tenant this happened on. */
29
+ tenant: string;
30
+ /** Merchant-facing summary: the text the surface already displayed, never an exception message. */
31
+ summary: string;
32
+ /** ISO timestamp. */
33
+ at: string;
34
+ /** The operation's problem code, when it had one. */
35
+ code?: string | null;
36
+ /** Our own correlation ids, for tracing through build and delivery logs. */
37
+ dispatchId?: string | null;
38
+ receiptId?: string | null;
39
+ pinnedSha?: string | null;
40
+ /** The CI run URL. Subject to the same non-owner redaction as everywhere else. */
41
+ runUrl?: string | null;
42
+ }
43
+
44
+ /** The minimal KV surface this needs, so tests need no Workers runtime. */
45
+ export interface DiagnosticKv {
46
+ get(key: string): Promise<string | null>;
47
+ put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
48
+ }
49
+
50
+ /** Namespaced so a reference can never collide with tenant content or another subsystem's keys. */
51
+ export function diagnosticRecordKey(reference: string): string {
52
+ return `diag:ref:${reference.trim().toUpperCase()}`;
53
+ }
54
+
55
+ /**
56
+ * Persist a record. NEVER THROWS.
57
+ *
58
+ * A diagnostic record is a secondary artifact of a failure that has already happened. If writing it
59
+ * fails, the merchant must still get their error, their retry and their reference — losing the
60
+ * ability to explain a problem must never also lose the problem's own handling. Returns whether it
61
+ * landed so a caller can say "reference unavailable" rather than print a code that resolves to
62
+ * nothing.
63
+ */
64
+ export async function putDiagnosticRecord(
65
+ kv: DiagnosticKv | null | undefined,
66
+ record: DiagnosticRecord,
67
+ ttlSeconds: number = DIAGNOSTIC_RECORD_TTL_SECONDS,
68
+ ): Promise<boolean> {
69
+ if (!kv) return false;
70
+ try {
71
+ await kv.put(diagnosticRecordKey(record.reference), JSON.stringify(record), {
72
+ expirationTtl: ttlSeconds,
73
+ });
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Read a record back, or null.
82
+ *
83
+ * Null covers every failure mode on purpose — absent, expired, unreadable KV, malformed JSON, or a
84
+ * record whose shape we no longer recognise. `/diag` shows one honest "we can't find that
85
+ * reference" for all of them, because to the person holding the code they are the same situation.
86
+ */
87
+ export async function getDiagnosticRecord(
88
+ kv: DiagnosticKv | null | undefined,
89
+ reference: string,
90
+ ): Promise<DiagnosticRecord | null> {
91
+ if (!kv) return null;
92
+ try {
93
+ const raw = await kv.get(diagnosticRecordKey(reference));
94
+ if (!raw) return null;
95
+ const parsed = JSON.parse(raw) as unknown;
96
+ return isDiagnosticRecord(parsed) ? parsed : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /** Structural check on a record read back from storage. Anything we cannot vouch for is not shown. */
103
+ function isDiagnosticRecord(value: unknown): value is DiagnosticRecord {
104
+ if (!value || typeof value !== "object") return false;
105
+ const r = value as Record<string, unknown>;
106
+ return (
107
+ typeof r.reference === "string" &&
108
+ (r.stage === "preview" || r.stage === "live") &&
109
+ typeof r.tenant === "string" &&
110
+ typeof r.summary === "string" &&
111
+ typeof r.at === "string"
112
+ );
113
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * "REPORT THIS" — turn a problem the merchant is already looking at into a filed report, with the
3
+ * diagnostic reference already in it.
4
+ *
5
+ * THE LOOP THIS CLOSES. Filing friction has required an MCP tool call, which means a signed-in agent
6
+ * session. A tenant developer hitting a publish failure therefore had two options: describe it in
7
+ * prose to a human, or open an agent session so something else could file on their behalf. Both
8
+ * happened this week. Neither is a reporting path — they are workarounds for the absence of one.
9
+ *
10
+ * WHAT THIS MODULE IS. The pure payload builder: problem + reference in, a `feedback_submit`-shaped
11
+ * report out. It performs no I/O, so the rule about what a report may contain is testable on its own
12
+ * and does not depend on whichever surface ends up posting it.
13
+ *
14
+ * PII IS THE WHOLE RISK HERE. `feedback_submit` clusters reports by title and description and may
15
+ * show them to OTHER builders — so those fields carry only our own identifiers and closed-vocabulary
16
+ * text. Nothing typed by a person and nothing read from the page goes in them. The tool has a
17
+ * dedicated `sensitive` field that is private to ToT admins and never matched; a merchant's own note
18
+ * goes THERE, not into the description, because the merchant cannot be expected to know which field
19
+ * is which.
20
+ */
21
+ import { diagnosticPathFor } from "./reference";
22
+
23
+ /** The `feedback_submit` fields this builder populates. Deliberately a subset — anything not listed
24
+ * is not ours to guess at from a UI click. */
25
+ export interface DiagnosticReport {
26
+ type: "bug";
27
+ category: "platform-bug";
28
+ severity: "critical" | "high" | "medium" | "low";
29
+ title: string;
30
+ description: string;
31
+ project: string;
32
+ scope: "project";
33
+ /** Free text the merchant typed, kept out of the clustered fields. Omitted when they typed none. */
34
+ sensitive?: string;
35
+ /** Stable class for triage, when the problem carried a code. */
36
+ errorClass?: string;
37
+ }
38
+
39
+ /** What the surface knows when the merchant clicks Report. */
40
+ export interface DiagnosticReportInput {
41
+ /** The quotable reference for this problem. */
42
+ reference: string;
43
+ /** The tenant this happened on. */
44
+ tenant: string;
45
+ /** Merchant-facing summary already on screen — our text, not an exception message. */
46
+ summary: string;
47
+ /** The operation's problem code, when it had one. Closed enum; used for triage clustering. */
48
+ code?: string | null;
49
+ /** The browser session, when one was established. */
50
+ sessionId?: string | null;
51
+ /** Whether debug capture was on, so triage knows whether a trail exists before going to look. */
52
+ debugCaptureOn?: boolean;
53
+ /** Anything the merchant typed. Goes to `sensitive`, never to the clustered fields. */
54
+ note?: string | null;
55
+ }
56
+
57
+ /** Severity by problem code. A publish that cannot proceed is blocking; everything else defaults to
58
+ * high rather than critical, because a human triages these and over-claiming is its own noise. */
59
+ function severityFor(code: string | null | undefined): DiagnosticReport["severity"] {
60
+ switch (code) {
61
+ case "delivery-undeliverable":
62
+ case "bundle-unavailable":
63
+ return "critical";
64
+ case "checks-blocked":
65
+ case "needs-confirmation":
66
+ case "not-ready":
67
+ return "medium";
68
+ default:
69
+ return "high";
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Build the report.
75
+ *
76
+ * The title leads with the reference so the queue is searchable by the thing the merchant is
77
+ * holding — someone who quotes only the code in a thread can be matched to their own report without
78
+ * a lookup.
79
+ */
80
+ export function buildDiagnosticReport(input: DiagnosticReportInput): DiagnosticReport {
81
+ const lines = [
82
+ `Reported from the storefront UI by a tenant, using the in-product report action.`,
83
+ ``,
84
+ `Reference: ${input.reference}`,
85
+ `Diagnostics: ${diagnosticPathFor(input.reference)}`,
86
+ `Tenant: ${input.tenant}`,
87
+ `What the merchant was shown: ${input.summary}`,
88
+ input.code ? `Problem code: ${input.code}` : null,
89
+ input.sessionId ? `Session: ${input.sessionId}` : null,
90
+ // Say plainly whether a trail exists. Triage otherwise opens the reference, finds nothing, and
91
+ // cannot tell "no events" from "capture was off" — which is the ambiguity that wastes the hour.
92
+ `Debug capture: ${input.debugCaptureOn ? "on — a session trail is attached to the reference" : "off — no client trail was recorded"}`,
93
+ ].filter((l): l is string => l !== null);
94
+
95
+ const report: DiagnosticReport = {
96
+ type: "bug",
97
+ category: "platform-bug",
98
+ severity: severityFor(input.code),
99
+ title: `${input.reference}: ${input.summary}`.slice(0, 200),
100
+ description: lines.join("\n").slice(0, 4000),
101
+ project: "storefront",
102
+ scope: "project",
103
+ };
104
+ if (input.code) report.errorClass = input.code;
105
+ // The merchant's own words are the one field that can contain anything at all.
106
+ const note = input.note?.trim();
107
+ if (note) report.sensitive = note.slice(0, 4000);
108
+ return report;
109
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The DIAGNOSTIC SESSION ID — a correlation handle for everything one browser session did, so a
3
+ * report can be read as a sequence rather than as a single failing call.
4
+ *
5
+ * WHY, GIVEN WE ALREADY MINT A REFERENCE PER PROBLEM. A reference answers "what broke". Most reports
6
+ * also contain some version of "it did something odd just before", and that half has never been
7
+ * answerable: each failure is its own island, with nothing tying it to the navigation, the retry, or
8
+ * the earlier error that actually explains it. The session id is what lets the trail be assembled.
9
+ *
10
+ * `TOTS-<8>`, from the SAME reading-safe alphabet as the reference, because it appears in the same
11
+ * console line and gets retyped the same way.
12
+ *
13
+ * DELIBERATELY PER-SESSION, NOT PER-USER. It is a correlation handle with a natural lifetime, not an
14
+ * identifier of a person: it lives in `sessionStorage`, so it dies with the tab and never follows
15
+ * anyone between visits. On a regulated storefront that distinction is the difference between a
16
+ * diagnostic aid and a tracking identifier, and `sessionStorage` is what enforces it rather than a
17
+ * promise that we will not join it to anything.
18
+ */
19
+ import { mintToken } from "./reference";
20
+
21
+ /** The key the session id is cached under. Namespaced so it cannot collide with tenant content. */
22
+ export const SESSION_STORAGE_KEY = "tot.diag.session";
23
+
24
+ /** How many token characters a session id carries — longer than a reference's, and never retyped
25
+ * from a screenshot as often, but still readable aloud. */
26
+ const SESSION_TOKEN_LENGTH = 8;
27
+
28
+ /** The canonical session-id shape. */
29
+ const SESSION_RE = /^TOTS-[0-9A-HJKMNP-TV-Z]{8}$/;
30
+
31
+ /**
32
+ * The minimal storage surface this needs. Typed structurally rather than as `Storage` so a test can
33
+ * pass a plain object, and so a caller can hand over any equivalent store without a cast.
34
+ */
35
+ export interface SessionStore {
36
+ getItem(key: string): string | null;
37
+ setItem(key: string, value: string): void;
38
+ }
39
+
40
+ /** Is this a well-formed session id? */
41
+ export function isSessionId(raw: string | null | undefined): boolean {
42
+ return typeof raw === "string" && SESSION_RE.test(raw.trim().toUpperCase());
43
+ }
44
+
45
+ /** Mint a fresh session id. */
46
+ export function mintSessionId(randomBytes?: (n: number) => Uint8Array): string {
47
+ return `TOTS-${mintToken(SESSION_TOKEN_LENGTH, randomBytes)}`;
48
+ }
49
+
50
+ /**
51
+ * The session id for this browser session, creating one on first use.
52
+ *
53
+ * NEVER THROWS, and that is the point rather than politeness: `sessionStorage` throws outright in a
54
+ * Safari private window and wherever site data is blocked, and this is DIAGNOSTIC code. Code that
55
+ * exists to explain failures must not become one — a storefront that will not render because its
56
+ * diagnostics could not find a place to keep an id is a worse bug than any it would have reported.
57
+ * When storage is unavailable the id is still minted and returned; it just does not survive the
58
+ * navigation, which degrades the trail rather than the page.
59
+ */
60
+ export function readOrCreateSessionId(args?: {
61
+ store?: SessionStore | null;
62
+ randomBytes?: (n: number) => Uint8Array;
63
+ }): string {
64
+ const store = args?.store !== undefined ? args.store : safeSessionStore();
65
+ if (store) {
66
+ try {
67
+ const existing = store.getItem(SESSION_STORAGE_KEY);
68
+ if (isSessionId(existing)) return existing!.trim().toUpperCase();
69
+ } catch {
70
+ // Readable-in-principle, unreadable-in-fact. Fall through and mint.
71
+ }
72
+ }
73
+ const minted = mintSessionId(args?.randomBytes);
74
+ if (store) {
75
+ try {
76
+ store.setItem(SESSION_STORAGE_KEY, minted);
77
+ } catch {
78
+ // Storage refused the write (private mode, quota, blocked site data). The id is still good
79
+ // for this page; it simply will not be the same one after a navigation.
80
+ }
81
+ }
82
+ return minted;
83
+ }
84
+
85
+ /** `sessionStorage` when it exists AND is usable, else null. Merely existing is not enough — the
86
+ * accessor itself throws in some configurations. */
87
+ function safeSessionStore(): SessionStore | null {
88
+ try {
89
+ return typeof sessionStorage === "undefined" ? null : sessionStorage;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
@@ -0,0 +1,5 @@
1
+ import type { FeaturedProductsBlock } from "@/lib/storyblok/content-model";
2
+
3
+ export function featuredProductsPresentationView(presentation: FeaturedProductsBlock["presentation"]) {
4
+ return presentation === "carousel" ? "carousel" : "grid";
5
+ }
@@ -0,0 +1,41 @@
1
+ import type { HeroBlock } from "@/lib/storyblok/content-model";
2
+ import { withBase } from "@/lib/basePath";
3
+
4
+ export function heroAutoplayAllowed(input: {
5
+ enabled: boolean;
6
+ reducedMotion: boolean;
7
+ paused: boolean;
8
+ pageVisible: boolean;
9
+ slideCount: number;
10
+ }) {
11
+ return input.enabled && !input.reducedMotion && !input.paused && input.pageVisible && input.slideCount > 1;
12
+ }
13
+
14
+ export function nextHeroSlideIndex(current: number, slideCount: number) {
15
+ if (slideCount < 1) return -1;
16
+ return current < 0 ? 0 : (current + 1) % slideCount;
17
+ }
18
+
19
+ export function heroSlideshowView(block: HeroBlock, instanceKey: string, basePath: string) {
20
+ const slides = block.slides ?? [];
21
+ return {
22
+ id: `hero-slides-${instanceKey}`,
23
+ autoplay: slides.length > 1,
24
+ intervalMs: 6_000,
25
+ slides: slides.map((slide, index) => ({
26
+ ...slide,
27
+ href: slide.ctaHref ? withBase(basePath, slide.ctaHref) : undefined,
28
+ checked: index === 0,
29
+ loading: index === 0 ? ("eager" as const) : ("lazy" as const),
30
+ fetchpriority: index === 0 ? ("high" as const) : ("auto" as const),
31
+ decoding: index === 0 ? ("sync" as const) : ("async" as const),
32
+ width: 1920,
33
+ height: 650,
34
+ radioId: `hero-slides-${instanceKey}-${index}`,
35
+ radioLabel: `Show slide ${index + 1}: ${slide.headline ?? slide.imageAlt}`,
36
+ groupRole: "group" as const,
37
+ groupLabel: `${index + 1} of ${slides.length}`,
38
+ focusTarget: "slide" as const,
39
+ })),
40
+ };
41
+ }