@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.
- package/apps/storefront/migrations-apps/0026_fulfillment_assignment.sql +44 -0
- package/apps/storefront/migrations-apps/0027_app_contact_authority.sql +70 -0
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +56 -1
- 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/appContactAuthority.ts +27 -0
- package/apps/storefront/src/lib/integrations/apps/appContactConsents.ts +112 -0
- package/apps/storefront/src/lib/integrations/apps/registryService.ts +101 -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/apps/storefront/src/lib/orders/vault/appRecipientRead.ts +217 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -3
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +57 -0
- 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
|
|
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,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.
|
|
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.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
|
|
742
|
-
*
|
|
743
|
-
*
|
|
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
|
|
773
|
-
|
|
774
|
-
`UPDATE app_installs SET status = ?, updated_at = ? WHERE install_id = ?`,
|
|
775
|
-
status,
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
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
|
-
}):
|
|
820
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
|
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 {
|