@tokenoftrust/storefront-runner 2.4.91 → 2.5.0
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/migrations-apps/0017_commerce_durable_projection.sql +28 -0
- package/apps/storefront/migrations-apps/0018_commerce_provider_cart_identity.sql +13 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +5 -0
- package/apps/storefront/src/config/devTenantSeed.ts +3 -2
- package/apps/storefront/src/lib/checkout/commerce/commerceAdmission.ts +108 -0
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +1 -1
- package/apps/storefront/src/lib/checkout/shared/checkoutCommerce.ts +6 -4
- package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +111 -0
- package/apps/storefront/src/lib/fulfillment/admin/viewmodels/fulfillment.ts +1 -1
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +146 -0
- package/apps/storefront/src/lib/fulfillment/commerceReadback.ts +60 -0
- package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +65 -0
- package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +13 -0
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +50 -4
- package/apps/storefront/src/lib/fulfillment/readiness.ts +1 -0
- package/apps/storefront/src/lib/fulfillment/store.ts +15 -0
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +6 -3
- package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +6 -5
- package/apps/storefront/src/lib/inventory/allocations.ts +58 -9
- package/apps/storefront/src/lib/inventory/mirrorStock.ts +121 -0
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +8 -8
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +22 -1
- package/apps/storefront/src/lib/orders/data/ordersStore.ts +12 -4
- package/apps/storefront/src/pages/api/admin/commerce-readiness.ts +56 -26
- package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +34 -2
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +158 -306
- package/apps/storefront/src/pages/products/[handle].astro +13 -8
- package/apps/storefront/test/pipeline/harness.ts +6 -0
- package/apps/storefront/test/pipeline/pipeline-host.ts +29 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout-orders.ts +45 -7
- package/packages/public-runtime/src/checkout.ts +37 -9
- package/packages/public-runtime/src/tenant.ts +3 -0
|
@@ -1,333 +1,185 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** Provider-authenticated final-cart admission. Evidence and applicability are server-owned. */
|
|
6
2
|
import type { APIContext } from "astro";
|
|
7
|
-
import { createLocalJWKSet,
|
|
8
|
-
import type { TenantConfig } from "@tot/public-runtime";
|
|
3
|
+
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify, type JSONWebKeySet } from "jose";
|
|
9
4
|
import {
|
|
10
|
-
|
|
11
|
-
type CommerceEvidence,
|
|
12
|
-
type
|
|
13
|
-
type EvidenceState,
|
|
14
|
-
type FinalCartBinding,
|
|
15
|
-
type FulfillmentState,
|
|
16
|
-
type LabelState,
|
|
17
|
-
type PaymentState,
|
|
18
|
-
type StockState,
|
|
5
|
+
commerceTenantId, totalsMatch, canonicalJson, deriveProductProfile,
|
|
6
|
+
type CallerToken, type CatalogProduct, type CommerceEvidence, type CommerceEvidenceProvenance,
|
|
7
|
+
type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
|
|
19
8
|
} from "@tot/public-runtime";
|
|
20
|
-
import { fromD1
|
|
21
|
-
import { readD1, readEnv } from "@/lib/publication/shared/env";
|
|
9
|
+
import { fromD1 } from "@/lib/catalog/d1/catalog.js";
|
|
10
|
+
import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
|
|
22
11
|
import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
|
|
23
|
-
import { d1CommerceRunner,
|
|
12
|
+
import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
|
|
13
|
+
import { readMirrorStock } from "@/lib/inventory/mirrorStock.js";
|
|
14
|
+
import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
|
|
15
|
+
import { isRegulatedTenant } from "@/lib/compliance/enforcement.js";
|
|
24
16
|
import { POST as compliancePreflight } from "../compliance/preflight.js";
|
|
25
|
-
|
|
17
|
+
import { resolveAdminTargetTenant } from "@/config/adminTargetTenant";
|
|
18
|
+
import { withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
|
|
19
|
+
import { createContentProvider } from "@/lib/content/storyblok/provider";
|
|
20
|
+
import { createToTClient } from "@/lib/catalog/tot/ToTClient";
|
|
26
21
|
export const prerender = false;
|
|
27
|
-
|
|
28
|
-
const EVIDENCE_STATES = new Set(["fresh", "UNKNOWN", "stale", "unreadable", "absent"]);
|
|
29
|
-
const STAGES = new Set(["preview", "publication"]);
|
|
30
|
-
|
|
22
|
+
const HEX = /^[a-f0-9]{64}$/;
|
|
31
23
|
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;
|
|
24
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
|
|
61
25
|
}
|
|
62
|
-
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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;
|
|
26
|
+
function deny(finding: string, status = 409, owner?: string): Response { return json({ ok: false, finding, ...(owner ? { owner } : {}) }, status); }
|
|
27
|
+
function list(value: string | undefined): string[] | null {
|
|
28
|
+
if (!value) return null;
|
|
29
|
+
try {
|
|
30
|
+
const values: unknown = value.trim().startsWith("[") ? JSON.parse(value) : value.split(",").map(v => v.trim());
|
|
31
|
+
return Array.isArray(values) && values.length > 0 && values.every(v => typeof v === "string" && v.length > 0) ? values : null;
|
|
32
|
+
} catch { return null; }
|
|
82
33
|
}
|
|
83
|
-
|
|
84
34
|
function publicJwks(value: string | undefined): JSONWebKeySet | null {
|
|
85
|
-
if (!value) return null;
|
|
86
35
|
try {
|
|
87
|
-
const parsed = JSON.parse(value
|
|
88
|
-
if (!parsed || typeof parsed !== "object" || !Array.isArray(
|
|
89
|
-
|
|
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
|
-
}
|
|
36
|
+
const parsed: unknown = JSON.parse(value ?? "");
|
|
37
|
+
if (!parsed || typeof parsed !== "object" || !("keys" in parsed) || !Array.isArray(parsed.keys) || parsed.keys.length < 1) return null;
|
|
38
|
+
if (parsed.keys.some(key => !key || typeof key !== "object" || "d" in key || "k" in key || key.kty !== "OKP" || key.crv !== "Ed25519")) return null;
|
|
96
39
|
return parsed as JSONWebKeySet;
|
|
97
|
-
} catch {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
40
|
+
} catch { return null; }
|
|
100
41
|
}
|
|
101
|
-
|
|
102
|
-
|
|
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;
|
|
42
|
+
async function digest(value: string): Promise<string> {
|
|
43
|
+
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))].map(b => b.toString(16).padStart(2, "0")).join("");
|
|
139
44
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
45
|
+
function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
47
|
+
const v = value as Record<string, unknown>;
|
|
48
|
+
const allowed = ["provider", "providerCartId", "providerCartDigest", "tenantId", "stage", "currency", "lines", "totals", "destinationDigest", "destination"];
|
|
49
|
+
if (Object.keys(v).some(k => !allowed.includes(k) && k !== "expectedAuthorizationId") || allowed.some(k => !(k in v))) return null;
|
|
50
|
+
if ("expectedAuthorizationId" in v && (typeof v.expectedAuthorizationId !== "string" || !/^[a-z0-9][a-z0-9-]{0,80}$/.test(v.expectedAuthorizationId))) return null;
|
|
51
|
+
if (v.provider !== "foxycart" || typeof v.providerCartId !== "string" || !/^[a-zA-Z0-9._:-]{1,128}$/.test(v.providerCartId) ||
|
|
52
|
+
typeof v.providerCartDigest !== "string" || !HEX.test(v.providerCartDigest) || typeof v.destinationDigest !== "string" || !HEX.test(v.destinationDigest) ||
|
|
53
|
+
typeof v.tenantId !== "string" || !["preview", "publication"].includes(String(v.stage)) || typeof v.currency !== "string" || !/^[A-Z]{3}$/.test(v.currency) ||
|
|
54
|
+
!Array.isArray(v.lines) || v.lines.length < 1 || v.lines.length > 50) return null;
|
|
55
|
+
if (v.lines.some(line => !line || typeof line !== "object" || typeof line.sku !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(line.sku) ||
|
|
56
|
+
!Number.isSafeInteger(line.quantity) || line.quantity < 1 || line.quantity > 99 || !Number.isSafeInteger(line.unitMinor) || line.unitMinor < 0 ||
|
|
57
|
+
Object.keys(line).some(k => !["sku", "quantity", "unitMinor"].includes(k)))) return null;
|
|
58
|
+
if (!v.totals || typeof v.totals !== "object" || Object.keys(v.totals).length !== 4 ||
|
|
59
|
+
!["subtotalMinor", "shippingMinor", "taxMinor", "totalMinor"].every(k => Number.isSafeInteger((v.totals as Record<string, unknown>)[k]) && Number((v.totals as Record<string, unknown>)[k]) >= 0)) return null;
|
|
60
|
+
const d = v.destination as Record<string, unknown> | undefined;
|
|
61
|
+
if (!d || Object.keys(d).length !== 2 || typeof d.country !== "string" || !/^[A-Z]{2}$/.test(d.country) || typeof d.region !== "string" || !/^[A-Z0-9-]{1,8}$/.test(d.region)) return null;
|
|
62
|
+
const snapshot = v as unknown as CommerceProviderCartSnapshot;
|
|
63
|
+
if (new Set(snapshot.lines.map(line => line.sku)).size !== snapshot.lines.length) return null;
|
|
64
|
+
if (!totalsMatch(snapshot.totals) || snapshot.lines.reduce((sum, line) => sum + line.quantity * line.unitMinor, 0) !== snapshot.totals.subtotalMinor) return null;
|
|
65
|
+
return snapshot;
|
|
143
66
|
}
|
|
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 catalogAvailableFor(skus: readonly string[]): Promise<boolean> {
|
|
156
|
-
if (skus.length === 0) return false;
|
|
157
|
-
const database = await readD1("CATALOG_DB");
|
|
158
|
-
if (!database) return false;
|
|
159
|
-
const wanted = new Set(skus);
|
|
160
|
-
try {
|
|
161
|
-
const products = await listAllActiveProducts(fromD1(database));
|
|
162
|
-
for (const product of products) {
|
|
163
|
-
for (const variant of product.variants) {
|
|
164
|
-
if (variant.sku === undefined) continue;
|
|
165
|
-
if (wanted.has(variant.sku) && variant.inventory.available === true) return true;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
} catch {
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async function verificationSeam(
|
|
175
|
-
context: APIContext,
|
|
176
|
-
cart: FinalCartBinding,
|
|
177
|
-
destination: { region: string; country: string } | null,
|
|
178
|
-
): Promise<SeamView> {
|
|
179
|
-
const tot = (context.locals as { tot?: { getProductByHandle: (handle: string) => Promise<unknown> } }).tot ?? {
|
|
180
|
-
getProductByHandle: async () => null,
|
|
181
|
-
};
|
|
67
|
+
async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot, products: readonly CatalogProduct[]): Promise<SeamView> {
|
|
182
68
|
const response = await compliancePreflight({
|
|
183
|
-
request: new Request("https://
|
|
184
|
-
method: "POST",
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
lines: cart.lines.map((line) => ({ handle: line.sku, sku: line.sku, quantity: line.quantity })),
|
|
188
|
-
...(destination ? { destination: { region: destination.region, country: destination.country } } : {}),
|
|
189
|
-
}),
|
|
190
|
-
}),
|
|
191
|
-
locals: { tenant: context.locals.tenant, tot },
|
|
69
|
+
request: new Request("https://checkout.invalid/api/compliance/preflight", {
|
|
70
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ lines: snapshot.lines.map(line => ({ handle: products.find(p => p.variants.some(v => v.sku === line.sku))!.handle, sku: line.sku, quantity: line.quantity })), destination: snapshot.destination }),
|
|
72
|
+
}), locals: { tenant: context.locals.tenant, tot: context.locals.tot },
|
|
192
73
|
} as APIContext);
|
|
193
|
-
let body: unknown;
|
|
194
74
|
try {
|
|
195
|
-
body = await response.json();
|
|
196
|
-
|
|
197
|
-
return { status: "
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (!decision || decision.status === "clear") return { status: decision ? "clear" : "absent" };
|
|
201
|
-
const blocks = Array.isArray(decision.blocks) ? decision.blocks : [];
|
|
202
|
-
const codes = blocks
|
|
203
|
-
.map((block) => (block && typeof block === "object" ? (block as { code?: unknown }).code : undefined))
|
|
204
|
-
.filter((code): code is string => typeof code === "string");
|
|
205
|
-
return { status: "blocked", failedClosed: decision.failedClosed === true, codes };
|
|
75
|
+
const body = await response.json() as { decision?: { status?: string; failedClosed?: boolean; blocks?: { code?: string }[] } };
|
|
76
|
+
if (!body.decision) return { status: "absent" };
|
|
77
|
+
if (body.decision.status === "clear") return { status: "clear" };
|
|
78
|
+
return { status: "blocked", failedClosed: body.decision.failedClosed === true, codes: body.decision.blocks?.map(b => b.code ?? "") };
|
|
79
|
+
} catch { return { status: "absent" }; }
|
|
206
80
|
}
|
|
207
|
-
|
|
208
81
|
export async function POST(context: APIContext): Promise<Response> {
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return deny("malformed-request", 409);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (body.scheme === "shared-secret") return deny("shared-secret", statusFor("shared-secret"));
|
|
223
|
-
|
|
224
|
-
const quoted = readCart(body.quoted);
|
|
225
|
-
const presented = readCart(body.presented);
|
|
226
|
-
if (!quoted || !presented) return deny("malformed-request", 409);
|
|
227
|
-
|
|
228
|
-
const jwks = publicJwks(await readEnv("SCRAPPY_CALLER_JWKS"));
|
|
229
|
-
const issuers = parseList(await readEnv("SCRAPPY_CALLER_ISSUERS"));
|
|
230
|
-
const audience = await readEnv("SCRAPPY_CALLER_AUDIENCE");
|
|
231
|
-
const activeKids = parseList(await readEnv("SCRAPPY_CALLER_ACTIVE_KIDS"));
|
|
82
|
+
if (Number(context.request.headers.get("content-length") ?? 0) > 65536) return deny("malformed-request");
|
|
83
|
+
const raw = await context.request.text();
|
|
84
|
+
if (raw.length > 65536) return deny("malformed-request");
|
|
85
|
+
let snapshot: CommerceProviderCartSnapshot | null;
|
|
86
|
+
try { snapshot = readSnapshot(JSON.parse(raw)); } catch { snapshot = null; }
|
|
87
|
+
if (!snapshot) return deny("malformed-request");
|
|
88
|
+
const jwks = publicJwks(await readEnv("COMMERCE_CALLER_JWKS"));
|
|
89
|
+
const issuers = list(await readEnv("COMMERCE_CALLER_ISSUERS"));
|
|
90
|
+
const audience = await readEnv("COMMERCE_CALLER_AUDIENCE");
|
|
91
|
+
const activeKids = list(await readEnv("COMMERCE_CALLER_ACTIVE_KIDS"));
|
|
232
92
|
if (!jwks || !issuers || !audience || !activeKids) return deny("caller-unconfigured", 503);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
93
|
+
const bearer = context.request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "";
|
|
94
|
+
let token: CallerToken;
|
|
95
|
+
try {
|
|
96
|
+
const header = decodeProtectedHeader(bearer);
|
|
97
|
+
if (!header.kid || !activeKids.includes(header.kid)) return deny("kid", 401);
|
|
98
|
+
const { payload } = await jwtVerify(bearer, createLocalJWKSet(jwks), { algorithms: ["EdDSA"], issuer: issuers, audience });
|
|
99
|
+
if (payload.requestDigest !== await digest(raw)) return deny("request-digest", 401);
|
|
100
|
+
if (payload.tenantId !== snapshot.tenantId || payload.stage !== snapshot.stage) return deny("tenant", 401);
|
|
101
|
+
if (typeof payload.exp !== "number") return deny("expired", 401);
|
|
102
|
+
token = { scheme: "asymmetric-jwks", signatureValid: true, kid: header.kid, issuer: payload.iss ?? "", audience,
|
|
103
|
+
expiresAt: new Date(payload.exp * 1000).toISOString(), jti: payload.jti ?? "", tenantId: snapshot.tenantId, stage: snapshot.stage };
|
|
104
|
+
} catch { return deny("signature", 401); }
|
|
105
|
+
const allowedTenants = list(await readEnv("COMMERCE_TENANT_ALLOWLIST"));
|
|
106
|
+
if (!allowedTenants || !allowedTenants.includes(snapshot.tenantId)) return deny("tenant", 401);
|
|
107
|
+
const registered = await resolveAdminTargetTenant(snapshot.tenantId);
|
|
108
|
+
if (!registered || commerceTenantId(registered) !== snapshot.tenantId) return deny("tenant-unregistered", 401);
|
|
109
|
+
const kv = await readKv("TENANT_CACHE");
|
|
110
|
+
const kvGet = async (key: string): Promise<string | null> => kv ? kv.get(key) : null;
|
|
111
|
+
const contentEnv = snapshot.stage === "publication" ? "live" : "test";
|
|
112
|
+
const tenant = await withDeclaredTenantConfig(registered, kvGet, { env: contentEnv });
|
|
113
|
+
const content = createContentProvider(tenant, { get: kvGet, env: contentEnv });
|
|
114
|
+
const tot = await createToTClient(tenant, await readD1("CATALOG_DB"), content);
|
|
115
|
+
const authorityContext = { ...context, locals: { ...context.locals, tenant, tot, content } } as APIContext;
|
|
116
|
+
const { tenantId, stage, currency, totals, destinationDigest } = snapshot;
|
|
117
|
+
const lines = [...snapshot.lines].sort((a, b) => a.sku.localeCompare(b.sku) || a.unitMinor - b.unitMinor || a.quantity - b.quantity);
|
|
118
|
+
if (snapshot.providerCartDigest !== await digest(canonicalJson({ tenantId, stage, currency, lines, totals, destinationDigest }))) return deny("provider-cart-digest");
|
|
119
|
+
let products: CatalogProduct[];
|
|
120
|
+
try { products = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
|
|
121
|
+
for (const line of snapshot.lines) {
|
|
122
|
+
const matches = products.flatMap(product => product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope
|
|
123
|
+
? product.variants.filter(v => v.sku === line.sku).map(variant => ({ product, variant })) : []);
|
|
124
|
+
const digits = new Intl.NumberFormat("en", { style: "currency", currency: snapshot.currency }).resolvedOptions().maximumFractionDigits ?? 2;
|
|
125
|
+
const scaled = (matches[0]?.variant.price ?? -1) * 10 ** digits;
|
|
126
|
+
if (matches.length !== 1 || !matches[0]!.variant.inventory.available || matches[0]!.variant.currency !== snapshot.currency || Math.abs(scaled - Math.round(scaled)) > 0.000001 || Math.round(scaled) !== line.unitMinor) return deny("catalog-cart-mismatch");
|
|
267
127
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
128
|
+
const database = await readD1("STOREFRONT_APPS_DB");
|
|
129
|
+
if (!database) return deny("store-unconfigured", 503);
|
|
130
|
+
if (tenant.inventory?.provider && tenant.inventory.provider !== "mirror") return deny("authority-unavailable");
|
|
271
131
|
const now = new Date().toISOString();
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
132
|
+
const queryable = fromD1(database);
|
|
133
|
+
const prior = await queryable.first<{ authorization_id: string; state: string }>(
|
|
134
|
+
`SELECT s.authorization_id,a.state FROM commerce_admission_snapshots s JOIN scrappy_final_cart_authorizations a
|
|
135
|
+
ON a.tenant_id=s.tenant_id AND a.stage=s.stage AND a.authorization_id=s.authorization_id
|
|
136
|
+
JOIN commerce_provider_carts p ON p.tenant_id=s.tenant_id AND p.stage=s.stage AND p.authorization_id=s.authorization_id
|
|
137
|
+
WHERE p.tenant_id=? AND p.stage=? AND p.provider='foxycart' AND p.provider_cart_id=?`,
|
|
138
|
+
snapshot.tenantId, snapshot.stage, snapshot.providerCartId,
|
|
139
|
+
);
|
|
140
|
+
if (snapshot.expectedAuthorizationId && snapshot.expectedAuthorizationId !== prior?.authorization_id) return deny("provider-cart-conflict");
|
|
141
|
+
if (prior && prior.state !== "active") return deny("admission-consumed");
|
|
142
|
+
const existing = prior ? await readCommerceAdmission(queryable, snapshot.tenantId, snapshot.stage, prior.authorization_id) : null;
|
|
143
|
+
if (prior && (!existing || existing.expiresAt <= now)) return deny("admission-expired");
|
|
144
|
+
if (existing && !admissionMatches(existing, snapshot)) return deny("provider-cart-changed");
|
|
145
|
+
const policy = tenant.commerce?.authorityPolicy;
|
|
146
|
+
const testPolicy = policy?.purpose === "synthetic-nonbillable" && typeof policy.revision === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(policy.revision) &&
|
|
147
|
+
Number.isFinite(Date.parse(policy.expiresAt)) && policy.expiresAt > now && await readEnv("COMMERCE_DEMO_CHECKOUT_TIER") === "test" &&
|
|
148
|
+
tenant.commerce?.checkout?.storeSubDomain === "tot-preview" && tenant.commerce.checkout.mode === "signed" &&
|
|
149
|
+
["verification", "tax", "risk"].every(field => ["required", "not-required"].includes(policy.requirements?.[field as "verification" | "tax" | "risk"]));
|
|
150
|
+
const regulated = isRegulatedTenant(tenant) || !!tenant.compliance?.pactAct || !!tenant.compliance?.exciseTax ||
|
|
151
|
+
snapshot.lines.some(line => products.some(p => p.variants.some(v => v.sku === line.sku && ["ends", "e-liquid", "other-nicotine"].includes(deriveProductProfile(p, v).category))));
|
|
152
|
+
if (testPolicy && regulated && (policy.requirements.verification === "not-required" || policy.requirements.tax === "not-required")) return deny("authority-policy-regulated");
|
|
153
|
+
const authorities = [];
|
|
154
|
+
for (const sku of new Set(snapshot.lines.map(line => line.sku))) {
|
|
155
|
+
authorities.push(await readMirrorStock(queryable, { tenantId: snapshot.tenantId, stage: snapshot.stage, sku, locationId: "unspecified", now, maxAgeMs: 15 * 60_000 }));
|
|
156
|
+
}
|
|
157
|
+
const stockExpiry = Math.min(...authorities.flatMap(authority => authority ? [Date.parse(authority.asOf) + 15 * 60_000] : []));
|
|
158
|
+
const evidence: CommerceEvidence = { verification: "absent", tax: "absent", risk: "absent", stock: "fresh" };
|
|
159
|
+
const expiresAt = existing?.expiresAt ?? new Date(Math.min(Date.now() + 5 * 60_000, stockExpiry, testPolicy ? Date.parse(policy.expiresAt) : Infinity)).toISOString();
|
|
160
|
+
const provenance = {} as CommerceEvidenceProvenance;
|
|
161
|
+
for (const field of ["verification", "tax", "risk"] as const) {
|
|
162
|
+
if (testPolicy && policy.requirements?.[field] === "not-required") {
|
|
163
|
+
evidence[field] = "not-required";
|
|
164
|
+
provenance[field] = { requirement: "not-required", state: "not-required", source: "declared-synthetic-policy", policyRevision: policy.revision, expiresAt };
|
|
283
165
|
}
|
|
284
166
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
authorities,
|
|
301
|
-
payment: readState<PaymentState>(body.payment, ["UNKNOWN", "pending", "authorized", "captured", "capture_unknown", "voided", "refund_requested", "refunded", "refund_unknown"]),
|
|
302
|
-
stock: readState<StockState>(body.stock, ["UNKNOWN", "unallocated", "reserved", "confirmed", "released", "expired"]),
|
|
303
|
-
fulfillment: readState<FulfillmentState>(body.fulfillment, ["UNKNOWN", "held", "ready", "handed_off", "cancelled"]),
|
|
304
|
-
label: readState<LabelState>(body.label, ["UNKNOWN", "none", "intent", "purchased", "printed", "void_requested", "voided"]),
|
|
305
|
-
});
|
|
306
|
-
if (!decision.ok) return deny(decision.finding, statusFor(decision.finding), decision.owner);
|
|
307
|
-
|
|
308
|
-
const database = await readD1("STOREFRONT_APPS_DB");
|
|
309
|
-
if (!database || typeof database.prepare !== "function") return deny("store-unconfigured", 503);
|
|
310
|
-
const reserved = await reserveAllocation(d1CommerceRunner(database), {
|
|
311
|
-
tenantId: decision.tenantId,
|
|
312
|
-
stage: decision.stage,
|
|
313
|
-
orderId: `a${crypto.randomUUID()}`,
|
|
314
|
-
cartDigest: decision.digest,
|
|
315
|
-
expiresAt: decision.expiresAt,
|
|
316
|
-
now,
|
|
317
|
-
currency: presented.currency,
|
|
318
|
-
amountMinor: decision.amountMinor,
|
|
319
|
-
issuer: decision.issuer,
|
|
320
|
-
jti: decision.jti,
|
|
321
|
-
holds: decision.holds,
|
|
322
|
-
});
|
|
323
|
-
if (!reserved.ok) return deny(reserved.finding, statusFor(reserved.finding));
|
|
324
|
-
return json({
|
|
325
|
-
ok: true,
|
|
326
|
-
authorizationId: reserved.authorizationId,
|
|
327
|
-
cartDigest: decision.digest,
|
|
328
|
-
expiresAt: reserved.expiresAt,
|
|
329
|
-
repeated: reserved.repeated,
|
|
330
|
-
stage: decision.stage,
|
|
167
|
+
provenance.stock = { requirement: "required", state: "satisfied", source: "tenant-inventory-movements", policyRevision: "mirror-v1", expiresAt };
|
|
168
|
+
const presented: FinalCartBinding = { tenantId: snapshot.tenantId, stage: snapshot.stage, currency: snapshot.currency, lines: snapshot.lines,
|
|
169
|
+
destinationDigest: snapshot.destinationDigest, verificationExpiresAt: expiresAt, taxExpiresAt: expiresAt };
|
|
170
|
+
const decision = await evaluateFinalCart({ now, routedTenantId: commerceTenantId(tenant), quoted: presented, presented, evidence,
|
|
171
|
+
seam: await seam(authorityContext, snapshot, products), catalogAvailable: true, browserConfirmed: false, token,
|
|
172
|
+
trustedIssuers: issuers, expectedAudience: audience, activeKids, authorities });
|
|
173
|
+
if (!decision.ok) return deny(decision.finding, 409, decision.owner);
|
|
174
|
+
const authorizationId = existing?.authorizationId ?? `a${crypto.randomUUID()}`;
|
|
175
|
+
// Retries of the same verified provider cart share one reservation, including concurrent callbacks.
|
|
176
|
+
const cartDigest = existing?.cartDigest ?? await digest(`commerce-provider-cart-v1\n${snapshot.providerCartId}\n${snapshot.providerCartDigest}`);
|
|
177
|
+
const reserved = await reserveAllocation(d1CommerceRunner(database), { tenantId: decision.tenantId, stage: decision.stage, orderId: authorizationId,
|
|
178
|
+
cartDigest, expiresAt: decision.expiresAt, now, currency: snapshot.currency, amountMinor: snapshot.totals.totalMinor,
|
|
179
|
+
issuer: decision.issuer, jti: decision.jti, holds: decision.holds,
|
|
180
|
+
providerCart: { provider: snapshot.provider, id: snapshot.providerCartId, digest: snapshot.providerCartDigest, expectedAuthorizationId: snapshot.expectedAuthorizationId },
|
|
181
|
+
admissionStatement: admissionSnapshotStatement({ ...snapshot, authorizationId, cartDigest, evidence, provenance, expiresAt: decision.expiresAt, now }),
|
|
331
182
|
});
|
|
183
|
+
if (!reserved.ok) return deny(reserved.finding);
|
|
184
|
+
return json({ ok: true, authorizationId: reserved.authorizationId, cartDigest, expiresAt: reserved.expiresAt, repeated: reserved.repeated, stage: snapshot.stage });
|
|
332
185
|
}
|
|
333
|
-
|
|
@@ -22,6 +22,9 @@ import ReviewList from "@/components/catalog/storefront/commerce/ReviewList.astr
|
|
|
22
22
|
import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/catalog/tot/query";
|
|
23
23
|
import {
|
|
24
24
|
buildMemberSignedCartPayload,
|
|
25
|
+
commerceTenantId,
|
|
26
|
+
SAMPLE_CART_HANDOFF_URL,
|
|
27
|
+
sampleVariantCartPayload,
|
|
25
28
|
workerCartApiPath,
|
|
26
29
|
normalizePurchaseOptions,
|
|
27
30
|
visiblePurchaseOptions,
|
|
@@ -39,6 +42,7 @@ import type {
|
|
|
39
42
|
PurchaseOptionPricingContext,
|
|
40
43
|
} from "@tot/public-runtime";
|
|
41
44
|
import { resolveCartSigning, shopperCheckoutUnavailableHtml } from "@/lib/checkout/shared/checkoutCommerce";
|
|
45
|
+
import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
|
|
42
46
|
import { isRegulatedTenant } from "@/lib/compliance/enforcement";
|
|
43
47
|
import { resolveMemberContext } from "@/lib/membership/eligibility";
|
|
44
48
|
import { truncate } from "@/lib/content/shared/format";
|
|
@@ -233,11 +237,12 @@ let checkoutCart:
|
|
|
233
237
|
}
|
|
234
238
|
| undefined;
|
|
235
239
|
let checkoutUnavailable = false;
|
|
236
|
-
|
|
240
|
+
const sampleHandoff = isLocalSampleCheckout(tenant.tenant_id);
|
|
241
|
+
if (commerce || sampleHandoff) {
|
|
237
242
|
// Fail closed for production signed checkout: a signed tenant with no
|
|
238
243
|
// cart-validation secret builds NO cart (never a tamperable form). See
|
|
239
244
|
// resolveCartSigning — it also logs the misconfiguration loudly.
|
|
240
|
-
const signing = await resolveCartSigning(commerce);
|
|
245
|
+
const signing = sampleHandoff ? { enabled: true, secret: undefined } : await resolveCartSigning(commerce!);
|
|
241
246
|
if (!signing.enabled) {
|
|
242
247
|
checkoutUnavailable = true;
|
|
243
248
|
} else {
|
|
@@ -253,13 +258,13 @@ if (commerce) {
|
|
|
253
258
|
purchaseOptions.map(async (purchaseOption) => {
|
|
254
259
|
return [
|
|
255
260
|
purchaseOption.id,
|
|
256
|
-
await buildMemberSignedCartPayload(product, v, {
|
|
257
|
-
mode: commerce
|
|
261
|
+
sampleHandoff ? sampleVariantCartPayload(product.handle, v.id) : await buildMemberSignedCartPayload(product, v, {
|
|
262
|
+
mode: commerce!.mode,
|
|
258
263
|
secret: signing.secret,
|
|
259
264
|
purchaseOption,
|
|
260
265
|
pricing: pricingCtx,
|
|
261
266
|
autopilotEnabled,
|
|
262
|
-
tenantId: tenant
|
|
267
|
+
tenantId: commerceTenantId(tenant),
|
|
263
268
|
}),
|
|
264
269
|
] as const;
|
|
265
270
|
}),
|
|
@@ -269,7 +274,7 @@ if (commerce) {
|
|
|
269
274
|
),
|
|
270
275
|
);
|
|
271
276
|
checkoutCart = {
|
|
272
|
-
action: cartApiPath,
|
|
277
|
+
action: sampleHandoff ? SAMPLE_CART_HANDOFF_URL : cartApiPath,
|
|
273
278
|
purchaseOptions,
|
|
274
279
|
byVariantId: Object.fromEntries(entries),
|
|
275
280
|
};
|
|
@@ -415,13 +420,13 @@ const structuredData = {
|
|
|
415
420
|
productTitle={product.title}
|
|
416
421
|
checkoutCart={checkoutCart}
|
|
417
422
|
pricing={pricing}
|
|
418
|
-
handoffUrl={cartApiPath}
|
|
423
|
+
handoffUrl={sampleHandoff ? undefined : cartApiPath}
|
|
419
424
|
/>
|
|
420
425
|
{/* Regulated-tenant checkout ENFORCEMENT gate. Intercepts the
|
|
421
426
|
managed-cart handoff to clear age + ship-to compliance server-side
|
|
422
427
|
before the external checkout. Only mounts when a store is wired AND
|
|
423
428
|
the tenant is regulated. */}
|
|
424
|
-
{checkoutCart && isRegulatedTenant(tenant) && compliance?.minAge && (
|
|
429
|
+
{checkoutCart && !sampleHandoff && isRegulatedTenant(tenant) && compliance?.minAge && (
|
|
425
430
|
<CheckoutComplianceGate
|
|
426
431
|
client:load
|
|
427
432
|
preflightUrl={withBase(apiBasePath, "/api/compliance/preflight")}
|
|
@@ -22,6 +22,12 @@ interface HarnessEnv {
|
|
|
22
22
|
|
|
23
23
|
type TenantPipelineStub = {
|
|
24
24
|
[K in
|
|
25
|
+
| "holdCommerce"
|
|
26
|
+
| "resumeCommerce"
|
|
27
|
+
| "commerceRaceReceive"
|
|
28
|
+
| "commerceRaceWakeups"
|
|
29
|
+
| "scheduleCommerce"
|
|
30
|
+
| "commerceRuns"
|
|
25
31
|
| "submit"
|
|
26
32
|
| "deliverExternal"
|
|
27
33
|
| "job"
|
|
@@ -80,6 +80,35 @@ function harnessSeedServices(
|
|
|
80
80
|
const matrices = new WeakMap<DurableObjectState, { faults: MatrixFaults; registry: JobKindRegistry }>();
|
|
81
81
|
|
|
82
82
|
export class TenantPipeline extends DeployedTenantPipeline {
|
|
83
|
+
private commercePaused = false;
|
|
84
|
+
private commerceResume: (() => void) | null = null;
|
|
85
|
+
private commerceOutcome: string = "complete";
|
|
86
|
+
async holdCommerce(outcome = "complete"): Promise<void> { this.commercePaused = true; this.commerceOutcome = outcome; }
|
|
87
|
+
async resumeCommerce(): Promise<void> {
|
|
88
|
+
this.commercePaused = false; this.commerceOutcome = "complete";
|
|
89
|
+
this.commerceResume?.(); this.commerceResume = null;
|
|
90
|
+
}
|
|
91
|
+
async commerceRaceReceive(tenantId: string): Promise<void> {
|
|
92
|
+
return this.ctx.blockConcurrencyWhile(async () => { await this.scheduleCommerce(tenantId, Date.now() + 2000); });
|
|
93
|
+
}
|
|
94
|
+
async commerceRaceWakeups(): Promise<number> {
|
|
95
|
+
return [...this.ctx.storage.sql.exec<{count:number}>(`SELECT COUNT(*) count FROM commerce_wakeup`)][0]?.count ?? 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
protected override async runCommerce(tenantId:string,_now:string):Promise<number|null> {
|
|
99
|
+
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS commerce_probe(tenant_id TEXT PRIMARY KEY,runs INTEGER NOT NULL)`);
|
|
100
|
+
this.ctx.storage.sql.exec(`INSERT INTO commerce_probe VALUES(?,1) ON CONFLICT(tenant_id) DO UPDATE SET runs=runs+1`,tenantId);
|
|
101
|
+
const outcome = this.commerceOutcome;
|
|
102
|
+
if (this.commercePaused) await new Promise<void>(resolve => { this.commerceResume = resolve; });
|
|
103
|
+
if (outcome === "error") throw new Error("delivery-unavailable");
|
|
104
|
+
if (outcome === "retry") return Date.now() + 60_000;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
async commerceRuns():Promise<number> {
|
|
108
|
+
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS commerce_probe(tenant_id TEXT PRIMARY KEY,runs INTEGER NOT NULL)`);
|
|
109
|
+
return [...this.ctx.storage.sql.exec<{runs:number}>(`SELECT SUM(runs) runs FROM commerce_probe`)][0]?.runs ?? 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
83
112
|
protected override jobKinds(ctx: DurableObjectState): JobKindRegistry {
|
|
84
113
|
const faults = new MatrixFaults(ctx, () => publishWorldOf(ctx).kv);
|
|
85
114
|
const services = previewServicesWithEffects(fakePreviewServices(ctx, faults.landed), faults.landed);
|