@tokenoftrust/storefront-runner 6.4.0 → 6.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/apps/storefront/migrations-apps/0026_fulfillment_assignment.sql +44 -0
  2. package/apps/storefront/src/lib/assets/preview-pinned-image-urls.ts +29 -3
  3. package/apps/storefront/src/lib/checkout/commerce/cardCartForm.ts +2 -0
  4. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +16 -0
  5. package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +21 -1
  6. package/apps/storefront/src/lib/fulfillment/assignmentStore.ts +284 -0
  7. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +2 -0
  8. package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +18 -1
  9. package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +28 -0
  10. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +429 -70
  11. package/apps/storefront/src/lib/integrations/apps/registryService.ts +92 -65
  12. package/apps/storefront/src/lib/integrations/webhooks/commercePayloads.ts +101 -0
  13. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +17 -34
  14. package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +130 -0
  15. package/apps/storefront/src/lib/integrations/webhooks/dispatcher.ts +1 -1
  16. package/apps/storefront/src/lib/integrations/webhooks/getDispatcher.ts +8 -3
  17. package/apps/storefront/src/lib/integrations/webhooks/orderFulfilledClaim.ts +75 -0
  18. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +65 -4
  19. package/apps/storefront/src/pages/products/[handle].astro +2 -0
  20. package/package.json +1 -1
  21. package/packages/public-runtime/src/checkout.ts +73 -11
  22. package/packages/public-runtime/src/membership.ts +3 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `order.fulfilled` is claimed once per order across its two producers: the fulfillment handoff
3
+ * and the order-forward receiver. The claim is keyed (tenant, stage, source, source order id,
4
+ * topic); each producer writes its claim and its gated event rows in one batch, and only the
5
+ * producer whose claim carries its event id writes the event.
6
+ */
7
+ import type { CommerceStage } from "@tot/public-runtime";
8
+ import type { CommerceEventStatement } from "./deliveryStore.js";
9
+
10
+ export interface OrderFulfilledClaimKey {
11
+ tenantId: string;
12
+ stage: CommerceStage;
13
+ source: string;
14
+ sourceOrderId: string;
15
+ }
16
+
17
+ /** The forward contract's `env` mapped to a commerce stage; any other value claims and emits nothing. */
18
+ export function forwardStage(env: string): CommerceStage | null {
19
+ if (env === "production") return "publication";
20
+ if (env === "sandbox") return "preview";
21
+ return null;
22
+ }
23
+
24
+ /** The handoff's claim: written only when no claim exists and `gate` holds (the handoff's CAS committed). */
25
+ export function handoffClaimStatement(
26
+ key: OrderFulfilledClaimKey,
27
+ eventId: string,
28
+ now: string,
29
+ gate: CommerceEventStatement,
30
+ ): CommerceEventStatement {
31
+ return {
32
+ sql: `INSERT INTO commerce_event_claims
33
+ (tenant_id, stage, source, source_order_id, topic, event_id, claimed_by, claimed_at)
34
+ SELECT ?, ?, ?, ?, 'order.fulfilled', ?, 'handoff', ?
35
+ WHERE NOT EXISTS (
36
+ SELECT 1 FROM commerce_event_claims
37
+ WHERE tenant_id = ? AND stage = ? AND source = ? AND source_order_id = ? AND topic = 'order.fulfilled'
38
+ ) AND (${gate.sql})`,
39
+ params: [
40
+ key.tenantId,
41
+ key.stage,
42
+ key.source,
43
+ key.sourceOrderId,
44
+ eventId,
45
+ now,
46
+ key.tenantId,
47
+ key.stage,
48
+ key.source,
49
+ key.sourceOrderId,
50
+ ...gate.params,
51
+ ],
52
+ };
53
+ }
54
+
55
+ /** The forward receiver's claim. */
56
+ export function forwardClaimStatement(key: OrderFulfilledClaimKey, eventId: string, now: string): CommerceEventStatement {
57
+ return {
58
+ sql: `INSERT OR IGNORE INTO commerce_event_claims
59
+ (tenant_id, stage, source, source_order_id, topic, event_id, claimed_by, claimed_at)
60
+ VALUES (?, ?, ?, ?, 'order.fulfilled', ?, 'forward', ?)`,
61
+ params: [key.tenantId, key.stage, key.source, key.sourceOrderId, eventId, now],
62
+ };
63
+ }
64
+
65
+ /** Event gate: the claim for `key` carries `eventId`. */
66
+ export function claimedByEventGate(key: OrderFulfilledClaimKey, eventId: string): CommerceEventStatement {
67
+ return {
68
+ sql: `EXISTS (
69
+ SELECT 1 FROM commerce_event_claims
70
+ WHERE tenant_id = ? AND stage = ? AND source = ? AND source_order_id = ? AND topic = 'order.fulfilled'
71
+ AND event_id = ?
72
+ )`,
73
+ params: [key.tenantId, key.stage, key.source, key.sourceOrderId, eventId],
74
+ };
75
+ }
@@ -21,6 +21,11 @@
21
21
  * corresponding topic — e.g. -> `cancelled`, which V1 has no topic for)
22
22
  * -> no emit; the row still updates, silently.
23
23
  *
24
+ * `order.fulfilled` is claimed once per order (`orderFulfilledClaim.ts`) under the key the signed
25
+ * intake and the fulfillment handoff use: (tenant, stage from `env`, `foxycart`, external order id).
26
+ * The claim, the event and its deliveries commit in one batch; a forward whose order is already
27
+ * claimed writes nothing, and an `env` other than production or sandbox emits no `order.fulfilled`.
28
+ *
24
29
  * Customer identity: `rawCustomerId` is optional (tot-foxycart's own
25
30
  * order-forward payload doesn't always carry one — mirrors `FoxyOrder`'s
26
31
  * own optional `customerEmail`). This receiver never derives, stores, or
@@ -36,7 +41,8 @@ import { SourceAuthorityStore } from "./sourceAuthorityStore.js";
36
41
  import { SourceAuthorityError, parseSourceWire, exactKeys, digestJson, type BeginAck, type ResultAck, type AuthorityCode, isRecord, isSourceId } from "./sourceAuthorityContract.js";
37
42
  import { OrdersStore, ScrappyOrderLedger, type OrderLineItem, type OrderStatus, type Order } from "./ordersStore.js";
38
43
  import { emitWebhookEvent } from "../../integrations/webhooks/emit.js";
39
- import type { WebhookTopic } from "../../integrations/webhooks/deliveryStore.js";
44
+ import { commerceEventStatements, commerceTargetsQuery, type WebhookTopic } from "../../integrations/webhooks/deliveryStore.js";
45
+ import { claimedByEventGate, forwardClaimStatement, forwardStage, type OrderFulfilledClaimKey } from "../../integrations/webhooks/orderFulfilledClaim.js";
40
46
  import type { Queryable } from "../../catalog/d1/catalog.js";
41
47
  import {
42
48
  createLocalJWKSet,
@@ -125,7 +131,11 @@ function topicsFor(previousStatus: OrderStatus | null, status: OrderStatus): Web
125
131
  * verified the request's HMAC signature — this function never re-checks
126
132
  * authenticity and must never be reached from an unverified request.
127
133
  */
128
- export async function receiveForwardedOrder(db: Queryable, input: OrderForwardInput): Promise<OrderForwardResult> {
134
+ export async function receiveForwardedOrder(
135
+ db: Queryable,
136
+ input: OrderForwardInput,
137
+ options: { notify?: (tenantId: string, deliveryIds: string[]) => void | Promise<void> } = {},
138
+ ): Promise<OrderForwardResult> {
129
139
  const store = new OrdersStore(db);
130
140
  // input.rawCustomerId is intentionally not read here — see the module doc's
131
141
  // "Customer identity" note for why no hash is derived from it.
@@ -142,14 +152,65 @@ export async function receiveForwardedOrder(db: Queryable, input: OrderForwardIn
142
152
  createdAt: input.createdAt ?? new Date().toISOString(),
143
153
  });
144
154
 
145
- const emitted = topicsFor(previousStatus, input.status);
146
- for (const topic of emitted) {
155
+ const emitted: WebhookTopic[] = [];
156
+ for (const topic of topicsFor(previousStatus, input.status)) {
157
+ if (topic === "order.fulfilled") {
158
+ if (await emitClaimedOrderFulfilled(db, input, order, options.notify)) emitted.push(topic);
159
+ continue;
160
+ }
147
161
  await emitWebhookEvent(topic, input.tenantId, toOrderData(order), {});
162
+ emitted.push(topic);
148
163
  }
149
164
 
150
165
  return { order, previousStatus, emitted };
151
166
  }
152
167
 
168
+ /** Claims `order.fulfilled` for this order and, only under that claim, writes its event and deliveries. */
169
+ async function emitClaimedOrderFulfilled(
170
+ db: Queryable,
171
+ input: OrderForwardInput,
172
+ order: Order,
173
+ notify: ((tenantId: string, deliveryIds: string[]) => void | Promise<void>) | undefined,
174
+ ): Promise<boolean> {
175
+ const stage = forwardStage(input.env);
176
+ if (!stage) return false;
177
+ if (!db.batch) throw new Error("Atomic D1 batch support is required.");
178
+ const key: OrderFulfilledClaimKey = {
179
+ tenantId: input.tenantId,
180
+ stage,
181
+ source: "foxycart",
182
+ sourceOrderId: input.externalOrderId,
183
+ };
184
+ const eventId = crypto.randomUUID();
185
+ const now = new Date().toISOString();
186
+ const targets = commerceTargetsQuery(input.tenantId, "order.fulfilled");
187
+ const installs = await db.all<{ install_id: string }>(targets.sql, ...targets.params);
188
+ const deliveries = installs.map((row) => ({ installId: row.install_id, deliveryId: crypto.randomUUID() }));
189
+ await db.batch([
190
+ forwardClaimStatement(key, eventId, now),
191
+ ...commerceEventStatements({
192
+ eventId,
193
+ tenantId: input.tenantId,
194
+ topic: "order.fulfilled",
195
+ payload: toOrderData(order),
196
+ gate: claimedByEventGate(key, eventId),
197
+ targets: deliveries,
198
+ now,
199
+ }),
200
+ ]);
201
+ const written = await db.first<{ ok: number }>(`SELECT 1 AS ok FROM webhook_events WHERE id = ?`, eventId);
202
+ if (!written) return false;
203
+ const committed = await db.all<{ delivery_id: string }>(`SELECT delivery_id FROM webhook_deliveries WHERE event_id = ?`, eventId);
204
+ if (notify && committed.length > 0) {
205
+ try {
206
+ await notify(input.tenantId, committed.map((row) => row.delivery_id));
207
+ } catch {
208
+ // The committed deliveries stay pending; the tenant commerce alarm redelivers them.
209
+ }
210
+ }
211
+ return true;
212
+ }
213
+
153
214
  export interface CommerceCallerConfig {
154
215
  expectedStage: CommerceStage;
155
216
  jwks: JSONWebKeySet;
@@ -33,6 +33,7 @@ import {
33
33
  DEFAULT_STACKING_POLICY,
34
34
  readReviewAggregate,
35
35
  resolveProductDetails,
36
+ tenantPublicOrigin,
36
37
  } from "@tot/public-runtime";
37
38
  import type {
38
39
  ProductPurchaseOption,
@@ -268,6 +269,7 @@ if (commerce || sampleHandoff) {
268
269
  tenantId: commerceTenantId(tenant),
269
270
  currency: tenant.currency,
270
271
  contentVersionId: Astro.locals.browserEnvironment.environment === "live" ? undefined : Astro.locals.contentVersionId,
272
+ publicOrigin: tenantPublicOrigin(tenant),
271
273
  }),
272
274
  ] as const;
273
275
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "6.4.0",
3
+ "version": "6.5.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -172,6 +172,32 @@ export interface BuildVariantCartPayloadOptions {
172
172
  * A publication page and a shared-channel preview omit it. Never taken from the form.
173
173
  */
174
174
  contentVersionId?: string;
175
+ /**
176
+ * The tenant's canonical public origin (`tenantPublicOrigin`), from server-side tenant config.
177
+ * A root-relative catalog image is signed as an absolute URL on it, so the checkout can load it
178
+ * and no page rewrite of the relative path can break the signature. Never a request value.
179
+ */
180
+ publicOrigin?: string;
181
+ }
182
+
183
+ /** `https://<canonicalDomain>` from server-side tenant config; undefined when it is not a bare host. */
184
+ export function tenantPublicOrigin(tenant: Pick<TenantConfig, "canonicalDomain">): string | undefined {
185
+ const host = tenant.canonicalDomain?.trim().toLowerCase();
186
+ return host && /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d{1,5})?$/.test(host) ? `https://${host}` : undefined;
187
+ }
188
+
189
+ /**
190
+ * A root-relative image resolved against the public origin. Absolute and protocol-relative values,
191
+ * and any that fail to parse or would resolve onto another origin, are signed as they are.
192
+ */
193
+ function cartImageUrl(image: string, publicOrigin: string | undefined): string {
194
+ if (!publicOrigin || !image.startsWith("/") || image.startsWith("//")) return image;
195
+ try {
196
+ const resolved = new URL(image, publicOrigin);
197
+ return resolved.origin === new URL(publicOrigin).origin ? resolved.href : image;
198
+ } catch {
199
+ return image;
200
+ }
175
201
  }
176
202
 
177
203
  /** Declared public serving substrate (ADR 0013 `serving.provider`). */
@@ -527,11 +553,12 @@ export function cartFieldsForVariant(
527
553
  product: CatalogProduct,
528
554
  variant: CatalogVariant,
529
555
  purchaseOption?: ProductPurchaseOption,
530
- opts?: { signedPrice?: number; tenantId?: string; currency?: string; contentVersionId?: string },
556
+ opts?: { signedPrice?: number; tenantId?: string; currency?: string; contentVersionId?: string; publicOrigin?: string },
531
557
  ): { code: string; fields: CartField[] } {
532
558
  const code = cartLineCode(variant);
533
- const image = product.images.find((i) => i.id === variant.image_id)?.url ??
559
+ const catalogImage = product.images.find((i) => i.id === variant.image_id)?.url ??
534
560
  product.images[0]?.url;
561
+ const image = catalogImage ? cartImageUrl(catalogImage, opts?.publicOrigin) : undefined;
535
562
  // The signed price is either the caller's member-adjusted EFFECTIVE price
536
563
  // (F11b — the single signed source of truth, computed server-side from the
537
564
  // shopper's resolved eligibility) or, absent that, the list/subscription price.
@@ -762,7 +789,10 @@ export async function buildVariantCartPayload(
762
789
  product,
763
790
  variant,
764
791
  opts.purchaseOption,
765
- { signedPrice: opts.signedPrice, tenantId: opts.tenantId, currency: opts.currency, contentVersionId: opts.contentVersionId },
792
+ {
793
+ signedPrice: opts.signedPrice, tenantId: opts.tenantId, currency: opts.currency,
794
+ contentVersionId: opts.contentVersionId, publicOrigin: opts.publicOrigin,
795
+ },
766
796
  );
767
797
  const signed = opts.mode === "signed";
768
798
  if (signed && !opts.secret) {
@@ -796,7 +826,15 @@ export interface CartSubmissionField {
796
826
 
797
827
  export type CartEnforcement =
798
828
  | { ok: true; fields: CartSubmissionField[]; paymentAuthorized: false }
799
- | { ok: false; code: CartDenialCode; message: string; paymentAuthorized: false };
829
+ | {
830
+ ok: false; code: CartDenialCode; message: string; paymentAuthorized: false;
831
+ /**
832
+ * Base names of the signed fields that failed verification or were missing, for the operator
833
+ * log. Names only, never a value, hash or the secret; a name that is not a plain field name is
834
+ * reported as `(unrecognized)`.
835
+ */
836
+ failedFields: string[];
837
+ };
800
838
 
801
839
  interface ParsedCartField {
802
840
  rawName: string;
@@ -839,12 +877,26 @@ function parseSubmittedField(rawName: string, value: string): ParsedCartField {
839
877
  return { rawName, baseName: rawName, value, open: false, passthrough: false };
840
878
  }
841
879
 
842
- function denial(code: CartDenialCode): CartEnforcement {
880
+ const MAX_REPORTED_CART_FIELDS = 20;
881
+
882
+ /**
883
+ * A submitted base name safe to log. Native and internal names are reported as-is; any other
884
+ * (a variant option) only when it is a short letters-only label, so a crafted name can never
885
+ * carry digits, an address or markup into the log.
886
+ */
887
+ function reportedFieldName(baseName: string): string {
888
+ if ((NATIVE_CART_FIELDS as readonly string[]).includes(baseName) ||
889
+ (INTERNAL_CART_FIELDS as readonly string[]).includes(baseName)) return baseName;
890
+ return /^[A-Za-z][A-Za-z _.-]{0,63}$/.test(baseName) ? baseName : "(unrecognized)";
891
+ }
892
+
893
+ function denial(code: CartDenialCode, failedFields: Iterable<string> = []): CartEnforcement {
843
894
  return {
844
895
  ok: false,
845
896
  code,
846
897
  message: CART_DENIAL_MESSAGE[code],
847
898
  paymentAuthorized: false,
899
+ failedFields: [...new Set([...failedFields].map(reportedFieldName))].slice(0, MAX_REPORTED_CART_FIELDS),
848
900
  };
849
901
  }
850
902
 
@@ -875,14 +927,15 @@ export async function enforceCartSubmission(input: {
875
927
  const byBase = new Map<string, ParsedCartField>();
876
928
  for (const field of parsed) {
877
929
  if (field.passthrough) continue;
878
- if (byBase.has(field.baseName)) return denial("signature");
930
+ if (byBase.has(field.baseName)) return denial("signature", [field.baseName]);
879
931
  byBase.set(field.baseName, field);
880
932
  }
881
933
 
882
934
  const codeField = byBase.get("code");
883
- if (!codeField || !codeField.value) return denial("signature");
935
+ if (!codeField || !codeField.value) return denial("signature", ["code"]);
884
936
 
885
937
  const failures = new Set<CartDenialCode>();
938
+ const failedFields = new Set<string>();
886
939
  const signed = input.mode === "signed";
887
940
  const secret = input.secret ?? "";
888
941
 
@@ -894,10 +947,16 @@ export async function enforceCartSubmission(input: {
894
947
  };
895
948
 
896
949
  if (signed) {
897
- if (!(await hashOk(codeField))) failures.add("signature");
950
+ if (!(await hashOk(codeField))) {
951
+ failures.add("signature");
952
+ failedFields.add("code");
953
+ }
898
954
  for (const field of byBase.values()) {
899
955
  if (field.baseName === "code") continue;
900
- if (!(await hashOk(field))) failures.add(specificCode(field.baseName));
956
+ if (!(await hashOk(field))) {
957
+ failures.add(specificCode(field.baseName));
958
+ failedFields.add(field.baseName);
959
+ }
901
960
  }
902
961
  }
903
962
 
@@ -911,13 +970,16 @@ export async function enforceCartSubmission(input: {
911
970
  if (!quantityField || !quantityAllowed(quantityField.value)) failures.add("quantity");
912
971
  if (signed) {
913
972
  for (const required of ["name", "currency"]) {
914
- if (!byBase.get(required)) failures.add("signature");
973
+ if (!byBase.get(required)) {
974
+ failures.add("signature");
975
+ failedFields.add(required);
976
+ }
915
977
  }
916
978
  }
917
979
 
918
980
  const order: CartDenialCode[] = ["tenant", "price", "quantity", "signature"];
919
981
  for (const code of order) {
920
- if (failures.has(code)) return denial(code);
982
+ if (failures.has(code)) return denial(code, failedFields);
921
983
  }
922
984
 
923
985
  return {
@@ -282,6 +282,8 @@ export interface MemberSignedCartPayloadOptions {
282
282
  currency?: string;
283
283
  /** Candidate content pin the preview page rendered. Omitted for publication and the shared channel. */
284
284
  contentVersionId?: string;
285
+ /** Tenant public origin a relative image is signed on (see {@link BuildVariantCartPayloadOptions.publicOrigin}). */
286
+ publicOrigin?: string;
285
287
  }
286
288
 
287
289
  /**
@@ -318,6 +320,7 @@ export async function buildMemberSignedCartPayload(
318
320
  tenantId: opts.tenantId,
319
321
  currency: opts.currency,
320
322
  contentVersionId: opts.contentVersionId,
323
+ publicOrigin: opts.publicOrigin,
321
324
  });
322
325
  }
323
326