@tokenoftrust/storefront-runner 2.0.17 → 2.0.19

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 (98) hide show
  1. package/apps/storefront/env.d.ts +21 -0
  2. package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
  3. package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
  4. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
  5. package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
  6. package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
  7. package/apps/storefront/src/config/devTenantSeed.ts +24 -1
  8. package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
  9. package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
  10. package/apps/storefront/src/config/resolver.ts +5 -0
  11. package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
  12. package/apps/storefront/src/config/tenants.ts +11 -0
  13. package/apps/storefront/src/layouts/Layout.astro +5 -1
  14. package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
  15. package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
  16. package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
  17. package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
  18. package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
  19. package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
  20. package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
  21. package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
  22. package/apps/storefront/src/lib/auth/session.ts +3 -0
  23. package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
  24. package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
  25. package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
  26. package/apps/storefront/src/lib/edgeCache.ts +17 -1
  27. package/apps/storefront/src/lib/env.ts +13 -111
  28. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
  29. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
  30. package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
  31. package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
  32. package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
  33. package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
  34. package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
  35. package/apps/storefront/src/middleware/candidatePin.ts +35 -0
  36. package/apps/storefront/src/middleware/controlPlane.ts +291 -0
  37. package/apps/storefront/src/middleware/index.ts +200 -793
  38. package/apps/storefront/src/middleware/publicRender.ts +43 -0
  39. package/apps/storefront/src/middleware/renderChain.ts +730 -0
  40. package/apps/storefront/src/middleware/routeBranch.ts +192 -0
  41. package/apps/storefront/src/middleware/staticBundle.ts +109 -0
  42. package/apps/storefront/src/pages/[...slug].astro +1 -0
  43. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
  44. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
  45. package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
  46. package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
  47. package/apps/storefront/src/pages/index.astro +1 -0
  48. package/apps/storefront/vitest.config.ts +5 -0
  49. package/package.json +1 -1
  50. package/packages/public-runtime/package.json +1 -0
  51. package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
  52. package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
  53. package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
  54. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
  55. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
  56. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
  57. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
  58. package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
  59. package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
  60. package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
  61. package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
  62. package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
  63. package/packages/public-runtime/src/activity/catalog.ts +1 -1
  64. package/packages/public-runtime/src/candidate-index.ts +6 -0
  65. package/packages/public-runtime/src/customization-reconcile.ts +8 -0
  66. package/packages/public-runtime/src/html-cache-policy.ts +57 -0
  67. package/packages/public-runtime/src/index.ts +9 -0
  68. package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
  69. package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
  70. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
  71. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
  72. package/packages/public-runtime/src/integration/render-phases.ts +8 -3
  73. package/packages/public-runtime/src/publication-artifact.ts +220 -0
  74. package/packages/public-runtime/src/publication-reader.ts +187 -0
  75. package/packages/public-runtime/src/publication-routing.ts +246 -0
  76. package/packages/public-runtime/src/publication-space.ts +853 -0
  77. package/packages/public-runtime/src/publication-version.ts +602 -0
  78. package/packages/public-runtime/src/route-tier.ts +257 -0
  79. package/packages/public-runtime/src/static-bundle.ts +18 -5
  80. package/packages/public-runtime/src/tenant-assets.ts +85 -17
  81. package/packages/public-runtime/src/tenant.ts +70 -0
  82. package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
  83. package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
  84. package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
  85. package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
  86. package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
  87. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
  88. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
  89. package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
  90. package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
  91. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
  92. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
  93. package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
  94. package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
  95. package/scripts/dev/ai-edit.mjs +16 -3
  96. package/scripts/dev/port-check.mjs +18 -3
  97. package/scripts/dev/publish.mjs +26 -3
  98. package/scripts/tenant/validate.mjs +14 -1
@@ -10,6 +10,9 @@ import type {
10
10
  BrowserEnvironment,
11
11
  CompiledIntegrationCsp,
12
12
  EffectivePlan,
13
+ ConsentSnapshot,
14
+ ResolvedRouteTierV1,
15
+ PublicationRoutePlanV1,
13
16
  } from "@tot/public-runtime";
14
17
  import type { ToTClient } from "@/lib/tot/ToTClient";
15
18
  import type { ContentProvider } from "@/lib/storyblok/provider";
@@ -104,6 +107,14 @@ declare global {
104
107
  interface Locals {
105
108
  /** Tenant resolved from the request Host by src/middleware. */
106
109
  tenant: TenantConfig;
110
+ /**
111
+ * THE route-tier answer for this request (ADR 0013), resolved once by the
112
+ * middleware from the TENANT-RELATIVE path and used to pick which branch
113
+ * served it. Downstream code reads this rather than re-deriving a tier.
114
+ */
115
+ routeTier: ResolvedRouteTierV1;
116
+ /** Normalized publication mount/canonical/content plan for this request. */
117
+ publicationRoute: PublicationRoutePlanV1;
107
118
  /**
108
119
  * Effective capability flags for the resolved tenant (commerce defaults
109
120
  * unless siteType:"marketing"/explicit features). Chrome, layout islands,
@@ -157,6 +168,16 @@ declare global {
157
168
  * wired; carries no secret and no nonce.
158
169
  */
159
170
  integrationPlan: EffectivePlan;
171
+ /**
172
+ * The tenant's actual initial consent snapshot — computed once by the
173
+ * middleware from its saved {@link TenantConsentConfig} (ext-admin-03) via
174
+ * the neutral consent broker (ext-policy-03), or the fail-closed
175
+ * essential-only default when the tenant has never configured one. BOTH
176
+ * document mounts (Layout.astro + rawHtmlResponse) inline THIS value as
177
+ * `consent.defaults`, so a shopper's pre-interaction eligibility matches
178
+ * what the tenant actually approved — never a hardcoded default.
179
+ */
180
+ consentSnapshot: ConsentSnapshot;
160
181
  /**
161
182
  * Resolved cart/checkout facts for this request (loader src, cart URL,
162
183
  * mode). Present only for commerce tenants with a checkout store;
@@ -0,0 +1,29 @@
1
+ -- A tenant-owned, NON-LIVE-ONLY destination a developer registers to prove
2
+ -- exactly-once delivery for a new marketing action before wiring a real vendor
3
+ -- adapter. `environment` is never `live` — enforced by the managed-analytics
4
+ -- service, not by this table.
5
+ CREATE TABLE `test_destinations` (
6
+ `destination_id` text PRIMARY KEY NOT NULL,
7
+ `tenant_id` text NOT NULL,
8
+ `environment` text NOT NULL,
9
+ `created_at` text NOT NULL
10
+ );
11
+ --> statement-breakpoint
12
+ CREATE INDEX `idx_test_destinations_tenant`
13
+ ON `test_destinations` (`tenant_id`);
14
+ --> statement-breakpoint
15
+ -- The delivered-event ledger a test destination reads back. Keyed by
16
+ -- (destination_id, event_id): a retried/duplicate delivery of the same event id
17
+ -- is `INSERT OR IGNORE`, so the row a caller reads back always reflects the
18
+ -- FIRST delivery only — the proof that delivery landed exactly once.
19
+ CREATE TABLE `test_destination_deliveries` (
20
+ `destination_id` text NOT NULL,
21
+ `event_id` text NOT NULL,
22
+ `tenant_id` text NOT NULL,
23
+ `action_name` text NOT NULL,
24
+ `delivered_at` text NOT NULL,
25
+ PRIMARY KEY(`destination_id`, `event_id`)
26
+ );
27
+ --> statement-breakpoint
28
+ CREATE INDEX `idx_test_destination_deliveries_tenant`
29
+ ON `test_destination_deliveries` (`tenant_id`);
@@ -9,7 +9,7 @@
9
9
  * view counts). Tenant/collection-generic: mounts on any post route.
10
10
  */
11
11
  import { getReactionCounts, REACTIONS, type Reaction } from "@/lib/blog/reactions.js";
12
- import { readKv } from "@/lib/env";
12
+ import { readKv } from "@/lib/runtimeEnv";
13
13
  import { withBase } from "@/lib/basePath";
14
14
 
15
15
  interface Props {
@@ -11,6 +11,16 @@
11
11
  * unconfigured verification) STOPS it and shows why. Fail-closed by construction:
12
12
  * if the preflight itself errors on a regulated tenant, we do NOT hand off.
13
13
  *
14
+ * On a tenant wired to the LIVE Token of Trust verification provider, preflight
15
+ * additionally echoes a `verification` block (ref / provider status / continuation
16
+ * URL). That block's presence — and only that — branches this dialog into the live
17
+ * trip: collect the shopper's email, start the verification, open the provider's
18
+ * continuation URL in a new tab, then re-read the SERVER's decision on an interval
19
+ * until it clears or denies. Sandbox / no-provider tenants never receive the block
20
+ * and see exactly the flow they always did. The client never decides a pass:
21
+ * `decision.status === "clear"` from the server is the only thing that releases the
22
+ * handoff, here as everywhere else. See `checkoutVerificationFlow.ts`.
23
+ *
14
24
  * This is progressive enhancement: the underlying form still posts the real
15
25
  * (server-pre-signed) cart fields. If this island never hydrates, the form submits
16
26
  * as before — so the gate hardens the flow without being a single point of failure
@@ -20,6 +30,17 @@
20
30
  */
21
31
  import { useCallback, useEffect, useRef, useState } from "preact/hooks";
22
32
  import type { CartComplianceDecision } from "@tot/public-runtime";
33
+ import {
34
+ clearStoredRef,
35
+ parsePreflightResponse,
36
+ pollVerification,
37
+ readStoredRef,
38
+ verificationPhase,
39
+ verificationRefKey,
40
+ writeStoredRef,
41
+ type PreflightResponse,
42
+ type VerificationPhase,
43
+ } from "./checkoutVerificationFlow.js";
23
44
 
24
45
  interface Props {
25
46
  /** Absolute (base-path-aware) URL of the preflight endpoint. */
@@ -70,6 +91,12 @@ interface PendingLine {
70
91
  title: string;
71
92
  }
72
93
 
94
+ /** Shopper-facing phase, extended with the two paused states polling can end in. */
95
+ type DialogPhase = VerificationPhase | "stalled";
96
+
97
+ const FAIL_CLOSED_MESSAGE =
98
+ "We couldn't reach our compliance check. For your safety, checkout is paused — please try again.";
99
+
73
100
  export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props) {
74
101
  const [pending, setPending] = useState<PendingLine | null>(null);
75
102
  const [region, setRegion] = useState("");
@@ -79,6 +106,22 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
79
106
  const [error, setError] = useState<string | null>(null);
80
107
  const dialogRef = useRef<HTMLDivElement>(null);
81
108
 
109
+ // --- Live-provider verification trip (inert on sandbox / no-provider tenants).
110
+ const [phase, setPhase] = useState<DialogPhase>("none");
111
+ const [email, setEmail] = useState("");
112
+ const [givenName, setGivenName] = useState("");
113
+ const [familyName, setFamilyName] = useState("");
114
+ const [verificationRef, setVerificationRef] = useState<string | null>(null);
115
+ const [continuationUrl, setContinuationUrl] = useState<string | null>(null);
116
+ const [popupBlocked, setPopupBlocked] = useState(false);
117
+ /** Cancellation token for the in-flight poll loop (at most one at a time). */
118
+ const pollToken = useRef<{ cancelled: boolean } | null>(null);
119
+
120
+ const stopPolling = useCallback(() => {
121
+ if (pollToken.current) pollToken.current.cancelled = true;
122
+ pollToken.current = null;
123
+ }, []);
124
+
82
125
  // Intercept managed-cart form submits (capture phase, before the native POST).
83
126
  useEffect(() => {
84
127
  function onSubmit(e: Event) {
@@ -91,16 +134,23 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
91
134
  e.stopPropagation();
92
135
  const qtyInput = form.querySelector<HTMLInputElement>('input[name$="quantity" i], input[name="quantity"]');
93
136
  const quantity = Math.max(1, parseInt(qtyInput?.value ?? "1", 10) || 1);
94
- setPending({
137
+ const line: PendingLine = {
95
138
  form,
96
139
  handle: form.dataset.productHandle ?? "",
97
140
  sku: form.dataset.variantSku || undefined,
98
141
  variantId: form.dataset.variantId || undefined,
99
142
  quantity,
100
143
  title: form.dataset.productTitle ?? "this item",
101
- });
144
+ };
145
+ setPending(line);
102
146
  setDecision(null);
103
147
  setError(null);
148
+ setPhase("none");
149
+ setContinuationUrl(null);
150
+ setPopupBlocked(false);
151
+ // A reload mid-trip must not lose the correlation: replay the stored ref so
152
+ // the server re-reads THIS attempt's verification instead of starting over.
153
+ setVerificationRef(readStoredRef(verificationRefKey(preflightUrl, line)));
104
154
  let pre = false;
105
155
  try {
106
156
  pre = localStorage.getItem(AGE_KEY) === "yes";
@@ -112,17 +162,21 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
112
162
  }
113
163
  document.addEventListener("submit", onSubmit, true);
114
164
  return () => document.removeEventListener("submit", onSubmit, true);
115
- }, []);
165
+ }, [preflightUrl]);
116
166
 
117
167
  const close = useCallback(() => {
168
+ stopPolling();
118
169
  setPending(null);
119
170
  setDecision(null);
120
171
  setError(null);
121
172
  setLoading(false);
173
+ setPhase("none");
122
174
  document.documentElement.style.overflow = "";
123
- }, []);
175
+ }, [stopPolling]);
124
176
 
125
177
  const proceed = useCallback((line: PendingLine) => {
178
+ stopPolling();
179
+ clearStoredRef(verificationRefKey(preflightUrl, line));
126
180
  line.form.dataset.complianceCleared = "1";
127
181
  document.documentElement.style.overflow = "";
128
182
  setPending(null);
@@ -130,54 +184,194 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
130
184
  // managed-cart loader takes over the (still server-pre-signed) POST.
131
185
  if (typeof line.form.requestSubmit === "function") line.form.requestSubmit();
132
186
  else line.form.submit();
133
- }, []);
187
+ }, [preflightUrl, stopPolling]);
188
+
189
+ /**
190
+ * One preflight POST. `null` for a transport error or any body we cannot fully
191
+ * read — the caller treats both as fail-closed.
192
+ */
193
+ const send = useCallback(
194
+ async (
195
+ line: PendingLine,
196
+ opts: { ref: string | null; includeShopper: boolean },
197
+ ): Promise<PreflightResponse | null> => {
198
+ const trimmedEmail = email.trim();
199
+ try {
200
+ const res = await fetch(preflightUrl, {
201
+ method: "POST",
202
+ headers: { "content-type": "application/json" },
203
+ body: JSON.stringify({
204
+ lines: [
205
+ {
206
+ handle: line.handle,
207
+ sku: line.sku,
208
+ variantId: line.variantId,
209
+ quantity: line.quantity,
210
+ },
211
+ ],
212
+ destination: region ? { region, country: "US" } : null,
213
+ ageAffirmed: affirmed,
214
+ ...(opts.ref ? { verificationRef: opts.ref } : {}),
215
+ ...(opts.includeShopper && trimmedEmail
216
+ ? {
217
+ shopper: {
218
+ email: trimmedEmail,
219
+ ...(givenName.trim() ? { givenName: givenName.trim() } : {}),
220
+ ...(familyName.trim() ? { familyName: familyName.trim() } : {}),
221
+ },
222
+ }
223
+ : {}),
224
+ }),
225
+ });
226
+ return parsePreflightResponse(await res.json());
227
+ } catch {
228
+ return null;
229
+ }
230
+ },
231
+ [preflightUrl, region, affirmed, email, givenName, familyName],
232
+ );
233
+
234
+ /**
235
+ * Wait for the shopper to finish on their own device by RE-READING the server's
236
+ * decision. The client is never told "done" by the provider widget — it asks.
237
+ */
238
+ const startPolling = useCallback(
239
+ (line: PendingLine, ref: string) => {
240
+ stopPolling();
241
+ const token = { cancelled: false };
242
+ pollToken.current = token;
243
+ void pollVerification({
244
+ poll: () => send(line, { ref, includeShopper: false }),
245
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
246
+ now: () => Date.now(),
247
+ cancelled: () => token.cancelled,
248
+ }).then((outcome) => {
249
+ if (token.cancelled) return;
250
+ pollToken.current = null;
251
+ switch (outcome.kind) {
252
+ case "cleared":
253
+ setDecision(outcome.response.decision);
254
+ proceed(line);
255
+ return;
256
+ case "denied":
257
+ setDecision(outcome.response.decision);
258
+ setPhase("denied");
259
+ clearStoredRef(verificationRefKey(preflightUrl, line));
260
+ setVerificationRef(null);
261
+ return;
262
+ case "halted":
263
+ // Verification settled but the cart still isn't clear (another block,
264
+ // or a decision polling can't move). Stop, show it, stay re-checkable.
265
+ setDecision(outcome.response.decision);
266
+ setPhase(
267
+ verificationPhase(outcome.response) === "cleared" ? "cleared" : "stalled",
268
+ );
269
+ return;
270
+ case "error":
271
+ // Fail closed: an unreadable poll never proceeds; it pauses.
272
+ setError(FAIL_CLOSED_MESSAGE);
273
+ setPhase("stalled");
274
+ return;
275
+ case "timeout":
276
+ setPhase("stalled");
277
+ return;
278
+ default:
279
+ return;
280
+ }
281
+ });
282
+ },
283
+ [send, proceed, preflightUrl, stopPolling],
284
+ );
285
+
286
+ const apply = useCallback(
287
+ (line: PendingLine, res: PreflightResponse) => {
288
+ const key = verificationRefKey(preflightUrl, line);
289
+ const meta = res.verification;
290
+ const ref = meta?.ref ?? null;
291
+ if (ref) {
292
+ setVerificationRef(ref);
293
+ writeStoredRef(key, ref);
294
+ }
295
+ if (meta?.continuationUrl) setContinuationUrl(meta.continuationUrl);
296
+
297
+ setDecision(res.decision);
298
+ // Only the server's own verdict releases the handoff.
299
+ if (res.decision.status === "clear") {
300
+ proceed(line);
301
+ return;
302
+ }
303
+
304
+ const next = verificationPhase(res);
305
+ setPhase(next);
306
+ if (next === "denied") {
307
+ clearStoredRef(key);
308
+ setVerificationRef(null);
309
+ return;
310
+ }
311
+ if (next === "continue" && meta?.continuationUrl && ref) {
312
+ // The provider's own embedded workflow, opened in a new tab: no
313
+ // third-party framing/cookie surprises, and nothing here assumes it can
314
+ // observe completion — the poll below asks the server instead.
315
+ let opened: Window | null = null;
316
+ try {
317
+ opened = window.open(meta.continuationUrl, "_blank", "noopener,noreferrer");
318
+ } catch {
319
+ opened = null;
320
+ }
321
+ setPopupBlocked(!opened);
322
+ startPolling(line, ref);
323
+ return;
324
+ }
325
+ if (next === "waiting" && ref) startPolling(line, ref);
326
+ },
327
+ [preflightUrl, proceed, startPolling],
328
+ );
134
329
 
135
330
  const check = useCallback(async () => {
136
331
  if (!pending) return;
332
+ stopPolling();
137
333
  setLoading(true);
138
334
  setError(null);
139
335
  setDecision(null);
140
- try {
141
- const res = await fetch(preflightUrl, {
142
- method: "POST",
143
- headers: { "content-type": "application/json" },
144
- body: JSON.stringify({
145
- lines: [
146
- {
147
- handle: pending.handle,
148
- sku: pending.sku,
149
- variantId: pending.variantId,
150
- quantity: pending.quantity,
151
- },
152
- ],
153
- destination: region ? { region, country: "US" } : null,
154
- ageAffirmed: affirmed,
155
- }),
156
- });
157
- const data = (await res.json()) as { decision?: CartComplianceDecision };
158
- const d = data.decision;
159
- if (!d) {
160
- // Fail closed: a regulated tenant never proceeds on an unreadable result.
161
- setError(
162
- "We couldn't confirm this order can ship. Please try again in a moment.",
163
- );
164
- return;
165
- }
166
- setDecision(d);
167
- if (d.status === "clear") proceed(pending);
168
- } catch {
169
- setError(
170
- "We couldn't reach our compliance check. For your safety, checkout is paused — please try again.",
171
- );
172
- } finally {
173
- setLoading(false);
336
+ setPopupBlocked(false);
337
+ const res = await send(pending, {
338
+ ref: verificationRef,
339
+ includeShopper: phase === "collect" || phase === "stalled",
340
+ });
341
+ setLoading(false);
342
+ if (!res) {
343
+ // Fail closed: a regulated tenant never proceeds on an unreadable result.
344
+ setError(FAIL_CLOSED_MESSAGE);
345
+ return;
174
346
  }
175
- }, [pending, preflightUrl, region, affirmed, proceed]);
347
+ apply(pending, res);
348
+ }, [pending, send, apply, verificationRef, phase, stopPolling]);
349
+
350
+ /** Abandon a denied attempt and start a brand-new verification (new ref). */
351
+ const restartVerification = useCallback(() => {
352
+ stopPolling();
353
+ setVerificationRef(null);
354
+ setContinuationUrl(null);
355
+ setDecision(null);
356
+ setError(null);
357
+ setPhase("collect");
358
+ if (pending) clearStoredRef(verificationRefKey(preflightUrl, pending));
359
+ }, [pending, preflightUrl, stopPolling]);
176
360
 
177
361
  if (!pending) return null;
178
362
 
179
363
  const blocks = decision?.blocks ?? [];
180
364
  const adultSignature = decision?.requirements.adultSignature;
365
+ const waiting = phase === "continue" || phase === "waiting";
366
+ const primaryLabel = loading
367
+ ? "Checking…"
368
+ : waiting
369
+ ? "Waiting for verification…"
370
+ : phase === "collect"
371
+ ? "Start verification"
372
+ : phase === "stalled"
373
+ ? "Check verification status"
374
+ : "Continue to checkout";
181
375
 
182
376
  return (
183
377
  <div
@@ -236,10 +430,64 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
236
430
  />
237
431
  <span>I confirm I am {minAge} or older.</span>
238
432
  </label>
433
+
434
+ {/*
435
+ LIVE provider only. Rendered strictly when the server has told us a
436
+ verification is required AND unresolved for this attempt — sandbox /
437
+ no-provider tenants stay on the flow above, untouched.
438
+ */}
439
+ {phase === "collect" && (
440
+ <div class="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
441
+ <p class="text-sm text-[var(--color-text)]">
442
+ This order needs a one-time age verification. Enter your email and
443
+ we'll open a secure step you can complete on this device.
444
+ </p>
445
+ <label class="flex flex-col gap-1.5">
446
+ <span class="font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-muted)]">
447
+ Email
448
+ </span>
449
+ <input
450
+ type="email"
451
+ required
452
+ autocomplete="email"
453
+ name="tot-verification-email"
454
+ class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 text-[var(--color-text)]"
455
+ value={email}
456
+ onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
457
+ />
458
+ </label>
459
+ <div class="flex flex-wrap gap-3">
460
+ <label class="flex min-w-[9rem] flex-1 flex-col gap-1.5">
461
+ <span class="font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-muted)]">
462
+ First name (optional)
463
+ </span>
464
+ <input
465
+ type="text"
466
+ autocomplete="given-name"
467
+ class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 text-[var(--color-text)]"
468
+ value={givenName}
469
+ onInput={(e) => setGivenName((e.target as HTMLInputElement).value)}
470
+ />
471
+ </label>
472
+ <label class="flex min-w-[9rem] flex-1 flex-col gap-1.5">
473
+ <span class="font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-muted)]">
474
+ Last name (optional)
475
+ </span>
476
+ <input
477
+ type="text"
478
+ autocomplete="family-name"
479
+ class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 text-[var(--color-text)]"
480
+ value={familyName}
481
+ onInput={(e) => setFamilyName((e.target as HTMLInputElement).value)}
482
+ />
483
+ </label>
484
+ </div>
485
+ </div>
486
+ )}
239
487
  </div>
240
488
 
241
489
  {/* Decision surface */}
242
- {blocks.length > 0 && (
490
+ {blocks.length > 0 && phase !== "denied" && (
243
491
  <div
244
492
  class="mt-5 rounded-[var(--radius-md)] border border-[var(--color-sale)] bg-[color-mix(in_srgb,var(--color-sale)_8%,transparent)] px-4 py-3"
245
493
  role="alert"
@@ -257,6 +505,64 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
257
505
  </div>
258
506
  )}
259
507
 
508
+ {/* Live verification trip */}
509
+ {(phase === "continue" || phase === "waiting") && (
510
+ <div class="mt-5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-3">
511
+ <p class="font-display text-sm font-semibold text-[var(--color-text)]">
512
+ Finish your age verification
513
+ </p>
514
+ {phase === "continue" && continuationUrl && (
515
+ <p class="mt-1.5 text-sm text-[var(--color-text)]">
516
+ {popupBlocked
517
+ ? "Your browser blocked the verification window — open it here:"
518
+ : "We opened a secure verification step in a new tab. If you don't see it:"}{" "}
519
+ <a
520
+ href={continuationUrl}
521
+ target="_blank"
522
+ rel="noopener noreferrer"
523
+ class="underline"
524
+ onClick={() => setPopupBlocked(false)}
525
+ >
526
+ open verification
527
+ </a>
528
+ .
529
+ </p>
530
+ )}
531
+ <p class="mt-1.5 text-sm text-[var(--color-muted)]">
532
+ Keep this page open — checkout continues automatically once your
533
+ verification is confirmed.
534
+ </p>
535
+ </div>
536
+ )}
537
+
538
+ {phase === "denied" && (
539
+ <div
540
+ class="mt-5 rounded-[var(--radius-md)] border border-[var(--color-sale)] bg-[color-mix(in_srgb,var(--color-sale)_8%,transparent)] px-4 py-3"
541
+ role="alert"
542
+ >
543
+ <p class="font-display text-sm font-semibold text-[var(--color-sale)]">
544
+ Verification didn't pass
545
+ </p>
546
+ <p class="mt-1.5 text-sm text-[var(--color-text)]">
547
+ We couldn't confirm you meet the {minAge}+ requirement for this
548
+ order, so it can't proceed. If you believe this is a mistake you can
549
+ start a new verification.
550
+ </p>
551
+ </div>
552
+ )}
553
+
554
+ {phase === "stalled" && !error && blocks.length === 0 && (
555
+ <div
556
+ class="mt-5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-3"
557
+ role="status"
558
+ >
559
+ <p class="text-sm text-[var(--color-text)]">
560
+ We stopped waiting for your verification. Checkout is paused — once
561
+ you've finished the verification step, check again.
562
+ </p>
563
+ </div>
564
+ )}
565
+
260
566
  {error && (
261
567
  <p
262
568
  class="mt-5 rounded-[var(--radius-md)] border border-[var(--color-sale)] bg-[color-mix(in_srgb,var(--color-sale)_8%,transparent)] px-4 py-3 text-sm text-[var(--color-text)]"
@@ -273,14 +579,24 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
273
579
  )}
274
580
 
275
581
  <div class="mt-6 flex flex-wrap gap-3">
276
- <button
277
- type="button"
278
- disabled={loading}
279
- onClick={check}
280
- class="rounded-full bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-medium uppercase tracking-[0.08em] text-[var(--color-primary-contrast)] transition-opacity hover:opacity-90 disabled:opacity-60"
281
- >
282
- {loading ? "Checking…" : "Continue to checkout"}
283
- </button>
582
+ {phase === "denied" ? (
583
+ <button
584
+ type="button"
585
+ onClick={restartVerification}
586
+ class="rounded-full bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-medium uppercase tracking-[0.08em] text-[var(--color-primary-contrast)] transition-opacity hover:opacity-90 disabled:opacity-60"
587
+ >
588
+ Start a new verification
589
+ </button>
590
+ ) : (
591
+ <button
592
+ type="button"
593
+ disabled={loading || waiting || (phase === "collect" && !email.trim())}
594
+ onClick={check}
595
+ class="rounded-full bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-medium uppercase tracking-[0.08em] text-[var(--color-primary-contrast)] transition-opacity hover:opacity-90 disabled:opacity-60"
596
+ >
597
+ {primaryLabel}
598
+ </button>
599
+ )}
284
600
  <button
285
601
  type="button"
286
602
  onClick={close}