@tokenoftrust/storefront-runner 2.0.41 → 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.
- package/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
- package/apps/storefront/package.json +1 -0
- package/apps/storefront/platform-reconcile-tenants.json +3 -0
- package/apps/storefront/public/favicon.ico +0 -0
- package/apps/storefront/public/shared/commerce-chrome.css +147 -16
- package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
- package/apps/storefront/src/components/Img.astro +6 -5
- package/apps/storefront/src/components/ProductCard.astro +6 -4
- package/apps/storefront/src/components/Seo.astro +2 -1
- package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
- package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
- package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
- package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
- package/apps/storefront/src/components/home/Hero.astro +156 -4
- package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
- package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
- package/apps/storefront/src/components/marketing/Faq.astro +9 -2
- package/apps/storefront/src/config/devTenantSeed.ts +1 -1
- package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
- package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
- package/apps/storefront/src/config/tenants.ts +22 -0
- package/apps/storefront/src/layouts/Layout.astro +69 -44
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
- package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
- package/apps/storefront/src/lib/asyncCss.ts +23 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
- package/apps/storefront/src/lib/basePath.ts +70 -9
- package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
- package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
- package/apps/storefront/src/lib/diag/capture.ts +161 -0
- package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
- package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
- package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
- package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
- package/apps/storefront/src/lib/diag/reference.ts +148 -0
- package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
- package/apps/storefront/src/lib/diag/report.ts +109 -0
- package/apps/storefront/src/lib/diag/session.ts +93 -0
- package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
- package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
- package/apps/storefront/src/lib/imageAttrs.ts +9 -6
- package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
- package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
- package/apps/storefront/src/lib/jsonScript.ts +13 -0
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
- package/apps/storefront/src/lib/previewReload.ts +53 -9
- package/apps/storefront/src/lib/rawChrome.ts +148 -19
- package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
- package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
- package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
- package/apps/storefront/src/lib/tenantRouting.ts +15 -4
- package/apps/storefront/src/lib/tot/syncState.ts +21 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
- package/apps/storefront/src/middleware/controlPlane.ts +9 -5
- package/apps/storefront/src/middleware/index.ts +13 -1
- package/apps/storefront/src/middleware/renderChain.ts +54 -11
- package/apps/storefront/src/middleware/routeBranch.ts +58 -12
- package/apps/storefront/src/middleware/staticBundle.ts +37 -3
- package/apps/storefront/src/pages/[...slug].astro +6 -2
- package/apps/storefront/src/pages/api/request-access.ts +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +11 -1
- package/apps/storefront/src/pages/cart.ts +23 -0
- package/apps/storefront/src/pages/index.astro +64 -85
- package/apps/storefront/src/pages/products/[handle].astro +8 -6
- package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
- package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
- package/apps/storefront/src/styles/fonts.css +7 -0
- package/apps/storefront/src/styles/global.css +397 -3
- package/package.json +4 -1
- package/packages/cli/src/visual-parity.mjs +321 -0
- package/packages/public-runtime/package.json +2 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/chrome.ts +44 -7
- package/packages/public-runtime/src/csp.ts +12 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
- package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
- package/packages/public-runtime/src/extension-contract.ts +20 -6
- package/packages/public-runtime/src/index.ts +8 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
- package/packages/public-runtime/src/integration/consent-config.ts +9 -0
- package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
- package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
- package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
- package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
- package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
- package/packages/public-runtime/src/publication-redirects.mjs +379 -0
- package/packages/public-runtime/src/publication-space.ts +24 -3
- package/packages/public-runtime/src/static-bundle.ts +145 -26
- package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
- package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
- package/packages/public-runtime/src/tenant.ts +59 -1
- package/pnpm-lock.runner.yaml +12 -0
- package/pnpm-workspace.yaml +4 -0
- package/scripts/dev/git-status.mjs +1 -1
- package/scripts/dev/publish.mjs +6 -0
- package/scripts/lib/tenant-asset-copy.mjs +4 -2
- package/scripts/tenant/materialize-tenant-content.mjs +1 -0
- package/scripts/tenant/validate.mjs +171 -8
- package/apps/storefront/src/lib/cfImage.ts +0 -55
- package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/js/storefront.js +0 -129
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +0 -235
- 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 {
|
|
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({
|
|
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,
|
|
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
|
+
}
|