@tokenoftrust/storefront-runner 4.5.0 → 4.6.1
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/agency-kit/tools/lib/catalog-model/files.mjs +30 -3
- package/agency-kit/tools/lib/catalog-model/sku.d.mts +16 -0
- package/agency-kit/tools/lib/catalog-model/sku.mjs +146 -0
- package/apps/storefront/src/lib/inventory/allocations.ts +3 -3
- package/apps/storefront/src/lib/inventory/mirrorStock.ts +3 -1
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +342 -0
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +1 -1
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +10 -4
- package/apps/storefront/src/lib/orders/data/ordersStore.ts +48 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +15 -30
- package/apps/storefront/src/pages/api/admin/orders.ts +15 -32
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +22 -11
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/checkout.ts +13 -3
- package/packages/public-runtime/src/sku.ts +19 -0
- package/scripts/dev/catalog-preview-refresh.mjs +6 -2
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
* - `created_at` / `updated_at` / `migrated_at` / `source_checksum` are optional.
|
|
19
19
|
* - `variants[].inventory` is never read, and writers leave it out. Availability is hot data and
|
|
20
20
|
* lives behind the store's declared `inventory.provider`, never in a content file.
|
|
21
|
+
* - `variants[].sku` follows the SKU contract (`./sku.mjs`): reader and writer trim it, a blank
|
|
22
|
+
* one is no SKU, and one with a control character or over the length cap refuses the file.
|
|
21
23
|
*/
|
|
22
24
|
import { validateRecord } from "./model.mjs";
|
|
25
|
+
import { parseSku, skuFindings } from "./sku.mjs";
|
|
23
26
|
|
|
24
27
|
/** Store-relative directories, one per record kind. */
|
|
25
28
|
export const CATALOG_CONTENT_DIRS = {
|
|
@@ -66,14 +69,37 @@ export function classifyCatalogPath(relative) {
|
|
|
66
69
|
|
|
67
70
|
// --- Writing ----------------------------------------------------------------------------------
|
|
68
71
|
|
|
69
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* A variant's SKU in its stored form: trimmed, a blank one removed. A SKU the contract refuses is
|
|
74
|
+
* left as written so the validator names it.
|
|
75
|
+
*/
|
|
76
|
+
function storedSkuVariant(variant) {
|
|
77
|
+
if (!isObject(variant) || typeof variant.sku !== "string") return variant;
|
|
78
|
+
const parsed = parseSku(variant.sku);
|
|
79
|
+
if (parsed.ok) return parsed.sku === variant.sku ? variant : { ...variant, sku: parsed.sku };
|
|
80
|
+
if (parsed.reason !== "empty") return variant;
|
|
81
|
+
const { sku: _blank, ...rest } = variant;
|
|
82
|
+
return rest;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every refused variant SKU in a product record, as validator issues. */
|
|
86
|
+
function skuIssues(record) {
|
|
87
|
+
if (!isObject(record) || !Array.isArray(record.variants)) return [];
|
|
88
|
+
return record.variants.flatMap((variant, index) =>
|
|
89
|
+
isObject(variant) && variant.sku !== undefined
|
|
90
|
+
? skuFindings(variant.sku).filter((f) => f.level === "error").map((f) => `variants[${index}].sku: ${f.message}`)
|
|
91
|
+
: [],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A record as its file holds it: every variant's `inventory` left out, every SKU stored form. */
|
|
70
96
|
export function catalogFileRecord(kind, record) {
|
|
71
97
|
if (kind !== "product" || !isObject(record) || !Array.isArray(record.variants)) return record;
|
|
72
98
|
return {
|
|
73
99
|
...record,
|
|
74
100
|
variants: record.variants.map((variant) => {
|
|
75
101
|
if (!isObject(variant)) return variant;
|
|
76
|
-
const { inventory: _inventory, ...rest } = variant;
|
|
102
|
+
const { inventory: _inventory, ...rest } = storedSkuVariant(variant);
|
|
77
103
|
return rest;
|
|
78
104
|
}),
|
|
79
105
|
};
|
|
@@ -157,7 +183,7 @@ function stamp(kind, doc, tenantScope, availability) {
|
|
|
157
183
|
const productId = typeof doc.id === "string" ? doc.id : "";
|
|
158
184
|
out.variants = doc.variants.map((variant) =>
|
|
159
185
|
isObject(variant)
|
|
160
|
-
? { ...variant, tenant_id: tenantScope, product_id: productId, inventory: { ...availability } }
|
|
186
|
+
? { ...storedSkuVariant(variant), tenant_id: tenantScope, product_id: productId, inventory: { ...availability } }
|
|
161
187
|
: variant,
|
|
162
188
|
);
|
|
163
189
|
}
|
|
@@ -185,6 +211,7 @@ function parse(relative, raw, tenantScope, availability) {
|
|
|
185
211
|
const issues = validateRecord(where.kind === "product" ? "CatalogProduct" : "CatalogCollection", record).map(
|
|
186
212
|
(i) => (i.path ? `${i.path}: ${i.message}` : i.message),
|
|
187
213
|
);
|
|
214
|
+
if (where.kind === "product") issues.push(...skuIssues(record));
|
|
188
215
|
if (isObject(doc) && typeof doc.handle === "string" && doc.handle !== where.handle) {
|
|
189
216
|
issues.push(`handle: "${doc.handle}" does not match the file name "${where.handle}.json"`);
|
|
190
217
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const SKU_MAX_LENGTH: number;
|
|
2
|
+
export const CHECKOUT_CODE_MAX_LENGTH: number;
|
|
3
|
+
export const SKU_TOKEN_MAX_LENGTH: number;
|
|
4
|
+
export type SkuRefusal = "not-string" | "empty" | "control-character" | "too-long";
|
|
5
|
+
export function parseSku(value: unknown): { ok: true; sku: string } | { ok: false; reason: SkuRefusal };
|
|
6
|
+
export function isStoredSku(value: unknown): value is string;
|
|
7
|
+
export function skuKey(sku: string): string;
|
|
8
|
+
export function skuToken(sku: string): string;
|
|
9
|
+
export function skuFromToken(token: unknown): string | null;
|
|
10
|
+
export interface SkuFinding {
|
|
11
|
+
level: "error" | "warning";
|
|
12
|
+
code: string;
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
export function skuFindings(value: unknown): SkuFinding[];
|
|
16
|
+
export function skuCollisions<T extends { sku: string }>(entries: Iterable<T>): Array<{ key: string; entries: T[] }>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SKU contract: one rule for every reader and writer of a merchant's SKU.
|
|
3
|
+
*
|
|
4
|
+
* A SKU is merchant data, kept exactly as the merchant wrote it. The only rewrite is trimming
|
|
5
|
+
* leading and trailing whitespace on the way in. A SKU is refused only when it is empty, carries a
|
|
6
|
+
* control character, or is longer than {@link SKU_MAX_LENGTH} characters. Anything else, a space
|
|
7
|
+
* included, is a valid SKU; a space is a lint warning, never an error.
|
|
8
|
+
*
|
|
9
|
+
* Two SKUs are the same SKU when their {@link skuKey} is equal: trimmed, NBSP read as a space, and
|
|
10
|
+
* case-folded. The key is for comparison only and is never stored.
|
|
11
|
+
*
|
|
12
|
+
* A SKU is never used raw where a boundary needs a safe token (a path, a file name, a URL segment,
|
|
13
|
+
* a checkout product code). {@link skuToken} is the one encoding for those boundaries: reversible,
|
|
14
|
+
* injective, and the identity for a SKU of letters, digits, `.`, `_` and `-`.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no filesystem, no network, no dependencies. `packages/cli/src/sku.mjs` is a byte-identical
|
|
17
|
+
* copy (the CLI installs without the kit), pinned by `packages/cli/test/sku-copy.test.mjs`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Longest SKU accepted, in characters (code points). */
|
|
21
|
+
export const SKU_MAX_LENGTH = 250;
|
|
22
|
+
/** The checkout provider's product-code limit; a longer {@link skuToken} is a lint warning. */
|
|
23
|
+
export const CHECKOUT_CODE_MAX_LENGTH = 200;
|
|
24
|
+
/** Longest {@link skuToken} any SKU can produce (four UTF-8 bytes, three characters each). */
|
|
25
|
+
export const SKU_TOKEN_MAX_LENGTH = SKU_MAX_LENGTH * 12;
|
|
26
|
+
|
|
27
|
+
const CONTROL = /\p{Cc}/u;
|
|
28
|
+
const WHITESPACE = /\s/u;
|
|
29
|
+
const TOKEN_SAFE = /^[A-Za-z0-9._-]$/;
|
|
30
|
+
const TOKEN = /^(?:[A-Za-z0-9._-]|%[0-9A-F]{2})+$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a SKU as it arrives from a merchant, a file or an import.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} value
|
|
36
|
+
* @returns {{ ok: true, sku: string } | { ok: false, reason: "not-string" | "empty" | "control-character" | "too-long" }}
|
|
37
|
+
*/
|
|
38
|
+
export function parseSku(value) {
|
|
39
|
+
if (typeof value !== "string") return { ok: false, reason: "not-string" };
|
|
40
|
+
const sku = value.trim();
|
|
41
|
+
if (sku === "") return { ok: false, reason: "empty" };
|
|
42
|
+
if (CONTROL.test(sku)) return { ok: false, reason: "control-character" };
|
|
43
|
+
if ([...sku].length > SKU_MAX_LENGTH) return { ok: false, reason: "too-long" };
|
|
44
|
+
return { ok: true, sku };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when `value` is a SKU in its stored form: valid and already trimmed. */
|
|
48
|
+
export function isStoredSku(value) {
|
|
49
|
+
const parsed = parseSku(value);
|
|
50
|
+
return parsed.ok && parsed.sku === value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The comparison key two SKUs share when they are the same SKU. Never stored. */
|
|
54
|
+
export function skuKey(sku) {
|
|
55
|
+
return String(sku).replace(/ /g, " ").trim().toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The SKU as a token safe in a path, file name, URL segment or checkout product code. Letters,
|
|
60
|
+
* digits, `.`, `_` and `-` pass through; every other character is its UTF-8 bytes as `%XX`.
|
|
61
|
+
*/
|
|
62
|
+
export function skuToken(sku) {
|
|
63
|
+
let out = "";
|
|
64
|
+
for (const char of String(sku)) {
|
|
65
|
+
if (TOKEN_SAFE.test(char)) {
|
|
66
|
+
out += char;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
for (const byte of new TextEncoder().encode(char)) out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The stored SKU a {@link skuToken} encodes, or null when `token` is not one. */
|
|
75
|
+
export function skuFromToken(token) {
|
|
76
|
+
if (typeof token !== "string" || token.length > SKU_TOKEN_MAX_LENGTH || !TOKEN.test(token)) return null;
|
|
77
|
+
const bytes = [];
|
|
78
|
+
for (let i = 0; i < token.length; i += 1) {
|
|
79
|
+
if (token[i] === "%") {
|
|
80
|
+
bytes.push(Number.parseInt(token.slice(i + 1, i + 3), 16));
|
|
81
|
+
i += 2;
|
|
82
|
+
} else {
|
|
83
|
+
bytes.push(token.charCodeAt(i));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Bytes that are not UTF-8 decode to U+FFFD, whose token differs, so the round trip refuses them.
|
|
87
|
+
const sku = new TextDecoder().decode(new Uint8Array(bytes));
|
|
88
|
+
return isStoredSku(sku) && skuToken(sku) === token ? sku : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const REASONS = {
|
|
92
|
+
"not-string": "a SKU must be text",
|
|
93
|
+
empty: "a SKU cannot be empty",
|
|
94
|
+
"control-character": "a SKU cannot contain a control character (tab, newline, ...)",
|
|
95
|
+
"too-long": `a SKU is at most ${SKU_MAX_LENGTH} characters`,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Lint findings for one SKU. `error` means the SKU is refused; `warning` means it works.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} value
|
|
102
|
+
* @returns {Array<{ level: "error" | "warning", code: string, message: string }>}
|
|
103
|
+
*/
|
|
104
|
+
export function skuFindings(value) {
|
|
105
|
+
const parsed = parseSku(value);
|
|
106
|
+
if (!parsed.ok) return [{ level: "error", code: `sku-${parsed.reason}`, message: REASONS[parsed.reason] }];
|
|
107
|
+
/** @type {Array<{ level: "error" | "warning", code: string, message: string }>} */
|
|
108
|
+
const findings = [];
|
|
109
|
+
if (parsed.sku !== value) {
|
|
110
|
+
findings.push({ level: "warning", code: "sku-untrimmed", message: "leading or trailing whitespace is trimmed when the SKU is read" });
|
|
111
|
+
}
|
|
112
|
+
if (WHITESPACE.test(parsed.sku)) {
|
|
113
|
+
findings.push({
|
|
114
|
+
level: "warning",
|
|
115
|
+
code: "sku-whitespace",
|
|
116
|
+
message: "works, but we recommend against spaces in a SKU: spreadsheets, scanners and other systems often trim or split on them",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (skuToken(parsed.sku).length > CHECKOUT_CODE_MAX_LENGTH) {
|
|
120
|
+
findings.push({
|
|
121
|
+
level: "warning",
|
|
122
|
+
code: "sku-checkout-code-long",
|
|
123
|
+
message: `its checkout product code is longer than ${CHECKOUT_CODE_MAX_LENGTH} characters, which the checkout provider may refuse`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return findings;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* SKUs that are the same SKU under {@link skuKey}. Each group lists every entry that shares a key.
|
|
131
|
+
*
|
|
132
|
+
* @template {{ sku: string }} T
|
|
133
|
+
* @param {Iterable<T>} entries
|
|
134
|
+
* @returns {Array<{ key: string, entries: T[] }>}
|
|
135
|
+
*/
|
|
136
|
+
export function skuCollisions(entries) {
|
|
137
|
+
/** @type {Map<string, T[]>} */
|
|
138
|
+
const groups = new Map();
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
const key = skuKey(entry.sku);
|
|
141
|
+
const group = groups.get(key);
|
|
142
|
+
if (group) group.push(entry);
|
|
143
|
+
else groups.set(key, [entry]);
|
|
144
|
+
}
|
|
145
|
+
return [...groups].filter(([, group]) => group.length > 1).map(([key, group]) => ({ key, entries: group }));
|
|
146
|
+
}
|
|
@@ -15,12 +15,12 @@ import {
|
|
|
15
15
|
type PaymentState,
|
|
16
16
|
type StockState,
|
|
17
17
|
} from "@tot/public-runtime";
|
|
18
|
+
import { isStoredSku } from "@tot/public-runtime/sku";
|
|
18
19
|
|
|
19
20
|
export const RESERVATION_AUTHORITY_HEADER = "x-tot-reservation-authority";
|
|
20
21
|
export const RESERVATION_AUTHORITY_NONE = "none";
|
|
21
22
|
export const VERIFICATION_PROVIDER_OWNER = "tot-9577-tot-verification-provider";
|
|
22
23
|
|
|
23
|
-
const SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
24
24
|
const ID_RE = /^[a-z0-9][a-z0-9-]{0,80}$/;
|
|
25
25
|
const TENANT_RE = /^[a-z0-9][a-z0-9.-]{0,253}$/;
|
|
26
26
|
const LOCATION_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,80}$/;
|
|
@@ -153,7 +153,7 @@ export function readMirrorAuthority(value: unknown): MirrorStockAuthority | null
|
|
|
153
153
|
const record = value as Record<string, unknown>;
|
|
154
154
|
if (record.provider !== "mirror" || record.readable !== true) return null;
|
|
155
155
|
if (typeof record.locationId !== "string" || !LOCATION_RE.test(record.locationId)) return null;
|
|
156
|
-
if (typeof record.sku !== "string" || !
|
|
156
|
+
if (typeof record.sku !== "string" || !isStoredSku(record.sku)) return null;
|
|
157
157
|
if (typeof record.onHand !== "number" || !Number.isSafeInteger(record.onHand) || record.onHand < 0) return null;
|
|
158
158
|
if (typeof record.asOf !== "string" || record.asOf.trim() === "" || record.asOf.length > 40) return null;
|
|
159
159
|
return {
|
|
@@ -252,7 +252,7 @@ function reserveShape(input: ReserveInput): ReserveResult | null {
|
|
|
252
252
|
if (!Number.isSafeInteger(input.amountMinor) || input.amountMinor < 0) return { ok: false, finding: "malformed-request" };
|
|
253
253
|
if (input.holds.length < 1) return { ok: false, finding: "malformed-request" };
|
|
254
254
|
for (const hold of input.holds) {
|
|
255
|
-
if (!
|
|
255
|
+
if (!isStoredSku(hold.sku) || !LOCATION_RE.test(hold.locationId)) return { ok: false, finding: "malformed-request" };
|
|
256
256
|
if (!Number.isSafeInteger(hold.quantity) || hold.quantity < 1) return { ok: false, finding: "malformed-request" };
|
|
257
257
|
if (!Number.isSafeInteger(hold.onHand) || hold.onHand < 0) return { ok: false, finding: "malformed-request" };
|
|
258
258
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Tenant-scoped inventory authority. Catalog availability never supplies a balance. */
|
|
2
2
|
import type { Queryable } from "../catalog/d1/catalog.js";
|
|
3
3
|
import type { CommerceStage } from "@tot/public-runtime";
|
|
4
|
+
import { isStoredSku } from "@tot/public-runtime/sku";
|
|
4
5
|
import type { MirrorStockAuthority } from "./allocations.js";
|
|
5
6
|
|
|
6
7
|
const ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
@@ -25,9 +26,10 @@ export interface MirrorMovement {
|
|
|
25
26
|
}
|
|
26
27
|
export async function appendMirrorMovement(db: Queryable, input: MirrorMovement): Promise<void> {
|
|
27
28
|
if (
|
|
28
|
-
![input.tenantId, input.source, input.sourceEventId, input.
|
|
29
|
+
![input.tenantId, input.source, input.sourceEventId, input.locationId, input.actorId].every((v) =>
|
|
29
30
|
ID.test(v),
|
|
30
31
|
) ||
|
|
32
|
+
!isStoredSku(input.sku) ||
|
|
31
33
|
!Number.isSafeInteger(input.quantityDelta) ||
|
|
32
34
|
!Number.isFinite(Date.parse(input.asOf)) ||
|
|
33
35
|
!["preview", "publication"].includes(input.stage)
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a tenant's admin reads its orders. Resolved once per request, so the Orders tab, the
|
|
3
|
+
* `/api/admin/orders` routes and the sidebar status always describe the same connection.
|
|
4
|
+
*
|
|
5
|
+
* A tenant admitted to the shared ToT checkout store (checkout's `admitSharedStoreTenant`, the
|
|
6
|
+
* same admission the signed order intake applies) has its paid orders forwarded into the platform
|
|
7
|
+
* ledger (`STOREFRONT_APPS_DB`, `scrappy_commerce_orders`) under this Worker's `COMMERCE_STAGE`.
|
|
8
|
+
* The shared store's partner API returns every tenant's transactions unfiltered, so the ledger is
|
|
9
|
+
* the only per-tenant record for that tenant. A tenant on its own FoxyCart store reads that
|
|
10
|
+
* store's partner API with `FOXY_ORDER_API_KEY`.
|
|
11
|
+
*
|
|
12
|
+
* The ledger carries no customer identity and no address (the intake refuses both), so a ledger
|
|
13
|
+
* order names no customer and has no shipping address here.
|
|
14
|
+
*/
|
|
15
|
+
import type {
|
|
16
|
+
CheckoutOrderDetail,
|
|
17
|
+
CheckoutOrderFulfillment,
|
|
18
|
+
CheckoutOrderListFilters,
|
|
19
|
+
CheckoutOrderListResponse,
|
|
20
|
+
CheckoutOrderPayment,
|
|
21
|
+
CheckoutOrderSummary,
|
|
22
|
+
CommerceScope,
|
|
23
|
+
CommerceStage,
|
|
24
|
+
CustomizationEnv,
|
|
25
|
+
PaymentState,
|
|
26
|
+
} from "@tot/public-runtime";
|
|
27
|
+
import { evidencePermitsRelease, paginateOrders } from "@tot/public-runtime";
|
|
28
|
+
import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
|
|
29
|
+
import { readD1, readEnv } from "@/lib/publication/shared/env";
|
|
30
|
+
import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
|
|
31
|
+
import { ScrappyOrderLedger, type CommerceOrderBookEntry } from "../data/ordersStore.js";
|
|
32
|
+
import { foldCommerceEvents, type CommerceSourceEvent } from "../data/orderMapper.js";
|
|
33
|
+
import {
|
|
34
|
+
OrderApiError,
|
|
35
|
+
createCheckoutOrderClient,
|
|
36
|
+
orderApiBaseUrl,
|
|
37
|
+
type CheckoutOrderClient,
|
|
38
|
+
} from "./orderClient";
|
|
39
|
+
|
|
40
|
+
/** Why a tenant has no order source. Each names what connects it. */
|
|
41
|
+
export type OrderSourceGap = "no-tenant" | "stage-unknown" | "ledger-unavailable" | "partner-key-missing";
|
|
42
|
+
|
|
43
|
+
/** Ledger health: when the last paid order arrived, and reservations no payment has confirmed. */
|
|
44
|
+
export interface LedgerSyncStatus {
|
|
45
|
+
orders: number;
|
|
46
|
+
lastOrderAt: string | null;
|
|
47
|
+
unconfirmed: { count: number; oldestAt: string | null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Which FoxyCart store takes this tenant's payments, or null when no store is selected. */
|
|
51
|
+
export type CheckoutStore = "shared-test-store" | "own-store" | null;
|
|
52
|
+
|
|
53
|
+
export type OrderSource =
|
|
54
|
+
| {
|
|
55
|
+
kind: "ledger";
|
|
56
|
+
checkout: "shared-test-store";
|
|
57
|
+
scope: CommerceScope;
|
|
58
|
+
client: CheckoutOrderClient;
|
|
59
|
+
status: (now?: Date) => Promise<LedgerSyncStatus>;
|
|
60
|
+
}
|
|
61
|
+
| { kind: "partner-api"; checkout: "own-store"; scope: CommerceScope; client: CheckoutOrderClient }
|
|
62
|
+
| { kind: "not-connected"; checkout: CheckoutStore; gap: OrderSourceGap };
|
|
63
|
+
|
|
64
|
+
export interface OrderSourceDeps {
|
|
65
|
+
admitSharedStoreTenant: (tenantId: string) => Promise<{ ok: boolean }>;
|
|
66
|
+
readEnv: (name: string) => Promise<string | undefined>;
|
|
67
|
+
readLedgerDb: () => Promise<Queryable | null>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Worker deps. The caller passes checkout's `admitSharedStoreTenant`, the same admission the
|
|
72
|
+
* signed order intake applies, so reading and writing the ledger cannot disagree on who is in it.
|
|
73
|
+
*/
|
|
74
|
+
export function workerOrderSourceDeps(
|
|
75
|
+
admitSharedStoreTenant: OrderSourceDeps["admitSharedStoreTenant"],
|
|
76
|
+
): OrderSourceDeps {
|
|
77
|
+
return {
|
|
78
|
+
admitSharedStoreTenant,
|
|
79
|
+
readEnv: async (name) => (await readEnv(name)) ?? undefined,
|
|
80
|
+
readLedgerDb: async () => {
|
|
81
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
82
|
+
return d1 ? fromD1(d1) : null;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function resolveOrderSource(
|
|
88
|
+
input: { tenantId: string; customizationEnv: CustomizationEnv | undefined },
|
|
89
|
+
deps: OrderSourceDeps,
|
|
90
|
+
): Promise<OrderSource> {
|
|
91
|
+
const tenantId = input.tenantId.trim();
|
|
92
|
+
if (!tenantId) return { kind: "not-connected", checkout: null, gap: "no-tenant" };
|
|
93
|
+
|
|
94
|
+
if ((await deps.admitSharedStoreTenant(tenantId)).ok) {
|
|
95
|
+
const checkout = "shared-test-store";
|
|
96
|
+
const stage = await deps.readEnv("COMMERCE_STAGE");
|
|
97
|
+
if (stage !== "preview" && stage !== "publication") return { kind: "not-connected", checkout, gap: "stage-unknown" };
|
|
98
|
+
const db = await deps.readLedgerDb();
|
|
99
|
+
if (!db) return { kind: "not-connected", checkout, gap: "ledger-unavailable" };
|
|
100
|
+
const book = ledgerOrderBook(new ScrappyOrderLedger(db), { tenantId, stage });
|
|
101
|
+
return { kind: "ledger", checkout, scope: { tenantId, stage }, client: book.client, status: book.status };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const checkout = "own-store";
|
|
105
|
+
const stage = commerceStageForEnv(input.customizationEnv);
|
|
106
|
+
if (!stage) return { kind: "not-connected", checkout, gap: "stage-unknown" };
|
|
107
|
+
const bearer = await deps.readEnv("FOXY_ORDER_API_KEY");
|
|
108
|
+
if (!bearer) return { kind: "not-connected", checkout, gap: "partner-key-missing" };
|
|
109
|
+
return {
|
|
110
|
+
kind: "partner-api",
|
|
111
|
+
checkout,
|
|
112
|
+
scope: { tenantId, stage },
|
|
113
|
+
client: createCheckoutOrderClient({
|
|
114
|
+
baseUrl: orderApiBaseUrl(input.customizationEnv ?? "test"),
|
|
115
|
+
appDomain: tenantId,
|
|
116
|
+
bearer,
|
|
117
|
+
}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Merchant copy for a gap: what is missing and who connects it. */
|
|
122
|
+
export function orderSourceGapMessage(gap: OrderSourceGap): string {
|
|
123
|
+
switch (gap) {
|
|
124
|
+
case "no-tenant":
|
|
125
|
+
return "No store is selected.";
|
|
126
|
+
case "stage-unknown":
|
|
127
|
+
return "This environment does not declare a commerce stage, so it has no order record to read. Token of Trust support connects it.";
|
|
128
|
+
case "ledger-unavailable":
|
|
129
|
+
return "The platform order ledger is not bound in this environment. Token of Trust support connects it.";
|
|
130
|
+
case "partner-key-missing":
|
|
131
|
+
return "This store checks out on its own FoxyCart store, and its partner API key is not provisioned on the platform. Token of Trust support provisions it.";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface OrderSyncHealth {
|
|
136
|
+
value: "Not connected" | "Connected" | "Check";
|
|
137
|
+
detail: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The sidebar's order-sync row, from the same source the Orders tab reads. */
|
|
141
|
+
export function orderSyncHealth(source: OrderSource, status: LedgerSyncStatus | null): OrderSyncHealth {
|
|
142
|
+
if (source.kind === "not-connected") return { value: "Not connected", detail: orderSourceGapMessage(source.gap) };
|
|
143
|
+
if (source.kind === "partner-api" || !status) return { value: "Connected", detail: "Reads this store's checkout orders." };
|
|
144
|
+
if (status.unconfirmed.count > 0) {
|
|
145
|
+
const n = status.unconfirmed.count;
|
|
146
|
+
return {
|
|
147
|
+
value: "Check",
|
|
148
|
+
detail: `${n} checkout${n === 1 ? "" : "s"} in the last 7 days passed final cart with no payment confirmation received.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
value: "Connected",
|
|
153
|
+
detail: status.lastOrderAt ? `Last paid order ${status.lastOrderAt}.` : "No paid orders yet.",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The sidebar's checkout row: which store takes this tenant's payments. */
|
|
158
|
+
export function checkoutStoreLabel(checkout: CheckoutStore): string {
|
|
159
|
+
if (checkout === "shared-test-store") return "Test store";
|
|
160
|
+
if (checkout === "own-store") return "Own store";
|
|
161
|
+
return "Not connected";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The ledger's money is integer minor units of a two-decimal currency. */
|
|
165
|
+
function toMajor(minor: number): number {
|
|
166
|
+
return minor / 100;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Provider timestamps arrive with a colonless offset; compare and page on one ISO form. */
|
|
170
|
+
function isoInstant(value: string): string {
|
|
171
|
+
const parsed = new Date(value);
|
|
172
|
+
return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function paymentOf(payment: PaymentState): CheckoutOrderPayment {
|
|
176
|
+
if (payment === "captured") return "captured";
|
|
177
|
+
if (payment === "refunded" || payment === "refund_requested" || payment === "refund_unknown" || payment === "voided") {
|
|
178
|
+
return "refunded";
|
|
179
|
+
}
|
|
180
|
+
return "authorized";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface LedgerOrder {
|
|
184
|
+
summary: CheckoutOrderSummary;
|
|
185
|
+
detail: CheckoutOrderDetail;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
|
|
189
|
+
const placedAt = isoInstant(entry.placedAt);
|
|
190
|
+
const snapshot = entry.events.length > 0 ? foldCommerceEvents(entry.events) : null;
|
|
191
|
+
const payment = snapshot?.payment ?? entry.payment;
|
|
192
|
+
const fulfillment = snapshot?.fulfillment ?? entry.fulfillment;
|
|
193
|
+
const evidence = snapshot?.verificationDetail ?? { verification: "UNKNOWN", tax: "UNKNOWN", risk: "UNKNOWN", stock: "UNKNOWN" };
|
|
194
|
+
const totals = snapshot?.totals ?? { subtotalMinor: entry.amountMinor, shippingMinor: 0, taxMinor: 0, totalMinor: entry.amountMinor };
|
|
195
|
+
const lines =
|
|
196
|
+
[...entry.events].sort((a, b) => b.eventVersion - a.eventVersion).find((event) => event.lines?.length)?.lines ?? [];
|
|
197
|
+
|
|
198
|
+
const releasable = payment === "captured" && evidencePermitsRelease(evidence);
|
|
199
|
+
let state: CheckoutOrderFulfillment;
|
|
200
|
+
let blocker: string | null = null;
|
|
201
|
+
if (fulfillment === "handed_off") state = "completed";
|
|
202
|
+
else if (fulfillment === "cancelled") {
|
|
203
|
+
state = "completed";
|
|
204
|
+
blocker = "Cancelled";
|
|
205
|
+
} else if (fulfillment === "UNKNOWN") state = "unfulfilled";
|
|
206
|
+
else if (fulfillment === "ready" && releasable) state = "ready_to_ship";
|
|
207
|
+
else {
|
|
208
|
+
state = "hold_review_required";
|
|
209
|
+
blocker = payment !== "captured" ? "Payment capture" : evidencePermitsRelease(evidence) ? "Manual review" : "Evidence review";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0);
|
|
213
|
+
const itemsSummary =
|
|
214
|
+
lines.length === 0 ? "Items not recorded" : lines.length === 1 ? lines[0]!.sku : `${lines[0]!.sku} +${lines.length - 1} more`;
|
|
215
|
+
const verification: Record<string, string> = {};
|
|
216
|
+
for (const key of ["verification", "tax", "risk", "stock"] as const) {
|
|
217
|
+
verification[key] = evidence[key];
|
|
218
|
+
verification[`${key}Source`] = "commerce-ledger";
|
|
219
|
+
verification[`${key}AsOf`] = placedAt;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const summary: CheckoutOrderSummary = {
|
|
223
|
+
id: entry.orderId,
|
|
224
|
+
placedAt,
|
|
225
|
+
currency: entry.currency,
|
|
226
|
+
customer: { id: null, name: "Checkout customer" },
|
|
227
|
+
itemsSummary,
|
|
228
|
+
itemCount,
|
|
229
|
+
total: toMajor(totals.totalMinor),
|
|
230
|
+
payment: paymentOf(payment),
|
|
231
|
+
fulfillment: state,
|
|
232
|
+
blocker,
|
|
233
|
+
verification,
|
|
234
|
+
};
|
|
235
|
+
return {
|
|
236
|
+
summary,
|
|
237
|
+
detail: {
|
|
238
|
+
...summary,
|
|
239
|
+
lineItems: lines.map((line) => ({ sku: line.sku, name: line.sku, quantity: line.quantity, unitPrice: toMajor(line.unitMinor) })),
|
|
240
|
+
totals: {
|
|
241
|
+
subtotal: toMajor(totals.subtotalMinor),
|
|
242
|
+
shipping: toMajor(totals.shippingMinor),
|
|
243
|
+
estimatedTax: toMajor(totals.taxMinor),
|
|
244
|
+
taxesAndFees: 0,
|
|
245
|
+
total: toMajor(totals.totalMinor),
|
|
246
|
+
},
|
|
247
|
+
shippingAddress: null,
|
|
248
|
+
verificationDetail: { readinessChecks: [], evidenceTimeline: [] },
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function matchesQueue(order: CheckoutOrderSummary, queue: string | undefined): boolean {
|
|
254
|
+
switch (queue) {
|
|
255
|
+
case undefined:
|
|
256
|
+
case "all":
|
|
257
|
+
return true;
|
|
258
|
+
case "needs_attention":
|
|
259
|
+
return order.fulfillment === "hold_review_required";
|
|
260
|
+
case "ready_to_ship":
|
|
261
|
+
return order.fulfillment === "ready_to_ship";
|
|
262
|
+
case "unfulfilled":
|
|
263
|
+
return order.fulfillment === "hold_review_required" || order.fulfillment === "ready_to_ship";
|
|
264
|
+
case "completed":
|
|
265
|
+
return order.fulfillment === "completed";
|
|
266
|
+
default:
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function listLimit(limit: number | undefined): number {
|
|
272
|
+
if (limit === undefined || !Number.isFinite(limit)) return 25;
|
|
273
|
+
return Math.min(100, Math.max(1, Math.trunc(limit)));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A shopper who abandons at payment leaves a reservation too, so only recent ones are reported. */
|
|
277
|
+
export const UNCONFIRMED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Paid orders and sync status over one tenant's ledger book, read once per request. A `scrappy`
|
|
281
|
+
* reservation is never listed as an order; one from the last seven days that no provider event
|
|
282
|
+
* has confirmed is reported as waiting on payment confirmation.
|
|
283
|
+
*/
|
|
284
|
+
export function ledgerOrderBook(
|
|
285
|
+
ledger: Pick<ScrappyOrderLedger, "readOrderBook">,
|
|
286
|
+
scope: { tenantId: string; stage: CommerceStage },
|
|
287
|
+
): { client: CheckoutOrderClient; status: (now?: Date) => Promise<LedgerSyncStatus> } {
|
|
288
|
+
let loaded: Promise<{ orders: LedgerOrder[]; unconfirmed: CommerceOrderBookEntry[] }> | null = null;
|
|
289
|
+
const load = () =>
|
|
290
|
+
(loaded ??= ledger.readOrderBook(scope.tenantId, scope.stage).then((book) => {
|
|
291
|
+
const paid = book.filter((entry) => entry.source !== "scrappy");
|
|
292
|
+
const confirmed = new Set(
|
|
293
|
+
paid.flatMap((entry) => entry.events.map((event: CommerceSourceEvent) => event.admissionReference)).filter(Boolean),
|
|
294
|
+
);
|
|
295
|
+
return {
|
|
296
|
+
orders: paid.map(ledgerOrder),
|
|
297
|
+
unconfirmed: book.filter((entry) => entry.source === "scrappy" && !confirmed.has(entry.orderId)),
|
|
298
|
+
};
|
|
299
|
+
}));
|
|
300
|
+
|
|
301
|
+
const client: CheckoutOrderClient = {
|
|
302
|
+
async listOrders(filters?: CheckoutOrderListFilters): Promise<CheckoutOrderListResponse> {
|
|
303
|
+
const { orders } = await load();
|
|
304
|
+
const from = filters?.from ? isoInstant(filters.from) : null;
|
|
305
|
+
const to = filters?.to ? isoInstant(filters.to) : null;
|
|
306
|
+
const matching = orders
|
|
307
|
+
.map((order) => order.summary)
|
|
308
|
+
.filter((order) => matchesQueue(order, filters?.queue))
|
|
309
|
+
.filter((order) => !filters?.status || order.payment === filters.status)
|
|
310
|
+
.filter((order) => (!from || order.placedAt >= from) && (!to || order.placedAt < to));
|
|
311
|
+
const limit = listLimit(filters?.limit);
|
|
312
|
+
const page = paginateOrders(matching, limit, filters?.cursor ?? null);
|
|
313
|
+
return {
|
|
314
|
+
orders: page.orders,
|
|
315
|
+
page: { limit, cursor: filters?.cursor ?? null, nextCursor: page.nextCursor, total: matching.length },
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
async getOrder(orderId: string): Promise<CheckoutOrderDetail> {
|
|
319
|
+
const { orders } = await load();
|
|
320
|
+
const found = orders.find((order) => order.summary.id === orderId);
|
|
321
|
+
if (!found) throw new OrderApiError("order_not_found", 404);
|
|
322
|
+
return found.detail;
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const status = async (now: Date = new Date()): Promise<LedgerSyncStatus> => {
|
|
327
|
+
const { orders, unconfirmed } = await load();
|
|
328
|
+
const placed = orders.map((order) => order.summary.placedAt).sort();
|
|
329
|
+
const since = new Date(now.getTime() - UNCONFIRMED_WINDOW_MS).toISOString();
|
|
330
|
+
const waiting = unconfirmed
|
|
331
|
+
.map((entry) => isoInstant(entry.placedAt))
|
|
332
|
+
.filter((at) => at >= since)
|
|
333
|
+
.sort();
|
|
334
|
+
return {
|
|
335
|
+
orders: orders.length,
|
|
336
|
+
lastOrderAt: placed[placed.length - 1] ?? null,
|
|
337
|
+
unconfirmed: { count: waiting.length, oldestAt: waiting[0] ?? null },
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
return { client, status };
|
|
342
|
+
}
|
|
@@ -52,7 +52,7 @@ import type {
|
|
|
52
52
|
} from "../viewmodels/orders";
|
|
53
53
|
import type { CheckoutOrderClient } from "../orderClient";
|
|
54
54
|
|
|
55
|
-
const EVIDENCE_STATES: readonly EvidenceState[] = ["fresh", "UNKNOWN", "stale", "unreadable", "absent"];
|
|
55
|
+
const EVIDENCE_STATES: readonly EvidenceState[] = ["fresh", "not-required", "UNKNOWN", "stale", "unreadable", "absent"];
|
|
56
56
|
const REFUND_PAYMENTS: ReadonlySet<PaymentState> = new Set(["refunded", "refund_requested", "refund_unknown"]);
|
|
57
57
|
|
|
58
58
|
export interface SourcedState<T extends string = string> {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* internals — and never lets a raw customer identifier leave this module:
|
|
9
9
|
* customer identity is always the per-(tenant, install) `customerHash`.
|
|
10
10
|
*/
|
|
11
|
+
import { skuFromToken } from "@tot/public-runtime/sku";
|
|
11
12
|
import { customerHash } from "../../integrations/apps/orders/customerHash.js";
|
|
12
13
|
import type { FoxyOrder, FoxyOrderLineItem } from "../providers/foxyOrderClient.js";
|
|
13
14
|
import {
|
|
@@ -82,9 +83,10 @@ function toIsoDateTime(foxyDate: string): string {
|
|
|
82
83
|
return Number.isNaN(parsed.getTime()) ? foxyDate : parsed.toISOString();
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/** The provider carries the signed checkout product code; the order names the stored SKU it encodes. */
|
|
85
87
|
function toContractLineItem(item: FoxyOrderLineItem): ContractOrderLineItem {
|
|
86
88
|
return {
|
|
87
|
-
sku: item.sku,
|
|
89
|
+
sku: skuFromToken(item.sku) ?? item.sku,
|
|
88
90
|
quantity: item.quantity,
|
|
89
91
|
lineTotal: item.totalPrice,
|
|
90
92
|
};
|
|
@@ -264,11 +266,15 @@ export function normalizeCommerceEvent(value: unknown): CommerceSourceEvent | "m
|
|
|
264
266
|
if (!(STOCK_STATES as readonly string[]).includes(stock)) return "malformed";
|
|
265
267
|
if (!(FULFILLMENT_STATES as readonly string[]).includes(fulfillment)) return "malformed";
|
|
266
268
|
if (!(LABEL_STATES as readonly string[]).includes(label)) return "malformed";
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
269
|
+
// A line's `sku` arrives as the signed checkout product code and is kept as the line key it
|
|
270
|
+
// encodes, the key the admission record and the stock allocations carry.
|
|
271
|
+
const rawLines = value.lines;
|
|
272
|
+
if (rawLines !== undefined && (!Array.isArray(rawLines) || rawLines.some(line => !isRecord(line) ||
|
|
273
|
+
skuFromToken(line.sku) === null ||
|
|
270
274
|
!Number.isSafeInteger(line.quantity) || Number(line.quantity) < 1 ||
|
|
271
275
|
!Number.isSafeInteger(line.unitMinor) || Number(line.unitMinor) < 0))) return "malformed";
|
|
276
|
+
const lines = rawLines === undefined ? undefined : (rawLines as Record<string, unknown>[]).map(line =>
|
|
277
|
+
({ ...line, sku: skuFromToken(line.sku)! }));
|
|
272
278
|
for (const key of ["admissionReference", "providerCartDigest", "destinationDigest"] as const) {
|
|
273
279
|
if (value[key] !== undefined && (typeof value[key] !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(value[key] as string))) return "malformed";
|
|
274
280
|
}
|
|
@@ -222,6 +222,18 @@ export interface CommerceOrderQueryRow {
|
|
|
222
222
|
eventVersion: number;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/** One ledger order row and the source events recorded for it. */
|
|
226
|
+
export interface CommerceOrderBookEntry {
|
|
227
|
+
orderId: string;
|
|
228
|
+
source: string;
|
|
229
|
+
placedAt: string;
|
|
230
|
+
currency: string;
|
|
231
|
+
payment: PaymentState;
|
|
232
|
+
fulfillment: CommerceSnapshot["fulfillment"];
|
|
233
|
+
amountMinor: number;
|
|
234
|
+
events: CommerceSourceEvent[];
|
|
235
|
+
}
|
|
236
|
+
|
|
225
237
|
export interface CommerceAggregate {
|
|
226
238
|
count: number;
|
|
227
239
|
amountMinor: number;
|
|
@@ -468,6 +480,42 @@ export class ScrappyOrderLedger {
|
|
|
468
480
|
return { count, amountMinor, byPayment };
|
|
469
481
|
}
|
|
470
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Every order row for one tenant and stage, each with the source events recorded for it. Two
|
|
485
|
+
* reads, never one per order. A `scrappy` row is a final-cart reservation, not a paid order: a
|
|
486
|
+
* provider event names it in `admissionReference` once that payment reaches the ledger.
|
|
487
|
+
*/
|
|
488
|
+
async readOrderBook(tenantId: string, stage: CommerceStage): Promise<CommerceOrderBookEntry[]> {
|
|
489
|
+
const rows = await this.db.all<CommerceOrderRow>(
|
|
490
|
+
`SELECT * FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ?`,
|
|
491
|
+
tenantId,
|
|
492
|
+
stage,
|
|
493
|
+
);
|
|
494
|
+
const payloads = await this.db.all<{ order_id: string; payload: string }>(
|
|
495
|
+
`SELECT order_id, payload FROM scrappy_commerce_outbox WHERE tenant_id = ? AND stage = ?`,
|
|
496
|
+
tenantId,
|
|
497
|
+
stage,
|
|
498
|
+
);
|
|
499
|
+
const events = new Map<string, CommerceSourceEvent[]>();
|
|
500
|
+
for (const row of payloads) {
|
|
501
|
+
const parsed = JSON.parse(row.payload) as StoredCommercePayload;
|
|
502
|
+
if (!parsed.source) continue;
|
|
503
|
+
const list = events.get(row.order_id) ?? [];
|
|
504
|
+
list.push(parsed.source);
|
|
505
|
+
events.set(row.order_id, list);
|
|
506
|
+
}
|
|
507
|
+
return rows.map((row) => ({
|
|
508
|
+
orderId: row.order_id,
|
|
509
|
+
source: row.source,
|
|
510
|
+
placedAt: row.placed_at,
|
|
511
|
+
currency: row.currency,
|
|
512
|
+
payment: row.payment_state,
|
|
513
|
+
fulfillment: row.fulfillment_state,
|
|
514
|
+
amountMinor: row.amount_minor,
|
|
515
|
+
events: events.get(row.order_id) ?? [],
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
|
|
471
519
|
/** Reset one outbox row for this tenant and stage. Does not write another order. */
|
|
472
520
|
async replayOutbox(tenantId: string, stage: CommerceStage, outboxId: string, now: string): Promise<boolean> {
|
|
473
521
|
const row = await this.db.first<{ outbox_id: string }>(
|
|
@@ -1,23 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `GET /api/admin/orders/:id` — one order's full detail for the Orders tab.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Read-only.
|
|
4
|
+
* Reads one order from the source `resolveOrderSource` picks for the resolved tenant + env.
|
|
5
|
+
* Any bearer stays server-side. Address is included only for an owner of this tenant and stage.
|
|
6
|
+
* Readiness is assessed, not inferred. Read-only.
|
|
8
7
|
*/
|
|
9
8
|
import type { APIContext } from "astro";
|
|
10
|
-
import { readEnv } from "@/lib/publication/shared/env";
|
|
11
9
|
import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
orderApiBaseUrl,
|
|
16
|
-
OrderApiError,
|
|
17
|
-
projectCheckoutOrder,
|
|
18
|
-
} from "@/lib/orders/admin/orderClient";
|
|
10
|
+
import { commerceActorForAdmin, OrderApiError, projectCheckoutOrder } from "@/lib/orders/admin/orderClient";
|
|
11
|
+
import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
|
|
12
|
+
import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
|
|
19
13
|
import { assessCheckoutOrder } from "@/lib/orders/admin/services/orders";
|
|
20
|
-
import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
|
|
21
14
|
|
|
22
15
|
export const prerender = false;
|
|
23
16
|
|
|
@@ -43,29 +36,21 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
43
36
|
const orderId = context.params.id;
|
|
44
37
|
if (!orderId) return jsonError("order_not_found", 404);
|
|
45
38
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
const tenantId = tenant.appDomain.trim();
|
|
52
|
-
if (!tenantId) return jsonError("not_provisioned", 503);
|
|
53
|
-
|
|
54
|
-
const client = createCheckoutOrderClient({
|
|
55
|
-
baseUrl: orderApiBaseUrl(customizationEnv),
|
|
56
|
-
appDomain: tenantId,
|
|
57
|
-
bearer,
|
|
58
|
-
});
|
|
39
|
+
const source = await resolveOrderSource(
|
|
40
|
+
{ tenantId: tenant.appDomain, customizationEnv },
|
|
41
|
+
workerOrderSourceDeps(admitSharedStoreTenant),
|
|
42
|
+
);
|
|
43
|
+
if (source.kind === "not-connected") return jsonError(source.gap, source.gap === "stage-unknown" ? 409 : 503);
|
|
59
44
|
|
|
60
45
|
try {
|
|
61
|
-
const scope =
|
|
46
|
+
const scope = source.scope;
|
|
62
47
|
const actor = commerceActorForAdmin({
|
|
63
|
-
tenantId,
|
|
64
|
-
stage,
|
|
48
|
+
tenantId: scope.tenantId,
|
|
49
|
+
stage: scope.stage,
|
|
65
50
|
owner: access.entry.isOwnerViewer,
|
|
66
51
|
admitted: access.entry.admitted,
|
|
67
52
|
});
|
|
68
|
-
const order = projectCheckoutOrder(await client.getOrder(orderId), actor, scope);
|
|
53
|
+
const order = projectCheckoutOrder(await source.client.getOrder(orderId), actor, scope);
|
|
69
54
|
const readiness = assessCheckoutOrder(order, scope);
|
|
70
55
|
return new Response(JSON.stringify({ order, readiness }), { headers: JSON_HEADERS });
|
|
71
56
|
} catch (err) {
|
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `GET /api/admin/orders` — internal admin route backing the Orders tab.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* response); the browser only ever sees order DTO JSON. Read-only
|
|
4
|
+
* Reads the tenant's orders from the source `resolveOrderSource` picks (the platform ledger for
|
|
5
|
+
* a shared-checkout tenant, the store's partner API otherwise). Any partner bearer key stays on
|
|
6
|
+
* the Worker (never in the response); the browser only ever sees order DTO JSON. Read-only.
|
|
7
7
|
* The admin UI consumes this and maps the DTO → OrdersViewModel.
|
|
8
8
|
*
|
|
9
9
|
* Access control matches the rest of the admin surface (owned by the admin
|
|
10
10
|
* host/approver layer) — same posture as `api/evidence.json.ts`; no new gate here.
|
|
11
11
|
*/
|
|
12
12
|
import type { APIContext } from "astro";
|
|
13
|
-
import { readEnv } from "@/lib/publication/shared/env";
|
|
14
13
|
import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
OrderApiError,
|
|
19
|
-
} from "@/lib/orders/admin/orderClient";
|
|
20
|
-
import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
|
|
14
|
+
import { OrderApiError } from "@/lib/orders/admin/orderClient";
|
|
15
|
+
import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
|
|
16
|
+
import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
|
|
21
17
|
import { assessCheckoutOrder, parseOrderFilters } from "@/lib/orders/admin/services/orders";
|
|
22
18
|
|
|
23
19
|
export const prerender = false;
|
|
@@ -43,34 +39,21 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
43
39
|
const access = await requireAdminApiEntry(context);
|
|
44
40
|
if (!access.ok) return jsonError(access.reason, access.status);
|
|
45
41
|
|
|
46
|
-
const bearer = await readEnv("FOXY_ORDER_API_KEY");
|
|
47
|
-
if (!bearer) {
|
|
48
|
-
// Worker secret not provisioned for this env — a config error, not the
|
|
49
|
-
// caller's fault. Never echo the (missing) key.
|
|
50
|
-
return jsonError("not_provisioned", 503);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const stage = commerceStageForEnv(customizationEnv);
|
|
54
|
-
if (!stage) return jsonError("stage-unknown", 409);
|
|
55
|
-
const tenantId = tenant.appDomain.trim();
|
|
56
|
-
if (!tenantId) return jsonError("not_provisioned", 503);
|
|
57
|
-
|
|
58
|
-
const url = new URL(context.request.url);
|
|
59
42
|
// Tenant comes from the host, never from a query parameter.
|
|
60
|
-
const
|
|
43
|
+
const source = await resolveOrderSource(
|
|
44
|
+
{ tenantId: tenant.appDomain, customizationEnv },
|
|
45
|
+
workerOrderSourceDeps(admitSharedStoreTenant),
|
|
46
|
+
);
|
|
47
|
+
// An unconnected source is a config gap, not the caller's fault. Never echo a key.
|
|
48
|
+
if (source.kind === "not-connected") return jsonError(source.gap, source.gap === "stage-unknown" ? 409 : 503);
|
|
61
49
|
|
|
62
|
-
const
|
|
63
|
-
baseUrl: orderApiBaseUrl(customizationEnv),
|
|
64
|
-
appDomain: tenantId,
|
|
65
|
-
bearer,
|
|
66
|
-
});
|
|
50
|
+
const filters = parseOrderFilters(new URL(context.request.url).searchParams);
|
|
67
51
|
|
|
68
52
|
try {
|
|
69
|
-
const data = await client.listOrders(filters);
|
|
70
|
-
const scope = { tenantId, stage };
|
|
53
|
+
const data = await source.client.listOrders(filters);
|
|
71
54
|
const orders = data.orders.map((order) => ({
|
|
72
55
|
...order,
|
|
73
|
-
readiness: assessCheckoutOrder(order, scope),
|
|
56
|
+
readiness: assessCheckoutOrder(order, source.scope),
|
|
74
57
|
}));
|
|
75
58
|
return new Response(JSON.stringify({ ...data, orders }), { headers: JSON_HEADERS });
|
|
76
59
|
} catch (err) {
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import type { APIContext } from "astro";
|
|
3
3
|
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify, type JSONWebKeySet } from "jose";
|
|
4
4
|
import {
|
|
5
|
-
commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding,
|
|
5
|
+
commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding, cartLineKey,
|
|
6
6
|
type CallerToken, type CatalogProduct, type CatalogVariant, type CommerceEvidence, type CommerceEvidenceProvenance,
|
|
7
7
|
type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
|
|
8
8
|
} from "@tot/public-runtime";
|
|
9
|
+
import { skuFromToken } from "@tot/public-runtime/sku";
|
|
9
10
|
import { fromD1 } from "@/lib/catalog/d1/catalog.js";
|
|
10
11
|
import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
|
|
11
12
|
import { evaluateFinalCart, type RefusedLine, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
|
|
@@ -72,7 +73,7 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
|
|
|
72
73
|
typeof v.providerCartDigest !== "string" || !HEX.test(v.providerCartDigest) || typeof v.destinationDigest !== "string" || !HEX.test(v.destinationDigest) ||
|
|
73
74
|
typeof v.tenantId !== "string" || !["preview", "publication"].includes(String(v.stage)) || typeof v.currency !== "string" || !/^[A-Z]{3}$/.test(v.currency) ||
|
|
74
75
|
!Array.isArray(v.lines) || v.lines.length < 1 || v.lines.length > 50) return null;
|
|
75
|
-
if (v.lines.some(line => !line || typeof line !== "object" ||
|
|
76
|
+
if (v.lines.some(line => !line || typeof line !== "object" || skuFromToken(line.sku) === null ||
|
|
76
77
|
!Number.isSafeInteger(line.quantity) || line.quantity < 1 || line.quantity > 99 || !Number.isSafeInteger(line.unitMinor) || line.unitMinor < 0 ||
|
|
77
78
|
Object.keys(line).some(k => !["sku", "quantity", "unitMinor"].includes(k)))) return null;
|
|
78
79
|
if (!v.totals || typeof v.totals !== "object" || Object.keys(v.totals).length !== 4 ||
|
|
@@ -84,6 +85,15 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
|
|
|
84
85
|
if (!totalsMatch(snapshot.totals) || snapshot.lines.reduce((sum, line) => sum + line.quantity * line.unitMinor, 0) !== snapshot.totals.subtotalMinor) return null;
|
|
85
86
|
return snapshot;
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The snapshot with each line's checkout product code read back to its line key (the stored SKU,
|
|
90
|
+
* or the variant id for a variant with none). The provider digest covers the codes as the caller
|
|
91
|
+
* sent them; everything after it — catalog match, stock, holds, the admission record — is keyed
|
|
92
|
+
* by the line key, the same key inventory movements carry.
|
|
93
|
+
*/
|
|
94
|
+
function keyedLines(wire: CommerceProviderCartSnapshot): CommerceProviderCartSnapshot {
|
|
95
|
+
return { ...wire, lines: wire.lines.map(line => ({ ...line, sku: skuFromToken(line.sku)! })) };
|
|
96
|
+
}
|
|
87
97
|
/**
|
|
88
98
|
* The compliance seam under the shared test store's policy. When verification is required the
|
|
89
99
|
* cart's verification ref goes to preflight, whose live provider re-reads its real status
|
|
@@ -97,8 +107,8 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
|
|
|
97
107
|
getProductByHandle: async (handle: string) => selected.get(handle) ?? null,
|
|
98
108
|
} } } as APIContext, {
|
|
99
109
|
lines: snapshot.lines.map(line => {
|
|
100
|
-
const product = products.find(p => p.variants.some(v =>
|
|
101
|
-
const variant = product.variants.find(v =>
|
|
110
|
+
const product = products.find(p => p.variants.some(v => cartLineKey(v) === line.sku))!;
|
|
111
|
+
const variant = product.variants.find(v => cartLineKey(v) === line.sku)!;
|
|
102
112
|
return { handle: product.handle, variantId: variant.id, sku: line.sku, quantity: line.quantity };
|
|
103
113
|
}),
|
|
104
114
|
destination: snapshot.destination,
|
|
@@ -170,9 +180,10 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
170
180
|
if (Number(context.request.headers.get("content-length") ?? 0) > 65536) return deny("malformed-request");
|
|
171
181
|
const raw = await context.request.text();
|
|
172
182
|
if (raw.length > 65536) return deny("malformed-request");
|
|
173
|
-
let
|
|
174
|
-
try {
|
|
175
|
-
if (!
|
|
183
|
+
let wire: CommerceProviderCartSnapshot | null;
|
|
184
|
+
try { wire = readSnapshot(JSON.parse(raw)); } catch { wire = null; }
|
|
185
|
+
if (!wire) return deny("malformed-request");
|
|
186
|
+
const snapshot = keyedLines(wire);
|
|
176
187
|
const jwks = publicJwks(await readEnv("COMMERCE_CALLER_JWKS"));
|
|
177
188
|
const issuers = list(await readEnv("COMMERCE_CALLER_ISSUERS"));
|
|
178
189
|
const audience = await readEnv("COMMERCE_CALLER_AUDIENCE");
|
|
@@ -212,7 +223,7 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
212
223
|
: createContentProvider(tenant, { get: kvGet, env: contentEnv });
|
|
213
224
|
const tot = await createToTClient(tenant, await readD1("CATALOG_DB"), content);
|
|
214
225
|
const authorityContext = { ...context, locals: { ...context.locals, tenant, tot, content } } as APIContext;
|
|
215
|
-
const catalogPin = await catalogPinDigestFinding(
|
|
226
|
+
const catalogPin = await catalogPinDigestFinding(wire);
|
|
216
227
|
if (catalogPin) return deny(catalogPin);
|
|
217
228
|
let listing: CatalogProduct[];
|
|
218
229
|
try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
|
|
@@ -220,9 +231,9 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
220
231
|
const selected = new Map<string, CatalogProduct>();
|
|
221
232
|
const mismatch = (check: string, line?: number) => refuse("catalog-cart-mismatch", line === undefined ? { check } : { check, line });
|
|
222
233
|
if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return mismatch("store-currency");
|
|
223
|
-
// A line's `sku` is the
|
|
224
|
-
// variant id for a variant with none.
|
|
225
|
-
const coded = (line: { sku: string }) => (variant: CatalogVariant | null | undefined) => !!variant &&
|
|
234
|
+
// A line's `sku` is the line key the page's signed product code encodes (`cartLineKey`): the
|
|
235
|
+
// stored SKU, or the variant id for a variant with none.
|
|
236
|
+
const coded = (line: { sku: string }) => (variant: CatalogVariant | null | undefined) => !!variant && cartLineKey(variant) === line.sku;
|
|
226
237
|
for (const [index, line] of snapshot.lines.entries()) {
|
|
227
238
|
const matches = listing.flatMap(product => product && product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope && Array.isArray(product.variants)
|
|
228
239
|
? product.variants.filter(coded(line)).map(variant => ({ product, variant })) : []);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.1",
|
|
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",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"./image-release-gate": "./src/image-release-gate.ts",
|
|
27
27
|
"./pool-asset-headers": "./src/pool-asset-headers.ts",
|
|
28
28
|
"./catalog-files": "./src/catalog-files.ts",
|
|
29
|
+
"./sku": "./src/sku.ts",
|
|
29
30
|
"./embed-catalog": "./src/embed-catalog.mjs",
|
|
30
31
|
"./retention-holds": "./src/retention-holds.ts",
|
|
31
32
|
"./publication-content-redirects": "./src/publication-content-redirects.ts",
|
|
@@ -28,6 +28,7 @@ import { resolveStoreCurrency } from "./declared-tenant-config.mjs";
|
|
|
28
28
|
import { DEFAULT_ONE_TIME_PURCHASE_OPTION } from "./product.js";
|
|
29
29
|
import type { ShippingPolicy, TenantConfig } from "./tenant.js";
|
|
30
30
|
import { isContentVersionId } from "./checkout-orders.js";
|
|
31
|
+
import { skuToken } from "./sku.js";
|
|
31
32
|
|
|
32
33
|
/** Stable commerce scope shared by signed carts, provider callers and operator authorization. */
|
|
33
34
|
export function commerceTenantId(tenant: Pick<TenantConfig, "appDomain">): string {
|
|
@@ -501,13 +502,22 @@ export function resolveManagedCheckout(
|
|
|
501
502
|
}
|
|
502
503
|
|
|
503
504
|
/**
|
|
504
|
-
* The
|
|
505
|
-
*
|
|
505
|
+
* The line identity of a variant across commerce: its stored SKU, or its id when it has none.
|
|
506
|
+
* Cart lines, holds, allocations and inventory movements are all keyed by it.
|
|
506
507
|
*/
|
|
507
|
-
export function
|
|
508
|
+
export function cartLineKey(variant: Pick<CatalogVariant, "id" | "sku">): string {
|
|
508
509
|
return variant.sku && variant.sku.length ? variant.sku : variant.id;
|
|
509
510
|
}
|
|
510
511
|
|
|
512
|
+
/**
|
|
513
|
+
* The checkout product code for a variant: its {@link cartLineKey} as a `skuToken`, so a SKU with
|
|
514
|
+
* a space or any other character still makes a token-safe code. Every field is hashed against it;
|
|
515
|
+
* the final-cart authority reads it back with `skuFromToken`.
|
|
516
|
+
*/
|
|
517
|
+
export function cartLineCode(variant: Pick<CatalogVariant, "id" | "sku">): string {
|
|
518
|
+
return skuToken(cartLineKey(variant));
|
|
519
|
+
}
|
|
520
|
+
|
|
511
521
|
/**
|
|
512
522
|
* The raw (unsigned) cart fields for a variant: name, price, code, image, the
|
|
513
523
|
* variant's options, and an editable quantity. `code` is the checkout product code
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SKU contract for the platform. It lives once, in the agency kit's pure catalog module
|
|
3
|
+
* (`agency-kit/tools/lib/catalog-model/sku.mjs`), which the catalog file reader, the kit's tools
|
|
4
|
+
* and this runtime all import.
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
CHECKOUT_CODE_MAX_LENGTH,
|
|
8
|
+
SKU_MAX_LENGTH,
|
|
9
|
+
SKU_TOKEN_MAX_LENGTH,
|
|
10
|
+
isStoredSku,
|
|
11
|
+
parseSku,
|
|
12
|
+
skuCollisions,
|
|
13
|
+
skuFindings,
|
|
14
|
+
skuFromToken,
|
|
15
|
+
skuKey,
|
|
16
|
+
skuToken,
|
|
17
|
+
type SkuFinding,
|
|
18
|
+
type SkuRefusal,
|
|
19
|
+
} from "../../../agency-kit/tools/lib/catalog-model/sku.mjs";
|
|
@@ -276,6 +276,10 @@ function assertFixtureTree(path) {
|
|
|
276
276
|
if (info.isDirectory()) for (const name of readdirSync(path)) assertFixtureTree(join(path, name));
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
export function assertPreviewBuildConfig(config) {
|
|
280
|
+
if (config.targetEnvironment !== "preview" || config.no_bundle !== true) throw fixedError("refresh_build_config_invalid");
|
|
281
|
+
}
|
|
282
|
+
|
|
279
283
|
function embedsSha(path, sha) {
|
|
280
284
|
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
281
285
|
const file = join(path, entry.name);
|
|
@@ -373,10 +377,10 @@ export const realEffects = {
|
|
|
373
377
|
for (const [path, hash] of Object.entries(pin.files)) {
|
|
374
378
|
if (digest(execFileSync("git", ["-C", catalog, "show", `${options.catalogSha}:${path}`], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024 })) !== hash) throw fixedError("refresh_contract_hash_mismatch");
|
|
375
379
|
}
|
|
376
|
-
if (prior.storefrontSha === options.storefrontSha) return {};
|
|
377
380
|
for (const rel of e2eCatalogFixtures(prior.preview)) {
|
|
378
381
|
if (existsSync(join(prior.preview, rel)) && git(prior.preview, "ls-tree", "-r", "--name-only", options.storefrontSha, "--", rel)) throw fixedError("refresh_fixture_tracked_collision");
|
|
379
382
|
}
|
|
383
|
+
if (prior.storefrontSha === options.storefrontSha) return {};
|
|
380
384
|
let checkout;
|
|
381
385
|
let catalogCheckout;
|
|
382
386
|
try {
|
|
@@ -409,7 +413,7 @@ export const realEffects = {
|
|
|
409
413
|
if (git(checkout, "rev-parse", "HEAD") !== options.storefrontSha || git(checkout, "status", "--porcelain", "--untracked-files=no")) throw fixedError("refresh_build_source_changed");
|
|
410
414
|
const config = JSON.parse(readFileSync(join(checkout, "apps/storefront/dist/server/wrangler.json"), "utf8"));
|
|
411
415
|
if (!embedsSha(join(checkout, "apps/storefront/dist/server"), options.storefrontSha)) throw fixedError("refresh_build_ref_missing");
|
|
412
|
-
|
|
416
|
+
assertPreviewBuildConfig(config);
|
|
413
417
|
const priorConfig = JSON.parse(readFileSync(join(prior.checkout, "apps/storefront/dist/server/wrangler.json"), "utf8"));
|
|
414
418
|
for (const key of ["d1_databases", "kv_namespaces", "r2_buckets", "durable_objects", "migrations"]) {
|
|
415
419
|
if (JSON.stringify(config[key] ?? null) !== JSON.stringify(priorConfig[key] ?? null)) throw fixedError("refresh_state_bindings_changed");
|