@aglyn/tenant-data-admin 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +7 -0
- package/jest.integration.config.d.ts +39 -0
- package/jest.integration.setup.d.ts +17 -0
- package/package.json +51 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/render-cache.d.ts +184 -0
- package/src/lib/render-cache.js +198 -0
- package/src/lib/render-cache.js.map +1 -0
- package/src/lib/server/account-addresses.d.ts +160 -0
- package/src/lib/server/account-addresses.js +311 -0
- package/src/lib/server/account-addresses.js.map +1 -0
- package/src/lib/server/account-emails.d.ts +219 -0
- package/src/lib/server/account-emails.js +640 -0
- package/src/lib/server/account-emails.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +180 -0
- package/src/lib/server/admin-audit.js +233 -0
- package/src/lib/server/admin-audit.js.map +1 -0
- package/src/lib/server/analytics-retention.d.ts +63 -0
- package/src/lib/server/analytics-retention.js +66 -0
- package/src/lib/server/analytics-retention.js.map +1 -0
- package/src/lib/server/api-http.d.ts +159 -0
- package/src/lib/server/api-http.js +230 -0
- package/src/lib/server/api-http.js.map +1 -0
- package/src/lib/server/api-keys.d.ts +117 -0
- package/src/lib/server/api-keys.js +298 -0
- package/src/lib/server/api-keys.js.map +1 -0
- package/src/lib/server/auth-handoff.d.ts +165 -0
- package/src/lib/server/auth-handoff.js +482 -0
- package/src/lib/server/auth-handoff.js.map +1 -0
- package/src/lib/server/auth-pools.d.ts +246 -0
- package/src/lib/server/auth-pools.js +551 -0
- package/src/lib/server/auth-pools.js.map +1 -0
- package/src/lib/server/billing-addons.d.ts +171 -0
- package/src/lib/server/billing-addons.js +277 -0
- package/src/lib/server/billing-addons.js.map +1 -0
- package/src/lib/server/campaign-attribution-store.d.ts +55 -0
- package/src/lib/server/campaign-attribution-store.js +95 -0
- package/src/lib/server/campaign-attribution-store.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +205 -0
- package/src/lib/server/campaign-conversion-attribution.js +279 -0
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
- package/src/lib/server/client-error-report.d.ts +301 -0
- package/src/lib/server/client-error-report.js +755 -0
- package/src/lib/server/client-error-report.js.map +1 -0
- package/src/lib/server/connect-account-status.d.ts +74 -0
- package/src/lib/server/connect-account-status.js +94 -0
- package/src/lib/server/connect-account-status.js.map +1 -0
- package/src/lib/server/connect-payout-failure.d.ts +103 -0
- package/src/lib/server/connect-payout-failure.js +127 -0
- package/src/lib/server/connect-payout-failure.js.map +1 -0
- package/src/lib/server/console-api-rate-limit.d.ts +81 -0
- package/src/lib/server/console-api-rate-limit.js +111 -0
- package/src/lib/server/console-api-rate-limit.js.map +1 -0
- package/src/lib/server/console-domains.d.ts +326 -0
- package/src/lib/server/console-domains.js +779 -0
- package/src/lib/server/console-domains.js.map +1 -0
- package/src/lib/server/consume-once.d.ts +91 -0
- package/src/lib/server/consume-once.js +96 -0
- package/src/lib/server/consume-once.js.map +1 -0
- package/src/lib/server/contact-company-link.d.ts +85 -0
- package/src/lib/server/contact-company-link.js +105 -0
- package/src/lib/server/contact-company-link.js.map +1 -0
- package/src/lib/server/contact-email-engagement.d.ts +32 -0
- package/src/lib/server/contact-email-engagement.js +145 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-email-index.d.ts +56 -0
- package/src/lib/server/contact-email-index.js +158 -0
- package/src/lib/server/contact-email-index.js.map +1 -0
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +88 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-merge.d.ts +55 -0
- package/src/lib/server/contact-merge.js +279 -0
- package/src/lib/server/contact-merge.js.map +1 -0
- package/src/lib/server/contact-suppression.d.ts +153 -0
- package/src/lib/server/contact-suppression.js +259 -0
- package/src/lib/server/contact-suppression.js.map +1 -0
- package/src/lib/server/crm-booking-activity.d.ts +86 -0
- package/src/lib/server/crm-booking-activity.js +251 -0
- package/src/lib/server/crm-booking-activity.js.map +1 -0
- package/src/lib/server/crm-email-activity.d.ts +112 -0
- package/src/lib/server/crm-email-activity.js +143 -0
- package/src/lib/server/crm-email-activity.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +145 -0
- package/src/lib/server/crm-inbound-email.js +287 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-next-activity.d.ts +74 -0
- package/src/lib/server/crm-next-activity.js +194 -0
- package/src/lib/server/crm-next-activity.js.map +1 -0
- package/src/lib/server/crm-records.d.ts +144 -0
- package/src/lib/server/crm-records.js +218 -0
- package/src/lib/server/crm-records.js.map +1 -0
- package/src/lib/server/csp-aggregate.d.ts +62 -0
- package/src/lib/server/csp-aggregate.js +276 -0
- package/src/lib/server/csp-aggregate.js.map +1 -0
- package/src/lib/server/data-storage-gate.d.ts +66 -0
- package/src/lib/server/data-storage-gate.js +48 -0
- package/src/lib/server/data-storage-gate.js.map +1 -0
- package/src/lib/server/dataset-live-pages.d.ts +179 -0
- package/src/lib/server/dataset-live-pages.js +421 -0
- package/src/lib/server/dataset-live-pages.js.map +1 -0
- package/src/lib/server/dns-probe.d.ts +53 -0
- package/src/lib/server/dns-probe.js +184 -0
- package/src/lib/server/dns-probe.js.map +1 -0
- package/src/lib/server/document-id.d.ts +29 -0
- package/src/lib/server/document-id.js +100 -0
- package/src/lib/server/document-id.js.map +1 -0
- package/src/lib/server/domain-provider-vercel.d.ts +33 -0
- package/src/lib/server/domain-provider-vercel.js +298 -0
- package/src/lib/server/domain-provider-vercel.js.map +1 -0
- package/src/lib/server/domain-provider-webhook.d.ts +73 -0
- package/src/lib/server/domain-provider-webhook.js +223 -0
- package/src/lib/server/domain-provider-webhook.js.map +1 -0
- package/src/lib/server/domain-provider-wildcard.d.ts +54 -0
- package/src/lib/server/domain-provider-wildcard.js +163 -0
- package/src/lib/server/domain-provider-wildcard.js.map +1 -0
- package/src/lib/server/domain-provider.d.ts +197 -0
- package/src/lib/server/domain-provider.js +221 -0
- package/src/lib/server/domain-provider.js.map +1 -0
- package/src/lib/server/duplicate-activity.d.ts +54 -0
- package/src/lib/server/duplicate-activity.js +67 -0
- package/src/lib/server/duplicate-activity.js.map +1 -0
- package/src/lib/server/duplicate-resource.d.ts +55 -0
- package/src/lib/server/duplicate-resource.js +474 -0
- package/src/lib/server/duplicate-resource.js.map +1 -0
- package/src/lib/server/dynamic-list-materialize.d.ts +153 -0
- package/src/lib/server/dynamic-list-materialize.js +581 -0
- package/src/lib/server/dynamic-list-materialize.js.map +1 -0
- package/src/lib/server/edit-access-authz.d.ts +56 -0
- package/src/lib/server/edit-access-authz.js +107 -0
- package/src/lib/server/edit-access-authz.js.map +1 -0
- package/src/lib/server/edit-access-token.d.ts +72 -0
- package/src/lib/server/edit-access-token.js +116 -0
- package/src/lib/server/edit-access-token.js.map +1 -0
- package/src/lib/server/edit-hint-token.d.ts +83 -0
- package/src/lib/server/edit-hint-token.js +119 -0
- package/src/lib/server/edit-hint-token.js.map +1 -0
- package/src/lib/server/email-delivery-log.d.ts +509 -0
- package/src/lib/server/email-delivery-log.js +1043 -0
- package/src/lib/server/email-delivery-log.js.map +1 -0
- package/src/lib/server/email-flow-gate.d.ts +80 -0
- package/src/lib/server/email-flow-gate.js +153 -0
- package/src/lib/server/email-flow-gate.js.map +1 -0
- package/src/lib/server/email-marketing-gate.d.ts +333 -0
- package/src/lib/server/email-marketing-gate.js +639 -0
- package/src/lib/server/email-marketing-gate.js.map +1 -0
- package/src/lib/server/email-metering.d.ts +293 -0
- package/src/lib/server/email-metering.js +359 -0
- package/src/lib/server/email-metering.js.map +1 -0
- package/src/lib/server/email-revenue-attribution.d.ts +185 -0
- package/src/lib/server/email-revenue-attribution.js +300 -0
- package/src/lib/server/email-revenue-attribution.js.map +1 -0
- package/src/lib/server/email-send-rate.d.ts +232 -0
- package/src/lib/server/email-send-rate.js +417 -0
- package/src/lib/server/email-send-rate.js.map +1 -0
- package/src/lib/server/email-sender-reputation.d.ts +317 -0
- package/src/lib/server/email-sender-reputation.js +464 -0
- package/src/lib/server/email-sender-reputation.js.map +1 -0
- package/src/lib/server/email-suppression.d.ts +490 -0
- package/src/lib/server/email-suppression.js +648 -0
- package/src/lib/server/email-suppression.js.map +1 -0
- package/src/lib/server/email-topic-confirmation.d.ts +150 -0
- package/src/lib/server/email-topic-confirmation.js +272 -0
- package/src/lib/server/email-topic-confirmation.js.map +1 -0
- package/src/lib/server/email-unsubscribe-link.d.ts +174 -0
- package/src/lib/server/email-unsubscribe-link.js +236 -0
- package/src/lib/server/email-unsubscribe-link.js.map +1 -0
- package/src/lib/server/erase-person.d.ts +116 -0
- package/src/lib/server/erase-person.js +272 -0
- package/src/lib/server/erase-person.js.map +1 -0
- package/src/lib/server/erase.d.ts +458 -0
- package/src/lib/server/erase.js +1506 -0
- package/src/lib/server/erase.js.map +1 -0
- package/src/lib/server/firebase-admin.d.ts +71 -0
- package/src/lib/server/firebase-admin.js +343 -0
- package/src/lib/server/firebase-admin.js.map +1 -0
- package/src/lib/server/form-dataset-binding-token.d.ts +35 -0
- package/src/lib/server/form-dataset-binding-token.js +107 -0
- package/src/lib/server/form-dataset-binding-token.js.map +1 -0
- package/src/lib/server/free-workspace-cap.d.ts +201 -0
- package/src/lib/server/free-workspace-cap.js +404 -0
- package/src/lib/server/free-workspace-cap.js.map +1 -0
- package/src/lib/server/ga4-measurement-protocol.d.ts +256 -0
- package/src/lib/server/ga4-measurement-protocol.js +558 -0
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +47 -0
- package/src/lib/server/host-email-tokens.js +72 -0
- package/src/lib/server/host-email-tokens.js.map +1 -0
- package/src/lib/server/host-memberships.d.ts +54 -0
- package/src/lib/server/host-memberships.js +194 -0
- package/src/lib/server/host-memberships.js.map +1 -0
- package/src/lib/server/host-sending-domain.d.ts +292 -0
- package/src/lib/server/host-sending-domain.js +451 -0
- package/src/lib/server/host-sending-domain.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +150 -0
- package/src/lib/server/host-visitor-records.js +262 -0
- package/src/lib/server/host-visitor-records.js.map +1 -0
- package/src/lib/server/id-token-refusal.d.ts +28 -0
- package/src/lib/server/id-token-refusal.js +111 -0
- package/src/lib/server/id-token-refusal.js.map +1 -0
- package/src/lib/server/legal-acceptance.d.ts +214 -0
- package/src/lib/server/legal-acceptance.js +305 -0
- package/src/lib/server/legal-acceptance.js.map +1 -0
- package/src/lib/server/list-members.d.ts +211 -0
- package/src/lib/server/list-members.js +176 -0
- package/src/lib/server/list-members.js.map +1 -0
- package/src/lib/server/live-page-usage.d.ts +202 -0
- package/src/lib/server/live-page-usage.js +300 -0
- package/src/lib/server/live-page-usage.js.map +1 -0
- package/src/lib/server/lockdown.d.ts +313 -0
- package/src/lib/server/lockdown.js +643 -0
- package/src/lib/server/lockdown.js.map +1 -0
- package/src/lib/server/media-cdn-rate-limit.d.ts +188 -0
- package/src/lib/server/media-cdn-rate-limit.js +204 -0
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +267 -0
- package/src/lib/server/media-delivery.js +485 -0
- package/src/lib/server/media-delivery.js.map +1 -0
- package/src/lib/server/media-download-tokens.d.ts +161 -0
- package/src/lib/server/media-download-tokens.js +386 -0
- package/src/lib/server/media-download-tokens.js.map +1 -0
- package/src/lib/server/media-quarantine.d.ts +127 -0
- package/src/lib/server/media-quarantine.js +170 -0
- package/src/lib/server/media-quarantine.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +126 -0
- package/src/lib/server/media-signing.js +159 -0
- package/src/lib/server/media-signing.js.map +1 -0
- package/src/lib/server/media-storage-path.d.ts +96 -0
- package/src/lib/server/media-storage-path.js +111 -0
- package/src/lib/server/media-storage-path.js.map +1 -0
- package/src/lib/server/media-strong-digest.d.ts +85 -0
- package/src/lib/server/media-strong-digest.js +190 -0
- package/src/lib/server/media-strong-digest.js.map +1 -0
- package/src/lib/server/media-tombstone.d.ts +276 -0
- package/src/lib/server/media-tombstone.js +421 -0
- package/src/lib/server/media-tombstone.js.map +1 -0
- package/src/lib/server/media-variants.d.ts +309 -0
- package/src/lib/server/media-variants.js +428 -0
- package/src/lib/server/media-variants.js.map +1 -0
- package/src/lib/server/member-email-aliases.d.ts +180 -0
- package/src/lib/server/member-email-aliases.js +420 -0
- package/src/lib/server/member-email-aliases.js.map +1 -0
- package/src/lib/server/member-photo.d.ts +88 -0
- package/src/lib/server/member-photo.js +185 -0
- package/src/lib/server/member-photo.js.map +1 -0
- package/src/lib/server/membership-recover-throttle.d.ts +211 -0
- package/src/lib/server/membership-recover-throttle.js +231 -0
- package/src/lib/server/membership-recover-throttle.js.map +1 -0
- package/src/lib/server/notifications.d.ts +55 -0
- package/src/lib/server/notifications.js +134 -0
- package/src/lib/server/notifications.js.map +1 -0
- package/src/lib/server/org-billing.d.ts +97 -0
- package/src/lib/server/org-billing.js +236 -0
- package/src/lib/server/org-billing.js.map +1 -0
- package/src/lib/server/organizations.d.ts +837 -0
- package/src/lib/server/organizations.js +1814 -0
- package/src/lib/server/organizations.js.map +1 -0
- package/src/lib/server/password-reset-throttle.d.ts +84 -0
- package/src/lib/server/password-reset-throttle.js +102 -0
- package/src/lib/server/password-reset-throttle.js.map +1 -0
- package/src/lib/server/personal-data-export.d.ts +241 -0
- package/src/lib/server/personal-data-export.js +858 -0
- package/src/lib/server/personal-data-export.js.map +1 -0
- package/src/lib/server/platform-domain-names.d.ts +62 -0
- package/src/lib/server/platform-domain-names.js +197 -0
- package/src/lib/server/platform-domain-names.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +133 -0
- package/src/lib/server/platform-marketing-consent.js +209 -0
- package/src/lib/server/platform-marketing-consent.js.map +1 -0
- package/src/lib/server/rate-limit-store.d.ts +702 -0
- package/src/lib/server/rate-limit-store.js +1220 -0
- package/src/lib/server/rate-limit-store.js.map +1 -0
- package/src/lib/server/realm-plugins.d.ts +67 -0
- package/src/lib/server/realm-plugins.js +250 -0
- package/src/lib/server/realm-plugins.js.map +1 -0
- package/src/lib/server/recaptcha-allowlist.d.ts +90 -0
- package/src/lib/server/recaptcha-allowlist.js +409 -0
- package/src/lib/server/recaptcha-allowlist.js.map +1 -0
- package/src/lib/server/release-flags.d.ts +105 -0
- package/src/lib/server/release-flags.js +183 -0
- package/src/lib/server/release-flags.js.map +1 -0
- package/src/lib/server/replay-erasures.d.ts +126 -0
- package/src/lib/server/replay-erasures.js +217 -0
- package/src/lib/server/replay-erasures.js.map +1 -0
- package/src/lib/server/resolve-people.d.ts +66 -0
- package/src/lib/server/resolve-people.js +112 -0
- package/src/lib/server/resolve-people.js.map +1 -0
- package/src/lib/server/safe-equal.d.ts +45 -0
- package/src/lib/server/safe-equal.js +58 -0
- package/src/lib/server/safe-equal.js.map +1 -0
- package/src/lib/server/sending-domain-debt.d.ts +127 -0
- package/src/lib/server/sending-domain-debt.js +268 -0
- package/src/lib/server/sending-domain-debt.js.map +1 -0
- package/src/lib/server/sending-domain-recheck.d.ts +151 -0
- package/src/lib/server/sending-domain-recheck.js +253 -0
- package/src/lib/server/sending-domain-recheck.js.map +1 -0
- package/src/lib/server/sending-domains.d.ts +364 -0
- package/src/lib/server/sending-domains.js +683 -0
- package/src/lib/server/sending-domains.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +433 -0
- package/src/lib/server/serve-media-cdn.js +1370 -0
- package/src/lib/server/serve-media-cdn.js.map +1 -0
- package/src/lib/server/serve-plugin-fetch.d.ts +61 -0
- package/src/lib/server/serve-plugin-fetch.js +294 -0
- package/src/lib/server/serve-plugin-fetch.js.map +1 -0
- package/src/lib/server/sms-keywords.d.ts +63 -0
- package/src/lib/server/sms-keywords.js +146 -0
- package/src/lib/server/sms-keywords.js.map +1 -0
- package/src/lib/server/sso-break-glass-owners.d.ts +91 -0
- package/src/lib/server/sso-break-glass-owners.js +227 -0
- package/src/lib/server/sso-break-glass-owners.js.map +1 -0
- package/src/lib/server/sso-domain-policy.d.ts +140 -0
- package/src/lib/server/sso-domain-policy.js +239 -0
- package/src/lib/server/sso-domain-policy.js.map +1 -0
- package/src/lib/server/sso-drift-logic.d.ts +106 -0
- package/src/lib/server/sso-drift-logic.js +95 -0
- package/src/lib/server/sso-drift-logic.js.map +1 -0
- package/src/lib/server/sso-enforcement.d.ts +204 -0
- package/src/lib/server/sso-enforcement.js +309 -0
- package/src/lib/server/sso-enforcement.js.map +1 -0
- package/src/lib/server/sso-provisioning.d.ts +320 -0
- package/src/lib/server/sso-provisioning.js +715 -0
- package/src/lib/server/sso-provisioning.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +41 -0
- package/src/lib/server/staff-alert-email.js +63 -0
- package/src/lib/server/staff-alert-email.js.map +1 -0
- package/src/lib/server/stripe-account-mode.d.ts +156 -0
- package/src/lib/server/stripe-account-mode.js +162 -0
- package/src/lib/server/stripe-account-mode.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +131 -0
- package/src/lib/server/tenant-write-lockdown.js +176 -0
- package/src/lib/server/tenant-write-lockdown.js.map +1 -0
- package/src/lib/server/test-firestore.d.ts +45 -0
- package/src/lib/server/test-firestore.js +124 -0
- package/src/lib/server/test-firestore.js.map +1 -0
- package/src/lib/server/token-revocation.d.ts +140 -0
- package/src/lib/server/token-revocation.js +255 -0
- package/src/lib/server/token-revocation.js.map +1 -0
- package/src/lib/server/update-existing.d.ts +32 -0
- package/src/lib/server/update-existing.js +73 -0
- package/src/lib/server/update-existing.js.map +1 -0
- package/src/lib/server/upload-cors-reconcile.d.ts +118 -0
- package/src/lib/server/upload-cors-reconcile.js +234 -0
- package/src/lib/server/upload-cors-reconcile.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +262 -0
- package/src/lib/server/upsert-contact.js +546 -0
- package/src/lib/server/upsert-contact.js.map +1 -0
- package/src/lib/server/usage-invoice.d.ts +203 -0
- package/src/lib/server/usage-invoice.js +397 -0
- package/src/lib/server/usage-invoice.js.map +1 -0
- package/src/lib/server/user-profiles.d.ts +151 -0
- package/src/lib/server/user-profiles.js +364 -0
- package/src/lib/server/user-profiles.js.map +1 -0
- package/src/lib/server/vercel-drain-signature.d.ts +30 -0
- package/src/lib/server/vercel-drain-signature.js +61 -0
- package/src/lib/server/vercel-drain-signature.js.map +1 -0
- package/src/lib/server/vercel-log-drain.d.ts +259 -0
- package/src/lib/server/vercel-log-drain.js +479 -0
- package/src/lib/server/vercel-log-drain.js.map +1 -0
- package/src/lib/server/verify-email-cooldown.d.ts +102 -0
- package/src/lib/server/verify-email-cooldown.js +114 -0
- package/src/lib/server/verify-email-cooldown.js.map +1 -0
- package/src/lib/server/visitor-write-rate-limit.d.ts +87 -0
- package/src/lib/server/visitor-write-rate-limit.js +117 -0
- package/src/lib/server/visitor-write-rate-limit.js.map +1 -0
- package/src/lib/server/workspace-domains.d.ts +156 -0
- package/src/lib/server/workspace-domains.js +271 -0
- package/src/lib/server/workspace-domains.js.map +1 -0
- package/src/tenancy.d.ts +115 -0
- package/src/tenancy.js +142 -0
- package/src/tenancy.js.map +1 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The shared secret, or empty when the deployment has none.
|
|
19
|
+
*
|
|
20
|
+
* `CRON_SECRET` is the documented fallback, matching what the campaign sender
|
|
21
|
+
* and the unsubscribe handler already resolve. Read per call rather than at
|
|
22
|
+
* module load: these run in serverless handlers where the module may be
|
|
23
|
+
* evaluated during a build, long before the runtime env exists.
|
|
24
|
+
*/
|
|
25
|
+
export declare function unsubscribeLinkSecret(): string;
|
|
26
|
+
/**
|
|
27
|
+
* The signed subject for an unsubscribe link.
|
|
28
|
+
*
|
|
29
|
+
* ## Two signed forms, and why the campaign id is additive
|
|
30
|
+
*
|
|
31
|
+
* Every email already sitting in an inbox carries a two-part signature over
|
|
32
|
+
* `hostId:email`, and those links must go on working forever — an unsubscribe
|
|
33
|
+
* link that stops honoring itself is the one bug in this area with a legal
|
|
34
|
+
* edge on it. So the campaign is appended only when it is present, and the
|
|
35
|
+
* verifier chooses which form to check by whether the link carries a `cid`.
|
|
36
|
+
*
|
|
37
|
+
* SIGNED rather than passed alongside. An unsigned `cid` would be an
|
|
38
|
+
* attribution anybody holding one valid link could point at any campaign they
|
|
39
|
+
* liked — a small forgery, and a completely gratuitous one, since the
|
|
40
|
+
* campaign is already known at the moment the link is minted.
|
|
41
|
+
*/
|
|
42
|
+
export declare function unsubscribeSignatureSubject(hostId: string, email: string, campaignId?: string, topicId?: string): string;
|
|
43
|
+
/** HMAC for unsubscribe links; env-gated on the shared secret. */
|
|
44
|
+
export declare function unsubscribeSignature(hostId: string, email: string, secret: string, campaignId?: string, topicId?: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Whether a signature is this link's.
|
|
47
|
+
*
|
|
48
|
+
* Which form is checked is decided by the LINK, not by the signature: a link
|
|
49
|
+
* carrying no `cid` is checked against the two-part form and a link carrying
|
|
50
|
+
* one against the three-part form. There is no fallback between them, and
|
|
51
|
+
* that is what stops this being a downgrade — an attacker cannot take a
|
|
52
|
+
* three-part link, drop the `cid` and have it verify, because the two-part
|
|
53
|
+
* check over the same `hostId:email` produces a different digest. Nor can
|
|
54
|
+
* they bolt a `cid` onto a two-part link: the three-part check then fails.
|
|
55
|
+
*
|
|
56
|
+
* `timingSafeEqual` needs equal lengths, so the length is compared first — it
|
|
57
|
+
* is not a secret, both digests are fixed-width hex, and the call throws on a
|
|
58
|
+
* mismatch rather than returning false.
|
|
59
|
+
*/
|
|
60
|
+
export declare function unsubscribeSignatureMatches(args: {
|
|
61
|
+
hostId: string;
|
|
62
|
+
email: string;
|
|
63
|
+
campaignId?: string;
|
|
64
|
+
signature: string;
|
|
65
|
+
secret: string;
|
|
66
|
+
}): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The purpose component that distinguishes a CONFIRMATION subject.
|
|
69
|
+
*
|
|
70
|
+
* See {@link confirmSignatureSubject}. Exported because the plugin's verifier
|
|
71
|
+
* imports it rather than restating the literal.
|
|
72
|
+
*/
|
|
73
|
+
export declare const CONFIRM_SUBJECT_PREFIX = "confirm";
|
|
74
|
+
/**
|
|
75
|
+
* The signed subject for a double opt-in confirmation link.
|
|
76
|
+
*
|
|
77
|
+
* ## Why it is not one of the three forms above
|
|
78
|
+
*
|
|
79
|
+
* A confirmation names a host, an address and a topic, and it has NO
|
|
80
|
+
* campaign — nobody is unsubscribing from a message, they are joining a
|
|
81
|
+
* stream. The forms above refuse exactly that combination: a topic with no
|
|
82
|
+
* campaign would leave an empty middle component, and `host:email::t` is the
|
|
83
|
+
* same string as a three-part subject whose campaign id is `:t`.
|
|
84
|
+
*
|
|
85
|
+
* So it gets a leading PURPOSE component. That is a fourth form of the same
|
|
86
|
+
* scheme, not a second scheme: the digest, the secret and the comparison are
|
|
87
|
+
* unchanged.
|
|
88
|
+
*
|
|
89
|
+
* ## The one collision, and the guard for it
|
|
90
|
+
*
|
|
91
|
+
* `confirm:H:E:T` is byte-identical to the four-part unsubscribe subject
|
|
92
|
+
* `A:B:C:D` when the site's id is literally `confirm`, which would let one
|
|
93
|
+
* signature verify as both. Ids are not ours to constrain after the fact, so
|
|
94
|
+
* the subject is refused for that host rather than the collision being
|
|
95
|
+
* reasoned about: the cost is one unusable document id, and the alternative
|
|
96
|
+
* is a signature that means two things.
|
|
97
|
+
*
|
|
98
|
+
* @returns the subject, or `''` for a combination it cannot sign
|
|
99
|
+
* unambiguously. Empty rather than a partial subject, so
|
|
100
|
+
* {@link confirmSignature} yields no signature rather than one over
|
|
101
|
+
* the empty string — which would verify for every other caller that
|
|
102
|
+
* also produced one.
|
|
103
|
+
*/
|
|
104
|
+
export declare function confirmSignatureSubject(hostId: string, email: string, topicId: string): string;
|
|
105
|
+
/** HMAC for a confirmation link; empty for an unsignable subject. */
|
|
106
|
+
export declare function confirmSignature(hostId: string, email: string, topicId: string, secret: string): string;
|
|
107
|
+
/**
|
|
108
|
+
* The absolute confirmation URL for one address and one topic.
|
|
109
|
+
*
|
|
110
|
+
* Minted where the signup happens, because the message carrying it is sent
|
|
111
|
+
* from there — and that message is TRANSACTIONAL, not marketing: the person
|
|
112
|
+
* just asked for this, so asking them to confirm it is the transaction they
|
|
113
|
+
* started. It carries no unsubscribe header for the same reason a receipt
|
|
114
|
+
* does not.
|
|
115
|
+
*
|
|
116
|
+
* Empty when there is no secret or no origin, for the reason
|
|
117
|
+
* {@link buildUnsubscribeUrl} gives: a link pointing at nothing is worse than
|
|
118
|
+
* no link, because the recipient believes they have confirmed.
|
|
119
|
+
*/
|
|
120
|
+
export declare function buildConfirmUrl(input: {
|
|
121
|
+
siteBase: string;
|
|
122
|
+
hostId: string;
|
|
123
|
+
email: string;
|
|
124
|
+
topicId: string;
|
|
125
|
+
/** Defaults to {@link unsubscribeLinkSecret} — the same signing secret. */
|
|
126
|
+
secret?: string;
|
|
127
|
+
}): string;
|
|
128
|
+
/**
|
|
129
|
+
* The absolute unsubscribe URL for one recipient of one site's mail.
|
|
130
|
+
*
|
|
131
|
+
* @returns the URL, or empty string when there is no secret to sign with or
|
|
132
|
+
* no origin to resolve against. Empty rather than a half-built URL:
|
|
133
|
+
* a caller can tell it does not have a link, and a link pointing at
|
|
134
|
+
* nothing is worse than an absent header, because the recipient
|
|
135
|
+
* believes they have unsubscribed.
|
|
136
|
+
*/
|
|
137
|
+
export declare function buildUnsubscribeUrl(input: {
|
|
138
|
+
siteBase: string;
|
|
139
|
+
hostId: string;
|
|
140
|
+
email: string;
|
|
141
|
+
/** The campaign this link rides in, when there is one. */
|
|
142
|
+
campaignId?: string;
|
|
143
|
+
/** The topic the message belonged to, when it belonged to one. */
|
|
144
|
+
topicId?: string;
|
|
145
|
+
/**
|
|
146
|
+
* Which of the two URLs over this one signature to build.
|
|
147
|
+
*
|
|
148
|
+
* `one-click` is what the `List-Unsubscribe` header names: a mailbox
|
|
149
|
+
* provider POSTs it with no human present and expects the act to have
|
|
150
|
+
* happened when it reads the 200, so it points at the route whose POST
|
|
151
|
+
* writes immediately and must never point at a page somebody has to submit.
|
|
152
|
+
* `preferences` is the link a PERSON clicks in the footer, where the topic
|
|
153
|
+
* this message belonged to is one of the things they can stop instead of
|
|
154
|
+
* all of it. Defaults to `one-click`, which is what a sender with no topic
|
|
155
|
+
* — every non-campaign marketing path — wants.
|
|
156
|
+
*/
|
|
157
|
+
surface?: 'one-click' | 'preferences';
|
|
158
|
+
/** Defaults to {@link unsubscribeLinkSecret}. */
|
|
159
|
+
secret?: string;
|
|
160
|
+
}): string;
|
|
161
|
+
/**
|
|
162
|
+
* The signature of a payload for one purpose, base64url — or `''` when there
|
|
163
|
+
* is no secret, no purpose or no payload to sign, so a caller can tell it has
|
|
164
|
+
* no link rather than one that verifies for anybody.
|
|
165
|
+
*/
|
|
166
|
+
export declare function signedLinkSignature(purpose: string, payload: string, secret?: string): string;
|
|
167
|
+
/** Whether a signature is this payload's, for this purpose, in constant time. */
|
|
168
|
+
export declare function signedLinkSignatureMatches(args: {
|
|
169
|
+
purpose: string;
|
|
170
|
+
payload: string;
|
|
171
|
+
signature: string;
|
|
172
|
+
/** Defaults to {@link unsubscribeLinkSecret}. */
|
|
173
|
+
secret?: string;
|
|
174
|
+
}): boolean;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* THE UNSUBSCRIBE LINK — one signer, one URL shape, one verifier.
|
|
18
|
+
*
|
|
19
|
+
* A signed `/api/email/unsubscribe` URL was minted in exactly one place, the
|
|
20
|
+
* campaign sender, and verified in another, the unsubscribe handler. Two
|
|
21
|
+
* copies of an HMAC subject is a shape that only stays correct while nobody
|
|
22
|
+
* adds a third caller — and the marketing gate is the third caller, so the
|
|
23
|
+
* subject moved here and both existing sides now read it from one place.
|
|
24
|
+
*
|
|
25
|
+
* ## The address is lowercased in the subject AND in the URL
|
|
26
|
+
*
|
|
27
|
+
* This is the property that makes one derivation work rather than two that
|
|
28
|
+
* agree by luck. The campaign sender lowercases every address far upstream,
|
|
29
|
+
* so its links have always carried a lowercase address and its signature has
|
|
30
|
+
* always covered one; the handler's verifier does not lowercase, and did not
|
|
31
|
+
* need to. Marketing mail reaches addresses that no upstream step
|
|
32
|
+
* normalized — a checkout's `customerEmail`, a form payload's `email` — so a
|
|
33
|
+
* link minted over `Bob@Example.com` would sign the lowercase form and put
|
|
34
|
+
* the mixed-case form on the URL, and the verifier would answer 403 to a
|
|
35
|
+
* recipient pressing Unsubscribe.
|
|
36
|
+
*
|
|
37
|
+
* So {@link buildUnsubscribeUrl} writes the lowercased address into the query
|
|
38
|
+
* as well as into the subject, and the two agree by construction rather than
|
|
39
|
+
* by every caller remembering.
|
|
40
|
+
*/ import { createHmac, timingSafeEqual } from "crypto";
|
|
41
|
+
/**
|
|
42
|
+
* The shared secret, or empty when the deployment has none.
|
|
43
|
+
*
|
|
44
|
+
* `CRON_SECRET` is the documented fallback, matching what the campaign sender
|
|
45
|
+
* and the unsubscribe handler already resolve. Read per call rather than at
|
|
46
|
+
* module load: these run in serverless handlers where the module may be
|
|
47
|
+
* evaluated during a build, long before the runtime env exists.
|
|
48
|
+
*/ export function unsubscribeLinkSecret() {
|
|
49
|
+
return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || '';
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The signed subject for an unsubscribe link.
|
|
53
|
+
*
|
|
54
|
+
* ## Two signed forms, and why the campaign id is additive
|
|
55
|
+
*
|
|
56
|
+
* Every email already sitting in an inbox carries a two-part signature over
|
|
57
|
+
* `hostId:email`, and those links must go on working forever — an unsubscribe
|
|
58
|
+
* link that stops honoring itself is the one bug in this area with a legal
|
|
59
|
+
* edge on it. So the campaign is appended only when it is present, and the
|
|
60
|
+
* verifier chooses which form to check by whether the link carries a `cid`.
|
|
61
|
+
*
|
|
62
|
+
* SIGNED rather than passed alongside. An unsigned `cid` would be an
|
|
63
|
+
* attribution anybody holding one valid link could point at any campaign they
|
|
64
|
+
* liked — a small forgery, and a completely gratuitous one, since the
|
|
65
|
+
* campaign is already known at the moment the link is minted.
|
|
66
|
+
*/ export function unsubscribeSignatureSubject(hostId, email, campaignId, topicId) {
|
|
67
|
+
const address = String(email != null ? email : '').trim().toLowerCase();
|
|
68
|
+
/*
|
|
69
|
+
* A colon in either id is refused outright, because the forms are joined
|
|
70
|
+
* with one. A four-part `host:email:c:t` is byte-identical to a three-part
|
|
71
|
+
* subject whose campaign id is `c:t`, so one signature would verify two
|
|
72
|
+
* different parameter tuples and a topic link could be re-presented as a
|
|
73
|
+
* campaign link with the topic spliced in. It costs nothing real: ids come
|
|
74
|
+
* from `createResourceUid()`, whose alphabet has no colon.
|
|
75
|
+
*
|
|
76
|
+
* A topic with no campaign DROPS the topic rather than producing a
|
|
77
|
+
* four-part subject with an empty middle, which would make
|
|
78
|
+
* `host:email::t` and a campaign id of `:t` the same string. The sender
|
|
79
|
+
* always has a campaign, so this is a guard rather than a path.
|
|
80
|
+
*/ const campaign = String(campaignId != null ? campaignId : '');
|
|
81
|
+
const topic = String(topicId != null ? topicId : '');
|
|
82
|
+
if (campaign.includes(':') || topic.includes(':')) return '';
|
|
83
|
+
if (topic && campaign) return `${hostId}:${address}:${campaign}:${topic}`;
|
|
84
|
+
if (campaign) return `${hostId}:${address}:${campaign}`;
|
|
85
|
+
return `${hostId}:${address}`;
|
|
86
|
+
}
|
|
87
|
+
/** HMAC for unsubscribe links; env-gated on the shared secret. */ export function unsubscribeSignature(hostId, email, secret, campaignId, topicId) {
|
|
88
|
+
const subject = unsubscribeSignatureSubject(hostId, email, campaignId, topicId);
|
|
89
|
+
// An unsignable subject yields no signature rather than a signature over the
|
|
90
|
+
// empty string, which would verify for every caller that also passed one.
|
|
91
|
+
if (!subject) return '';
|
|
92
|
+
return createHmac('sha256', secret).update(subject).digest('hex');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether a signature is this link's.
|
|
96
|
+
*
|
|
97
|
+
* Which form is checked is decided by the LINK, not by the signature: a link
|
|
98
|
+
* carrying no `cid` is checked against the two-part form and a link carrying
|
|
99
|
+
* one against the three-part form. There is no fallback between them, and
|
|
100
|
+
* that is what stops this being a downgrade — an attacker cannot take a
|
|
101
|
+
* three-part link, drop the `cid` and have it verify, because the two-part
|
|
102
|
+
* check over the same `hostId:email` produces a different digest. Nor can
|
|
103
|
+
* they bolt a `cid` onto a two-part link: the three-part check then fails.
|
|
104
|
+
*
|
|
105
|
+
* `timingSafeEqual` needs equal lengths, so the length is compared first — it
|
|
106
|
+
* is not a secret, both digests are fixed-width hex, and the call throws on a
|
|
107
|
+
* mismatch rather than returning false.
|
|
108
|
+
*/ export function unsubscribeSignatureMatches(args) {
|
|
109
|
+
const expected = unsubscribeSignature(args.hostId, args.email, args.secret, args.campaignId || undefined);
|
|
110
|
+
return expected.length === args.signature.length && timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(args.signature)));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The purpose component that distinguishes a CONFIRMATION subject.
|
|
114
|
+
*
|
|
115
|
+
* See {@link confirmSignatureSubject}. Exported because the plugin's verifier
|
|
116
|
+
* imports it rather than restating the literal.
|
|
117
|
+
*/ export const CONFIRM_SUBJECT_PREFIX = 'confirm';
|
|
118
|
+
/**
|
|
119
|
+
* The signed subject for a double opt-in confirmation link.
|
|
120
|
+
*
|
|
121
|
+
* ## Why it is not one of the three forms above
|
|
122
|
+
*
|
|
123
|
+
* A confirmation names a host, an address and a topic, and it has NO
|
|
124
|
+
* campaign — nobody is unsubscribing from a message, they are joining a
|
|
125
|
+
* stream. The forms above refuse exactly that combination: a topic with no
|
|
126
|
+
* campaign would leave an empty middle component, and `host:email::t` is the
|
|
127
|
+
* same string as a three-part subject whose campaign id is `:t`.
|
|
128
|
+
*
|
|
129
|
+
* So it gets a leading PURPOSE component. That is a fourth form of the same
|
|
130
|
+
* scheme, not a second scheme: the digest, the secret and the comparison are
|
|
131
|
+
* unchanged.
|
|
132
|
+
*
|
|
133
|
+
* ## The one collision, and the guard for it
|
|
134
|
+
*
|
|
135
|
+
* `confirm:H:E:T` is byte-identical to the four-part unsubscribe subject
|
|
136
|
+
* `A:B:C:D` when the site's id is literally `confirm`, which would let one
|
|
137
|
+
* signature verify as both. Ids are not ours to constrain after the fact, so
|
|
138
|
+
* the subject is refused for that host rather than the collision being
|
|
139
|
+
* reasoned about: the cost is one unusable document id, and the alternative
|
|
140
|
+
* is a signature that means two things.
|
|
141
|
+
*
|
|
142
|
+
* @returns the subject, or `''` for a combination it cannot sign
|
|
143
|
+
* unambiguously. Empty rather than a partial subject, so
|
|
144
|
+
* {@link confirmSignature} yields no signature rather than one over
|
|
145
|
+
* the empty string — which would verify for every other caller that
|
|
146
|
+
* also produced one.
|
|
147
|
+
*/ export function confirmSignatureSubject(hostId, email, topicId) {
|
|
148
|
+
const address = String(email != null ? email : '').trim().toLowerCase();
|
|
149
|
+
const host = String(hostId != null ? hostId : '');
|
|
150
|
+
const topic = String(topicId != null ? topicId : '');
|
|
151
|
+
if (!host || !address || !topic) return '';
|
|
152
|
+
if (host === CONFIRM_SUBJECT_PREFIX) return '';
|
|
153
|
+
if (host.includes(':') || topic.includes(':')) return '';
|
|
154
|
+
return `${CONFIRM_SUBJECT_PREFIX}:${host}:${address}:${topic}`;
|
|
155
|
+
}
|
|
156
|
+
/** HMAC for a confirmation link; empty for an unsignable subject. */ export function confirmSignature(hostId, email, topicId, secret) {
|
|
157
|
+
const subject = confirmSignatureSubject(hostId, email, topicId);
|
|
158
|
+
if (!subject || !secret) return '';
|
|
159
|
+
return createHmac('sha256', secret).update(subject).digest('hex');
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The absolute confirmation URL for one address and one topic.
|
|
163
|
+
*
|
|
164
|
+
* Minted where the signup happens, because the message carrying it is sent
|
|
165
|
+
* from there — and that message is TRANSACTIONAL, not marketing: the person
|
|
166
|
+
* just asked for this, so asking them to confirm it is the transaction they
|
|
167
|
+
* started. It carries no unsubscribe header for the same reason a receipt
|
|
168
|
+
* does not.
|
|
169
|
+
*
|
|
170
|
+
* Empty when there is no secret or no origin, for the reason
|
|
171
|
+
* {@link buildUnsubscribeUrl} gives: a link pointing at nothing is worse than
|
|
172
|
+
* no link, because the recipient believes they have confirmed.
|
|
173
|
+
*/ export function buildConfirmUrl(input) {
|
|
174
|
+
var _input_secret, _input_siteBase, _input_email;
|
|
175
|
+
const secret = (_input_secret = input.secret) != null ? _input_secret : unsubscribeLinkSecret();
|
|
176
|
+
const siteBase = String((_input_siteBase = input.siteBase) != null ? _input_siteBase : '').replace(/\/+$/, '');
|
|
177
|
+
const address = String((_input_email = input.email) != null ? _input_email : '').trim().toLowerCase();
|
|
178
|
+
const signature = confirmSignature(input.hostId, address, input.topicId, secret);
|
|
179
|
+
if (!siteBase || !signature) return '';
|
|
180
|
+
return `${siteBase}/api/email/confirm` + `?hostId=${encodeURIComponent(input.hostId)}` + `&email=${encodeURIComponent(address)}` + `&tid=${encodeURIComponent(input.topicId)}` + `&sig=${signature}`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The absolute unsubscribe URL for one recipient of one site's mail.
|
|
184
|
+
*
|
|
185
|
+
* @returns the URL, or empty string when there is no secret to sign with or
|
|
186
|
+
* no origin to resolve against. Empty rather than a half-built URL:
|
|
187
|
+
* a caller can tell it does not have a link, and a link pointing at
|
|
188
|
+
* nothing is worse than an absent header, because the recipient
|
|
189
|
+
* believes they have unsubscribed.
|
|
190
|
+
*/ export function buildUnsubscribeUrl(input) {
|
|
191
|
+
var _input_secret, _input_siteBase, _input_email;
|
|
192
|
+
const secret = (_input_secret = input.secret) != null ? _input_secret : unsubscribeLinkSecret();
|
|
193
|
+
const siteBase = String((_input_siteBase = input.siteBase) != null ? _input_siteBase : '').replace(/\/+$/, '');
|
|
194
|
+
const address = String((_input_email = input.email) != null ? _input_email : '').trim().toLowerCase();
|
|
195
|
+
if (!secret || !siteBase || !input.hostId || !address) return '';
|
|
196
|
+
const signature = unsubscribeSignature(input.hostId, address, secret, input.campaignId, input.topicId);
|
|
197
|
+
if (!signature) return '';
|
|
198
|
+
const route = input.surface === 'preferences' ? '/api/email/preferences' : '/api/email/unsubscribe';
|
|
199
|
+
return `${siteBase}${route}` + `?hostId=${encodeURIComponent(input.hostId)}` + `&email=${encodeURIComponent(address)}` + `&sig=${signature}` + (input.campaignId ? `&cid=${encodeURIComponent(input.campaignId)}` : '') + (input.topicId ? `&tid=${encodeURIComponent(input.topicId)}` : '');
|
|
200
|
+
}
|
|
201
|
+
/*==========================================
|
|
202
|
+
* A SIGNED LINK OF A SENDER'S OWN (AGL-2981).
|
|
203
|
+
*
|
|
204
|
+
* The forms above are the platform's own unsubscribe and confirmation
|
|
205
|
+
* links, each a subject over a site and an address. A sender that mails
|
|
206
|
+
* from somewhere else — a plugin sending from a person's own mailbox, whose
|
|
207
|
+
* link carries its own facts — signs its link here instead, over a payload
|
|
208
|
+
* of its own and under a PURPOSE of its own.
|
|
209
|
+
*
|
|
210
|
+
* The purpose is not a prefix of the subject, which is how the confirmation
|
|
211
|
+
* form had to guard a collision by hand. It selects the KEY: every purpose's
|
|
212
|
+
* links are signed with an HMAC key derived from the shared secret and the
|
|
213
|
+
* purpose, so a signature minted for one purpose verifies for no other and
|
|
214
|
+
* for none of the platform's own forms above, whatever the payloads spell.
|
|
215
|
+
* The secret, the digest and the constant-time comparison are the same.
|
|
216
|
+
*==========================================*/ /** The key one purpose's links are signed with: derived from the secret, bound to the purpose. */ function signedLinkKey(purpose, secret) {
|
|
217
|
+
return createHmac('sha256', secret).update(`aglyn-signed-link:${purpose}`).digest();
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The signature of a payload for one purpose, base64url — or `''` when there
|
|
221
|
+
* is no secret, no purpose or no payload to sign, so a caller can tell it has
|
|
222
|
+
* no link rather than one that verifies for anybody.
|
|
223
|
+
*/ export function signedLinkSignature(purpose, payload, secret = unsubscribeLinkSecret()) {
|
|
224
|
+
const name = String(purpose != null ? purpose : '').trim();
|
|
225
|
+
const body = String(payload != null ? payload : '');
|
|
226
|
+
if (!secret || !name || !body) return '';
|
|
227
|
+
return createHmac('sha256', signedLinkKey(name, secret)).update(body).digest('base64url');
|
|
228
|
+
}
|
|
229
|
+
/** Whether a signature is this payload's, for this purpose, in constant time. */ export function signedLinkSignatureMatches(args) {
|
|
230
|
+
var _args_secret, _args_signature;
|
|
231
|
+
const expected = signedLinkSignature(args.purpose, args.payload, (_args_secret = args.secret) != null ? _args_secret : unsubscribeLinkSecret());
|
|
232
|
+
const presented = String((_args_signature = args.signature) != null ? _args_signature : '');
|
|
233
|
+
return expected.length > 0 && expected.length === presented.length && timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(presented)));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
//# sourceMappingURL=email-unsubscribe-link.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-unsubscribe-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE UNSUBSCRIBE LINK — one signer, one URL shape, one verifier.\n *\n * A signed `/api/email/unsubscribe` URL was minted in exactly one place, the\n * campaign sender, and verified in another, the unsubscribe handler. Two\n * copies of an HMAC subject is a shape that only stays correct while nobody\n * adds a third caller — and the marketing gate is the third caller, so the\n * subject moved here and both existing sides now read it from one place.\n *\n * ## The address is lowercased in the subject AND in the URL\n *\n * This is the property that makes one derivation work rather than two that\n * agree by luck. The campaign sender lowercases every address far upstream,\n * so its links have always carried a lowercase address and its signature has\n * always covered one; the handler's verifier does not lowercase, and did not\n * need to. Marketing mail reaches addresses that no upstream step\n * normalized — a checkout's `customerEmail`, a form payload's `email` — so a\n * link minted over `Bob@Example.com` would sign the lowercase form and put\n * the mixed-case form on the URL, and the verifier would answer 403 to a\n * recipient pressing Unsubscribe.\n *\n * So {@link buildUnsubscribeUrl} writes the lowercased address into the query\n * as well as into the subject, and the two agree by construction rather than\n * by every caller remembering.\n */\n\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * The shared secret, or empty when the deployment has none.\n *\n * `CRON_SECRET` is the documented fallback, matching what the campaign sender\n * and the unsubscribe handler already resolve. Read per call rather than at\n * module load: these run in serverless handlers where the module may be\n * evaluated during a build, long before the runtime env exists.\n */\nexport function unsubscribeLinkSecret(): string {\n return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || ''\n}\n\n/**\n * The signed subject for an unsubscribe link.\n *\n * ## Two signed forms, and why the campaign id is additive\n *\n * Every email already sitting in an inbox carries a two-part signature over\n * `hostId:email`, and those links must go on working forever — an unsubscribe\n * link that stops honoring itself is the one bug in this area with a legal\n * edge on it. So the campaign is appended only when it is present, and the\n * verifier chooses which form to check by whether the link carries a `cid`.\n *\n * SIGNED rather than passed alongside. An unsigned `cid` would be an\n * attribution anybody holding one valid link could point at any campaign they\n * liked — a small forgery, and a completely gratuitous one, since the\n * campaign is already known at the moment the link is minted.\n */\nexport function unsubscribeSignatureSubject(\n hostId: string,\n email: string,\n campaignId?: string,\n topicId?: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n /*\n * A colon in either id is refused outright, because the forms are joined\n * with one. A four-part `host:email:c:t` is byte-identical to a three-part\n * subject whose campaign id is `c:t`, so one signature would verify two\n * different parameter tuples and a topic link could be re-presented as a\n * campaign link with the topic spliced in. It costs nothing real: ids come\n * from `createResourceUid()`, whose alphabet has no colon.\n *\n * A topic with no campaign DROPS the topic rather than producing a\n * four-part subject with an empty middle, which would make\n * `host:email::t` and a campaign id of `:t` the same string. The sender\n * always has a campaign, so this is a guard rather than a path.\n */\n const campaign = String(campaignId ?? '')\n const topic = String(topicId ?? '')\n if (campaign.includes(':') || topic.includes(':')) return ''\n if (topic && campaign) return `${hostId}:${address}:${campaign}:${topic}`\n if (campaign) return `${hostId}:${address}:${campaign}`\n return `${hostId}:${address}`\n}\n\n/** HMAC for unsubscribe links; env-gated on the shared secret. */\nexport function unsubscribeSignature(\n hostId: string,\n email: string,\n secret: string,\n campaignId?: string,\n topicId?: string,\n): string {\n const subject = unsubscribeSignatureSubject(hostId, email, campaignId, topicId)\n // An unsignable subject yields no signature rather than a signature over the\n // empty string, which would verify for every caller that also passed one.\n if (!subject) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * Whether a signature is this link's.\n *\n * Which form is checked is decided by the LINK, not by the signature: a link\n * carrying no `cid` is checked against the two-part form and a link carrying\n * one against the three-part form. There is no fallback between them, and\n * that is what stops this being a downgrade — an attacker cannot take a\n * three-part link, drop the `cid` and have it verify, because the two-part\n * check over the same `hostId:email` produces a different digest. Nor can\n * they bolt a `cid` onto a two-part link: the three-part check then fails.\n *\n * `timingSafeEqual` needs equal lengths, so the length is compared first — it\n * is not a secret, both digests are fixed-width hex, and the call throws on a\n * mismatch rather than returning false.\n */\nexport function unsubscribeSignatureMatches(args: {\n hostId: string\n email: string\n campaignId?: string\n signature: string\n secret: string\n}): boolean {\n const expected = unsubscribeSignature(\n args.hostId,\n args.email,\n args.secret,\n args.campaignId || undefined,\n )\n return (\n expected.length === args.signature.length &&\n timingSafeEqual(\n new Uint8Array(Buffer.from(expected)),\n new Uint8Array(Buffer.from(args.signature)),\n )\n )\n}\n\n/**\n * The purpose component that distinguishes a CONFIRMATION subject.\n *\n * See {@link confirmSignatureSubject}. Exported because the plugin's verifier\n * imports it rather than restating the literal.\n */\nexport const CONFIRM_SUBJECT_PREFIX = 'confirm'\n\n/**\n * The signed subject for a double opt-in confirmation link.\n *\n * ## Why it is not one of the three forms above\n *\n * A confirmation names a host, an address and a topic, and it has NO\n * campaign — nobody is unsubscribing from a message, they are joining a\n * stream. The forms above refuse exactly that combination: a topic with no\n * campaign would leave an empty middle component, and `host:email::t` is the\n * same string as a three-part subject whose campaign id is `:t`.\n *\n * So it gets a leading PURPOSE component. That is a fourth form of the same\n * scheme, not a second scheme: the digest, the secret and the comparison are\n * unchanged.\n *\n * ## The one collision, and the guard for it\n *\n * `confirm:H:E:T` is byte-identical to the four-part unsubscribe subject\n * `A:B:C:D` when the site's id is literally `confirm`, which would let one\n * signature verify as both. Ids are not ours to constrain after the fact, so\n * the subject is refused for that host rather than the collision being\n * reasoned about: the cost is one unusable document id, and the alternative\n * is a signature that means two things.\n *\n * @returns the subject, or `''` for a combination it cannot sign\n * unambiguously. Empty rather than a partial subject, so\n * {@link confirmSignature} yields no signature rather than one over\n * the empty string — which would verify for every other caller that\n * also produced one.\n */\nexport function confirmSignatureSubject(\n hostId: string,\n email: string,\n topicId: string,\n): string {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n const host = String(hostId ?? '')\n const topic = String(topicId ?? '')\n if (!host || !address || !topic) return ''\n if (host === CONFIRM_SUBJECT_PREFIX) return ''\n if (host.includes(':') || topic.includes(':')) return ''\n return `${CONFIRM_SUBJECT_PREFIX}:${host}:${address}:${topic}`\n}\n\n/** HMAC for a confirmation link; empty for an unsignable subject. */\nexport function confirmSignature(\n hostId: string,\n email: string,\n topicId: string,\n secret: string,\n): string {\n const subject = confirmSignatureSubject(hostId, email, topicId)\n if (!subject || !secret) return ''\n return createHmac('sha256', secret).update(subject).digest('hex')\n}\n\n/**\n * The absolute confirmation URL for one address and one topic.\n *\n * Minted where the signup happens, because the message carrying it is sent\n * from there — and that message is TRANSACTIONAL, not marketing: the person\n * just asked for this, so asking them to confirm it is the transaction they\n * started. It carries no unsubscribe header for the same reason a receipt\n * does not.\n *\n * Empty when there is no secret or no origin, for the reason\n * {@link buildUnsubscribeUrl} gives: a link pointing at nothing is worse than\n * no link, because the recipient believes they have confirmed.\n */\nexport function buildConfirmUrl(input: {\n siteBase: string\n hostId: string\n email: string\n topicId: string\n /** Defaults to {@link unsubscribeLinkSecret} — the same signing secret. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n const signature = confirmSignature(\n input.hostId,\n address,\n input.topicId,\n secret,\n )\n if (!siteBase || !signature) return ''\n return (\n `${siteBase}/api/email/confirm` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&tid=${encodeURIComponent(input.topicId)}` +\n `&sig=${signature}`\n )\n}\n\n/**\n * The absolute unsubscribe URL for one recipient of one site's mail.\n *\n * @returns the URL, or empty string when there is no secret to sign with or\n * no origin to resolve against. Empty rather than a half-built URL:\n * a caller can tell it does not have a link, and a link pointing at\n * nothing is worse than an absent header, because the recipient\n * believes they have unsubscribed.\n */\nexport function buildUnsubscribeUrl(input: {\n siteBase: string\n hostId: string\n email: string\n /** The campaign this link rides in, when there is one. */\n campaignId?: string\n /** The topic the message belonged to, when it belonged to one. */\n topicId?: string\n /**\n * Which of the two URLs over this one signature to build.\n *\n * `one-click` is what the `List-Unsubscribe` header names: a mailbox\n * provider POSTs it with no human present and expects the act to have\n * happened when it reads the 200, so it points at the route whose POST\n * writes immediately and must never point at a page somebody has to submit.\n * `preferences` is the link a PERSON clicks in the footer, where the topic\n * this message belonged to is one of the things they can stop instead of\n * all of it. Defaults to `one-click`, which is what a sender with no topic\n * — every non-campaign marketing path — wants.\n */\n surface?: 'one-click' | 'preferences'\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): string {\n const secret = input.secret ?? unsubscribeLinkSecret()\n const siteBase = String(input.siteBase ?? '').replace(/\\/+$/, '')\n const address = String(input.email ?? '')\n .trim()\n .toLowerCase()\n if (!secret || !siteBase || !input.hostId || !address) return ''\n const signature = unsubscribeSignature(\n input.hostId,\n address,\n secret,\n input.campaignId,\n input.topicId,\n )\n if (!signature) return ''\n const route =\n input.surface === 'preferences'\n ? '/api/email/preferences'\n : '/api/email/unsubscribe'\n return (\n `${siteBase}${route}` +\n `?hostId=${encodeURIComponent(input.hostId)}` +\n `&email=${encodeURIComponent(address)}` +\n `&sig=${signature}` +\n (input.campaignId ? `&cid=${encodeURIComponent(input.campaignId)}` : '') +\n (input.topicId ? `&tid=${encodeURIComponent(input.topicId)}` : '')\n )\n}\n\n/*==========================================\n * A SIGNED LINK OF A SENDER'S OWN (AGL-2981).\n *\n * The forms above are the platform's own unsubscribe and confirmation\n * links, each a subject over a site and an address. A sender that mails\n * from somewhere else — a plugin sending from a person's own mailbox, whose\n * link carries its own facts — signs its link here instead, over a payload\n * of its own and under a PURPOSE of its own.\n *\n * The purpose is not a prefix of the subject, which is how the confirmation\n * form had to guard a collision by hand. It selects the KEY: every purpose's\n * links are signed with an HMAC key derived from the shared secret and the\n * purpose, so a signature minted for one purpose verifies for no other and\n * for none of the platform's own forms above, whatever the payloads spell.\n * The secret, the digest and the constant-time comparison are the same.\n *==========================================*/\n\n/** The key one purpose's links are signed with: derived from the secret, bound to the purpose. */\nfunction signedLinkKey(purpose: string, secret: string): Buffer {\n return createHmac('sha256', secret).update(`aglyn-signed-link:${purpose}`).digest()\n}\n\n/**\n * The signature of a payload for one purpose, base64url — or `''` when there\n * is no secret, no purpose or no payload to sign, so a caller can tell it has\n * no link rather than one that verifies for anybody.\n */\nexport function signedLinkSignature(\n purpose: string,\n payload: string,\n secret: string = unsubscribeLinkSecret(),\n): string {\n const name = String(purpose ?? '').trim()\n const body = String(payload ?? '')\n if (!secret || !name || !body) return ''\n return createHmac('sha256', signedLinkKey(name, secret)).update(body).digest('base64url')\n}\n\n/** Whether a signature is this payload's, for this purpose, in constant time. */\nexport function signedLinkSignatureMatches(args: {\n purpose: string\n payload: string\n signature: string\n /** Defaults to {@link unsubscribeLinkSecret}. */\n secret?: string\n}): boolean {\n const expected = signedLinkSignature(args.purpose, args.payload, args.secret ?? unsubscribeLinkSecret())\n const presented = String(args.signature ?? '')\n return (\n expected.length > 0 &&\n expected.length === presented.length &&\n timingSafeEqual(new Uint8Array(Buffer.from(expected)), new Uint8Array(Buffer.from(presented)))\n )\n}\n"],"names":["createHmac","timingSafeEqual","unsubscribeLinkSecret","process","env","EMAIL_UNSUBSCRIBE_SECRET","CRON_SECRET","unsubscribeSignatureSubject","hostId","email","campaignId","topicId","address","String","trim","toLowerCase","campaign","topic","includes","unsubscribeSignature","secret","subject","update","digest","unsubscribeSignatureMatches","args","expected","undefined","length","signature","Uint8Array","Buffer","from","CONFIRM_SUBJECT_PREFIX","confirmSignatureSubject","host","confirmSignature","buildConfirmUrl","input","siteBase","replace","encodeURIComponent","buildUnsubscribeUrl","route","surface","signedLinkKey","purpose","signedLinkSignature","payload","name","body","signedLinkSignatureMatches","presented"],"mappings":"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"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginPersonErasureReport } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure';
|
|
18
|
+
export interface ErasePersonOptions {
|
|
19
|
+
orgId: string;
|
|
20
|
+
/** Any spelling; normalized before anything is looked up. */
|
|
21
|
+
email: unknown;
|
|
22
|
+
/** Injectable for tests; defaults to the admin app's Firestore. */
|
|
23
|
+
firestore?: any;
|
|
24
|
+
/** Injectable for tests; defaults to `Date.now()`. */
|
|
25
|
+
now?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Counts, never identities: this is what the request document and the
|
|
29
|
+
* audit row record, and both are read by people who must not learn the
|
|
30
|
+
* address from them.
|
|
31
|
+
*/
|
|
32
|
+
export interface ErasePersonCounts {
|
|
33
|
+
/** Sites in the workspace the sweep walked. */
|
|
34
|
+
hosts: number;
|
|
35
|
+
/** Suppression rows written, one per site. */
|
|
36
|
+
hostsSuppressed: number;
|
|
37
|
+
/** Contact documents deleted — the row every site shared. */
|
|
38
|
+
contacts: number;
|
|
39
|
+
/** Companies whose contact count moved down. */
|
|
40
|
+
companyLinks: number;
|
|
41
|
+
/** Deals whose `contactId` was removed. */
|
|
42
|
+
deals: number;
|
|
43
|
+
tasks: number;
|
|
44
|
+
activities: number;
|
|
45
|
+
/** `hosts/{hostId}/leads/{personKey}` rows deleted. */
|
|
46
|
+
leads: number;
|
|
47
|
+
/** Audience-list member rows deleted, across every list. */
|
|
48
|
+
listMemberships: number;
|
|
49
|
+
/** Orders with the buyer's identity removed; the record stays. */
|
|
50
|
+
orders: number;
|
|
51
|
+
/** Bookings with the person's identity removed; the record stays. */
|
|
52
|
+
bookings: number;
|
|
53
|
+
/** Delivery-log messages deleted under the address. */
|
|
54
|
+
emailDeliveries: number;
|
|
55
|
+
/**
|
|
56
|
+
* Each plugin's share (AGL-2981), by plugin id: its eraser's own counts,
|
|
57
|
+
* or `null` for an eraser that failed — its data may remain.
|
|
58
|
+
*/
|
|
59
|
+
plugins: Record<string, PluginPersonErasureReport | null>;
|
|
60
|
+
}
|
|
61
|
+
export type ErasePersonResult = ({
|
|
62
|
+
ok: true;
|
|
63
|
+
} & ErasePersonCounts) | {
|
|
64
|
+
ok: false;
|
|
65
|
+
skippedReason: 'invalid-email';
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Remove one person from one workspace (AGL-2623).
|
|
69
|
+
*
|
|
70
|
+
* `planContactDetach` is deliberately not consulted: the CRM's delete is a
|
|
71
|
+
* detach that leaves the row for the other holders, and this is the act
|
|
72
|
+
* that must not. The contact document goes whole — every site's facet,
|
|
73
|
+
* every consent entry, every attribution — and with it everything the
|
|
74
|
+
* workspace keeps beside the person.
|
|
75
|
+
*
|
|
76
|
+
* ## Order
|
|
77
|
+
*
|
|
78
|
+
* 1. **Suppress first.** One row per site on the per-site suppression
|
|
79
|
+
* list, before any delete: a form filled in while the sweep runs must
|
|
80
|
+
* already find the door closed, or the sweep deletes a row that the
|
|
81
|
+
* capture re-creates a moment later.
|
|
82
|
+
* 2. The plugins' share: every eraser registered on
|
|
83
|
+
* `plugin-person-erasure`, handed the address, its key and the ids of
|
|
84
|
+
* the contacts about to go, while those documents still exist.
|
|
85
|
+
* 3. The contact and its satellites — company counts, deals unlinked,
|
|
86
|
+
* tasks and activities deleted — by the contact's id, then the
|
|
87
|
+
* document itself.
|
|
88
|
+
* 4. Leads, list memberships, orders and bookings by the address, on
|
|
89
|
+
* every site of the workspace.
|
|
90
|
+
* 5. The delivery log, last: it is filed under the address alone, and the
|
|
91
|
+
* tombstone it leaves is what keeps a later import from refilling it.
|
|
92
|
+
*
|
|
93
|
+
* ## What is anonymized rather than deleted
|
|
94
|
+
*
|
|
95
|
+
* An order is the merchant's record of a sale and a booking of an
|
|
96
|
+
* appointment; the amounts, the line items and the tax are theirs to keep
|
|
97
|
+
* and the law expects them kept. The person is taken OFF those records —
|
|
98
|
+
* name, email, phone, addresses — and a stamp says when. A deal is the
|
|
99
|
+
* team's own pipeline record and is unlinked. Everything else that names
|
|
100
|
+
* the person is about the person and is deleted.
|
|
101
|
+
*
|
|
102
|
+
* ## What is not reached, and why
|
|
103
|
+
*
|
|
104
|
+
* A form submission keeps the address inside `fields`, under whatever the
|
|
105
|
+
* form called it — there is no key to query by and a scan of every
|
|
106
|
+
* submission on every site is unbounded. A site member's login is their
|
|
107
|
+
* own account. A subscription carries the address it bills, and a live one
|
|
108
|
+
* cannot be anonymized without breaking its receipts. Each is named to the
|
|
109
|
+
* admin by the dialog so they can finish by hand.
|
|
110
|
+
*
|
|
111
|
+
* Every sweep is best-effort against the others: a failure in one is
|
|
112
|
+
* logged and counted as zero, and the request's counts say what happened.
|
|
113
|
+
* The caller decides whether zero contacts on a person it could see is a
|
|
114
|
+
* failure to retry.
|
|
115
|
+
*/
|
|
116
|
+
export declare function erasePerson(options: ErasePersonOptions): Promise<ErasePersonResult>;
|