@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.
- package/apps/storefront/migrations-apps/0026_fulfillment_assignment.sql +44 -0
- package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +21 -1
- package/apps/storefront/src/lib/fulfillment/assignmentStore.ts +284 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +18 -1
- package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +28 -0
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +429 -70
- package/apps/storefront/src/lib/integrations/apps/registryService.ts +92 -65
- package/apps/storefront/src/lib/integrations/webhooks/commercePayloads.ts +101 -0
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +17 -34
- package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +130 -0
- package/apps/storefront/src/lib/integrations/webhooks/dispatcher.ts +1 -1
- package/apps/storefront/src/lib/integrations/webhooks/getDispatcher.ts +8 -3
- package/apps/storefront/src/lib/integrations/webhooks/orderFulfilledClaim.ts +75 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +65 -4
- package/package.json +1 -1
|
@@ -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
|
|
45
|
-
* the
|
|
46
|
-
*
|
|
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
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
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.
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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.
|
|
650
|
-
|
|
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
|
-
|
|
664
|
-
await this.
|
|
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.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
742
|
-
*
|
|
743
|
-
*
|
|
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
|
|
773
|
-
|
|
774
|
-
`UPDATE app_installs SET status = ?, updated_at = ? WHERE install_id = ?`,
|
|
775
|
-
status,
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
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
|
-
}):
|
|
820
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
|
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,
|
|
100
|
+
function isDeadInstall(install: AppInstall | null, delivery: WebhookDelivery): boolean {
|
|
97
101
|
if (!install || !install.webhookEndpoint) return true;
|
|
98
|
-
if (
|
|
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
|
|
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
|
|
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);
|