@tokenoftrust/storefront-runner 2.4.48 → 2.4.50
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/0016_scrappy_commerce_contract.sql +166 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +16 -20
- package/apps/storefront/src/lib/checkoutCommerce.ts +8 -2
- package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +79 -1
- package/apps/storefront/src/pages/products/[handle].astro +48 -21
- package/package.json +1 -1
- package/packages/public-runtime/fixtures/scrappy/orders-v1.json +847 -0
- package/packages/public-runtime/src/checkout-orders.ts +602 -16
- package/packages/public-runtime/src/checkout.ts +272 -6
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
-- Commerce orders, inbox/outbox, allocations, label purchases, and shipment audit.
|
|
2
|
+
-- Tenant and stage are on every row. Money is integer minor units.
|
|
3
|
+
-- The SQL directory is the apply set. One transaction per file.
|
|
4
|
+
CREATE TABLE `scrappy_commerce_orders` (
|
|
5
|
+
`tenant_id` text NOT NULL,
|
|
6
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
7
|
+
`order_id` text NOT NULL,
|
|
8
|
+
`source` text NOT NULL,
|
|
9
|
+
`source_order_id` text NOT NULL,
|
|
10
|
+
`currency` text NOT NULL,
|
|
11
|
+
`placed_at` text NOT NULL,
|
|
12
|
+
`payment_state` text NOT NULL CHECK (payment_state IN ('UNKNOWN', 'pending', 'authorized', 'captured', 'capture_unknown', 'voided', 'refund_requested', 'refunded', 'refund_unknown')),
|
|
13
|
+
`compliance_state` text NOT NULL CHECK (compliance_state IN ('UNKNOWN', 'pending', 'fresh', 'stale', 'unreadable', 'refused')),
|
|
14
|
+
`stock_state` text NOT NULL CHECK (stock_state IN ('UNKNOWN', 'unallocated', 'reserved', 'confirmed', 'released', 'expired')),
|
|
15
|
+
`fulfillment_state` text NOT NULL CHECK (fulfillment_state IN ('UNKNOWN', 'held', 'ready', 'handed_off', 'cancelled')),
|
|
16
|
+
`label_state` text NOT NULL CHECK (label_state IN ('UNKNOWN', 'none', 'intent', 'purchased', 'printed', 'void_requested', 'voided')),
|
|
17
|
+
`amount_minor` integer NOT NULL CHECK (amount_minor >= 0),
|
|
18
|
+
`event_version` integer NOT NULL CHECK (event_version >= 1),
|
|
19
|
+
PRIMARY KEY (`tenant_id`, `stage`, `order_id`)
|
|
20
|
+
);
|
|
21
|
+
--> statement-breakpoint
|
|
22
|
+
CREATE UNIQUE INDEX `uq_scrappy_commerce_source_order`
|
|
23
|
+
ON `scrappy_commerce_orders` (`tenant_id`, `stage`, `source`, `source_order_id`);
|
|
24
|
+
--> statement-breakpoint
|
|
25
|
+
CREATE TABLE `scrappy_order_addresses` (
|
|
26
|
+
`tenant_id` text NOT NULL,
|
|
27
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
28
|
+
`order_id` text NOT NULL,
|
|
29
|
+
`address_json` text NOT NULL,
|
|
30
|
+
PRIMARY KEY (`tenant_id`, `stage`, `order_id`),
|
|
31
|
+
FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
|
|
32
|
+
);
|
|
33
|
+
--> statement-breakpoint
|
|
34
|
+
CREATE TABLE `scrappy_commerce_inbox` (
|
|
35
|
+
`tenant_id` text NOT NULL,
|
|
36
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
37
|
+
`source` text NOT NULL,
|
|
38
|
+
`source_event_id` text NOT NULL,
|
|
39
|
+
`event_version` integer NOT NULL CHECK (event_version >= 1),
|
|
40
|
+
`idempotency_key` text NOT NULL,
|
|
41
|
+
`payload_digest` text NOT NULL,
|
|
42
|
+
`received_at` text NOT NULL,
|
|
43
|
+
PRIMARY KEY (`tenant_id`, `stage`, `source`, `source_event_id`)
|
|
44
|
+
);
|
|
45
|
+
--> statement-breakpoint
|
|
46
|
+
CREATE UNIQUE INDEX `uq_scrappy_inbox_idempotency`
|
|
47
|
+
ON `scrappy_commerce_inbox` (`tenant_id`, `stage`, `idempotency_key`);
|
|
48
|
+
--> statement-breakpoint
|
|
49
|
+
CREATE TABLE `scrappy_commerce_outbox` (
|
|
50
|
+
`outbox_id` text NOT NULL,
|
|
51
|
+
`tenant_id` text NOT NULL,
|
|
52
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
53
|
+
`order_id` text NOT NULL,
|
|
54
|
+
`source` text NOT NULL,
|
|
55
|
+
`source_event_id` text NOT NULL,
|
|
56
|
+
`event_version` integer NOT NULL CHECK (event_version >= 1),
|
|
57
|
+
`event_type` text NOT NULL,
|
|
58
|
+
`payload` text NOT NULL,
|
|
59
|
+
`available_at` text NOT NULL,
|
|
60
|
+
`attempt_count` integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
61
|
+
`state` text NOT NULL CHECK (state IN ('pending', 'delivered', 'dead')),
|
|
62
|
+
PRIMARY KEY (`outbox_id`),
|
|
63
|
+
UNIQUE (`tenant_id`, `stage`, `source`, `source_event_id`)
|
|
64
|
+
);
|
|
65
|
+
--> statement-breakpoint
|
|
66
|
+
CREATE INDEX `idx_scrappy_outbox_due`
|
|
67
|
+
ON `scrappy_commerce_outbox` (`state`, `available_at`);
|
|
68
|
+
--> statement-breakpoint
|
|
69
|
+
CREATE TABLE `scrappy_allocations` (
|
|
70
|
+
`tenant_id` text NOT NULL,
|
|
71
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
72
|
+
`order_id` text NOT NULL,
|
|
73
|
+
`sku` text NOT NULL,
|
|
74
|
+
`location_id` text NOT NULL,
|
|
75
|
+
`quantity` integer NOT NULL CHECK (quantity > 0),
|
|
76
|
+
`state` text NOT NULL CHECK (state IN ('reserved', 'confirmed', 'released', 'expired')),
|
|
77
|
+
PRIMARY KEY (`tenant_id`, `stage`, `order_id`, `sku`, `location_id`),
|
|
78
|
+
FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
|
|
79
|
+
);
|
|
80
|
+
--> statement-breakpoint
|
|
81
|
+
CREATE TABLE `scrappy_final_cart_authorizations` (
|
|
82
|
+
`tenant_id` text NOT NULL,
|
|
83
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
84
|
+
`authorization_id` text NOT NULL,
|
|
85
|
+
`cart_digest` text NOT NULL,
|
|
86
|
+
`expires_at` text NOT NULL,
|
|
87
|
+
`state` text NOT NULL CHECK (state IN ('active', 'consumed', 'expired', 'denied')),
|
|
88
|
+
PRIMARY KEY (`tenant_id`, `stage`, `authorization_id`)
|
|
89
|
+
);
|
|
90
|
+
--> statement-breakpoint
|
|
91
|
+
CREATE UNIQUE INDEX `uq_scrappy_active_final_cart`
|
|
92
|
+
ON `scrappy_final_cart_authorizations` (`tenant_id`, `stage`, `cart_digest`)
|
|
93
|
+
WHERE `state` = 'active';
|
|
94
|
+
--> statement-breakpoint
|
|
95
|
+
CREATE TABLE `scrappy_provider_operations` (
|
|
96
|
+
`tenant_id` text NOT NULL,
|
|
97
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
98
|
+
`provider` text NOT NULL,
|
|
99
|
+
`purchase_id` text NOT NULL,
|
|
100
|
+
`order_id` text NOT NULL,
|
|
101
|
+
`kind` text NOT NULL CHECK (kind IN ('label_purchase', 'payment_capture', 'refund')),
|
|
102
|
+
`outcome` text NOT NULL CHECK (outcome IN ('intent', 'accepted', 'unknown', 'voided', 'refunded')),
|
|
103
|
+
`amount_minor` integer NOT NULL CHECK (amount_minor >= 0),
|
|
104
|
+
PRIMARY KEY (`tenant_id`, `stage`, `provider`, `purchase_id`),
|
|
105
|
+
FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
|
|
106
|
+
);
|
|
107
|
+
--> statement-breakpoint
|
|
108
|
+
CREATE UNIQUE INDEX `uq_scrappy_open_label_purchase`
|
|
109
|
+
ON `scrappy_provider_operations` (`tenant_id`, `stage`, `order_id`)
|
|
110
|
+
WHERE `kind` = 'label_purchase' AND `outcome` IN ('intent', 'accepted', 'unknown');
|
|
111
|
+
--> statement-breakpoint
|
|
112
|
+
CREATE TABLE `scrappy_shipments` (
|
|
113
|
+
`tenant_id` text NOT NULL,
|
|
114
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
115
|
+
`order_id` text NOT NULL,
|
|
116
|
+
`scope` text NOT NULL CHECK (scope = 'whole-order'),
|
|
117
|
+
`label_purchase_state` text NOT NULL CHECK (label_purchase_state IN ('none', 'intent', 'purchased', 'void_requested', 'voided', 'unknown')),
|
|
118
|
+
`label_print_state` text NOT NULL CHECK (label_print_state IN ('none', 'printed')),
|
|
119
|
+
`handoff_state` text NOT NULL CHECK (handoff_state IN ('none', 'handed_off', 'unknown')),
|
|
120
|
+
CHECK (handoff_state = 'none' OR label_purchase_state = 'purchased'),
|
|
121
|
+
CHECK (label_print_state = 'none' OR label_purchase_state = 'purchased'),
|
|
122
|
+
PRIMARY KEY (`tenant_id`, `stage`, `order_id`),
|
|
123
|
+
FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
|
|
124
|
+
);
|
|
125
|
+
--> statement-breakpoint
|
|
126
|
+
CREATE TABLE `scrappy_shipment_audit` (
|
|
127
|
+
`audit_id` text NOT NULL,
|
|
128
|
+
`tenant_id` text NOT NULL,
|
|
129
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
130
|
+
`order_id` text NOT NULL,
|
|
131
|
+
`at` text NOT NULL,
|
|
132
|
+
`actor_id` text NOT NULL,
|
|
133
|
+
`kind` text NOT NULL,
|
|
134
|
+
`from_state` text NOT NULL,
|
|
135
|
+
`to_state` text NOT NULL,
|
|
136
|
+
PRIMARY KEY (`audit_id`),
|
|
137
|
+
FOREIGN KEY (`tenant_id`, `stage`, `order_id`) REFERENCES `scrappy_commerce_orders` (`tenant_id`, `stage`, `order_id`)
|
|
138
|
+
);
|
|
139
|
+
--> statement-breakpoint
|
|
140
|
+
CREATE TRIGGER `scrappy_shipment_audit_no_update`
|
|
141
|
+
BEFORE UPDATE ON `scrappy_shipment_audit`
|
|
142
|
+
BEGIN
|
|
143
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
144
|
+
END;
|
|
145
|
+
--> statement-breakpoint
|
|
146
|
+
CREATE TRIGGER `scrappy_shipment_audit_no_delete`
|
|
147
|
+
BEFORE DELETE ON `scrappy_shipment_audit`
|
|
148
|
+
BEGIN
|
|
149
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
150
|
+
END;
|
|
151
|
+
--> statement-breakpoint
|
|
152
|
+
CREATE TABLE `scrappy_caller_replay` (
|
|
153
|
+
`tenant_id` text NOT NULL,
|
|
154
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
155
|
+
`issuer` text NOT NULL,
|
|
156
|
+
`jti` text NOT NULL,
|
|
157
|
+
`seen_at` text NOT NULL,
|
|
158
|
+
PRIMARY KEY (`tenant_id`, `stage`, `issuer`, `jti`)
|
|
159
|
+
);
|
|
160
|
+
--> statement-breakpoint
|
|
161
|
+
CREATE TABLE `scrappy_sweep_cursor` (
|
|
162
|
+
`tenant_id` text NOT NULL,
|
|
163
|
+
`stage` text NOT NULL CHECK (stage IN ('preview', 'publication')),
|
|
164
|
+
`cursor` text NOT NULL,
|
|
165
|
+
PRIMARY KEY (`tenant_id`, `stage`)
|
|
166
|
+
);
|
|
@@ -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
|
}
|
|
@@ -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.
|
|
@@ -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.50",
|
|
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",
|