@tokenoftrust/storefront-runner 2.0.40 → 2.0.42

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 (150) 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/platform/google-measurement/v2/entry.mjs +1 -1
  6. package/apps/storefront/public/shared/commerce-chrome.css +147 -16
  7. package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
  8. package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
  9. package/apps/storefront/src/components/Img.astro +6 -5
  10. package/apps/storefront/src/components/ProductCard.astro +6 -4
  11. package/apps/storefront/src/components/Seo.astro +2 -1
  12. package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
  13. package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
  14. package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
  15. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
  16. package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
  17. package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
  18. package/apps/storefront/src/components/home/Hero.astro +156 -4
  19. package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
  20. package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
  21. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
  22. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
  23. package/apps/storefront/src/components/marketing/Faq.astro +9 -2
  24. package/apps/storefront/src/config/devTenantSeed.ts +1 -1
  25. package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
  26. package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
  27. package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
  28. package/apps/storefront/src/config/tenants.ts +22 -0
  29. package/apps/storefront/src/layouts/Layout.astro +69 -44
  30. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
  31. package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
  32. package/apps/storefront/src/lib/asyncCss.ts +23 -0
  33. package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
  34. package/apps/storefront/src/lib/basePath.ts +70 -9
  35. package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
  36. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  37. package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
  38. package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
  39. package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
  40. package/apps/storefront/src/lib/diag/capture.ts +161 -0
  41. package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
  42. package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
  43. package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
  44. package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
  45. package/apps/storefront/src/lib/diag/reference.ts +148 -0
  46. package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
  47. package/apps/storefront/src/lib/diag/report.ts +109 -0
  48. package/apps/storefront/src/lib/diag/session.ts +93 -0
  49. package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
  50. package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
  51. package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
  52. package/apps/storefront/src/lib/imageAttrs.ts +9 -6
  53. package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
  54. package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
  55. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
  56. package/apps/storefront/src/lib/jsonScript.ts +13 -0
  57. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
  58. package/apps/storefront/src/lib/previewReload.ts +53 -9
  59. package/apps/storefront/src/lib/rawChrome.ts +148 -19
  60. package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
  61. package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
  62. package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
  63. package/apps/storefront/src/lib/tenantRouting.ts +15 -4
  64. package/apps/storefront/src/lib/tot/syncState.ts +21 -0
  65. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
  66. package/apps/storefront/src/middleware/controlPlane.ts +9 -5
  67. package/apps/storefront/src/middleware/index.ts +13 -1
  68. package/apps/storefront/src/middleware/renderChain.ts +54 -11
  69. package/apps/storefront/src/middleware/routeBranch.ts +58 -12
  70. package/apps/storefront/src/middleware/staticBundle.ts +37 -3
  71. package/apps/storefront/src/pages/[...slug].astro +6 -2
  72. package/apps/storefront/src/pages/api/request-access.ts +5 -4
  73. package/apps/storefront/src/pages/blog/[slug].astro +11 -1
  74. package/apps/storefront/src/pages/cart.ts +23 -0
  75. package/apps/storefront/src/pages/index.astro +64 -85
  76. package/apps/storefront/src/pages/products/[handle].astro +8 -6
  77. package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
  78. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
  79. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
  80. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
  81. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
  82. package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
  83. package/apps/storefront/src/styles/fonts.css +7 -0
  84. package/apps/storefront/src/styles/global.css +397 -3
  85. package/package.json +4 -1
  86. package/packages/cli/src/visual-parity.mjs +321 -0
  87. package/packages/public-runtime/package.json +2 -0
  88. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  89. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  90. package/packages/public-runtime/src/chrome.ts +44 -7
  91. package/packages/public-runtime/src/csp.ts +12 -1
  92. package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
  93. package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
  94. package/packages/public-runtime/src/extension-contract.ts +20 -6
  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/csp-compiler.ts +6 -3
  100. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  101. package/packages/public-runtime/src/integration/index.ts +1 -0
  102. package/packages/public-runtime/src/integration/lifecycle-boot.ts +34 -6
  103. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  104. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  105. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
  107. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +35 -1
  108. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +45 -12
  109. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +28 -7
  110. package/packages/public-runtime/src/integration/native-ga4.ts +18 -2
  111. package/packages/public-runtime/src/integration/platform-adapters.ts +14 -3
  112. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  113. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  114. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  115. package/packages/public-runtime/src/publication-space.ts +24 -3
  116. package/packages/public-runtime/src/static-bundle.ts +145 -26
  117. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  118. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  119. package/packages/public-runtime/src/tenant.ts +59 -1
  120. package/pnpm-lock.runner.yaml +12 -0
  121. package/pnpm-workspace.yaml +4 -0
  122. package/scripts/dev/git-status.mjs +1 -1
  123. package/scripts/dev/publish.mjs +6 -0
  124. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  125. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  126. package/scripts/tenant/validate.mjs +171 -8
  127. package/apps/storefront/src/lib/cfImage.ts +0 -55
  128. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  129. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  144. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  145. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  146. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  147. package/tenants/home/public/js/storefront.js +0 -129
  148. package/tenants/home/public/logo-wordmark.png +0 -0
  149. package/tenants/home/public/pages/storefront.css +0 -235
  150. package/tenants/home/theme.json +0 -6
@@ -12,17 +12,43 @@
12
12
  *
13
13
  * Node-free + presentation-free; the .astro widget renders the sanitized items.
14
14
  */
15
- import { isFabricatedTrustProof } from "@tot/public-runtime";
15
+ import {
16
+ isFabricatedTrustProof,
17
+ isSafeRehostedAssetPath,
18
+ isSafeRehostedAssetPathForTenant,
19
+ } from "@tot/public-runtime";
16
20
 
17
21
  export type TrustStripItem =
18
22
  /** A plain proof claim, e.g. "Ships from USA", "Lab-tested". */
19
- | { kind: "claim"; label: string }
23
+ | { kind: "claim"; label: string; icon?: string; iconAlt?: string }
20
24
  /**
21
25
  * A REAL aggregate rating. Surfaced only when `average` (1–5) and `count`
22
26
  * are real positive finite numbers — a half-populated aggregate is dropped.
23
27
  */
24
28
  | { kind: "rating"; average: number; count: number; label?: string };
25
29
 
30
+ /** Stable, declarative mobile ordering; unknown/duplicate labels are harmless. */
31
+ export function orderTrustStripForMobile(
32
+ items: ReadonlyArray<TrustStripItem>,
33
+ preferredLabels?: ReadonlyArray<string>,
34
+ ): TrustStripItem[] {
35
+ if (!preferredLabels?.length) return [...items];
36
+ const rank = new Map<string, number>();
37
+ for (const label of preferredLabels) {
38
+ const normalized = label.trim();
39
+ if (normalized && !rank.has(normalized)) rank.set(normalized, rank.size);
40
+ }
41
+ return items
42
+ .map((item, index) => ({ item, index, rank: rank.get(item.label?.trim() ?? "") }))
43
+ .sort((a, b) => {
44
+ if (a.rank === undefined && b.rank === undefined) return a.index - b.index;
45
+ if (a.rank === undefined) return 1;
46
+ if (b.rank === undefined) return -1;
47
+ return a.rank - b.rank;
48
+ })
49
+ .map(({ item }) => item);
50
+ }
51
+
26
52
  /** True when a rating token is backed by real, positive, finite numbers. */
27
53
  export function isRealRating(
28
54
  item: Extract<TrustStripItem, { kind: "rating" }>,
@@ -46,13 +72,22 @@ export function isRealRating(
46
72
  */
47
73
  export function sanitizeTrustStrip(
48
74
  items: ReadonlyArray<TrustStripItem>,
75
+ tenantId?: string,
49
76
  ): TrustStripItem[] {
50
77
  const out: TrustStripItem[] = [];
51
78
  for (const item of items) {
52
79
  if (item.kind === "claim") {
53
80
  const label = item.label.trim();
54
81
  if (label.length > 0 && !isFabricatedTrustProof(label)) {
55
- out.push({ kind: "claim", label });
82
+ out.push({
83
+ kind: "claim",
84
+ label,
85
+ ...((tenantId
86
+ ? isSafeRehostedAssetPathForTenant(item.icon, tenantId)
87
+ : isSafeRehostedAssetPath(item.icon))
88
+ ? { icon: item.icon, iconAlt: item.iconAlt?.trim() || "" }
89
+ : {}),
90
+ });
56
91
  }
57
92
  } else if (item.kind === "rating") {
58
93
  if (isRealRating(item)) out.push(item);
@@ -2,7 +2,8 @@
2
2
  * CSP hashes for the **static** inline `<script>`/`<style>` that Astro emits in
3
3
  * the production build — the `client:load|idle|only` hydration directive
4
4
  * loaders, the `<astro-island>` definition, the `astro-island{display:contents}`
5
- * style, and bundled Layout scripts. These are identical across tenants and
5
+ * style, bundled Layout scripts, and reviewed static runtime documents such as
6
+ * the GA4 sandbox. These are identical across tenants and
6
7
  * requests (no dynamic data), so hashes (not nonces) are the correct, strict
7
8
  * tool for them.
8
9
  *
@@ -17,6 +18,8 @@ export const STATIC_SCRIPT_HASHES: readonly string[] = [
17
18
  "'sha256-QzWFZi+FLIx23tnm9SBU4aEgx4x8DsuASP07mfqol/c='",
18
19
  "'sha256-YicfxUFBkKrQapZ/tzZKlaKTdNHgwVfv7y2Wa0NC6/k='",
19
20
  "'sha256-eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc='",
21
+ "'sha256-elMLh/6Oubj8u9FRLwZLyRa6TqKikGs75mf0jfWcoxI='",
22
+ "'sha256-yxhGRh5KeGL1P3NgVN7YoKE4N5zBl1XGEycWPbX0TWo='",
20
23
  ];
21
24
 
22
25
  export const STATIC_STYLE_HASHES: readonly string[] = [
@@ -0,0 +1,161 @@
1
+ /**
2
+ * What debug mode is ALLOWED to capture (ADR 0034).
3
+ *
4
+ * THE DIRECTION IS THE DECISION. This is an allowlist, not a denylist. A denylist captures every new
5
+ * field until someone remembers to exclude it, which makes the leak the default and guarantees that
6
+ * the one field nobody thought about is the one that ships. Here a new field is invisible until
7
+ * somebody deliberately adds it, and the cost — some bugs still need us — is accepted.
8
+ *
9
+ * The permitted set is derived from what the flows actually emit, not invented:
10
+ * - operation stages are a CLOSED set (`operation-contract.ts`), so they cannot carry tenant data;
11
+ * - problem codes are a CLOSED enum;
12
+ * - routes are captured as PATTERNS from a fixed list, never as URLs — a raw URL carries path
13
+ * params and query strings, which on a regulated storefront is exactly where the data is;
14
+ * - timings and viewport are numbers.
15
+ *
16
+ * Everything else is dropped: raw URLs, query strings and fragments, form values, request/response
17
+ * bodies, error MESSAGE text (the name only), cookies, storage contents, headers, DOM content, and
18
+ * the full user-agent.
19
+ */
20
+ /**
21
+ * Stages the client may report.
22
+ *
23
+ * Declared here rather than imported from the operation contract, for two reasons that point the
24
+ * same way. The architectural one: `lib/diag` is renderer core and `lib/change` is the control-plane
25
+ * moat, and the renderer must stay carve-able into a source-available distributable
26
+ * (`renderer-no-controlplane`). The behavioural one: this is an ALLOWLIST, so a stage added to the
27
+ * contract must be considered before it becomes capturable — inheriting it automatically is exactly
28
+ * the drift this file exists to prevent.
29
+ */
30
+ export const CAPTURABLE_STAGES: readonly string[] = Object.freeze([
31
+ "queued",
32
+ "preparing",
33
+ "publishing",
34
+ "verifying",
35
+ "live",
36
+ ]);
37
+
38
+ /** Routes the client may report timing/status for. Fixed list: an unlisted route is DROPPED, not
39
+ * recorded as "other" — a route we did not anticipate is one we have not reasoned about. */
40
+ export const CAPTURABLE_ROUTES: readonly string[] = Object.freeze([
41
+ "/api/changes",
42
+ "/api/changes/ship",
43
+ "/api/changes/accept",
44
+ "/api/changes/accept-issue",
45
+ "/api/changes/reconcile-delivery",
46
+ "/api/publish/stage-bundle",
47
+ "/api/preview/build",
48
+ ]);
49
+
50
+ /** Problem codes the client may report. Closed enum from the operation contract. */
51
+ export const CAPTURABLE_PROBLEM_CODES: readonly string[] = Object.freeze([
52
+ "needs-confirmation",
53
+ "entitlement-required",
54
+ "bundle-unavailable",
55
+ "not-ready",
56
+ "delivery-undeliverable",
57
+ "checks-blocked",
58
+ ]);
59
+
60
+ /** A captured event, after the allowlist has had its say. Every variant is closed-vocabulary. */
61
+ export type CaptureEvent =
62
+ | { kind: "stage"; operation: string; stage: string; at: string }
63
+ | { kind: "request"; route: string; status: number; durationMs: number; at: string }
64
+ | { kind: "problem"; code: string; at: string }
65
+ | { kind: "navigation"; route: string; ttfbMs: number; loadMs: number; at: string }
66
+ | { kind: "error"; name: string; at: string }
67
+ | { kind: "viewport"; width: number; height: number; online: boolean; at: string };
68
+
69
+ /**
70
+ * Reduce a URL to an allowlisted route PATTERN, or null.
71
+ *
72
+ * Only the pathname is ever consulted — `search` and `hash` are discarded before matching rather
73
+ * than after, so a query string cannot reach the comparison, let alone the output. A path that is
74
+ * not on the list returns null and the caller drops the event entirely.
75
+ */
76
+ export function captureRoute(rawUrl: string | null | undefined): string | null {
77
+ if (!rawUrl) return null;
78
+ let pathname: string;
79
+ try {
80
+ // A base is supplied so relative URLs parse; its origin is never read.
81
+ pathname = new URL(rawUrl, "https://example.invalid").pathname;
82
+ } catch {
83
+ return null;
84
+ }
85
+ const normalised = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
86
+ return CAPTURABLE_ROUTES.includes(normalised) ? normalised : null;
87
+ }
88
+
89
+ /** A finite, non-negative number rounded to a whole millisecond, or null. Guards against `Infinity`
90
+ * and `NaN` reaching the ingest as a "timing". */
91
+ function millis(value: unknown): number | null {
92
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
93
+ return Math.round(value);
94
+ }
95
+
96
+ /** A plausible HTTP status, or null. */
97
+ function httpStatus(value: unknown): number | null {
98
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 100 || value > 599) return null;
99
+ return value;
100
+ }
101
+
102
+ /**
103
+ * Build a `request` event, or null when anything about it is not capturable.
104
+ *
105
+ * Returning null rather than a partially-populated event is the fail-closed half: a half-recorded
106
+ * request is worse than none, because it looks like evidence.
107
+ */
108
+ export function captureRequest(input: {
109
+ url: string | null | undefined;
110
+ status: unknown;
111
+ durationMs: unknown;
112
+ at: string;
113
+ }): CaptureEvent | null {
114
+ const route = captureRoute(input.url);
115
+ const status = httpStatus(input.status);
116
+ const durationMs = millis(input.durationMs);
117
+ if (!route || status === null || durationMs === null) return null;
118
+ return { kind: "request", route, status, durationMs, at: input.at };
119
+ }
120
+
121
+ /** Build a `navigation` event, or null. Same fail-closed rule, same route allowlist. */
122
+ export function captureNavigation(input: {
123
+ url: string | null | undefined;
124
+ ttfbMs: unknown;
125
+ loadMs: unknown;
126
+ at: string;
127
+ }): CaptureEvent | null {
128
+ const route = captureRoute(input.url);
129
+ const ttfbMs = millis(input.ttfbMs);
130
+ const loadMs = millis(input.loadMs);
131
+ if (!route || ttfbMs === null || loadMs === null) return null;
132
+ return { kind: "navigation", route, ttfbMs, loadMs, at: input.at };
133
+ }
134
+
135
+ /** Build a `stage` event, or null when the stage is not one this allowlist admits. */
136
+ export function captureStage(operation: unknown, stage: unknown, at: string): CaptureEvent | null {
137
+ if (typeof operation !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(operation)) return null;
138
+ if (typeof stage !== "string" || !CAPTURABLE_STAGES.includes(stage)) return null;
139
+ return { kind: "stage", operation, stage, at };
140
+ }
141
+
142
+ /** Build a `problem` event from a closed-enum code, or null when the code is not one of ours. */
143
+ export function captureProblem(code: unknown, at: string): CaptureEvent | null {
144
+ if (typeof code !== "string" || !CAPTURABLE_PROBLEM_CODES.includes(code)) return null;
145
+ return { kind: "problem", code, at };
146
+ }
147
+
148
+ /**
149
+ * Build an `error` event carrying the error's NAME only.
150
+ *
151
+ * `message` is deliberately never captured. An exception message is free text assembled at the
152
+ * throw site, and on a storefront it routinely interpolates a URL, an id, or a value — the exact
153
+ * content this allowlist exists to keep out. The name is a type, and a type is a fact about our
154
+ * code rather than about the tenant's data.
155
+ */
156
+ export function captureError(error: unknown, at: string): CaptureEvent | null {
157
+ const name =
158
+ error instanceof Error ? error.name : typeof error === "object" && error && "name" in error ? String((error as { name: unknown }).name) : null;
159
+ if (!name || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name)) return null;
160
+ return { kind: "error", name, at };
161
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The one console line a tenant can screenshot.
3
+ *
4
+ * A merchant who hits a problem does one of two things: reads the dialog, or opens devtools. Today
5
+ * those two surfaces share nothing — the dialog has a support reference, the console has whatever
6
+ * the error happened to throw, and neither can be joined to the other or to our logs. So a console
7
+ * screenshot, which is what people actually send, has been the least useful artifact in a report.
8
+ *
9
+ * This emits the SAME reference the dialog shows, plus the session id and the path that resolves
10
+ * them. Holding the line is then equivalent to holding the dialog, and either one is enough.
11
+ *
12
+ * ONE LINE, NEVER A DUMP. It is pasted into chat and photographed off a screen, so it has to survive
13
+ * both. It also must never carry anything sensitive: a console line goes wherever a screenshot goes,
14
+ * including into a support thread with people who should not see tenant data. Everything here is an
15
+ * identifier we minted — no URLs, no message bodies, no tenant content.
16
+ */
17
+ import { diagnosticPathFor } from "./reference";
18
+
19
+ /** What the line describes. `summary` is merchant-facing text we already show, never raw error text. */
20
+ export interface DiagnosticConsoleLine {
21
+ /** The quotable reference for this problem. */
22
+ reference: string;
23
+ /** A short merchant-safe description — reuse what the dialog says, never an exception message. */
24
+ summary: string;
25
+ /** The browser session this happened in, when one could be established. */
26
+ sessionId?: string | null;
27
+ /**
28
+ * Advertise `tot.debug()` — pass true when capture is AVAILABLE here and currently OFF.
29
+ *
30
+ * This is the discovery mechanism for the console toggle, and the timing is the whole point: the
31
+ * merchant is looking at a failure with devtools already open, which is the one moment they will
32
+ * read a hint about how to record one. Suppressed when capture is already on (nothing to offer)
33
+ * and on live (where the answer is no).
34
+ */
35
+ offerDebug?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Format the line. Pure, so its exact shape is testable and cannot drift from what support is told
40
+ * to look for.
41
+ *
42
+ * The `[tot]` prefix is deliberate: it is what someone greps a noisy console for, and what lets a
43
+ * reader tell OUR line from a tenant's own logging in a screenshot full of both.
44
+ */
45
+ export function formatDiagnosticConsoleLine(input: DiagnosticConsoleLine): string {
46
+ const parts = [
47
+ `[tot] ${input.reference}`,
48
+ input.summary,
49
+ input.sessionId ? `session ${input.sessionId}` : null,
50
+ `diagnostics: ${diagnosticPathFor(input.reference)}`,
51
+ // Last, so it never pushes the reference — the thing they must copy — off the readable part of
52
+ // a narrow console or a screenshot.
53
+ input.offerDebug ? "run tot.debug() to record more detail" : null,
54
+ ].filter((p): p is string => Boolean(p));
55
+ return parts.join(" · ");
56
+ }
57
+
58
+ /**
59
+ * Emit the line, once, for a problem.
60
+ *
61
+ * `console.warn` rather than `.error`: this is a pointer to diagnostics, not an exception. Sites
62
+ * that fail their own builds on console errors should not start failing because we explained
63
+ * ourselves, and a merchant scanning a red console should see THEIR problem first.
64
+ *
65
+ * Never throws — a console that refuses to print must not take the page down with it.
66
+ */
67
+ export function emitDiagnosticConsoleLine(
68
+ input: DiagnosticConsoleLine,
69
+ sink: Pick<Console, "warn"> = console,
70
+ ): void {
71
+ try {
72
+ sink.warn(formatDiagnosticConsoleLine(input));
73
+ } catch {
74
+ // A sandboxed or replaced console. Losing the line is acceptable; losing the page is not.
75
+ }
76
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `tot.debug()` — the console entry point a merchant can actually type.
3
+ *
4
+ * WHY A CONSOLE FUNCTION AT ALL. The admin toggle arms capture for everyone, which is the right tool
5
+ * when the problem happens to someone else. But a developer who is already looking at the broken
6
+ * page, with devtools open, should not have to go to another surface, find a setting, and come back
7
+ * — by which point they have navigated away from the state they were trying to capture. Typing one
8
+ * word is the shortest path from "this is broken right now" to "I am recording it".
9
+ *
10
+ * IT ANSWERS, IT DOES NOT JUST ACT. Every call prints what happened and what is true now. A silent
11
+ * toggle in a console is indistinguishable from a typo, and the only thing worse than capture being
12
+ * off is believing it is on.
13
+ *
14
+ * DISCOVERABILITY IS THE HARD PART, and it is not solved here: a function nobody knows about is not
15
+ * an entry point. The `[tot]` problem line advertises it (see `consoleLine.ts`), which is the moment
16
+ * it is relevant — the merchant is already looking at a failure and already has the console open.
17
+ */
18
+ import type { IntegrationEnvironment } from "@tot/public-runtime";
19
+ import {
20
+ isDebugAvailable,
21
+ setDebugEnabled,
22
+ resolveDebugMode,
23
+ readSessionPreference,
24
+ debugUnavailableReason,
25
+ } from "./debugMode";
26
+ import type { SessionStore } from "./session";
27
+ import { describeDebugWindow, type TenantDebugWindow } from "./debugWindow";
28
+
29
+ /** The surface `window.tot` exposes. Kept to one verb — this is a debugging aid, not an API. */
30
+ export interface TotConsoleApi {
31
+ /** Turn capture on (default), off, or report the current state with no argument. */
32
+ debug(on?: boolean): string;
33
+ }
34
+
35
+ export interface DebugConsoleContext {
36
+ environment: IntegrationEnvironment | null | undefined;
37
+ store?: SessionStore | null;
38
+ /** The tenant-wide window, when the page was rendered with one. */
39
+ tenantWindow?: TenantDebugWindow | null;
40
+ now?: () => Date;
41
+ }
42
+
43
+ /**
44
+ * Build the console API. Pure — returns an object rather than touching `window`, so its wording is
45
+ * testable and so nothing is attached to the global scope as a side effect of an import.
46
+ */
47
+ export function createTotConsoleApi(ctx: DebugConsoleContext): TotConsoleApi {
48
+ const now = () => ctx.now?.() ?? new Date();
49
+
50
+ const state = (): string => {
51
+ const resolution = resolveDebugMode({
52
+ environment: ctx.environment,
53
+ sessionPreference: readSessionPreference(ctx.store),
54
+ tenantWindow: ctx.tenantWindow ?? null,
55
+ now: now(),
56
+ });
57
+ if (!resolution.enabled) {
58
+ // Say WHY it is off, not just that it is. "Off" with no reason is what sends someone to
59
+ // support to ask whether the feature is broken.
60
+ if (resolution.source === "stage-ineligible") return `Debug logging is off. ${debugUnavailableReason(ctx.environment)}`;
61
+ return "Debug logging is off. Run tot.debug() to turn it on for this browser tab.";
62
+ }
63
+ const via =
64
+ resolution.source === "tenant-window"
65
+ ? describeDebugWindow(ctx.tenantWindow, now())
66
+ : "Debug logging is on for this browser tab.";
67
+ return `${via} Run tot.debug(false) to turn it off here.`;
68
+ };
69
+
70
+ return {
71
+ debug(on?: boolean): string {
72
+ // No argument = "tell me", not "toggle me". A bare `tot.debug()` typed by someone who is
73
+ // unsure of the current state must never flip it to the opposite of what they wanted.
74
+ if (on === undefined) {
75
+ const resolution = resolveDebugMode({
76
+ environment: ctx.environment,
77
+ sessionPreference: readSessionPreference(ctx.store),
78
+ tenantWindow: ctx.tenantWindow ?? null,
79
+ now: now(),
80
+ });
81
+ // ...except when it is off and CAN be on, where the obvious intent of typing it is to start.
82
+ if (!resolution.enabled && isDebugAvailable(ctx.environment)) return this.debug(true);
83
+ return state();
84
+ }
85
+
86
+ const outcome = setDebugEnabled(on, { environment: ctx.environment, store: ctx.store });
87
+ if (!outcome.changed) {
88
+ if (outcome.reason === "stage-ineligible") {
89
+ return `Not turned on. ${debugUnavailableReason(ctx.environment)}`;
90
+ }
91
+ return "Not changed — this browser is blocking site data, so the setting cannot be remembered. Debug logging needs session storage.";
92
+ }
93
+ return state();
94
+ },
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Attach the API to a global. Never throws and never overwrites an existing `tot` object's other
100
+ * keys — a tenant may well have their own `window.tot`, and clobbering it would be a bug we
101
+ * introduced into their site while trying to diagnose one.
102
+ */
103
+ export function installTotConsoleApi(
104
+ ctx: DebugConsoleContext,
105
+ globalObject: Record<string, unknown> = globalThis as unknown as Record<string, unknown>,
106
+ ): void {
107
+ try {
108
+ const api = createTotConsoleApi(ctx);
109
+ const existing = globalObject.tot;
110
+ if (existing && typeof existing === "object") {
111
+ (existing as Record<string, unknown>).debug = api.debug.bind(api);
112
+ return;
113
+ }
114
+ globalObject.tot = api;
115
+ } catch {
116
+ // A frozen or exotic global. Losing the shortcut is acceptable; breaking the page is not.
117
+ }
118
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * DEBUG MODE — a tenant developer can turn on richer client-side capture while reproducing a
3
+ * problem, and turn it off again. Off by default, and impossible to turn on where it does not belong.
4
+ *
5
+ * NEVER ON A LIVE PUBLIC SITE, and the gate is structural rather than advisory (ADR 0034):
6
+ *
7
+ * - the public path is CDN → content-addressed storage with at most ONE submillisecond edge
8
+ * function (ADR 0013). A debug beacon in that path is origin compute on a ranked,
9
+ * revenue-bearing surface, which is a direct commercial cost on every cache miss;
10
+ * - increased client-side logging is precisely where PII leaks, and this is a REGULATED
11
+ * storefront;
12
+ * - the people who need it are previewing, which is where the failures happen anyway.
13
+ *
14
+ * So `live` is refused at BOTH the read and the write: a stored `on` flag carried into a live
15
+ * document — by a shared sessionStorage, a copied profile, or a stage that changed under it — still
16
+ * reads as off. Enabling is the request; eligibility is the answer, and eligibility is re-decided
17
+ * every time rather than trusted from what was stored.
18
+ */
19
+ import type { IntegrationEnvironment } from "@tot/public-runtime";
20
+ import type { SessionStore } from "./session";
21
+ import { isDebugWindowActive, type TenantDebugWindow } from "./debugWindow";
22
+
23
+ /** Where the toggle is remembered. Session-scoped, like the session id: debug is for one sitting. */
24
+ export const DEBUG_STORAGE_KEY = "tot.diag.debug";
25
+
26
+ /**
27
+ * Stages where debug capture may run. Written as the ALLOWED set, not as `!== "live"`: a fifth stage
28
+ * added later must be considered deliberately rather than inheriting permission from a negation.
29
+ */
30
+ export const DEBUG_ELIGIBLE_STAGES: readonly IntegrationEnvironment[] = Object.freeze([
31
+ "local",
32
+ "candidate",
33
+ "staged",
34
+ ]);
35
+
36
+ /** May debug mode run at all here? The stage decides; the stored flag never overrides this. */
37
+ export function isDebugAvailable(environment: IntegrationEnvironment | null | undefined): boolean {
38
+ return !!environment && DEBUG_ELIGIBLE_STAGES.includes(environment);
39
+ }
40
+
41
+ /**
42
+ * This browser session's explicit choice, or null when the developer has not expressed one.
43
+ *
44
+ * The three states are load-bearing. "Not set" and "explicitly off" look identical while capture is
45
+ * off anyway, but they differ the moment the merchant arms a tenant-wide window: an untouched
46
+ * session joins it, and a session someone deliberately switched off stays off. Collapsing them would
47
+ * silently re-enable capture in a tab where a person had just turned it off.
48
+ */
49
+ export function readSessionPreference(store?: SessionStore | null): SessionDebugPreference {
50
+ if (!store) return null;
51
+ try {
52
+ const raw = store.getItem(DEBUG_STORAGE_KEY);
53
+ return raw === "on" || raw === "off" ? raw : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** A session's explicit choice: on, off, or never asked. */
60
+ export type SessionDebugPreference = "on" | "off" | null;
61
+
62
+ /** Why capture is on or off, so a surface can explain itself rather than just showing a state. */
63
+ export type DebugModeSource = "stage-ineligible" | "session" | "tenant-window" | "default-off";
64
+
65
+ export interface DebugModeResolution {
66
+ enabled: boolean;
67
+ source: DebugModeSource;
68
+ }
69
+
70
+ /**
71
+ * Resolve whether capture is on, from the three things that can say so.
72
+ *
73
+ * PRECEDENCE, and why:
74
+ *
75
+ * 1. STAGE first, and it can only ever say no. `live` refuses regardless of what any session or
76
+ * any armed window claims (ADR 0034). A tenant-wide window is stored per tenant, not per stage,
77
+ * so this is the check that stops an armed window following a merchant onto their public site.
78
+ * 2. The SESSION's explicit choice next, either way. Someone who turned it on in their own tab
79
+ * gets it without waiting for an admin, and someone who turned it OFF while a tenant window is
80
+ * running stays off — otherwise the only way to quieten your own console would be to disarm
81
+ * capture for every colleague mid-investigation.
82
+ * 3. The tenant WINDOW, for every session that has not expressed a preference. This is the whole
83
+ * point of the window: it reaches the sessions nobody has touched, including the colleague who
84
+ * can actually reproduce the bug.
85
+ * 4. Off.
86
+ */
87
+ export function resolveDebugMode(args: {
88
+ environment: IntegrationEnvironment | null | undefined;
89
+ sessionPreference?: SessionDebugPreference;
90
+ tenantWindow?: TenantDebugWindow | null;
91
+ now?: Date;
92
+ }): DebugModeResolution {
93
+ if (!isDebugAvailable(args.environment)) return { enabled: false, source: "stage-ineligible" };
94
+ const preference = args.sessionPreference ?? null;
95
+ if (preference === "on") return { enabled: true, source: "session" };
96
+ if (preference === "off") return { enabled: false, source: "session" };
97
+ if (isDebugWindowActive(args.tenantWindow, args.now ?? new Date())) {
98
+ return { enabled: true, source: "tenant-window" };
99
+ }
100
+ return { enabled: false, source: "default-off" };
101
+ }
102
+
103
+ /**
104
+ * Is capture ON right now?
105
+ *
106
+ * Fails closed on every uncertainty: no stage, an ineligible stage, no store, a store that throws,
107
+ * or anything in storage other than our exact markers.
108
+ */
109
+ export function isDebugEnabled(args: {
110
+ environment: IntegrationEnvironment | null | undefined;
111
+ store?: SessionStore | null;
112
+ tenantWindow?: TenantDebugWindow | null;
113
+ now?: Date;
114
+ }): boolean {
115
+ return resolveDebugMode({
116
+ environment: args.environment,
117
+ sessionPreference: readSessionPreference(args.store),
118
+ tenantWindow: args.tenantWindow ?? null,
119
+ ...(args.now ? { now: args.now } : {}),
120
+ }).enabled;
121
+ }
122
+
123
+ /** What a toggle attempt did — returned so the UI can explain a refusal instead of silently no-oping. */
124
+ export type DebugToggleOutcome =
125
+ | { changed: true; enabled: boolean }
126
+ | { changed: false; reason: "stage-ineligible"; environment: string | null }
127
+ | { changed: false; reason: "no-store" };
128
+
129
+ /**
130
+ * Turn capture on or off.
131
+ *
132
+ * Turning it ON at an ineligible stage is REFUSED and says so — it is never silently ignored, and
133
+ * never silently honoured. A developer who asked for debug on a live site deserves to be told the
134
+ * stage is why, rather than left wondering why no events appear.
135
+ *
136
+ * Turning it OFF is always allowed, at any stage: switching something off must never be gated by the
137
+ * same condition that makes it dangerous to have on.
138
+ */
139
+ export function setDebugEnabled(
140
+ enabled: boolean,
141
+ args: { environment: IntegrationEnvironment | null | undefined; store?: SessionStore | null },
142
+ ): DebugToggleOutcome {
143
+ const store = args.store;
144
+ if (!store) return { changed: false, reason: "no-store" };
145
+ if (enabled && !isDebugAvailable(args.environment)) {
146
+ return { changed: false, reason: "stage-ineligible", environment: args.environment ?? null };
147
+ }
148
+ try {
149
+ store.setItem(DEBUG_STORAGE_KEY, enabled ? "on" : "off");
150
+ return { changed: true, enabled };
151
+ } catch {
152
+ // Private mode or blocked site data. The toggle did not take; say so rather than report success.
153
+ return { changed: false, reason: "no-store" };
154
+ }
155
+ }
156
+
157
+ /** The merchant-facing sentence for why debug is unavailable here, or null when it is available. */
158
+ export function debugUnavailableReason(
159
+ environment: IntegrationEnvironment | null | undefined,
160
+ ): string | null {
161
+ if (isDebugAvailable(environment)) return null;
162
+ return "Debug logging is available on preview, not on your live site. Turn it on there, reproduce the problem, then quote the reference.";
163
+ }