@tokenoftrust/storefront-runner 2.2.93 → 2.2.95

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.
@@ -107,11 +107,13 @@ export async function resolveTenantRuleset(
107
107
  /** Build the enforcement→evidence signals for a tenant (for the promote gate). */
108
108
  export async function resolveEnforcementEvidenceSignals(
109
109
  tenant: Pick<TenantConfig, "tenant_id" | "compliance">,
110
+ opts: { ageGateBypassedByRawHtml?: boolean } = {},
110
111
  ): Promise<EnforcementEvidenceSignals> {
111
112
  const regulated = isRegulatedTenant(tenant);
112
113
  const ruleset = await resolveTenantRuleset(tenant);
113
114
  return enforcementSignals(ruleset, {
114
115
  regulated,
115
116
  reportingCaptureWired: PACT_REPORTING_CAPTURE_WIRED,
117
+ ageGateBypassedByRawHtml: !!opts.ageGateBypassedByRawHtml,
116
118
  });
117
119
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Force-injectable compliance markup for the raw-HTML serve path.
3
+ *
4
+ * `rawHtmlResponse` (rawMarketingHtml.ts) bypasses Astro's Layout, so these are
5
+ * the platform-owned anchors that CAN be guaranteed on a raw document
6
+ * independent of its content: the FDA nicotine warning, the shipping/
7
+ * jurisdiction notice, and the excise-tax disclosure. Each mirrors the exact
8
+ * markup its Layout.astro sibling renders (NicotineWarning.astro "bar" variant,
9
+ * ShippingRestrictionNotice.astro "footer" variant via ComplianceNotice.astro,
10
+ * and Layout's own excise-tax <p>) so a raw and a block-composed page present
11
+ * these anchors identically. See `rawHtmlPolicy` for which anchors this does
12
+ * and doesn't cover (notably: NOT `age-gate`).
13
+ */
14
+
15
+ function escapeHtml(value: string): string {
16
+ return value
17
+ .replace(/&/g, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;")
20
+ .replace(/"/g, "&quot;")
21
+ .replace(/'/g, "&#39;");
22
+ }
23
+
24
+ const NICOTINE_WARNING_TEXT =
25
+ "WARNING: This product contains nicotine. Nicotine is an addictive chemical.";
26
+
27
+ /** Mirrors NicotineWarning.astro's "bar" variant. */
28
+ export function nicotineWarningBarHtml(): string {
29
+ return (
30
+ '<div class="border-b-2 border-[var(--color-text)] bg-[var(--color-text)] text-[var(--color-bg)]" role="note" aria-label="Health warning" data-compliance="nicotine-warning">' +
31
+ '<p class="container-prose py-2 text-center text-[0.72rem] font-semibold uppercase tracking-[0.06em] sm:text-xs">' +
32
+ NICOTINE_WARNING_TEXT +
33
+ "</p></div>"
34
+ );
35
+ }
36
+
37
+ /** Mirrors ShippingRestrictionNotice.astro's "footer" variant (via ComplianceNotice.astro). Empty when `text` is absent — same as the Astro component. */
38
+ export function shippingRestrictionNoticeHtml(text: string | undefined): string {
39
+ if (!text) return "";
40
+ return (
41
+ '<div class="border-t border-[var(--color-border)] bg-[var(--color-bg)]" data-compliance="shipping-restriction">' +
42
+ '<p class="container-prose py-3 text-center text-sm text-[var(--color-muted)]" role="note" aria-label="Shipping restriction notice">' +
43
+ escapeHtml(text) +
44
+ "</p></div>"
45
+ );
46
+ }
47
+
48
+ /** Mirrors Layout.astro's excise-tax disclosure <p>. */
49
+ export function exciseTaxNoticeHtml(): string {
50
+ return (
51
+ '<p class="border-t border-[var(--color-border)] bg-[var(--color-bg)] py-2.5 text-center text-xs text-[var(--color-muted)]" data-capability="excise-tax">' +
52
+ "Applicable excise tax is calculated at checkout.</p>"
53
+ );
54
+ }
55
+
56
+ export interface RawComplianceChromeInput {
57
+ nicotineWarning?: boolean;
58
+ shippingRestriction?: string;
59
+ exciseTaxEnabled?: boolean;
60
+ }
61
+
62
+ /** The markup to inject at `rawHtmlResponse`'s body-start boundary. */
63
+ export function renderRawComplianceTop(input: RawComplianceChromeInput): string {
64
+ return input.nicotineWarning ? nicotineWarningBarHtml() : "";
65
+ }
66
+
67
+ /** The markup to inject at `rawHtmlResponse`'s body-deferred boundary. */
68
+ export function renderRawComplianceBottom(input: RawComplianceChromeInput): string {
69
+ let out = shippingRestrictionNoticeHtml(input.shippingRestriction);
70
+ if (input.exciseTaxEnabled) out += exciseTaxNoticeHtml();
71
+ return out;
72
+ }
@@ -299,6 +299,15 @@ export function rawHtmlResponse(
299
299
  * unchanged from today (see resolveCanonicalHref).
300
300
  */
301
301
  canonicalUrl?: string;
302
+ /**
303
+ * Platform-owned compliance markup to force-inject at the body-start and
304
+ * body-deferred boundaries, independent of the tenant's own document —
305
+ * see rawComplianceNotices.ts and rawHtmlPolicy's uncoveredSurfaces.
306
+ * Omitted (the default) is a strict no-op, so every existing raw-HTML
307
+ * tenant that never passes these is byte-for-byte unchanged.
308
+ */
309
+ complianceTop?: string;
310
+ complianceBottom?: string;
302
311
  },
303
312
  ): Response {
304
313
  let composed =
@@ -344,7 +353,9 @@ export function rawHtmlResponse(
344
353
  const phases = renderIntegrationPhases(opts.plan, opts.nonce, opts.consentSnapshot);
345
354
  composed = injectHeadStart(composed, phases.head);
346
355
  composed = injectBodyStart(composed, phases.bodyStart);
356
+ if (opts.complianceTop) composed = injectBodyStart(composed, opts.complianceTop);
347
357
  composed = injectBodyEnd(composed, phases.bodyDeferred);
358
+ if (opts.complianceBottom) composed = injectBodyEnd(composed, opts.complianceBottom);
348
359
  // Declarative interaction tracking, last in the body so the whole document —
349
360
  // a verbatim tenant document, a fragment spliced into a hand-authored
350
361
  // `chrome.html`, or a fragment spliced into the generated `renderRawChrome`
@@ -12,9 +12,10 @@ import ProseToc from "@/components/content/ProseToc.astro";
12
12
  import { breadcrumbLd } from "@/lib/jsonld";
13
13
  import { truncate } from "@/lib/format";
14
14
  import { rawHtmlPolicy, rawHtmlResponse, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml";
15
+ import { renderRawComplianceTop, renderRawComplianceBottom } from "@/lib/compliance/rawComplianceNotices";
15
16
  import { markPinnedRouteRewriteComplete } from "@/lib/pinnedRouteRewriteBoundary";
16
17
 
17
- const { tenant, canonicalBase, content, basePath } = Astro.locals;
18
+ const { tenant, canonicalBase, content, basePath, capabilities, features } = Astro.locals;
18
19
  const slug = (Astro.params.slug ?? "").replace(/^\/+|\/+$/g, "");
19
20
 
20
21
  // Defensive: never let the catch-all answer for first-class route namespaces.
@@ -43,10 +44,16 @@ if (slug === "_style-guide" || slug.startsWith("_style-guide/")) {
43
44
  // bypass Layout. Regulated commerce tenants fall through to platform-composed
44
45
  // pages so compliance UI cannot be removed by raw HTML.
45
46
  const rawHtml = await content.getPageHtml(slug);
46
- if (rawHtml && rawHtmlPolicy(tenant).allowed) {
47
+ const rawPolicy = rawHtmlPolicy(tenant);
48
+ if (rawHtml && rawPolicy.allowed) {
47
49
  // Shared chrome: a body-only fragment gets wrapped in the tenant's chrome.html;
48
50
  // a full document serves verbatim (backward-compatible). See rawMarketingHtml.ts.
49
51
  const chromeWrapper = await content.getChromeHtml(`/${slug}`);
52
+ const notices = {
53
+ nicotineWarning: !!tenant.compliance?.nicotineWarning,
54
+ shippingRestriction: tenant.compliance?.shippingRestriction,
55
+ exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
56
+ };
50
57
  return rawHtmlResponse(rawHtml, {
51
58
  nonce: Astro.locals.cspNonce,
52
59
  basePath,
@@ -54,6 +61,8 @@ if (rawHtml && rawHtmlPolicy(tenant).allowed) {
54
61
  plan: Astro.locals.integrationPlan,
55
62
  consentSnapshot: Astro.locals.consentSnapshot,
56
63
  canonicalUrl: rawPageCanonicalUrl(canonicalBase, slug),
64
+ complianceTop: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
65
+ complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
57
66
  });
58
67
  }
59
68
 
@@ -20,6 +20,7 @@ import MarketingBlocks from "@/components/marketing/MarketingBlocks.astro";
20
20
  import TierCards from "@/components/membership/TierCards.astro";
21
21
  import { withBase } from "@/lib/basePath";
22
22
  import { rawHtmlPolicy, rawHtmlResponse } from "@/lib/rawMarketingHtml";
23
+ import { renderRawComplianceTop, renderRawComplianceBottom } from "@/lib/compliance/rawComplianceNotices";
23
24
  import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
24
25
  import { getRegistryService } from "@/lib/apps/registryAccess.js";
25
26
  import { renderWidgetSlot } from "@/lib/apps/widgets/renderSlot.js";
@@ -42,7 +43,7 @@ import {
42
43
  type CommerceStaticResolved,
43
44
  } from "@/lib/homeVisualParity";
44
45
 
45
- const { tenant, tot, content, basePath, canonicalBase, features } = Astro.locals;
46
+ const { tenant, tot, content, basePath, canonicalBase, features, capabilities } = Astro.locals;
46
47
  const marketing = !features.catalog;
47
48
 
48
49
  // Raw-HTML home (vendor-injected): serve the tenant's own self-contained
@@ -59,11 +60,17 @@ const marketing = !features.catalog;
59
60
  // age/identity, warning, tax, and checkout guardrails remain non-removable.
60
61
  // Mirrors the raw-subpage path in [...slug].astro.
61
62
  const rawHtml = await content.getHomeHtml();
62
- if (rawHtml && rawHtmlPolicy(tenant).allowed) {
63
+ const rawPolicy = rawHtmlPolicy(tenant);
64
+ if (rawHtml && rawPolicy.allowed) {
63
65
  // Shared chrome: if the tenant ships a chrome.html wrapper AND this home is a
64
66
  // body-only fragment, rawHtmlResponse splices it in; a full document serves
65
67
  // verbatim (backward-compatible).
66
68
  const chromeWrapper = await content.getChromeHtml("/");
69
+ const notices = {
70
+ nicotineWarning: !!tenant.compliance?.nicotineWarning,
71
+ shippingRestriction: tenant.compliance?.shippingRestriction,
72
+ exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
73
+ };
67
74
  return rawHtmlResponse(rawHtml, {
68
75
  nonce: Astro.locals.cspNonce,
69
76
  basePath,
@@ -71,6 +78,8 @@ if (rawHtml && rawHtmlPolicy(tenant).allowed) {
71
78
  plan: Astro.locals.integrationPlan,
72
79
  consentSnapshot: Astro.locals.consentSnapshot,
73
80
  canonicalUrl: new URL("/", canonicalBase + "/").toString(),
81
+ complianceTop: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
82
+ complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
74
83
  });
75
84
  }
76
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.93",
3
+ "version": "2.2.95",
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",
@@ -27,6 +27,14 @@ export interface EnforcementEvidenceSignals {
27
27
  exciseWired: boolean;
28
28
  /** PACT per-order reporting capture is wired when required. */
29
29
  reportingWired: boolean;
30
+ /**
31
+ * The age-gate component actually RENDERS on the live page, OR no age gate is
32
+ * required. False when a regulated tenant's home/pages serve via the raw-HTML
33
+ * bypass (which cannot yet force-inject the hydrated age-gate island) — distinct
34
+ * from `verificationWired` (a provider being configured is moot if the gate
35
+ * component itself never reaches the page).
36
+ */
37
+ ageGateRenderable: boolean;
30
38
  }
31
39
 
32
40
  export interface EnforcementSignalInput {
@@ -34,6 +42,12 @@ export interface EnforcementSignalInput {
34
42
  regulated: boolean;
35
43
  /** Whether the PACT per-order reporting capture sink is wired in this deployment. */
36
44
  reportingCaptureWired: boolean;
45
+ /**
46
+ * True when this tenant's regulated pages are currently served via the
47
+ * raw-HTML bypass with no injected age gate (see `rawHtmlPolicy`'s
48
+ * `uncoveredSurfaces`). Defaults to false (block-composed / not applicable).
49
+ */
50
+ ageGateBypassedByRawHtml?: boolean;
37
51
  }
38
52
 
39
53
  /**
@@ -52,6 +66,7 @@ export function enforcementSignals(
52
66
  verificationWired: true,
53
67
  exciseWired: true,
54
68
  reportingWired: true,
69
+ ageGateRenderable: true,
55
70
  };
56
71
  }
57
72
  if (!ruleset) {
@@ -61,6 +76,7 @@ export function enforcementSignals(
61
76
  verificationWired: false,
62
77
  exciseWired: false,
63
78
  reportingWired: false,
79
+ ageGateRenderable: false,
64
80
  };
65
81
  }
66
82
  const verificationWired =
@@ -77,5 +93,6 @@ export function enforcementSignals(
77
93
  verificationWired,
78
94
  exciseWired,
79
95
  reportingWired,
96
+ ageGateRenderable: !input.ageGateBypassedByRawHtml,
80
97
  };
81
98
  }
@@ -2,16 +2,65 @@
2
2
  * Policy for the raw tenant-authored HTML path.
3
3
  *
4
4
  * Raw HTML is a useful migration/marketing bridge, but it bypasses Astro Layout
5
- * composition. Regulated tenants must stay on a platform-composed path
6
- * so age/identity verification, warnings, taxes, checkout signing, and publish
7
- * gates remain non-removable.
5
+ * composition — so whatever compliance anchors the page itself would normally
6
+ * render have to be guaranteed another way. The raw-serve path (`rawHtmlResponse`
7
+ * in `rawMarketingHtml.ts`) force-injects the platform's own markup for the
8
+ * anchors it CAN guarantee independent of the tenant's document content —
9
+ * `compliance-warning` (NicotineWarning) and `shipping-jurisdiction-notice`
10
+ * (ShippingRestrictionNotice) are plain, policy-fixed text, so injecting them is
11
+ * exactly as non-removable on the raw path as on the block-composed one.
12
+ *
13
+ * `age-gate` is NOT yet injectable on the raw path — `IsolatedAgeGate` is a
14
+ * hydrated island, and the raw-serve Response is built outside Astro's own
15
+ * component/island pipeline, so there is no verified way yet to force a real,
16
+ * functioning age gate onto an arbitrary raw document.
17
+ *
18
+ * PUBLISH/PREVIEW vs LIVE are deliberately split here: raw HTML is always
19
+ * ALLOWED to reconcile and serve on preview — a tenant can get its warnings
20
+ * visible and iterate even before the age gate is solved. `uncoveredSurfaces`
21
+ * is the signal, not a block: the LIVE promotion gate
22
+ * (`resolveEnforcementEvidenceSignals` → `ageGateRenderable` →
23
+ * `evidence-producer.ts`'s `age_gate` check) is what actually refuses to
24
+ * promote a tenant whose required `age-gate` surface isn't covered. That is
25
+ * where restrictions belong — not here.
8
26
  */
9
27
  import type { TenantConfig } from "./tenant.js";
10
- import { isRegulated } from "./compliance-obligations.js";
28
+ import { isRegulated, protectedSurfacesFor, type ProtectedSurface } from "./compliance-obligations.js";
29
+
30
+ /**
31
+ * Protected surfaces the raw-serve path force-injects itself (see
32
+ * `rawHtmlResponse`'s `complianceTop`/`complianceBottom`), independent of the
33
+ * tenant's own document. Anything else required but not in this list blocks
34
+ * raw HTML.
35
+ */
36
+ const RAW_HTML_INJECTABLE_SURFACES: readonly ProtectedSurface[] = [
37
+ "compliance-warning",
38
+ "shipping-jurisdiction-notice",
39
+ ];
40
+
41
+ /**
42
+ * Protected surfaces a MARKETING PAGE (home / raw subpage) is responsible for.
43
+ * `cart-preflight` and `checkout-controls` are commerce-route concerns (cart,
44
+ * checkout) that a raw marketing page can never touch either way — the hybrid
45
+ * pattern already keeps those on the live, platform-owned commerce core — so
46
+ * they're irrelevant to whether THIS page may serve raw HTML.
47
+ */
48
+ const MARKETING_PAGE_SURFACES: readonly ProtectedSurface[] = [
49
+ "age-gate",
50
+ "compliance-warning",
51
+ "shipping-jurisdiction-notice",
52
+ ];
11
53
 
12
54
  export interface RawHtmlPolicyDecision {
55
+ /** Always true — raw HTML may always reconcile/preview. See module header. */
13
56
  allowed: boolean;
14
57
  regulated: boolean;
58
+ /**
59
+ * Protected surfaces this tenant requires that the raw path cannot (yet)
60
+ * force-inject — informational: the LIVE promotion gate reads this, serving
61
+ * is never blocked on it.
62
+ */
63
+ uncoveredSurfaces: ProtectedSurface[];
15
64
  reason?: string;
16
65
  }
17
66
 
@@ -19,15 +68,10 @@ export function rawHtmlPolicy(
19
68
  tenant: Pick<TenantConfig, "siteType" | "compliance">,
20
69
  ): RawHtmlPolicyDecision {
21
70
  const regulated = isRegulated(tenant);
71
+ const required = protectedSurfacesFor(tenant);
72
+ const uncoveredSurfaces = required.filter(
73
+ (s) => MARKETING_PAGE_SURFACES.includes(s) && !RAW_HTML_INJECTABLE_SURFACES.includes(s),
74
+ );
22
75
 
23
- if (regulated) {
24
- return {
25
- allowed: false,
26
- regulated,
27
- reason:
28
- "Raw HTML bypasses platform Layout compliance; regulated commerce tenants must use block composition or an extracted runtime.",
29
- };
30
- }
31
-
32
- return { allowed: true, regulated };
76
+ return { allowed: true, regulated, uncoveredSurfaces };
33
77
  }