@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.77",
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
- const raw = win.localStorage?.getItem(scopedStorageKey);
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, malformed data, and hostile access all fail closed.
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
- : tenantDefaultAnalytics
642
- ? "tenant-default"
643
- : "default-deny";
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 anonymous analytics may run.</span></span>` +
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