@tokenoftrust/storefront-runner 3.10.2 → 3.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/migrations-apps/0019_commerce_source_authority.sql +23 -0
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +15 -3
- package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +7 -5
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +39 -12
- package/apps/storefront/src/lib/fulfillment/readiness.ts +8 -1
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +23 -25
- package/apps/storefront/src/lib/inventory/allocations.ts +22 -13
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +37 -7
- package/apps/storefront/src/lib/orders/data/ordersStore.ts +9 -2
- package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +379 -0
- package/apps/storefront/src/lib/orders/data/sourceAuthorityStore.ts +830 -0
- 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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- Source authority is independent of business event arrival and fulfillment progress.
|
|
2
|
+
CREATE TABLE commerce_source_authority (
|
|
3
|
+
tenant_id TEXT NOT NULL, stage TEXT NOT NULL CHECK(stage IN ('preview','publication')),
|
|
4
|
+
source TEXT NOT NULL, provider_store_id TEXT NOT NULL, source_order_id TEXT NOT NULL,
|
|
5
|
+
order_id TEXT, source_state_version INTEGER NOT NULL, check_revision INTEGER NOT NULL,
|
|
6
|
+
active_check_id TEXT, pending_check INTEGER NOT NULL DEFAULT 1,
|
|
7
|
+
accepted_ledger_generation TEXT, accepted_business_event_set_digest TEXT,
|
|
8
|
+
source_proof_expires_at TEXT, source_sync_pending INTEGER NOT NULL DEFAULT 1,
|
|
9
|
+
unresolved_count INTEGER NOT NULL DEFAULT 0, authority_budget INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
binding_digest TEXT NOT NULL, state_json TEXT NOT NULL, mutation_id TEXT NOT NULL,
|
|
11
|
+
PRIMARY KEY(tenant_id,stage,source,provider_store_id,source_order_id),
|
|
12
|
+
UNIQUE(tenant_id,stage,source,source_order_id), UNIQUE(tenant_id,stage,order_id)
|
|
13
|
+
);
|
|
14
|
+
-- Immutable issued checks, results and hold openings survive retries and process restarts.
|
|
15
|
+
CREATE TABLE commerce_source_observations (
|
|
16
|
+
tenant_id TEXT NOT NULL, stage TEXT NOT NULL, source TEXT NOT NULL,
|
|
17
|
+
provider_store_id TEXT NOT NULL, source_order_id TEXT NOT NULL,
|
|
18
|
+
record_kind TEXT NOT NULL CHECK(record_kind IN ('check','result','hold','manifest')),
|
|
19
|
+
record_id TEXT NOT NULL, payload_digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
|
20
|
+
resolved_version INTEGER, resolved_generation TEXT,
|
|
21
|
+
PRIMARY KEY(tenant_id,stage,source,provider_store_id,source_order_id,record_kind,record_id)
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX commerce_source_observations_scope ON commerce_source_observations(tenant_id,stage,source,source_order_id,record_kind);
|
|
@@ -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;
|
|
@@ -11,6 +11,7 @@ export async function recoverTenantCommerce(
|
|
|
11
11
|
now: string,
|
|
12
12
|
deployedSha: string,
|
|
13
13
|
): Promise<number | null> {
|
|
14
|
+
let incomplete=false;
|
|
14
15
|
for (const stage of ["preview", "publication"] as const) {
|
|
15
16
|
const runId = crypto.randomUUID();
|
|
16
17
|
await db.run(
|
|
@@ -37,16 +38,17 @@ export async function recoverTenantCommerce(
|
|
|
37
38
|
});
|
|
38
39
|
},
|
|
39
40
|
});
|
|
40
|
-
if
|
|
41
|
+
if(!result.ok)incomplete=true;
|
|
41
42
|
await db.run(
|
|
42
43
|
`UPDATE commerce_recovery_runs SET completed_at=?,delivered=?,retried=?,dead=? WHERE run_id=?`,
|
|
43
|
-
now,
|
|
44
|
-
result.delivered,
|
|
45
|
-
result.retried,
|
|
46
|
-
result.dead,
|
|
44
|
+
result.ok?now:null,
|
|
45
|
+
result.delivered ?? 0,
|
|
46
|
+
result.retried ?? 0,
|
|
47
|
+
result.dead ?? 0,
|
|
47
48
|
runId,
|
|
48
49
|
);
|
|
49
50
|
}
|
|
51
|
+
if(incomplete)throw new Error("commerce-recovery-incomplete");
|
|
50
52
|
const due = await db.first<{ available_at: string }>(
|
|
51
53
|
`SELECT MIN(available_at) available_at FROM scrappy_commerce_outbox WHERE tenant_id=? AND state='pending'`,
|
|
52
54
|
tenantId,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* read the admin fulfillment mock. Label purchase stays an intent row for a
|
|
7
7
|
* separate adapter. UI fields are not source truth.
|
|
8
8
|
*/
|
|
9
|
+
import { authorityHolds, authorityFence, type AuthorityRow, type SourceReadiness } from "../orders/data/sourceAuthorityStore.js";
|
|
9
10
|
import type { APIContext } from "astro";
|
|
10
11
|
import {
|
|
11
12
|
COMPLIANCE_STATES,
|
|
@@ -149,6 +150,8 @@ export type FulfillmentResult<T> =
|
|
|
149
150
|
| { ok: false; code: string; holds?: readonly ReleaseHold[] };
|
|
150
151
|
|
|
151
152
|
interface LoadedOrder {
|
|
153
|
+
sourceAuthority: AuthorityRow|null;
|
|
154
|
+
sourceHolds: SourceReadiness[];
|
|
152
155
|
order: {
|
|
153
156
|
tenantId: string;
|
|
154
157
|
stage: CommerceStage;
|
|
@@ -185,7 +188,7 @@ interface LoadedOrder {
|
|
|
185
188
|
type CommitPlan =
|
|
186
189
|
| { type: "done"; view: FulfillmentView }
|
|
187
190
|
| { type: "refuse"; code: string; holds?: readonly ReleaseHold[] }
|
|
188
|
-
| { type: "write"; statements: SqlStatement[]; minimumChanges: number };
|
|
191
|
+
| { type: "write"; statements: SqlStatement[]; minimumChanges: number; sourceAdvance?:boolean };
|
|
189
192
|
|
|
190
193
|
const FAIL_CLOSED_EVIDENCE: CommerceEvidence = {
|
|
191
194
|
verification: "unreadable",
|
|
@@ -655,8 +658,10 @@ export class FulfillmentService {
|
|
|
655
658
|
return this.batch(actor, [...batches][0] ?? "");
|
|
656
659
|
}
|
|
657
660
|
if (batches.size > 0) return refuse("conflict");
|
|
661
|
+
const sourceBlocked=states.find(s=>s.sourceHolds.length);
|
|
662
|
+
if(sourceBlocked)return refuse(sourceBlocked.sourceHolds[0]!,sourceBlocked.sourceHolds);
|
|
658
663
|
const batchId = `batch_${crypto.randomUUID()}`;
|
|
659
|
-
const statements: SqlStatement[] = [claimUpdate(states)];
|
|
664
|
+
const statements: SqlStatement[] = [claimUpdate(states,this.now())];
|
|
660
665
|
states.forEach((state, index) => {
|
|
661
666
|
statements.push(
|
|
662
667
|
auditStatement(
|
|
@@ -670,7 +675,7 @@ export class FulfillmentService {
|
|
|
670
675
|
),
|
|
671
676
|
);
|
|
672
677
|
});
|
|
673
|
-
const changes = await this.write(statements);
|
|
678
|
+
const changes = await this.write(statements,states.length);
|
|
674
679
|
if (!changes) return refuse("unavailable");
|
|
675
680
|
if ((changes[0] ?? 0) === states.length) return this.batch(actor, batchId);
|
|
676
681
|
if (expectedVersions) return refuse("conflict");
|
|
@@ -823,10 +828,12 @@ export class FulfillmentService {
|
|
|
823
828
|
if (state.shipment.labelPurchaseState === mapped.shipment && state.labelOutcome?.adultSignature === input.adultSignature) {
|
|
824
829
|
return { type: "done", view: state.view };
|
|
825
830
|
}
|
|
831
|
+
if(input.outcome==="purchased"&&state.sourceHolds.length)return {type:"refuse",code:state.sourceHolds[0]!,holds:state.sourceHolds};
|
|
826
832
|
const version = state.order.eventVersion + 1;
|
|
827
833
|
return {
|
|
828
834
|
type: "write",
|
|
829
835
|
minimumChanges: 1,
|
|
836
|
+
sourceAdvance:input.outcome==="purchased",
|
|
830
837
|
statements: [
|
|
831
838
|
{
|
|
832
839
|
sql: `UPDATE scrappy_commerce_orders
|
|
@@ -906,6 +913,7 @@ export class FulfillmentService {
|
|
|
906
913
|
return {
|
|
907
914
|
type: "write",
|
|
908
915
|
minimumChanges: 1,
|
|
916
|
+
sourceAdvance:true,
|
|
909
917
|
statements: [
|
|
910
918
|
{
|
|
911
919
|
sql: `UPDATE scrappy_commerce_orders
|
|
@@ -967,6 +975,7 @@ export class FulfillmentService {
|
|
|
967
975
|
|
|
968
976
|
private readiness(state: LoadedOrder): ReadinessInput {
|
|
969
977
|
return {
|
|
978
|
+
sourceHolds: state.sourceHolds,
|
|
970
979
|
payment: state.order.payment,
|
|
971
980
|
stock: state.order.stock,
|
|
972
981
|
fulfillment: state.order.fulfillment,
|
|
@@ -1032,7 +1041,7 @@ export class FulfillmentService {
|
|
|
1032
1041
|
});
|
|
1033
1042
|
statements.push(...intent);
|
|
1034
1043
|
}
|
|
1035
|
-
return { type: "write", statements, minimumChanges: 1 };
|
|
1044
|
+
return { type: "write", statements, minimumChanges: 1, sourceAdvance:true };
|
|
1036
1045
|
});
|
|
1037
1046
|
}
|
|
1038
1047
|
|
|
@@ -1099,7 +1108,11 @@ export class FulfillmentService {
|
|
|
1099
1108
|
if (plan.type === "done") return ok(plan.view);
|
|
1100
1109
|
if (plan.type === "refuse") return refuse(plan.code, plan.holds);
|
|
1101
1110
|
if (expectedVersion !== undefined && state.order.eventVersion !== expectedVersion) return refuse("conflict");
|
|
1102
|
-
const
|
|
1111
|
+
const fence=authorityFence(state.sourceAuthority,this.now(),plan.sourceAdvance===true);
|
|
1112
|
+
const first=plan.statements[0];
|
|
1113
|
+
if(!first)return refuse("unavailable");
|
|
1114
|
+
const guarded=[{sql:`${first.sql} AND (${fence.sql})`,params:[...first.params,...fence.params]},...plan.statements.slice(1)];
|
|
1115
|
+
const changes = await this.write(guarded);
|
|
1103
1116
|
if (!changes) return refuse("unavailable");
|
|
1104
1117
|
if ((changes[0] ?? 0) >= plan.minimumChanges) {
|
|
1105
1118
|
const next = await this.load(scope.tenantId, scope.stage, orderId);
|
|
@@ -1110,10 +1123,17 @@ export class FulfillmentService {
|
|
|
1110
1123
|
return refuse("conflict");
|
|
1111
1124
|
}
|
|
1112
1125
|
|
|
1113
|
-
private async write(statements: readonly SqlStatement[]): Promise<number[] | null> {
|
|
1126
|
+
private async write(statements: readonly SqlStatement[],minimumChanges=1): Promise<number[] | null> {
|
|
1114
1127
|
try {
|
|
1115
|
-
|
|
1116
|
-
|
|
1128
|
+
if(!statements.length)return [];
|
|
1129
|
+
// A competing transaction can occupy the expected next event version. Abort this
|
|
1130
|
+
// entire batch unless its first CAS actually wrote; version-only side effects are unsafe.
|
|
1131
|
+
const gated=[statements[0]!,{sql:`SELECT json(CASE WHEN changes() >= ? THEN 'true' ELSE 'cas-not-applied' END)`,params:[minimumChanges]},...statements.slice(1)];
|
|
1132
|
+
const changes=await this.db.batch(gated);
|
|
1133
|
+
return [changes[0] ?? 0,...changes.slice(2)];
|
|
1134
|
+
} catch(error) {
|
|
1135
|
+
let cause:unknown=error;
|
|
1136
|
+
for(let depth=0;depth<4&&cause instanceof Error;depth++,cause=cause.cause){if(cause.message.includes("malformed JSON"))return statements.map(()=>0);}
|
|
1117
1137
|
return null;
|
|
1118
1138
|
}
|
|
1119
1139
|
}
|
|
@@ -1222,7 +1242,7 @@ export class FulfillmentService {
|
|
|
1222
1242
|
);
|
|
1223
1243
|
if (snapshot.sourceVersion !== undefined) statements.push(projectionCheckpoint(snapshot,version,this.now()));
|
|
1224
1244
|
const changes = await this.write(statements);
|
|
1225
|
-
if (!changes) return refuse("unavailable");
|
|
1245
|
+
if (!changes) {const existing=await this.load(snapshot.tenantId,snapshot.stage,snapshot.orderId);return existing?"exists":refuse("unavailable");}
|
|
1226
1246
|
if ((changes[0] ?? 0) !== 1) return "exists";
|
|
1227
1247
|
const loaded = await this.load(snapshot.tenantId, snapshot.stage, snapshot.orderId);
|
|
1228
1248
|
return loaded ? ok(loaded.view) : refuse("unavailable");
|
|
@@ -1437,7 +1457,12 @@ export class FulfillmentService {
|
|
|
1437
1457
|
amountMinor: Number(row.amount_minor),
|
|
1438
1458
|
eventVersion: Number(row.event_version),
|
|
1439
1459
|
};
|
|
1460
|
+
let sourceAuthority:AuthorityRow|null=null;let sourceHolds:SourceReadiness[];
|
|
1461
|
+
try { sourceAuthority=await this.db.get<AuthorityRow & Record<string,unknown>>(`SELECT * FROM commerce_source_authority WHERE tenant_id=? AND stage=? AND source=? AND source_order_id=?`,[tenantId,stage,order.source,order.sourceOrderId]) ?? null;
|
|
1462
|
+
sourceHolds=sourceAuthority?.order_id!==order.orderId?["source-authority-unavailable"]:authorityHolds(sourceAuthority,this.now());
|
|
1463
|
+
}catch{sourceHolds=["source-authority-unreadable"];}
|
|
1440
1464
|
const loaded: LoadedOrder = {
|
|
1465
|
+
sourceAuthority,sourceHolds,
|
|
1441
1466
|
order,
|
|
1442
1467
|
evidence,
|
|
1443
1468
|
lines,
|
|
@@ -1477,6 +1502,7 @@ export class FulfillmentService {
|
|
|
1477
1502
|
const readiness = this.readiness(loaded);
|
|
1478
1503
|
const holds = releaseHolds(readiness);
|
|
1479
1504
|
loaded.view.holds = holds;
|
|
1505
|
+
if(sourceHolds.length&&order.fulfillment==="ready")loaded.view.fulfillment="held";
|
|
1480
1506
|
loaded.view.canRelease = canRelease(readiness) && !loaded.shipment;
|
|
1481
1507
|
loaded.view.canHandOff = handoffRefusal(readiness) === null && loaded.shipment?.handoffState !== "handed_off";
|
|
1482
1508
|
loaded.view.shipment = loaded.shipment
|
|
@@ -1517,11 +1543,12 @@ function touchOrder(state: LoadedOrder, wantIntent: boolean): SqlStatement {
|
|
|
1517
1543
|
};
|
|
1518
1544
|
}
|
|
1519
1545
|
|
|
1520
|
-
function claimUpdate(states: readonly LoadedOrder[]): SqlStatement {
|
|
1546
|
+
function claimUpdate(states: readonly LoadedOrder[],now:string): SqlStatement {
|
|
1521
1547
|
const first = states[0];
|
|
1522
1548
|
if (!first) return { sql: "SELECT 0", params: [] };
|
|
1523
|
-
const
|
|
1524
|
-
const
|
|
1549
|
+
const fences=states.map(state=>authorityFence(state.sourceAuthority,now,true));
|
|
1550
|
+
const match = states.map((_,i) => `(order_id = ? AND event_version = ? AND (${fences[i]!.sql}))`).join(" OR ");
|
|
1551
|
+
const versionParams = states.flatMap((state,i) => [state.order.orderId, state.order.eventVersion,...fences[i]!.params]);
|
|
1525
1552
|
return {
|
|
1526
1553
|
sql: `UPDATE scrappy_commerce_orders
|
|
1527
1554
|
SET event_version = event_version + 1
|
|
@@ -17,9 +17,15 @@ import {
|
|
|
17
17
|
type ProviderOutcome,
|
|
18
18
|
type StockState,
|
|
19
19
|
} from "@tot/public-runtime";
|
|
20
|
+
import type { SourceReadiness } from "../orders/data/sourceAuthorityStore.js";
|
|
20
21
|
import { COMPLIANCE_RULE_PROFILES } from "@/config/compliance/rulesets";
|
|
21
22
|
|
|
22
23
|
export const RELEASE_HOLDS = [
|
|
24
|
+
"source-check-pending",
|
|
25
|
+
"source-ledger-conflict",
|
|
26
|
+
"source-authority-unreadable",
|
|
27
|
+
"source-authority-unavailable",
|
|
28
|
+
"source-sync-pending",
|
|
23
29
|
"evidence-unknown",
|
|
24
30
|
"evidence-stale",
|
|
25
31
|
"evidence-unreadable",
|
|
@@ -48,6 +54,7 @@ export interface CarrierPolicy {
|
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
export interface ReadinessInput {
|
|
57
|
+
sourceHolds?: readonly SourceReadiness[];
|
|
51
58
|
payment: PaymentState;
|
|
52
59
|
stock: StockState;
|
|
53
60
|
fulfillment: FulfillmentState;
|
|
@@ -75,7 +82,7 @@ function add(holds: ReleaseHold[], hold: ReleaseHold | null): void {
|
|
|
75
82
|
|
|
76
83
|
/** Holds that forbid creating a shipment intent. Compliance is not an input. */
|
|
77
84
|
export function releaseHolds(input: ReadinessInput): ReleaseHold[] {
|
|
78
|
-
const holds: ReleaseHold[] = [];
|
|
85
|
+
const holds: ReleaseHold[] = [...(input.sourceHolds ?? [])];
|
|
79
86
|
for (const field of ["verification", "tax", "risk", "stock"] as const) {
|
|
80
87
|
const value = input.evidence[field];
|
|
81
88
|
add(holds, value in EVIDENCE_HOLDS ? EVIDENCE_HOLDS[value as EvidenceState] : "evidence-unreadable");
|
|
@@ -326,7 +326,7 @@ function publicCommercePayload(payload: string): Record<string, unknown> | null
|
|
|
326
326
|
|
|
327
327
|
export type CommerceSweepResult =
|
|
328
328
|
| { ok: true; delivered: number; retried: number; dead: number }
|
|
329
|
-
| { ok: false; reason: "scheduler" };
|
|
329
|
+
| { ok: false; reason: "scheduler" | "storage"; delivered?:number; retried?:number; dead?:number; incomplete?:number };
|
|
330
330
|
|
|
331
331
|
export type CommerceDeliver = (event: Record<string, unknown>, row: CommerceOutboxDue) => Promise<void>;
|
|
332
332
|
|
|
@@ -344,10 +344,13 @@ export async function sweepDurableCommerce(input: {
|
|
|
344
344
|
}): Promise<CommerceSweepResult> {
|
|
345
345
|
if (input.scheduler !== COMMERCE_SCHEDULER) return { ok: false, reason: "scheduler" };
|
|
346
346
|
const port = new ScrappyCommerceOutbox(input.db);
|
|
347
|
-
|
|
347
|
+
let due:CommerceOutboxDue[];
|
|
348
|
+
try{due=await port.due(input.now,input.tenantId,input.stage);}catch{return {ok:false,reason:"storage",delivered:0,retried:0,dead:0,incomplete:1};}
|
|
348
349
|
let delivered = 0;
|
|
349
350
|
let retried = 0;
|
|
350
351
|
let dead = 0;
|
|
352
|
+
let incomplete=0;
|
|
353
|
+
const failedTenants=new Set<string>();
|
|
351
354
|
const tenants: { tenantId: string; stage: string }[] = [];
|
|
352
355
|
const seenTenant = (tenantId: string, stage: string) => {
|
|
353
356
|
if (!tenants.some((item) => item.tenantId === tenantId && item.stage === stage)) {
|
|
@@ -356,39 +359,34 @@ export async function sweepDurableCommerce(input: {
|
|
|
356
359
|
};
|
|
357
360
|
for (const row of due) {
|
|
358
361
|
seenTenant(row.tenant_id, row.stage);
|
|
359
|
-
|
|
362
|
+
let outcome:"delivered"|"retried"|"dead";
|
|
363
|
+
try{outcome=await settleCommerceRow(input.db,row,input.now,input.deliver);}catch{incomplete++;failedTenants.add(JSON.stringify([row.tenant_id,row.stage]));continue;}
|
|
360
364
|
if (outcome === "delivered") delivered += 1;
|
|
361
365
|
else if (outcome === "retried") retried += 1;
|
|
362
366
|
else dead += 1;
|
|
363
367
|
}
|
|
364
|
-
for
|
|
368
|
+
for(const tenant of tenants){if(failedTenants.has(JSON.stringify([tenant.tenantId,tenant.stage])))continue;try{await port.noteCursor(tenant.tenantId,tenant.stage,input.now);}catch{incomplete++;}}
|
|
369
|
+
if(incomplete)return {ok:false,reason:"storage",delivered,retried,dead,incomplete};
|
|
365
370
|
return { ok: true, delivered, retried, dead };
|
|
366
371
|
}
|
|
367
372
|
|
|
373
|
+
async function persistCommerceOutcome(db:Queryable,row:CommerceOutboxDue,state:"delivered"|"pending"|"dead",attemptCount=row.attempt_count,availableAt=row.available_at):Promise<void> {
|
|
374
|
+
const committed=await db.first<{outbox_id:string}>(`UPDATE scrappy_commerce_outbox SET state=?,attempt_count=?,available_at=? WHERE outbox_id=? AND tenant_id=? AND stage=? AND order_id=? AND state='pending' AND attempt_count=? RETURNING outbox_id`,state,attemptCount,availableAt,row.outbox_id,row.tenant_id,row.stage,row.order_id,row.attempt_count);
|
|
375
|
+
if(committed?.outbox_id!==row.outbox_id)throw new Error("commerce-outcome-incomplete");
|
|
376
|
+
}
|
|
368
377
|
async function settleCommerceRow(
|
|
369
|
-
|
|
378
|
+
db:Queryable,
|
|
370
379
|
row: CommerceOutboxDue,
|
|
371
380
|
now: string,
|
|
372
381
|
deliver: CommerceDeliver | undefined,
|
|
373
382
|
): Promise<"delivered" | "retried" | "dead"> {
|
|
374
|
-
const event
|
|
375
|
-
if
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return "delivered";
|
|
384
|
-
} catch {
|
|
385
|
-
const next = row.attempt_count + 1;
|
|
386
|
-
if (next >= MAX_DELIVERY_ATTEMPTS) {
|
|
387
|
-
await port.markDead(row.outbox_id, next);
|
|
388
|
-
return "dead";
|
|
389
|
-
}
|
|
390
|
-
const availableAt = new Date(Date.parse(now) + computeBackoffDelaySeconds(next) * 1000).toISOString();
|
|
391
|
-
await port.markRetry(row.outbox_id, next, availableAt);
|
|
392
|
-
return "retried";
|
|
393
|
-
}
|
|
383
|
+
const event=publicCommercePayload(row.payload);
|
|
384
|
+
if(!event){await persistCommerceOutcome(db,row,"dead");return "dead";}
|
|
385
|
+
let succeeded=false;
|
|
386
|
+
try{if(deliver){await deliver(event,row);succeeded=true;}}catch{/* Retry the failed order independently; diagnostic payloads stay private. */}
|
|
387
|
+
if(succeeded){await persistCommerceOutcome(db,row,"delivered");return "delivered";}
|
|
388
|
+
const next=row.attempt_count+1;
|
|
389
|
+
if(next>=MAX_DELIVERY_ATTEMPTS){await persistCommerceOutcome(db,row,"dead",next);return "dead";}
|
|
390
|
+
const availableAt=new Date(Date.parse(now)+computeBackoffDelaySeconds(next)*1000).toISOString();
|
|
391
|
+
await persistCommerceOutcome(db,row,"pending",next,availableAt);return "retried";
|
|
394
392
|
}
|
|
@@ -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,
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
* compute one FOR either. `rawCustomerId` is accepted and simply unused;
|
|
33
33
|
* see `docs/private-apps/order-forward-contract.md` for the full rationale.
|
|
34
34
|
*/
|
|
35
|
+
import { SourceAuthorityStore } from "./sourceAuthorityStore.js";
|
|
36
|
+
import { SourceAuthorityError, parseSourceWire, exactKeys, digestJson, type BeginAck, type ResultAck, type AuthorityCode, isRecord, isSourceId } from "./sourceAuthorityContract.js";
|
|
35
37
|
import { OrdersStore, ScrappyOrderLedger, type OrderLineItem, type OrderStatus, type Order } from "./ordersStore.js";
|
|
36
38
|
import { emitWebhookEvent } from "../../integrations/webhooks/emit.js";
|
|
37
39
|
import type { WebhookTopic } from "../../integrations/webhooks/deliveryStore.js";
|
|
@@ -149,6 +151,7 @@ export async function receiveForwardedOrder(db: Queryable, input: OrderForwardIn
|
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
export interface CommerceCallerConfig {
|
|
154
|
+
expectedStage: CommerceStage;
|
|
152
155
|
jwks: JSONWebKeySet;
|
|
153
156
|
trustedIssuers: readonly string[];
|
|
154
157
|
expectedAudience: string;
|
|
@@ -159,6 +162,7 @@ export interface CommerceCallerConfig {
|
|
|
159
162
|
export type SignedCommerceRefusal = CallerTokenRefusal | "version" | "malformed" | "totals" | "private" | "replay";
|
|
160
163
|
|
|
161
164
|
export type SignedCommerceResult =
|
|
165
|
+
| { ok:true; authorityAck:BeginAck|ResultAck; tenantId:string; stage:CommerceStage }
|
|
162
166
|
| {
|
|
163
167
|
ok: true;
|
|
164
168
|
duplicate: boolean;
|
|
@@ -170,7 +174,7 @@ export type SignedCommerceResult =
|
|
|
170
174
|
currency: string;
|
|
171
175
|
tenantId: string;
|
|
172
176
|
}
|
|
173
|
-
| { ok: false; status: number; reason: SignedCommerceRefusal | "order commit failed" };
|
|
177
|
+
| { ok: false; status: number; reason: SignedCommerceRefusal | AuthorityCode | "order commit failed" };
|
|
174
178
|
|
|
175
179
|
function isStage(value: unknown): value is CommerceStage {
|
|
176
180
|
return value === "preview" || value === "publication";
|
|
@@ -198,6 +202,7 @@ export async function verifySignedCommerceToken(
|
|
|
198
202
|
config: CommerceCallerConfig,
|
|
199
203
|
binding: { tenantId: string; stage: CommerceStage },
|
|
200
204
|
): Promise<{ ok: true; issuer: string; jti: string; digest: string } | { ok: false; reason: CallerTokenRefusal | "version" }> {
|
|
205
|
+
if(!isStage(config.expectedStage)||binding.stage!==config.expectedStage)return {ok:false,reason:"stage"};
|
|
201
206
|
if (token.split(".").length !== 3) {
|
|
202
207
|
const admitted = admitCallerToken({
|
|
203
208
|
token: { scheme: "shared-secret" },
|
|
@@ -265,6 +270,7 @@ export async function verifySignedCommerceToken(
|
|
|
265
270
|
});
|
|
266
271
|
if (!isStage(claims.stage)) return { ok: false, reason: admitted.ok ? "stage" : admitted.reason === "signature" ? "signature" : "stage" };
|
|
267
272
|
if (!admitted.ok) return admitted;
|
|
273
|
+
if(typeof claims.exp!=="number"||claims.exp-Math.floor(Date.parse(config.now)/1000)>60)return {ok:false,reason:"lifetime"};
|
|
268
274
|
if (claims.ver !== 1) return { ok: false, reason: "version" };
|
|
269
275
|
if (typeof claims.digest !== "string" || claims.digest === "") return { ok: false, reason: "signature" };
|
|
270
276
|
return { ok: true, issuer: typeof claims.iss === "string" ? claims.iss : "", jti: typeof claims.jti === "string" ? claims.jti : "", digest: claims.digest };
|
|
@@ -277,6 +283,7 @@ export async function receiveSignedCommerceOrder(
|
|
|
277
283
|
token: string,
|
|
278
284
|
config: CommerceCallerConfig,
|
|
279
285
|
): Promise<SignedCommerceResult> {
|
|
286
|
+
if(!isStage(config.expectedStage))return {ok:false,status:503,reason:"source-authority-unavailable"};
|
|
280
287
|
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
281
288
|
return { ok: false, status: 400, reason: "malformed" };
|
|
282
289
|
}
|
|
@@ -285,7 +292,7 @@ export async function receiveSignedCommerceOrder(
|
|
|
285
292
|
return { ok: false, status: 401, reason: "tenant" };
|
|
286
293
|
}
|
|
287
294
|
if (!isStage(record.stage)) return { ok: false, status: 401, reason: "stage" };
|
|
288
|
-
if (!("event"
|
|
295
|
+
if (!exactKeys(record,["tenantId","stage","event"])) return { ok: false, status: 400, reason: "malformed" };
|
|
289
296
|
|
|
290
297
|
const verified = await verifySignedCommerceToken(token, config, {
|
|
291
298
|
tenantId: record.tenantId,
|
|
@@ -298,18 +305,40 @@ export async function receiveSignedCommerceOrder(
|
|
|
298
305
|
const digest = await sha256Hex(canonicalJson(envelope));
|
|
299
306
|
if (digest !== verified.digest) return { ok: false, status: 401, reason: "signature" };
|
|
300
307
|
|
|
301
|
-
const
|
|
302
|
-
if (event === "malformed" || event === "totals") return { ok: false, status: 400, reason: event };
|
|
303
|
-
|
|
308
|
+
const authority=new SourceAuthorityStore(db);
|
|
304
309
|
try {
|
|
310
|
+
const replay={issuer:verified.issuer,jti:verified.jti};
|
|
311
|
+
const seen=await db.first(`SELECT jti FROM scrappy_caller_replay WHERE tenant_id=? AND stage=? AND issuer=? AND jti=?`,record.tenantId,record.stage,replay.issuer,replay.jti);
|
|
312
|
+
if(seen)return {ok:false,status:401,reason:"replay"};
|
|
313
|
+
const wire=parseSourceWire(record.event);
|
|
314
|
+
if(!wire){
|
|
315
|
+
const e=record.event;
|
|
316
|
+
if(isRecord(e)&&e.source==="foxycart"&&isSourceId(e.providerStoreId)&&isSourceId(e.sourceOrderId)&&["validatedManifestCores","businessEventDigestPairs","resolvesHoldIds","completeValidatedManifestSetOrNull","conflictingValidatedGenerationsOrNull"].some(k=>Array.isArray(e[k])&&(e[k] as unknown[]).length>64)){await authority.budget({tenantId:record.tenantId,stage:record.stage,source:"foxycart",providerStoreId:e.providerStoreId,sourceOrderId:e.sourceOrderId},config.now);return {ok:false,status:409,reason:"authority-budget"};}
|
|
317
|
+
return {ok:false,status:400,reason:"malformed"};
|
|
318
|
+
}
|
|
319
|
+
if(wire.kind!=="source-order-event") {
|
|
320
|
+
const key={tenantId:record.tenantId,stage:record.stage,source:wire.source,providerStoreId:wire.providerStoreId,sourceOrderId:wire.sourceOrderId};
|
|
321
|
+
const authorityAck=wire.kind==="source-check-begin"?await authority.begin(key,wire,config.now,replay):await authority.result(key,wire,config.now,replay);
|
|
322
|
+
return {ok:true,authorityAck,tenantId:record.tenantId,stage:record.stage};
|
|
323
|
+
}
|
|
324
|
+
const event=normalizeCommerceEvent(wire.event);
|
|
325
|
+
if(event==="malformed"||event==="totals")return {ok:false,status:400,reason:event};
|
|
326
|
+
// Strict original bytes: defaults, aliases, omitted keys and additional keys cannot silently change the digest.
|
|
327
|
+
if(canonicalJson(wire.event)!==canonicalJson(event))return {ok:false,status:400,reason:"malformed"};
|
|
328
|
+
if(await digestJson(wire.event)!==wire.provenance.exactOriginalEventDigest)return {ok:false,status:401,reason:"signature"};
|
|
329
|
+
const sourceAuthority=await authority.authorizeEvent(record.tenantId,record.stage,event,wire.provenance);
|
|
305
330
|
const committed = await new ScrappyOrderLedger(db).commit({
|
|
306
331
|
tenantId: record.tenantId,
|
|
307
332
|
stage: record.stage,
|
|
308
333
|
event,
|
|
309
334
|
now: config.now,
|
|
310
335
|
replay: { issuer: verified.issuer, jti: verified.jti },
|
|
336
|
+
sourceAuthority,
|
|
311
337
|
});
|
|
312
|
-
if
|
|
338
|
+
if(!committed.ok){
|
|
339
|
+
if(committed.reason==="conflict")await authority.budget({tenantId:record.tenantId,stage:record.stage,source:"foxycart",providerStoreId:sourceAuthority.provider_store_id,sourceOrderId:sourceAuthority.source_order_id},config.now);
|
|
340
|
+
return {ok:false,status:committed.reason==="conflict"?409:401,reason:committed.reason==="conflict"?"control-identity-conflict":"replay"};
|
|
341
|
+
}
|
|
313
342
|
return {
|
|
314
343
|
ok: true,
|
|
315
344
|
duplicate: committed.duplicate,
|
|
@@ -321,7 +350,8 @@ export async function receiveSignedCommerceOrder(
|
|
|
321
350
|
currency: committed.snapshot.currency,
|
|
322
351
|
tenantId: record.tenantId,
|
|
323
352
|
};
|
|
324
|
-
} catch {
|
|
353
|
+
} catch(error) {
|
|
354
|
+
if(error instanceof SourceAuthorityError)return {ok:false,status:error.code==="malformed"?400:error.code==="signature"?401:409,reason:error.code};
|
|
325
355
|
return { ok: false, status: 500, reason: "order commit failed" };
|
|
326
356
|
}
|
|
327
357
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `AppRegistryService` uses, so this runs unchanged against real D1 in the
|
|
11
11
|
* Worker and an in-memory `node:sqlite` in tests.
|
|
12
12
|
*/
|
|
13
|
+
import { SourceAuthorityStore, type AuthorityRow } from "./sourceAuthorityStore.js";
|
|
13
14
|
import type { Queryable, QueryStatement } from "../../catalog/d1/catalog.js";
|
|
14
15
|
import {
|
|
15
16
|
canonicalJson,
|
|
@@ -233,6 +234,7 @@ export interface CommitCommerceInput {
|
|
|
233
234
|
event: CommerceSourceEvent;
|
|
234
235
|
now: string;
|
|
235
236
|
replay?: { issuer: string; jti: string };
|
|
237
|
+
sourceAuthority?: AuthorityRow;
|
|
236
238
|
}
|
|
237
239
|
|
|
238
240
|
export type CommitCommerceResult =
|
|
@@ -288,7 +290,7 @@ export class ScrappyOrderLedger {
|
|
|
288
290
|
);
|
|
289
291
|
if (existing) {
|
|
290
292
|
const storedDigest = await this.storedPayloadDigest(tenantId, stage, event.source, event.sourceEventId);
|
|
291
|
-
if (storedDigest !==
|
|
293
|
+
if (storedDigest !== digest) return { ok: false, reason: "conflict" };
|
|
292
294
|
if (input.replay) {
|
|
293
295
|
try {
|
|
294
296
|
await this.insertReplay(input);
|
|
@@ -317,6 +319,10 @@ export class ScrappyOrderLedger {
|
|
|
317
319
|
const key = idempotencyKey(tenantId, stage, event.source, event.sourceEventId);
|
|
318
320
|
const outboxId = (await sha256Hex(key)).slice(0, 32);
|
|
319
321
|
const statements: QueryStatement[] = [];
|
|
322
|
+
if (input.sourceAuthority) {
|
|
323
|
+
const a=input.sourceAuthority;
|
|
324
|
+
statements.push({sql:`SELECT json(CASE WHEN EXISTS(SELECT 1 FROM commerce_source_authority WHERE tenant_id=? AND stage=? AND source=? AND source_order_id=? AND source_state_version=? AND accepted_ledger_generation=? AND accepted_business_event_set_digest=?) THEN 'true' ELSE 'source-state-changed' END)`,params:[tenantId,stage,event.source,event.sourceOrderId,a.source_state_version,a.accepted_ledger_generation,a.accepted_business_event_set_digest]});
|
|
325
|
+
}
|
|
320
326
|
if (input.replay) {
|
|
321
327
|
statements.push({
|
|
322
328
|
sql: `INSERT INTO scrappy_caller_replay (tenant_id, stage, issuer, jti, seen_at) VALUES (?,?,?,?,?)`,
|
|
@@ -393,6 +399,7 @@ export class ScrappyOrderLedger {
|
|
|
393
399
|
},
|
|
394
400
|
);
|
|
395
401
|
|
|
402
|
+
if(input.sourceAuthority)statements.push(...await new SourceAuthorityStore(this.db).businessStatements(input.sourceAuthority,event.orderId));
|
|
396
403
|
try {
|
|
397
404
|
await atomic(this.db, statements);
|
|
398
405
|
} catch (error) {
|
|
@@ -409,7 +416,7 @@ export class ScrappyOrderLedger {
|
|
|
409
416
|
);
|
|
410
417
|
if (raced) {
|
|
411
418
|
const storedDigest = await this.storedPayloadDigest(tenantId, stage, event.source, event.sourceEventId);
|
|
412
|
-
if (storedDigest !==
|
|
419
|
+
if (storedDigest !== digest) return { ok: false, reason: "conflict" };
|
|
413
420
|
const snapshotNow = await this.readSnapshot(tenantId, stage, event.orderId, event);
|
|
414
421
|
return { ok: true, duplicate: true, snapshot: snapshotNow, outboxId: raced.outbox_id };
|
|
415
422
|
}
|