@tokenoftrust/storefront-runner 6.5.0 → 6.5.2

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 (23) hide show
  1. package/apps/storefront/migrations-apps/0026_fulfillment_assignment.sql +44 -0
  2. package/apps/storefront/migrations-apps/0027_app_contact_authority.sql +70 -0
  3. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +56 -1
  4. package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +21 -1
  5. package/apps/storefront/src/lib/fulfillment/assignmentStore.ts +284 -0
  6. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +2 -0
  7. package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +18 -1
  8. package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +28 -0
  9. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +429 -70
  10. package/apps/storefront/src/lib/integrations/apps/appContactAuthority.ts +27 -0
  11. package/apps/storefront/src/lib/integrations/apps/appContactConsents.ts +112 -0
  12. package/apps/storefront/src/lib/integrations/apps/registryService.ts +101 -65
  13. package/apps/storefront/src/lib/integrations/webhooks/commercePayloads.ts +101 -0
  14. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +17 -34
  15. package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +130 -0
  16. package/apps/storefront/src/lib/integrations/webhooks/dispatcher.ts +1 -1
  17. package/apps/storefront/src/lib/integrations/webhooks/getDispatcher.ts +8 -3
  18. package/apps/storefront/src/lib/integrations/webhooks/orderFulfilledClaim.ts +75 -0
  19. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +65 -4
  20. package/apps/storefront/src/lib/orders/vault/appRecipientRead.ts +217 -0
  21. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -3
  22. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +57 -0
  23. package/package.json +1 -1
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Whether a verified Private App request may ask for order contact at all: the only source of the
3
+ * `app` contact-reader kind. It is separate from `contactReaderAuthority`, whose outcomes stay
4
+ * owner, staff or none, so no human contact route can ever see an app.
5
+ *
6
+ * Every predicate is required: an install record, active, on the host tenant, the token's subject
7
+ * naming it, and the scope in both the install grant and the token. Consent, assignment and quota
8
+ * are decided by the audit insert, not here.
9
+ */
10
+ export type AppContactAuthority = { kind: "app"; installId: string; tenantId: string } | { kind: "none" };
11
+
12
+ export interface AppContactAuthorityInput {
13
+ install: { installId: string; tenantId: string; status: string; scopes: readonly string[] } | null | undefined;
14
+ claims: { sub: string; scopes: readonly string[] } | null | undefined;
15
+ hostResource: string;
16
+ scope: string;
17
+ }
18
+
19
+ export function appContactAuthority(input: AppContactAuthorityInput): AppContactAuthority {
20
+ const { install, claims, scope } = input;
21
+ const host = input.hostResource.trim();
22
+ if (!install || !claims || !host || !scope) return { kind: "none" };
23
+ if (install.status !== "active" || install.tenantId !== host) return { kind: "none" };
24
+ if (!install.installId || claims.sub !== install.installId) return { kind: "none" };
25
+ if (!install.scopes.includes(scope) || !claims.scopes.includes(scope)) return { kind: "none" };
26
+ return { kind: "app", installId: install.installId, tenantId: host };
27
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Owner consent for a Private App install to read order recipients (`app_contact_consents`). One
3
+ * purpose, `fulfillment-shipping`. Rows hold opaque ids, times and `granted_by` (sha256 hex of the
4
+ * owner actor id); never contact data or an email. Grant inserts a row only when the install is
5
+ * active on the tenant; revoke sets `revoked_at` once; uninstall revokes every live row in its own
6
+ * batch. The table's triggers refuse DELETE and every other UPDATE.
7
+ */
8
+ import type { Queryable } from "../../catalog/d1/catalog.js";
9
+
10
+ export const CONSENT_PURPOSE = "fulfillment-shipping";
11
+ export const CONSENT_MAX_MS = 365 * 24 * 3_600_000;
12
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
13
+ const CONSENT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
14
+
15
+ export type ConsentStage = "preview" | "publication";
16
+
17
+ export interface ConsentView {
18
+ consentId: string;
19
+ installId: string;
20
+ stage: ConsentStage;
21
+ purpose: typeof CONSENT_PURPOSE;
22
+ grantedAt: string;
23
+ expiresAt: string;
24
+ revokedAt: string | null;
25
+ }
26
+
27
+ export function isConsentId(value: string): boolean {
28
+ return CONSENT_ID.test(value) && !value.includes("@");
29
+ }
30
+
31
+ /**
32
+ * The closed grant body `{installId, purpose, expiresAt}`: purpose exactly `fulfillment-shipping`,
33
+ * expiresAt an ISO instant after `now` and at most 365 days later. Null otherwise.
34
+ */
35
+ export function readConsentGrant(body: unknown, now: Date): { installId: string; expiresAt: string } | null {
36
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
37
+ const record = body as Record<string, unknown>;
38
+ const keys = Object.keys(record).sort();
39
+ if (keys.length !== 3 || keys.join(",") !== "expiresAt,installId,purpose") return null;
40
+ const { installId, purpose, expiresAt } = record;
41
+ if (purpose !== CONSENT_PURPOSE) return null;
42
+ if (typeof installId !== "string" || !isConsentId(installId)) return null;
43
+ if (typeof expiresAt !== "string" || !ISO_INSTANT.test(expiresAt)) return null;
44
+ const expires = Date.parse(expiresAt);
45
+ if (!Number.isFinite(expires) || new Date(expires).toISOString() !== expiresAt) return null;
46
+ if (expires <= now.getTime() || expires - now.getTime() > CONSENT_MAX_MS) return null;
47
+ return { installId, expiresAt };
48
+ }
49
+
50
+ /** sha256 hex of the owner actor id: the only owner reference a consent row keeps. */
51
+ export async function grantedByHash(actorId: string): Promise<string> {
52
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(actorId));
53
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
54
+ }
55
+
56
+ function isUniqueViolation(error: unknown): boolean {
57
+ return error instanceof Error && /UNIQUE constraint failed/i.test(error.message);
58
+ }
59
+
60
+ export type GrantOutcome =
61
+ | { ok: true; consent: ConsentView }
62
+ | { ok: false; code: "install-not-found" | "conflict" };
63
+
64
+ export async function grantConsent(
65
+ db: Queryable,
66
+ input: { tenantId: string; stage: ConsentStage; installId: string; expiresAt: string; grantedBy: string; now: Date; consentId?: string },
67
+ ): Promise<GrantOutcome> {
68
+ const consentId = input.consentId ?? crypto.randomUUID();
69
+ const grantedAt = input.now.toISOString();
70
+ try {
71
+ const rows = await db.all<{ consent_id: string }>(
72
+ `INSERT INTO app_contact_consents (consent_id, install_id, tenant_id, stage, purpose, granted_by, granted_at, expires_at, revoked_at)
73
+ SELECT ?, ?, ?, ?, '${CONSENT_PURPOSE}', ?, ?, ?, NULL
74
+ WHERE EXISTS (SELECT 1 FROM app_installs WHERE install_id = ? AND tenant_id = ? AND status = 'active')
75
+ RETURNING consent_id`,
76
+ consentId, input.installId, input.tenantId, input.stage, input.grantedBy, grantedAt, input.expiresAt,
77
+ input.installId, input.tenantId,
78
+ );
79
+ if (rows.length !== 1) return { ok: false, code: "install-not-found" };
80
+ } catch (error) {
81
+ if (isUniqueViolation(error)) return { ok: false, code: "conflict" };
82
+ throw error;
83
+ }
84
+ return {
85
+ ok: true,
86
+ consent: {
87
+ consentId, installId: input.installId, stage: input.stage, purpose: CONSENT_PURPOSE,
88
+ grantedAt, expiresAt: input.expiresAt, revokedAt: null,
89
+ },
90
+ };
91
+ }
92
+
93
+ /** Sets `revoked_at` on one live consent of (tenant, stage); null when there is none to revoke. */
94
+ export async function revokeConsent(
95
+ db: Queryable,
96
+ input: { tenantId: string; stage: ConsentStage; consentId: string; now: Date },
97
+ ): Promise<ConsentView | null> {
98
+ const rows = await db.all<{
99
+ consent_id: string; install_id: string; stage: ConsentStage; granted_at: string; expires_at: string; revoked_at: string;
100
+ }>(
101
+ `UPDATE app_contact_consents SET revoked_at = ?
102
+ WHERE consent_id = ? AND tenant_id = ? AND stage = ? AND revoked_at IS NULL
103
+ RETURNING consent_id, install_id, stage, granted_at, expires_at, revoked_at`,
104
+ input.now.toISOString(), input.consentId, input.tenantId, input.stage,
105
+ );
106
+ const row = rows[0];
107
+ if (!row) return null;
108
+ return {
109
+ consentId: row.consent_id, installId: row.install_id, stage: row.stage, purpose: CONSENT_PURPOSE,
110
+ grantedAt: row.granted_at, expiresAt: row.expires_at, revokedAt: row.revoked_at,
111
+ };
112
+ }
@@ -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,16 @@ 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.revokeConsentsStatement(installId, row.tenant_id),
713
+ this.auditStatement({ installId, tenantId: row.tenant_id, event: "uninstalled", actor }),
714
+ ]);
703
715
  await this.credentials.clear(installId);
704
- await this.appendAudit({ installId, tenantId: row.tenant_id, event: "uninstalled", actor });
705
716
  return this.getOrThrow(installId);
706
717
  }
707
718
 
@@ -738,10 +749,9 @@ export class AppRegistryService {
738
749
  /**
739
750
  * Re-point `app_installs.app_version` (the derived mirror) to the version
740
751
  * 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.
752
+ * keeps the mirror and the source-of-truth authority from diverging after a
753
+ * `rollback`. `install` and `update` write the mirror from the activation they
754
+ * commit in the same write, so they are already consistent.
745
755
  */
746
756
  private async syncActiveVersionMirror(installId: string): Promise<void> {
747
757
  const current = await this.versionStore.currentVersion(installId);
@@ -769,13 +779,32 @@ export class AppRegistryService {
769
779
  return cause;
770
780
  }
771
781
 
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
- );
782
+ private statusStatement(installId: string, status: AppInstallStatus): { sql: string; params: unknown[] } {
783
+ return {
784
+ sql: `UPDATE app_installs SET status = ?, updated_at = ? WHERE install_id = ?`,
785
+ params: [status, this.now(), installId],
786
+ };
787
+ }
788
+
789
+ /** Revokes every live contact-read consent of the install on its tenant (`app_contact_consents`). */
790
+ private revokeConsentsStatement(installId: string, tenantId: string): { sql: string; params: unknown[] } {
791
+ return {
792
+ sql: "UPDATE app_contact_consents SET revoked_at = ? WHERE install_id = ? AND tenant_id = ? AND revoked_at IS NULL",
793
+ params: [this.now(), installId, tenantId],
794
+ };
795
+ }
796
+
797
+ private detach(tenantId: string, installId: string, actor: string): { sql: string; params: unknown[] }[] {
798
+ return detachInstallStatements({ tenantId, installId, actor, now: this.now() }).map((statement) => ({
799
+ sql: statement.sql,
800
+ params: [...statement.params],
801
+ }));
802
+ }
803
+
804
+ /** Lifecycle writes commit together or not at all; a handle without batch is refused before any write. */
805
+ private async lifecycleBatch(statements: { sql: string; params: unknown[] }[]): Promise<void> {
806
+ if (!this.db.batch) throw new Error("Atomic D1 batch support is required.");
807
+ await this.db.batch(statements);
779
808
  }
780
809
 
781
810
  private async findRowByAppTenantEnv(
@@ -809,25 +838,32 @@ export class AppRegistryService {
809
838
  return toDomain(await this.mustFindRow(installId));
810
839
  }
811
840
 
812
- private async appendAudit(params: {
841
+ private auditStatement(params: {
813
842
  installId: string;
814
843
  tenantId: string;
815
844
  event: AppAuditEvent;
816
845
  actor: string;
817
846
  detail?: Record<string, unknown>;
818
847
  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)
848
+ }): { sql: string; params: unknown[] } {
849
+ return {
850
+ sql: `INSERT INTO app_audit_log (id, install_id, tenant_id, event, actor, trace_id, detail, at)
822
851
  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
- );
852
+ params: [
853
+ crypto.randomUUID(),
854
+ params.installId,
855
+ params.tenantId,
856
+ params.event,
857
+ params.actor,
858
+ params.traceId ?? null,
859
+ JSON.stringify(params.detail ?? {}),
860
+ this.now(),
861
+ ],
862
+ };
863
+ }
864
+
865
+ private async appendAudit(params: Parameters<AppRegistryService["auditStatement"]>[0]): Promise<void> {
866
+ const statement = this.auditStatement(params);
867
+ await this.db.run(statement.sql, ...statement.params);
832
868
  }
833
869
  }
@@ -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 {