@tokenoftrust/storefront-runner 2.4.49 → 2.4.51

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.
@@ -1,15 +1,11 @@
1
1
  /**
2
- * <CheckoutComplianceGate> — the storefront's pre-handoff compliance ENFORCEMENT
3
- * seam for regulated (age/nicotine) tenants.
2
+ * <CheckoutComplianceGate> — browser preflight for a regulated add-to-cart submit.
4
3
  *
5
- * Checkout completion happens in the EXTERNAL managed cart, so the last moment the
6
- * storefront controls is the add-to-cart submit. This island intercepts that
7
- * submit for any `form[data-managed-cart]`, collects the ship-to state + 21+
8
- * affirmation, and asks the server-authoritative gate (`/api/compliance/preflight`,
9
- * F12) whether the cart may proceed. Only a `clear` decision releases the handoff;
10
- * a hard block (restricted destination, flavored ban, per-order limit, failed /
11
- * unconfigured verification) STOPS it and shows why. Fail-closed by construction:
12
- * if the preflight itself errors on a regulated tenant, we do NOT hand off.
4
+ * The dialog collects the ship-to state and the age affirmation, then asks
5
+ * `/api/compliance/preflight` whether this browser may submit the form. Only a
6
+ * server `clear` releases that submit. A hard block (restricted destination,
7
+ * flavored ban, per-order limit, failed or unconfigured verification) stops it.
8
+ * If the preflight cannot be read, the dialog does not submit.
13
9
  *
14
10
  * On a tenant wired to the LIVE Token of Trust verification provider, preflight
15
11
  * additionally echoes a `verification` block (ref / provider status / continuation
@@ -18,15 +14,13 @@
18
14
  * continuation URL in a new tab, then re-read the SERVER's decision on an interval
19
15
  * until it clears or denies. Sandbox / no-provider tenants never receive the block
20
16
  * 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`.
17
+ * `decision.status === "clear"` from the server is the only thing that releases
18
+ * the submit. See `checkoutVerificationFlow.ts`.
23
19
  *
24
- * This is progressive enhancement: the underlying form still posts the real
25
- * (server-pre-signed) cart fields. If this island never hydrates, the form submits
26
- * as before — so the gate hardens the flow without being a single point of failure
27
- * for rendering the buy button. The REAL production enforcement is defense-in-depth
28
- * in the managed-cart pre-cart webhook (Track B); this is the storefront-side check
29
- * and the demo/preview's visible compliance checkpoint.
20
+ * This preflight is browser UX. It is not payment authorization. A `clear`
21
+ * decision only lets the native form submit; the server checks the cart again
22
+ * and never treats this dialog as authority to take payment. If the island
23
+ * never hydrates, or scripts are disabled, the form posts on its own.
30
24
  */
31
25
  import { useCallback, useEffect, useRef, useState } from "preact/hooks";
32
26
  import type { CartComplianceDecision } from "@tot/public-runtime";
@@ -177,11 +171,12 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
177
171
  const proceed = useCallback((line: PendingLine) => {
178
172
  stopPolling();
179
173
  clearStoredRef(verificationRefKey(preflightUrl, line));
174
+ // UX latch so this dialog does not intercept the resubmit. Not payment
175
+ // authority — the form's server action checks the cart.
180
176
  line.form.dataset.complianceCleared = "1";
177
+ line.form.dataset.paymentAuthority = "none";
181
178
  document.documentElement.style.overflow = "";
182
179
  setPending(null);
183
- // requestSubmit re-runs submit; our listener now passes it through, and the
184
- // managed-cart loader takes over the (still server-pre-signed) POST.
185
180
  if (typeof line.form.requestSubmit === "function") line.form.requestSubmit();
186
181
  else line.form.submit();
187
182
  }, [preflightUrl, stopPolling]);
@@ -377,6 +372,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
377
372
  <div
378
373
  class="fixed inset-0 z-[110] bg-[color-mix(in_srgb,var(--color-text)_75%,transparent)] backdrop-blur-sm"
379
374
  role="dialog"
375
+ data-payment-authority="none"
380
376
  aria-modal="true"
381
377
  aria-labelledby="cgate-title"
382
378
  onClick={(e) => {
@@ -24,7 +24,11 @@
24
24
  * wins; this only fills in when `commerce.checkout` is absent.
25
25
  */
26
26
  import type { TenantConfig, ResolvedManagedCheckout } from "@tot/public-runtime";
27
- import { resolveManagedCheckout, SHARED_DEMO_CHECKOUT } from "@tot/public-runtime";
27
+ import {
28
+ CART_SIGNING_OPERATOR_ERROR,
29
+ resolveManagedCheckout,
30
+ SHARED_DEMO_CHECKOUT,
31
+ } from "@tot/public-runtime";
28
32
  import { readEnv } from "@/lib/runtimeEnv";
29
33
 
30
34
  /**
@@ -86,6 +90,8 @@ export async function readCartSecret(
86
90
  export interface CartSigningResolution {
87
91
  enabled: boolean;
88
92
  secret?: string;
93
+ /** Set when signed mode has no secret. Actionable for the operator. No secret value. */
94
+ operatorError?: string;
89
95
  }
90
96
 
91
97
  /**
@@ -108,5 +114,5 @@ export async function resolveCartSigning(
108
114
  "[storefront] signed checkout is configured but FOXY_CART_SECRET is missing — " +
109
115
  "buy forms disabled (fail-closed). Set the cart-validation secret for this environment.",
110
116
  );
111
- return { enabled: false };
117
+ return { enabled: false, operatorError: CART_SIGNING_OPERATOR_ERROR };
112
118
  }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Final-cart authorization decision. Payment is admitted only when the caller
3
+ * token, the bound cart, fresh evidence, the verification seam, and mirror stock
4
+ * all agree. Catalog availability and a browser confirmation are not authority.
5
+ */
6
+ import {
7
+ admitCallerToken,
8
+ assertMinor,
9
+ decideCommerceCommand,
10
+ finalCartDigest,
11
+ finalCartStillBound,
12
+ sumMinor,
13
+ type CallerToken,
14
+ type CommerceEvidence,
15
+ type CommerceStage,
16
+ type EvidenceState,
17
+ type FinalCartBinding,
18
+ type FulfillmentState,
19
+ type LabelState,
20
+ type PaymentState,
21
+ type StockState,
22
+ } from "@tot/public-runtime";
23
+ import { readMirrorAuthority, VERIFICATION_PROVIDER_OWNER, type ReserveHold } from "@/lib/inventory/allocations.js";
24
+
25
+ export interface SeamView {
26
+ status: "clear" | "absent" | "blocked";
27
+ failedClosed?: boolean;
28
+ codes?: readonly string[];
29
+ }
30
+
31
+ export interface FinalCartRequest {
32
+ now: string;
33
+ routedTenantId: string;
34
+ quoted: FinalCartBinding;
35
+ presented: FinalCartBinding;
36
+ evidence: CommerceEvidence | null;
37
+ seam: SeamView | null;
38
+ catalogAvailable: boolean;
39
+ browserConfirmed: boolean;
40
+ token: CallerToken | null;
41
+ trustedIssuers: readonly string[];
42
+ expectedAudience: string;
43
+ activeKids: readonly string[];
44
+ authorities: readonly unknown[];
45
+ payment?: PaymentState;
46
+ stock?: StockState;
47
+ fulfillment?: FulfillmentState;
48
+ label?: LabelState;
49
+ }
50
+
51
+ export type FinalCartDecision =
52
+ | {
53
+ ok: true;
54
+ digest: string;
55
+ expiresAt: string;
56
+ amountMinor: number;
57
+ holds: ReserveHold[];
58
+ issuer: string;
59
+ jti: string;
60
+ tenantId: string;
61
+ stage: CommerceStage;
62
+ }
63
+ | { ok: false; finding: string; owner?: string };
64
+
65
+ const EVIDENCE_FIELDS = ["verification", "tax", "risk", "stock"] as const;
66
+
67
+ function sortedLines(cart: FinalCartBinding) {
68
+ return [...cart.lines].sort(
69
+ (left, right) => left.sku.localeCompare(right.sku) || left.unitMinor - right.unitMinor || left.quantity - right.quantity,
70
+ );
71
+ }
72
+
73
+ function cartFinding(quoted: FinalCartBinding, presented: FinalCartBinding, routedTenantId: string): string | null {
74
+ if (quoted.tenantId !== routedTenantId || presented.tenantId !== routedTenantId || quoted.tenantId !== presented.tenantId) {
75
+ return "tenant-changed";
76
+ }
77
+ if (quoted.stage !== presented.stage) return "stage-changed";
78
+ if (quoted.currency !== presented.currency) return "currency-changed";
79
+ if (quoted.destinationDigest !== presented.destinationDigest) return "destination-changed";
80
+ const left = sortedLines(quoted);
81
+ const right = sortedLines(presented);
82
+ if (left.length !== right.length || left.some((line, index) => line.sku !== right[index]?.sku)) return "product-changed";
83
+ if (left.some((line, index) => line.unitMinor !== right[index]?.unitMinor)) return "price-changed";
84
+ if (left.some((line, index) => line.quantity !== right[index]?.quantity)) return "quantity-changed";
85
+ if (quoted.verificationExpiresAt !== presented.verificationExpiresAt || quoted.taxExpiresAt !== presented.taxExpiresAt) {
86
+ return "evidence-window-changed";
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function evidenceFinding(evidence: CommerceEvidence | null): string | null {
92
+ if (!evidence) return "missing-evidence";
93
+ for (const field of EVIDENCE_FIELDS) {
94
+ const state: EvidenceState | string = evidence[field];
95
+ if (state === "fresh") continue;
96
+ if (state === "UNKNOWN") return `evidence-${field}-unknown`;
97
+ if (state === "stale" || state === "unreadable" || state === "absent") return `evidence-${field}-${state}`;
98
+ return `evidence-${field}-unreadable`;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function seamFinding(seam: SeamView | null): { finding: string; owner?: string } | null {
104
+ if (!seam || seam.status === "absent") return { finding: "verification-seam-absent" };
105
+ if (seam.status === "clear") return null;
106
+ const codes = (seam.codes ?? []).filter((code) => /^[a-z0-9_-]+$/.test(code));
107
+ if (seam.failedClosed && codes.some((code) => code === "verification_unconfigured" || code === "ruleset_unavailable")) {
108
+ return { finding: "verification-provider-unconfigured", owner: VERIFICATION_PROVIDER_OWNER };
109
+ }
110
+ return { finding: codes[0] ? `verification-refused:${codes[0]}` : "verification-refused" };
111
+ }
112
+
113
+ function isCatalogShape(value: unknown): boolean {
114
+ return value == null || (typeof value === "object" && value !== null && "available" in value && !("provider" in value));
115
+ }
116
+
117
+ function commandFinding(reason: string): string {
118
+ switch (reason) {
119
+ case "stock":
120
+ return "stock-unknown";
121
+ case "fulfillment":
122
+ return "fulfillment-refused";
123
+ case "reconcile":
124
+ return "payment-reconcile";
125
+ case "payment":
126
+ return "payment-refused";
127
+ default:
128
+ return "evidence-refused";
129
+ }
130
+ }
131
+
132
+ function earlierExpiry(left: string, right: string): string {
133
+ return left <= right ? left : right;
134
+ }
135
+
136
+ /** Deny with one specific finding, or clear a mirror hold for each sku. */
137
+ export async function evaluateFinalCart(input: FinalCartRequest): Promise<FinalCartDecision> {
138
+ if (input.browserConfirmed && (!input.token || input.token.scheme !== "asymmetric-jwks" || !input.token.signatureValid)) {
139
+ return { ok: false, finding: "browser-not-authority" };
140
+ }
141
+ if (!input.token) return { ok: false, finding: "signature" };
142
+ const admitted = admitCallerToken({
143
+ token: input.token,
144
+ trustedIssuers: input.trustedIssuers,
145
+ expectedAudience: input.expectedAudience,
146
+ activeKids: input.activeKids,
147
+ now: input.now,
148
+ tenantId: input.routedTenantId,
149
+ stage: input.presented.stage,
150
+ });
151
+ if (!admitted.ok) return { ok: false, finding: admitted.reason };
152
+
153
+ const changed = cartFinding(input.quoted, input.presented, input.routedTenantId);
154
+ if (changed) return { ok: false, finding: changed };
155
+
156
+ if (input.presented.lines.length < 1) return { ok: false, finding: "malformed-request" };
157
+
158
+ const evidence = evidenceFinding(input.evidence);
159
+ if (evidence) return { ok: false, finding: evidence };
160
+
161
+ const expiresAt = earlierExpiry(input.presented.verificationExpiresAt, input.presented.taxExpiresAt);
162
+ if (
163
+ input.presented.verificationExpiresAt <= input.now ||
164
+ input.presented.taxExpiresAt <= input.now ||
165
+ expiresAt <= input.now
166
+ ) {
167
+ return { ok: false, finding: "evidence-expired" };
168
+ }
169
+
170
+ let digest: string;
171
+ let amountMinor: number;
172
+ try {
173
+ for (const line of input.presented.lines) {
174
+ assertMinor(line.quantity, "quantity");
175
+ assertMinor(line.unitMinor, "unitMinor");
176
+ }
177
+ amountMinor = sumMinor(input.presented.lines.map((line) => line.unitMinor * line.quantity));
178
+ digest = await finalCartDigest(input.presented);
179
+ const quotedDigest = await finalCartDigest(input.quoted);
180
+ if (!finalCartStillBound(quotedDigest, digest, input.now, expiresAt)) return { ok: false, finding: "cart-unbound" };
181
+ } catch {
182
+ return { ok: false, finding: "malformed-request" };
183
+ }
184
+
185
+ const seam = seamFinding(input.seam);
186
+ if (seam) return { ok: false, finding: seam.finding, ...(seam.owner ? { owner: seam.owner } : {}) };
187
+
188
+ const command = decideCommerceCommand({
189
+ command: "authorize-final-cart",
190
+ payment: input.payment ?? "pending",
191
+ stock: input.stock ?? "unallocated",
192
+ fulfillment: input.fulfillment ?? "held",
193
+ label: input.label ?? "none",
194
+ evidence: input.evidence!,
195
+ providerOutcome: null,
196
+ });
197
+ if (!command.admit) return { ok: false, finding: commandFinding(command.reason) };
198
+
199
+ const quantities = new Map<string, number>();
200
+ for (const line of input.presented.lines) {
201
+ quantities.set(line.sku, (quantities.get(line.sku) ?? 0) + line.quantity);
202
+ }
203
+ const holds: ReserveHold[] = [];
204
+ for (const sku of [...quantities.keys()].sort()) {
205
+ const quantity = quantities.get(sku)!;
206
+ const named =
207
+ input.authorities.find((value) => {
208
+ if (!value || typeof value !== "object") return false;
209
+ return (value as { sku?: unknown }).sku === sku;
210
+ }) ?? (quantities.size === 1 ? (input.authorities[0] ?? null) : null);
211
+ if (named && typeof named === "object" && "provider" in named && (named as { provider: unknown }).provider !== "mirror") {
212
+ return { ok: false, finding: "authority-unavailable" };
213
+ }
214
+ const mirror = readMirrorAuthority(named);
215
+ if (!mirror || mirror.sku !== sku) {
216
+ return { ok: false, finding: input.catalogAvailable && isCatalogShape(named) ? "catalog-not-authority" : "authority-unavailable" };
217
+ }
218
+ if (mirror.onHand < quantity) return { ok: false, finding: "stock-unavailable" };
219
+ holds.push({ sku, locationId: mirror.locationId, quantity, onHand: mirror.onHand });
220
+ }
221
+
222
+ if (input.token.scheme !== "asymmetric-jwks") return { ok: false, finding: "shared-secret" };
223
+ return {
224
+ ok: true,
225
+ digest,
226
+ expiresAt,
227
+ amountMinor,
228
+ holds,
229
+ issuer: input.token.issuer,
230
+ jti: input.token.jti,
231
+ tenantId: input.routedTenantId,
232
+ stage: input.presented.stage,
233
+ };
234
+ }