@tokenoftrust/storefront-runner 1.3.2 → 1.3.4-rc.2
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/astro.config.mjs +59 -0
- package/apps/storefront/env.d.ts +24 -8
- package/apps/storefront/package.json +1 -1
- package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
- package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
- package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
- package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
- package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
- package/apps/storefront/src/config/devTenantSeed.ts +65 -16
- package/apps/storefront/src/config/resolver.ts +48 -1
- package/apps/storefront/src/layouts/Layout.astro +14 -12
- package/apps/storefront/src/lib/admin/README.md +2 -2
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
- package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
- package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
- package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
- package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
- package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
- package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
- package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
- package/apps/storefront/src/lib/dev/runtimeStore.ts +193 -22
- package/apps/storefront/src/lib/edgeCache.ts +7 -6
- package/apps/storefront/src/lib/health.ts +53 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
- package/apps/storefront/src/lib/sections.ts +19 -23
- package/apps/storefront/src/lib/securityHeaders.ts +1 -1
- package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
- package/apps/storefront/src/middleware/index.ts +29 -12
- package/apps/storefront/src/pages/[...slug].astro +5 -6
- package/apps/storefront/src/pages/admin.astro +10 -3
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
- package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
- package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
- package/apps/storefront/src/pages/auth/login.astro +4 -1
- package/apps/storefront/src/pages/auth/magic.astro +263 -43
- package/apps/storefront/src/pages/capabilities.astro +1 -1
- package/apps/storefront/src/pages/cockpit.astro +2365 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
- package/apps/storefront/src/pages/index.astro +23 -29
- package/apps/storefront/src/pages/products/[handle].astro +50 -20
- package/package.json +6 -2
- package/packages/public-runtime/src/capabilities.ts +97 -9
- package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
- package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
- package/packages/public-runtime/src/checkout-style.ts +13 -13
- package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
- package/packages/public-runtime/src/csp.ts +11 -11
- package/packages/public-runtime/src/customization-scripts.ts +28 -0
- package/packages/public-runtime/src/extension-contract.ts +73 -0
- package/packages/public-runtime/src/index.ts +4 -2
- package/packages/public-runtime/src/product.ts +57 -0
- package/packages/public-runtime/src/raw-html-policy.ts +35 -0
- package/packages/public-runtime/src/tenant.ts +14 -11
- package/pnpm-workspace.yaml +1 -0
- package/scripts/dev/checkout-watch.mjs +9 -0
- package/scripts/dev/file-browser.mjs +5 -1
- package/scripts/dev/transient-files.mjs +34 -0
- package/scripts/tot-dev.mjs +16 -3
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
- package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
- package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
- package/apps/storefront/src/pages/api/dev/file.ts +0 -57
- package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
- package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
- package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
- package/apps/storefront/src/pages/api/dev/write.ts +0 -116
|
@@ -14,10 +14,22 @@ export const prerender = false;
|
|
|
14
14
|
|
|
15
15
|
import { readViewerSession } from "@/lib/auth/route";
|
|
16
16
|
import { membershipFor } from "@/lib/dashboard/tenantSelection";
|
|
17
|
+
import { readEnv } from "@/lib/env";
|
|
17
18
|
|
|
18
19
|
const cspNonce = Astro.locals.cspNonce;
|
|
19
20
|
const appDomain = decodeURIComponent(Astro.params.appDomain ?? "");
|
|
20
21
|
|
|
22
|
+
// qa-tier warn: derived from the SAME MCP origin the invite preflight uses
|
|
23
|
+
// (MCP_APP_BINDING_URL). We don't want people onboarding developers via qa now that
|
|
24
|
+
// prod+qa share data — so warn (non-blocking; qa is legitimate for testing).
|
|
25
|
+
let isQaTier = false;
|
|
26
|
+
try {
|
|
27
|
+
const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
|
|
28
|
+
isQaTier = !!bindingUrl && new URL(bindingUrl).hostname.startsWith("mcp.qa.");
|
|
29
|
+
} catch {
|
|
30
|
+
isQaTier = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
const session = await readViewerSession(Astro);
|
|
22
34
|
if (!session) {
|
|
23
35
|
return Astro.redirect(
|
|
@@ -89,6 +101,9 @@ const backHref = `/dashboard/${encodeURIComponent(appDomain)}`;
|
|
|
89
101
|
.msg.ok { background: #16311f; border-color: #2f6b41; color: #a7e0ba; }
|
|
90
102
|
.msg.err { background: #3a1f22; border-color: #6b2f34; color: #f6b9b9; }
|
|
91
103
|
}
|
|
104
|
+
.warn { margin: 0 0 1rem; font-size: .88rem; border-radius: 9px; padding: .6rem .8rem;
|
|
105
|
+
background: #fff7e6; border: 1px solid #f0d999; color: #7a5b12; }
|
|
106
|
+
@media (prefers-color-scheme: dark) { .warn { background: #332a12; border-color: #6b5a2f; color: #e8d39a; } }
|
|
92
107
|
.muted { color: #9a9a9a; font-size: .85rem; }
|
|
93
108
|
.who { margin-top: 2rem; font-size: .82rem; color: #8a8a8a; }
|
|
94
109
|
.who a, a.back { color: inherit; }
|
|
@@ -105,6 +120,10 @@ const backHref = `/dashboard/${encodeURIComponent(appDomain)}`;
|
|
|
105
120
|
: " Viewing as Token of Trust staff — invite by email to grant a developer access."}</p>
|
|
106
121
|
|
|
107
122
|
<h2>Invite a developer</h2>
|
|
123
|
+
{isQaTier && (
|
|
124
|
+
<p class="warn">⚠ This invite connects the developer to the <strong>QA</strong> environment
|
|
125
|
+
— likely not what you want.</p>
|
|
126
|
+
)}
|
|
108
127
|
<div class="card">
|
|
109
128
|
<label for="email">Developer email</label>
|
|
110
129
|
<input id="email" type="email" placeholder="dev@agency.com" autocomplete="off" />
|
|
@@ -14,7 +14,7 @@ import CollectionCard from "@/components/CollectionCard.astro";
|
|
|
14
14
|
import Img from "@/components/Img.astro";
|
|
15
15
|
import MarketingBlocks from "@/components/marketing/MarketingBlocks.astro";
|
|
16
16
|
import { withBase } from "@/lib/basePath";
|
|
17
|
-
import { rawHtmlResponse } from "@/lib/rawMarketingHtml";
|
|
17
|
+
import { rawHtmlPolicy, rawHtmlResponse } from "@/lib/rawMarketingHtml";
|
|
18
18
|
import type { CatalogCollection, CatalogProduct } from "@tot/public-runtime";
|
|
19
19
|
import {
|
|
20
20
|
isMarketingBlock,
|
|
@@ -23,7 +23,12 @@ import {
|
|
|
23
23
|
type NewsletterBlock,
|
|
24
24
|
type PromoTilesBlock,
|
|
25
25
|
type MarketingBlock,
|
|
26
|
+
type FeaturedProductsBlock,
|
|
26
27
|
} from "@/lib/storyblok/content-model";
|
|
28
|
+
import {
|
|
29
|
+
normalizeProductSectionSource,
|
|
30
|
+
} from "@tot/public-runtime";
|
|
31
|
+
import { resolveSectionSource } from "@/lib/sections";
|
|
27
32
|
|
|
28
33
|
const { tenant, tot, content, basePath, features } = Astro.locals;
|
|
29
34
|
const marketing = !features.catalog;
|
|
@@ -37,22 +42,17 @@ const marketing = !features.catalog;
|
|
|
37
42
|
// OpenGraph/JSON-LD come from the document's own <head>. The homepage `/` is in
|
|
38
43
|
// the marketing sitemap (sitemap.xml.ts).
|
|
39
44
|
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// body-only fragment, rawHtmlResponse splices it in; a full document serves
|
|
52
|
-
// verbatim (backward-compatible).
|
|
53
|
-
const chromeWrapper = await content.getChromeHtml();
|
|
54
|
-
return rawHtmlResponse(rawHtml, { nonce: Astro.locals.cspNonce, basePath, chromeWrapper });
|
|
55
|
-
}
|
|
45
|
+
// Allowed only when rawHtmlPolicy says the tenant can safely bypass Layout.
|
|
46
|
+
// Regulated tenants fall through to the block-composed home so platform
|
|
47
|
+
// age/identity, warning, tax, and checkout guardrails remain non-removable.
|
|
48
|
+
// Mirrors the raw-subpage path in [...slug].astro.
|
|
49
|
+
const rawHtml = await content.getHomeHtml();
|
|
50
|
+
if (rawHtml && rawHtmlPolicy(tenant).allowed) {
|
|
51
|
+
// Shared chrome: if the tenant ships a chrome.html wrapper AND this home is a
|
|
52
|
+
// body-only fragment, rawHtmlResponse splices it in; a full document serves
|
|
53
|
+
// verbatim (backward-compatible).
|
|
54
|
+
const chromeWrapper = await content.getChromeHtml();
|
|
55
|
+
return rawHtmlResponse(rawHtml, { nonce: Astro.locals.cspNonce, basePath, chromeWrapper });
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const home = await content.getHome();
|
|
@@ -75,21 +75,15 @@ type Resolved =
|
|
|
75
75
|
| { kind: "newsletter"; block: NewsletterBlock };
|
|
76
76
|
|
|
77
77
|
async function resolveProducts(
|
|
78
|
-
source: "
|
|
78
|
+
source: FeaturedProductsBlock["source"],
|
|
79
79
|
handles: string[] | undefined,
|
|
80
80
|
limit: number,
|
|
81
81
|
): Promise<CatalogProduct[]> {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
if (source === "newest") {
|
|
89
|
-
const { products } = await tot.listProducts({ sort: "newest", pageSize: limit });
|
|
90
|
-
return products;
|
|
91
|
-
}
|
|
92
|
-
return tot.getFeatured(limit);
|
|
82
|
+
return resolveSectionSource(
|
|
83
|
+
normalizeProductSectionSource(source, handles),
|
|
84
|
+
tot,
|
|
85
|
+
limit,
|
|
86
|
+
);
|
|
93
87
|
}
|
|
94
88
|
|
|
95
89
|
const resolved: Resolved[] = [];
|
|
@@ -17,9 +17,15 @@ import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
|
|
|
17
17
|
import ReviewSummary from "@/components/commerce/ReviewSummary.astro";
|
|
18
18
|
import ReviewList from "@/components/commerce/ReviewList.astro";
|
|
19
19
|
import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/tot/query";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
import {
|
|
21
|
+
buildVariantCartPayload,
|
|
22
|
+
normalizePurchaseOptions,
|
|
23
|
+
} from "@tot/public-runtime";
|
|
24
|
+
import type {
|
|
25
|
+
ProductPurchaseOption,
|
|
26
|
+
VariantCartPayload,
|
|
27
|
+
} from "@tot/public-runtime";
|
|
28
|
+
import { readCartSecret } from "@/lib/checkoutCommerce";
|
|
23
29
|
import { truncate } from "@/lib/format";
|
|
24
30
|
import { productLd, breadcrumbLd, reviewLd } from "@/lib/jsonld";
|
|
25
31
|
import { isUsableImageUrl } from "@/lib/placeholder";
|
|
@@ -138,29 +144,53 @@ const reviews = await tot.getReviews(product.handle);
|
|
|
138
144
|
const onSale = isOnSale(product);
|
|
139
145
|
const available = isAvailable(product);
|
|
140
146
|
|
|
141
|
-
//
|
|
147
|
+
// Managed add-to-cart (Track A). Pre-build each variant's cart payload server-
|
|
142
148
|
// side so the island can render a signed, tamper-proof form. In `signed` mode a
|
|
143
149
|
// missing secret means we render NO cart (never a tamperable form); the
|
|
144
150
|
// `unsigned-demo` mode needs no secret. Drafts still get a cart (preview links).
|
|
145
|
-
let
|
|
146
|
-
| {
|
|
151
|
+
let checkoutCart:
|
|
152
|
+
| {
|
|
153
|
+
action: string;
|
|
154
|
+
purchaseOptions: ProductPurchaseOption[];
|
|
155
|
+
byVariantId: Record<string, Record<string, VariantCartPayload>>;
|
|
156
|
+
}
|
|
147
157
|
| undefined;
|
|
148
158
|
if (commerce) {
|
|
149
159
|
const secret = await readCartSecret(commerce);
|
|
150
160
|
if (commerce.mode !== "signed" || secret) {
|
|
151
|
-
|
|
152
|
-
product
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
try {
|
|
162
|
+
const purchaseOptions = normalizePurchaseOptions(product);
|
|
163
|
+
const entries = await Promise.all(
|
|
164
|
+
product.variants.map(
|
|
165
|
+
async (v) => {
|
|
166
|
+
const payloads = await Promise.all(
|
|
167
|
+
purchaseOptions.map(
|
|
168
|
+
async (purchaseOption) =>
|
|
169
|
+
[
|
|
170
|
+
purchaseOption.id,
|
|
171
|
+
await buildVariantCartPayload(product, v, {
|
|
172
|
+
mode: commerce.mode,
|
|
173
|
+
secret,
|
|
174
|
+
purchaseOption,
|
|
175
|
+
}),
|
|
176
|
+
] as const,
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
return [v.id, Object.fromEntries(payloads)] as const;
|
|
180
|
+
},
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
checkoutCart = {
|
|
184
|
+
action: commerce.cartUrl,
|
|
185
|
+
purchaseOptions,
|
|
186
|
+
byVariantId: Object.fromEntries(entries),
|
|
187
|
+
};
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.warn(
|
|
190
|
+
`[storefront] Invalid checkout purchase options for ${product.handle}; cart disabled for this product.`,
|
|
191
|
+
err,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
164
194
|
}
|
|
165
195
|
}
|
|
166
196
|
|
|
@@ -268,7 +298,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
268
298
|
options={product.options}
|
|
269
299
|
currency={product.price_range.currency}
|
|
270
300
|
locale={tenant.locale}
|
|
271
|
-
|
|
301
|
+
checkoutCart={checkoutCart}
|
|
272
302
|
/>
|
|
273
303
|
<button
|
|
274
304
|
type="button"
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "1.3.2",
|
|
3
|
+
"version": "1.3.4-rc.2",
|
|
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",
|
|
7
7
|
"engines": {
|
|
8
|
-
"node": ">=
|
|
8
|
+
"node": ">=22.12.0"
|
|
9
9
|
},
|
|
10
|
+
"workspaces": [
|
|
11
|
+
"apps/*",
|
|
12
|
+
"packages/*"
|
|
13
|
+
],
|
|
10
14
|
"scripts": {
|
|
11
15
|
"dev": "pnpm --filter @tot/storefront-runner dev",
|
|
12
16
|
"build": "pnpm --filter @tot/storefront-runner build",
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tenant capability switches — config that travels with the PR (Track A/B).
|
|
3
3
|
*
|
|
4
|
-
* Three
|
|
5
|
-
* - cartCheckout —
|
|
4
|
+
* Three capability states:
|
|
5
|
+
* - cartCheckout — managed cart/checkout (Track A)
|
|
6
6
|
* - ageVerification — Token of Trust age/identity gate (21+ regulated)
|
|
7
|
-
* - exciseTax — excise tax collection at checkout (Track B,
|
|
7
|
+
* - exciseTax — excise tax collection at checkout (Track B, commerce service)
|
|
8
|
+
*
|
|
9
|
+
* Important: these are configurable surfaces, not compliance obligations. A
|
|
10
|
+
* tenant repo may request that a surface turn off, but `resolveCapabilities`
|
|
11
|
+
* reapplies the compliance floor after every override. Developers can disable
|
|
12
|
+
* optional commerce affordances; they cannot disable required age/identity or
|
|
13
|
+
* excise-tax enforcement for regulated tenants.
|
|
8
14
|
*
|
|
9
15
|
* The source of truth is the tenant repo's `.tot/config.json` `capabilities`
|
|
10
16
|
* block — so a change is a code change that travels with a PR. Resolution is
|
|
@@ -23,16 +29,28 @@ export interface CapabilityState {
|
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export interface CapabilityConfig {
|
|
26
|
-
/**
|
|
32
|
+
/** Managed cart/checkout: loader + signed add-to-cart. */
|
|
27
33
|
cartCheckout: CapabilityState;
|
|
28
34
|
/** Token of Trust age/identity verification (soft 21+ gate now; full ToT later). */
|
|
29
35
|
ageVerification: CapabilityState;
|
|
30
|
-
/** Excise tax collection at checkout (materialized in the
|
|
36
|
+
/** Excise tax collection at checkout (materialized in the managed cart). */
|
|
31
37
|
exciseTax: CapabilityState;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export type CapabilityKey = keyof CapabilityConfig;
|
|
35
41
|
|
|
42
|
+
export interface CapabilityObligation {
|
|
43
|
+
required: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type CapabilityObligations = Record<CapabilityKey, CapabilityObligation>;
|
|
48
|
+
|
|
49
|
+
export interface CapabilityFloorViolation {
|
|
50
|
+
capability: CapabilityKey;
|
|
51
|
+
reason: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
/** Stable capability order + labels for any surface that lists them. */
|
|
37
55
|
export const CAPABILITY_KEYS: CapabilityKey[] = [
|
|
38
56
|
"cartCheckout",
|
|
@@ -49,6 +67,58 @@ export function capabilitiesArtifactPath(tenantId: string): string {
|
|
|
49
67
|
return `tenants/${tenantId}/capabilities.json`;
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the non-negotiable compliance floor for a tenant. This is intentionally
|
|
72
|
+
* narrower than feature defaults: it says only what MUST remain on for the site
|
|
73
|
+
* to be publishable.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveComplianceObligations(
|
|
76
|
+
tenant: Pick<TenantConfig, "compliance" | "siteType">,
|
|
77
|
+
): CapabilityObligations {
|
|
78
|
+
return {
|
|
79
|
+
cartCheckout: { required: false },
|
|
80
|
+
ageVerification: tenant.compliance?.minAge
|
|
81
|
+
? {
|
|
82
|
+
required: true,
|
|
83
|
+
reason: `Tenant requires age/identity verification for ${tenant.compliance.minAge}+ access.`,
|
|
84
|
+
}
|
|
85
|
+
: { required: false },
|
|
86
|
+
exciseTax: tenant.compliance?.nicotineWarning
|
|
87
|
+
? {
|
|
88
|
+
required: true,
|
|
89
|
+
reason:
|
|
90
|
+
"Tenant sells nicotine/vape products and must keep excise-tax collection enabled at checkout.",
|
|
91
|
+
}
|
|
92
|
+
: { required: false },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find attempted overrides that weaken the compliance floor. This is used by
|
|
98
|
+
* validators to fail a publish early; `resolveCapabilities` also enforces the
|
|
99
|
+
* same floor at runtime as defense in depth.
|
|
100
|
+
*/
|
|
101
|
+
export function capabilityFloorViolations(
|
|
102
|
+
tenant: Pick<TenantConfig, "compliance" | "siteType">,
|
|
103
|
+
requested?: Partial<CapabilityConfig> | null,
|
|
104
|
+
): CapabilityFloorViolation[] {
|
|
105
|
+
if (!requested) return [];
|
|
106
|
+
const obligations = resolveComplianceObligations(tenant);
|
|
107
|
+
const out: CapabilityFloorViolation[] = [];
|
|
108
|
+
for (const key of CAPABILITY_KEYS) {
|
|
109
|
+
if (
|
|
110
|
+
obligations[key].required &&
|
|
111
|
+
requested[key]?.enabled === false
|
|
112
|
+
) {
|
|
113
|
+
out.push({
|
|
114
|
+
capability: key,
|
|
115
|
+
reason: obligations[key].reason ?? `${key} is required for this tenant.`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
52
122
|
/**
|
|
53
123
|
* Resolve a tenant's effective capabilities: build-time `TenantConfig.capabilities`
|
|
54
124
|
* default (itself defaulted from siteType/compliance), then any per-environment
|
|
@@ -74,16 +144,34 @@ export function resolveCapabilities(
|
|
|
74
144
|
enabled: c?.exciseTax?.enabled ?? !!tenant.compliance?.nicotineWarning,
|
|
75
145
|
},
|
|
76
146
|
};
|
|
77
|
-
|
|
147
|
+
const requested = override
|
|
148
|
+
? {
|
|
149
|
+
cartCheckout: {
|
|
150
|
+
enabled: override.cartCheckout?.enabled ?? base.cartCheckout.enabled,
|
|
151
|
+
},
|
|
152
|
+
ageVerification: {
|
|
153
|
+
enabled:
|
|
154
|
+
override.ageVerification?.enabled ?? base.ageVerification.enabled,
|
|
155
|
+
},
|
|
156
|
+
exciseTax: {
|
|
157
|
+
enabled: override.exciseTax?.enabled ?? base.exciseTax.enabled,
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
: base;
|
|
161
|
+
|
|
162
|
+
const obligations = resolveComplianceObligations(tenant);
|
|
78
163
|
return {
|
|
79
164
|
cartCheckout: {
|
|
80
|
-
enabled:
|
|
165
|
+
enabled:
|
|
166
|
+
obligations.cartCheckout.required || requested.cartCheckout.enabled,
|
|
81
167
|
},
|
|
82
168
|
ageVerification: {
|
|
83
|
-
enabled:
|
|
169
|
+
enabled:
|
|
170
|
+
obligations.ageVerification.required ||
|
|
171
|
+
requested.ageVerification.enabled,
|
|
84
172
|
},
|
|
85
173
|
exciseTax: {
|
|
86
|
-
enabled:
|
|
174
|
+
enabled: obligations.exciseTax.required || requested.exciseTax.enabled,
|
|
87
175
|
},
|
|
88
176
|
};
|
|
89
177
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Order READ DTO — the FROZEN v1 contract shape the storefront consumes from
|
|
3
|
-
*
|
|
3
|
+
* the checkout order API's `GET /commerce/orders[/:orderId]` (card 8585 / U1). Kept in
|
|
4
4
|
* `@tot/public-runtime` (types only, no secrets, edge-safe) so the server-side
|
|
5
5
|
* order client (U2), the Orders admin route (U2), and the view-model mapping (U3)
|
|
6
6
|
* all code against one source of truth.
|
|
7
7
|
*
|
|
8
|
-
* Authoritative
|
|
8
|
+
* Authoritative API shape lives with the checkout order service; this file
|
|
9
|
+
* freezes the storefront-facing v1 DTO.
|
|
9
10
|
*
|
|
10
11
|
* READ-ONLY (scope-locks): no write/mutation shapes here. Two fields are
|
|
11
12
|
* ENRICHED-later by U4 (the tot20 identity/age join) and are `null`/`[]` until
|
|
@@ -15,52 +16,52 @@
|
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
/** Payment state of an order. */
|
|
18
|
-
export type
|
|
19
|
+
export type CheckoutOrderPayment = "authorized" | "captured" | "refunded";
|
|
19
20
|
|
|
20
21
|
/** Fulfillment / queue state of an order. */
|
|
21
|
-
export type
|
|
22
|
+
export type CheckoutOrderFulfillment =
|
|
22
23
|
| "unfulfilled"
|
|
23
24
|
| "ready_to_ship"
|
|
24
25
|
| "hold_review_required"
|
|
25
26
|
| "completed";
|
|
26
27
|
|
|
27
|
-
export interface
|
|
28
|
+
export interface CheckoutOrderCustomer {
|
|
28
29
|
/** ToT-core customer id, or null for a guest / unlinked order. */
|
|
29
30
|
id: string | null;
|
|
30
31
|
name: string;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/** A row in the orders list. All fields populated by U1 EXCEPT `verification`. */
|
|
34
|
-
export interface
|
|
35
|
+
export interface CheckoutOrderSummary {
|
|
35
36
|
/** Order / transaction id (string). */
|
|
36
37
|
id: string;
|
|
37
38
|
/** ISO 8601 placed-at timestamp. */
|
|
38
39
|
placedAt: string;
|
|
39
40
|
currency: string;
|
|
40
|
-
customer:
|
|
41
|
+
customer: CheckoutOrderCustomer;
|
|
41
42
|
/** Human one-liner, e.g. "Vape Pen Starter Kit +1 more". */
|
|
42
43
|
itemsSummary: string;
|
|
43
44
|
itemCount: number;
|
|
44
45
|
/** Order total in MAJOR currency units (e.g. dollars). */
|
|
45
46
|
total: number;
|
|
46
|
-
payment:
|
|
47
|
-
fulfillment:
|
|
47
|
+
payment: CheckoutOrderPayment;
|
|
48
|
+
fulfillment: CheckoutOrderFulfillment;
|
|
48
49
|
/** Why the order is held, or null. */
|
|
49
50
|
blocker: string | null;
|
|
50
51
|
/**
|
|
51
52
|
* Identity/age verification summary — ENRICHED by U4 (tot20 join). `null` from
|
|
52
53
|
* U1; consumers render risk as "pending" until it is filled.
|
|
53
54
|
*/
|
|
54
|
-
verification:
|
|
55
|
+
verification: CheckoutOrderVerification | null;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/** U4-filled verification summary (shape reserved; opaque to U1/U2/U3). */
|
|
58
|
-
export interface
|
|
59
|
+
export interface CheckoutOrderVerification {
|
|
59
60
|
riskLevel?: string;
|
|
60
61
|
[k: string]: unknown;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
export interface
|
|
64
|
+
export interface CheckoutOrderLineItem {
|
|
64
65
|
sku: string;
|
|
65
66
|
name: string;
|
|
66
67
|
quantity: number;
|
|
@@ -68,7 +69,7 @@ export interface FoxyOrderLineItem {
|
|
|
68
69
|
unitPrice: number;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
export interface
|
|
72
|
+
export interface CheckoutOrderTotals {
|
|
72
73
|
subtotal: number;
|
|
73
74
|
shipping: number;
|
|
74
75
|
estimatedTax: number;
|
|
@@ -76,7 +77,7 @@ export interface FoxyOrderTotals {
|
|
|
76
77
|
total: number;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
export interface
|
|
80
|
+
export interface CheckoutOrderShippingAddress {
|
|
80
81
|
line1: string;
|
|
81
82
|
line2: string;
|
|
82
83
|
city: string;
|
|
@@ -87,21 +88,21 @@ export interface FoxyOrderShippingAddress {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/** U4-filled per-order verification detail; `readinessChecks`/`evidenceTimeline` empty from U1. */
|
|
90
|
-
export interface
|
|
91
|
+
export interface CheckoutOrderVerificationDetail {
|
|
91
92
|
readinessChecks: unknown[];
|
|
92
93
|
evidenceTimeline: unknown[];
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
/** A single order's full detail (summary + line items, totals, address, verification detail). */
|
|
96
|
-
export interface
|
|
97
|
+
export interface CheckoutOrderDetail extends CheckoutOrderSummary {
|
|
97
98
|
/** The excise line (TOT_EXCISE_TAX) is NOT here — it surfaces in totals.taxesAndFees. */
|
|
98
|
-
lineItems:
|
|
99
|
-
totals:
|
|
100
|
-
shippingAddress:
|
|
101
|
-
verificationDetail:
|
|
99
|
+
lineItems: CheckoutOrderLineItem[];
|
|
100
|
+
totals: CheckoutOrderTotals;
|
|
101
|
+
shippingAddress: CheckoutOrderShippingAddress | null;
|
|
102
|
+
verificationDetail: CheckoutOrderVerificationDetail;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
export interface
|
|
105
|
+
export interface CheckoutOrderPage {
|
|
105
106
|
limit: number;
|
|
106
107
|
cursor: string | null;
|
|
107
108
|
/** Opaque cursor for the next page, or null on the last page. */
|
|
@@ -111,18 +112,18 @@ export interface FoxyOrderPage {
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
/** `GET /commerce/orders` response envelope. */
|
|
114
|
-
export interface
|
|
115
|
-
orders:
|
|
116
|
-
page:
|
|
115
|
+
export interface CheckoutOrderListResponse {
|
|
116
|
+
orders: CheckoutOrderSummary[];
|
|
117
|
+
page: CheckoutOrderPage;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
/** `GET /commerce/orders/:orderId` response envelope. */
|
|
120
|
-
export interface
|
|
121
|
-
order:
|
|
121
|
+
export interface CheckoutOrderDetailResponse {
|
|
122
|
+
order: CheckoutOrderDetail;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
/** Optional list filters (all server-side; see contract §9). */
|
|
125
|
-
export interface
|
|
126
|
+
export interface CheckoutOrderListFilters {
|
|
126
127
|
queue?: string;
|
|
127
128
|
status?: string;
|
|
128
129
|
/** ISO datetime lower bound. */
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Signed storefront →
|
|
2
|
+
* Signed storefront → checkout theme-publish client (U11b, the PUSH half of U11).
|
|
3
3
|
*
|
|
4
|
-
* Points a store's
|
|
5
|
-
* (`GET /api/checkout-style.css`, U11) by driving
|
|
4
|
+
* Points a store's hosted checkout template at the storefront-hosted stylesheet
|
|
5
|
+
* (`GET /api/checkout-style.css`, U11) by driving the commerce service's theme
|
|
6
6
|
* endpoint: PUT the `content_url`, then POST `/refresh` (PUT alone only sets the
|
|
7
|
-
* pointer —
|
|
7
|
+
* pointer — checkout caches the template, so nothing goes live until refresh).
|
|
8
8
|
*
|
|
9
9
|
* AUTH: each request carries an `x-tot-signature` header = lowercase-hex
|
|
10
10
|
* HMAC-SHA256 of `JSON.stringify(body)` keyed by the STORE'S ToT SECRET KEY —
|
|
11
|
-
* byte-identical to
|
|
11
|
+
* byte-identical to the commerce service's webhook-signature verifier. That
|
|
12
12
|
* secret is the storefront's `TOT_SECRET_KEY`; this is independent of the
|
|
13
|
-
*
|
|
13
|
+
* provider API credentials (see decision `u11b-not-blocked-by-u7`).
|
|
14
14
|
*
|
|
15
15
|
* Edge-safe + node-free (WebCrypto HMAC); the network call is injected so the
|
|
16
16
|
* flow is fully unit-testable offline. The caller (the checkout-styling promote
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import type { CustomizationEnv } from "./customization-preview.js";
|
|
21
21
|
|
|
22
|
-
/** Header
|
|
22
|
+
/** Header the commerce service reads the HMAC from. */
|
|
23
23
|
export const TOT_SIGNATURE_HEADER = "x-tot-signature";
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/** The provider caps content_url at 300 chars; '' detaches the template. */
|
|
26
26
|
export const MAX_CONTENT_URL = 300;
|
|
27
27
|
|
|
28
28
|
/** Fetch shape we depend on — the platform `fetch`, injectable for tests. */
|
|
@@ -36,15 +36,15 @@ export type FetchLike = (
|
|
|
36
36
|
) => Promise<{ ok: boolean; status: number; text: () => Promise<string> }>;
|
|
37
37
|
|
|
38
38
|
export interface ThemePublishTarget {
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
/** The store's app domain / subdomain (
|
|
39
|
+
/** Commerce service base URL for the checkout proxy. */
|
|
40
|
+
checkoutServiceBaseUrl: string;
|
|
41
|
+
/** The store's app domain / subdomain (the service resolves the store from it). */
|
|
42
42
|
appDomain: string;
|
|
43
|
-
/** The hosted stylesheet URL
|
|
43
|
+
/** The hosted stylesheet URL checkout will fetch (U11's `/api/checkout-style.css`). */
|
|
44
44
|
contentUrl: string;
|
|
45
45
|
/** The store's ToT secret key (storefront `TOT_SECRET_KEY`) — signs the request. */
|
|
46
46
|
secret: string;
|
|
47
|
-
/**
|
|
47
|
+
/** Checkout template to target; checkout styling uses `checkout` (the default). */
|
|
48
48
|
templateType?: string;
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -58,7 +58,7 @@ export interface ThemePublishResult {
|
|
|
58
58
|
error?: string;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** Lowercase-hex HMAC-SHA256(message) under `secret` (WebCrypto).
|
|
61
|
+
/** Lowercase-hex HMAC-SHA256(message) under `secret` (WebCrypto). */
|
|
62
62
|
async function hmacHex(secret: string, message: string): Promise<string> {
|
|
63
63
|
const enc = new TextEncoder();
|
|
64
64
|
const key = await crypto.subtle.importKey(
|
|
@@ -75,8 +75,8 @@ async function hmacHex(secret: string, message: string): Promise<string> {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* The exact
|
|
79
|
-
*
|
|
78
|
+
* The exact commerce-service signature: hex HMAC-SHA256 of the JSON body string
|
|
79
|
+
* under the store secret. Exported so the promote hook and tests can
|
|
80
80
|
* compute/assert it identically.
|
|
81
81
|
*/
|
|
82
82
|
export function signThemeRequestBody(
|
|
@@ -88,9 +88,9 @@ export function signThemeRequestBody(
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* The canonical, stable content_url a store's checkout template points at — the
|
|
91
|
-
* U11 route on the store's own host, so
|
|
92
|
-
* the tenant + env. Returns null if it would exceed
|
|
93
|
-
* caller surfaces a config error
|
|
91
|
+
* U11 route on the store's own host, so the server-side fetch self-identifies
|
|
92
|
+
* the tenant + env. Returns null if it would exceed the provider's 300-char cap
|
|
93
|
+
* so the caller surfaces a config error before pushing a URL checkout will reject.
|
|
94
94
|
*/
|
|
95
95
|
export function checkoutStyleContentUrl(
|
|
96
96
|
host: string,
|
|
@@ -114,7 +114,7 @@ async function readErr(
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* Publish a store's checkout styling
|
|
117
|
+
* Publish a store's checkout styling: signed PUT `/theme/foxy` then
|
|
118
118
|
* signed POST `/theme/foxy/refresh`. Returns a discriminated result — we do NOT
|
|
119
119
|
* fail open: if the PUT fails we skip the refresh and report the error, so the
|
|
120
120
|
* caller never reports "published" for something that didn't take.
|
|
@@ -127,7 +127,7 @@ export async function publishCheckoutTheme(
|
|
|
127
127
|
): Promise<ThemePublishResult> {
|
|
128
128
|
const doFetch = fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
|
129
129
|
const templateType = target.templateType ?? "checkout";
|
|
130
|
-
const base = target.
|
|
130
|
+
const base = target.checkoutServiceBaseUrl.replace(/\/+$/, "");
|
|
131
131
|
|
|
132
132
|
if (
|
|
133
133
|
target.contentUrl !== "" &&
|
|
@@ -188,11 +188,11 @@ export async function publishCheckoutTheme(
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
|
-
* Convenience: the env →
|
|
192
|
-
* qa
|
|
193
|
-
*
|
|
191
|
+
* Convenience: the env → checkout service base URL for this workstream's targets.
|
|
192
|
+
* qa/test use the non-prod proxy; live uses the prod proxy. Kept here so the
|
|
193
|
+
* promote hook (U13) resolves one place.
|
|
194
194
|
*/
|
|
195
|
-
export function
|
|
195
|
+
export function checkoutServiceBaseUrl(env: CustomizationEnv): string {
|
|
196
196
|
// "test" is the qa target's env; "live" is prod.
|
|
197
197
|
return env === "live"
|
|
198
198
|
? "https://foxycart.tokenoftrust.com"
|