@openlup/platform 0.17.0 → 0.19.0
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/CHANGELOG.md +143 -0
- package/README.md +299 -6
- package/dist/catalog/activeSkuSelection.d.ts +2 -0
- package/dist/catalog/activeSkuSelection.js +20 -0
- package/dist/catalog/adminBindings.d.ts +72 -0
- package/dist/catalog/adminBindings.js +1 -0
- package/dist/catalog/adminBundleHandler.d.ts +26 -0
- package/dist/catalog/adminBundleHandler.js +111 -0
- package/dist/catalog/adminBundleReadHandler.d.ts +21 -0
- package/dist/catalog/adminBundleReadHandler.js +185 -0
- package/dist/catalog/adminBundleWritePort.d.ts +85 -0
- package/dist/catalog/adminBundleWritePort.js +1 -0
- package/dist/catalog/bundleAvailability.d.ts +63 -0
- package/dist/catalog/bundleAvailability.js +78 -0
- package/dist/catalog/bundleAvailabilityService.d.ts +65 -0
- package/dist/catalog/bundleAvailabilityService.js +89 -0
- package/dist/catalog/bundleBindings.d.ts +33 -0
- package/dist/catalog/bundleBindings.js +1 -0
- package/dist/catalog/bundleCatalogQueries.d.ts +28 -0
- package/dist/catalog/bundleCatalogQueries.js +84 -0
- package/dist/catalog/bundleCatalogReadPort.d.ts +173 -0
- package/dist/catalog/bundleCatalogReadPort.js +36 -0
- package/dist/catalog/bundleCatalogStore.d.ts +5 -0
- package/dist/catalog/bundleCatalogStore.js +156 -0
- package/dist/catalog/bundlePriceSelection.d.ts +13 -0
- package/dist/catalog/bundlePriceSelection.js +26 -0
- package/dist/catalog/bundleRuleCodes.d.ts +41 -0
- package/dist/catalog/bundleRuleCodes.js +110 -0
- package/dist/catalog/bundleRules.d.ts +69 -0
- package/dist/catalog/bundleRules.js +102 -0
- package/dist/catalog/catalogDraftStore.d.ts +4 -0
- package/dist/catalog/catalogDraftStore.js +63 -0
- package/dist/catalog/catalogSkuEnvelope.d.ts +7 -0
- package/dist/catalog/catalogSkuEnvelope.js +142 -0
- package/dist/catalog/catalogSkuEnvelopeMapper.d.ts +38 -0
- package/dist/catalog/catalogSkuEnvelopeMapper.js +88 -0
- package/dist/catalog/contracts/bundleAdmin.d.ts +143 -0
- package/dist/catalog/contracts/bundleAdmin.js +1 -0
- package/dist/catalog/contracts/bundleAvailability.d.ts +61 -0
- package/dist/catalog/contracts/bundleAvailability.js +1 -0
- package/dist/catalog/contracts/bundleRead.d.ts +241 -0
- package/dist/catalog/contracts/bundleRead.js +1 -0
- package/dist/catalog/contracts/draft.d.ts +79 -0
- package/dist/catalog/contracts/draft.js +47 -0
- package/dist/catalog/contracts/foundation.d.ts +78 -0
- package/dist/catalog/contracts/foundation.js +53 -0
- package/dist/catalog/contracts/index.d.ts +14 -0
- package/dist/catalog/contracts/index.js +6 -0
- package/dist/catalog/contracts/lifecycle.d.ts +77 -0
- package/dist/catalog/contracts/lifecycle.js +1 -0
- package/dist/catalog/contracts/productMediaContracts.d.ts +77 -0
- package/dist/catalog/contracts/productMediaContracts.js +149 -0
- package/dist/catalog/contracts/productTypes.d.ts +54 -0
- package/dist/catalog/contracts/productTypes.js +16 -0
- package/dist/catalog/contracts/resolveProductMedia.d.ts +3 -0
- package/dist/catalog/contracts/resolveProductMedia.js +78 -0
- package/dist/catalog/contracts/sellable.d.ts +23 -0
- package/dist/catalog/contracts/sellable.js +1 -0
- package/dist/catalog/draftHandler.d.ts +25 -0
- package/dist/catalog/draftHandler.js +118 -0
- package/dist/catalog/draftPorts.d.ts +18 -0
- package/dist/catalog/draftPorts.js +8 -0
- package/dist/catalog/draftSchemas.d.ts +423 -0
- package/dist/catalog/draftSchemas.js +89 -0
- package/dist/catalog/draftValidation.d.ts +21 -0
- package/dist/catalog/draftValidation.js +125 -0
- package/dist/catalog/effectiveBundleCatalogQueries.d.ts +8 -0
- package/dist/catalog/effectiveBundleCatalogQueries.js +79 -0
- package/dist/catalog/effectiveBundleCatalogStore.d.ts +4 -0
- package/dist/catalog/effectiveBundleCatalogStore.js +156 -0
- package/dist/catalog/foundationSchemas.d.ts +98 -0
- package/dist/catalog/foundationSchemas.js +110 -0
- package/dist/catalog/index.d.ts +44 -0
- package/dist/catalog/index.js +26 -0
- package/dist/catalog/mediaSourcePack.d.ts +51 -0
- package/dist/catalog/mediaSourcePack.js +247 -0
- package/dist/catalog/productTypeSchemas.d.ts +46 -0
- package/dist/catalog/productTypeSchemas.js +72 -0
- package/dist/catalog/publicationPorts.d.ts +42 -0
- package/dist/catalog/publicationPorts.js +1 -0
- package/dist/catalog/readPorts.d.ts +51 -0
- package/dist/catalog/readPorts.js +1 -0
- package/dist/catalog/referenceCatalogHandler.d.ts +7 -0
- package/dist/catalog/referenceCatalogHandler.js +27 -0
- package/dist/catalog/resolvedSkuProjection.d.ts +88 -0
- package/dist/catalog/resolvedSkuProjection.js +60 -0
- package/dist/catalog/sellableBundleHandler.d.ts +46 -0
- package/dist/catalog/sellableBundleHandler.js +84 -0
- package/dist/catalog/sellableSchemas.d.ts +46 -0
- package/dist/catalog/sellableSchemas.js +27 -0
- package/dist/catalog/sql.d.ts +46 -0
- package/dist/catalog/sql.js +13 -0
- package/dist/catalog/validation.d.ts +2 -0
- package/dist/catalog/validation.js +3 -0
- package/dist/communications/contracts/index.d.ts +1 -0
- package/dist/communications/contracts/marketingSync.d.ts +194 -0
- package/dist/communications/contracts/marketingSync.js +1 -0
- package/dist/communications/index.d.ts +1 -0
- package/dist/communications/klaviyo/consentPush.d.ts +51 -0
- package/dist/communications/klaviyo/consentPush.js +19 -0
- package/dist/communications/klaviyo/index.d.ts +110 -0
- package/dist/communications/klaviyo/index.js +860 -0
- package/dist/communications/newsletterIntegrationContracts.d.ts +3 -3
- package/dist/communications/resend/index.d.ts +40 -0
- package/dist/communications/resend/index.js +310 -0
- package/dist/composition/catalogContribution.d.ts +18 -0
- package/dist/composition/catalogContribution.js +21 -0
- package/dist/composition/index.d.ts +3 -0
- package/dist/composition/index.js +3 -0
- package/dist/composition/inventoryContribution.d.ts +16 -0
- package/dist/composition/inventoryContribution.js +17 -0
- package/dist/composition/shippingContribution.d.ts +16 -0
- package/dist/composition/shippingContribution.js +17 -0
- package/dist/identity/customerContracts.d.ts +2 -2
- package/dist/inventory/adminInventory.d.ts +13 -0
- package/dist/inventory/adminInventory.js +214 -0
- package/dist/inventory/bindings.d.ts +35 -0
- package/dist/inventory/bindings.js +45 -0
- package/dist/inventory/contracts/forecast.d.ts +86 -0
- package/dist/inventory/contracts/forecast.js +1 -0
- package/dist/inventory/contracts/index.d.ts +4 -0
- package/dist/inventory/contracts/index.js +1 -0
- package/dist/inventory/contracts/inventory.d.ts +157 -0
- package/dist/inventory/contracts/inventory.js +1 -0
- package/dist/inventory/contracts/newPurchaseStock.d.ts +20 -0
- package/dist/inventory/contracts/newPurchaseStock.js +1 -0
- package/dist/inventory/contracts/reservation.d.ts +18 -0
- package/dist/inventory/contracts/reservation.js +1 -0
- package/dist/inventory/forecast.d.ts +24 -0
- package/dist/inventory/forecast.js +200 -0
- package/dist/inventory/index.d.ts +14 -0
- package/dist/inventory/index.js +11 -0
- package/dist/inventory/inventoryHandlers.d.ts +53 -0
- package/dist/inventory/inventoryHandlers.js +151 -0
- package/dist/inventory/newPurchaseStockAvailability.d.ts +47 -0
- package/dist/inventory/newPurchaseStockAvailability.js +75 -0
- package/dist/inventory/ports.d.ts +21 -0
- package/dist/inventory/ports.js +16 -0
- package/dist/inventory/renewalReservation.d.ts +27 -0
- package/dist/inventory/renewalReservation.js +61 -0
- package/dist/inventory/reservationPolicy.d.ts +6 -0
- package/dist/inventory/reservationPolicy.js +19 -0
- package/dist/inventory/schemas.d.ts +137 -0
- package/dist/inventory/schemas.js +112 -0
- package/dist/inventory/sql.d.ts +14 -0
- package/dist/inventory/sql.js +5 -0
- package/dist/inventory/subscriptionForecastCalculation.d.ts +4 -0
- package/dist/inventory/subscriptionForecastCalculation.js +133 -0
- package/dist/pricing/acquisitionFinancialPolicy.d.ts +26 -0
- package/dist/pricing/acquisitionFinancialPolicy.js +1 -0
- package/dist/pricing/acquisitionOfferPricing.d.ts +27 -0
- package/dist/pricing/acquisitionOfferPricing.js +25 -0
- package/dist/pricing/acquisitionOfferPricingHandler.d.ts +20 -0
- package/dist/pricing/acquisitionOfferPricingHandler.js +105 -0
- package/dist/pricing/acquisitionOfferPricingSchemas.d.ts +31 -0
- package/dist/pricing/acquisitionOfferPricingSchemas.js +33 -0
- package/dist/pricing/acquisitionQuoteBinding.d.ts +12 -0
- package/dist/pricing/acquisitionQuoteBinding.js +12 -0
- package/dist/pricing/acquisitionReadiness.d.ts +31 -0
- package/dist/pricing/acquisitionReadiness.js +117 -0
- package/dist/pricing/acquisitionTerms.d.ts +19 -0
- package/dist/pricing/acquisitionTerms.js +126 -0
- package/dist/pricing/acquisitionTermsSchemas.d.ts +59 -0
- package/dist/pricing/acquisitionTermsSchemas.js +42 -0
- package/dist/pricing/catalogFactsProvenance.d.ts +33 -0
- package/dist/pricing/catalogFactsProvenance.js +64 -0
- package/dist/pricing/catalogFactsSchemas.d.ts +45 -0
- package/dist/pricing/catalogFactsSchemas.js +58 -0
- package/dist/pricing/commerceOfferPricing.d.ts +25 -0
- package/dist/pricing/commerceOfferPricing.js +139 -0
- package/dist/pricing/commerceOfferPricingCache.d.ts +10 -0
- package/dist/pricing/commerceOfferPricingCache.js +44 -0
- package/dist/pricing/commerceOfferPricingHandler.d.ts +21 -0
- package/dist/pricing/commerceOfferPricingHandler.js +78 -0
- package/dist/pricing/commercePriceAuthority.d.ts +28 -0
- package/dist/pricing/commercePriceAuthority.js +140 -0
- package/dist/pricing/commercePricingPolicyErrors.d.ts +40 -0
- package/dist/pricing/commercePricingPolicyErrors.js +67 -0
- package/dist/pricing/commerceQuoteBatchHandler.d.ts +37 -0
- package/dist/pricing/commerceQuoteBatchHandler.js +83 -0
- package/dist/pricing/commerceQuoteCatalogReadPort.d.ts +27 -0
- package/dist/pricing/commerceQuoteCatalogReadPort.js +8 -0
- package/dist/pricing/commerceQuoteHandler.d.ts +35 -0
- package/dist/pricing/commerceQuoteHandler.js +122 -0
- package/dist/pricing/commerceQuoteSnapshotExpect.d.ts +9 -0
- package/dist/pricing/commerceQuoteSnapshotExpect.js +22 -0
- package/dist/pricing/contracts/acquisitionTerms.d.ts +32 -0
- package/dist/pricing/contracts/acquisitionTerms.js +2 -0
- package/dist/pricing/contracts/authority.d.ts +27 -0
- package/dist/pricing/contracts/authority.js +1 -0
- package/dist/pricing/contracts/evidence.d.ts +33 -0
- package/dist/pricing/contracts/evidence.js +1 -0
- package/dist/pricing/contracts/index.d.ts +17 -0
- package/dist/pricing/contracts/index.js +7 -0
- package/dist/pricing/contracts/money.d.ts +27 -0
- package/dist/pricing/contracts/money.js +1 -0
- package/dist/pricing/contracts/offer.d.ts +52 -0
- package/dist/pricing/contracts/offer.js +4 -0
- package/dist/pricing/contracts/offerPolicy.d.ts +16 -0
- package/dist/pricing/contracts/offerPolicy.js +5 -0
- package/dist/pricing/contracts/policy.d.ts +22 -0
- package/dist/pricing/contracts/policy.js +3 -0
- package/dist/pricing/contracts/provenance.d.ts +24 -0
- package/dist/pricing/contracts/provenance.js +2 -0
- package/dist/pricing/contracts/quote.d.ts +94 -0
- package/dist/pricing/contracts/quote.js +1 -0
- package/dist/pricing/dbBackedCommerceQuoteHelpers.d.ts +78 -0
- package/dist/pricing/dbBackedCommerceQuoteHelpers.js +146 -0
- package/dist/pricing/dbBackedCommerceQuotePort.d.ts +74 -0
- package/dist/pricing/dbBackedCommerceQuotePort.js +223 -0
- package/dist/pricing/dbBackedCommerceQuoteSnapshotVerifier.d.ts +4 -0
- package/dist/pricing/dbBackedCommerceQuoteSnapshotVerifier.js +25 -0
- package/dist/pricing/index.d.ts +52 -0
- package/dist/pricing/index.js +36 -0
- package/dist/pricing/offerPolicyReadiness.d.ts +21 -0
- package/dist/pricing/offerPolicyReadiness.js +79 -0
- package/dist/pricing/offerPolicySchemas.d.ts +25 -0
- package/dist/pricing/offerPolicySchemas.js +15 -0
- package/dist/pricing/offerPricingSchemas.d.ts +315 -0
- package/dist/pricing/offerPricingSchemas.js +65 -0
- package/dist/pricing/ports.d.ts +13 -0
- package/dist/pricing/ports.js +9 -0
- package/dist/pricing/pricingPolicyToken.d.ts +39 -0
- package/dist/pricing/pricingPolicyToken.js +84 -0
- package/dist/pricing/quoteAcquisitionTerms.d.ts +2 -0
- package/dist/pricing/quoteAcquisitionTerms.js +4 -0
- package/dist/pricing/quoteCodeRejectionDetails.d.ts +20 -0
- package/dist/pricing/quoteCodeRejectionDetails.js +22 -0
- package/dist/pricing/quotePorts.d.ts +30 -0
- package/dist/pricing/quotePorts.js +20 -0
- package/dist/pricing/quotePrimitives.d.ts +13 -0
- package/dist/pricing/quotePrimitives.js +18 -0
- package/dist/pricing/quotePromotionEvidence.d.ts +39 -0
- package/dist/pricing/quotePromotionEvidence.js +73 -0
- package/dist/pricing/quoteSchemas.d.ts +1176 -0
- package/dist/pricing/quoteSchemas.js +196 -0
- package/dist/pricing/quoteShippingPromotions.d.ts +20 -0
- package/dist/pricing/quoteShippingPromotions.js +1 -0
- package/dist/pricing/subscriptionPricePolicy.d.ts +52 -0
- package/dist/pricing/subscriptionPricePolicy.js +96 -0
- package/dist/pricing/taxProfileGuard.d.ts +2 -0
- package/dist/pricing/taxProfileGuard.js +24 -0
- package/dist/pricing/validation.d.ts +2 -0
- package/dist/pricing/validation.js +3 -0
- package/dist/promotions/adminBindings.d.ts +72 -0
- package/dist/promotions/adminBindings.js +1 -0
- package/dist/promotions/adminPromotionCodesContracts.d.ts +498 -0
- package/dist/promotions/adminPromotionCodesContracts.js +211 -0
- package/dist/promotions/adminPromotionCodesDataPort.d.ts +47 -0
- package/dist/promotions/adminPromotionCodesDataPort.js +1 -0
- package/dist/promotions/adminPromotionCodesHandler.d.ts +14 -0
- package/dist/promotions/adminPromotionCodesHandler.js +194 -0
- package/dist/promotions/adminPromotionsContracts.d.ts +235 -0
- package/dist/promotions/adminPromotionsContracts.js +99 -0
- package/dist/promotions/adminPromotionsDataPort.d.ts +42 -0
- package/dist/promotions/adminPromotionsDataPort.js +1 -0
- package/dist/promotions/adminPromotionsHandler.d.ts +16 -0
- package/dist/promotions/adminPromotionsHandler.js +47 -0
- package/dist/promotions/commerceQuoteReadPreparation.d.ts +3 -0
- package/dist/promotions/commerceQuoteReadPreparation.js +27 -0
- package/dist/promotions/contracts/index.d.ts +387 -0
- package/dist/promotions/contracts/index.js +1 -0
- package/dist/promotions/index.d.ts +25 -0
- package/dist/promotions/index.js +22 -0
- package/dist/promotions/promoDataPort.d.ts +47 -0
- package/dist/promotions/promoDataPort.js +14 -0
- package/dist/promotions/promotionAcceptanceTelemetry.d.ts +29 -0
- package/dist/promotions/promotionAcceptanceTelemetry.js +45 -0
- package/dist/promotions/promotionBenefitDefinition.d.ts +37 -0
- package/dist/promotions/promotionBenefitDefinition.js +33 -0
- package/dist/promotions/promotionClaimSweepPort.d.ts +14 -0
- package/dist/promotions/promotionClaimSweepPort.js +8 -0
- package/dist/promotions/promotionClaims.d.ts +5 -0
- package/dist/promotions/promotionClaims.js +57 -0
- package/dist/promotions/promotionCodePreview.d.ts +11 -0
- package/dist/promotions/promotionCodePreview.js +154 -0
- package/dist/promotions/promotionCodeQuotePort.d.ts +25 -0
- package/dist/promotions/promotionCodeQuotePort.js +1 -0
- package/dist/promotions/promotionQuoteAcceptance.d.ts +70 -0
- package/dist/promotions/promotionQuoteAcceptance.js +202 -0
- package/dist/promotions/promotionQuoteBinding.d.ts +73 -0
- package/dist/promotions/promotionQuoteBinding.js +87 -0
- package/dist/promotions/quoteAcquisitionBinding.d.ts +6 -0
- package/dist/promotions/quoteAcquisitionBinding.js +1 -0
- package/dist/promotions/quoteAutomaticPromotions.d.ts +26 -0
- package/dist/promotions/quoteAutomaticPromotions.js +60 -0
- package/dist/promotions/quotePromoDiscounts.d.ts +36 -0
- package/dist/promotions/quotePromoDiscounts.js +146 -0
- package/dist/promotions/quotePromotionCodeAdjustments.d.ts +27 -0
- package/dist/promotions/quotePromotionCodeAdjustments.js +223 -0
- package/dist/promotions/quotePromotionV2.d.ts +30 -0
- package/dist/promotions/quotePromotionV2.js +186 -0
- package/dist/promotions/quoteShippingPolicy.d.ts +23 -0
- package/dist/promotions/quoteShippingPolicy.js +47 -0
- package/dist/promotions/sql.d.ts +14 -0
- package/dist/promotions/sql.js +5 -0
- package/dist/promotions/validation.d.ts +2 -0
- package/dist/promotions/validation.js +3 -0
- package/dist/shipping/addressCanonLookupHandler.d.ts +7 -0
- package/dist/shipping/addressCanonLookupHandler.js +105 -0
- package/dist/shipping/addressCanonical.d.ts +2 -0
- package/dist/shipping/addressCanonical.js +26 -0
- package/dist/shipping/addressPorts.d.ts +39 -0
- package/dist/shipping/addressPorts.js +1 -0
- package/dist/shipping/addressSchemas.d.ts +30 -0
- package/dist/shipping/addressSchemas.js +1 -0
- package/dist/shipping/adminShippingRateHandler.d.ts +4 -0
- package/dist/shipping/adminShippingRateHandler.js +64 -0
- package/dist/shipping/bindings.d.ts +57 -0
- package/dist/shipping/bindings.js +46 -0
- package/dist/shipping/contracts/address.d.ts +144 -0
- package/dist/shipping/contracts/address.js +1 -0
- package/dist/shipping/contracts/delivery.d.ts +110 -0
- package/dist/shipping/contracts/delivery.js +1 -0
- package/dist/shipping/contracts/index.d.ts +4 -0
- package/dist/shipping/contracts/index.js +1 -0
- package/dist/shipping/contracts/shippingRate.d.ts +11 -0
- package/dist/shipping/contracts/shippingRate.js +1 -0
- package/dist/shipping/contracts/threshold.d.ts +22 -0
- package/dist/shipping/contracts/threshold.js +1 -0
- package/dist/shipping/customerAddressesHandler.d.ts +5 -0
- package/dist/shipping/customerAddressesHandler.js +80 -0
- package/dist/shipping/customerSelfServiceAddressModels.d.ts +27 -0
- package/dist/shipping/customerSelfServiceAddressModels.js +101 -0
- package/dist/shipping/deliverySelectionHandler.d.ts +10 -0
- package/dist/shipping/deliverySelectionHandler.js +77 -0
- package/dist/shipping/deliverySelectionPort.d.ts +8 -0
- package/dist/shipping/deliverySelectionPort.js +1 -0
- package/dist/shipping/deliverySelectionSummary.d.ts +2 -0
- package/dist/shipping/deliverySelectionSummary.js +53 -0
- package/dist/shipping/handlerSchemas.d.ts +21 -0
- package/dist/shipping/handlerSchemas.js +1 -0
- package/dist/shipping/index.d.ts +20 -0
- package/dist/shipping/index.js +10 -0
- package/dist/shipping/pickupPointSearchPort.d.ts +4 -0
- package/dist/shipping/pickupPointSearchPort.js +1 -0
- package/dist/shipping/readDeliverySelectionEvidence.d.ts +2 -0
- package/dist/shipping/readDeliverySelectionEvidence.js +44 -0
- package/dist/shipping/shippingRatePorts.d.ts +52 -0
- package/dist/shipping/shippingRatePorts.js +1 -0
- package/dist/shipping/thresholdPolicy.d.ts +42 -0
- package/dist/shipping/thresholdPolicy.js +60 -0
- package/package.json +62 -2
- package/release-gates.json +263 -53
- package/sql/20260807140000_read_parity_catalog.sql +94 -0
- package/sql/20260810120000_storefront_catalog_attributes.sql +51 -0
- package/sql/20260812140000_sellable_bundle_catalog.sql +124 -0
- package/sql/20260812210000_bundle_operator_write_boundary.sql +567 -0
- package/sql/20260814000000_fulfillment_stock_evidence.sql +239 -0
- package/sql/20260815120000_automatic_subscription_renewal.sql +1083 -0
- package/sql/20260817180000_promotion_claim_lifecycle.sql +591 -0
- package/sql/20260817180100_promotion_claim_lifecycle_validation.sql +23 -0
- package/sql/20260827220000_catalog_document_revision_foundation.sql +226 -0
- package/sql/20260827220001_catalog_document_revision_foundation_validate.sql +10 -0
- package/sql/20260908100000_catalog_draft_foundation.sql +217 -0
- package/src/catalog/activeSkuSelection.ts +20 -0
- package/src/catalog/adminBindings.ts +59 -0
- package/src/catalog/adminBundleHandler.ts +165 -0
- package/src/catalog/adminBundleReadHandler.ts +263 -0
- package/src/catalog/adminBundleWritePort.ts +102 -0
- package/src/catalog/bundleAvailability.ts +129 -0
- package/src/catalog/bundleAvailabilityService.ts +171 -0
- package/src/catalog/bundleBindings.ts +29 -0
- package/src/catalog/bundleCatalogQueries.ts +98 -0
- package/src/catalog/bundleCatalogReadPort.ts +181 -0
- package/src/catalog/bundleCatalogStore.ts +274 -0
- package/src/catalog/bundlePriceSelection.ts +25 -0
- package/src/catalog/bundleRuleCodes.ts +134 -0
- package/src/catalog/bundleRules.ts +153 -0
- package/src/catalog/catalogDraftStore.ts +71 -0
- package/src/catalog/catalogSkuEnvelope.ts +214 -0
- package/src/catalog/catalogSkuEnvelopeMapper.ts +160 -0
- package/src/catalog/contracts/bundleAdmin.ts +20 -0
- package/src/catalog/contracts/bundleAvailability.ts +5 -0
- package/src/catalog/contracts/bundleRead.ts +13 -0
- package/src/catalog/contracts/draft.ts +69 -0
- package/src/catalog/contracts/foundation.ts +104 -0
- package/src/catalog/contracts/index.ts +14 -0
- package/src/catalog/contracts/lifecycle.ts +74 -0
- package/src/catalog/contracts/productMediaContracts.ts +170 -0
- package/src/catalog/contracts/productTypes.ts +35 -0
- package/src/catalog/contracts/resolveProductMedia.ts +85 -0
- package/src/catalog/contracts/sellable.ts +12 -0
- package/src/catalog/draftHandler.ts +105 -0
- package/src/catalog/draftPorts.ts +18 -0
- package/src/catalog/draftSchemas.ts +88 -0
- package/src/catalog/draftValidation.ts +100 -0
- package/src/catalog/effectiveBundleCatalogQueries.ts +80 -0
- package/src/catalog/effectiveBundleCatalogStore.ts +208 -0
- package/src/catalog/foundationSchemas.ts +123 -0
- package/src/catalog/index.ts +44 -0
- package/src/catalog/mediaSourcePack.ts +219 -0
- package/src/catalog/productTypeSchemas.ts +70 -0
- package/src/catalog/publicationPorts.ts +44 -0
- package/src/catalog/readPorts.ts +61 -0
- package/src/catalog/referenceCatalogHandler.ts +39 -0
- package/src/catalog/resolvedSkuProjection.ts +76 -0
- package/src/catalog/sellableBundleHandler.ts +133 -0
- package/src/catalog/sellableSchemas.ts +33 -0
- package/src/catalog/sql.ts +14 -0
- package/src/catalog/validation.ts +3 -0
- package/src/communications/contracts/index.ts +1 -0
- package/src/communications/contracts/marketingSync.ts +109 -0
- package/src/communications/index.ts +1 -0
- package/src/communications/klaviyo/consentPush.ts +19 -0
- package/src/communications/klaviyo/index.ts +649 -0
- package/src/communications/resend/index.ts +350 -0
- package/src/composition/catalogContribution.ts +32 -0
- package/src/composition/index.ts +3 -0
- package/src/composition/inventoryContribution.ts +24 -0
- package/src/composition/shippingContribution.ts +24 -0
- package/src/inventory/adminInventory.ts +251 -0
- package/src/inventory/bindings.ts +61 -0
- package/src/inventory/contracts/forecast.ts +96 -0
- package/src/inventory/contracts/index.ts +18 -0
- package/src/inventory/contracts/inventory.ts +14 -0
- package/src/inventory/contracts/newPurchaseStock.ts +23 -0
- package/src/inventory/contracts/reservation.ts +20 -0
- package/src/inventory/forecast.ts +239 -0
- package/src/inventory/index.ts +22 -0
- package/src/inventory/inventoryHandlers.ts +215 -0
- package/src/inventory/newPurchaseStockAvailability.ts +120 -0
- package/src/inventory/ports.ts +46 -0
- package/src/inventory/renewalReservation.ts +88 -0
- package/src/inventory/reservationPolicy.ts +27 -0
- package/src/inventory/schemas.ts +128 -0
- package/src/inventory/sql.ts +5 -0
- package/src/inventory/subscriptionForecastCalculation.ts +157 -0
- package/src/pricing/acquisitionFinancialPolicy.ts +26 -0
- package/src/pricing/acquisitionOfferPricing.ts +38 -0
- package/src/pricing/acquisitionOfferPricingHandler.ts +157 -0
- package/src/pricing/acquisitionOfferPricingSchemas.ts +35 -0
- package/src/pricing/acquisitionQuoteBinding.ts +12 -0
- package/src/pricing/acquisitionReadiness.ts +148 -0
- package/src/pricing/acquisitionTerms.ts +125 -0
- package/src/pricing/acquisitionTermsSchemas.ts +52 -0
- package/src/pricing/catalogFactsProvenance.ts +102 -0
- package/src/pricing/catalogFactsSchemas.ts +69 -0
- package/src/pricing/commerceOfferPricing.ts +224 -0
- package/src/pricing/commerceOfferPricingCache.ts +53 -0
- package/src/pricing/commerceOfferPricingHandler.ts +137 -0
- package/src/pricing/commercePriceAuthority.ts +169 -0
- package/src/pricing/commercePricingPolicyErrors.ts +88 -0
- package/src/pricing/commerceQuoteBatchHandler.ts +129 -0
- package/src/pricing/commerceQuoteCatalogReadPort.ts +42 -0
- package/src/pricing/commerceQuoteHandler.ts +199 -0
- package/src/pricing/commerceQuoteSnapshotExpect.ts +48 -0
- package/src/pricing/contracts/acquisitionTerms.ts +26 -0
- package/src/pricing/contracts/authority.ts +44 -0
- package/src/pricing/contracts/evidence.ts +25 -0
- package/src/pricing/contracts/index.ts +17 -0
- package/src/pricing/contracts/money.ts +32 -0
- package/src/pricing/contracts/offer.ts +42 -0
- package/src/pricing/contracts/offerPolicy.ts +17 -0
- package/src/pricing/contracts/policy.ts +26 -0
- package/src/pricing/contracts/provenance.ts +19 -0
- package/src/pricing/contracts/quote.ts +106 -0
- package/src/pricing/dbBackedCommerceQuoteHelpers.ts +256 -0
- package/src/pricing/dbBackedCommerceQuotePort.ts +280 -0
- package/src/pricing/dbBackedCommerceQuoteSnapshotVerifier.ts +49 -0
- package/src/pricing/index.ts +53 -0
- package/src/pricing/offerPolicyReadiness.ts +110 -0
- package/src/pricing/offerPolicySchemas.ts +23 -0
- package/src/pricing/offerPricingSchemas.ts +92 -0
- package/src/pricing/ports.ts +17 -0
- package/src/pricing/pricingPolicyToken.ts +119 -0
- package/src/pricing/quoteAcquisitionTerms.ts +10 -0
- package/src/pricing/quoteCodeRejectionDetails.ts +23 -0
- package/src/pricing/quotePorts.ts +58 -0
- package/src/pricing/quotePrimitives.ts +21 -0
- package/src/pricing/quotePromotionEvidence.ts +82 -0
- package/src/pricing/quoteSchemas.ts +230 -0
- package/src/pricing/quoteShippingPromotions.ts +21 -0
- package/src/pricing/subscriptionPricePolicy.ts +113 -0
- package/src/pricing/taxProfileGuard.ts +33 -0
- package/src/pricing/validation.ts +3 -0
- package/src/promotions/adminBindings.ts +53 -0
- package/src/promotions/adminPromotionCodesContracts.ts +244 -0
- package/src/promotions/adminPromotionCodesDataPort.ts +56 -0
- package/src/promotions/adminPromotionCodesHandler.ts +254 -0
- package/src/promotions/adminPromotionsContracts.ts +121 -0
- package/src/promotions/adminPromotionsDataPort.ts +48 -0
- package/src/promotions/adminPromotionsHandler.ts +72 -0
- package/src/promotions/commerceQuoteReadPreparation.ts +29 -0
- package/src/promotions/contracts/index.ts +387 -0
- package/src/promotions/index.ts +25 -0
- package/src/promotions/promoDataPort.ts +65 -0
- package/src/promotions/promotionAcceptanceTelemetry.ts +73 -0
- package/src/promotions/promotionBenefitDefinition.ts +48 -0
- package/src/promotions/promotionClaimSweepPort.ts +23 -0
- package/src/promotions/promotionClaims.ts +70 -0
- package/src/promotions/promotionCodePreview.ts +183 -0
- package/src/promotions/promotionCodeQuotePort.ts +35 -0
- package/src/promotions/promotionQuoteAcceptance.ts +291 -0
- package/src/promotions/promotionQuoteBinding.ts +163 -0
- package/src/promotions/quoteAcquisitionBinding.ts +7 -0
- package/src/promotions/quoteAutomaticPromotions.ts +84 -0
- package/src/promotions/quotePromoDiscounts.ts +194 -0
- package/src/promotions/quotePromotionCodeAdjustments.ts +277 -0
- package/src/promotions/quotePromotionV2.ts +239 -0
- package/src/promotions/quoteShippingPolicy.ts +61 -0
- package/src/promotions/sql.ts +5 -0
- package/src/promotions/validation.ts +3 -0
- package/src/shipping/addressCanonLookupHandler.ts +114 -0
- package/src/shipping/addressCanonical.ts +31 -0
- package/src/shipping/addressPorts.ts +48 -0
- package/src/shipping/addressSchemas.ts +28 -0
- package/src/shipping/adminShippingRateHandler.ts +72 -0
- package/src/shipping/bindings.ts +74 -0
- package/src/shipping/contracts/address.ts +71 -0
- package/src/shipping/contracts/delivery.ts +114 -0
- package/src/shipping/contracts/index.ts +19 -0
- package/src/shipping/contracts/shippingRate.ts +7 -0
- package/src/shipping/contracts/threshold.ts +22 -0
- package/src/shipping/customerAddressesHandler.ts +100 -0
- package/src/shipping/customerSelfServiceAddressModels.ts +124 -0
- package/src/shipping/deliverySelectionHandler.ts +94 -0
- package/src/shipping/deliverySelectionPort.ts +10 -0
- package/src/shipping/deliverySelectionSummary.ts +56 -0
- package/src/shipping/handlerSchemas.ts +19 -0
- package/src/shipping/index.ts +26 -0
- package/src/shipping/pickupPointSearchPort.ts +8 -0
- package/src/shipping/readDeliverySelectionEvidence.ts +51 -0
- package/src/shipping/shippingRatePorts.ts +36 -0
- package/src/shipping/thresholdPolicy.ts +65 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import type { ConsentPushPort, ConsentPushRequest, ConsentPushResult, ConsentReadback, MarketingSyncProvider, NegativeFeedback, ProfileProjection, SyncAudience, SyncBinding, SyncRequest, SyncResult } from "../contracts/marketingSync.js";
|
|
2
|
+
import type { MarketingAutomationClaim, MarketingAutomationObservation, MarketingAutomationProviderPort, MarketingAutomationScope } from "../contracts/marketingAutomation.js";
|
|
3
|
+
import type { NewsletterSyncProviderPort } from "../contracts/newsletter.js";
|
|
4
|
+
import { consentCommand } from "./consentPush.js";
|
|
5
|
+
|
|
6
|
+
// Retain the cause when a grant admission CAS throws before any journal bytes
|
|
7
|
+
// survive. Shared host availability keeps it across adapter reconstruction;
|
|
8
|
+
// a fresh host process still starts quarantined for explicit reconciliation.
|
|
9
|
+
type QuarantineCause = { kind: "grants"; grants: Map<string, string> } | { kind: "non-grant" };
|
|
10
|
+
const quarantineCauses = new WeakMap<object, QuarantineCause>();
|
|
11
|
+
const providerCooling = new WeakMap<object, number>();
|
|
12
|
+
const REVISION = "2026-07-15";
|
|
13
|
+
const ORIGIN = "https://a.klaviyo.com";
|
|
14
|
+
type Request = SyncRequest | ConsentPushRequest;
|
|
15
|
+
type Result = SyncResult | ConsentPushResult;
|
|
16
|
+
type Operation = Request["operation"];
|
|
17
|
+
type Failure = Extract<Result, { kind: "refused" | "retryable" | "unknown" }>;
|
|
18
|
+
type Reason = Extract<Failure, { kind: "refused" }>["reason"];
|
|
19
|
+
type Subject = {
|
|
20
|
+
binding: SyncBinding; subjectReference: string; generation: number; erased: boolean;
|
|
21
|
+
id?: string; email: string; externalReference: string;
|
|
22
|
+
ownedFields: readonly (keyof ProfileProjection["fields"])[]; ownedProperties: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
type LocalEvidence = {
|
|
25
|
+
binding: SyncBinding; subjectReference: string; audience: SyncAudience; purpose: string;
|
|
26
|
+
action: "profile" | "grant" | "withdraw" | "erase"; current: boolean;
|
|
27
|
+
projection?: ProfileProjection; caseReference?: string; authorityReference?: string; allPurposes?: boolean;
|
|
28
|
+
};
|
|
29
|
+
type Authorization = { binding: SyncBinding; subjectReference: string; audience: SyncAudience;
|
|
30
|
+
operation: Operation; expiresAtMs: number };
|
|
31
|
+
type Verification = {
|
|
32
|
+
binding: SyncBinding; audience: SyncAudience; evidenceReference: string; revision: string;
|
|
33
|
+
operations: readonly Exclude<Operation, "erasure.read">[]; scopes: readonly string[];
|
|
34
|
+
noTrigger: { profile: boolean; accountWideGrant: boolean; doi: boolean };
|
|
35
|
+
safeCreate: boolean; safeListRemoval: boolean; suppressionSafeAtEffect: boolean;
|
|
36
|
+
negativeSourcesComplete: boolean; listMappings: Readonly<Record<string, SyncAudience>>;
|
|
37
|
+
};
|
|
38
|
+
type Entry = { request: Request; semantic: string; target: Subject; result?: Result;
|
|
39
|
+
phase: "admitted" | "dispatched" | "settled"; remoteId?: string; grantSettled?: boolean };
|
|
40
|
+
type Ledger = { version: 1; coolingUntil: number; nextAt: number; slot: boolean; quarantined: boolean;
|
|
41
|
+
entries: Record<string, Entry>; mappings: Record<string, string>; tombstones: string[];
|
|
42
|
+
withdrawals: Record<string, { owner: string; audience: SyncAudience; earlier: string[]; resolved: boolean }> };
|
|
43
|
+
type Storage = {
|
|
44
|
+
/** Shared plain mutable record for this account/environment, independent of CAS failure.
|
|
45
|
+
* Start quarantined after host restart; only explicit host reconciliation clears it.
|
|
46
|
+
* Every adapter namespace/instance uses the same record. Never reset it at construction. */
|
|
47
|
+
availability: { account: string; environment: string; busy: boolean; quarantined: boolean };
|
|
48
|
+
/** Shared across every adapter for the account. false proves no commit; throws may have committed. */
|
|
49
|
+
get(key: string): Promise<string | null>;
|
|
50
|
+
compareAndSwap(key: string, expected: string | null, value: string): Promise<boolean>;
|
|
51
|
+
};
|
|
52
|
+
type Configuration = {
|
|
53
|
+
binding: SyncBinding; audience: SyncAudience;
|
|
54
|
+
/** Synchronous current host registry lookup; never parses an opaque subject reference. */
|
|
55
|
+
subjectMapper(subjectReference: string): Subject | undefined;
|
|
56
|
+
resolveCredential(): { binding: SyncBinding; key: string } | undefined;
|
|
57
|
+
authorizations: ReadonlyMap<string, Authorization>; localEvidence: ReadonlyMap<string, LocalEvidence>;
|
|
58
|
+
storage: Storage; verification?: Verification;
|
|
59
|
+
/** Private authenticated settlement facts from the host's existing reconciliation journal. */
|
|
60
|
+
grantSettlements?: ReadonlyMap<string, { binding: SyncBinding; subjectReference: string; requestId: string;
|
|
61
|
+
source: "provider-read"; evidenceReference: string; settledAtMs: number }>;
|
|
62
|
+
fetch?: typeof fetch; now?: () => number;
|
|
63
|
+
maxRequestBytes?: number; maxResponseBytes?: number; maxFeedbackItems?: number;
|
|
64
|
+
timeoutMs?: number; rateIntervalMs?: number;
|
|
65
|
+
legacy?: { scope: MarketingAutomationScope; claims: ReadonlyMap<string, { claim: MarketingAutomationClaim; request: Request }> };
|
|
66
|
+
};
|
|
67
|
+
const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
68
|
+
const text = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0 && value.length <= 512 && ![...value].some(c => c.charCodeAt(0) < 32);
|
|
69
|
+
const id = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,200}$/.test(value);
|
|
70
|
+
const positive = (value: number) => Number.isSafeInteger(value) && value > 0;
|
|
71
|
+
const bytes = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
72
|
+
function canonical(value: unknown): string {
|
|
73
|
+
if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
|
|
74
|
+
if (value && typeof value === "object") return "{" + Object.keys(value).filter(k => (value as Record<string, unknown>)[k] !== undefined).sort().map(k => JSON.stringify(k) + ":" + canonical((value as Record<string, unknown>)[k])).join(",") + "}";
|
|
75
|
+
return JSON.stringify(value);
|
|
76
|
+
}
|
|
77
|
+
const equal = (a: unknown, b: unknown) => canonical(a) === canonical(b);
|
|
78
|
+
function freeze<T>(value: T): T {
|
|
79
|
+
if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
const copy = <T>(value: T): T => freeze(structuredClone(value));
|
|
83
|
+
const exact = (value: unknown, keys: string[]) => Object.keys(object(value)).sort().join() === [...keys].sort().join();
|
|
84
|
+
const validBinding = (b: unknown) => exact(b, ["account", "environment", "namespace", "mappingVersion"]) && Object.values(object(b)).every(text);
|
|
85
|
+
const validAudience = (a: unknown) => object(a).kind === "account-wide" ? exact(a, ["kind"]) : object(a).kind === "audience" && exact(a, ["kind", "reference"]) && text(object(a).reference);
|
|
86
|
+
const overlaps = (a: SyncAudience, b: SyncAudience) => a.kind === "account-wide" || b.kind === "account-wide" || equal(a, b);
|
|
87
|
+
const keyOf = (r: Request) => canonical([r.binding, r.operation, r.subjectReference, r.requestId]);
|
|
88
|
+
const subjectKey = (r: Request) => canonical([r.binding, r.subjectReference]);
|
|
89
|
+
const semantic = (r: Request) => canonical([r.audience, r.payload]);
|
|
90
|
+
const echo = (r: Pick<Request, "binding" | "operation" | "subjectReference" | "requestId" | "generation">) => ({ binding: r.binding, operation: r.operation, subjectReference: r.subjectReference, requestId: r.requestId, generation: r.generation });
|
|
91
|
+
function result(r: Pick<Request, "binding" | "operation" | "subjectReference" | "requestId" | "generation">, value: object): Result { return copy({ ...value, ...echo(r) }) as Result; }
|
|
92
|
+
const refused = (r: Request, reason: Reason) => result(r, { kind: "refused", reason });
|
|
93
|
+
const retry = (r: Request, reason: "cooling" | "host" | "provider", afterSeconds = 1) => result(r, { kind: "retryable", reason, afterSeconds });
|
|
94
|
+
const unknown = (r: Request, reason: "dispatch_unresolved" | "acceptance_ambiguous" | "record_failed", receipt?: unknown) => result(r, { kind: "unknown", reason, ...(receipt ? { receipt } : {}) });
|
|
95
|
+
function validRequest(r: Request, max: number): boolean {
|
|
96
|
+
if (!exact(r, ["binding", "audience", "subjectReference", "requestId", "generation", "deadlineMs", "authorizationReference", "operation", "payload"]) ||
|
|
97
|
+
!validBinding(r.binding) || !validAudience(r.audience) || !text(r.subjectReference) || !text(r.requestId) || !text(r.authorizationReference) ||
|
|
98
|
+
!Number.isSafeInteger(r.generation) || r.generation < 0 || !Number.isFinite(r.deadlineMs) || bytes(r) > max) return false;
|
|
99
|
+
const p = r.payload;
|
|
100
|
+
switch (r.operation) {
|
|
101
|
+
case "profile.sync": {
|
|
102
|
+
const v = p as SyncRequest<"profile.sync">["payload"];
|
|
103
|
+
if (!exact(p, ["projection", "consentEvidenceReference"]) || !text(v.consentEvidenceReference) || !exact(v.projection, ["email", "externalReference", "fields", "properties"])) return false;
|
|
104
|
+
const q = v.projection;
|
|
105
|
+
return text(q.email) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q.email) && text(q.externalReference) && q.fields !== null && !Array.isArray(q.fields) && typeof q.fields === "object" &&
|
|
106
|
+
Object.entries(q.fields).every(([k, value]) => ["givenName", "familyName", "locale"].includes(k) && text(value)) &&
|
|
107
|
+
q.properties !== null && typeof q.properties === "object" && !Array.isArray(q.properties) && Object.entries(q.properties).every(([k, value]) => text(k) &&
|
|
108
|
+
(typeof value === "string" && value.length <= 512 || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)));
|
|
109
|
+
}
|
|
110
|
+
case "consent.read": return exact(p, []);
|
|
111
|
+
case "erasure.request": return exact(p, ["caseReference", "authorityReference"]) && Object.values(p).every(text);
|
|
112
|
+
case "erasure.read": return exact(p, ["receipt"]) && object(object(p).receipt).operation === "erasure.request";
|
|
113
|
+
case "consent.push": return exact(p, ["action", "consentEvidenceReference"]) && text(object(p).consentEvidenceReference) &&
|
|
114
|
+
(r.payload.action === "grant" && r.audience.kind === "audience" || r.payload.action === "withdraw");
|
|
115
|
+
default: return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function validEntry(entry: Entry): boolean {
|
|
119
|
+
if (!validRequest(entry.request, Number.MAX_SAFE_INTEGER) || entry.semantic !== semantic(entry.request) ||
|
|
120
|
+
!["admitted", "dispatched", "settled"].includes(entry.phase) || !equal(entry.target.binding, entry.request.binding) ||
|
|
121
|
+
entry.target.subjectReference !== entry.request.subjectReference) return false;
|
|
122
|
+
if (!entry.result) return entry.phase !== "settled";
|
|
123
|
+
const value = entry.result;
|
|
124
|
+
if (!equal(echo(value), echo(entry.request))) return false;
|
|
125
|
+
const receipt = "receipt" in value ? value.receipt : undefined;
|
|
126
|
+
if (receipt && (!["profile.sync", "erasure.request", "consent.push"].includes(entry.request.operation) || !equal(receipt.request, entry.request) || receipt.operation !== entry.request.operation)) return false;
|
|
127
|
+
switch (value.kind) {
|
|
128
|
+
case "refused": return ["invalid_request", "binding", "provenance", "stale", "deadline", "unsupported", "verification_pending", "restricted", "identity_conflict", "provider_refusal"].includes(value.reason);
|
|
129
|
+
case "retryable": return ["cooling", "host", "provider"].includes(value.reason) && Number.isFinite(value.afterSeconds) && value.afterSeconds > 0;
|
|
130
|
+
case "unknown": return ["dispatch_unresolved", "acceptance_ambiguous", "record_failed"].includes(value.reason);
|
|
131
|
+
case "accepted": case "acknowledged": return !!receipt && ["profile.sync", "erasure.request", "consent.push"].includes(entry.request.operation) &&
|
|
132
|
+
["confirmed", "unknown"].includes(value.journal) && (entry.request.operation !== "consent.push" || "reconcileRequired" in value && typeof value.reconcileRequired === "boolean");
|
|
133
|
+
case "completed": {
|
|
134
|
+
if (value.operation !== "consent.read" || !["confirmed", "unknown"].includes(value.journal)) return false;
|
|
135
|
+
const v = value.value;
|
|
136
|
+
return equal(v.audience, entry.request.audience) && Number.isFinite(v.observedAtMs) && ["complete", "partial", "unsupported"].includes(v.coverage) &&
|
|
137
|
+
["subscribed", "unsubscribed", "pending", "never_subscribed", "unknown"].includes(v.remoteConsent) && Array.isArray(v.feedback) && v.feedback.every(f =>
|
|
138
|
+
validAudience(f.audience) && text(f.sourceReference) && (f.occurredAtMs === null || Number.isFinite(f.occurredAtMs)) &&
|
|
139
|
+
(["hard_bounce", "invalid_address"].includes(f.kind) ? f.outcome === "deny_delivery" : ["unsubscribe", "complaint", "manual_block"].includes(f.kind) && f.outcome === "deny_permission"));
|
|
140
|
+
}
|
|
141
|
+
default: return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function emptyLedger(): Ledger { return { version: 1, coolingUntil: 0, nextAt: 0, slot: false, quarantined: false, entries: {}, mappings: {}, tombstones: [], withdrawals: {} }; }
|
|
145
|
+
function parseLedger(raw: string | null): Ledger {
|
|
146
|
+
if (raw === null) return emptyLedger();
|
|
147
|
+
const v = JSON.parse(raw) as Ledger;
|
|
148
|
+
if (v.version !== 1 || !Number.isFinite(v.coolingUntil) || !Number.isFinite(v.nextAt) || typeof v.quarantined !== "boolean" ||
|
|
149
|
+
!v.entries || Array.isArray(v.entries) || !v.mappings || !Array.isArray(v.tombstones) || !v.withdrawals || typeof v.slot !== "boolean" || !Object.values(v.entries).every(validEntry)) throw new Error("journal");
|
|
150
|
+
return v;
|
|
151
|
+
}
|
|
152
|
+
function profileAttributes(p: ProfileProjection, creating: boolean) {
|
|
153
|
+
const names = { givenName: "first_name", familyName: "last_name", locale: "locale" };
|
|
154
|
+
return { ...(creating ? { email: p.email, external_id: p.externalReference } : {}),
|
|
155
|
+
...Object.fromEntries(Object.entries(p.fields).map(([k, v]) => [names[k as keyof typeof names], v])), properties: p.properties };
|
|
156
|
+
}
|
|
157
|
+
function profileData(body: unknown, target: Subject, projection?: ProfileProjection): string | null {
|
|
158
|
+
const d = object(object(body).data), a = object(d.attributes);
|
|
159
|
+
if (d.type !== "profile" || !id(d.id) || (target.id && d.id !== target.id) || a.email !== target.email || a.external_id !== target.externalReference) return null;
|
|
160
|
+
if (projection) {
|
|
161
|
+
const wanted = profileAttributes(projection, false);
|
|
162
|
+
for (const [k, v] of Object.entries(wanted)) {
|
|
163
|
+
if (k === "properties") { for (const [name, value] of Object.entries(object(v))) if (!equal(object(a.properties)[name], value)) return null; }
|
|
164
|
+
else if (!equal(a[k], v)) return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return d.id;
|
|
168
|
+
}
|
|
169
|
+
function consentValue(body: unknown, target: Subject, audience: SyncAudience, verification: Verification, now: number, max: number): ConsentReadback | null {
|
|
170
|
+
if (!profileData(body, target)) return null;
|
|
171
|
+
const m = object(object(object(object(object(object(body).data).attributes).subscriptions).email).marketing);
|
|
172
|
+
const states: Record<string, ConsentReadback["remoteConsent"]> = { SUBSCRIBED: "subscribed", UNSUBSCRIBED: "unsubscribed", NEVER_SUBSCRIBED: "never_subscribed", PENDING: "pending" };
|
|
173
|
+
let complete = verification.negativeSourcesComplete && Array.isArray(m.suppression) && Array.isArray(m.list_suppressions) && typeof m.can_receive_email_marketing === "boolean" && typeof m.consent === "string" && m.consent in states;
|
|
174
|
+
const feedback: NegativeFeedback[] = [];
|
|
175
|
+
const kinds = { UNSUBSCRIBE: "unsubscribe", SPAM_REPORT: "complaint", SPAM_COMPLAINT: "complaint", USER_SUPPRESSED: "manual_block", HARD_BOUNCE: "hard_bounce", INVALID_EMAIL: "invalid_address" } as const;
|
|
176
|
+
function add(reason: keyof typeof kinds, timestamp: unknown, scope: SyncAudience, source: string) {
|
|
177
|
+
if (feedback.length >= max) { complete = false; return; }
|
|
178
|
+
const kind = kinds[reason];
|
|
179
|
+
feedback.push(copy({ kind, outcome: kind === "hard_bounce" || kind === "invalid_address" ? "deny_delivery" : "deny_permission",
|
|
180
|
+
sourceReference: source, occurredAtMs: typeof timestamp === "string" && Number.isFinite(Date.parse(timestamp)) ? Date.parse(timestamp) : null, audience: scope }) as NegativeFeedback);
|
|
181
|
+
}
|
|
182
|
+
if (m.consent === "UNSUBSCRIBED") add("UNSUBSCRIBE", m.consent_timestamp, { kind: "account-wide" }, `negative:${target.id}:consent`);
|
|
183
|
+
for (const [source, values] of [["global", m.suppression], ["list", m.list_suppressions]] as const) {
|
|
184
|
+
if (!Array.isArray(values)) continue;
|
|
185
|
+
// Current-state arrays are bounded by the response rail; no history, metrics discovery or pagination.
|
|
186
|
+
for (const [n, value] of values.entries()) {
|
|
187
|
+
const item = object(value);
|
|
188
|
+
if (typeof item.reason !== "string" || !Object.hasOwn(kinds, item.reason)) { complete = false; continue; }
|
|
189
|
+
const scope = source === "global" ? { kind: "account-wide" } as const : typeof item.list_id === "string" ? verification.listMappings[item.list_id] : undefined;
|
|
190
|
+
if (!scope || !validAudience(scope)) { complete = false; continue; }
|
|
191
|
+
add(item.reason as keyof typeof kinds, item.timestamp, scope, `negative:${target.id}:${source}:${n}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (m.can_receive_email_marketing === false && feedback.length === 0) complete = false;
|
|
195
|
+
return copy({ audience, observedAtMs: now, remoteConsent: audience.kind === "account-wide" ? states[String(m.consent)] ?? "unknown" : "unknown",
|
|
196
|
+
feedback, coverage: complete ? "complete" : "partial" });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Opt-in Node adapter; owner TEST evidence is required before any bound operation is enabled. */
|
|
200
|
+
export function createKlaviyoMarketingAdapter(input: Configuration): MarketingSyncProvider & MarketingAutomationProviderPort & { readonly consentPush: ConsentPushPort; readonly newsletter: NewsletterSyncProviderPort } {
|
|
201
|
+
const binding = copy(input.binding), audience = copy(input.audience), now = input.now ?? Date.now;
|
|
202
|
+
const maxRequestBytes = input.maxRequestBytes ?? 65536, maxResponseBytes = input.maxResponseBytes ?? 1048576;
|
|
203
|
+
const maxFeedbackItems = input.maxFeedbackItems ?? 64, timeoutMs = input.timeoutMs ?? 10000, rateIntervalMs = input.rateIntervalMs ?? 100;
|
|
204
|
+
if (!validBinding(binding) || !validAudience(audience) || ![maxRequestBytes, maxResponseBytes, maxFeedbackItems, timeoutMs, rateIntervalMs].every(positive)) throw new Error("Invalid adapter configuration");
|
|
205
|
+
const verification = input.verification ? copy(input.verification) : undefined;
|
|
206
|
+
if (verification && (!equal(verification.binding, binding) || !equal(verification.audience, audience) || verification.revision !== REVISION || !text(verification.evidenceReference))) throw new Error("Invalid verification binding");
|
|
207
|
+
const operationScopes: Record<Exclude<Operation, "erasure.read">, string[]> = {
|
|
208
|
+
"profile.sync": ["profiles:read", "profiles:write"], "consent.read": ["profiles:read"], "erasure.request": ["data-privacy:write"],
|
|
209
|
+
"consent.push": ["profiles:read", "subscriptions:write", "lists:write"],
|
|
210
|
+
};
|
|
211
|
+
const capability = (operation: Exclude<Operation, "erasure.read">) => verification?.operations.includes(operation) && operationScopes[operation].every(s => verification.scopes.includes(s))
|
|
212
|
+
? { state: "supported" as const, evidenceReference: verification.evidenceReference, maxRequestBytes, maxFeedbackItems }
|
|
213
|
+
: { state: "verification_pending" as const };
|
|
214
|
+
const capabilities: ConsentPushPort["capabilities"] = copy({ binding, audience, operations: {
|
|
215
|
+
"profile.sync": capability("profile.sync"), "consent.read": capability("consent.read"),
|
|
216
|
+
"erasure.request": audience.kind === "account-wide" ? capability("erasure.request") : { state: "unsupported" },
|
|
217
|
+
"erasure.read": { state: "unsupported" }, "consent.push": { ...capability("consent.push"), grantEffect: "account-wide" },
|
|
218
|
+
} });
|
|
219
|
+
const accountKey = canonical(["communications-adapter", binding.account, binding.environment]);
|
|
220
|
+
const availability = input.storage.availability;
|
|
221
|
+
if (!availability || availability.account !== binding.account || availability.environment !== binding.environment ||
|
|
222
|
+
!["busy", "quarantined"].every(k => { const d = Object.getOwnPropertyDescriptor(availability, k); return d?.writable && typeof d.value === "boolean"; })) throw new Error("Invalid account availability record");
|
|
223
|
+
if (!availability.quarantined) quarantineCauses.delete(availability);
|
|
224
|
+
let quarantine = false;
|
|
225
|
+
const closeAccount = (grantCause?: Request) => {
|
|
226
|
+
const previous = quarantineCauses.get(availability);
|
|
227
|
+
if (!grantCause || previous?.kind === "non-grant" ||
|
|
228
|
+
(quarantine || availability.quarantined) && !previous) quarantineCauses.set(availability, { kind: "non-grant" });
|
|
229
|
+
else {
|
|
230
|
+
const grants = previous?.kind === "grants" ? previous.grants : new Map<string, string>();
|
|
231
|
+
grants.set(keyOf(grantCause), subjectKey(grantCause));
|
|
232
|
+
quarantineCauses.set(availability, { kind: "grants", grants });
|
|
233
|
+
}
|
|
234
|
+
quarantine = true; availability.quarantined = true;
|
|
235
|
+
};
|
|
236
|
+
const isWithdrawal = (r: Request) => r.operation === "consent.push" && r.payload.action === "withdraw";
|
|
237
|
+
function availabilityQuarantined(r: Request): boolean {
|
|
238
|
+
// Exempt only unresolved grant fences; every non-grant admission predicate remains.
|
|
239
|
+
return (quarantine || availability.quarantined) &&
|
|
240
|
+
!(isWithdrawal(r) && quarantineCauses.get(availability)?.kind === "grants");
|
|
241
|
+
}
|
|
242
|
+
const load = async () => {
|
|
243
|
+
let raw: string | null;
|
|
244
|
+
try { raw = await input.storage.get(accountKey); } catch { closeAccount(); throw new Error("storage"); }
|
|
245
|
+
try {
|
|
246
|
+
const ledger = parseLedger(raw);
|
|
247
|
+
ledger.coolingUntil = Math.max(ledger.coolingUntil, providerCooling.get(availability) ?? 0);
|
|
248
|
+
return { raw, ledger };
|
|
249
|
+
} catch { closeAccount(); throw new Error("journal"); }
|
|
250
|
+
};
|
|
251
|
+
async function quarantineAccount(affectedKey?: string, grantCause?: Request): Promise<void> {
|
|
252
|
+
closeAccount(grantCause);
|
|
253
|
+
// An error reply may follow a committed CAS. Write the existing quarantine bit
|
|
254
|
+
// against the actual latest bytes, rather than trusting the failed writer's copy.
|
|
255
|
+
try {
|
|
256
|
+
const raw = await input.storage.get(accountKey), ledger = parseLedger(raw);
|
|
257
|
+
ledger.quarantined ||= !grantCause;
|
|
258
|
+
const saved = affectedKey ? ledger.entries[affectedKey]?.result : undefined;
|
|
259
|
+
if (affectedKey && saved && (saved.kind === "accepted" || saved.kind === "acknowledged" || saved.kind === "completed")) ledger.entries[affectedKey].result = result(ledger.entries[affectedKey].request, { ...saved, journal: "unknown" });
|
|
260
|
+
await input.storage.compareAndSwap(accountKey, raw, canonical(ledger));
|
|
261
|
+
} catch { closeAccount(); /* Recovery storage faults are non-grant causes. */ }
|
|
262
|
+
}
|
|
263
|
+
async function persist(raw: string | null, ledger: Ledger, affectedKey?: string, grantCause?: Request): Promise<"saved" | "conflict" | "uncertain"> {
|
|
264
|
+
try {
|
|
265
|
+
const next = canonical(ledger);
|
|
266
|
+
if (!await input.storage.compareAndSwap(accountKey, raw, next)) return "conflict";
|
|
267
|
+
if (await input.storage.get(accountKey) !== next) { await quarantineAccount(affectedKey, grantCause); return "uncertain"; }
|
|
268
|
+
return "saved";
|
|
269
|
+
} catch { await quarantineAccount(affectedKey, grantCause); return "uncertain"; }
|
|
270
|
+
}
|
|
271
|
+
async function releaseSlot(r?: Request): Promise<boolean> {
|
|
272
|
+
try {
|
|
273
|
+
const release = await load();
|
|
274
|
+
if (r ? availabilityQuarantined(r) : quarantine || availability.quarantined) return false;
|
|
275
|
+
release.ledger.slot = false;
|
|
276
|
+
if (await persist(release.raw, release.ledger) === "saved") return true;
|
|
277
|
+
} catch { /* The durable slot stays held until host repair. */ }
|
|
278
|
+
await quarantineAccount(); return false;
|
|
279
|
+
}
|
|
280
|
+
function current(r: Request, snapshot?: Subject): Subject | Reason {
|
|
281
|
+
const s = input.subjectMapper(r.subjectReference);
|
|
282
|
+
if (!s || s.subjectReference !== r.subjectReference || !equal(s.binding, binding)) return "identity_conflict";
|
|
283
|
+
if (s.generation !== r.generation) return "stale";
|
|
284
|
+
if (r.deadlineMs <= now()) return "deadline";
|
|
285
|
+
if (s.erased && (r.operation === "profile.sync" || r.operation === "consent.push" && r.payload.action === "grant")) return "restricted";
|
|
286
|
+
if (snapshot && !equal(s, snapshot)) return "provenance";
|
|
287
|
+
return copy(s);
|
|
288
|
+
}
|
|
289
|
+
function authority(r: Request, target: Subject): Reason | undefined {
|
|
290
|
+
const auth = input.authorizations.get(r.authorizationReference);
|
|
291
|
+
if (!auth || !equal(auth.binding, binding) || auth.subjectReference !== r.subjectReference || !equal(auth.audience, r.audience) || auth.operation !== r.operation) return "provenance";
|
|
292
|
+
if (auth.expiresAtMs <= now()) return "deadline";
|
|
293
|
+
if (r.operation === "consent.read" || r.operation === "erasure.read") return;
|
|
294
|
+
const ref = r.operation === "erasure.request" ? r.payload.authorityReference : r.payload.consentEvidenceReference;
|
|
295
|
+
const local = input.localEvidence.get(ref);
|
|
296
|
+
if (!local || !equal(local.binding, binding) || local.subjectReference !== r.subjectReference || !equal(local.audience, r.audience) || !text(local.purpose)) return "provenance";
|
|
297
|
+
const action = r.operation === "profile.sync" ? "profile" : r.operation === "erasure.request" ? "erase" : r.payload.action;
|
|
298
|
+
if (!local.current || local.action !== action) return "restricted";
|
|
299
|
+
if (r.operation === "profile.sync") {
|
|
300
|
+
if (!equal(local.projection, r.payload.projection)) return "provenance";
|
|
301
|
+
if (r.payload.projection.email !== target.email || r.payload.projection.externalReference !== target.externalReference ||
|
|
302
|
+
Object.keys(r.payload.projection.fields).some(k => !target.ownedFields.includes(k as keyof ProfileProjection["fields"])) ||
|
|
303
|
+
Object.keys(r.payload.projection.properties).some(k => !target.ownedProperties.includes(k))) return "identity_conflict";
|
|
304
|
+
if (!verification?.noTrigger.profile) return "restricted";
|
|
305
|
+
}
|
|
306
|
+
if (r.operation === "erasure.request" && (local.caseReference !== r.payload.caseReference || local.authorityReference !== r.payload.authorityReference)) return "provenance";
|
|
307
|
+
if (r.operation === "consent.push") {
|
|
308
|
+
if (r.payload.action === "withdraw" && r.audience.kind === "account-wide" && !local.allPurposes) return "restricted";
|
|
309
|
+
if (r.payload.action === "grant" && (!verification?.noTrigger.accountWideGrant || !verification.noTrigger.doi || !verification.suppressionSafeAtEffect)) return "restricted";
|
|
310
|
+
if (r.payload.action === "withdraw" && r.audience.kind === "audience" && !verification?.safeListRemoval) return "restricted";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function http(r: Request, method: string, path: string, body: unknown, guard: () => Reason | undefined): Promise<{ status: number; body?: unknown; failure?: Result; slotHeld?: boolean }> {
|
|
314
|
+
if (body !== undefined && bytes(body) > maxRequestBytes) return { status: 0, failure: refused(r, "invalid_request") };
|
|
315
|
+
const mutation = method !== "GET";
|
|
316
|
+
const ambiguous = () => mutation ? unknown(r, "acceptance_ambiguous", { operation: r.operation, request: r }) : retry(r, "provider");
|
|
317
|
+
let loaded: Awaited<ReturnType<typeof load>>;
|
|
318
|
+
try { loaded = await load(); } catch { closeAccount(); return { status: 0, failure: retry(r, "host") }; }
|
|
319
|
+
let { raw, ledger } = loaded;
|
|
320
|
+
// Pace the remaining HTTP steps of this operation without retrying a mutation.
|
|
321
|
+
// Provider cooldown and occupied/quarantined slots still return immediately.
|
|
322
|
+
const delay = ledger.nextAt - now();
|
|
323
|
+
if (!availabilityQuarantined(r) && !ledger.quarantined && !ledger.slot && ledger.coolingUntil <= now() && delay > 0 && delay < Math.min(timeoutMs, r.deadlineMs - now())) {
|
|
324
|
+
await new Promise<void>(resolve => setTimeout(resolve, delay));
|
|
325
|
+
try { ({ raw, ledger } = await load()); } catch { closeAccount(); return { status: 0, failure: retry(r, "host") }; }
|
|
326
|
+
}
|
|
327
|
+
if ((availabilityQuarantined(r) || ledger.quarantined || ledger.slot) || ledger.coolingUntil > now() || ledger.nextAt > now()) return { status: 0, failure: retry(r, "cooling", Math.max(1, Math.ceil((Math.max(ledger.coolingUntil, ledger.nextAt) - now()) / 1000))) };
|
|
328
|
+
if (mutation && (r.operation === "profile.sync" || r.operation === "consent.push" && r.payload.action === "grant") && ledger.tombstones.includes(subjectKey(r))) return { status: 0, failure: refused(r, "restricted") };
|
|
329
|
+
if (mutation && r.operation === "consent.push" && r.payload.action === "grant" && Object.values(ledger.withdrawals).some(w => w.owner === subjectKey(r) && !w.resolved && overlaps(w.audience, r.audience))) return { status: 0, failure: refused(r, "restricted") };
|
|
330
|
+
const before = guard(); if (before) return { status: 0, failure: refused(r, before) };
|
|
331
|
+
ledger.nextAt = now() + rateIntervalMs; ledger.slot = true;
|
|
332
|
+
const saved = await persist(raw, ledger);
|
|
333
|
+
if (saved !== "saved") return { status: 0, failure: saved === "conflict" ? retry(r, "host") : unknown(r, "dispatch_unresolved") };
|
|
334
|
+
async function transfer(): Promise<{ status: number; body?: unknown; failure?: Result }> {
|
|
335
|
+
const controller = new AbortController();
|
|
336
|
+
const remaining = Math.min(timeoutMs, r.deadlineMs - now());
|
|
337
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
338
|
+
const abort = new Promise<never>((_resolve, reject) => { timeout = setTimeout(() => { controller.abort(); reject(new Error("deadline")); }, remaining); });
|
|
339
|
+
try {
|
|
340
|
+
if (remaining <= 0) return { status: 0, failure: refused(r, "deadline") };
|
|
341
|
+
const recheck = guard(); if (recheck) return { status: 0, failure: refused(r, recheck) };
|
|
342
|
+
if (availabilityQuarantined(r) || !availability.busy) return { status: 0, failure: retry(r, "cooling") };
|
|
343
|
+
let credential: ReturnType<Configuration["resolveCredential"]>;
|
|
344
|
+
try { credential = input.resolveCredential(); } catch { return { status: 0, failure: retry(r, "host") }; }
|
|
345
|
+
if (!credential || !equal(credential.binding, binding) || !text(credential.key) || /\s/.test(credential.key)) return { status: 0, failure: refused(r, "binding") };
|
|
346
|
+
const response = await Promise.race([(input.fetch ?? fetch)(ORIGIN + path, {
|
|
347
|
+
method, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: controller.signal, redirect: "manual",
|
|
348
|
+
headers: { Authorization: `Klaviyo-API-Key ${credential.key}`, revision: REVISION, Accept: "application/vnd.api+json",
|
|
349
|
+
...(body === undefined ? {} : { "Content-Type": "application/vnd.api+json" }) },
|
|
350
|
+
}), abort]);
|
|
351
|
+
if (response.status === 429) {
|
|
352
|
+
const value = response.headers.get("retry-after");
|
|
353
|
+
const parsed = value && /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : value ? (Date.parse(value) - now()) / 1000 : NaN;
|
|
354
|
+
const afterSeconds = Number.isFinite(parsed) && parsed >= 0 && Number.isFinite(now() + Math.ceil(parsed) * 1000) ? Math.max(1, Math.ceil(parsed)) : 60;
|
|
355
|
+
// A lost cooldown write must never turn the withdrawal exemption into
|
|
356
|
+
// permission to ignore an observed account-level provider rate limit.
|
|
357
|
+
providerCooling.set(availability, Math.max(providerCooling.get(availability) ?? 0, now() + afterSeconds * 1000));
|
|
358
|
+
try {
|
|
359
|
+
const next = await load(); next.ledger.coolingUntil = Math.max(next.ledger.coolingUntil, now() + afterSeconds * 1000);
|
|
360
|
+
if (await persist(next.raw, next.ledger) !== "saved") closeAccount();
|
|
361
|
+
} catch { closeAccount(); }
|
|
362
|
+
await response.body?.cancel().catch(() => {});
|
|
363
|
+
return { status: 429, failure: retry(r, "cooling", afterSeconds) };
|
|
364
|
+
}
|
|
365
|
+
if (response.status >= 400 && response.status < 500) {
|
|
366
|
+
await response.body?.cancel().catch(() => {});
|
|
367
|
+
if (response.status === 404 && !mutation) return { status: 404 };
|
|
368
|
+
return { status: response.status, failure: refused(r, response.status === 409 ? "identity_conflict" : "provider_refusal") };
|
|
369
|
+
}
|
|
370
|
+
if (response.status >= 300) { await response.body?.cancel().catch(() => {}); return { status: response.status, failure: ambiguous() }; }
|
|
371
|
+
if (response.status === 202 || response.status === 204) { await response.body?.cancel().catch(() => {}); return { status: response.status }; }
|
|
372
|
+
if (!/^application\/(?:vnd\.api\+json|json)(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) { await response.body?.cancel().catch(() => {}); return { status: response.status, failure: ambiguous() }; }
|
|
373
|
+
const reader = response.body?.getReader();
|
|
374
|
+
if (!reader) return { status: response.status, failure: ambiguous() };
|
|
375
|
+
const chunks: Uint8Array[] = []; let size = 0;
|
|
376
|
+
for (;;) {
|
|
377
|
+
const chunk = await Promise.race([reader.read(), abort]);
|
|
378
|
+
if (chunk.done) break;
|
|
379
|
+
size += chunk.value.byteLength;
|
|
380
|
+
if (size > maxResponseBytes) { await reader.cancel(); return { status: response.status, failure: ambiguous() }; }
|
|
381
|
+
chunks.push(chunk.value);
|
|
382
|
+
}
|
|
383
|
+
const combined = new Uint8Array(size); let offset = 0;
|
|
384
|
+
for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
|
|
385
|
+
return { status: response.status, body: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(combined)) };
|
|
386
|
+
} catch { return { status: 0, failure: ambiguous() }; }
|
|
387
|
+
finally {
|
|
388
|
+
clearTimeout(timeout);
|
|
389
|
+
if (r.operation !== "consent.read") await releaseSlot(r);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const answer = await transfer();
|
|
393
|
+
return { ...answer, ...(r.operation === "consent.read" ? { slotHeld: true } : {}) };
|
|
394
|
+
}
|
|
395
|
+
async function execute(original: Request, ownsAvailability: boolean): Promise<Result> {
|
|
396
|
+
// Snapshot validation precedes every await, credential lookup and provider call.
|
|
397
|
+
let r: Request;
|
|
398
|
+
try { r = copy(original); if (!validRequest(r, maxRequestBytes)) return refused(r, "invalid_request"); }
|
|
399
|
+
catch { return refused(original, "invalid_request"); }
|
|
400
|
+
if (!equal(r.binding, binding)) return refused(r, "binding");
|
|
401
|
+
const initial = current(r); if (typeof initial === "string") return refused(r, initial);
|
|
402
|
+
const key = keyOf(r), owner = subjectKey(r);
|
|
403
|
+
let loaded: Awaited<ReturnType<typeof load>>;
|
|
404
|
+
try { loaded = await load(); } catch (error) { closeAccount(); return error instanceof Error && error.message === "journal" ? unknown(r, "record_failed") : retry(r, "host"); }
|
|
405
|
+
let { raw, ledger } = loaded;
|
|
406
|
+
// Receipt provenance precedes capability refusal, including deserialized receipts.
|
|
407
|
+
if (r.operation === "erasure.read") {
|
|
408
|
+
const receipt = r.payload.receipt;
|
|
409
|
+
if (!receipt?.request || !validRequest(receipt.request, maxRequestBytes)) return refused(r, "provenance");
|
|
410
|
+
const prior = ledger.entries[keyOf(receipt.request)];
|
|
411
|
+
if (receipt.operation !== "erasure.request" || !prior || !equal(prior.request, receipt.request) || prior.request.operation !== "erasure.request" ||
|
|
412
|
+
!equal(prior.request.binding, r.binding) || prior.request.subjectReference !== r.subjectReference || !equal(prior.request.audience, r.audience) ||
|
|
413
|
+
!prior.result || !("receipt" in prior.result) || !equal(prior.result.receipt, receipt)) return refused(r, "provenance");
|
|
414
|
+
}
|
|
415
|
+
const latest = current(r, initial); if (typeof latest === "string") return refused(r, latest);
|
|
416
|
+
if (ledger.entries[key] && ledger.entries[key].semantic !== semantic(r)) return refused(r, "invalid_request");
|
|
417
|
+
if (r.operation !== "consent.push" && !equal(r.audience, audience) || r.operation === "consent.push" && r.audience.kind === "audience" && !equal(r.audience, audience)) return refused(r, "unsupported");
|
|
418
|
+
const cap = capabilities.operations[r.operation];
|
|
419
|
+
if (cap.state !== "supported") return refused(r, cap.state);
|
|
420
|
+
const previous = ledger.entries[key];
|
|
421
|
+
if (previous) {
|
|
422
|
+
if (previous.semantic !== semantic(r)) return refused(r, "invalid_request");
|
|
423
|
+
if (!equal(previous.request.binding, r.binding) || previous.request.subjectReference !== r.subjectReference || previous.request.operation !== r.operation) { closeAccount(); return unknown(r, "record_failed"); }
|
|
424
|
+
if (previous.result) {
|
|
425
|
+
const value = { ...previous.result };
|
|
426
|
+
if (r.operation === "consent.push" && (value.kind === "accepted" || value.kind === "acknowledged") && "reconcileRequired" in value) {
|
|
427
|
+
value.reconcileRequired ||= Object.values(ledger.withdrawals).some(w => !w.resolved && w.owner === owner && overlaps(w.audience, r.audience) && ledger.entries[key]?.request.subjectReference === r.subjectReference);
|
|
428
|
+
}
|
|
429
|
+
return result(r, value);
|
|
430
|
+
}
|
|
431
|
+
return previous.phase === "admitted" ? result(r, { kind: "accepted", receipt: { operation: r.operation, request: previous.request }, journal: "confirmed", ...(r.operation === "consent.push" ? { reconcileRequired: true } : {}) }) : unknown(r, "dispatch_unresolved");
|
|
432
|
+
}
|
|
433
|
+
if (r.operation === "consent.push" && r.payload.action === "grant" && Object.values(ledger.withdrawals).some(w => !w.resolved && w.owner === owner && overlaps(w.audience, r.audience))) return refused(r, "restricted");
|
|
434
|
+
// Withdrawals omit only this subject's unresolved-grant fence.
|
|
435
|
+
const pending = Object.values(ledger.entries).filter(e => subjectKey(e.request) === owner && e.phase !== "settled");
|
|
436
|
+
const withdrawing = r.operation === "consent.push" && r.payload.action === "withdraw";
|
|
437
|
+
if (r.operation !== "consent.read" && pending.some(e => !withdrawing || e.request.operation !== "consent.push" || e.request.payload.action !== "grant")) return unknown(r, "dispatch_unresolved");
|
|
438
|
+
if ((availabilityQuarantined(r) || ledger.quarantined || ledger.slot) || ledger.coolingUntil > now()) return retry(r, "cooling", Math.max(1, Math.ceil((ledger.coolingUntil - now()) / 1000)));
|
|
439
|
+
const proof = authority(r, latest); if (proof) return refused(r, proof);
|
|
440
|
+
if (!ownsAvailability) return retry(r, "cooling");
|
|
441
|
+
if (ledger.tombstones.includes(owner) && (r.operation === "profile.sync" || r.operation === "consent.push" && r.payload.action === "grant")) return refused(r, "restricted");
|
|
442
|
+
const localRef = r.operation === "profile.sync" || r.operation === "consent.push" ? r.payload.consentEvidenceReference : r.operation === "erasure.request" ? r.payload.authorityReference : undefined;
|
|
443
|
+
const localSnapshot = localRef ? copy(input.localEvidence.get(localRef)) : undefined;
|
|
444
|
+
const authSnapshot = copy(input.authorizations.get(r.authorizationReference));
|
|
445
|
+
const guard = (): Reason | undefined => {
|
|
446
|
+
const s = current(r, initial); if (typeof s === "string") return s;
|
|
447
|
+
if (!equal(authSnapshot, input.authorizations.get(r.authorizationReference)) || localRef && !equal(localSnapshot, input.localEvidence.get(localRef))) return "provenance";
|
|
448
|
+
return authority(r, s);
|
|
449
|
+
};
|
|
450
|
+
const listId = r.audience.kind === "audience" ? Object.entries(verification!.listMappings).filter(([, scope]) => equal(scope, r.audience)).map(([k]) => k) : [];
|
|
451
|
+
let target = copy({ ...latest, ...(ledger.mappings[owner] ? { id: ledger.mappings[owner] } : {}) });
|
|
452
|
+
if (target.id && !id(target.id) || target.id && latest.id && target.id !== latest.id) return refused(r, "identity_conflict");
|
|
453
|
+
const readProfile = async (selected: Subject) => http(r, "GET", `/api/profiles/${selected.id}?additional-fields[profile]=subscriptions`, undefined, guard);
|
|
454
|
+
if (!target.id) {
|
|
455
|
+
if (r.operation !== "profile.sync") return refused(r, "identity_conflict");
|
|
456
|
+
const query = new URLSearchParams({ filter: `and(equals(email,${JSON.stringify(target.email)}),equals(external_id,${JSON.stringify(target.externalReference)}))`, "page[size]": "2" });
|
|
457
|
+
const lookup = await http(r, "GET", "/api/profiles?" + query, undefined, guard);
|
|
458
|
+
if (lookup.failure) return lookup.failure;
|
|
459
|
+
const root = object(lookup.body), data = root.data;
|
|
460
|
+
if (lookup.status !== 200 || !Array.isArray(data) || data.length > 1 || object(root.links).next !== null) return refused(r, "identity_conflict");
|
|
461
|
+
if (data.length === 1) {
|
|
462
|
+
const found = profileData({ data: data[0] }, target);
|
|
463
|
+
if (!found) return refused(r, "identity_conflict");
|
|
464
|
+
target = copy({ ...target, id: found });
|
|
465
|
+
} else if (!verification!.safeCreate || !verification!.noTrigger.profile) return refused(r, "restricted");
|
|
466
|
+
}
|
|
467
|
+
if (target.id && (r.operation === "profile.sync" || r.operation === "consent.push")) {
|
|
468
|
+
const read = await readProfile(target);
|
|
469
|
+
if (read.failure) return read.failure;
|
|
470
|
+
if (read.status !== 200 || !profileData(read.body, target)) return refused(r, "identity_conflict");
|
|
471
|
+
if (r.operation === "consent.push" && r.payload.action === "grant") {
|
|
472
|
+
const value = consentValue(read.body, target, r.audience, verification!, now(), maxFeedbackItems);
|
|
473
|
+
if (!value || value.coverage !== "complete" || value.feedback.length > 0) return refused(r, "restricted");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (r.operation === "consent.read") {
|
|
477
|
+
const readStartedAt = now();
|
|
478
|
+
const settlements = copy(Object.fromEntries(input.grantSettlements ?? []));
|
|
479
|
+
const read = await readProfile(target);
|
|
480
|
+
if (read.failure) {
|
|
481
|
+
if (read.slotHeld) await releaseSlot();
|
|
482
|
+
const changed = guard(); return changed ? refused(r, changed) : read.failure;
|
|
483
|
+
}
|
|
484
|
+
const changed = guard(); if (changed) { if (read.slotHeld) await releaseSlot(); return refused(r, changed); }
|
|
485
|
+
let value = read.status === 404 ? copy({ audience: r.audience, observedAtMs: now(), remoteConsent: "unknown" as const, coverage: "partial" as const, feedback: [] }) : consentValue(read.body, target, r.audience, verification!, now(), maxFeedbackItems);
|
|
486
|
+
if (!value) { if (read.slotHeld) await releaseSlot(); const changed = guard(); return changed ? refused(r, changed) : retry(r, "provider"); }
|
|
487
|
+
const unsettled = (w: Ledger["withdrawals"][string]) => w.earlier.some(k => {
|
|
488
|
+
const grant = ledger.entries[k];
|
|
489
|
+
const proof = grant && settlements[grant.request.requestId];
|
|
490
|
+
return !grant || !proof || proof.source !== "provider-read" || !text(proof.evidenceReference) ||
|
|
491
|
+
!equal(proof.binding, r.binding) || proof.subjectReference !== r.subjectReference ||
|
|
492
|
+
proof.requestId !== grant.request.requestId || !Number.isFinite(proof.settledAtMs) || proof.settledAtMs > readStartedAt;
|
|
493
|
+
});
|
|
494
|
+
const affected = Object.values(ledger.withdrawals).filter(w => w.owner === owner && !w.resolved && overlaps(w.audience, r.audience));
|
|
495
|
+
const uncertain = affected.some(w => unsettled(w)) || affected.length > 0 && (value.coverage !== "complete" || !["subscribed", "unsubscribed"].includes(value.remoteConsent));
|
|
496
|
+
if (uncertain) value = copy({ ...value, remoteConsent: "unknown" });
|
|
497
|
+
let journal: "confirmed" | "unknown" = "unknown";
|
|
498
|
+
try {
|
|
499
|
+
const next = await load();
|
|
500
|
+
if (!uncertain && value.coverage === "complete") {
|
|
501
|
+
for (const [k, w] of Object.entries(next.ledger.withdrawals)) {
|
|
502
|
+
if (w.owner === owner && overlaps(w.audience, r.audience) && !unsettled(w)) {
|
|
503
|
+
if (value.remoteConsent === "unsubscribed") next.ledger.withdrawals[k].resolved = true;
|
|
504
|
+
for (const earlier of w.earlier) { next.ledger.entries[earlier].grantSettled = true; next.ledger.entries[earlier].phase = "settled"; }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const alreadyQuarantined = next.ledger.quarantined;
|
|
509
|
+
next.ledger.entries[key] = { request: r, semantic: semantic(r), target, result: result(r, { kind: "completed", value, journal: "unknown" }), phase: "settled" };
|
|
510
|
+
next.ledger.quarantined = true;
|
|
511
|
+
if (await persist(next.raw, next.ledger, key) === "saved" && !alreadyQuarantined) {
|
|
512
|
+
const recorded = canonical(next.ledger);
|
|
513
|
+
next.ledger.entries[key].result = result(r, { kind: "completed", value, journal: "confirmed" });
|
|
514
|
+
next.ledger.quarantined = false; next.ledger.slot = false;
|
|
515
|
+
if (await persist(recorded, next.ledger, key) === "saved") journal = "confirmed";
|
|
516
|
+
else await quarantineAccount(key);
|
|
517
|
+
} else await quarantineAccount(key);
|
|
518
|
+
} catch { await quarantineAccount(key); }
|
|
519
|
+
const afterPersistence = guard(); if (afterPersistence) return refused(r, afterPersistence);
|
|
520
|
+
return result(r, { kind: "completed", value, journal });
|
|
521
|
+
}
|
|
522
|
+
if (r.operation === "erasure.read") return refused(r, "unsupported");
|
|
523
|
+
if (r.operation === "consent.push" && r.audience.kind === "audience" && (listId.length !== 1 || !id(listId[0]))) return refused(r, "restricted");
|
|
524
|
+
const changed = guard(); if (changed) return refused(r, changed);
|
|
525
|
+
// Refresh before CAS: all namespaces/instances sharing account storage arbitrate here.
|
|
526
|
+
({ raw, ledger } = await load());
|
|
527
|
+
if (ledger.entries[key]) return unknown(r, "dispatch_unresolved");
|
|
528
|
+
if (Object.values(ledger.entries).some(e => subjectKey(e.request) === owner && e.phase !== "settled" && (!withdrawing || e.request.operation !== "consent.push" || e.request.payload.action !== "grant"))) return unknown(r, "dispatch_unresolved");
|
|
529
|
+
if (r.operation === "consent.push" && r.payload.action === "grant" && Object.values(ledger.withdrawals).some(w => !w.resolved && w.owner === owner && overlaps(w.audience, r.audience))) return refused(r, "restricted");
|
|
530
|
+
const receipt = copy({ operation: r.operation, request: r });
|
|
531
|
+
ledger.entries[key] = { request: r, semantic: semantic(r), target, phase: "dispatched" };
|
|
532
|
+
if (r.operation === "erasure.request" && !ledger.tombstones.includes(owner)) ledger.tombstones.push(owner);
|
|
533
|
+
let reconcileRequired = false;
|
|
534
|
+
if (withdrawing) {
|
|
535
|
+
const earlier = Object.entries(ledger.entries).filter(([, e]) => subjectKey(e.request) === owner && e.request.operation === "consent.push" && e.request.payload.action === "grant" && !e.grantSettled).map(([k]) => k);
|
|
536
|
+
const causes = quarantineCauses.get(availability);
|
|
537
|
+
for (const [grant, subject] of causes?.kind === "grants" ? causes.grants : []) if (subject === owner && !earlier.includes(grant)) earlier.push(grant);
|
|
538
|
+
reconcileRequired = earlier.length > 0;
|
|
539
|
+
ledger.withdrawals[key] = { owner, audience: r.audience, earlier, resolved: false };
|
|
540
|
+
}
|
|
541
|
+
const grantCause = r.operation === "consent.push" && r.payload.action === "grant" ? r : undefined;
|
|
542
|
+
const admitted = await persist(raw, ledger, undefined, grantCause);
|
|
543
|
+
if (admitted !== "saved") return admitted === "conflict" ? retry(r, "host") : unknown(r, "dispatch_unresolved");
|
|
544
|
+
const command = r.operation === "consent.push" ? consentCommand(r, { id: target.id!, email: target.email }, listId[0]) : r.operation === "erasure.request" ? {
|
|
545
|
+
method: "POST", path: "/api/data-privacy-deletion-jobs", expected: [202], body: { data: { type: "data-privacy-deletion-job", attributes: { profile: { data: { type: "profile", id: target.id, attributes: {} } } } } },
|
|
546
|
+
} : {
|
|
547
|
+
method: target.id ? "PATCH" : "POST", path: target.id ? `/api/profiles/${target.id}` : "/api/profiles", expected: target.id ? [200, 202, 204] : [201, 202, 204],
|
|
548
|
+
body: { data: { type: "profile", ...(target.id ? { id: target.id } : {}), attributes: profileAttributes(r.payload.projection, !target.id) } },
|
|
549
|
+
};
|
|
550
|
+
const wire = await http(r, command.method, command.path, command.body, guard);
|
|
551
|
+
let settled: Result, remoteId: string | undefined;
|
|
552
|
+
if (wire.failure) settled = wire.failure;
|
|
553
|
+
else if (!command.expected.includes(wire.status)) settled = unknown(r, "acceptance_ambiguous", receipt);
|
|
554
|
+
else {
|
|
555
|
+
settled = result(r, { kind: "acknowledged", receipt, journal: quarantine || availability.quarantined || ledger.quarantined ? "unknown" : "confirmed", ...(r.operation === "consent.push" ? { reconcileRequired } : {}) });
|
|
556
|
+
if (r.operation === "profile.sync") {
|
|
557
|
+
const found = wire.body === undefined ? target.id : profileData(wire.body, target, r.payload.projection);
|
|
558
|
+
if (wire.body !== undefined && !found) settled = unknown(r, "acceptance_ambiguous", receipt);
|
|
559
|
+
else if (!found) settled = result(r, { kind: "acknowledged", receipt, journal: "unknown" });
|
|
560
|
+
else {
|
|
561
|
+
const verified = await readProfile({ ...target, id: found });
|
|
562
|
+
if (verified.failure || verified.status !== 200 || !profileData(verified.body, { ...target, id: found }, r.payload.projection) || guard()) settled = result(r, { kind: "acknowledged", receipt, journal: "unknown" });
|
|
563
|
+
else remoteId = found;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const final = await load(), entry = final.ledger.entries[key];
|
|
569
|
+
if (!entry || !equal(entry.request, r) || !equal(entry.target, target)) { closeAccount(); return settled.kind === "acknowledged" ? result(r, { ...settled, journal: "unknown" }) : unknown(r, "record_failed", receipt); }
|
|
570
|
+
entry.result = settled;
|
|
571
|
+
// Unknown acceptance retains the per-subject dispatch fence. Known acceptance with a
|
|
572
|
+
// failed projection/mapping check quarantines the account; it is never retried as a write.
|
|
573
|
+
entry.phase = settled.kind === "unknown" ? "dispatched" : "settled";
|
|
574
|
+
if (r.operation === "consent.push" && r.payload.action === "grant" && (settled.kind === "refused" || settled.kind === "retryable")) entry.grantSettled = true;
|
|
575
|
+
if (r.operation === "profile.sync" && settled.kind === "acknowledged" && settled.journal === "unknown") final.ledger.quarantined = true;
|
|
576
|
+
if (remoteId) { entry.remoteId = remoteId; final.ledger.mappings[owner] = remoteId; }
|
|
577
|
+
const releaseQuarantine = !final.ledger.quarantined && !availabilityQuarantined(r);
|
|
578
|
+
// A grant already has its durable per-subject dispatch fence. Only other
|
|
579
|
+
// operations need the non-grant quarantine bit during journal settlement.
|
|
580
|
+
final.ledger.quarantined ||= !grantCause;
|
|
581
|
+
if (await persist(final.raw, final.ledger, key, grantCause) !== "saved") {
|
|
582
|
+
await quarantineAccount(key, grantCause);
|
|
583
|
+
return settled.kind === "acknowledged" ? result(r, { ...settled, journal: "unknown" }) : settled.kind === "refused" || settled.kind === "retryable" ? settled : unknown(r, "record_failed", receipt);
|
|
584
|
+
}
|
|
585
|
+
if (releaseQuarantine) {
|
|
586
|
+
const recorded = canonical(final.ledger); final.ledger.quarantined = false;
|
|
587
|
+
if (await persist(recorded, final.ledger, key, grantCause) !== "saved") {
|
|
588
|
+
await quarantineAccount(key, grantCause);
|
|
589
|
+
return settled.kind === "acknowledged" ? result(r, { ...settled, journal: "unknown" }) : settled;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return settled;
|
|
593
|
+
} catch {
|
|
594
|
+
await quarantineAccount(key);
|
|
595
|
+
return settled.kind === "acknowledged" ? result(r, { ...settled, journal: "unknown" }) : settled.kind === "refused" || settled.kind === "retryable" ? settled : unknown(r, "record_failed", receipt);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async function run(r: Request, operation: Operation): Promise<Result> {
|
|
599
|
+
const fallback = { binding, operation, subjectReference: "" as Request["subjectReference"], requestId: "", generation: 0 };
|
|
600
|
+
try {
|
|
601
|
+
if (!validRequest(r, maxRequestBytes)) return result(fallback, { kind: "refused", reason: "invalid_request" });
|
|
602
|
+
} catch { return result(fallback, { kind: "refused", reason: "invalid_request" }); }
|
|
603
|
+
if (r.operation !== operation) return result(r, { kind: "refused", reason: "binding" });
|
|
604
|
+
const ownsAvailability = !availability.busy;
|
|
605
|
+
if (ownsAvailability) availability.busy = true;
|
|
606
|
+
try { return await execute(r, ownsAvailability); }
|
|
607
|
+
catch { closeAccount(); return result({ ...fallback, subjectReference: r.subjectReference, requestId: r.requestId, generation: r.generation }, { kind: "unknown", reason: "record_failed" }); }
|
|
608
|
+
finally { if (ownsAvailability) availability.busy = false; }
|
|
609
|
+
}
|
|
610
|
+
const scope = copy(input.legacy?.scope ?? { bindingId: binding.namespace, accountReference: binding.account, audienceReference: audience.kind === "audience" ? audience.reference : "account-wide" });
|
|
611
|
+
function legacyRequest(claim: MarketingAutomationClaim): Request | undefined {
|
|
612
|
+
const saved = input.legacy?.claims.get(claim.effectId);
|
|
613
|
+
return saved && equal(saved.claim, claim) && equal(claim.scope, scope) && claim.subjectReference === saved.request.subjectReference && claim.generation === saved.request.generation &&
|
|
614
|
+
claim.deadlineMs === saved.request.deadlineMs && claim.idempotencyKey === saved.request.requestId ? saved.request : undefined;
|
|
615
|
+
}
|
|
616
|
+
const observation = (claim: MarketingAutomationClaim, value: Result, remoteId?: string): MarketingAutomationObservation => {
|
|
617
|
+
const base = { effectId: claim.effectId, generation: claim.generation, observedAtMs: now() };
|
|
618
|
+
if (value.kind === "acknowledged" && value.journal === "confirmed" && remoteId) return { ...base, status: "accepted", remoteReference: remoteId };
|
|
619
|
+
if (value.kind === "retryable") return { ...base, status: "throttled", retryAfterMs: value.afterSeconds * 1000 };
|
|
620
|
+
if (value.kind === "refused") return { ...base, status: "refused", reason: value.reason === "deadline" ? "deadline" : ["binding", "provenance", "stale", "identity_conflict"].includes(value.reason) ? "scope" : ["unsupported", "verification_pending"].includes(value.reason) ? "unsupported" : "restricted" };
|
|
621
|
+
return { ...base, status: "ambiguous", retryAfterMs: 1000 };
|
|
622
|
+
};
|
|
623
|
+
return {
|
|
624
|
+
capabilities, profiles: { sync: r => run(r, "profile.sync") as Promise<SyncResult<"profile.sync">> },
|
|
625
|
+
consent: { read: r => run(r, "consent.read") as Promise<SyncResult<"consent.read">> },
|
|
626
|
+
erasure: { request: r => run(r, "erasure.request") as Promise<SyncResult<"erasure.request">>, read: r => run(r, "erasure.read") as Promise<SyncResult<"erasure.read">> },
|
|
627
|
+
consentPush: { capabilities, push: r => run(r, "consent.push") as Promise<ConsentPushResult> },
|
|
628
|
+
newsletter: { providerKind: "klaviyo", capabilities: { subscribe: false, unsubscribe: false, suppress: false, unsuppress: false, doubleOptIn: false, emailChange: false, listMembership: false, webhookIdempotency: false, retryAfter: false },
|
|
629
|
+
async sync() { return { status: "skipped", reason: "unsupported" }; }, async reconcile() { return { status: "skipped", reason: "unsupported" }; } },
|
|
630
|
+
scope,
|
|
631
|
+
async admit(claim) {
|
|
632
|
+
try {
|
|
633
|
+
const r = legacyRequest(claim); if (!r || !validRequest(r, maxRequestBytes) || !equal(r.binding, binding)) return "refused";
|
|
634
|
+
const s = current(r); return typeof s === "string" || authority(r, s) || capabilities.operations[r.operation].state !== "supported" ? "refused" : "admitted";
|
|
635
|
+
} catch { return "refused"; }
|
|
636
|
+
},
|
|
637
|
+
async apply(claim) {
|
|
638
|
+
const r = legacyRequest(claim); if (!r) return { effectId: claim.effectId, generation: claim.generation, observedAtMs: now(), status: "refused", reason: "scope" };
|
|
639
|
+
const value = await run(r, r.operation), saved = await load().catch(() => null);
|
|
640
|
+
return observation(claim, value, saved?.ledger.entries[keyOf(r)]?.remoteId);
|
|
641
|
+
},
|
|
642
|
+
async observe(claim) {
|
|
643
|
+
const r = legacyRequest(claim); if (!r) return { effectId: claim.effectId, generation: claim.generation, observedAtMs: now(), status: "refused", reason: "scope" };
|
|
644
|
+
try {
|
|
645
|
+
const saved = (await load()).ledger.entries[keyOf(r)]; return saved?.result ? observation(claim, saved.result, saved.remoteId) : null;
|
|
646
|
+
} catch { return observation(claim, unknown(r, "record_failed")); }
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|