@tokenoftrust/storefront-runner 2.4.53 → 2.4.54

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.
@@ -32,10 +32,34 @@
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 { OrdersStore, type OrderLineItem, type OrderStatus, type Order } from "./ordersStore.js";
35
+ import { OrdersStore, ScrappyOrderLedger, type OrderLineItem, type OrderStatus, type Order } from "./ordersStore.js";
36
36
  import { emitWebhookEvent } from "../../webhooks/emit.js";
37
37
  import type { WebhookTopic } from "../../webhooks/deliveryStore.js";
38
38
  import type { Queryable } from "../../d1/catalog.js";
39
+ import {
40
+ createLocalJWKSet,
41
+ decodeJwt,
42
+ decodeProtectedHeader,
43
+ errors as joseErrors,
44
+ jwtVerify,
45
+ type JSONWebKeySet,
46
+ type JWK,
47
+ type JWTPayload,
48
+ } from "jose";
49
+ import {
50
+ admitCallerToken,
51
+ canonicalJson,
52
+ sha256Hex,
53
+ type CallerTokenRefusal,
54
+ type CommerceStage,
55
+ type FulfillmentState,
56
+ type PaymentState,
57
+ } from "@tot/public-runtime";
58
+ import {
59
+ normalizeCommerceEvent,
60
+ payloadExposesPrivateIdentity,
61
+ type CommerceCommandName,
62
+ } from "./orderMapper.js";
39
63
 
40
64
  /** One reconciled order, as tot-foxycart forwards it. See the contract doc for the wire (JSON) shape. */
41
65
  export interface OrderForwardInput {
@@ -123,3 +147,181 @@ export async function receiveForwardedOrder(db: Queryable, input: OrderForwardIn
123
147
 
124
148
  return { order, previousStatus, emitted };
125
149
  }
150
+
151
+ export interface CommerceCallerConfig {
152
+ jwks: JSONWebKeySet;
153
+ trustedIssuers: readonly string[];
154
+ expectedAudience: string;
155
+ activeKids: readonly string[];
156
+ now: string;
157
+ }
158
+
159
+ export type SignedCommerceRefusal = CallerTokenRefusal | "version" | "malformed" | "totals" | "private" | "replay";
160
+
161
+ export type SignedCommerceResult =
162
+ | {
163
+ ok: true;
164
+ duplicate: boolean;
165
+ payment: PaymentState;
166
+ fulfillment: FulfillmentState;
167
+ eventVersion: number;
168
+ command: CommerceCommandName;
169
+ stage: CommerceStage;
170
+ currency: string;
171
+ tenantId: string;
172
+ }
173
+ | { ok: false; status: number; reason: SignedCommerceRefusal | "order commit failed" };
174
+
175
+ function isStage(value: unknown): value is CommerceStage {
176
+ return value === "preview" || value === "publication";
177
+ }
178
+
179
+ function publicJwk(key: { kid?: string; kty?: string; crv?: string; use?: string; alg?: string; x?: string }): Record<string, string> {
180
+ return {
181
+ kty: key.kty ?? "",
182
+ crv: key.crv ?? "",
183
+ kid: key.kid ?? "",
184
+ ...(key.use ? { use: key.use } : {}),
185
+ ...(key.alg ? { alg: key.alg } : {}),
186
+ ...(key.x ? { x: key.x } : {}),
187
+ };
188
+ }
189
+
190
+ function expiresAtIso(exp: unknown): string {
191
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return "";
192
+ return new Date(exp * 1000).toISOString();
193
+ }
194
+
195
+ /** Verify a caller-owned EdDSA JWT against the trusted public JWKS. No shared secret. */
196
+ export async function verifySignedCommerceToken(
197
+ token: string,
198
+ config: CommerceCallerConfig,
199
+ binding: { tenantId: string; stage: CommerceStage },
200
+ ): Promise<{ ok: true; issuer: string; jti: string; digest: string } | { ok: false; reason: CallerTokenRefusal | "version" }> {
201
+ if (token.split(".").length !== 3) {
202
+ const admitted = admitCallerToken({
203
+ token: { scheme: "shared-secret" },
204
+ trustedIssuers: config.trustedIssuers,
205
+ expectedAudience: config.expectedAudience,
206
+ activeKids: config.activeKids,
207
+ now: config.now,
208
+ tenantId: binding.tenantId,
209
+ stage: binding.stage,
210
+ });
211
+ return admitted.ok ? { ok: false, reason: "shared-secret" } : admitted;
212
+ }
213
+
214
+ let kid = "";
215
+ try {
216
+ const header = decodeProtectedHeader(token);
217
+ kid = typeof header.kid === "string" ? header.kid : "";
218
+ } catch {
219
+ return { ok: false, reason: "signature" };
220
+ }
221
+
222
+ const match = config.jwks.keys?.find((key) => key.kid === kid);
223
+ let signatureValid = false;
224
+ let payload: JWTPayload | null = null;
225
+ if (match) {
226
+ try {
227
+ const verified = await jwtVerify(token, createLocalJWKSet({ keys: [publicJwk(match) as JWK] }), {
228
+ currentDate: new Date(config.now),
229
+ });
230
+ signatureValid = true;
231
+ payload = verified.payload;
232
+ } catch (error) {
233
+ if (error instanceof joseErrors.JWTExpired || error instanceof joseErrors.JWTClaimValidationFailed) {
234
+ signatureValid = true;
235
+ try {
236
+ payload = decodeJwt(token);
237
+ } catch {
238
+ payload = null;
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ const claims = payload ?? {};
245
+ const stage = isStage(claims.stage) ? claims.stage : binding.stage;
246
+ const audience = typeof claims.aud === "string" ? claims.aud : "";
247
+ const admitted = admitCallerToken({
248
+ token: {
249
+ scheme: "asymmetric-jwks",
250
+ signatureValid,
251
+ kid,
252
+ issuer: typeof claims.iss === "string" ? claims.iss : "",
253
+ audience,
254
+ expiresAt: expiresAtIso(claims.exp),
255
+ jti: typeof claims.jti === "string" ? claims.jti : "",
256
+ tenantId: typeof claims.tenantId === "string" ? claims.tenantId : "",
257
+ stage: isStage(claims.stage) ? claims.stage : stage,
258
+ },
259
+ trustedIssuers: config.trustedIssuers,
260
+ expectedAudience: config.expectedAudience,
261
+ activeKids: config.activeKids,
262
+ now: config.now,
263
+ tenantId: binding.tenantId,
264
+ stage: binding.stage,
265
+ });
266
+ if (!isStage(claims.stage)) return { ok: false, reason: admitted.ok ? "stage" : admitted.reason === "signature" ? "signature" : "stage" };
267
+ if (!admitted.ok) return admitted;
268
+ if (claims.ver !== 1) return { ok: false, reason: "version" };
269
+ if (typeof claims.digest !== "string" || claims.digest === "") return { ok: false, reason: "signature" };
270
+ return { ok: true, issuer: typeof claims.iss === "string" ? claims.iss : "", jti: typeof claims.jti === "string" ? claims.jti : "", digest: claims.digest };
271
+ }
272
+
273
+ /** Signed-native intake. The order row and its outbox row commit together. */
274
+ export async function receiveSignedCommerceOrder(
275
+ db: Queryable,
276
+ body: unknown,
277
+ token: string,
278
+ config: CommerceCallerConfig,
279
+ ): Promise<SignedCommerceResult> {
280
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
281
+ return { ok: false, status: 400, reason: "malformed" };
282
+ }
283
+ const record = body as Record<string, unknown>;
284
+ if (typeof record.tenantId !== "string" || record.tenantId.trim() === "") {
285
+ return { ok: false, status: 401, reason: "tenant" };
286
+ }
287
+ if (!isStage(record.stage)) return { ok: false, status: 401, reason: "stage" };
288
+ if (!("event" in record)) return { ok: false, status: 400, reason: "malformed" };
289
+
290
+ const verified = await verifySignedCommerceToken(token, config, {
291
+ tenantId: record.tenantId,
292
+ stage: record.stage,
293
+ });
294
+ if (!verified.ok) return { ok: false, status: 401, reason: verified.reason };
295
+ if (payloadExposesPrivateIdentity(body)) return { ok: false, status: 400, reason: "private" };
296
+
297
+ const envelope = { tenantId: record.tenantId, stage: record.stage, event: record.event };
298
+ const digest = await sha256Hex(canonicalJson(envelope));
299
+ if (digest !== verified.digest) return { ok: false, status: 401, reason: "signature" };
300
+
301
+ const event = normalizeCommerceEvent(record.event);
302
+ if (event === "malformed" || event === "totals") return { ok: false, status: 400, reason: event };
303
+
304
+ try {
305
+ const committed = await new ScrappyOrderLedger(db).commit({
306
+ tenantId: record.tenantId,
307
+ stage: record.stage,
308
+ event,
309
+ now: config.now,
310
+ replay: { issuer: verified.issuer, jti: verified.jti },
311
+ });
312
+ if (!committed.ok) return { ok: false, status: 401, reason: "replay" };
313
+ return {
314
+ ok: true,
315
+ duplicate: committed.duplicate,
316
+ payment: committed.snapshot.payment,
317
+ fulfillment: committed.snapshot.fulfillment,
318
+ eventVersion: committed.snapshot.eventVersion,
319
+ command: event.command,
320
+ stage: record.stage,
321
+ currency: committed.snapshot.currency,
322
+ tenantId: record.tenantId,
323
+ };
324
+ } catch {
325
+ return { ok: false, status: 500, reason: "order commit failed" };
326
+ }
327
+ }
@@ -10,6 +10,24 @@
10
10
  */
11
11
  import { customerHash } from "./customerHash.js";
12
12
  import type { FoxyOrder, FoxyOrderLineItem } from "./foxyOrderClient.js";
13
+ import {
14
+ COMPLIANCE_STATES,
15
+ FULFILLMENT_STATES,
16
+ LABEL_STATES,
17
+ PAYMENT_STATES,
18
+ STOCK_STATES,
19
+ decideCommerceCommand,
20
+ publicCommerceEvent,
21
+ totalsMatch,
22
+ type CommerceEvidence,
23
+ type ComplianceState,
24
+ type FulfillmentState,
25
+ type LabelState,
26
+ type MinorTotals,
27
+ type PaymentState,
28
+ type PublicCommerceEvent,
29
+ type StockState,
30
+ } from "@tot/public-runtime";
13
31
 
14
32
  export type ContractOrderStatus = "created" | "fulfilled" | "cancelled";
15
33
 
@@ -89,3 +107,306 @@ export async function toContractOrder(order: FoxyOrder, ctx: ToContractOrderCont
89
107
  createdAt: toIsoDateTime(order.orderedAt),
90
108
  };
91
109
  }
110
+
111
+ export const COMMERCE_COMMANDS = ["observe", "capture", "refund", "cancel"] as const;
112
+ export type CommerceCommandName = (typeof COMMERCE_COMMANDS)[number];
113
+
114
+ const PRIVATE_COMMERCE_KEYS = new Set([
115
+ "customer",
116
+ "customerEmail",
117
+ "customerName",
118
+ "email",
119
+ "phone",
120
+ "line1",
121
+ "line2",
122
+ "postalCode",
123
+ "shippingAddress",
124
+ "billingAddress",
125
+ "address",
126
+ "firstName",
127
+ "lastName",
128
+ "name",
129
+ ]);
130
+
131
+ const UNKNOWN_EVIDENCE: CommerceEvidence = {
132
+ verification: "UNKNOWN",
133
+ tax: "UNKNOWN",
134
+ risk: "UNKNOWN",
135
+ stock: "UNKNOWN",
136
+ };
137
+
138
+ export interface CommerceSourceEvent {
139
+ eventVersion: number;
140
+ command: CommerceCommandName;
141
+ orderId: string;
142
+ sourceEventId: string;
143
+ source: string;
144
+ sourceOrderId: string;
145
+ placedAt: string;
146
+ currency: string;
147
+ payment: PaymentState;
148
+ compliance: ComplianceState;
149
+ stock: StockState;
150
+ fulfillment: FulfillmentState;
151
+ label: LabelState;
152
+ totals: MinorTotals;
153
+ verificationDetail: CommerceEvidence;
154
+ }
155
+
156
+ export interface CommerceSnapshot {
157
+ orderId: string;
158
+ source: string;
159
+ sourceOrderId: string;
160
+ placedAt: string;
161
+ currency: string;
162
+ payment: PaymentState;
163
+ compliance: ComplianceState;
164
+ stock: StockState;
165
+ fulfillment: FulfillmentState;
166
+ label: LabelState;
167
+ totals: MinorTotals;
168
+ verificationDetail: CommerceEvidence;
169
+ eventVersion: number;
170
+ amountMinor: number;
171
+ }
172
+
173
+ export interface StoredCommercePayload {
174
+ source: CommerceSourceEvent;
175
+ public: PublicCommerceEvent;
176
+ }
177
+
178
+ export function payloadExposesPrivateIdentity(value: unknown): boolean {
179
+ if (value === null || typeof value !== "object") return false;
180
+ if (Array.isArray(value)) return value.some((item) => payloadExposesPrivateIdentity(item));
181
+ return Object.entries(value as Record<string, unknown>).some(
182
+ ([key, child]) => PRIVATE_COMMERCE_KEYS.has(key) || payloadExposesPrivateIdentity(child),
183
+ );
184
+ }
185
+
186
+ function isRecord(value: unknown): value is Record<string, unknown> {
187
+ return value !== null && typeof value === "object" && !Array.isArray(value);
188
+ }
189
+
190
+ function commandOf(value: Record<string, unknown>): CommerceCommandName | null {
191
+ const command = value.command ?? value.type;
192
+ if (command === "observe" || command === "order.observed") return "observe";
193
+ if (command === "capture" || command === "order.captured") return "capture";
194
+ if (command === "refund" || command === "order.refunded") return "refund";
195
+ if (command === "cancel" || command === "order.cancelled") return "cancel";
196
+ return null;
197
+ }
198
+
199
+ function readTotals(value: unknown): MinorTotals | null {
200
+ if (!isRecord(value)) return null;
201
+ const totals = {
202
+ subtotalMinor: value.subtotalMinor,
203
+ shippingMinor: value.shippingMinor,
204
+ taxMinor: value.taxMinor,
205
+ totalMinor: value.totalMinor,
206
+ };
207
+ if (
208
+ typeof totals.subtotalMinor !== "number" ||
209
+ typeof totals.shippingMinor !== "number" ||
210
+ typeof totals.taxMinor !== "number" ||
211
+ typeof totals.totalMinor !== "number"
212
+ ) {
213
+ return null;
214
+ }
215
+ return totals as MinorTotals;
216
+ }
217
+
218
+ function readEvidence(value: unknown): CommerceEvidence {
219
+ if (!isRecord(value)) return { ...UNKNOWN_EVIDENCE };
220
+ const pick = (key: keyof CommerceEvidence): CommerceEvidence[keyof CommerceEvidence] => {
221
+ const found = value[key];
222
+ if (found === "fresh" || found === "UNKNOWN" || found === "stale" || found === "unreadable" || found === "absent") {
223
+ return found;
224
+ }
225
+ return "UNKNOWN";
226
+ };
227
+ return {
228
+ verification: pick("verification"),
229
+ tax: pick("tax"),
230
+ risk: pick("risk"),
231
+ stock: pick("stock"),
232
+ };
233
+ }
234
+
235
+ /** Narrow a signed commerce body to the source event, or a refusal reason. */
236
+ export function normalizeCommerceEvent(value: unknown): CommerceSourceEvent | "malformed" | "totals" {
237
+ if (!isRecord(value)) return "malformed";
238
+ const command = commandOf(value);
239
+ const eventVersion = value.eventVersion;
240
+ const orderId = value.orderId;
241
+ const sourceEventId = value.sourceEventId;
242
+ const placedAt = value.placedAt;
243
+ const currency = value.currency;
244
+ if (command === null) return "malformed";
245
+ if (typeof eventVersion !== "number" || !Number.isSafeInteger(eventVersion) || eventVersion < 1) return "malformed";
246
+ if (typeof orderId !== "string" || orderId.trim() === "" || orderId.includes("\n")) return "malformed";
247
+ if (typeof sourceEventId !== "string" || sourceEventId.trim() === "" || sourceEventId.includes("\n")) return "malformed";
248
+ if (typeof placedAt !== "string" || placedAt.trim() === "") return "malformed";
249
+ if (typeof currency !== "string" || currency.trim() === "") return "malformed";
250
+ const totals = readTotals(value.totals);
251
+ if (totals === null) return "malformed";
252
+ if (!totalsMatch(totals)) return "totals";
253
+ const payment = typeof value.payment === "string" ? value.payment : "UNKNOWN";
254
+ const compliance = typeof value.compliance === "string" ? value.compliance : "UNKNOWN";
255
+ const stock = typeof value.stock === "string" ? value.stock : "unallocated";
256
+ const fulfillment = typeof value.fulfillment === "string" ? value.fulfillment : "held";
257
+ const label = typeof value.label === "string" ? value.label : "none";
258
+ if (!(PAYMENT_STATES as readonly string[]).includes(payment)) return "malformed";
259
+ if (!(COMPLIANCE_STATES as readonly string[]).includes(compliance)) return "malformed";
260
+ if (!(STOCK_STATES as readonly string[]).includes(stock)) return "malformed";
261
+ if (!(FULFILLMENT_STATES as readonly string[]).includes(fulfillment)) return "malformed";
262
+ if (!(LABEL_STATES as readonly string[]).includes(label)) return "malformed";
263
+ return {
264
+ eventVersion,
265
+ command,
266
+ orderId,
267
+ sourceEventId,
268
+ source: typeof value.source === "string" && value.source.trim() !== "" ? value.source : "foxycart",
269
+ sourceOrderId: typeof value.sourceOrderId === "string" && value.sourceOrderId.trim() !== "" ? value.sourceOrderId : orderId,
270
+ placedAt,
271
+ currency,
272
+ payment: payment as PaymentState,
273
+ compliance: compliance as ComplianceState,
274
+ stock: stock as StockState,
275
+ fulfillment: fulfillment as FulfillmentState,
276
+ label: label as LabelState,
277
+ totals,
278
+ verificationDetail: readEvidence(value.verificationDetail),
279
+ };
280
+ }
281
+
282
+ export function toPublicCommerceSnapshot(snapshot: CommerceSnapshot): PublicCommerceEvent {
283
+ return publicCommerceEvent({
284
+ eventVersion: snapshot.eventVersion,
285
+ orderId: snapshot.orderId,
286
+ placedAt: snapshot.placedAt,
287
+ currency: snapshot.currency,
288
+ payment: snapshot.payment,
289
+ compliance: snapshot.compliance,
290
+ stock: snapshot.stock,
291
+ fulfillment: snapshot.fulfillment,
292
+ label: snapshot.label,
293
+ totals: snapshot.totals,
294
+ verificationDetail: snapshot.verificationDetail,
295
+ });
296
+ }
297
+
298
+ export function packCommercePayload(source: CommerceSourceEvent, snapshot: CommerceSnapshot): StoredCommercePayload {
299
+ return { source, public: toPublicCommerceSnapshot(snapshot) };
300
+ }
301
+
302
+ /**
303
+ * Version-sorted fold. Arrival order does not matter. A capture older than a
304
+ * cancel cannot put payment back to captured or clear the cancellation.
305
+ */
306
+ export function foldCommerceEvents(events: readonly CommerceSourceEvent[]): CommerceSnapshot {
307
+ const unique = new Map<string, CommerceSourceEvent>();
308
+ for (const event of events) unique.set(`${event.source}\n${event.sourceEventId}`, event);
309
+ const sorted = [...unique.values()].sort((left, right) => {
310
+ if (left.eventVersion !== right.eventVersion) return left.eventVersion - right.eventVersion;
311
+ const rank = { observe: 0, capture: 1, refund: 2, cancel: 3 };
312
+ if (rank[left.command] !== rank[right.command]) return rank[left.command] - rank[right.command];
313
+ return left.sourceEventId < right.sourceEventId ? -1 : left.sourceEventId > right.sourceEventId ? 1 : 0;
314
+ });
315
+ const seed = sorted[0];
316
+ if (seed === undefined) throw new Error("commerce fold requires an event");
317
+
318
+ let placedAt = seed.placedAt;
319
+ let currency = seed.currency;
320
+ let payment: PaymentState = "UNKNOWN";
321
+ let compliance: ComplianceState = "UNKNOWN";
322
+ let stock: StockState = "unallocated";
323
+ let fulfillment: FulfillmentState = "held";
324
+ let label: LabelState = "none";
325
+ let totals = seed.totals;
326
+ let verificationDetail: CommerceEvidence = { ...UNKNOWN_EVIDENCE };
327
+ let paymentVersion = 0;
328
+ let fulfillmentVersion = 0;
329
+ let detailVersion = 0;
330
+
331
+ for (const event of sorted) {
332
+ if (event.eventVersion >= detailVersion) {
333
+ placedAt = event.placedAt;
334
+ currency = event.currency;
335
+ compliance = event.compliance;
336
+ label = event.label;
337
+ totals = event.totals;
338
+ verificationDetail = event.verificationDetail;
339
+ stock = event.stock;
340
+ detailVersion = event.eventVersion;
341
+ }
342
+ if (event.command === "observe" && event.eventVersion >= paymentVersion) {
343
+ payment = event.payment;
344
+ paymentVersion = event.eventVersion;
345
+ }
346
+ if (event.command === "observe" && event.eventVersion >= fulfillmentVersion) {
347
+ fulfillment = event.fulfillment;
348
+ fulfillmentVersion = event.eventVersion;
349
+ }
350
+ if (event.command === "capture" && event.eventVersion >= paymentVersion) {
351
+ payment = "captured";
352
+ paymentVersion = event.eventVersion;
353
+ }
354
+ if (event.command === "refund" && event.eventVersion >= paymentVersion) {
355
+ const decision = decideCommerceCommand({
356
+ command: "refund",
357
+ payment,
358
+ stock,
359
+ fulfillment,
360
+ label,
361
+ evidence: verificationDetail,
362
+ providerOutcome: null,
363
+ });
364
+ if (decision.admit) payment = "refunded";
365
+ else if (decision.reason === "reconcile") payment = "refund_unknown";
366
+ paymentVersion = event.eventVersion;
367
+ }
368
+ if (event.command === "cancel" && event.eventVersion >= fulfillmentVersion) {
369
+ const decision = decideCommerceCommand({
370
+ command: "cancel",
371
+ payment,
372
+ stock,
373
+ fulfillment,
374
+ label,
375
+ evidence: verificationDetail,
376
+ providerOutcome: null,
377
+ });
378
+ if (decision.admit) {
379
+ fulfillment = "cancelled";
380
+ fulfillmentVersion = event.eventVersion;
381
+ if (decision.releaseReservation) stock = "released";
382
+ if (
383
+ event.eventVersion >= paymentVersion &&
384
+ payment !== "refunded" &&
385
+ payment !== "refund_unknown" &&
386
+ payment !== "refund_requested"
387
+ ) {
388
+ payment = "voided";
389
+ paymentVersion = event.eventVersion;
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ const eventVersion = sorted.reduce((max, event) => Math.max(max, event.eventVersion), 1);
396
+ return {
397
+ orderId: seed.orderId,
398
+ source: seed.source,
399
+ sourceOrderId: seed.sourceOrderId,
400
+ placedAt,
401
+ currency,
402
+ payment,
403
+ compliance,
404
+ stock,
405
+ fulfillment,
406
+ label,
407
+ totals,
408
+ verificationDetail,
409
+ eventVersion,
410
+ amountMinor: totals.totalMinor,
411
+ };
412
+ }
@@ -10,7 +10,22 @@
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 type { Queryable } from "../../d1/catalog.js";
13
+ import type { Queryable, QueryStatement } from "../../d1/catalog.js";
14
+ import {
15
+ canonicalJson,
16
+ idempotencyKey,
17
+ paginateOrders,
18
+ sha256Hex,
19
+ type CommerceStage,
20
+ type PaymentState,
21
+ } from "@tot/public-runtime";
22
+ import {
23
+ foldCommerceEvents,
24
+ packCommercePayload,
25
+ type CommerceSnapshot,
26
+ type CommerceSourceEvent,
27
+ type StoredCommercePayload,
28
+ } from "./orderMapper.js";
14
29
 
15
30
  export type OrderStatus = "created" | "fulfilled" | "refunded" | "cancelled";
16
31
 
@@ -179,3 +194,318 @@ export class OrdersStore {
179
194
  return row ? toDomain(row) : null;
180
195
  }
181
196
  }
197
+
198
+ interface CommerceOrderRow {
199
+ tenant_id: string;
200
+ stage: string;
201
+ order_id: string;
202
+ source: string;
203
+ source_order_id: string;
204
+ currency: string;
205
+ placed_at: string;
206
+ payment_state: PaymentState;
207
+ compliance_state: CommerceSnapshot["compliance"];
208
+ stock_state: CommerceSnapshot["stock"];
209
+ fulfillment_state: CommerceSnapshot["fulfillment"];
210
+ label_state: CommerceSnapshot["label"];
211
+ amount_minor: number;
212
+ event_version: number;
213
+ }
214
+
215
+ export interface CommerceOrderQueryRow {
216
+ id: string;
217
+ placedAt: string;
218
+ payment: PaymentState;
219
+ fulfillment: CommerceSnapshot["fulfillment"];
220
+ amountMinor: number;
221
+ eventVersion: number;
222
+ }
223
+
224
+ export interface CommerceAggregate {
225
+ count: number;
226
+ amountMinor: number;
227
+ byPayment: Partial<Record<PaymentState, number>>;
228
+ }
229
+
230
+ export interface CommitCommerceInput {
231
+ tenantId: string;
232
+ stage: CommerceStage;
233
+ event: CommerceSourceEvent;
234
+ now: string;
235
+ replay?: { issuer: string; jti: string };
236
+ }
237
+
238
+ export type CommitCommerceResult =
239
+ | { ok: true; duplicate: boolean; snapshot: CommerceSnapshot; outboxId: string }
240
+ | { ok: false; reason: "replay" };
241
+
242
+ async function atomic(db: Queryable, statements: readonly QueryStatement[]): Promise<void> {
243
+ if (typeof db.batch === "function") {
244
+ await db.batch(statements);
245
+ return;
246
+ }
247
+ await db.exec("BEGIN");
248
+ try {
249
+ for (const statement of statements) await db.run(statement.sql, ...(statement.params ?? []));
250
+ await db.exec("COMMIT");
251
+ } catch (error) {
252
+ await db.exec("ROLLBACK");
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ function constraintTable(error: unknown): string | null {
258
+ if (!(error instanceof Error)) return null;
259
+ const match = /scrappy_[a-z_]+/.exec(error.message);
260
+ return match ? match[0] : null;
261
+ }
262
+
263
+ /** Durable commerce projection. Order, inbox, outbox, and caller replay commit together. */
264
+ export class ScrappyOrderLedger {
265
+ constructor(private readonly db: Queryable) {}
266
+
267
+ async commit(input: CommitCommerceInput): Promise<CommitCommerceResult> {
268
+ const { tenantId, stage, event, now } = input;
269
+ if (input.replay) {
270
+ const seen = await this.db.first<{ jti: string }>(
271
+ `SELECT jti FROM scrappy_caller_replay WHERE tenant_id = ? AND stage = ? AND issuer = ? AND jti = ?`,
272
+ tenantId,
273
+ stage,
274
+ input.replay.issuer,
275
+ input.replay.jti,
276
+ );
277
+ if (seen) return { ok: false, reason: "replay" };
278
+ }
279
+
280
+ const existing = await this.db.first<{ outbox_id: string; payload: string }>(
281
+ `SELECT outbox_id, payload FROM scrappy_commerce_outbox
282
+ WHERE tenant_id = ? AND stage = ? AND source = ? AND source_event_id = ?`,
283
+ tenantId,
284
+ stage,
285
+ event.source,
286
+ event.sourceEventId,
287
+ );
288
+ if (existing) {
289
+ if (input.replay) {
290
+ try {
291
+ await this.insertReplay(input);
292
+ } catch (error) {
293
+ if (constraintTable(error) === "scrappy_caller_replay") return { ok: false, reason: "replay" };
294
+ throw error;
295
+ }
296
+ }
297
+ const snapshot = await this.readSnapshot(tenantId, stage, event.orderId, event);
298
+ return { ok: true, duplicate: true, snapshot, outboxId: existing.outbox_id };
299
+ }
300
+
301
+ const priorRows = await this.db.all<{ payload: string }>(
302
+ `SELECT payload FROM scrappy_commerce_outbox WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
303
+ tenantId,
304
+ stage,
305
+ event.orderId,
306
+ );
307
+ const prior: CommerceSourceEvent[] = [];
308
+ for (const row of priorRows) {
309
+ const parsed = JSON.parse(row.payload) as StoredCommercePayload;
310
+ prior.push(parsed.source);
311
+ }
312
+ const snapshot = foldCommerceEvents([...prior, event]);
313
+ const packed = packCommercePayload(event, snapshot);
314
+ const key = idempotencyKey(tenantId, stage, event.source, event.sourceEventId);
315
+ const outboxId = (await sha256Hex(key)).slice(0, 32);
316
+ const digest = await sha256Hex(canonicalJson(event));
317
+ const statements: QueryStatement[] = [];
318
+ if (input.replay) {
319
+ statements.push({
320
+ sql: `INSERT INTO scrappy_caller_replay (tenant_id, stage, issuer, jti, seen_at) VALUES (?,?,?,?,?)`,
321
+ params: [tenantId, stage, input.replay.issuer, input.replay.jti, now],
322
+ });
323
+ }
324
+ statements.push(
325
+ {
326
+ sql: `INSERT INTO scrappy_commerce_orders (
327
+ tenant_id, stage, order_id, source, source_order_id, currency, placed_at,
328
+ payment_state, compliance_state, stock_state, fulfillment_state, label_state,
329
+ amount_minor, event_version
330
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
331
+ ON CONFLICT(tenant_id, stage, order_id) DO UPDATE SET
332
+ source = excluded.source,
333
+ source_order_id = excluded.source_order_id,
334
+ currency = excluded.currency,
335
+ placed_at = excluded.placed_at,
336
+ payment_state = excluded.payment_state,
337
+ compliance_state = excluded.compliance_state,
338
+ stock_state = excluded.stock_state,
339
+ fulfillment_state = excluded.fulfillment_state,
340
+ label_state = excluded.label_state,
341
+ amount_minor = excluded.amount_minor,
342
+ event_version = excluded.event_version`,
343
+ params: [
344
+ tenantId,
345
+ stage,
346
+ snapshot.orderId,
347
+ snapshot.source,
348
+ snapshot.sourceOrderId,
349
+ snapshot.currency,
350
+ snapshot.placedAt,
351
+ snapshot.payment,
352
+ snapshot.compliance,
353
+ snapshot.stock,
354
+ snapshot.fulfillment,
355
+ snapshot.label,
356
+ snapshot.amountMinor,
357
+ snapshot.eventVersion,
358
+ ],
359
+ },
360
+ {
361
+ sql: `INSERT INTO scrappy_commerce_inbox (
362
+ tenant_id, stage, source, source_event_id, event_version, idempotency_key, payload_digest, received_at
363
+ ) VALUES (?,?,?,?,?,?,?,?)`,
364
+ params: [tenantId, stage, event.source, event.sourceEventId, event.eventVersion, key, digest, now],
365
+ },
366
+ {
367
+ sql: `INSERT INTO scrappy_commerce_outbox (
368
+ outbox_id, tenant_id, stage, order_id, source, source_event_id, event_version,
369
+ event_type, payload, available_at, attempt_count, state
370
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,0,'pending')`,
371
+ params: [
372
+ outboxId,
373
+ tenantId,
374
+ stage,
375
+ snapshot.orderId,
376
+ event.source,
377
+ event.sourceEventId,
378
+ event.eventVersion,
379
+ "order.observed",
380
+ JSON.stringify(packed),
381
+ now,
382
+ ],
383
+ },
384
+ );
385
+
386
+ try {
387
+ await atomic(this.db, statements);
388
+ } catch (error) {
389
+ const table = constraintTable(error);
390
+ if (table === "scrappy_caller_replay") return { ok: false, reason: "replay" };
391
+ if (table === "scrappy_commerce_inbox" || table === "scrappy_commerce_outbox") {
392
+ const raced = await this.db.first<{ outbox_id: string }>(
393
+ `SELECT outbox_id FROM scrappy_commerce_outbox
394
+ WHERE tenant_id = ? AND stage = ? AND source = ? AND source_event_id = ?`,
395
+ tenantId,
396
+ stage,
397
+ event.source,
398
+ event.sourceEventId,
399
+ );
400
+ if (raced) {
401
+ const snapshotNow = await this.readSnapshot(tenantId, stage, event.orderId, event);
402
+ return { ok: true, duplicate: true, snapshot: snapshotNow, outboxId: raced.outbox_id };
403
+ }
404
+ }
405
+ throw error;
406
+ }
407
+ return { ok: true, duplicate: false, snapshot, outboxId };
408
+ }
409
+
410
+ async queryOrders(
411
+ tenantId: string,
412
+ stage: CommerceStage,
413
+ limit: number,
414
+ cursor: string | null,
415
+ ): Promise<{ orders: CommerceOrderQueryRow[]; nextCursor: string | null; invalidCursor: boolean }> {
416
+ const rows = await this.db.all<CommerceOrderRow>(
417
+ `SELECT * FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ?`,
418
+ tenantId,
419
+ stage,
420
+ );
421
+ const orders = rows.map((row) => ({
422
+ id: row.order_id,
423
+ placedAt: row.placed_at,
424
+ payment: row.payment_state,
425
+ fulfillment: row.fulfillment_state,
426
+ amountMinor: row.amount_minor,
427
+ eventVersion: row.event_version,
428
+ }));
429
+ return paginateOrders(orders, limit, cursor);
430
+ }
431
+
432
+ async aggregateOrders(tenantId: string, stage: CommerceStage): Promise<CommerceAggregate> {
433
+ const rows = await this.db.all<{ payment_state: PaymentState; n: number; amount: number | null }>(
434
+ `SELECT payment_state, COUNT(*) AS n, SUM(amount_minor) AS amount
435
+ FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ?
436
+ GROUP BY payment_state`,
437
+ tenantId,
438
+ stage,
439
+ );
440
+ const byPayment: Partial<Record<PaymentState, number>> = {};
441
+ let count = 0;
442
+ let amountMinor = 0;
443
+ for (const row of rows) {
444
+ const n = Number(row.n);
445
+ byPayment[row.payment_state] = n;
446
+ count += n;
447
+ amountMinor += Number(row.amount ?? 0);
448
+ }
449
+ return { count, amountMinor, byPayment };
450
+ }
451
+
452
+ /** Reset one outbox row for this tenant and stage. Does not write another order. */
453
+ async replayOutbox(tenantId: string, stage: CommerceStage, outboxId: string, now: string): Promise<boolean> {
454
+ const row = await this.db.first<{ outbox_id: string }>(
455
+ `SELECT outbox_id FROM scrappy_commerce_outbox WHERE outbox_id = ? AND tenant_id = ? AND stage = ?`,
456
+ outboxId,
457
+ tenantId,
458
+ stage,
459
+ );
460
+ if (!row) return false;
461
+ await this.db.run(
462
+ `UPDATE scrappy_commerce_outbox
463
+ SET state = 'pending', available_at = ?, attempt_count = 0
464
+ WHERE outbox_id = ? AND tenant_id = ? AND stage = ?`,
465
+ now,
466
+ outboxId,
467
+ tenantId,
468
+ stage,
469
+ );
470
+ return true;
471
+ }
472
+
473
+ async readOrder(tenantId: string, stage: CommerceStage, orderId: string): Promise<CommerceOrderRow | null> {
474
+ return this.db.first<CommerceOrderRow>(
475
+ `SELECT * FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
476
+ tenantId,
477
+ stage,
478
+ orderId,
479
+ );
480
+ }
481
+
482
+ private async insertReplay(input: CommitCommerceInput): Promise<void> {
483
+ if (!input.replay) return;
484
+ await atomic(this.db, [
485
+ {
486
+ sql: `INSERT INTO scrappy_caller_replay (tenant_id, stage, issuer, jti, seen_at) VALUES (?,?,?,?,?)`,
487
+ params: [input.tenantId, input.stage, input.replay.issuer, input.replay.jti, input.now],
488
+ },
489
+ ]);
490
+ }
491
+
492
+ private async readSnapshot(
493
+ tenantId: string,
494
+ stage: CommerceStage,
495
+ orderId: string,
496
+ event: CommerceSourceEvent,
497
+ ): Promise<CommerceSnapshot> {
498
+ const rows = await this.db.all<{ payload: string }>(
499
+ `SELECT payload FROM scrappy_commerce_outbox WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
500
+ tenantId,
501
+ stage,
502
+ orderId,
503
+ );
504
+ const events: CommerceSourceEvent[] = [];
505
+ for (const row of rows) {
506
+ const parsed = JSON.parse(row.payload) as StoredCommercePayload;
507
+ if (parsed.source) events.push(parsed.source);
508
+ }
509
+ return foldCommerceEvents(events.length > 0 ? events : [event]);
510
+ }
511
+ }
@@ -22,11 +22,13 @@
22
22
  * public target can't 302 its way to an internal one; either case never
23
23
  * reads as `delivered`.
24
24
  */
25
- import { WebhookDeliveryStore, type WebhookDelivery, type WebhookEvent, type WebhookTopic } from "./deliveryStore.js";
25
+ import { WebhookDeliveryStore, ScrappyCommerceOutbox, type CommerceOutboxDue, type WebhookDelivery, type WebhookEvent, type WebhookTopic } from "./deliveryStore.js";
26
26
  import type { AppInstall } from "../apps/registryService.js";
27
27
  import { buildCloudEventEnvelope } from "./events.js";
28
28
  import { computeBackoffDelaySeconds, MAX_DELIVERY_ATTEMPTS, type AttemptOutcome } from "./dispatcher.js";
29
29
  import { validateWebhookEndpoint } from "./endpointGuard.js";
30
+ import { COMMERCE_SCHEDULER } from "@tot/public-runtime";
31
+ import type { Queryable } from "../d1/catalog.js";
30
32
 
31
33
  /** Bounded per-attempt timeout — a hung receiver must not hold a dispatcher slot open indefinitely. */
32
34
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -277,3 +279,110 @@ async function postWithTimeout(
277
279
  clearTimeout(timer);
278
280
  }
279
281
  }
282
+
283
+ const COMMERCE_PRIVATE_KEYS = new Set([
284
+ "customer",
285
+ "customerEmail",
286
+ "customerName",
287
+ "email",
288
+ "phone",
289
+ "line1",
290
+ "line2",
291
+ "postalCode",
292
+ "shippingAddress",
293
+ "billingAddress",
294
+ "address",
295
+ "firstName",
296
+ "lastName",
297
+ "name",
298
+ ]);
299
+
300
+ function commercePayloadHasPrivateIdentity(value: unknown): boolean {
301
+ if (value === null || typeof value !== "object") return false;
302
+ if (Array.isArray(value)) return value.some((item) => commercePayloadHasPrivateIdentity(item));
303
+ return Object.entries(value as Record<string, unknown>).some(
304
+ ([key, child]) => COMMERCE_PRIVATE_KEYS.has(key) || commercePayloadHasPrivateIdentity(child),
305
+ );
306
+ }
307
+
308
+ function publicCommercePayload(payload: string): Record<string, unknown> | null {
309
+ let parsed: unknown;
310
+ try {
311
+ parsed = JSON.parse(payload);
312
+ } catch {
313
+ return null;
314
+ }
315
+ if (commercePayloadHasPrivateIdentity(parsed)) return null;
316
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
317
+ const body = parsed as Record<string, unknown>;
318
+ const event = body.public;
319
+ if (event === null || typeof event !== "object" || Array.isArray(event)) return null;
320
+ if ((event as Record<string, unknown>).type !== "order.observed") return null;
321
+ return event as Record<string, unknown>;
322
+ }
323
+
324
+ export type CommerceSweepResult =
325
+ | { ok: true; delivered: number; retried: number; dead: number }
326
+ | { ok: false; reason: "scheduler" };
327
+
328
+ export type CommerceDeliver = (event: Record<string, unknown>) => Promise<void>;
329
+
330
+ /**
331
+ * Deliver due commerce outbox rows. A scheduler other than the declared alarm
332
+ * leaves every row pending.
333
+ */
334
+ export async function sweepDurableCommerce(input: {
335
+ db: Queryable;
336
+ scheduler: string;
337
+ now: string;
338
+ deliver?: CommerceDeliver;
339
+ }): Promise<CommerceSweepResult> {
340
+ if (input.scheduler !== COMMERCE_SCHEDULER) return { ok: false, reason: "scheduler" };
341
+ const port = new ScrappyCommerceOutbox(input.db);
342
+ const due = await port.due(input.now);
343
+ let delivered = 0;
344
+ let retried = 0;
345
+ let dead = 0;
346
+ const tenants: { tenantId: string; stage: string }[] = [];
347
+ const seenTenant = (tenantId: string, stage: string) => {
348
+ if (!tenants.some((item) => item.tenantId === tenantId && item.stage === stage)) {
349
+ tenants.push({ tenantId, stage });
350
+ }
351
+ };
352
+ for (const row of due) {
353
+ seenTenant(row.tenant_id, row.stage);
354
+ const outcome = await settleCommerceRow(port, row, input.now, input.deliver);
355
+ if (outcome === "delivered") delivered += 1;
356
+ else if (outcome === "retried") retried += 1;
357
+ else dead += 1;
358
+ }
359
+ for (const tenant of tenants) await port.noteCursor(tenant.tenantId, tenant.stage, input.now);
360
+ return { ok: true, delivered, retried, dead };
361
+ }
362
+
363
+ async function settleCommerceRow(
364
+ port: ScrappyCommerceOutbox,
365
+ row: CommerceOutboxDue,
366
+ now: string,
367
+ deliver: CommerceDeliver | undefined,
368
+ ): Promise<"delivered" | "retried" | "dead"> {
369
+ const event = publicCommercePayload(row.payload);
370
+ if (!event) {
371
+ await port.markDead(row.outbox_id);
372
+ return "dead";
373
+ }
374
+ try {
375
+ if (deliver) await deliver(event);
376
+ await port.markDelivered(row.outbox_id);
377
+ return "delivered";
378
+ } catch {
379
+ const next = row.attempt_count + 1;
380
+ if (next >= MAX_DELIVERY_ATTEMPTS) {
381
+ await port.markDead(row.outbox_id, next);
382
+ return "dead";
383
+ }
384
+ const availableAt = new Date(Date.parse(now) + computeBackoffDelaySeconds(next) * 1000).toISOString();
385
+ await port.markRetry(row.outbox_id, next, availableAt);
386
+ return "retried";
387
+ }
388
+ }
@@ -665,3 +665,73 @@ export class WebhookDeliveryStore {
665
665
  return toDomain(await this.mustFindDeliveryRow(deliveryId));
666
666
  }
667
667
  }
668
+
669
+ export interface CommerceOutboxDue {
670
+ outbox_id: string;
671
+ tenant_id: string;
672
+ stage: string;
673
+ payload: string;
674
+ attempt_count: number;
675
+ available_at: string;
676
+ }
677
+
678
+ /** Due commerce outbox rows. The order ledger owns the insert; this port owns sweep progress. */
679
+ export class ScrappyCommerceOutbox {
680
+ constructor(private readonly db: Queryable) {}
681
+
682
+ async due(now: string): Promise<CommerceOutboxDue[]> {
683
+ const rows = await this.db.all<CommerceOutboxDue>(
684
+ `SELECT outbox_id, tenant_id, stage, payload, attempt_count, available_at
685
+ FROM scrappy_commerce_outbox
686
+ WHERE state = 'pending' AND available_at <= ?
687
+ ORDER BY available_at`,
688
+ now,
689
+ );
690
+ return rows.map((row) => ({ ...row, attempt_count: Number(row.attempt_count) }));
691
+ }
692
+
693
+ async markDelivered(outboxId: string): Promise<void> {
694
+ await this.db.run(
695
+ `UPDATE scrappy_commerce_outbox SET state = 'delivered' WHERE outbox_id = ? AND state = 'pending'`,
696
+ outboxId,
697
+ );
698
+ }
699
+
700
+ async markRetry(outboxId: string, attemptCount: number, availableAt: string): Promise<void> {
701
+ await this.db.run(
702
+ `UPDATE scrappy_commerce_outbox
703
+ SET state = 'pending', attempt_count = ?, available_at = ?
704
+ WHERE outbox_id = ? AND state = 'pending'`,
705
+ attemptCount,
706
+ availableAt,
707
+ outboxId,
708
+ );
709
+ }
710
+
711
+ async markDead(outboxId: string, attemptCount?: number): Promise<void> {
712
+ if (attemptCount === undefined) {
713
+ await this.db.run(
714
+ `UPDATE scrappy_commerce_outbox SET state = 'dead' WHERE outbox_id = ? AND state = 'pending'`,
715
+ outboxId,
716
+ );
717
+ return;
718
+ }
719
+ await this.db.run(
720
+ `UPDATE scrappy_commerce_outbox
721
+ SET state = 'dead', attempt_count = ?
722
+ WHERE outbox_id = ? AND state = 'pending'`,
723
+ attemptCount,
724
+ outboxId,
725
+ );
726
+ }
727
+
728
+ async noteCursor(tenantId: string, stage: string, cursor: string): Promise<void> {
729
+ await this.db.run(
730
+ `INSERT INTO scrappy_sweep_cursor (tenant_id, stage, cursor) VALUES (?,?,?)
731
+ ON CONFLICT(tenant_id, stage) DO UPDATE SET cursor = excluded.cursor`,
732
+ tenantId,
733
+ stage,
734
+ cursor,
735
+ );
736
+ }
737
+ }
@@ -15,15 +15,16 @@
15
15
  * design-resolution + entry-wiring context).
16
16
  */
17
17
  import { WebhookDeliveryStore } from "./deliveryStore.js";
18
- import { attemptDelivery } from "./deliveryEngine.js";
18
+ import { attemptDelivery, sweepDurableCommerce, type CommerceDeliver, type CommerceSweepResult } from "./deliveryEngine.js";
19
19
  import { InMemoryDispatcher, type AttemptDeliveryFn, type WebhookDispatcher } from "./dispatcher.js";
20
20
  import { CloudflareQueueDispatcher, type CloudflareQueueSender } from "./cloudflareQueueDispatcher.js";
21
21
  import { AppRegistryService } from "../apps/registryService.js";
22
22
  import { D1CredentialStore } from "../apps/credentials.js";
23
23
  import { DEFAULT_APP_GATEWAY_KID, APP_GATEWAY_KID_ENV } from "../apps/gatewayKeys.js";
24
24
  import { json } from "../apps/registryAccess.js";
25
- import { fromD1 } from "../d1/catalog.js";
25
+ import { fromD1, type Queryable } from "../d1/catalog.js";
26
26
  import { readD1, readEnv, readQueue } from "../env.js";
27
+ import { COMMERCE_SCHEDULER } from "@tot/public-runtime";
27
28
 
28
29
  /** Binding name `getDispatcher()` checks to pick the Cloudflare Queues adapter. Not yet declared in `wrangler.toml`. */
29
30
  export const WEBHOOK_QUEUE_BINDING = "WEBHOOK_DELIVERY_QUEUE";
@@ -47,3 +48,29 @@ export async function getDispatcher(): Promise<WebhookDispatcher | Response> {
47
48
  if (queueSender) return new CloudflareQueueDispatcher(queueSender);
48
49
  return new InMemoryDispatcher(attempt);
49
50
  }
51
+
52
+ /**
53
+ * Commerce work is already durable when it is saved. This dispatcher only
54
+ * sweeps that outbox, and only when the declared scheduler is configured.
55
+ * It never falls back to an in-memory queue.
56
+ */
57
+ export class CommerceAlarmDispatcher {
58
+ constructor(
59
+ private readonly db: Queryable,
60
+ private readonly scheduler: string,
61
+ ) {}
62
+
63
+ async enqueue(): Promise<void> {}
64
+
65
+ sweep(now: string, deliver?: CommerceDeliver): Promise<CommerceSweepResult> {
66
+ return sweepDurableCommerce({ db: this.db, scheduler: this.scheduler, now, deliver });
67
+ }
68
+ }
69
+
70
+ export async function getReliableDispatcher(): Promise<CommerceAlarmDispatcher | Response> {
71
+ const scheduler = await readEnv("SCRAPPY_COMMERCE_SCHEDULER");
72
+ if (scheduler !== COMMERCE_SCHEDULER) return json({ error: "commerce scheduler not configured" }, 503);
73
+ const d1 = await readD1("STOREFRONT_APPS_DB");
74
+ if (!d1) return json({ error: "commerce scheduler not configured" }, 503);
75
+ return new CommerceAlarmDispatcher(fromD1(d1), scheduler);
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.53",
3
+ "version": "2.4.54",
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",