@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.
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +16 -20
- package/apps/storefront/src/lib/checkoutCommerce.ts +8 -2
- package/apps/storefront/src/lib/commerce/finalCartAuthorization.ts +234 -0
- package/apps/storefront/src/lib/inventory/allocations.ts +749 -0
- package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +79 -1
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +366 -0
- package/apps/storefront/src/pages/products/[handle].astro +48 -21
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout.ts +272 -6
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* <CheckoutComplianceGate> —
|
|
3
|
-
* seam for regulated (age/nicotine) tenants.
|
|
2
|
+
* <CheckoutComplianceGate> — browser preflight for a regulated add-to-cart submit.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
22
|
-
*
|
|
17
|
+
* `decision.status === "clear"` from the server is the only thing that releases
|
|
18
|
+
* the submit. See `checkoutVerificationFlow.ts`.
|
|
23
19
|
*
|
|
24
|
-
* This is
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 {
|
|
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
|
+
}
|