@tokenoftrust/storefront-runner 2.2.77 → 2.2.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.79",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -492,7 +492,7 @@ export type ViewerAnalyticsConsentDecision = "granted" | "denied";
|
|
|
492
492
|
export interface ViewerAnalyticsConsentState {
|
|
493
493
|
schemaVersion: 1;
|
|
494
494
|
decision: ViewerAnalyticsConsentDecision;
|
|
495
|
-
source: "tenant-default" | "default-deny" | "stored" | "global-privacy-control";
|
|
495
|
+
source: "tenant-default" | "default-deny" | "stored" | "stored-invalid" | "global-privacy-control";
|
|
496
496
|
canGrant: boolean;
|
|
497
497
|
expiresAt: number | null;
|
|
498
498
|
retentionDays: typeof VIEWER_ANALYTICS_CONSENT_RETENTION_DAYS;
|
|
@@ -596,51 +596,68 @@ export function applyEarlyViewerAnalyticsConsent(
|
|
|
596
596
|
const globalPrivacyControl = win.navigator?.globalPrivacyControl === true;
|
|
597
597
|
|
|
598
598
|
let stored: { decision: ViewerAnalyticsConsentDecision; expiresAt: number } | null = null;
|
|
599
|
+
// A stored record that is present but unreadable may have been a denial, so it denies
|
|
600
|
+
// rather than falling through to a granting tenant default. Only a well-formed record
|
|
601
|
+
// past its retention lapses to the default.
|
|
602
|
+
let storedInvalid = false;
|
|
599
603
|
if (override) {
|
|
600
604
|
stored = { decision: override, expiresAt: now + retentionMs };
|
|
601
605
|
} else {
|
|
606
|
+
let raw: string | null | undefined;
|
|
602
607
|
try {
|
|
603
|
-
|
|
604
|
-
if (raw) {
|
|
605
|
-
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
606
|
-
const keys = Object.keys(parsed).sort();
|
|
607
|
-
const decidedAt = parsed["decidedAt"];
|
|
608
|
-
const expiresAt = parsed["expiresAt"];
|
|
609
|
-
const decision = parsed["decision"];
|
|
610
|
-
if (
|
|
611
|
-
keys.join(",") === "decidedAt,decision,environment,expiresAt,schemaVersion,siteSurface,tenantPublicId" &&
|
|
612
|
-
parsed["schemaVersion"] === 1 &&
|
|
613
|
-
parsed["tenantPublicId"] === tenantPublicId &&
|
|
614
|
-
parsed["environment"] === environment &&
|
|
615
|
-
parsed["siteSurface"] === siteSurface &&
|
|
616
|
-
(decision === "granted" || decision === "denied") &&
|
|
617
|
-
typeof decidedAt === "number" &&
|
|
618
|
-
Number.isFinite(decidedAt) &&
|
|
619
|
-
typeof expiresAt === "number" &&
|
|
620
|
-
Number.isFinite(expiresAt) &&
|
|
621
|
-
decidedAt <= now &&
|
|
622
|
-
expiresAt > now &&
|
|
623
|
-
expiresAt - decidedAt === retentionMs
|
|
624
|
-
) {
|
|
625
|
-
stored = { decision, expiresAt };
|
|
626
|
-
}
|
|
627
|
-
}
|
|
608
|
+
raw = win.localStorage?.getItem(scopedStorageKey);
|
|
628
609
|
} catch {
|
|
629
|
-
// Storage denial
|
|
610
|
+
// Storage denial leaves no viewer decision to read.
|
|
611
|
+
}
|
|
612
|
+
if (raw) {
|
|
613
|
+
let parsed: unknown = null;
|
|
614
|
+
try {
|
|
615
|
+
parsed = JSON.parse(raw);
|
|
616
|
+
} catch {
|
|
617
|
+
// Malformed data is handled as an invalid record below.
|
|
618
|
+
}
|
|
619
|
+
const record = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
620
|
+
? parsed as Record<string, unknown>
|
|
621
|
+
: null;
|
|
622
|
+
const decidedAt = record?.["decidedAt"];
|
|
623
|
+
const expiresAt = record?.["expiresAt"];
|
|
624
|
+
const decision = record?.["decision"];
|
|
625
|
+
if (
|
|
626
|
+
record !== null &&
|
|
627
|
+
Object.keys(record).sort().join(",") ===
|
|
628
|
+
"decidedAt,decision,environment,expiresAt,schemaVersion,siteSurface,tenantPublicId" &&
|
|
629
|
+
record["schemaVersion"] === 1 &&
|
|
630
|
+
record["tenantPublicId"] === tenantPublicId &&
|
|
631
|
+
record["environment"] === environment &&
|
|
632
|
+
record["siteSurface"] === siteSurface &&
|
|
633
|
+
(decision === "granted" || decision === "denied") &&
|
|
634
|
+
typeof decidedAt === "number" &&
|
|
635
|
+
Number.isFinite(decidedAt) &&
|
|
636
|
+
typeof expiresAt === "number" &&
|
|
637
|
+
Number.isFinite(expiresAt) &&
|
|
638
|
+
decidedAt <= now &&
|
|
639
|
+
expiresAt - decidedAt === retentionMs
|
|
640
|
+
) {
|
|
641
|
+
if (expiresAt > now) stored = { decision, expiresAt };
|
|
642
|
+
} else {
|
|
643
|
+
storedInvalid = true;
|
|
644
|
+
}
|
|
630
645
|
}
|
|
631
646
|
}
|
|
632
647
|
|
|
633
648
|
const decision: ViewerAnalyticsConsentDecision =
|
|
634
|
-
globalPrivacyControl || !tenantApprovedAnalytics
|
|
649
|
+
globalPrivacyControl || !tenantApprovedAnalytics || (!stored && storedInvalid)
|
|
635
650
|
? "denied"
|
|
636
651
|
: stored?.decision ?? (tenantDefaultAnalytics ? "granted" : "denied");
|
|
637
652
|
const source: ViewerAnalyticsConsentState["source"] = globalPrivacyControl
|
|
638
653
|
? "global-privacy-control"
|
|
639
654
|
: stored
|
|
640
655
|
? "stored"
|
|
641
|
-
:
|
|
642
|
-
? "
|
|
643
|
-
:
|
|
656
|
+
: storedInvalid
|
|
657
|
+
? "stored-invalid"
|
|
658
|
+
: tenantDefaultAnalytics
|
|
659
|
+
? "tenant-default"
|
|
660
|
+
: "default-deny";
|
|
644
661
|
|
|
645
662
|
const eligible = canonicalPurposes.filter(
|
|
646
663
|
(purpose) =>
|
|
@@ -780,6 +797,49 @@ function renderControlState(root: HTMLElement, state: ViewerAnalyticsConsentStat
|
|
|
780
797
|
}
|
|
781
798
|
}
|
|
782
799
|
|
|
800
|
+
function firstEnabledButton(root: HTMLElement): HTMLButtonElement | null {
|
|
801
|
+
return root.querySelector<HTMLButtonElement>("button:not(:disabled)");
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Bind each persistent "Analytics choices" disclosure to the control its
|
|
806
|
+
* `aria-controls` names: a click opens the control and moves focus into it; a second
|
|
807
|
+
* click, the control's Close button, or Escape inside it closes it again, and the
|
|
808
|
+
* latter two return focus to the toggle.
|
|
809
|
+
*/
|
|
810
|
+
function bindViewerAnalyticsChoicesToggles(win: ViewerConsentWindow): void {
|
|
811
|
+
const doc = win.document;
|
|
812
|
+
doc?.querySelectorAll<HTMLButtonElement>("[data-tot-analytics-choices-toggle]").forEach((toggle) => {
|
|
813
|
+
if (toggle.dataset["totAnalyticsChoicesBound"] === "true") return;
|
|
814
|
+
const controlId = toggle.getAttribute("aria-controls");
|
|
815
|
+
const control = controlId ? doc.getElementById(controlId) : null;
|
|
816
|
+
if (!control) return;
|
|
817
|
+
toggle.dataset["totAnalyticsChoicesBound"] = "true";
|
|
818
|
+
|
|
819
|
+
const open = () => {
|
|
820
|
+
control.hidden = false;
|
|
821
|
+
toggle.setAttribute("aria-expanded", "true");
|
|
822
|
+
firstEnabledButton(control)?.focus();
|
|
823
|
+
};
|
|
824
|
+
const close = (returnFocus: boolean) => {
|
|
825
|
+
control.hidden = true;
|
|
826
|
+
toggle.setAttribute("aria-expanded", "false");
|
|
827
|
+
if (returnFocus) toggle.focus();
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
toggle.addEventListener("click", () => (control.hidden ? open() : close(false)));
|
|
831
|
+
control.addEventListener("click", (event) => {
|
|
832
|
+
const target = event.target;
|
|
833
|
+
if (target instanceof Element && target.closest("[data-tot-analytics-consent-close]")) close(true);
|
|
834
|
+
});
|
|
835
|
+
control.addEventListener("keydown", (event) => {
|
|
836
|
+
if (event.key !== "Escape" || control.hidden) return;
|
|
837
|
+
event.preventDefault();
|
|
838
|
+
close(true);
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
783
843
|
/** Bind the renderer-owned control once; safe when a document contains no analytics plan. */
|
|
784
844
|
export function bootViewerAnalyticsConsentControl(
|
|
785
845
|
win: ViewerConsentWindow,
|
|
@@ -799,8 +859,11 @@ export function bootViewerAnalyticsConsentControl(
|
|
|
799
859
|
const decision = button?.dataset["totAnalyticsConsentDecision"];
|
|
800
860
|
if (decision !== "granted" && decision !== "denied") return;
|
|
801
861
|
renderControlState(root, setViewerAnalyticsConsent(win, decision, now()));
|
|
862
|
+
// The pressed button is now disabled; keep keyboard focus inside the control.
|
|
863
|
+
if (button?.disabled) firstEnabledButton(root)?.focus();
|
|
802
864
|
});
|
|
803
865
|
});
|
|
866
|
+
bindViewerAnalyticsChoicesToggles(win);
|
|
804
867
|
|
|
805
868
|
const storageBoundGlobal = "__TOT_VIEWER_ANALYTICS_CONSENT_STORAGE_BOUND__";
|
|
806
869
|
if (win.addEventListener && win[storageBoundGlobal] !== true) {
|
|
@@ -177,6 +177,7 @@ export function renderIntegrationPhases(
|
|
|
177
177
|
const bodyDeferred =
|
|
178
178
|
phaseBoundary("document.deferred") +
|
|
179
179
|
`<div data-tot-integration-deferred hidden></div>` +
|
|
180
|
+
viewerAnalyticsChoicesLink(plan) +
|
|
180
181
|
lifecycleHostScript();
|
|
181
182
|
|
|
182
183
|
return { head, bodyStart, bodyDeferred };
|
|
@@ -234,20 +235,37 @@ function inlineScript(source: string, nonce: string): string {
|
|
|
234
235
|
|
|
235
236
|
/** Renderer-owned, provider-neutral viewer control: identical in both document mounts. */
|
|
236
237
|
function viewerAnalyticsConsentStyle(nonce: string): string {
|
|
237
|
-
const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}`;
|
|
238
|
+
const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}.tot-analytics-choices{display:flex;justify-content:center;padding:.75rem 1rem;background:var(--color-bg,#fff)}.tot-analytics-choices__toggle{border:0;background:none;padding:.25rem;color:var(--color-muted,#475569);font:500 .75rem/1.25 system-ui,sans-serif;text-decoration:underline;cursor:pointer}`;
|
|
238
239
|
return `<style${nonce ? ` nonce="${nonce}"` : ""}>${style}</style>`;
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
/** The id the persistent "Analytics choices" toggle names in `aria-controls`. */
|
|
243
|
+
const VIEWER_ANALYTICS_CONSENT_CONTROL_ID = "tot-analytics-consent";
|
|
244
|
+
|
|
241
245
|
function viewerAnalyticsConsentControl(): string {
|
|
242
|
-
return `<aside class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings" hidden>` +
|
|
246
|
+
return `<aside id="${VIEWER_ANALYTICS_CONSENT_CONTROL_ID}" class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings" hidden>` +
|
|
243
247
|
`<span class="tot-analytics-consent__copy"><strong data-tot-analytics-consent-status aria-live="polite">Analytics off</strong>` +
|
|
244
|
-
`<span class="tot-analytics-consent__detail">Choose whether
|
|
248
|
+
`<span class="tot-analytics-consent__detail">Choose whether analytics may run on this site.</span></span>` +
|
|
245
249
|
`<span class="tot-analytics-consent__actions">` +
|
|
246
250
|
`<button type="button" data-tot-analytics-consent-decision="granted" aria-pressed="false" disabled>Allow</button>` +
|
|
247
251
|
`<button type="button" data-tot-analytics-consent-decision="denied" aria-pressed="true" disabled>Decline</button>` +
|
|
252
|
+
`<button type="button" data-tot-analytics-consent-close>Close</button>` +
|
|
248
253
|
`</span></aside>`;
|
|
249
254
|
}
|
|
250
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The persistent, platform-owned way to reach the control: a disclosure button placed
|
|
258
|
+
* after the tenant's own footer, on every page whose plan asks for analytics consent.
|
|
259
|
+
* The lifecycle host binds it (`bootViewerAnalyticsConsentControl`); no inline handler.
|
|
260
|
+
*/
|
|
261
|
+
function viewerAnalyticsChoicesLink(plan: EffectivePlan): string {
|
|
262
|
+
if (!plan.consentNeeds.includes("analytics")) return "";
|
|
263
|
+
return `<div class="tot-analytics-choices">` +
|
|
264
|
+
`<button type="button" class="tot-analytics-choices__toggle" data-tot-analytics-choices-toggle ` +
|
|
265
|
+
`aria-controls="${VIEWER_ANALYTICS_CONSENT_CONTROL_ID}" aria-expanded="false">Analytics choices</button>` +
|
|
266
|
+
`</div>`;
|
|
267
|
+
}
|
|
268
|
+
|
|
251
269
|
/**
|
|
252
270
|
* Replace every {@link INTEGRATION_NONCE_PLACEHOLDER} in `text` (an HTML body or a
|
|
253
271
|
* CSP header value) with one fresh per-response `nonce`. Idempotent on text with no
|