@tokenoftrust/storefront-runner 3.10.2 → 3.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +15 -3
- package/apps/storefront/src/lib/inventory/allocations.ts +22 -13
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +13 -6
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout-orders.ts +1 -1
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Final-cart authorization decision. Payment is admitted only when the caller
|
|
3
3
|
* token, the bound cart, fresh evidence, the verification seam, and mirror stock
|
|
4
4
|
* all agree. Catalog availability and a browser confirmation are not authority.
|
|
5
|
+
* A tenant that declares no inventory provider does not track stock: its stock
|
|
6
|
+
* evidence is `not-required` and its holds carry no balance.
|
|
5
7
|
*/
|
|
6
8
|
import {
|
|
7
9
|
admitCallerToken,
|
|
@@ -43,6 +45,11 @@ export interface FinalCartRequest {
|
|
|
43
45
|
trustedIssuers: readonly string[];
|
|
44
46
|
expectedAudience: string;
|
|
45
47
|
activeKids: readonly string[];
|
|
48
|
+
/**
|
|
49
|
+
* False only when the tenant declares no inventory provider; `authorities` is read otherwise.
|
|
50
|
+
* Anything but an explicit `false` counts as tracked, so a caller that omits it fails closed.
|
|
51
|
+
*/
|
|
52
|
+
stockTracked: boolean;
|
|
46
53
|
authorities: readonly unknown[];
|
|
47
54
|
payment?: PaymentState;
|
|
48
55
|
stock?: StockState;
|
|
@@ -90,11 +97,11 @@ function cartFinding(quoted: FinalCartBinding, presented: FinalCartBinding, rout
|
|
|
90
97
|
return null;
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
function evidenceFinding(evidence: CommerceEvidence | null): string | null {
|
|
100
|
+
function evidenceFinding(evidence: CommerceEvidence | null, stockTracked: boolean): string | null {
|
|
94
101
|
if (!evidence) return "missing-evidence";
|
|
95
102
|
for (const field of EVIDENCE_FIELDS) {
|
|
96
103
|
const state: EvidenceState | string = evidence[field];
|
|
97
|
-
if (state === "fresh" || (
|
|
104
|
+
if (state === "fresh" || (state === "not-required" && (field !== "stock" || !stockTracked))) continue;
|
|
98
105
|
if (state === "UNKNOWN") return `evidence-${field}-unknown`;
|
|
99
106
|
if (state === "stale" || state === "unreadable" || state === "absent") return `evidence-${field}-${state}`;
|
|
100
107
|
return `evidence-${field}-unreadable`;
|
|
@@ -157,7 +164,8 @@ export async function evaluateFinalCart(input: FinalCartRequest): Promise<FinalC
|
|
|
157
164
|
|
|
158
165
|
if (input.presented.lines.length < 1) return { ok: false, finding: "malformed-request" };
|
|
159
166
|
|
|
160
|
-
const
|
|
167
|
+
const stockTracked = input.stockTracked !== false;
|
|
168
|
+
const evidence = evidenceFinding(input.evidence, stockTracked);
|
|
161
169
|
if (evidence) return { ok: false, finding: evidence };
|
|
162
170
|
|
|
163
171
|
const expiresAt = earlierExpiry(input.presented.verificationExpiresAt, input.presented.taxExpiresAt);
|
|
@@ -205,6 +213,10 @@ export async function evaluateFinalCart(input: FinalCartRequest): Promise<FinalC
|
|
|
205
213
|
const holds: ReserveHold[] = [];
|
|
206
214
|
for (const sku of [...quantities.keys()].sort()) {
|
|
207
215
|
const quantity = quantities.get(sku)!;
|
|
216
|
+
if (!stockTracked) {
|
|
217
|
+
holds.push({ sku, locationId: "unspecified", quantity, onHand: 0 });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
208
220
|
const named =
|
|
209
221
|
input.authorities.find((value) => {
|
|
210
222
|
if (!value || typeof value !== "object") return false;
|
|
@@ -88,6 +88,11 @@ export interface ReserveInput {
|
|
|
88
88
|
admissionStatement?: SqlStatement;
|
|
89
89
|
/** Immutable provider identity, claimed atomically with its receipt and holds. */
|
|
90
90
|
providerCart?: { provider: "foxycart"; id: string; digest: string; expectedAuthorizationId?: string };
|
|
91
|
+
/**
|
|
92
|
+
* The tenant declares no inventory provider: each hold records the order's units so payment
|
|
93
|
+
* confirmation and release work unchanged, but no balance bounds it.
|
|
94
|
+
*/
|
|
95
|
+
stockUntracked?: boolean;
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
export type ReserveResult =
|
|
@@ -255,17 +260,28 @@ function reserveShape(input: ReserveInput): ReserveResult | null {
|
|
|
255
260
|
}
|
|
256
261
|
|
|
257
262
|
function allocationInsert(input: ReserveInput, hold: ReserveHold, seenAt: string): SqlStatement {
|
|
258
|
-
|
|
259
|
-
sql: `INSERT INTO scrappy_allocations (tenant_id, stage, order_id, sku, location_id, quantity, state)
|
|
260
|
-
SELECT ?, ?, ?, ?, ?, ?, 'reserved'
|
|
261
|
-
WHERE (
|
|
263
|
+
const bound = input.stockUntracked ? "" : `(
|
|
262
264
|
SELECT COALESCE(SUM(quantity), 0) FROM scrappy_allocations
|
|
263
265
|
WHERE tenant_id = ? AND stage = ? AND sku = ? AND location_id = ? AND state IN ('reserved', 'confirmed')
|
|
264
266
|
) + ? <= ${input.admissionStatement
|
|
265
267
|
? `(SELECT COALESCE(SUM(quantity_delta), 0) FROM commerce_inventory_movements
|
|
266
268
|
WHERE tenant_id = ? AND stage = ? AND sku = ? AND location_id = ?)`
|
|
267
269
|
: "?"}
|
|
268
|
-
AND
|
|
270
|
+
AND `;
|
|
271
|
+
const boundParams = input.stockUntracked ? [] : [
|
|
272
|
+
input.tenantId,
|
|
273
|
+
input.stage,
|
|
274
|
+
hold.sku,
|
|
275
|
+
hold.locationId,
|
|
276
|
+
hold.quantity,
|
|
277
|
+
...(input.admissionStatement
|
|
278
|
+
? [input.tenantId, input.stage, hold.sku, hold.locationId]
|
|
279
|
+
: [hold.onHand]),
|
|
280
|
+
];
|
|
281
|
+
return {
|
|
282
|
+
sql: `INSERT INTO scrappy_allocations (tenant_id, stage, order_id, sku, location_id, quantity, state)
|
|
283
|
+
SELECT ?, ?, ?, ?, ?, ?, 'reserved'
|
|
284
|
+
WHERE ${bound}NOT EXISTS (
|
|
269
285
|
SELECT 1 FROM scrappy_allocations
|
|
270
286
|
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND sku = ? AND location_id = ?
|
|
271
287
|
AND state IN ('reserved', 'confirmed')
|
|
@@ -292,14 +308,7 @@ function allocationInsert(input: ReserveInput, hold: ReserveHold, seenAt: string
|
|
|
292
308
|
hold.sku,
|
|
293
309
|
hold.locationId,
|
|
294
310
|
hold.quantity,
|
|
295
|
-
|
|
296
|
-
input.stage,
|
|
297
|
-
hold.sku,
|
|
298
|
-
hold.locationId,
|
|
299
|
-
hold.quantity,
|
|
300
|
-
...(input.admissionStatement
|
|
301
|
-
? [input.tenantId, input.stage, hold.sku, hold.locationId]
|
|
302
|
-
: [hold.onHand]),
|
|
311
|
+
...boundParams,
|
|
303
312
|
input.tenantId,
|
|
304
313
|
input.stage,
|
|
305
314
|
input.orderId,
|
|
@@ -233,12 +233,17 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
233
233
|
const requirements = await readCheckoutRequirements(tenant.tenant_id, contentEnv);
|
|
234
234
|
if (!requirements) return deny("checkout-requirements-unreadable", 503);
|
|
235
235
|
const policy = testStoreEvidencePolicy(requirements);
|
|
236
|
+
// Stock is tracked only for a tenant that declares the `mirror` provider. An undeclared tenant
|
|
237
|
+
// has no balance to read, so its stock evidence is honestly `not-required`, not a refusal.
|
|
238
|
+
const stockTracked = tenant.inventory?.provider === "mirror";
|
|
236
239
|
const authorities = [];
|
|
237
|
-
|
|
238
|
-
|
|
240
|
+
if (stockTracked) {
|
|
241
|
+
for (const sku of new Set(snapshot.lines.map(line => line.sku))) {
|
|
242
|
+
authorities.push(await readMirrorStock(queryable, { tenantId: snapshot.tenantId, stage: snapshot.stage, sku, locationId: "unspecified", now, maxAgeMs: 15 * 60_000 }));
|
|
243
|
+
}
|
|
239
244
|
}
|
|
240
245
|
const stockExpiry = Math.min(...authorities.flatMap(authority => authority ? [Date.parse(authority.asOf) + 15 * 60_000] : []));
|
|
241
|
-
const evidence: CommerceEvidence = { verification: "absent", tax: "absent", risk: "absent", stock: "fresh" };
|
|
246
|
+
const evidence: CommerceEvidence = { verification: "absent", tax: "absent", risk: "absent", stock: stockTracked ? "fresh" : "not-required" };
|
|
242
247
|
const expiresAt = existing?.expiresAt ?? new Date(Math.min(Date.now() + 5 * 60_000, stockExpiry)).toISOString();
|
|
243
248
|
const provenance = {} as CommerceEvidenceProvenance;
|
|
244
249
|
for (const field of ["verification", "tax", "risk"] as const) {
|
|
@@ -247,7 +252,9 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
247
252
|
provenance[field] = { requirement: "not-required", state: "not-required", source: SHARED_STORE_POLICY_SOURCE, policyRevision: SHARED_STORE_POLICY_REVISION, expiresAt };
|
|
248
253
|
}
|
|
249
254
|
}
|
|
250
|
-
provenance.stock =
|
|
255
|
+
provenance.stock = stockTracked
|
|
256
|
+
? { requirement: "required", state: "satisfied", source: "tenant-inventory-movements", policyRevision: "mirror-v1", expiresAt }
|
|
257
|
+
: { requirement: "not-required", state: "not-required", source: "tenant-inventory-undeclared", policyRevision: "untracked-v1", expiresAt };
|
|
251
258
|
const presented: FinalCartBinding = { tenantId: snapshot.tenantId, stage: snapshot.stage, currency: snapshot.currency, lines: snapshot.lines,
|
|
252
259
|
destinationDigest: snapshot.destinationDigest, verificationExpiresAt: expiresAt, taxExpiresAt: expiresAt };
|
|
253
260
|
const seamView = await seam(authorityContext, snapshot, selected, policy);
|
|
@@ -257,7 +264,7 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
257
264
|
}
|
|
258
265
|
const decision = await evaluateFinalCart({ now, routedTenantId: commerceTenantId(tenant), quoted: presented, presented, evidence,
|
|
259
266
|
seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
|
|
260
|
-
trustedIssuers: issuers, expectedAudience: audience, activeKids, authorities });
|
|
267
|
+
trustedIssuers: issuers, expectedAudience: audience, activeKids, stockTracked, authorities });
|
|
261
268
|
if (!decision.ok) {
|
|
262
269
|
if (decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors) {
|
|
263
270
|
return json({ ok: false, finding: decision.finding, flavors: seamView.flavors }, 409);
|
|
@@ -269,7 +276,7 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
269
276
|
const cartDigest = existing?.cartDigest ?? await digest(`commerce-provider-cart-v1\n${snapshot.providerCartId}\n${snapshot.providerCartDigest}`);
|
|
270
277
|
const reserved = await reserveAllocation(d1CommerceRunner(database), { tenantId: decision.tenantId, stage: decision.stage, orderId: authorizationId,
|
|
271
278
|
cartDigest, expiresAt: decision.expiresAt, now, currency: snapshot.currency, amountMinor: snapshot.totals.totalMinor,
|
|
272
|
-
issuer: decision.issuer, jti: decision.jti, holds: decision.holds,
|
|
279
|
+
issuer: decision.issuer, jti: decision.jti, holds: decision.holds, stockUntracked: !stockTracked,
|
|
273
280
|
providerCart: { provider: snapshot.provider, id: snapshot.providerCartId, digest: snapshot.providerCartDigest, expectedAuthorizationId: snapshot.expectedAuthorizationId },
|
|
274
281
|
admissionStatement: admissionSnapshotStatement({ ...snapshot, authorizationId, cartDigest, evidence, provenance, expiresAt: decision.expiresAt, now }),
|
|
275
282
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.3",
|
|
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",
|
|
@@ -401,7 +401,7 @@ export type CallerTokenRefusal =
|
|
|
401
401
|
| "stage";
|
|
402
402
|
|
|
403
403
|
export function evidencePermitsRelease(evidence: CommerceEvidence): boolean {
|
|
404
|
-
return
|
|
404
|
+
return (["verification", "tax", "risk", "stock"] as const)
|
|
405
405
|
.every(field => evidence[field] === "fresh" || evidence[field] === "not-required");
|
|
406
406
|
}
|
|
407
407
|
|