@tokenoftrust/storefront-runner 6.5.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.
@@ -41,9 +41,10 @@
41
41
  * `app_version_snapshots` table over the SAME `Queryable` seam this service
42
42
  * uses). The authority for "which version is active" is the version store's
43
43
  * `MAX(seq)` current snapshot; `app_installs.app_version` is a DERIVED MIRROR
44
- * that this service re-points from the store's post-write current version in
45
- * the same operation ({@link AppRegistryService.syncActiveVersionMirror}). The
46
- * two can never diverge because the mirror is only ever written from the
44
+ * that this service writes from the store's current version in the same
45
+ * operation: `update` writes it from the activation it commits in the same
46
+ * batch, and `rollback` re-syncs it ({@link AppRegistryService.syncActiveVersionMirror}).
47
+ * The two can never diverge because the mirror is only ever written from the
47
48
  * store's current snapshot — you change the active version by retaining/rolling
48
49
  * one, never by writing the mirror directly. `rollback` restores a prior
49
50
  * version's whole facet set atomically (store-side) and re-syncs the mirror, so
@@ -56,6 +57,7 @@ import {
56
57
  AppVersionError,
57
58
  type AppVersionFacets,
58
59
  type AppVersionSnapshot,
60
+ type PlannedVersionWrite,
59
61
  } from "./appVersionStore.js";
60
62
  import {
61
63
  DEFAULT_EXECUTION_TIER,
@@ -71,6 +73,7 @@ import {
71
73
  type PlanIntegrationConsent,
72
74
  } from "@tot/public-runtime";
73
75
  import type { Queryable } from "../../catalog/d1/catalog.js";
76
+ import { PROCESSOR_SCOPE, detachInstallStatements } from "../../fulfillment/assignmentStore.js";
74
77
 
75
78
  export type AppInstallStatus = "active" | "suspended" | "uninstalled";
76
79
 
@@ -558,20 +561,19 @@ export class AppRegistryService {
558
561
  const widgetPlacementsJson =
559
562
  widgetPlacements !== undefined ? JSON.stringify(widgetPlacements) : row.widget_placements;
560
563
 
561
- // Retain this update as a new activation BEFORE the metadata write — after
562
- // every fail-closed validation above, so a bad patch retains nothing. This
563
- // captures the whole facet set at this version and, being the last write
564
- // gate that can refuse (a secret-bearing facet set), keeps `update` all-or-
565
- // nothing. `app_version` is NOT written here: it is the mirror, re-pointed
566
- // below from the store's post-retain current version.
564
+ // Plan this update's new activation after every fail-closed validation above, so a
565
+ // bad patch retains nothing; planning is the last gate that can refuse (a
566
+ // secret-bearing facet set). The activation, the metadata write and the
567
+ // `app_version` mirror (the planned activation's version) commit in one batch.
567
568
  const facets = resolveVersionFacets({
568
569
  appVersion,
569
570
  scopes: effectiveScopes,
570
571
  manifest: patch.manifest !== undefined ? patch.manifest : (JSON.parse(row.manifest) as unknown),
571
572
  override: patch.versionFacets,
572
573
  });
574
+ let retained: PlannedVersionWrite;
573
575
  try {
574
- await this.versionStore.retainVersion({
576
+ retained = await this.versionStore.planRetainVersion({
575
577
  installId,
576
578
  tenantId: row.tenant_id,
577
579
  facets,
@@ -581,27 +583,27 @@ export class AppRegistryService {
581
583
  throw this.asRegistryError(cause);
582
584
  }
583
585
 
584
- await this.db.run(
585
- `UPDATE app_installs
586
- SET scopes = ?, manifest = ?, webhook_endpoint = ?,
587
- widget_placements = ?, updated_at = ?
588
- WHERE install_id = ?`,
589
- scopesJson,
590
- manifestJson,
591
- webhookEndpoint,
592
- widgetPlacementsJson,
593
- now,
594
- installId,
595
- );
596
- await this.syncActiveVersionMirror(installId);
597
-
598
- await this.appendAudit({
599
- installId,
600
- tenantId: row.tenant_id,
601
- event: "updated",
602
- actor,
603
- detail: { fields: Object.keys(patch) },
604
- });
586
+ // An install that can no longer be notified of fulfillment work loses its assignments and
587
+ // processor config in the same batch as the change that descoped it.
588
+ const processorCapable = effectiveScopes.includes(PROCESSOR_SCOPE) && !!webhookEndpoint;
589
+ await this.lifecycleBatch([
590
+ { sql: retained.statement.sql, params: [...(retained.statement.params ?? [])] },
591
+ {
592
+ sql: `UPDATE app_installs
593
+ SET scopes = ?, manifest = ?, webhook_endpoint = ?,
594
+ widget_placements = ?, app_version = ?, updated_at = ?
595
+ WHERE install_id = ?`,
596
+ params: [scopesJson, manifestJson, webhookEndpoint, widgetPlacementsJson, retained.snapshot.appVersion, now, installId],
597
+ },
598
+ ...(processorCapable ? [] : this.detach(row.tenant_id, installId, actor)),
599
+ this.auditStatement({
600
+ installId,
601
+ tenantId: row.tenant_id,
602
+ event: "updated",
603
+ actor,
604
+ detail: { fields: Object.keys(patch) },
605
+ }),
606
+ ]);
605
607
 
606
608
  return this.getOrThrow(installId);
607
609
  }
@@ -646,8 +648,11 @@ export class AppRegistryService {
646
648
  "invalid_state",
647
649
  );
648
650
  }
649
- await this.setStatus(installId, "suspended");
650
- await this.appendAudit({ installId, tenantId: row.tenant_id, event: "suspended", actor });
651
+ await this.lifecycleBatch([
652
+ this.statusStatement(installId, "suspended"),
653
+ ...this.detach(row.tenant_id, installId, actor),
654
+ this.auditStatement({ installId, tenantId: row.tenant_id, event: "suspended", actor }),
655
+ ]);
651
656
  return this.getOrThrow(installId);
652
657
  }
653
658
 
@@ -660,8 +665,11 @@ export class AppRegistryService {
660
665
  "invalid_state",
661
666
  );
662
667
  }
663
- await this.setStatus(installId, "active");
664
- await this.appendAudit({ installId, tenantId: row.tenant_id, event: "resumed", actor });
668
+ // Resume restores access only; released assignments stay released until a new operator release.
669
+ await this.lifecycleBatch([
670
+ this.statusStatement(installId, "active"),
671
+ this.auditStatement({ installId, tenantId: row.tenant_id, event: "resumed", actor }),
672
+ ]);
665
673
  return this.getOrThrow(installId);
666
674
  }
667
675
 
@@ -695,13 +703,15 @@ export class AppRegistryService {
695
703
  if (row.status === "uninstalled") {
696
704
  throw new RegistryError(`Install is already uninstalled.`, "invalid_state");
697
705
  }
698
- await this.db.run(
699
- `UPDATE app_installs SET status = 'uninstalled', widget_placements = '[]', updated_at = ? WHERE install_id = ?`,
700
- this.now(),
701
- installId,
702
- );
706
+ await this.lifecycleBatch([
707
+ {
708
+ sql: `UPDATE app_installs SET status = 'uninstalled', widget_placements = '[]', updated_at = ? WHERE install_id = ?`,
709
+ params: [this.now(), installId],
710
+ },
711
+ ...this.detach(row.tenant_id, installId, actor),
712
+ this.auditStatement({ installId, tenantId: row.tenant_id, event: "uninstalled", actor }),
713
+ ]);
703
714
  await this.credentials.clear(installId);
704
- await this.appendAudit({ installId, tenantId: row.tenant_id, event: "uninstalled", actor });
705
715
  return this.getOrThrow(installId);
706
716
  }
707
717
 
@@ -738,10 +748,9 @@ export class AppRegistryService {
738
748
  /**
739
749
  * Re-point `app_installs.app_version` (the derived mirror) to the version
740
750
  * store's current (`MAX(seq)`) snapshot — the single reconciliation step that
741
- * keeps the mirror and the source-of-truth authority from diverging. Called
742
- * after every store write that can change the active version (`update`,
743
- * `rollback`); `install` sets the mirror from the same resolved facet set at
744
- * INSERT time, so it is already consistent there.
751
+ * keeps the mirror and the source-of-truth authority from diverging after a
752
+ * `rollback`. `install` and `update` write the mirror from the activation they
753
+ * commit in the same write, so they are already consistent.
745
754
  */
746
755
  private async syncActiveVersionMirror(installId: string): Promise<void> {
747
756
  const current = await this.versionStore.currentVersion(installId);
@@ -769,13 +778,24 @@ export class AppRegistryService {
769
778
  return cause;
770
779
  }
771
780
 
772
- private async setStatus(installId: string, status: AppInstallStatus): Promise<void> {
773
- await this.db.run(
774
- `UPDATE app_installs SET status = ?, updated_at = ? WHERE install_id = ?`,
775
- status,
776
- this.now(),
777
- installId,
778
- );
781
+ private statusStatement(installId: string, status: AppInstallStatus): { sql: string; params: unknown[] } {
782
+ return {
783
+ sql: `UPDATE app_installs SET status = ?, updated_at = ? WHERE install_id = ?`,
784
+ params: [status, this.now(), installId],
785
+ };
786
+ }
787
+
788
+ private detach(tenantId: string, installId: string, actor: string): { sql: string; params: unknown[] }[] {
789
+ return detachInstallStatements({ tenantId, installId, actor, now: this.now() }).map((statement) => ({
790
+ sql: statement.sql,
791
+ params: [...statement.params],
792
+ }));
793
+ }
794
+
795
+ /** Lifecycle writes commit together or not at all; a handle without batch is refused before any write. */
796
+ private async lifecycleBatch(statements: { sql: string; params: unknown[] }[]): Promise<void> {
797
+ if (!this.db.batch) throw new Error("Atomic D1 batch support is required.");
798
+ await this.db.batch(statements);
779
799
  }
780
800
 
781
801
  private async findRowByAppTenantEnv(
@@ -809,25 +829,32 @@ export class AppRegistryService {
809
829
  return toDomain(await this.mustFindRow(installId));
810
830
  }
811
831
 
812
- private async appendAudit(params: {
832
+ private auditStatement(params: {
813
833
  installId: string;
814
834
  tenantId: string;
815
835
  event: AppAuditEvent;
816
836
  actor: string;
817
837
  detail?: Record<string, unknown>;
818
838
  traceId?: string;
819
- }): Promise<void> {
820
- await this.db.run(
821
- `INSERT INTO app_audit_log (id, install_id, tenant_id, event, actor, trace_id, detail, at)
839
+ }): { sql: string; params: unknown[] } {
840
+ return {
841
+ sql: `INSERT INTO app_audit_log (id, install_id, tenant_id, event, actor, trace_id, detail, at)
822
842
  VALUES (?,?,?,?,?,?,?,?)`,
823
- crypto.randomUUID(),
824
- params.installId,
825
- params.tenantId,
826
- params.event,
827
- params.actor,
828
- params.traceId ?? null,
829
- JSON.stringify(params.detail ?? {}),
830
- this.now(),
831
- );
843
+ params: [
844
+ crypto.randomUUID(),
845
+ params.installId,
846
+ params.tenantId,
847
+ params.event,
848
+ params.actor,
849
+ params.traceId ?? null,
850
+ JSON.stringify(params.detail ?? {}),
851
+ this.now(),
852
+ ],
853
+ };
854
+ }
855
+
856
+ private async appendAudit(params: Parameters<AppRegistryService["auditStatement"]>[0]): Promise<void> {
857
+ const statement = this.auditStatement(params);
858
+ await this.db.run(statement.sql, ...statement.params);
832
859
  }
833
860
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Payload admission for the commerce topics the single commerce emitter writes:
3
+ * `fulfillment.assignment.created`, `fulfillment.assignment.updated` and `order.fulfilled`.
4
+ *
5
+ * Each topic has a closed key set (`asyncapi.yaml` `FulfillmentAssignmentData` and `OrderData`)
6
+ * and every payload also passes the private-identity check. Both run when the event row is
7
+ * written and again before the delivery engine signs and POSTs it.
8
+ */
9
+ import type { WebhookTopic } from "./deliveryStore.js";
10
+
11
+ export type CommerceTopic = "fulfillment.assignment.created" | "fulfillment.assignment.updated" | "order.fulfilled";
12
+
13
+ export const COMMERCE_TOPICS: readonly CommerceTopic[] = [
14
+ "fulfillment.assignment.created",
15
+ "fulfillment.assignment.updated",
16
+ "order.fulfilled",
17
+ ];
18
+
19
+ /** Keys that identify a person or an address. None may appear at any depth of a commerce payload. */
20
+ export const COMMERCE_PRIVATE_KEYS = new Set([
21
+ "customer",
22
+ "customerEmail",
23
+ "customerName",
24
+ "email",
25
+ "phone",
26
+ "line1",
27
+ "line2",
28
+ "city",
29
+ "company",
30
+ "postalCode",
31
+ "shippingAddress",
32
+ "billingAddress",
33
+ "shipTo",
34
+ "billTo",
35
+ "address",
36
+ "firstName",
37
+ "lastName",
38
+ "name",
39
+ ]);
40
+
41
+ const ASSIGNMENT_KEYS = ["assignmentId", "orderId", "stage", "state", "eventVersion", "occurredAt"] as const;
42
+ const ASSIGNMENT_STATES = new Set(["active", "held", "cancelled"]);
43
+ const ORDER_DATA_KEYS = new Set(["id", "status", "currency", "customerHash", "subtotal", "total", "lineItems"]);
44
+ const LINE_ITEM_KEYS = new Set(["sku", "handle", "quantity", "lineTotal"]);
45
+
46
+ export function isCommerceTopic(topic: WebhookTopic): topic is CommerceTopic {
47
+ return (COMMERCE_TOPICS as readonly string[]).includes(topic);
48
+ }
49
+
50
+ export function commercePayloadHasPrivateIdentity(value: unknown): boolean {
51
+ if (value === null || typeof value !== "object") return false;
52
+ if (Array.isArray(value)) return value.some((item) => commercePayloadHasPrivateIdentity(item));
53
+ return Object.entries(value as Record<string, unknown>).some(
54
+ ([key, child]) => COMMERCE_PRIVATE_KEYS.has(key) || commercePayloadHasPrivateIdentity(child),
55
+ );
56
+ }
57
+
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+
62
+ function nonEmptyString(value: unknown): value is string {
63
+ return typeof value === "string" && value.length > 0 && value.length <= 256;
64
+ }
65
+
66
+ function assignmentAdmissible(topic: CommerceTopic, payload: Record<string, unknown>): boolean {
67
+ const keys = Object.keys(payload);
68
+ if (keys.length !== ASSIGNMENT_KEYS.length || !ASSIGNMENT_KEYS.every((key) => keys.includes(key))) return false;
69
+ if (!nonEmptyString(payload.assignmentId) || !nonEmptyString(payload.orderId)) return false;
70
+ if (payload.stage !== "preview" && payload.stage !== "publication") return false;
71
+ if (typeof payload.state !== "string" || !ASSIGNMENT_STATES.has(payload.state)) return false;
72
+ if (topic === "fulfillment.assignment.created" && payload.state !== "active") return false;
73
+ if (!Number.isSafeInteger(payload.eventVersion) || Number(payload.eventVersion) < 0) return false;
74
+ return typeof payload.occurredAt === "string" && Number.isFinite(Date.parse(payload.occurredAt));
75
+ }
76
+
77
+ function orderFulfilledAdmissible(payload: Record<string, unknown>): boolean {
78
+ if (Object.keys(payload).some((key) => !ORDER_DATA_KEYS.has(key))) return false;
79
+ if (!nonEmptyString(payload.id) || payload.status !== "fulfilled" || !nonEmptyString(payload.currency)) return false;
80
+ if (payload.customerHash !== undefined && typeof payload.customerHash !== "string") return false;
81
+ for (const key of ["subtotal", "total"] as const) {
82
+ if (payload[key] !== undefined && (typeof payload[key] !== "number" || !Number.isFinite(payload[key]))) return false;
83
+ }
84
+ if (payload.lineItems === undefined) return true;
85
+ if (!Array.isArray(payload.lineItems)) return false;
86
+ return payload.lineItems.every(
87
+ (item) =>
88
+ isRecord(item) &&
89
+ Object.keys(item).every((key) => LINE_ITEM_KEYS.has(key)) &&
90
+ nonEmptyString(item.sku) &&
91
+ Number.isSafeInteger(item.quantity) &&
92
+ (item.handle === undefined || typeof item.handle === "string") &&
93
+ (item.lineTotal === undefined || (typeof item.lineTotal === "number" && Number.isFinite(item.lineTotal))),
94
+ );
95
+ }
96
+
97
+ /** True when `payload` is exactly the closed shape `topic` allows and carries no private identity. */
98
+ export function commercePayloadAdmissible(topic: CommerceTopic, payload: unknown): boolean {
99
+ if (!isRecord(payload) || commercePayloadHasPrivateIdentity(payload)) return false;
100
+ return topic === "order.fulfilled" ? orderFulfilledAdmissible(payload) : assignmentAdmissible(topic, payload);
101
+ }
@@ -24,7 +24,8 @@
24
24
  */
25
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
- import { buildCloudEventEnvelope } from "./events.js";
27
+ import { buildCloudEventEnvelope, TOPIC_REQUIRED_SCOPE } from "./events.js";
28
+ import { commercePayloadAdmissible, commercePayloadHasPrivateIdentity, isCommerceTopic } from "./commercePayloads.js";
28
29
  import { computeBackoffDelaySeconds, MAX_DELIVERY_ATTEMPTS, type AttemptOutcome } from "./dispatcher.js";
29
30
  import { validateWebhookEndpoint } from "./endpointGuard.js";
30
31
  import { COMMERCE_SCHEDULER } from "@tot/public-runtime";
@@ -91,11 +92,17 @@ export class DeliveryEngineError extends Error {
91
92
  * still creates a delivery row for it), so refusing it here on status alone
92
93
  * would silently swallow every uninstall notification. It still refuses a
93
94
  * missing install or one with no endpoint, and every OTHER topic still
94
- * refuses a non-active (suspended/uninstalled) install exactly as before.
95
+ * refuses a non-active (suspended/uninstalled) install. Every topic also
96
+ * requires, at delivery time, that the install belongs to the delivery's
97
+ * tenant and still holds `TOPIC_REQUIRED_SCOPE[topic]`, so a descoped install
98
+ * receives nothing already queued.
95
99
  */
96
- function isDeadInstall(install: AppInstall | null, topic: WebhookTopic): boolean {
100
+ function isDeadInstall(install: AppInstall | null, delivery: WebhookDelivery): boolean {
97
101
  if (!install || !install.webhookEndpoint) return true;
98
- if (topic === "app.uninstalled") return false;
102
+ if (install.tenantId !== delivery.tenantId) return true;
103
+ const requiredScope = TOPIC_REQUIRED_SCOPE[delivery.topic];
104
+ if (requiredScope != null && !install.scopes.includes(requiredScope)) return true;
105
+ if (delivery.topic === "app.uninstalled") return false;
99
106
  return install.status !== "active";
100
107
  }
101
108
 
@@ -138,7 +145,7 @@ export async function attemptDelivery(deliveryId: string, deps: AttemptDeliveryD
138
145
  throw new DeliveryEngineError(`Cannot resolve install "${delivery.installId}" for delivery "${deliveryId}".`, cause);
139
146
  }
140
147
 
141
- if (isDeadInstall(install, delivery.topic)) {
148
+ if (isDeadInstall(install, delivery)) {
142
149
  // Don't deliver to a dead/suspended install, or one with no endpoint
143
150
  // configured — no POST attempt, no recordAttempt row; go straight to the
144
151
  // one terminal-failure state the store has. This is how suspension kills
@@ -193,6 +200,11 @@ async function deliverSigned(
193
200
  return { kind: "dead-lettered", reason: "endpoint_blocked" };
194
201
  }
195
202
 
203
+ if (isCommerceTopic(delivery.topic) && !commercePayloadAdmissible(delivery.topic, event.payload)) {
204
+ await ctx.store.transitionState({ deliveryId, toState: "dead-lettered" });
205
+ return { kind: "dead-lettered", reason: "payload_refused" };
206
+ }
207
+
196
208
  const envelope = buildCloudEventEnvelope({
197
209
  deliveryId,
198
210
  tenantId: delivery.tenantId,
@@ -283,35 +295,6 @@ async function postWithTimeout(
283
295
  // Transport rejects private fields even when an outbox row was written outside
284
296
  // the Orders service. scrappyDurability.test.ts checks every Orders private key
285
297
  // recursively at actual retry time so the two boundary policies cannot diverge.
286
- const COMMERCE_PRIVATE_KEYS = new Set([
287
- "customer",
288
- "customerEmail",
289
- "customerName",
290
- "email",
291
- "phone",
292
- "line1",
293
- "line2",
294
- "city",
295
- "company",
296
- "postalCode",
297
- "shippingAddress",
298
- "billingAddress",
299
- "shipTo",
300
- "billTo",
301
- "address",
302
- "firstName",
303
- "lastName",
304
- "name",
305
- ]);
306
-
307
- function commercePayloadHasPrivateIdentity(value: unknown): boolean {
308
- if (value === null || typeof value !== "object") return false;
309
- if (Array.isArray(value)) return value.some((item) => commercePayloadHasPrivateIdentity(item));
310
- return Object.entries(value as Record<string, unknown>).some(
311
- ([key, child]) => COMMERCE_PRIVATE_KEYS.has(key) || commercePayloadHasPrivateIdentity(child),
312
- );
313
- }
314
-
315
298
  function publicCommercePayload(payload: string): Record<string, unknown> | null {
316
299
  let parsed: unknown;
317
300
  try {
@@ -40,6 +40,8 @@
40
40
  * read-after-write discipline `AppRegistryService` uses.
41
41
  */
42
42
  import type { Queryable } from "../../catalog/d1/catalog.js";
43
+ import { TOPIC_REQUIRED_SCOPE } from "./events.js";
44
+ import { COMMERCE_TOPICS, commercePayloadAdmissible, type CommerceTopic } from "./commercePayloads.js";
43
45
 
44
46
  /** Mirrors the `Delivery.topic` / CloudEvents channel enum in the D1/D2 contract. */
45
47
  export type WebhookTopic =
@@ -668,6 +670,134 @@ export class WebhookDeliveryStore {
668
670
  }
669
671
  }
670
672
 
673
+ /** A commerce delivery enqueued after its commit becomes the tenant commerce alarm's only after this long. */
674
+ export const COMMERCE_DELIVERY_GRACE_MS = 60_000;
675
+
676
+ /** `asyncapi.yaml`'s `dataschemaversion` for V1. */
677
+ export const COMMERCE_DATASCHEMA_VERSION = "1";
678
+
679
+ /** Thrown before any statement is built when a commerce payload fails its topic's closed schema. */
680
+ export class CommercePayloadRefused extends Error {
681
+ constructor(readonly topic: CommerceTopic) {
682
+ super("commerce-payload-refused");
683
+ this.name = "CommercePayloadRefused";
684
+ }
685
+ }
686
+
687
+ export interface CommerceEventStatement {
688
+ sql: string;
689
+ params: unknown[];
690
+ }
691
+
692
+ /** SQL condition an eligible install row `i` satisfies; binds the tenant id, then the topic's required scope. */
693
+ const ELIGIBLE_INSTALL_CONDITION = `i.tenant_id = ? AND i.status = 'active' AND i.managed_kind IS NULL
694
+ AND COALESCE(i.webhook_endpoint, '') != ''
695
+ AND EXISTS (SELECT 1 FROM json_each(i.scopes) WHERE json_each.value = ?)`;
696
+
697
+ /** Installs on `tenantId` that would receive `topic` now, oldest first. Statements re-check this at write. */
698
+ export function commerceTargetsQuery(tenantId: string, topic: CommerceTopic): CommerceEventStatement {
699
+ return {
700
+ sql: `SELECT i.install_id AS install_id FROM app_installs AS i
701
+ WHERE ${ELIGIBLE_INSTALL_CONDITION}
702
+ ORDER BY i.created_at ASC, i.install_id ASC`,
703
+ params: [tenantId, TOPIC_REQUIRED_SCOPE[topic]],
704
+ };
705
+ }
706
+
707
+ /**
708
+ * The single commerce emitter: one `webhook_events` row written only when `gate` holds, and one
709
+ * pending `webhook_deliveries` row per target written only when that event row exists and the
710
+ * target install is still eligible. Ids are minted by the caller so the statements run inside the
711
+ * batch of the change they report; the delivery id is the CloudEvents `id`.
712
+ */
713
+ export function commerceEventStatements(input: {
714
+ eventId: string;
715
+ tenantId: string;
716
+ topic: CommerceTopic;
717
+ payload: unknown;
718
+ gate: CommerceEventStatement;
719
+ targets: readonly { installId: string; deliveryId: string }[];
720
+ now: string;
721
+ }): CommerceEventStatement[] {
722
+ if (!commercePayloadAdmissible(input.topic, input.payload)) throw new CommercePayloadRefused(input.topic);
723
+ const statements: CommerceEventStatement[] = [
724
+ {
725
+ sql: `INSERT INTO webhook_events (id, tenant_id, topic, dataschema_version, payload, created_at)
726
+ SELECT ?, ?, ?, ?, ?, ? WHERE (${input.gate.sql})`,
727
+ params: [
728
+ input.eventId,
729
+ input.tenantId,
730
+ input.topic,
731
+ COMMERCE_DATASCHEMA_VERSION,
732
+ JSON.stringify(input.payload),
733
+ input.now,
734
+ ...input.gate.params,
735
+ ],
736
+ },
737
+ ];
738
+ for (const target of input.targets) {
739
+ statements.push({
740
+ sql: `INSERT INTO webhook_deliveries
741
+ (delivery_id, event_id, install_id, tenant_id, topic, state, attempts, created_at, updated_at)
742
+ SELECT ?, ?, i.install_id, i.tenant_id, ?, 'pending', 0, ?, ?
743
+ FROM app_installs AS i
744
+ WHERE i.install_id = ? AND ${ELIGIBLE_INSTALL_CONDITION}
745
+ AND EXISTS (SELECT 1 FROM webhook_events WHERE id = ? AND tenant_id = ? AND topic = ?)`,
746
+ params: [
747
+ target.deliveryId,
748
+ input.eventId,
749
+ input.topic,
750
+ input.now,
751
+ input.now,
752
+ target.installId,
753
+ input.tenantId,
754
+ TOPIC_REQUIRED_SCOPE[input.topic],
755
+ input.eventId,
756
+ input.tenantId,
757
+ input.topic,
758
+ ],
759
+ });
760
+ }
761
+ return statements;
762
+ }
763
+
764
+ /**
765
+ * Commerce-topic deliveries for one tenant that the alarm owns: `pending` rows older than the
766
+ * enqueue grace, and `retrying` rows whose next attempt is due.
767
+ */
768
+ export async function dueCommerceDeliveries(
769
+ db: Queryable,
770
+ tenantId: string,
771
+ now: string,
772
+ graceMs: number,
773
+ ): Promise<{ due: string[]; nextAt: string | null }> {
774
+ const cutoff = new Date(Date.parse(now) - graceMs).toISOString();
775
+ const topics = [...COMMERCE_TOPICS];
776
+ const marks = topics.map(() => "?").join(",");
777
+ const rows = await db.all<{ delivery_id: string }>(
778
+ `SELECT delivery_id FROM webhook_deliveries
779
+ WHERE tenant_id = ? AND topic IN (${marks})
780
+ AND ((state = 'pending' AND created_at <= ?) OR (state = 'retrying' AND next_attempt_at <= ?))
781
+ ORDER BY created_at ASC, delivery_id ASC LIMIT 100`,
782
+ tenantId,
783
+ ...topics,
784
+ cutoff,
785
+ now,
786
+ );
787
+ const next = await db.first<{ next_at: string | null }>(
788
+ `SELECT MIN(CASE WHEN state = 'pending' THEN strftime('%Y-%m-%dT%H:%M:%fZ', created_at, ?) ELSE next_attempt_at END) AS next_at
789
+ FROM webhook_deliveries
790
+ WHERE tenant_id = ? AND topic IN (${marks})
791
+ AND ((state = 'pending' AND created_at > ?) OR (state = 'retrying' AND next_attempt_at > ?))`,
792
+ `+${graceMs / 1000} seconds`,
793
+ tenantId,
794
+ ...topics,
795
+ cutoff,
796
+ now,
797
+ );
798
+ return { due: rows.map((row) => row.delivery_id), nextAt: next?.next_at ?? null };
799
+ }
800
+
671
801
  export interface CommerceOutboxDue {
672
802
  order_id: string;
673
803
  outbox_id: string;
@@ -57,7 +57,7 @@ export interface WebhookDispatcher {
57
57
  export type AttemptOutcome =
58
58
  | { kind: "delivered" }
59
59
  | { kind: "retrying"; nextAttemptAt: string }
60
- | { kind: "dead-lettered"; reason: "install_unavailable" | "max_attempts" | "endpoint_blocked" };
60
+ | { kind: "dead-lettered"; reason: "install_unavailable" | "max_attempts" | "endpoint_blocked" | "payload_refused" };
61
61
 
62
62
  /** One attempt of one delivery. Implemented by `deliveryEngine.ts`'s `attemptDelivery`, partially applied over its deps. */
63
63
  export type AttemptDeliveryFn = (deliveryId: string) => Promise<AttemptOutcome>;
@@ -29,6 +29,13 @@ import { COMMERCE_SCHEDULER } from "@tot/public-runtime";
29
29
  /** Binding name `getDispatcher()` checks to pick the Cloudflare Queues adapter. Not yet declared in `wrangler.toml`. */
30
30
  export const WEBHOOK_QUEUE_BINDING = "WEBHOOK_DELIVERY_QUEUE";
31
31
 
32
+ /** The `attemptDelivery` composition every dispatcher and the commerce alarm run per delivery id. */
33
+ export function deliveryAttemptFor(db: Queryable, activeKid: string = DEFAULT_APP_GATEWAY_KID): AttemptDeliveryFn {
34
+ const store = new WebhookDeliveryStore(db);
35
+ const registry = new AppRegistryService(db, new D1CredentialStore(db, activeKid));
36
+ return (deliveryId) => attemptDelivery(deliveryId, { store, registry });
37
+ }
38
+
32
39
  /**
33
40
  * Build a `WebhookDispatcher` over the real `STOREFRONT_APPS_DB` binding, or
34
41
  * return a 503 `Response` when that binding isn't configured in this
@@ -39,10 +46,8 @@ export async function getDispatcher(): Promise<WebhookDispatcher | Response> {
39
46
  if (!d1) return json({ error: "webhook dispatcher not configured" }, 503);
40
47
  const db = fromD1(d1);
41
48
 
42
- const store = new WebhookDeliveryStore(db);
43
49
  const activeKid = (await readEnv(APP_GATEWAY_KID_ENV)) ?? DEFAULT_APP_GATEWAY_KID;
44
- const registry = new AppRegistryService(db, new D1CredentialStore(db, activeKid));
45
- const attempt: AttemptDeliveryFn = (deliveryId) => attemptDelivery(deliveryId, { store, registry });
50
+ const attempt = deliveryAttemptFor(db, activeKid);
46
51
 
47
52
  const queueSender = (await readQueue(WEBHOOK_QUEUE_BINDING)) as CloudflareQueueSender | undefined;
48
53
  if (queueSender) return new CloudflareQueueDispatcher(queueSender);