@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,231 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { consumeRateLimit } from "./rate-limit-store.js";
|
|
18
|
+
/**
|
|
19
|
+
* Throttle for the UNAUTHENTICATED site-member password-recovery endpoint
|
|
20
|
+
* (AGL-1966).
|
|
21
|
+
*
|
|
22
|
+
* ## The shape being closed
|
|
23
|
+
*
|
|
24
|
+
* Two unauthenticated tenant plugin endpoints compose into a mail relay on
|
|
25
|
+
* `aglyn.com`'s sending domain: `membership/register` puts an arbitrary
|
|
26
|
+
* address into `hosts/{hostId}/siteMembers` (member accounts are unlimited on
|
|
27
|
+
* every plan, AGL-889, so nothing there refuses), and `membership/recover`
|
|
28
|
+
* mails a reset link to any address that has such a row. Register N addresses,
|
|
29
|
+
* call recover N times, and every one is a real message we sent, carrying an
|
|
30
|
+
* attacker-chosen site name in the subject.
|
|
31
|
+
*
|
|
32
|
+
* The only thing in the way was the dispatcher's `visitorWriteRateLimitRefusal`
|
|
33
|
+
* — 120 writes/60s per (site, IP), a bucket register and recover SHARE. That
|
|
34
|
+
* ceiling is sized for a shopper editing cart quantities; measured as mail it
|
|
35
|
+
* is ~86k messages a day from one address.
|
|
36
|
+
*
|
|
37
|
+
* The damage is not the Resend bill. It is the sending reputation of
|
|
38
|
+
* `aglyn.com`, which is shared by every customer's transactional mail, and
|
|
39
|
+
* which cannot be bought back after the fact.
|
|
40
|
+
*
|
|
41
|
+
* ## Why this is a separate module from `password-reset-throttle.ts`
|
|
42
|
+
*
|
|
43
|
+
* That one guards ADMIN-initiated resets, and says so at length: every caller
|
|
44
|
+
* is already authenticated as staff, an org admin or a site admin, "so this is
|
|
45
|
+
* **not** the enumeration defense that guards the public `membership/recover`
|
|
46
|
+
* endpoint — nobody reaches here without already knowing the account exists."
|
|
47
|
+
* This is that defense. The caps differ (there is no actor to key on here, and
|
|
48
|
+
* an IP is not an actor), and the refusal contract differs — see below, it is
|
|
49
|
+
* the part that is easy to get wrong.
|
|
50
|
+
*
|
|
51
|
+
* It lives at this layer, beside its sibling, for the reason that one gives:
|
|
52
|
+
* the commerce plugin cannot import the console's API helpers, and a number
|
|
53
|
+
* duplicated in two places drifts.
|
|
54
|
+
*
|
|
55
|
+
* ## Two kinds of cap, because they have two different refusals
|
|
56
|
+
*
|
|
57
|
+
* This is the load-bearing distinction in the module, and it exists entirely
|
|
58
|
+
* to keep the endpoint from becoming an existence oracle.
|
|
59
|
+
*
|
|
60
|
+
* **Attempt caps** ({@link consumeMembershipRecoverAttempt}) — per recipient
|
|
61
|
+
* and per IP. Consumed BEFORE the member lookup, so a request for an address
|
|
62
|
+
* that is a member and a request for one that is not spend exactly the same
|
|
63
|
+
* budget and get exactly the same answer. Their refusal may therefore be a
|
|
64
|
+
* visible `429`: it says nothing about whether the address exists, because the
|
|
65
|
+
* counter that produced it counts both alike. Consuming these *after* the
|
|
66
|
+
* lookup, or only on the send path, would be the bug — the 429 would then
|
|
67
|
+
* appear only for real members and the endpoint's whole silent-success
|
|
68
|
+
* contract would be undone by its own rate limiter.
|
|
69
|
+
*
|
|
70
|
+
* **The send cap** ({@link consumeMembershipRecoverSend}) — per host, per day.
|
|
71
|
+
* Consumed only when a message is actually about to go out, because it is a
|
|
72
|
+
* ceiling on OUTBOUND MAIL rather than on requests. That means it is only ever
|
|
73
|
+
* reached on the member-exists branch, so its refusal must NOT be visible: the
|
|
74
|
+
* caller takes the same silent `200 {ok:true}` exit as an unknown address. The
|
|
75
|
+
* cost of that is a real member who gets no mail and no error during an
|
|
76
|
+
* attack on their site — collateral that is accepted deliberately, and sized
|
|
77
|
+
* for below.
|
|
78
|
+
*
|
|
79
|
+
* ## Backed by the durable limiter, not a `Map`
|
|
80
|
+
*
|
|
81
|
+
* `membership-recover.ts` previously carried a per-instance `Map` keyed on
|
|
82
|
+
* IP+email. On Vercel that is close to no limit at all — it resets on every
|
|
83
|
+
* cold start and each concurrent instance keeps its own, so the effective cap
|
|
84
|
+
* is `limit × instances` and an attacker widens it by going wider (AGL-794).
|
|
85
|
+
* A mail send is exactly the "consequence, not volume" case that
|
|
86
|
+
* `RATE_LIMITING.md` says to spend a Firestore transaction on.
|
|
87
|
+
*
|
|
88
|
+
* ### Contention fails CLOSED here, and that is correct (AGL-2404)
|
|
89
|
+
*
|
|
90
|
+
* `consumeRateLimit` refuses with `contended: true` when one key is hot, and
|
|
91
|
+
* only degrades when the store itself is unreachable. Both postures land well
|
|
92
|
+
* on this surface. The attempt keys are per recipient and per IP, so a
|
|
93
|
+
* legitimate visitor essentially never races themselves — concurrency on one
|
|
94
|
+
* of these keys is the attack. And the send key is per host per day, which is
|
|
95
|
+
* one document: a busy site's genuine recover traffic is a handful of requests
|
|
96
|
+
* an hour, so contention there is likewise a flood rather than a Tuesday.
|
|
97
|
+
*/ /**
|
|
98
|
+
* Reset mails per recipient address, per hour, across every site.
|
|
99
|
+
*
|
|
100
|
+
* Matches `RESET_SENDS_PER_RECIPIENT` deliberately: a reset link is valid for
|
|
101
|
+
* an hour anyway, so a second and third are only useful if the first was lost,
|
|
102
|
+
* and one number for "how many resets may one mailbox receive in an hour" is
|
|
103
|
+
* easier to defend than two.
|
|
104
|
+
*
|
|
105
|
+
* NOT keyed by host. A per-(host, address) cap would let an attacker who
|
|
106
|
+
* controls several sites — which a free org can create — multiply one
|
|
107
|
+
* mailbox's mail by the number of sites, and the person being carpet-bombed
|
|
108
|
+
* does not care which of our customers' sites the mail claims to come from.
|
|
109
|
+
*/ export const RECOVER_SENDS_PER_RECIPIENT = 3;
|
|
110
|
+
/**
|
|
111
|
+
* Recovery attempts per client IP, per hour, across every site and address.
|
|
112
|
+
*
|
|
113
|
+
* Sized against the human behaviour, not the attack: a person who has
|
|
114
|
+
* forgotten a password tries it on one or two sites, mistypes the address
|
|
115
|
+
* once, and stops. Ten is already several times that. A household or office
|
|
116
|
+
* NAT sharing one address would need ten genuine forgotten passwords in one
|
|
117
|
+
* hour to notice this.
|
|
118
|
+
*
|
|
119
|
+
* This is the cap that actually prices enumeration. The per-recipient cap
|
|
120
|
+
* alone does nothing against the register-then-recover composition, because
|
|
121
|
+
* every address in that attack is fresh and none of them has a count.
|
|
122
|
+
*/ export const RECOVER_ATTEMPTS_PER_IP = 10;
|
|
123
|
+
/** Both attempt caps use a one-hour fixed window, like their sibling. */ export const RECOVER_ATTEMPT_WINDOW_MS = 60 * 60 * 1000;
|
|
124
|
+
/**
|
|
125
|
+
* Recovery mails one SITE may emit per day.
|
|
126
|
+
*
|
|
127
|
+
* An abuse ceiling, not a usage limit, and it is set where it is because of
|
|
128
|
+
* the collateral described above: it must sit far enough above any real site's
|
|
129
|
+
* genuine volume that reaching it is evidence of an attack. A storefront with
|
|
130
|
+
* ten thousand members sees single-digit resets a day; two hundred is more
|
|
131
|
+
* than an order of magnitude of headroom.
|
|
132
|
+
*
|
|
133
|
+
* What it buys, given the dispatcher's 120/min already upstream: it converts
|
|
134
|
+
* "~86k messages a day per site from one IP" into 200, and — because it is
|
|
135
|
+
* keyed on the site rather than on the caller — it holds no matter how many
|
|
136
|
+
* IPs the caller brings. That is the property the compound visitor-write key
|
|
137
|
+
* explicitly cannot have, and the reason this cap is per host alone.
|
|
138
|
+
*/ export const RECOVER_SENDS_PER_HOST_PER_DAY = 200;
|
|
139
|
+
/** Fixed 24h window for the send cap. */ export const RECOVER_SEND_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
140
|
+
/**
|
|
141
|
+
* How new a member row may be and still be worth a reset mail.
|
|
142
|
+
*
|
|
143
|
+
* The register-then-recover composition is the whole attack, and no real
|
|
144
|
+
* person needs a password reset in the first ten minutes of having chosen the
|
|
145
|
+
* password. This is the cheapest control on the list and the only one that
|
|
146
|
+
* refuses the composition itself rather than rationing it.
|
|
147
|
+
*
|
|
148
|
+
* Enforced by the handler (it needs the member document); the constant lives
|
|
149
|
+
* here so the policy is in one place.
|
|
150
|
+
*/ export const RECOVER_MIN_MEMBER_AGE_MS = 10 * 60 * 1000;
|
|
151
|
+
function refusal(limited, resetMs, at, rate) {
|
|
152
|
+
return {
|
|
153
|
+
allowed: false,
|
|
154
|
+
retryAfterSeconds: Math.max(1, Math.ceil((resetMs - at) / 1000)),
|
|
155
|
+
limited,
|
|
156
|
+
degraded: rate.degraded,
|
|
157
|
+
contended: rate.contended
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Counts one recovery ATTEMPT against the per-recipient and per-IP caps.
|
|
162
|
+
*
|
|
163
|
+
* Call before deciding whether the address is a member — see the module note
|
|
164
|
+
* on why the ordering is the control and not an implementation detail.
|
|
165
|
+
*
|
|
166
|
+
* The recipient cap is checked first and short-circuits, matching
|
|
167
|
+
* `consumePasswordResetSend`: one address being hammered must not also spend
|
|
168
|
+
* the IP budget that a person sharing that NAT is relying on.
|
|
169
|
+
*
|
|
170
|
+
* The email is passed through to the key in the clear on purpose —
|
|
171
|
+
* `consumeRateLimit` hashes every key into the document id precisely so that
|
|
172
|
+
* caller-held identifiers do not sit in plaintext ids or index exports.
|
|
173
|
+
*/ export async function consumeMembershipRecoverAttempt(options) {
|
|
174
|
+
const { email, ip, now, firestore } = options;
|
|
175
|
+
const shared = _extends({
|
|
176
|
+
windowMs: RECOVER_ATTEMPT_WINDOW_MS
|
|
177
|
+
}, now === undefined ? {} : {
|
|
178
|
+
now
|
|
179
|
+
}, firestore === undefined ? {} : {
|
|
180
|
+
firestore
|
|
181
|
+
});
|
|
182
|
+
const at = now != null ? now : Date.now();
|
|
183
|
+
const recipient = await consumeRateLimit(`recover:to:${email}`, _extends({}, shared, {
|
|
184
|
+
limit: RECOVER_SENDS_PER_RECIPIENT
|
|
185
|
+
}));
|
|
186
|
+
if (!recipient.allowed) {
|
|
187
|
+
return refusal('recipient', recipient.resetMs, at, recipient);
|
|
188
|
+
}
|
|
189
|
+
const source = ip ? await consumeRateLimit(`recover:ip:${ip}`, _extends({}, shared, {
|
|
190
|
+
limit: RECOVER_ATTEMPTS_PER_IP
|
|
191
|
+
})) : null;
|
|
192
|
+
if (source && !source.allowed) {
|
|
193
|
+
return refusal('ip', source.resetMs, at, source);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
allowed: true,
|
|
197
|
+
retryAfterSeconds: 0,
|
|
198
|
+
limited: null,
|
|
199
|
+
degraded: recipient.degraded || Boolean(source == null ? void 0 : source.degraded),
|
|
200
|
+
contended: false
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Counts one recovery mail against the per-site daily ceiling.
|
|
205
|
+
*
|
|
206
|
+
* Call immediately before sending, and treat a refusal as the silent-success
|
|
207
|
+
* exit — never as a visible error. A visible one would fire only for addresses
|
|
208
|
+
* that really are members, which is the oracle the endpoint exists to avoid.
|
|
209
|
+
*/ export async function consumeMembershipRecoverSend(options) {
|
|
210
|
+
const { hostId, now, firestore } = options;
|
|
211
|
+
const at = now != null ? now : Date.now();
|
|
212
|
+
const rate = await consumeRateLimit(`recover:host:${hostId}`, _extends({
|
|
213
|
+
limit: RECOVER_SENDS_PER_HOST_PER_DAY,
|
|
214
|
+
windowMs: RECOVER_SEND_WINDOW_MS
|
|
215
|
+
}, now === undefined ? {} : {
|
|
216
|
+
now
|
|
217
|
+
}, firestore === undefined ? {} : {
|
|
218
|
+
firestore
|
|
219
|
+
}));
|
|
220
|
+
if (!rate.allowed) return refusal('host', rate.resetMs, at, rate);
|
|
221
|
+
return {
|
|
222
|
+
allowed: true,
|
|
223
|
+
retryAfterSeconds: 0,
|
|
224
|
+
limited: null,
|
|
225
|
+
degraded: rate.degraded,
|
|
226
|
+
contended: false
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export default consumeMembershipRecoverAttempt;
|
|
230
|
+
|
|
231
|
+
//# sourceMappingURL=membership-recover-throttle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/membership-recover-throttle.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\nimport { consumeRateLimit } from './rate-limit-store'\n\n/**\n * Throttle for the UNAUTHENTICATED site-member password-recovery endpoint\n * (AGL-1966).\n *\n * ## The shape being closed\n *\n * Two unauthenticated tenant plugin endpoints compose into a mail relay on\n * `aglyn.com`'s sending domain: `membership/register` puts an arbitrary\n * address into `hosts/{hostId}/siteMembers` (member accounts are unlimited on\n * every plan, AGL-889, so nothing there refuses), and `membership/recover`\n * mails a reset link to any address that has such a row. Register N addresses,\n * call recover N times, and every one is a real message we sent, carrying an\n * attacker-chosen site name in the subject.\n *\n * The only thing in the way was the dispatcher's `visitorWriteRateLimitRefusal`\n * — 120 writes/60s per (site, IP), a bucket register and recover SHARE. That\n * ceiling is sized for a shopper editing cart quantities; measured as mail it\n * is ~86k messages a day from one address.\n *\n * The damage is not the Resend bill. It is the sending reputation of\n * `aglyn.com`, which is shared by every customer's transactional mail, and\n * which cannot be bought back after the fact.\n *\n * ## Why this is a separate module from `password-reset-throttle.ts`\n *\n * That one guards ADMIN-initiated resets, and says so at length: every caller\n * is already authenticated as staff, an org admin or a site admin, \"so this is\n * **not** the enumeration defense that guards the public `membership/recover`\n * endpoint — nobody reaches here without already knowing the account exists.\"\n * This is that defense. The caps differ (there is no actor to key on here, and\n * an IP is not an actor), and the refusal contract differs — see below, it is\n * the part that is easy to get wrong.\n *\n * It lives at this layer, beside its sibling, for the reason that one gives:\n * the commerce plugin cannot import the console's API helpers, and a number\n * duplicated in two places drifts.\n *\n * ## Two kinds of cap, because they have two different refusals\n *\n * This is the load-bearing distinction in the module, and it exists entirely\n * to keep the endpoint from becoming an existence oracle.\n *\n * **Attempt caps** ({@link consumeMembershipRecoverAttempt}) — per recipient\n * and per IP. Consumed BEFORE the member lookup, so a request for an address\n * that is a member and a request for one that is not spend exactly the same\n * budget and get exactly the same answer. Their refusal may therefore be a\n * visible `429`: it says nothing about whether the address exists, because the\n * counter that produced it counts both alike. Consuming these *after* the\n * lookup, or only on the send path, would be the bug — the 429 would then\n * appear only for real members and the endpoint's whole silent-success\n * contract would be undone by its own rate limiter.\n *\n * **The send cap** ({@link consumeMembershipRecoverSend}) — per host, per day.\n * Consumed only when a message is actually about to go out, because it is a\n * ceiling on OUTBOUND MAIL rather than on requests. That means it is only ever\n * reached on the member-exists branch, so its refusal must NOT be visible: the\n * caller takes the same silent `200 {ok:true}` exit as an unknown address. The\n * cost of that is a real member who gets no mail and no error during an\n * attack on their site — collateral that is accepted deliberately, and sized\n * for below.\n *\n * ## Backed by the durable limiter, not a `Map`\n *\n * `membership-recover.ts` previously carried a per-instance `Map` keyed on\n * IP+email. On Vercel that is close to no limit at all — it resets on every\n * cold start and each concurrent instance keeps its own, so the effective cap\n * is `limit × instances` and an attacker widens it by going wider (AGL-794).\n * A mail send is exactly the \"consequence, not volume\" case that\n * `RATE_LIMITING.md` says to spend a Firestore transaction on.\n *\n * ### Contention fails CLOSED here, and that is correct (AGL-2404)\n *\n * `consumeRateLimit` refuses with `contended: true` when one key is hot, and\n * only degrades when the store itself is unreachable. Both postures land well\n * on this surface. The attempt keys are per recipient and per IP, so a\n * legitimate visitor essentially never races themselves — concurrency on one\n * of these keys is the attack. And the send key is per host per day, which is\n * one document: a busy site's genuine recover traffic is a handful of requests\n * an hour, so contention there is likewise a flood rather than a Tuesday.\n */\n\n/**\n * Reset mails per recipient address, per hour, across every site.\n *\n * Matches `RESET_SENDS_PER_RECIPIENT` deliberately: a reset link is valid for\n * an hour anyway, so a second and third are only useful if the first was lost,\n * and one number for \"how many resets may one mailbox receive in an hour\" is\n * easier to defend than two.\n *\n * NOT keyed by host. A per-(host, address) cap would let an attacker who\n * controls several sites — which a free org can create — multiply one\n * mailbox's mail by the number of sites, and the person being carpet-bombed\n * does not care which of our customers' sites the mail claims to come from.\n */\nexport const RECOVER_SENDS_PER_RECIPIENT = 3\n\n/**\n * Recovery attempts per client IP, per hour, across every site and address.\n *\n * Sized against the human behaviour, not the attack: a person who has\n * forgotten a password tries it on one or two sites, mistypes the address\n * once, and stops. Ten is already several times that. A household or office\n * NAT sharing one address would need ten genuine forgotten passwords in one\n * hour to notice this.\n *\n * This is the cap that actually prices enumeration. The per-recipient cap\n * alone does nothing against the register-then-recover composition, because\n * every address in that attack is fresh and none of them has a count.\n */\nexport const RECOVER_ATTEMPTS_PER_IP = 10\n\n/** Both attempt caps use a one-hour fixed window, like their sibling. */\nexport const RECOVER_ATTEMPT_WINDOW_MS = 60 * 60 * 1000\n\n/**\n * Recovery mails one SITE may emit per day.\n *\n * An abuse ceiling, not a usage limit, and it is set where it is because of\n * the collateral described above: it must sit far enough above any real site's\n * genuine volume that reaching it is evidence of an attack. A storefront with\n * ten thousand members sees single-digit resets a day; two hundred is more\n * than an order of magnitude of headroom.\n *\n * What it buys, given the dispatcher's 120/min already upstream: it converts\n * \"~86k messages a day per site from one IP\" into 200, and — because it is\n * keyed on the site rather than on the caller — it holds no matter how many\n * IPs the caller brings. That is the property the compound visitor-write key\n * explicitly cannot have, and the reason this cap is per host alone.\n */\nexport const RECOVER_SENDS_PER_HOST_PER_DAY = 200\n\n/** Fixed 24h window for the send cap. */\nexport const RECOVER_SEND_WINDOW_MS = 24 * 60 * 60 * 1000\n\n/**\n * How new a member row may be and still be worth a reset mail.\n *\n * The register-then-recover composition is the whole attack, and no real\n * person needs a password reset in the first ten minutes of having chosen the\n * password. This is the cheapest control on the list and the only one that\n * refuses the composition itself rather than rationing it.\n *\n * Enforced by the handler (it needs the member document); the constant lives\n * here so the policy is in one place.\n */\nexport const RECOVER_MIN_MEMBER_AGE_MS = 10 * 60 * 1000\n\nexport interface MembershipRecoverThrottleResult {\n allowed: boolean\n /** Seconds until the exhausted window rolls over; 0 when allowed. */\n retryAfterSeconds: number\n /** Which cap refused, for logs. Never told to the caller. */\n limited: 'recipient' | 'ip' | 'host' | null\n /** True when the durable store was unreachable and only a local cap applied. */\n degraded: boolean\n /** True when the key was contended and the request was refused on that basis. */\n contended: boolean\n}\n\nexport interface MembershipRecoverAttemptOptions {\n /** The address the recovery was requested for, normalized by the caller. */\n email: string\n /**\n * The caller's address from `readClientIp`, or `null` when nothing readable\n * named one — in which case the per-source cap is SKIPPED rather than shared.\n * One `recover:ip:unknown` budget would spend every site's recovery\n * allowance on behalf of whoever tried first; the per-recipient cap above is\n * the half that stops mailbombing one person and needs no address.\n */\n ip: string | null\n now?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n}\n\nfunction refusal(\n limited: 'recipient' | 'ip' | 'host',\n resetMs: number,\n at: number,\n rate: { degraded: boolean; contended: boolean },\n): MembershipRecoverThrottleResult {\n return {\n allowed: false,\n retryAfterSeconds: Math.max(1, Math.ceil((resetMs - at) / 1000)),\n limited,\n degraded: rate.degraded,\n contended: rate.contended,\n }\n}\n\n/**\n * Counts one recovery ATTEMPT against the per-recipient and per-IP caps.\n *\n * Call before deciding whether the address is a member — see the module note\n * on why the ordering is the control and not an implementation detail.\n *\n * The recipient cap is checked first and short-circuits, matching\n * `consumePasswordResetSend`: one address being hammered must not also spend\n * the IP budget that a person sharing that NAT is relying on.\n *\n * The email is passed through to the key in the clear on purpose —\n * `consumeRateLimit` hashes every key into the document id precisely so that\n * caller-held identifiers do not sit in plaintext ids or index exports.\n */\nexport async function consumeMembershipRecoverAttempt(\n options: MembershipRecoverAttemptOptions,\n): Promise<MembershipRecoverThrottleResult> {\n const { email, ip, now, firestore } = options\n const shared = {\n windowMs: RECOVER_ATTEMPT_WINDOW_MS,\n ...(now === undefined ? {} : { now }),\n ...(firestore === undefined ? {} : { firestore }),\n }\n const at = now ?? Date.now()\n\n const recipient = await consumeRateLimit(`recover:to:${email}`, {\n ...shared,\n limit: RECOVER_SENDS_PER_RECIPIENT,\n })\n if (!recipient.allowed) {\n return refusal('recipient', recipient.resetMs, at, recipient)\n }\n\n const source = ip\n ? await consumeRateLimit(`recover:ip:${ip}`, {\n ...shared,\n limit: RECOVER_ATTEMPTS_PER_IP,\n })\n : null\n if (source && !source.allowed) {\n return refusal('ip', source.resetMs, at, source)\n }\n\n return {\n allowed: true,\n retryAfterSeconds: 0,\n limited: null,\n degraded: recipient.degraded || Boolean(source?.degraded),\n contended: false,\n }\n}\n\nexport interface MembershipRecoverSendOptions {\n hostId: string\n now?: number\n firestore?: unknown\n}\n\n/**\n * Counts one recovery mail against the per-site daily ceiling.\n *\n * Call immediately before sending, and treat a refusal as the silent-success\n * exit — never as a visible error. A visible one would fire only for addresses\n * that really are members, which is the oracle the endpoint exists to avoid.\n */\nexport async function consumeMembershipRecoverSend(\n options: MembershipRecoverSendOptions,\n): Promise<MembershipRecoverThrottleResult> {\n const { hostId, now, firestore } = options\n const at = now ?? Date.now()\n const rate = await consumeRateLimit(`recover:host:${hostId}`, {\n limit: RECOVER_SENDS_PER_HOST_PER_DAY,\n windowMs: RECOVER_SEND_WINDOW_MS,\n ...(now === undefined ? {} : { now }),\n ...(firestore === undefined ? {} : { firestore }),\n })\n if (!rate.allowed) return refusal('host', rate.resetMs, at, rate)\n return {\n allowed: true,\n retryAfterSeconds: 0,\n limited: null,\n degraded: rate.degraded,\n contended: false,\n }\n}\n\nexport default consumeMembershipRecoverAttempt\n"],"names":["consumeRateLimit","RECOVER_SENDS_PER_RECIPIENT","RECOVER_ATTEMPTS_PER_IP","RECOVER_ATTEMPT_WINDOW_MS","RECOVER_SENDS_PER_HOST_PER_DAY","RECOVER_SEND_WINDOW_MS","RECOVER_MIN_MEMBER_AGE_MS","refusal","limited","resetMs","at","rate","allowed","retryAfterSeconds","Math","max","ceil","degraded","contended","consumeMembershipRecoverAttempt","options","email","ip","now","firestore","shared","windowMs","undefined","Date","recipient","limit","source","Boolean","consumeMembershipRecoverSend","hostId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,wBAAoB;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+EC,GAED;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,8BAA8B,EAAC;AAE5C;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,0BAA0B,GAAE;AAEzC,uEAAuE,GACvE,OAAO,MAAMC,4BAA4B,KAAK,KAAK,KAAI;AAEvD;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMC,iCAAiC,IAAG;AAEjD,uCAAuC,GACvC,OAAO,MAAMC,yBAAyB,KAAK,KAAK,KAAK,KAAI;AAEzD;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,4BAA4B,KAAK,KAAK,KAAI;AA8BvD,SAASC,QACPC,OAAoC,EACpCC,OAAe,EACfC,EAAU,EACVC,IAA+C;IAE/C,OAAO;QACLC,SAAS;QACTC,mBAAmBC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,IAAI,CAAC,AAACP,CAAAA,UAAUC,EAAC,IAAK;QAC1DF;QACAS,UAAUN,KAAKM,QAAQ;QACvBC,WAAWP,KAAKO,SAAS;IAC3B;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeC,gCACpBC,OAAwC;IAExC,MAAM,EAAEC,KAAK,EAAEC,EAAE,EAAEC,GAAG,EAAEC,SAAS,EAAE,GAAGJ;IACtC,MAAMK,SAAS;QACbC,UAAUvB;OACNoB,QAAQI,YAAY,CAAC,IAAI;QAAEJ;IAAI,GAC/BC,cAAcG,YAAY,CAAC,IAAI;QAAEH;IAAU;IAEjD,MAAMd,KAAKa,cAAAA,MAAOK,KAAKL,GAAG;IAE1B,MAAMM,YAAY,MAAM7B,iBAAiB,CAAC,WAAW,EAAEqB,OAAO,EAAE,aAC3DI;QACHK,OAAO7B;;IAET,IAAI,CAAC4B,UAAUjB,OAAO,EAAE;QACtB,OAAOL,QAAQ,aAAasB,UAAUpB,OAAO,EAAEC,IAAImB;IACrD;IAEA,MAAME,SAAST,KACX,MAAMtB,iBAAiB,CAAC,WAAW,EAAEsB,IAAI,EAAE,aACtCG;QACHK,OAAO5B;UAET;IACJ,IAAI6B,UAAU,CAACA,OAAOnB,OAAO,EAAE;QAC7B,OAAOL,QAAQ,MAAMwB,OAAOtB,OAAO,EAAEC,IAAIqB;IAC3C;IAEA,OAAO;QACLnB,SAAS;QACTC,mBAAmB;QACnBL,SAAS;QACTS,UAAUY,UAAUZ,QAAQ,IAAIe,QAAQD,0BAAAA,OAAQd,QAAQ;QACxDC,WAAW;IACb;AACF;AAQA;;;;;;CAMC,GACD,OAAO,eAAee,6BACpBb,OAAqC;IAErC,MAAM,EAAEc,MAAM,EAAEX,GAAG,EAAEC,SAAS,EAAE,GAAGJ;IACnC,MAAMV,KAAKa,cAAAA,MAAOK,KAAKL,GAAG;IAC1B,MAAMZ,OAAO,MAAMX,iBAAiB,CAAC,aAAa,EAAEkC,QAAQ,EAAE;QAC5DJ,OAAO1B;QACPsB,UAAUrB;OACNkB,QAAQI,YAAY,CAAC,IAAI;QAAEJ;IAAI,GAC/BC,cAAcG,YAAY,CAAC,IAAI;QAAEH;IAAU;IAEjD,IAAI,CAACb,KAAKC,OAAO,EAAE,OAAOL,QAAQ,QAAQI,KAAKF,OAAO,EAAEC,IAAIC;IAC5D,OAAO;QACLC,SAAS;QACTC,mBAAmB;QACnBL,SAAS;QACTS,UAAUN,KAAKM,QAAQ;QACvBC,WAAW;IACb;AACF;AAEA,eAAeC,gCAA+B"}
|
|
@@ -0,0 +1,55 @@
|
|
|
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 AglynNotification } from '@aglyn/aglyn/server';
|
|
18
|
+
export type NotificationPayload = Omit<AglynNotification, '$id' | 'createdAt' | 'readAt'>;
|
|
19
|
+
/**
|
|
20
|
+
* Notification fan-out (AGL-259): batch-writes one doc per recipient at
|
|
21
|
+
* `users/{uid}/notifications`. Never throws — a notification miss must
|
|
22
|
+
* not break the mutation that emitted it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function notifyUsers(uids: Iterable<string>, payload: NotificationPayload): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Notifies every staff-claim holder (AGL-850) — the support-desk audience.
|
|
27
|
+
* Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never
|
|
28
|
+
* reach them; this enumerates the `staff` custom claim instead. The docs land
|
|
29
|
+
* at `users/{uid}/notifications`, which the console notifications menu already
|
|
30
|
+
* renders, so no separate staff inbox is needed. Never throws.
|
|
31
|
+
*/
|
|
32
|
+
export declare function notifyStaff(payload: NotificationPayload): Promise<void>;
|
|
33
|
+
/** Notifies the org's owner + admins (billing, membership, org events). */
|
|
34
|
+
export declare function notifyOrgAdmins(orgId: string, payload: NotificationPayload): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Notifies everyone who manages a host (admin/editor in the host doc's
|
|
37
|
+
* `memberRoles` projection) — the audience for form submissions and
|
|
38
|
+
* bookings on that site.
|
|
39
|
+
*
|
|
40
|
+
* The OWNING ORG travels with the notification (AGL-1773). Host links are
|
|
41
|
+
* stored in the legacy `/{hostDocId}/rest` shape and rewritten to
|
|
42
|
+
* `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED
|
|
43
|
+
* (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.
|
|
44
|
+
* With no `orgId` on the doc the console had nothing to key on and fell back
|
|
45
|
+
* to whichever org the reader happens to have OPEN, so a manager who belongs
|
|
46
|
+
* to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`
|
|
47
|
+
* and got a designed 404: `HostGuard` only resolves subdomains belonging to
|
|
48
|
+
* the current org. `notifyOrgAdmins` has always stamped it; the host fan-out
|
|
49
|
+
* — every form submission, booking, order and stock alert — never did.
|
|
50
|
+
*
|
|
51
|
+
* The host doc already carries `orgId` (AGL-233), so this costs no extra
|
|
52
|
+
* read. Spread conditionally: the field is optional on the host model and
|
|
53
|
+
* Firestore rejects `undefined`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function notifyHostManagers(hostId: string, payload: NotificationPayload): Promise<void>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { notificationMuted } from "@aglyn/aglyn/server";
|
|
18
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
19
|
+
import { listStaffUidsAcrossPools } from "./auth-pools.js";
|
|
20
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
21
|
+
import { listOrgMembers } from "./organizations.js";
|
|
22
|
+
const firestore = ()=>firebaseAdmin.app().firestore();
|
|
23
|
+
/**
|
|
24
|
+
* Notification fan-out (AGL-259): batch-writes one doc per recipient at
|
|
25
|
+
* `users/{uid}/notifications`. Never throws — a notification miss must
|
|
26
|
+
* not break the mutation that emitted it.
|
|
27
|
+
*/ export async function notifyUsers(uids, payload) {
|
|
28
|
+
try {
|
|
29
|
+
const db = firestore();
|
|
30
|
+
const targets = [
|
|
31
|
+
...new Set(uids)
|
|
32
|
+
].filter(Boolean).slice(0, 400);
|
|
33
|
+
if (!targets.length) return;
|
|
34
|
+
// Per-user category mutes (AGL-267): one getAll over the user docs.
|
|
35
|
+
const userDocs = await db.getAll(...targets.map((uid)=>db.collection('users').doc(uid)));
|
|
36
|
+
const batch = db.batch();
|
|
37
|
+
let count = 0;
|
|
38
|
+
for (const userDoc of userDocs){
|
|
39
|
+
const prefs = userDoc.get('notificationPrefs');
|
|
40
|
+
if (notificationMuted(prefs, payload.type)) continue;
|
|
41
|
+
batch.set(db.collection('users').doc(userDoc.id).collection('notifications').doc(), _extends({}, payload, {
|
|
42
|
+
createdAt: FieldValue.serverTimestamp()
|
|
43
|
+
}));
|
|
44
|
+
count += 1;
|
|
45
|
+
}
|
|
46
|
+
if (count > 0) await batch.commit();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('notification fan-out failed', error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Staff are identified by a Firebase Auth custom claim (`staff`), which is not
|
|
52
|
+
// a Firestore query — so the roster comes from paginating the auth users. A
|
|
53
|
+
// burst of ticket activity shouldn't rescan every time, so the (small) result
|
|
54
|
+
// is cached briefly. Fails soft to an empty list.
|
|
55
|
+
let staffUidCache = null;
|
|
56
|
+
const STAFF_CACHE_MS = 60000;
|
|
57
|
+
async function listStaffUids() {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {
|
|
60
|
+
return staffUidCache.uids;
|
|
61
|
+
}
|
|
62
|
+
// Across ALL auth pools (AGL-1122). This scanned only the project pool, so
|
|
63
|
+
// a staff member who signs in through enterprise SSO — whose account lives
|
|
64
|
+
// in a GCIP tenant pool — was never in the fan-out. Nothing errored; the
|
|
65
|
+
// notification simply never arrived, which is the worst shape a miss can
|
|
66
|
+
// take on an alerting path.
|
|
67
|
+
const uids = await listStaffUidsAcrossPools();
|
|
68
|
+
staffUidCache = {
|
|
69
|
+
uids,
|
|
70
|
+
at: now
|
|
71
|
+
};
|
|
72
|
+
return uids;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Notifies every staff-claim holder (AGL-850) — the support-desk audience.
|
|
76
|
+
* Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never
|
|
77
|
+
* reach them; this enumerates the `staff` custom claim instead. The docs land
|
|
78
|
+
* at `users/{uid}/notifications`, which the console notifications menu already
|
|
79
|
+
* renders, so no separate staff inbox is needed. Never throws.
|
|
80
|
+
*/ export async function notifyStaff(payload) {
|
|
81
|
+
try {
|
|
82
|
+
await notifyUsers(await listStaffUids(), payload);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('staff notification failed', error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Notifies the org's owner + admins (billing, membership, org events). */ export async function notifyOrgAdmins(orgId, payload) {
|
|
88
|
+
try {
|
|
89
|
+
const members = await listOrgMembers(orgId);
|
|
90
|
+
const admins = members.filter((member)=>member.role === 'owner' || member.role === 'admin').map((member)=>member.$id);
|
|
91
|
+
await notifyUsers(admins, _extends({}, payload, {
|
|
92
|
+
orgId
|
|
93
|
+
}));
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error('org admin notification failed', error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Notifies everyone who manages a host (admin/editor in the host doc's
|
|
100
|
+
* `memberRoles` projection) — the audience for form submissions and
|
|
101
|
+
* bookings on that site.
|
|
102
|
+
*
|
|
103
|
+
* The OWNING ORG travels with the notification (AGL-1773). Host links are
|
|
104
|
+
* stored in the legacy `/{hostDocId}/rest` shape and rewritten to
|
|
105
|
+
* `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED
|
|
106
|
+
* (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.
|
|
107
|
+
* With no `orgId` on the doc the console had nothing to key on and fell back
|
|
108
|
+
* to whichever org the reader happens to have OPEN, so a manager who belongs
|
|
109
|
+
* to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`
|
|
110
|
+
* and got a designed 404: `HostGuard` only resolves subdomains belonging to
|
|
111
|
+
* the current org. `notifyOrgAdmins` has always stamped it; the host fan-out
|
|
112
|
+
* — every form submission, booking, order and stock alert — never did.
|
|
113
|
+
*
|
|
114
|
+
* The host doc already carries `orgId` (AGL-233), so this costs no extra
|
|
115
|
+
* read. Spread conditionally: the field is optional on the host model and
|
|
116
|
+
* Firestore rejects `undefined`.
|
|
117
|
+
*/ export async function notifyHostManagers(hostId, payload) {
|
|
118
|
+
try {
|
|
119
|
+
var _host_get, _payload_orgId;
|
|
120
|
+
const host = await firestore().collection('hosts').doc(hostId).get();
|
|
121
|
+
const memberRoles = (_host_get = host.get('memberRoles')) != null ? _host_get : {};
|
|
122
|
+
const managers = Object.entries(memberRoles).filter(([, role])=>role === 'admin' || role === 'editor').map(([uid])=>uid);
|
|
123
|
+
const orgId = (_payload_orgId = payload.orgId) != null ? _payload_orgId : host.get('orgId');
|
|
124
|
+
await notifyUsers(managers, _extends({}, payload, {
|
|
125
|
+
hostId
|
|
126
|
+
}, orgId ? {
|
|
127
|
+
orgId
|
|
128
|
+
} : {}));
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('host manager notification failed', error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
//# sourceMappingURL=notifications.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.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\nimport { notificationMuted, type AglynNotification } from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { listStaffUidsAcrossPools } from './auth-pools'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`. Never throws — a notification miss must\n * not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user category mutes (AGL-267): one getAll over the user docs.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const batch = db.batch()\n let count = 0\n for (const userDoc of userDocs) {\n const prefs = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (notificationMuted(prefs, payload.type)) continue\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n { ...payload, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (count > 0) await batch.commit()\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members\n .filter((member) => member.role === 'owner' || member.role === 'admin')\n .map((member) => member.$id)\n await notifyUsers(admins, { ...payload, orgId })\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\n })\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["notificationMuted","FieldValue","listStaffUidsAcrossPools","firebaseAdmin","listOrgMembers","firestore","app","notifyUsers","uids","payload","db","targets","Set","filter","Boolean","slice","length","userDocs","getAll","map","uid","collection","doc","batch","count","userDoc","prefs","get","type","set","id","createdAt","serverTimestamp","commit","error","console","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","notifyOrgAdmins","orgId","members","admins","member","role","$id","notifyHostManagers","hostId","host","memberRoles","managers","Object","entries"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,QAAgC,sBAAqB;AAC/E,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,wBAAwB,QAAQ,kBAAc;AACvD,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,cAAc,QAAQ,qBAAiB;AAEhD,MAAMC,YAAY,IAAMF,cAAcG,GAAG,GAAGD,SAAS;AAOrD;;;;CAIC,GACD,OAAO,eAAeE,YACpBC,IAAsB,EACtBC,OAA4B;IAE5B,IAAI;QACF,MAAMC,KAAKL;QACX,MAAMM,UAAU;eAAI,IAAIC,IAAIJ;SAAM,CAACK,MAAM,CAACC,SAASC,KAAK,CAAC,GAAG;QAC5D,IAAI,CAACJ,QAAQK,MAAM,EAAE;QACrB,oEAAoE;QACpE,MAAMC,WAAW,MAAMP,GAAGQ,MAAM,IAC3BP,QAAQQ,GAAG,CAAC,CAACC,MAAQV,GAAGW,UAAU,CAAC,SAASC,GAAG,CAACF;QAErD,MAAMG,QAAQb,GAAGa,KAAK;QACtB,IAAIC,QAAQ;QACZ,KAAK,MAAMC,WAAWR,SAAU;YAC9B,MAAMS,QAAQD,QAAQE,GAAG,CAAC;YAG1B,IAAI3B,kBAAkB0B,OAAOjB,QAAQmB,IAAI,GAAG;YAC5CL,MAAMM,GAAG,CACPnB,GACGW,UAAU,CAAC,SACXC,GAAG,CAACG,QAAQK,EAAE,EACdT,UAAU,CAAC,iBACXC,GAAG,IACN,aAAKb;gBAASsB,WAAW9B,WAAW+B,eAAe;;YAErDR,SAAS;QACX;QACA,IAAIA,QAAQ,GAAG,MAAMD,MAAMU,MAAM;IACnC,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,+BAA+BA;IAC/C;AACF;AAEA,+EAA+E;AAC/E,4EAA4E;AAC5E,8EAA8E;AAC9E,kDAAkD;AAClD,IAAIE,gBAAuD;AAC3D,MAAMC,iBAAiB;AAEvB,eAAeC;IACb,MAAMC,MAAMC,KAAKD,GAAG;IACpB,IAAIH,iBAAiBG,MAAMH,cAAcK,EAAE,GAAGJ,gBAAgB;QAC5D,OAAOD,cAAc5B,IAAI;IAC3B;IACA,2EAA2E;IAC3E,2EAA2E;IAC3E,yEAAyE;IACzE,yEAAyE;IACzE,4BAA4B;IAC5B,MAAMA,OAAO,MAAMN;IACnBkC,gBAAgB;QAAE5B;QAAMiC,IAAIF;IAAI;IAChC,OAAO/B;AACT;AAEA;;;;;;CAMC,GACD,OAAO,eAAekC,YAAYjC,OAA4B;IAC5D,IAAI;QACF,MAAMF,YAAY,MAAM+B,iBAAiB7B;IAC3C,EAAE,OAAOyB,OAAO;QACdC,QAAQD,KAAK,CAAC,6BAA6BA;IAC7C;AACF;AAEA,yEAAyE,GACzE,OAAO,eAAeS,gBACpBC,KAAa,EACbnC,OAA4B;IAE5B,IAAI;QACF,MAAMoC,UAAU,MAAMzC,eAAewC;QACrC,MAAME,SAASD,QACZhC,MAAM,CAAC,CAACkC,SAAWA,OAAOC,IAAI,KAAK,WAAWD,OAAOC,IAAI,KAAK,SAC9D7B,GAAG,CAAC,CAAC4B,SAAWA,OAAOE,GAAG;QAC7B,MAAM1C,YAAYuC,QAAQ,aAAKrC;YAASmC;;IAC1C,EAAE,OAAOV,OAAO;QACdC,QAAQD,KAAK,CAAC,iCAAiCA;IACjD;AACF;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAegB,mBACpBC,MAAc,EACd1C,OAA4B;IAE5B,IAAI;YAGC2C,WAIW3C;QANd,MAAM2C,OAAO,MAAM/C,YAAYgB,UAAU,CAAC,SAASC,GAAG,CAAC6B,QAAQxB,GAAG;QAClE,MAAM0B,eACHD,YAAAA,KAAKzB,GAAG,CAAC,0BAATyB,YAAkE,CAAC;QACtE,MAAME,WAAWC,OAAOC,OAAO,CAACH,aAC7BxC,MAAM,CAAC,CAAC,GAAGmC,KAAK,GAAKA,SAAS,WAAWA,SAAS,UAClD7B,GAAG,CAAC,CAAC,CAACC,IAAI,GAAKA;QAClB,MAAMwB,SAAQnC,iBAAAA,QAAQmC,KAAK,YAAbnC,iBAAkB2C,KAAKzB,GAAG,CAAC;QACzC,MAAMpB,YAAY+C,UAAU,aACvB7C;YACH0C;WACIP,QAAQ;YAAEA;QAAM,IAAI,CAAC;IAE7B,EAAE,OAAOV,OAAO;QACdC,QAAQD,KAAK,CAAC,oCAAoCA;IACpD;AACF"}
|
|
@@ -0,0 +1,97 @@
|
|
|
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 { hasInlineOrgBilling, type OrgBillingDoc, type OrgInlineBilling } from '@aglyn/aglyn/server';
|
|
18
|
+
/** `orgs/{orgId}/billing/stripe`. */
|
|
19
|
+
export declare function orgBillingRef(orgId: string): FirebaseFirestore.DocumentReference<FirebaseFirestore.DocumentData, FirebaseFirestore.DocumentData>;
|
|
20
|
+
/**
|
|
21
|
+
* Reads an org's commercial keys, falling back to the org doc.
|
|
22
|
+
*
|
|
23
|
+
* The fallback is what makes the migration safe to land in pieces: a reader
|
|
24
|
+
* repointed here keeps working against an org that has not been backfilled
|
|
25
|
+
* yet, and keeps working if the backfill missed one. It stops being reachable
|
|
26
|
+
* only when the inline fields are finally deleted from the org doc — which is
|
|
27
|
+
* the LAST step, taken once production has been observed reading from the new
|
|
28
|
+
* location.
|
|
29
|
+
*
|
|
30
|
+
* Without it, the failure mode is silent and expensive: a missing billing doc
|
|
31
|
+
* reads as `undefined`, which every caller downstream interprets as "no
|
|
32
|
+
* subscription", i.e. a paying workspace rendered as Free.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readOrgBilling(orgId: string): Promise<OrgBillingDoc>;
|
|
35
|
+
/**
|
|
36
|
+
* Which Stripe MODES this org has a stored customer for (AGL-2486, follow-up).
|
|
37
|
+
*
|
|
38
|
+
* `readOrgBilling` answering `stripeCustomerId: null` has two completely
|
|
39
|
+
* different meanings — "this workspace has never been billed" and "this
|
|
40
|
+
* workspace's customer lives in the other Stripe world and THIS deployment
|
|
41
|
+
* cannot see it" — and every surface downstream rendered them as the same
|
|
42
|
+
* sentence, "No invoices yet." That is the swallowed-query-as-measured-zero
|
|
43
|
+
* shape: nothing looks wrong, and the wrong reading is the reassuring one.
|
|
44
|
+
*
|
|
45
|
+
* The fix is NOT to fall back to the other mode's id — that fallback is the
|
|
46
|
+
* original bug, and it stays deleted. It is to let a caller say which of the
|
|
47
|
+
* two silences it is looking at.
|
|
48
|
+
*
|
|
49
|
+
* Returns BOOLEANS, never ids. A test-mode surface asking this question must
|
|
50
|
+
* not come away holding a live `cus_…`: it would have no legitimate use for
|
|
51
|
+
* one, and putting it in a JSON response would publish a live customer id to
|
|
52
|
+
* every browser on a test deployment.
|
|
53
|
+
*/
|
|
54
|
+
export declare function readOrgBillingCustomerModes(orgId: string): Promise<{
|
|
55
|
+
live: boolean;
|
|
56
|
+
test: boolean;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Writes the commercial keys to the billing doc, and onto the org doc the
|
|
60
|
+
* entitlement inputs that stay there.
|
|
61
|
+
*
|
|
62
|
+
* Three writes, one batch:
|
|
63
|
+
*
|
|
64
|
+
* 1. the billing doc itself, `merge: true` to match what the org-doc write did;
|
|
65
|
+
* 2. the org doc, merged, with what entitlement resolution reads there:
|
|
66
|
+
* `billingStatus`, so the AGL-275 dunning banner keeps working for
|
|
67
|
+
* non-managers who can no longer read `subscription`; and `seatAddons`,
|
|
68
|
+
* which never moved (`ORG_BILLING_MOVED_KEYS`), whenever the patch carries
|
|
69
|
+
* the add-on quantities;
|
|
70
|
+
* 3. the `stripeCustomers` reverse index, so the webhook can still resolve an
|
|
71
|
+
* org from a Stripe customer id without a query.
|
|
72
|
+
*
|
|
73
|
+
* `writeInline` mirrored the keys back onto the org doc as well. It was on
|
|
74
|
+
* during the migration so a rollback lost nothing, and is now OFF: writing them
|
|
75
|
+
* inline is what the whole issue exists to stop, and leaving it on would let the
|
|
76
|
+
* next Stripe webhook silently re-add the fields minutes after they were
|
|
77
|
+
* deleted, re-opening the exposure with nothing to show for it.
|
|
78
|
+
*
|
|
79
|
+
* The option survives for a deliberate one-off rollback, and nothing passes it.
|
|
80
|
+
*
|
|
81
|
+
* The READ fallback in `readOrgBilling` deliberately stays. It costs one get on
|
|
82
|
+
* a doc that no longer exists, and it is the only thing standing between an org
|
|
83
|
+
* the backfill somehow missed and a paying workspace rendering as Free.
|
|
84
|
+
*/
|
|
85
|
+
export declare function writeOrgBilling(orgId: string, patch: Partial<OrgBillingDoc> & Pick<OrgInlineBilling, 'seatAddons'>, options?: {
|
|
86
|
+
writeInline?: boolean;
|
|
87
|
+
}): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Resolves an org id from a Stripe customer id.
|
|
90
|
+
*
|
|
91
|
+
* Prefers the reverse index; falls back to the legacy `where('stripeCustomerId')`
|
|
92
|
+
* query on `orgs` for customers whose index entry has not been written yet.
|
|
93
|
+
* The fallback disappears with the inline fields.
|
|
94
|
+
*/
|
|
95
|
+
export declare function findOrgIdByStripeCustomer(customerId: string): Promise<string | null>;
|
|
96
|
+
/** Re-exported so callers do not need a second import to test migration state. */
|
|
97
|
+
export { hasInlineOrgBilling };
|