@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
|
@@ -18,8 +18,15 @@
|
|
|
18
18
|
* unattributed response; the handoff proceeds, checkout is never blocked.
|
|
19
19
|
*/
|
|
20
20
|
import type { APIContext } from "astro";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
enforceCartSubmission,
|
|
23
|
+
foxyCartRedirectUrl,
|
|
24
|
+
parseConsentSnapshot,
|
|
25
|
+
defaultConsentSnapshot,
|
|
26
|
+
type CartDenialCode,
|
|
27
|
+
} from "@tot/public-runtime";
|
|
22
28
|
import { resolveCheckoutHandoffCorrelation } from "@/lib/commerce/checkoutHandoffCorrelation";
|
|
29
|
+
import { resolveCartSigning } from "@/lib/checkoutCommerce";
|
|
23
30
|
|
|
24
31
|
export const prerender = false;
|
|
25
32
|
|
|
@@ -38,7 +45,78 @@ interface HandoffBody {
|
|
|
38
45
|
consent?: unknown;
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
function escapeHtml(value: string): string {
|
|
49
|
+
return value.replace(/[&<>"']/g, (ch) => {
|
|
50
|
+
if (ch === "&") return "&";
|
|
51
|
+
if (ch === "<") return "<";
|
|
52
|
+
if (ch === ">") return ">";
|
|
53
|
+
if (ch === '"') return """;
|
|
54
|
+
return "'";
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cartDenial(code: CartDenialCode, message: string, status: number): Response {
|
|
59
|
+
const body =
|
|
60
|
+
`<!doctype html><html><body><p data-cart-denial="${code}" data-payment-authorized="false">${escapeHtml(message)}</p></body></html>`;
|
|
61
|
+
return new Response(body, {
|
|
62
|
+
status,
|
|
63
|
+
headers: {
|
|
64
|
+
"content-type": "text/html; charset=utf-8",
|
|
65
|
+
"cache-control": "no-store",
|
|
66
|
+
"x-tot-payment-authorized": "false",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isCartForm(request: Request): boolean {
|
|
72
|
+
const type = request.headers.get("content-type") ?? "";
|
|
73
|
+
return (
|
|
74
|
+
type.includes("application/x-www-form-urlencoded") ||
|
|
75
|
+
type.includes("multipart/form-data")
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
|
|
80
|
+
async function cartFormPost(context: APIContext): Promise<Response> {
|
|
81
|
+
const { tenant, commerce } = context.locals;
|
|
82
|
+
if (!commerce) {
|
|
83
|
+
return cartDenial(
|
|
84
|
+
"signing-unavailable",
|
|
85
|
+
"Checkout is unavailable because this store has no cart configured.",
|
|
86
|
+
409,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const signing = await resolveCartSigning(commerce);
|
|
90
|
+
if (!signing.enabled) {
|
|
91
|
+
return cartDenial("signing-unavailable", signing.operatorError ?? "Signed checkout is unavailable.", 503);
|
|
92
|
+
}
|
|
93
|
+
const form = await context.request.formData();
|
|
94
|
+
const entries: Array<[string, string]> = [];
|
|
95
|
+
for (const [name, value] of form.entries()) {
|
|
96
|
+
if (typeof value === "string") entries.push([name, value]);
|
|
97
|
+
}
|
|
98
|
+
const result = await enforceCartSubmission({
|
|
99
|
+
entries,
|
|
100
|
+
mode: commerce.mode,
|
|
101
|
+
secret: signing.secret,
|
|
102
|
+
trustedTenantId: tenant.tenant_id,
|
|
103
|
+
});
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
const status = result.code === "signing-unavailable" ? 503 : 422;
|
|
106
|
+
return cartDenial(result.code, result.message, status);
|
|
107
|
+
}
|
|
108
|
+
return new Response(null, {
|
|
109
|
+
status: 303,
|
|
110
|
+
headers: {
|
|
111
|
+
location: foxyCartRedirectUrl(commerce.cartUrl, result.fields),
|
|
112
|
+
"cache-control": "no-store",
|
|
113
|
+
"x-tot-payment-authorized": "false",
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
41
118
|
export async function POST(context: APIContext): Promise<Response> {
|
|
119
|
+
if (isCartForm(context.request)) return cartFormPost(context);
|
|
42
120
|
const { tenant, browserEnvironment, commerce } = context.locals;
|
|
43
121
|
|
|
44
122
|
// No managed checkout on this tenant → nothing to attribute.
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/commerce/final-cart-authorization — time-limited final-cart admission.
|
|
3
|
+
* Caller-owned asymmetric tokens only. Catalog availability, a browser confirmation,
|
|
4
|
+
* and missing verification evidence do not authorize payment.
|
|
5
|
+
*/
|
|
6
|
+
import type { APIContext } from "astro";
|
|
7
|
+
import { createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify, type JSONWebKeySet, type JWTPayload } from "jose";
|
|
8
|
+
import type { TenantConfig } from "@tot/public-runtime";
|
|
9
|
+
import {
|
|
10
|
+
type CallerToken,
|
|
11
|
+
type CommerceEvidence,
|
|
12
|
+
type CommerceStage,
|
|
13
|
+
type EvidenceState,
|
|
14
|
+
type FinalCartBinding,
|
|
15
|
+
type FulfillmentState,
|
|
16
|
+
type LabelState,
|
|
17
|
+
type PaymentState,
|
|
18
|
+
type StockState,
|
|
19
|
+
} from "@tot/public-runtime";
|
|
20
|
+
import { fromD1, listAllActiveProducts } from "@/lib/d1/catalog.js";
|
|
21
|
+
import { readD1, readEnv } from "@/lib/env";
|
|
22
|
+
import { evaluateFinalCart, type SeamView } from "@/lib/commerce/finalCartAuthorization.js";
|
|
23
|
+
import { d1CommerceRunner, mirrorOnHand, reserveAllocation } from "@/lib/inventory/allocations.js";
|
|
24
|
+
import { POST as compliancePreflight } from "../compliance/preflight.js";
|
|
25
|
+
|
|
26
|
+
export const prerender = false;
|
|
27
|
+
|
|
28
|
+
const EVIDENCE_STATES = new Set(["fresh", "UNKNOWN", "stale", "unreadable", "absent"]);
|
|
29
|
+
const STAGES = new Set(["preview", "publication"]);
|
|
30
|
+
|
|
31
|
+
function json(body: unknown, status = 200): Response {
|
|
32
|
+
return new Response(JSON.stringify(body), {
|
|
33
|
+
status,
|
|
34
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function deny(finding: string, status: number, owner?: string): Response {
|
|
39
|
+
return json(owner ? { ok: false, finding, owner } : { ok: false, finding }, status);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statusFor(finding: string): number {
|
|
43
|
+
if (finding === "store-unconfigured" || finding === "caller-unconfigured") return 503;
|
|
44
|
+
if (
|
|
45
|
+
finding === "browser-not-authority" ||
|
|
46
|
+
finding === "tenant-unbound" ||
|
|
47
|
+
finding === "shared-secret" ||
|
|
48
|
+
finding === "signature" ||
|
|
49
|
+
finding === "kid" ||
|
|
50
|
+
finding === "issuer" ||
|
|
51
|
+
finding === "audience" ||
|
|
52
|
+
finding === "expired" ||
|
|
53
|
+
finding === "lifetime" ||
|
|
54
|
+
finding === "jti" ||
|
|
55
|
+
finding === "tenant" ||
|
|
56
|
+
finding === "stage"
|
|
57
|
+
) {
|
|
58
|
+
return 401;
|
|
59
|
+
}
|
|
60
|
+
return 409;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function claimString(payload: JWTPayload, key: string): string {
|
|
64
|
+
const value = payload[key];
|
|
65
|
+
return typeof value === "string" ? value : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseList(value: string | undefined): string[] | null {
|
|
69
|
+
if (!value || value.trim() === "") return null;
|
|
70
|
+
const trimmed = value.trim();
|
|
71
|
+
if (trimmed.startsWith("[")) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
74
|
+
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string" || item.trim() === "")) return null;
|
|
75
|
+
return parsed as string[];
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const parts = trimmed.split(",").map((part) => part.trim()).filter(Boolean);
|
|
81
|
+
return parts.length > 0 ? parts : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function publicJwks(value: string | undefined): JSONWebKeySet | null {
|
|
85
|
+
if (!value) return null;
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(value) as unknown;
|
|
88
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as { keys?: unknown }).keys)) return null;
|
|
89
|
+
const keys = (parsed as { keys: unknown[] }).keys;
|
|
90
|
+
if (keys.length < 1) return null;
|
|
91
|
+
for (const key of keys) {
|
|
92
|
+
if (!key || typeof key !== "object") return null;
|
|
93
|
+
const record = key as Record<string, unknown>;
|
|
94
|
+
if (typeof record.d === "string" || record.kty === "oct" || typeof record.k === "string") return null;
|
|
95
|
+
}
|
|
96
|
+
return parsed as JSONWebKeySet;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readCart(value: unknown): FinalCartBinding | null {
|
|
103
|
+
if (!value || typeof value !== "object") return null;
|
|
104
|
+
const record = value as Record<string, unknown>;
|
|
105
|
+
if (typeof record.tenantId !== "string" || typeof record.currency !== "string" || typeof record.destinationDigest !== "string") {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (typeof record.stage !== "string" || !STAGES.has(record.stage)) return null;
|
|
109
|
+
if (typeof record.verificationExpiresAt !== "string" || typeof record.taxExpiresAt !== "string") return null;
|
|
110
|
+
if (!Array.isArray(record.lines) || record.lines.length > 50) return null;
|
|
111
|
+
const lines = [];
|
|
112
|
+
for (const line of record.lines) {
|
|
113
|
+
if (!line || typeof line !== "object") return null;
|
|
114
|
+
const row = line as Record<string, unknown>;
|
|
115
|
+
if (typeof row.sku !== "string" || typeof row.quantity !== "number" || typeof row.unitMinor !== "number") return null;
|
|
116
|
+
lines.push({ sku: row.sku, quantity: row.quantity, unitMinor: row.unitMinor });
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
tenantId: record.tenantId,
|
|
120
|
+
stage: record.stage as CommerceStage,
|
|
121
|
+
currency: record.currency,
|
|
122
|
+
lines,
|
|
123
|
+
destinationDigest: record.destinationDigest,
|
|
124
|
+
verificationExpiresAt: record.verificationExpiresAt,
|
|
125
|
+
taxExpiresAt: record.taxExpiresAt,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readEvidence(value: unknown): CommerceEvidence | null {
|
|
130
|
+
if (!value || typeof value !== "object") return null;
|
|
131
|
+
const record = value as Record<string, unknown>;
|
|
132
|
+
const evidence = {} as CommerceEvidence;
|
|
133
|
+
for (const field of ["verification", "tax", "risk", "stock"] as const) {
|
|
134
|
+
const state = record[field];
|
|
135
|
+
if (typeof state !== "string" || !EVIDENCE_STATES.has(state)) return null;
|
|
136
|
+
evidence[field] = state as EvidenceState;
|
|
137
|
+
}
|
|
138
|
+
return evidence;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readState<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
|
|
142
|
+
return typeof value === "string" && (allowed as readonly string[]).includes(value) ? (value as T) : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function destinationOf(value: unknown): { region: string; country: string } | null {
|
|
146
|
+
if (!value || typeof value !== "object") return null;
|
|
147
|
+
const record = value as Record<string, unknown>;
|
|
148
|
+
const region = typeof record.region === "string" ? record.region.trim() : "";
|
|
149
|
+
const country = typeof record.country === "string" ? record.country.trim() : "";
|
|
150
|
+
if (!/^[A-Za-z]{2}$/.test(region)) return null;
|
|
151
|
+
if (country !== "" && !/^[A-Za-z]{2}$/.test(country)) return null;
|
|
152
|
+
return { region: region.toUpperCase(), country: country === "" ? "US" : country.toUpperCase() };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function catalogOffers(sku: string): Promise<boolean> {
|
|
156
|
+
const database = await readD1("CATALOG_DB");
|
|
157
|
+
if (!database) return false;
|
|
158
|
+
try {
|
|
159
|
+
const products = await listAllActiveProducts(fromD1(database));
|
|
160
|
+
for (const product of products) {
|
|
161
|
+
for (const variant of product.variants) {
|
|
162
|
+
if (variant.sku === sku && variant.inventory.available === true) return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function verificationSeam(
|
|
172
|
+
context: APIContext,
|
|
173
|
+
cart: FinalCartBinding,
|
|
174
|
+
destination: { region: string; country: string } | null,
|
|
175
|
+
): Promise<SeamView> {
|
|
176
|
+
const tot = (context.locals as { tot?: { getProductByHandle: (handle: string) => Promise<unknown> } }).tot ?? {
|
|
177
|
+
getProductByHandle: async () => null,
|
|
178
|
+
};
|
|
179
|
+
const response = await compliancePreflight({
|
|
180
|
+
request: new Request("https://synthetic-tenant.invalid/api/compliance/preflight", {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "content-type": "application/json" },
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
lines: cart.lines.map((line) => ({ handle: line.sku, sku: line.sku, quantity: line.quantity })),
|
|
185
|
+
...(destination ? { destination: { region: destination.region, country: destination.country } } : {}),
|
|
186
|
+
}),
|
|
187
|
+
}),
|
|
188
|
+
locals: { tenant: context.locals.tenant, tot },
|
|
189
|
+
} as APIContext);
|
|
190
|
+
let body: unknown;
|
|
191
|
+
try {
|
|
192
|
+
body = await response.json();
|
|
193
|
+
} catch {
|
|
194
|
+
return { status: "absent" };
|
|
195
|
+
}
|
|
196
|
+
const decision = body && typeof body === "object" ? (body as { decision?: { status?: unknown; failedClosed?: unknown; blocks?: unknown } }).decision : undefined;
|
|
197
|
+
if (!decision || decision.status === "clear") return { status: decision ? "clear" : "absent" };
|
|
198
|
+
const blocks = Array.isArray(decision.blocks) ? decision.blocks : [];
|
|
199
|
+
const codes = blocks
|
|
200
|
+
.map((block) => (block && typeof block === "object" ? (block as { code?: unknown }).code : undefined))
|
|
201
|
+
.filter((code): code is string => typeof code === "string");
|
|
202
|
+
return { status: "blocked", failedClosed: decision.failedClosed === true, codes };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function POST(context: APIContext): Promise<Response> {
|
|
206
|
+
const tenant = context.locals.tenant as TenantConfig | undefined;
|
|
207
|
+
const appDomain = tenant?.appDomain ?? "";
|
|
208
|
+
if (!tenant || appDomain === "") return deny("tenant-unbound", 401);
|
|
209
|
+
|
|
210
|
+
let body: Record<string, unknown>;
|
|
211
|
+
try {
|
|
212
|
+
const parsed = await context.request.json();
|
|
213
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return deny("malformed-request", 409);
|
|
214
|
+
body = parsed as Record<string, unknown>;
|
|
215
|
+
} catch {
|
|
216
|
+
return deny("malformed-request", 409);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (body.scheme === "shared-secret") {
|
|
220
|
+
const decision = await evaluateFinalCart({
|
|
221
|
+
now: new Date().toISOString(),
|
|
222
|
+
routedTenantId: appDomain,
|
|
223
|
+
quoted: readCart(body.quoted) ?? emptyCart(appDomain),
|
|
224
|
+
presented: readCart(body.presented) ?? emptyCart(appDomain),
|
|
225
|
+
evidence: readEvidence(body.evidence),
|
|
226
|
+
seam: { status: "clear" },
|
|
227
|
+
catalogAvailable: false,
|
|
228
|
+
browserConfirmed: body.browserConfirmed === true,
|
|
229
|
+
token: { scheme: "shared-secret" },
|
|
230
|
+
trustedIssuers: [],
|
|
231
|
+
expectedAudience: "",
|
|
232
|
+
activeKids: [],
|
|
233
|
+
authorities: [],
|
|
234
|
+
});
|
|
235
|
+
return deny(decision.ok ? "shared-secret" : decision.finding, statusFor(decision.ok ? "shared-secret" : decision.finding), decision.ok ? undefined : decision.owner);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const quoted = readCart(body.quoted);
|
|
239
|
+
const presented = readCart(body.presented);
|
|
240
|
+
if (!quoted || !presented) return deny("malformed-request", 409);
|
|
241
|
+
|
|
242
|
+
const jwks = publicJwks(await readEnv("SCRAPPY_CALLER_JWKS"));
|
|
243
|
+
const issuers = parseList(await readEnv("SCRAPPY_CALLER_ISSUERS"));
|
|
244
|
+
const audience = await readEnv("SCRAPPY_CALLER_AUDIENCE");
|
|
245
|
+
const activeKids = parseList(await readEnv("SCRAPPY_CALLER_ACTIVE_KIDS"));
|
|
246
|
+
if (!jwks || !issuers || !audience || !activeKids) return deny("caller-unconfigured", 503);
|
|
247
|
+
|
|
248
|
+
const header = context.request.headers.get("authorization") ?? "";
|
|
249
|
+
const bearer = header.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : "";
|
|
250
|
+
let token: CallerToken | null = null;
|
|
251
|
+
if (bearer !== "") {
|
|
252
|
+
try {
|
|
253
|
+
const decoded = decodeJwt(bearer);
|
|
254
|
+
const protectedHeader = decodeProtectedHeader(bearer);
|
|
255
|
+
let signatureValid = false;
|
|
256
|
+
try {
|
|
257
|
+
await jwtVerify(bearer, createLocalJWKSet(jwks), { algorithms: ["EdDSA"] });
|
|
258
|
+
signatureValid = true;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const code = error && typeof error === "object" && "code" in error ? String((error as { code: unknown }).code) : "";
|
|
261
|
+
signatureValid = code === "ERR_JWT_EXPIRED" || code === "ERR_JWT_CLAIM_VALIDATION_FAILED";
|
|
262
|
+
}
|
|
263
|
+
const exp = typeof decoded.exp === "number" ? new Date(decoded.exp * 1000).toISOString() : "";
|
|
264
|
+
const audienceClaim = typeof decoded.aud === "string" ? decoded.aud : Array.isArray(decoded.aud) ? decoded.aud[0] ?? "" : "";
|
|
265
|
+
const stageClaim = claimString(decoded, "stage");
|
|
266
|
+
const stage: CommerceStage =
|
|
267
|
+
stageClaim === "preview" || stageClaim === "publication"
|
|
268
|
+
? stageClaim
|
|
269
|
+
: presented.stage === "preview"
|
|
270
|
+
? "publication"
|
|
271
|
+
: "preview";
|
|
272
|
+
token = {
|
|
273
|
+
scheme: "asymmetric-jwks",
|
|
274
|
+
signatureValid,
|
|
275
|
+
kid: typeof protectedHeader.kid === "string" ? protectedHeader.kid : "",
|
|
276
|
+
issuer: typeof decoded.iss === "string" ? decoded.iss : "",
|
|
277
|
+
audience: audienceClaim,
|
|
278
|
+
expiresAt: exp,
|
|
279
|
+
jti: claimString(decoded, "jti"),
|
|
280
|
+
tenantId: claimString(decoded, "tenantId"),
|
|
281
|
+
stage,
|
|
282
|
+
};
|
|
283
|
+
} catch {
|
|
284
|
+
token = null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const provider = tenant.inventory?.provider ?? "mirror";
|
|
289
|
+
const authorities: unknown[] = [];
|
|
290
|
+
const now = new Date().toISOString();
|
|
291
|
+
if (provider !== "mirror") {
|
|
292
|
+
authorities.push({ provider, sku: presented.lines[0]?.sku });
|
|
293
|
+
} else {
|
|
294
|
+
const skus = [...new Set(presented.lines.map((line) => line.sku))];
|
|
295
|
+
for (const sku of skus) {
|
|
296
|
+
const onHand = mirrorOnHand(sku, "unspecified");
|
|
297
|
+
authorities.push(
|
|
298
|
+
onHand == null
|
|
299
|
+
? null
|
|
300
|
+
: { provider: "mirror", readable: true, locationId: "unspecified", sku, onHand, asOf: now },
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
let catalogAvailable = false;
|
|
305
|
+
for (const line of presented.lines) {
|
|
306
|
+
if (await catalogOffers(line.sku)) catalogAvailable = true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const decision = await evaluateFinalCart({
|
|
310
|
+
now,
|
|
311
|
+
routedTenantId: appDomain,
|
|
312
|
+
quoted,
|
|
313
|
+
presented,
|
|
314
|
+
evidence: readEvidence(body.evidence),
|
|
315
|
+
seam: await verificationSeam(context, presented, destinationOf(body.destination)),
|
|
316
|
+
catalogAvailable,
|
|
317
|
+
browserConfirmed: body.browserConfirmed === true,
|
|
318
|
+
token,
|
|
319
|
+
trustedIssuers: issuers,
|
|
320
|
+
expectedAudience: audience,
|
|
321
|
+
activeKids,
|
|
322
|
+
authorities,
|
|
323
|
+
payment: readState<PaymentState>(body.payment, ["UNKNOWN", "pending", "authorized", "captured", "capture_unknown", "voided", "refund_requested", "refunded", "refund_unknown"]),
|
|
324
|
+
stock: readState<StockState>(body.stock, ["UNKNOWN", "unallocated", "reserved", "confirmed", "released", "expired"]),
|
|
325
|
+
fulfillment: readState<FulfillmentState>(body.fulfillment, ["UNKNOWN", "held", "ready", "handed_off", "cancelled"]),
|
|
326
|
+
label: readState<LabelState>(body.label, ["UNKNOWN", "none", "intent", "purchased", "printed", "void_requested", "voided"]),
|
|
327
|
+
});
|
|
328
|
+
if (!decision.ok) return deny(decision.finding, statusFor(decision.finding), decision.owner);
|
|
329
|
+
|
|
330
|
+
const database = await readD1("STOREFRONT_APPS_DB");
|
|
331
|
+
if (!database || typeof database.prepare !== "function") return deny("store-unconfigured", 503);
|
|
332
|
+
const reserved = await reserveAllocation(d1CommerceRunner(database), {
|
|
333
|
+
tenantId: decision.tenantId,
|
|
334
|
+
stage: decision.stage,
|
|
335
|
+
orderId: `a${crypto.randomUUID()}`,
|
|
336
|
+
cartDigest: decision.digest,
|
|
337
|
+
expiresAt: decision.expiresAt,
|
|
338
|
+
now,
|
|
339
|
+
currency: presented.currency,
|
|
340
|
+
amountMinor: decision.amountMinor,
|
|
341
|
+
issuer: decision.issuer,
|
|
342
|
+
jti: decision.jti,
|
|
343
|
+
holds: decision.holds,
|
|
344
|
+
});
|
|
345
|
+
if (!reserved.ok) return deny(reserved.finding, statusFor(reserved.finding));
|
|
346
|
+
return json({
|
|
347
|
+
ok: true,
|
|
348
|
+
authorizationId: reserved.authorizationId,
|
|
349
|
+
cartDigest: decision.digest,
|
|
350
|
+
expiresAt: reserved.expiresAt,
|
|
351
|
+
repeated: reserved.repeated,
|
|
352
|
+
stage: decision.stage,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function emptyCart(tenantId: string): FinalCartBinding {
|
|
357
|
+
return {
|
|
358
|
+
tenantId,
|
|
359
|
+
stage: "preview",
|
|
360
|
+
currency: "USD",
|
|
361
|
+
lines: [{ sku: "sku-1", quantity: 1, unitMinor: 0 }],
|
|
362
|
+
destinationDigest: "dest-synthetic",
|
|
363
|
+
verificationExpiresAt: "1970-01-01T00:00:00.000Z",
|
|
364
|
+
taxExpiresAt: "1970-01-01T00:00:00.000Z",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
@@ -21,7 +21,9 @@ import ReviewSummary from "@/components/commerce/ReviewSummary.astro";
|
|
|
21
21
|
import ReviewList from "@/components/commerce/ReviewList.astro";
|
|
22
22
|
import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/tot/query";
|
|
23
23
|
import {
|
|
24
|
-
|
|
24
|
+
buildVariantCartPayload,
|
|
25
|
+
priceForPurchaseOption,
|
|
26
|
+
workerCartApiPath,
|
|
25
27
|
normalizePurchaseOptions,
|
|
26
28
|
visiblePurchaseOptions,
|
|
27
29
|
isAutopilotEnabled,
|
|
@@ -208,6 +210,13 @@ const autopilotEnabled = isAutopilotEnabled(tenant.commerce?.autopilot);
|
|
|
208
210
|
// list/subscription price; a Phase-2 member request signs their member price.
|
|
209
211
|
const pricingCtx: PurchaseOptionPricingContext = { member, program: membershipProgram };
|
|
210
212
|
|
|
213
|
+
const cartApiPath = workerCartApiPath({
|
|
214
|
+
provider: tenant.serving?.provider ?? "worker-r2-pointer",
|
|
215
|
+
phase: Astro.locals.browserEnvironment.environment === "live" ? "publication" : "preview",
|
|
216
|
+
apiBasePath,
|
|
217
|
+
endpoint: "/api/commerce/checkout-handoff",
|
|
218
|
+
});
|
|
219
|
+
|
|
211
220
|
let checkoutCart:
|
|
212
221
|
| {
|
|
213
222
|
action: string;
|
|
@@ -215,12 +224,15 @@ let checkoutCart:
|
|
|
215
224
|
byVariantId: Record<string, Record<string, VariantCartPayload>>;
|
|
216
225
|
}
|
|
217
226
|
| undefined;
|
|
227
|
+
let cartOperatorError: string | undefined;
|
|
218
228
|
if (commerce) {
|
|
219
229
|
// Fail closed for production signed checkout: a signed tenant with no
|
|
220
230
|
// cart-validation secret builds NO cart (never a tamperable form). See
|
|
221
231
|
// resolveCartSigning — it also logs the misconfiguration loudly.
|
|
222
|
-
const
|
|
223
|
-
if (enabled) {
|
|
232
|
+
const signing = await resolveCartSigning(commerce);
|
|
233
|
+
if (!signing.enabled) {
|
|
234
|
+
cartOperatorError = signing.operatorError;
|
|
235
|
+
} else {
|
|
224
236
|
try {
|
|
225
237
|
const purchaseOptions = visiblePurchaseOptions(
|
|
226
238
|
normalizePurchaseOptions(product),
|
|
@@ -230,33 +242,41 @@ if (commerce) {
|
|
|
230
242
|
product.variants.map(
|
|
231
243
|
async (v) => {
|
|
232
244
|
const payloads = await Promise.all(
|
|
233
|
-
purchaseOptions.map(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
245
|
+
purchaseOptions.map(async (purchaseOption) => {
|
|
246
|
+
const effective = priceForPurchaseOption(
|
|
247
|
+
v.price,
|
|
248
|
+
purchaseOption,
|
|
249
|
+
pricingCtx,
|
|
250
|
+
);
|
|
251
|
+
return [
|
|
252
|
+
purchaseOption.id,
|
|
253
|
+
await buildVariantCartPayload(product, v, {
|
|
254
|
+
mode: commerce.mode,
|
|
255
|
+
secret: signing.secret,
|
|
256
|
+
purchaseOption,
|
|
257
|
+
signedPrice: effective.price,
|
|
258
|
+
autopilotEnabled,
|
|
259
|
+
tenantId: tenant.tenant_id,
|
|
260
|
+
}),
|
|
261
|
+
] as const;
|
|
262
|
+
}),
|
|
246
263
|
);
|
|
247
264
|
return [v.id, Object.fromEntries(payloads)] as const;
|
|
248
265
|
},
|
|
249
266
|
),
|
|
250
267
|
);
|
|
251
268
|
checkoutCart = {
|
|
252
|
-
action:
|
|
269
|
+
action: cartApiPath,
|
|
253
270
|
purchaseOptions,
|
|
254
271
|
byVariantId: Object.fromEntries(entries),
|
|
255
272
|
};
|
|
256
273
|
} catch (err) {
|
|
274
|
+
cartOperatorError =
|
|
275
|
+
err instanceof Error && err.name === "CartSigningUnavailableError"
|
|
276
|
+
? err.message
|
|
277
|
+
: undefined;
|
|
257
278
|
console.warn(
|
|
258
279
|
`[storefront] Invalid checkout purchase options for ${product.handle}; cart disabled for this product.`,
|
|
259
|
-
err,
|
|
260
280
|
);
|
|
261
281
|
}
|
|
262
282
|
}
|
|
@@ -368,8 +388,15 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
368
388
|
</div>
|
|
369
389
|
)}
|
|
370
390
|
|
|
371
|
-
{/* Variant selector island owns live price/SKU/availability + buy CTA.
|
|
372
|
-
|
|
391
|
+
{/* Variant selector island owns live price/SKU/availability + buy CTA.
|
|
392
|
+
The form action is the worker cart route, so a native submit reaches
|
|
393
|
+
the server check. A clear browser preflight is not payment authority. */}
|
|
394
|
+
<div class="mt-7" data-payment-authority="none">
|
|
395
|
+
{cartOperatorError && (
|
|
396
|
+
<p class="mb-3 text-sm text-[var(--color-text)]" role="alert" data-cart-operator-error>
|
|
397
|
+
{cartOperatorError}
|
|
398
|
+
</p>
|
|
399
|
+
)}
|
|
373
400
|
<VariantSelector
|
|
374
401
|
client:load
|
|
375
402
|
variants={publicSelectorVariants(product.variants)}
|
|
@@ -380,7 +407,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
380
407
|
productTitle={product.title}
|
|
381
408
|
checkoutCart={checkoutCart}
|
|
382
409
|
pricing={pricing}
|
|
383
|
-
handoffUrl={
|
|
410
|
+
handoffUrl={cartApiPath}
|
|
384
411
|
/>
|
|
385
412
|
{/* Regulated-tenant checkout ENFORCEMENT gate. Intercepts the
|
|
386
413
|
managed-cart handoff to clear age + ship-to compliance server-side
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.51",
|
|
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",
|