@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 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/member-email-aliases.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 {\n evaluateMemberEmailAliasAdd,\n isVerifiedMemberEmailAlias,\n MEMBER_EMAIL_ALIAS_CONFIRM_PARAM,\n MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n MEMBER_EMAIL_ALIASES_COLLECTION,\n type MemberEmailAlias,\n type MemberEmailAliasAddRefusal,\n normalizeMemberEmailAlias,\n readMemberEmailAliases,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { createHmac } from 'crypto'\nimport { tokenSigningSecret } from './media-signing'\nimport { safeEqual } from './safe-equal'\n\n/**\n * A MEMBER'S OWN ADDRESSES (AGL-2975): the half with Firestore.\n *\n * The pure rules — what an address is, which are confirmed, whose an\n * address is — are `@aglyn/aglyn`'s `member-email-aliases.ts`. This module\n * is the store under `orgs/{orgId}/memberEmailAliases/{uid}`, the signed\n * confirmation link, and the email that carries it.\n *\n * ## Every write is here\n *\n * The rules deny the collection to every client, reads included, so\n * `verifiedAtMs` is only ever set here, by one of two writers, each running\n * behind the member's own verified session:\n * {@link confirmMemberEmailAlias}, for a member who opened the link mailed\n * to the address, and {@link confirmMemberEmailAliasesByProvider}, for a\n * member whose connected mail account lists the address as one its provider\n * has already verified they may send as. A client that could write the\n * document could mark any address it liked as its own, and the capture\n * webhook would then file a stranger's mail as the member's.\n *\n * ## The confirmation link\n *\n * `{ org, uid, address, addedAtMs, exp }`, HMAC-signed with the shared,\n * fail-closed `TOKEN_SIGNING_SECRET` under its own `member-email-alias:`\n * prefix, so it can never be replayed as a media, edit-bar or form token.\n * Nothing is stored for it: the entry's `addedAtMs` is what binds a link to\n * one request, so removing an address and adding it again retires every\n * link sent before, and a link for an address already confirmed confirms\n * nothing new.\n *\n * Confirming takes the session of the member who added the address — not\n * merely the link. Opening the mailbox proves the address receives mail;\n * it does not prove the mailbox is the member's. A prospect whose address\n * was pasted into the wrong field receives the link too, and a link that\n * worked for whoever opened it would let that prospect turn their own\n * address into the member's, after which every reply they sent would be\n * filed as the member writing to nobody.\n *\n * Firestore is a parameter, as in `crm-inbound-email.ts`, so a spec needs a\n * store and nothing else; the rate limiter and the email meter are loaded\n * only when a message is actually sent.\n */\n\n/** The version tag at the head of a token, so a later claim change is refused by shape. */\nconst TOKEN_VERSION = 'mea1'\n\n/** Domain separation from every other token the shared secret signs. */\nconst TOKEN_CONTEXT = 'member-email-alias:v1'\n\n/** Far above any real token, so an oversized value is refused unparsed. */\nconst TOKEN_MAX_CHARS = 2048\n\n/** Confirmation emails one member may cause per hour, across addresses. */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR = 10\n\n/**\n * Confirmation emails one address may receive per hour, from anybody.\n * Per member alone would let many members aim at one mailbox; per address\n * alone would let one member walk a list — the two limits the account\n * address route applies, for the same mailbomb shape.\n */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR = 3\n\n/** Every refusal this module answers with, as a stable code. */\nexport type MemberEmailAliasRefusal =\n | MemberEmailAliasAddRefusal\n | 'not-a-member'\n | 'unknown-address'\n | 'token-invalid'\n | 'token-expired'\n | 'wrong-member'\n | 'link-retired'\n\nexport interface MemberEmailAliasRefused {\n ok: false\n refusal: MemberEmailAliasRefusal\n message: string\n}\n\nconst refused = (refusal: MemberEmailAliasRefusal, message: string): MemberEmailAliasRefused => ({\n ok: false,\n refusal,\n message,\n})\n\nconst NOT_A_MEMBER = 'Only a member of this workspace can add addresses to it.'\n\nfunction aliasesRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(MEMBER_EMAIL_ALIASES_COLLECTION)\n .doc(uid)\n}\n\nfunction memberRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore.collection('orgs').doc(orgId).collection('members').doc(uid)\n}\n\n/** The addresses one member has added in one workspace, confirmed or not. */\nexport async function listMemberEmailAliases(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): Promise<MemberEmailAlias[]> {\n const snapshot = await aliasesRef(firestore, orgId, uid).get()\n return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null)\n}\n\nexport type MemberEmailAliasAdded =\n | {\n ok: true\n alias: MemberEmailAlias\n /** False when the address was already waiting for confirmation. */\n created: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Adds an address, unconfirmed, for a member of the workspace — or answers\n * the entry already waiting for it, so the caller sends another link. One\n * transaction over the roster row and the member's document, so the\n * ceiling is counted from what is stored at the moment of the write and an\n * address is never added for somebody who is no longer a member.\n */\nexport async function addMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n uid: string\n address: unknown\n /** The address the member signs in with, which is theirs already. */\n signInEmail?: string | null\n /** Domains the deployment receives mail at — the capture domain. */\n reservedDomains?: readonly string[]\n nowMs?: number\n },\n): Promise<MemberEmailAliasAdded> {\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasAdded>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const verdict = evaluateMemberEmailAliasAdd({\n address: input.address,\n signInEmail: input.signInEmail ?? null,\n aliases,\n reservedDomains: input.reservedDomains ?? [],\n })\n if (verdict.ok === false) return refused(verdict.refusal, verdict.message)\n if (verdict.existing) return { ok: true, alias: verdict.existing, created: false }\n const alias: MemberEmailAlias = { address: verdict.address, addedAtMs: nowMs }\n tx.set(ref, { uid: input.uid, aliases: [...aliases, alias], updatedAtMs: nowMs })\n return { ok: true, alias, created: true }\n })\n}\n\n/**\n * Removes one address, confirmed or not; the document goes with its last\n * address. Removal retires every link sent for the address, because a link\n * names the entry's `addedAtMs` and there is no longer an entry to match.\n */\nexport type MemberEmailAliasRemoved =\n | { ok: true; removed: MemberEmailAlias }\n | MemberEmailAliasRefused\n\nexport async function removeMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; address: unknown; nowMs?: number },\n): Promise<MemberEmailAliasRemoved> {\n const address = normalizeMemberEmailAlias(input.address)\n if (!address) return refused('invalid-address', 'Enter a valid email address.')\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasRemoved>(async (tx) => {\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const removed = aliases.find((alias) => alias.address === address)\n if (!removed) return refused('unknown-address', 'That address is not on your list.')\n const remaining = aliases.filter((alias) => alias.address !== address)\n if (remaining.length) {\n tx.set(ref, { uid: input.uid, aliases: remaining, updatedAtMs: nowMs })\n } else {\n tx.delete(ref)\n }\n return { ok: true, removed }\n })\n}\n\n/** What a confirmation link asserts. */\nexport interface MemberEmailAliasClaims {\n orgId: string\n uid: string\n address: string\n /** The `addedAtMs` of the entry the link was sent for. */\n addedAtMs: number\n /** Expiry, epoch ms. */\n exp: number\n}\n\ninterface WireClaims {\n o: string\n u: string\n a: string\n t: number\n e: number\n}\n\nconst signature = (payload: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${TOKEN_CONTEXT}:${payload}`).digest('base64url')\n\n/**\n * The token a confirmation link carries for one entry. Throws when\n * `TOKEN_SIGNING_SECRET` is not configured, like every token that secret\n * signs; the route answers that as a failure to send.\n */\nexport function mintMemberEmailAliasToken(\n claims: Omit<MemberEmailAliasClaims, 'exp'>,\n nowMs: number = Date.now(),\n): string {\n const wire: WireClaims = {\n o: claims.orgId,\n u: claims.uid,\n a: claims.address,\n t: claims.addedAtMs,\n e: nowMs + MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return `${TOKEN_VERSION}.${payload}.${signature(payload)}`\n}\n\n/**\n * The claims a token carries, or why it carries none: `token-expired` for a\n * genuine link past its day, `token-invalid` for anything else — tampered,\n * truncated, another version, or a deployment with no signing secret, whose\n * only safe reading of a link is that it proves nothing.\n */\nexport function readMemberEmailAliasToken(\n token: unknown,\n nowMs: number = Date.now(),\n): { ok: true; claims: MemberEmailAliasClaims } | MemberEmailAliasRefused {\n const invalid = refused('token-invalid', 'That confirmation link is not valid.')\n if (typeof token !== 'string' || !token || token.length > TOKEN_MAX_CHARS) return invalid\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return invalid\n const [, payload, signed] = parts\n let expected: string\n try {\n expected = signature(payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signed, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const address = normalizeMemberEmailAlias(wire?.a)\n const orgId = typeof wire?.o === 'string' ? wire.o : ''\n const uid = typeof wire?.u === 'string' ? wire.u : ''\n const addedAtMs = Number(wire?.t)\n const exp = Number(wire?.e)\n if (!orgId || !uid || !address || !Number.isFinite(addedAtMs) || !Number.isFinite(exp)) {\n return invalid\n }\n if (exp <= nowMs) {\n return refused('token-expired', 'That confirmation link has expired. Send a new one from the list.')\n }\n return { ok: true, claims: { orgId, uid, address, addedAtMs, exp } }\n}\n\nexport type MemberEmailAliasConfirmed =\n | {\n ok: true\n orgId: string\n address: string\n /** True when the address had been confirmed before this call. */\n alreadyConfirmed: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms the address a link was sent for, for the member signed in — who\n * must be the member who added it (see the module comment). A link for an\n * entry that has since been removed or added again is refused as retired,\n * and opening a link twice is not an error.\n */\nexport async function confirmMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { token: unknown; callerUid: string; nowMs?: number },\n): Promise<MemberEmailAliasConfirmed> {\n const nowMs = input.nowMs ?? Date.now()\n const read = readMemberEmailAliasToken(input.token, nowMs)\n if (read.ok === false) return read\n const { claims } = read\n if (!input.callerUid || claims.uid !== input.callerUid) {\n return refused(\n 'wrong-member',\n 'This link confirms an address for another member. Sign in as the member who added it, then open the link again.',\n )\n }\n const ref = aliasesRef(firestore, claims.orgId, claims.uid)\n const roster = memberRef(firestore, claims.orgId, claims.uid)\n return firestore.runTransaction<MemberEmailAliasConfirmed>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', 'You are no longer a member of that workspace.')\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const entry = aliases.find((alias) => alias.address === claims.address)\n if (!entry || entry.addedAtMs !== claims.addedAtMs) {\n return refused(\n 'link-retired',\n 'This link is for an address that has since been removed or added again. Send a new link from the list.',\n )\n }\n if (isVerifiedMemberEmailAlias(entry)) {\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: true }\n }\n const next = aliases.map((alias) =>\n alias.address === entry.address ? { ...alias, verifiedAtMs: nowMs } : alias,\n )\n tx.set(ref, { uid: claims.uid, aliases: next, updatedAtMs: nowMs })\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: false }\n })\n}\n\nexport type MemberEmailAliasesConfirmedByProvider =\n | {\n ok: true\n /** The addresses this call confirmed, normalized, in list order. */\n confirmed: string[]\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms a member's PENDING addresses that their connected mail account\n * already proves are theirs (AGL-2978).\n *\n * The confirmation link proves an address receives mail and asks the member\n * to open it. A provider's verified send-as list proves more: the provider\n * confirmed the account may send as the address, and the member proved they\n * hold that account by connecting it under their own session. So an address\n * the member added and has not yet confirmed counts as confirmed once it\n * appears in that list.\n *\n * Only addresses the member ALREADY added are touched — this never adds one,\n * because an address the member did not ask for is not a claim they made —\n * and an address already confirmed keeps its original `verifiedAtMs`. The\n * caller passes only the provider's VERIFIED addresses; which of a\n * provider's listings count as verified is the caller's rule, not this\n * store's. Nothing is written when nothing changes.\n */\nexport async function confirmMemberEmailAliasesByProvider(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; addresses: readonly unknown[]; nowMs?: number },\n): Promise<MemberEmailAliasesConfirmedByProvider> {\n const nowMs = input.nowMs ?? Date.now()\n const proven = new Set(\n input.addresses\n .map((address) => normalizeMemberEmailAlias(address))\n .filter((address): address is string => Boolean(address)),\n )\n if (!input.orgId || !input.uid) return refused('not-a-member', NOT_A_MEMBER)\n if (!proven.size) return { ok: true, confirmed: [] }\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasesConfirmedByProvider>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const confirmed: string[] = []\n const next = aliases.map((alias) => {\n if (isVerifiedMemberEmailAlias(alias) || !proven.has(alias.address)) return alias\n confirmed.push(alias.address)\n return { ...alias, verifiedAtMs: nowMs }\n })\n if (confirmed.length) {\n tx.set(ref, { uid: input.uid, aliases: next, updatedAtMs: nowMs })\n }\n return { ok: true, confirmed }\n })\n}\n\n/**\n * A console path a link may land on: absolute, on the console's own\n * origin, with no query or fragment of its own. Anything else lands on\n * `/`, which still signs the member in.\n */\nconst RETURN_PATH = /^\\/(?![/\\\\])[^\\s?#\\\\]{0,511}$/\n\n/**\n * The link a confirmation email carries: the console origin the caller\n * resolved from server configuration — never from a request header — the\n * page the member added the address on, and the token.\n */\nexport function memberEmailAliasConfirmUrl(input: {\n origin: string\n returnPath?: unknown\n token: string\n}): string {\n const origin = String(input.origin ?? '').replace(/\\/+$/, '')\n const raw = typeof input.returnPath === 'string' ? input.returnPath : ''\n const path = RETURN_PATH.test(raw) ? raw : '/'\n return `${origin}${path}?${MEMBER_EMAIL_ALIAS_CONFIRM_PARAM}=${encodeURIComponent(input.token)}`\n}\n\nexport type MemberEmailAliasConfirmationSent =\n | { sent: true }\n | { sent: false; status: 429 | 501 | 502; error: string }\n\n/**\n * Emails the confirmation link to the address, in the workspace's brand.\n *\n * Rate-limited per member and per destination address before anything is\n * sent (see the two ceilings above), and metered to the workspace, whose\n * member asked for it.\n */\nexport async function sendMemberEmailAliasConfirmation(input: {\n orgId: string\n /** The org document, for its name and its brand. */\n org: Record<string, unknown> | null\n uid: string\n /** The member's display name, when the roster row carries one. */\n memberName?: string | null\n address: string\n confirmUrl: string\n}): Promise<MemberEmailAliasConfirmationSent> {\n if (!isEmailConfigured()) {\n return { sent: false, status: 501, error: 'Email is not configured on this deployment.' }\n }\n const { consumeRateLimit } = await import('./rate-limit-store')\n const perMember = await consumeRateLimit(`member-email-alias-confirm:${input.uid}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perMember.allowed) {\n return { sent: false, status: 429, error: 'Too many confirmation emails. Try again in an hour.' }\n }\n const perAddress = await consumeRateLimit(`member-email-alias-target:${input.address}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perAddress.allowed) {\n return {\n sent: false,\n status: 429,\n error: 'Too many confirmation emails for that address. Try again later.',\n }\n }\n\n const branding = resolveBrandingProfile((input.org ?? null) as never)\n const workspace = String(input.org?.['name'] ?? '').trim() || 'your workspace'\n const member = String(input.memberName ?? '').trim()\n const text = [\n `${member || 'A member'} asked to add ${input.address} as one of their own email ` +\n `addresses in the ${workspace} workspace on ${branding.productName}.`,\n '',\n `To confirm it, open this link while signed in as ${member || 'that member'}:`,\n '',\n input.confirmUrl,\n '',\n `Once it is confirmed, email sent from ${input.address} with the workspace's capture ` +\n 'address in BCC is filed under the person it was written to. The link works for 24 hours.',\n '',\n 'If you did not expect this email, ignore it. Nothing changes unless the member who asked opens the link.',\n ].join('\\n')\n const result = await sendEmail({\n to: input.address,\n subject: `Confirm your address for ${workspace}`,\n text,\n fromName: branding.fromName,\n context: 'member-email-alias-confirmation',\n })\n if (!result.sent) {\n return { sent: false, status: 502, error: 'The confirmation email could not be sent.' }\n }\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n return { sent: true }\n}\n"],"names":["evaluateMemberEmailAliasAdd","isVerifiedMemberEmailAlias","MEMBER_EMAIL_ALIAS_CONFIRM_PARAM","MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS","MEMBER_EMAIL_ALIASES_COLLECTION","normalizeMemberEmailAlias","readMemberEmailAliases","resolveBrandingProfile","isEmailConfigured","sendEmail","createHmac","tokenSigningSecret","safeEqual","TOKEN_VERSION","TOKEN_CONTEXT","TOKEN_MAX_CHARS","MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR","MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR","refused","refusal","message","ok","NOT_A_MEMBER","aliasesRef","firestore","orgId","uid","collection","doc","memberRef","listMemberEmailAliases","snapshot","get","exists","data","addMemberEmailAlias","input","nowMs","Date","now","ref","roster","runTransaction","tx","member","stored","aliases","verdict","address","signInEmail","reservedDomains","existing","alias","created","addedAtMs","set","updatedAtMs","removeMemberEmailAlias","removed","find","remaining","filter","length","delete","signature","payload","update","digest","mintMemberEmailAliasToken","claims","wire","o","u","a","t","e","Buffer","from","JSON","stringify","toString","readMemberEmailAliasToken","token","invalid","parts","split","signed","expected","parse","Number","exp","isFinite","confirmMemberEmailAlias","read","callerUid","entry","alreadyConfirmed","next","map","verifiedAtMs","confirmMemberEmailAliasesByProvider","proven","Set","addresses","Boolean","size","confirmed","has","push","RETURN_PATH","memberEmailAliasConfirmUrl","origin","String","replace","raw","returnPath","path","test","encodeURIComponent","sendMemberEmailAliasConfirmation","sent","status","error","consumeRateLimit","perMember","limit","windowMs","allowed","perAddress","branding","org","workspace","trim","memberName","text","productName","confirmUrl","join","result","to","subject","fromName","context","meterOrgEmail","catch","undefined"],"mappings":"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"}
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
* Matches the cap the staff identity editor already imposes on the same field
|
|
19
|
+
* (`/api/admin/users/manage`, `updateProfile`), so the two surfaces cannot
|
|
20
|
+
* accept different values for one column.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MEMBER_PHOTO_MAX_LENGTH = 500;
|
|
23
|
+
export type MemberPhotoRejection = 'not-https' | 'too-long';
|
|
24
|
+
/**
|
|
25
|
+
* Flat rather than a discriminated union on `ok`, because `strictNullChecks`
|
|
26
|
+
* is OFF repo-wide and TypeScript will not narrow a boolean-literal
|
|
27
|
+
* discriminant without it — `if (!value.ok)` left `reason` unreachable in
|
|
28
|
+
* every caller. The shape is still exact: a refusal carries `reason` and no
|
|
29
|
+
* `photoURL`, an acceptance the reverse, and the spec asserts both with
|
|
30
|
+
* `toEqual`.
|
|
31
|
+
*/
|
|
32
|
+
export interface MemberPhotoValue {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
/** The storable value, on an acceptance. `''` when clearing. */
|
|
35
|
+
photoURL?: string;
|
|
36
|
+
/** Whether this acceptance removes the avatar rather than setting one. */
|
|
37
|
+
clearing?: boolean;
|
|
38
|
+
/** Why it was refused, on a refusal. */
|
|
39
|
+
reason?: MemberPhotoRejection;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Normalize what a caller offered into either a storable URL or a refusal.
|
|
43
|
+
*
|
|
44
|
+
* An empty string is a legitimate value and means CLEAR — "worth doing at the
|
|
45
|
+
* same time" per AGL-1976, because otherwise a removed avatar lingers on the
|
|
46
|
+
* roster after it is gone everywhere else, which is the same bug pointing the
|
|
47
|
+
* other way.
|
|
48
|
+
*
|
|
49
|
+
* Pure, so the policy can be tested without Firestore.
|
|
50
|
+
*/
|
|
51
|
+
export declare function normalizeMemberPhotoUrl(raw: unknown): MemberPhotoValue;
|
|
52
|
+
export interface PropagateMemberPhotoResult {
|
|
53
|
+
/** Orgs whose roster row was updated. */
|
|
54
|
+
orgIds: string[];
|
|
55
|
+
/** Memberships listed but with no roster row to write — see below. */
|
|
56
|
+
missingRows: string[];
|
|
57
|
+
/** Whether the photo was removed rather than set. */
|
|
58
|
+
cleared: boolean;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Write `photoURL` — and nothing else — onto every roster row for `uid`.
|
|
62
|
+
*
|
|
63
|
+
* Memberships are enumerated from `users/{uid}/orgs`, the same reverse index
|
|
64
|
+
* `eraseUser` walks to find the rosters a person appears on. Using it rather
|
|
65
|
+
* than a collection-group query over `members` is deliberate: a collection
|
|
66
|
+
* group would read every workspace's roster in the estate to find one person's
|
|
67
|
+
* rows, and this runs on an interactive save.
|
|
68
|
+
*
|
|
69
|
+
* A membership whose roster row does NOT exist is reported and skipped, never
|
|
70
|
+
* created. Minting a row here would produce a membership document with no
|
|
71
|
+
* `role`, which every permission check that asks only whether the doc exists
|
|
72
|
+
* reads as a member of some kind — the AGL-1122 bug class, and the same reason
|
|
73
|
+
* `backfillMemberIdentity` refuses it.
|
|
74
|
+
*
|
|
75
|
+
* Each write is a `merge` carrying exactly one key, so `role`, `allHosts`,
|
|
76
|
+
* `hostAccess` and `roleId` are untouched by construction rather than by
|
|
77
|
+
* remembering to leave them alone.
|
|
78
|
+
*
|
|
79
|
+
* @param input - the uid to write for and the value: an `https://` URL to
|
|
80
|
+
* set, or `''`/`null` to clear.
|
|
81
|
+
*/
|
|
82
|
+
export declare function propagateMemberPhoto(input: {
|
|
83
|
+
uid: string;
|
|
84
|
+
photoURL: string | null;
|
|
85
|
+
/** Injectable for tests; defaults to the admin app's Firestore. */
|
|
86
|
+
firestore?: any;
|
|
87
|
+
}): Promise<PropagateMemberPhotoResult>;
|
|
88
|
+
export default propagateMemberPhoto;
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
* Propagate the avatar a person chose for themselves onto every org roster
|
|
18
|
+
* row that names them (AGL-1976).
|
|
19
|
+
*
|
|
20
|
+
* `orgs/{orgId}/members/{uid}.photoURL` is the ONLY avatar any member surface
|
|
21
|
+
* reads — the Team list, the member detail page, activity entries and presence
|
|
22
|
+
* all feed `MemberAvatar` from the roster row, because none of them can read
|
|
23
|
+
* another person's auth record: an SSO member's lives in a per-org GCIP pool
|
|
24
|
+
* that project-level auth cannot see at all (AGL-1122). Manage Account →
|
|
25
|
+
* Profile image wrote `users/{uid}.photoUrl` and the auth record and stopped
|
|
26
|
+
* there, so a person who set an avatar saw it in their own app bar and their
|
|
27
|
+
* colleagues went on seeing a grey initial (AGL-1126, reopened by AGL-1961
|
|
28
|
+
* fixing only the app bar half).
|
|
29
|
+
*
|
|
30
|
+
* ## Why this is a server function and not a client write
|
|
31
|
+
*
|
|
32
|
+
* `orgs/{orgId}/members/{memberUid}` is `allow write: if false` in
|
|
33
|
+
* `cloud/firebase-firestore.rules`, and that is the right rule: the roster row
|
|
34
|
+
* is a PERMISSIONS document. It carries `role`, `allHosts`, `hostAccess` and
|
|
35
|
+
* `roleId` alongside the display identity, and a field-scoped self-write
|
|
36
|
+
* exception on a permissions document — to render an avatar — is the wrong
|
|
37
|
+
* shape of risk to take. It also has to fan out: a person can belong to
|
|
38
|
+
* several workspaces and all their rows should agree.
|
|
39
|
+
*
|
|
40
|
+
* ## THE DISTINCTION THIS FILE EXISTS TO GET RIGHT
|
|
41
|
+
*
|
|
42
|
+
* `backfillMemberIdentity` (AGL-1131) is **absent-only** and must stay that
|
|
43
|
+
* way: it runs on the SSO sign-in path on EVERY sign-in, so an overwriting
|
|
44
|
+
* version would replace a photo somebody uploaded with their IdP directory
|
|
45
|
+
* thumbnail, silently, on their next sign-in — and it would do it forever.
|
|
46
|
+
*
|
|
47
|
+
* This function is the opposite case and must **overwrite**. A person opening
|
|
48
|
+
* Manage Account, typing a URL and pressing Save has stated a preference; a
|
|
49
|
+
* write that declined to replace the IdP thumbnail already sitting on the row
|
|
50
|
+
* would leave them looking at the old picture with no way to change it, which
|
|
51
|
+
* is the same defect as the one being fixed, only harder to explain.
|
|
52
|
+
*
|
|
53
|
+
* The two rules are not in tension because their inputs differ in kind: one is
|
|
54
|
+
* an assertion made ABOUT a person by a directory, the other is a choice made
|
|
55
|
+
* BY the person. `member-photo.spec.ts` pins both directions in one file, so a
|
|
56
|
+
* future edit that collapses them into a single helper fails a test.
|
|
57
|
+
*
|
|
58
|
+
* ## The value is not trusted because a member typed it
|
|
59
|
+
*
|
|
60
|
+
* It ends up as an `<img src>` in front of every colleague in the workspace,
|
|
61
|
+
* so it is re-validated here rather than at the client that offered it —
|
|
62
|
+
* `https://` only, the same rule `resolveIdpPhotoUrl` enforces on an IdP
|
|
63
|
+
* assertion (AGL-1131), which exists to refuse `javascript:` and `data:` URLs.
|
|
64
|
+
* The client's check is a courtesy to the person typing; this one is the
|
|
65
|
+
* boundary.
|
|
66
|
+
*/ import { isMediaCdnPath } from "@aglyn/aglyn/server";
|
|
67
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
68
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
69
|
+
const firestore = ()=>firebaseAdmin.app().firestore();
|
|
70
|
+
/**
|
|
71
|
+
* Matches the cap the staff identity editor already imposes on the same field
|
|
72
|
+
* (`/api/admin/users/manage`, `updateProfile`), so the two surfaces cannot
|
|
73
|
+
* accept different values for one column.
|
|
74
|
+
*/ export const MEMBER_PHOTO_MAX_LENGTH = 500;
|
|
75
|
+
/**
|
|
76
|
+
* Normalize what a caller offered into either a storable URL or a refusal.
|
|
77
|
+
*
|
|
78
|
+
* An empty string is a legitimate value and means CLEAR — "worth doing at the
|
|
79
|
+
* same time" per AGL-1976, because otherwise a removed avatar lingers on the
|
|
80
|
+
* roster after it is gone everywhere else, which is the same bug pointing the
|
|
81
|
+
* other way.
|
|
82
|
+
*
|
|
83
|
+
* Pure, so the policy can be tested without Firestore.
|
|
84
|
+
*/ export function normalizeMemberPhotoUrl(raw) {
|
|
85
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
86
|
+
if (!value) return {
|
|
87
|
+
ok: true,
|
|
88
|
+
photoURL: '',
|
|
89
|
+
clearing: true
|
|
90
|
+
};
|
|
91
|
+
if (value.length > MEMBER_PHOTO_MAX_LENGTH) return {
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: 'too-long'
|
|
94
|
+
};
|
|
95
|
+
// `https://`, or a media-library CDN path (AGL-2286). Still an ALLOWLIST of
|
|
96
|
+
// exactly two shapes rather than a general URL parse: the point is to refuse
|
|
97
|
+
// the schemes that turn an avatar into script execution, and neither shape
|
|
98
|
+
// can be widened by a clever input — `isMediaCdnPath` is anchored on the
|
|
99
|
+
// literal `/api/media/cdn/` prefix and validates both remaining segments
|
|
100
|
+
// against the CDN's own grammar, so `javascript:`, `data:`, a
|
|
101
|
+
// protocol-relative `//evil.example`, a traversal, and a query string are
|
|
102
|
+
// all still refused.
|
|
103
|
+
//
|
|
104
|
+
// The second shape is here because the Manage Account → Profile image field
|
|
105
|
+
// is a `MediaUrlField` offering "Browse the org media library", and Browse
|
|
106
|
+
// writes `media.cdnPath`, which is never absolute. Every user's avatar
|
|
107
|
+
// Browse button was therefore a dead control — refused twice, once by the
|
|
108
|
+
// client courtesy check and again here at the boundary.
|
|
109
|
+
if (!/^https:\/\//i.test(value) && !isMediaCdnPath(value)) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
reason: 'not-https'
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
photoURL: value,
|
|
118
|
+
clearing: false
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Write `photoURL` — and nothing else — onto every roster row for `uid`.
|
|
123
|
+
*
|
|
124
|
+
* Memberships are enumerated from `users/{uid}/orgs`, the same reverse index
|
|
125
|
+
* `eraseUser` walks to find the rosters a person appears on. Using it rather
|
|
126
|
+
* than a collection-group query over `members` is deliberate: a collection
|
|
127
|
+
* group would read every workspace's roster in the estate to find one person's
|
|
128
|
+
* rows, and this runs on an interactive save.
|
|
129
|
+
*
|
|
130
|
+
* A membership whose roster row does NOT exist is reported and skipped, never
|
|
131
|
+
* created. Minting a row here would produce a membership document with no
|
|
132
|
+
* `role`, which every permission check that asks only whether the doc exists
|
|
133
|
+
* reads as a member of some kind — the AGL-1122 bug class, and the same reason
|
|
134
|
+
* `backfillMemberIdentity` refuses it.
|
|
135
|
+
*
|
|
136
|
+
* Each write is a `merge` carrying exactly one key, so `role`, `allHosts`,
|
|
137
|
+
* `hostAccess` and `roleId` are untouched by construction rather than by
|
|
138
|
+
* remembering to leave them alone.
|
|
139
|
+
*
|
|
140
|
+
* @param input - the uid to write for and the value: an `https://` URL to
|
|
141
|
+
* set, or `''`/`null` to clear.
|
|
142
|
+
*/ export async function propagateMemberPhoto(input) {
|
|
143
|
+
var _input_firestore;
|
|
144
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : firestore();
|
|
145
|
+
const normalized = normalizeMemberPhotoUrl(input.photoURL);
|
|
146
|
+
if (!normalized.ok) {
|
|
147
|
+
// Callers validate first; reaching here means a route forgot to. Throwing
|
|
148
|
+
// is better than storing an unvalidated value on ten colleagues' screens.
|
|
149
|
+
throw new Error(`propagateMemberPhoto: refused photo (${normalized.reason})`);
|
|
150
|
+
}
|
|
151
|
+
if (!input.uid) return {
|
|
152
|
+
orgIds: [],
|
|
153
|
+
missingRows: [],
|
|
154
|
+
cleared: normalized.clearing
|
|
155
|
+
};
|
|
156
|
+
const memberships = await db.collection('users').doc(input.uid).collection('orgs').get();
|
|
157
|
+
const orgIds = [];
|
|
158
|
+
const missingRows = [];
|
|
159
|
+
for (const row of memberships.docs){
|
|
160
|
+
const ref = db.collection('orgs').doc(row.id).collection('members').doc(input.uid);
|
|
161
|
+
const snapshot = await ref.get();
|
|
162
|
+
if (!snapshot.exists) {
|
|
163
|
+
missingRows.push(row.id);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// `FieldValue.delete()` rather than `''` on a clear. A blank string is a
|
|
167
|
+
// value, and `backfillMemberIdentity`'s `blank()` treats it as absent —
|
|
168
|
+
// so storing `''` would invite the IdP to refill the row on the next
|
|
169
|
+
// sign-in with exactly the photo the person just removed.
|
|
170
|
+
await ref.set({
|
|
171
|
+
photoURL: normalized.clearing ? FieldValue.delete() : normalized.photoURL
|
|
172
|
+
}, {
|
|
173
|
+
merge: true
|
|
174
|
+
});
|
|
175
|
+
orgIds.push(row.id);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
orgIds,
|
|
179
|
+
missingRows,
|
|
180
|
+
cleared: normalized.clearing
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export default propagateMemberPhoto;
|
|
184
|
+
|
|
185
|
+
//# sourceMappingURL=member-photo.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/member-photo.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 * Propagate the avatar a person chose for themselves onto every org roster\n * row that names them (AGL-1976).\n *\n * `orgs/{orgId}/members/{uid}.photoURL` is the ONLY avatar any member surface\n * reads — the Team list, the member detail page, activity entries and presence\n * all feed `MemberAvatar` from the roster row, because none of them can read\n * another person's auth record: an SSO member's lives in a per-org GCIP pool\n * that project-level auth cannot see at all (AGL-1122). Manage Account →\n * Profile image wrote `users/{uid}.photoUrl` and the auth record and stopped\n * there, so a person who set an avatar saw it in their own app bar and their\n * colleagues went on seeing a grey initial (AGL-1126, reopened by AGL-1961\n * fixing only the app bar half).\n *\n * ## Why this is a server function and not a client write\n *\n * `orgs/{orgId}/members/{memberUid}` is `allow write: if false` in\n * `cloud/firebase-firestore.rules`, and that is the right rule: the roster row\n * is a PERMISSIONS document. It carries `role`, `allHosts`, `hostAccess` and\n * `roleId` alongside the display identity, and a field-scoped self-write\n * exception on a permissions document — to render an avatar — is the wrong\n * shape of risk to take. It also has to fan out: a person can belong to\n * several workspaces and all their rows should agree.\n *\n * ## THE DISTINCTION THIS FILE EXISTS TO GET RIGHT\n *\n * `backfillMemberIdentity` (AGL-1131) is **absent-only** and must stay that\n * way: it runs on the SSO sign-in path on EVERY sign-in, so an overwriting\n * version would replace a photo somebody uploaded with their IdP directory\n * thumbnail, silently, on their next sign-in — and it would do it forever.\n *\n * This function is the opposite case and must **overwrite**. A person opening\n * Manage Account, typing a URL and pressing Save has stated a preference; a\n * write that declined to replace the IdP thumbnail already sitting on the row\n * would leave them looking at the old picture with no way to change it, which\n * is the same defect as the one being fixed, only harder to explain.\n *\n * The two rules are not in tension because their inputs differ in kind: one is\n * an assertion made ABOUT a person by a directory, the other is a choice made\n * BY the person. `member-photo.spec.ts` pins both directions in one file, so a\n * future edit that collapses them into a single helper fails a test.\n *\n * ## The value is not trusted because a member typed it\n *\n * It ends up as an `<img src>` in front of every colleague in the workspace,\n * so it is re-validated here rather than at the client that offered it —\n * `https://` only, the same rule `resolveIdpPhotoUrl` enforces on an IdP\n * assertion (AGL-1131), which exists to refuse `javascript:` and `data:` URLs.\n * The client's check is a courtesy to the person typing; this one is the\n * boundary.\n */\n\nimport { isMediaCdnPath } from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * Matches the cap the staff identity editor already imposes on the same field\n * (`/api/admin/users/manage`, `updateProfile`), so the two surfaces cannot\n * accept different values for one column.\n */\nexport const MEMBER_PHOTO_MAX_LENGTH = 500\n\nexport type MemberPhotoRejection = 'not-https' | 'too-long'\n\n/**\n * Flat rather than a discriminated union on `ok`, because `strictNullChecks`\n * is OFF repo-wide and TypeScript will not narrow a boolean-literal\n * discriminant without it — `if (!value.ok)` left `reason` unreachable in\n * every caller. The shape is still exact: a refusal carries `reason` and no\n * `photoURL`, an acceptance the reverse, and the spec asserts both with\n * `toEqual`.\n */\nexport interface MemberPhotoValue {\n ok: boolean\n /** The storable value, on an acceptance. `''` when clearing. */\n photoURL?: string\n /** Whether this acceptance removes the avatar rather than setting one. */\n clearing?: boolean\n /** Why it was refused, on a refusal. */\n reason?: MemberPhotoRejection\n}\n\n/**\n * Normalize what a caller offered into either a storable URL or a refusal.\n *\n * An empty string is a legitimate value and means CLEAR — \"worth doing at the\n * same time\" per AGL-1976, because otherwise a removed avatar lingers on the\n * roster after it is gone everywhere else, which is the same bug pointing the\n * other way.\n *\n * Pure, so the policy can be tested without Firestore.\n */\nexport function normalizeMemberPhotoUrl(raw: unknown): MemberPhotoValue {\n const value = typeof raw === 'string' ? raw.trim() : ''\n if (!value) return { ok: true, photoURL: '', clearing: true }\n if (value.length > MEMBER_PHOTO_MAX_LENGTH) return { ok: false, reason: 'too-long' }\n // `https://`, or a media-library CDN path (AGL-2286). Still an ALLOWLIST of\n // exactly two shapes rather than a general URL parse: the point is to refuse\n // the schemes that turn an avatar into script execution, and neither shape\n // can be widened by a clever input — `isMediaCdnPath` is anchored on the\n // literal `/api/media/cdn/` prefix and validates both remaining segments\n // against the CDN's own grammar, so `javascript:`, `data:`, a\n // protocol-relative `//evil.example`, a traversal, and a query string are\n // all still refused.\n //\n // The second shape is here because the Manage Account → Profile image field\n // is a `MediaUrlField` offering \"Browse the org media library\", and Browse\n // writes `media.cdnPath`, which is never absolute. Every user's avatar\n // Browse button was therefore a dead control — refused twice, once by the\n // client courtesy check and again here at the boundary.\n if (!/^https:\\/\\//i.test(value) && !isMediaCdnPath(value)) {\n return { ok: false, reason: 'not-https' }\n }\n return { ok: true, photoURL: value, clearing: false }\n}\n\nexport interface PropagateMemberPhotoResult {\n /** Orgs whose roster row was updated. */\n orgIds: string[]\n /** Memberships listed but with no roster row to write — see below. */\n missingRows: string[]\n /** Whether the photo was removed rather than set. */\n cleared: boolean\n}\n\n/**\n * Write `photoURL` — and nothing else — onto every roster row for `uid`.\n *\n * Memberships are enumerated from `users/{uid}/orgs`, the same reverse index\n * `eraseUser` walks to find the rosters a person appears on. Using it rather\n * than a collection-group query over `members` is deliberate: a collection\n * group would read every workspace's roster in the estate to find one person's\n * rows, and this runs on an interactive save.\n *\n * A membership whose roster row does NOT exist is reported and skipped, never\n * created. Minting a row here would produce a membership document with no\n * `role`, which every permission check that asks only whether the doc exists\n * reads as a member of some kind — the AGL-1122 bug class, and the same reason\n * `backfillMemberIdentity` refuses it.\n *\n * Each write is a `merge` carrying exactly one key, so `role`, `allHosts`,\n * `hostAccess` and `roleId` are untouched by construction rather than by\n * remembering to leave them alone.\n *\n * @param input - the uid to write for and the value: an `https://` URL to\n * set, or `''`/`null` to clear.\n */\nexport async function propagateMemberPhoto(input: {\n uid: string\n photoURL: string | null\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}): Promise<PropagateMemberPhotoResult> {\n const db = input.firestore ?? firestore()\n const normalized = normalizeMemberPhotoUrl(input.photoURL)\n if (!normalized.ok) {\n // Callers validate first; reaching here means a route forgot to. Throwing\n // is better than storing an unvalidated value on ten colleagues' screens.\n throw new Error(`propagateMemberPhoto: refused photo (${normalized.reason})`)\n }\n if (!input.uid) return { orgIds: [], missingRows: [], cleared: normalized.clearing }\n\n const memberships = await db\n .collection('users')\n .doc(input.uid)\n .collection('orgs')\n .get()\n\n const orgIds: string[] = []\n const missingRows: string[] = []\n for (const row of memberships.docs) {\n const ref = db\n .collection('orgs')\n .doc(row.id)\n .collection('members')\n .doc(input.uid)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n missingRows.push(row.id)\n continue\n }\n // `FieldValue.delete()` rather than `''` on a clear. A blank string is a\n // value, and `backfillMemberIdentity`'s `blank()` treats it as absent —\n // so storing `''` would invite the IdP to refill the row on the next\n // sign-in with exactly the photo the person just removed.\n await ref.set(\n {\n photoURL: normalized.clearing\n ? FieldValue.delete()\n : normalized.photoURL,\n },\n { merge: true },\n )\n orgIds.push(row.id)\n }\n\n return { orgIds, missingRows, cleared: normalized.clearing }\n}\n\nexport default propagateMemberPhoto\n"],"names":["isMediaCdnPath","FieldValue","firebaseAdmin","firestore","app","MEMBER_PHOTO_MAX_LENGTH","normalizeMemberPhotoUrl","raw","value","trim","ok","photoURL","clearing","length","reason","test","propagateMemberPhoto","input","db","normalized","Error","uid","orgIds","missingRows","cleared","memberships","collection","doc","get","row","docs","ref","id","snapshot","exists","push","set","delete","merge"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GAED,SAASA,cAAc,QAAQ,sBAAqB;AACpD,SAASC,UAAU,QAAQ,2BAA0B;AACrD,OAAOC,mBAAmB,sBAAkB;AAE5C,MAAMC,YAAY,IAAMD,cAAcE,GAAG,GAAGD,SAAS;AAErD;;;;CAIC,GACD,OAAO,MAAME,0BAA0B,IAAG;AAsB1C;;;;;;;;;CASC,GACD,OAAO,SAASC,wBAAwBC,GAAY;IAClD,MAAMC,QAAQ,OAAOD,QAAQ,WAAWA,IAAIE,IAAI,KAAK;IACrD,IAAI,CAACD,OAAO,OAAO;QAAEE,IAAI;QAAMC,UAAU;QAAIC,UAAU;IAAK;IAC5D,IAAIJ,MAAMK,MAAM,GAAGR,yBAAyB,OAAO;QAAEK,IAAI;QAAOI,QAAQ;IAAW;IACnF,4EAA4E;IAC5E,6EAA6E;IAC7E,2EAA2E;IAC3E,yEAAyE;IACzE,yEAAyE;IACzE,8DAA8D;IAC9D,0EAA0E;IAC1E,qBAAqB;IACrB,EAAE;IACF,4EAA4E;IAC5E,2EAA2E;IAC3E,uEAAuE;IACvE,0EAA0E;IAC1E,wDAAwD;IACxD,IAAI,CAAC,eAAeC,IAAI,CAACP,UAAU,CAACR,eAAeQ,QAAQ;QACzD,OAAO;YAAEE,IAAI;YAAOI,QAAQ;QAAY;IAC1C;IACA,OAAO;QAAEJ,IAAI;QAAMC,UAAUH;QAAOI,UAAU;IAAM;AACtD;AAWA;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,eAAeI,qBAAqBC,KAK1C;QACYA;IAAX,MAAMC,MAAKD,mBAAAA,MAAMd,SAAS,YAAfc,mBAAmBd;IAC9B,MAAMgB,aAAab,wBAAwBW,MAAMN,QAAQ;IACzD,IAAI,CAACQ,WAAWT,EAAE,EAAE;QAClB,0EAA0E;QAC1E,0EAA0E;QAC1E,MAAM,IAAIU,MAAM,CAAC,qCAAqC,EAAED,WAAWL,MAAM,CAAC,CAAC,CAAC;IAC9E;IACA,IAAI,CAACG,MAAMI,GAAG,EAAE,OAAO;QAAEC,QAAQ,EAAE;QAAEC,aAAa,EAAE;QAAEC,SAASL,WAAWP,QAAQ;IAAC;IAEnF,MAAMa,cAAc,MAAMP,GACvBQ,UAAU,CAAC,SACXC,GAAG,CAACV,MAAMI,GAAG,EACbK,UAAU,CAAC,QACXE,GAAG;IAEN,MAAMN,SAAmB,EAAE;IAC3B,MAAMC,cAAwB,EAAE;IAChC,KAAK,MAAMM,OAAOJ,YAAYK,IAAI,CAAE;QAClC,MAAMC,MAAMb,GACTQ,UAAU,CAAC,QACXC,GAAG,CAACE,IAAIG,EAAE,EACVN,UAAU,CAAC,WACXC,GAAG,CAACV,MAAMI,GAAG;QAChB,MAAMY,WAAW,MAAMF,IAAIH,GAAG;QAC9B,IAAI,CAACK,SAASC,MAAM,EAAE;YACpBX,YAAYY,IAAI,CAACN,IAAIG,EAAE;YACvB;QACF;QACA,yEAAyE;QACzE,wEAAwE;QACxE,qEAAqE;QACrE,0DAA0D;QAC1D,MAAMD,IAAIK,GAAG,CACX;YACEzB,UAAUQ,WAAWP,QAAQ,GACzBX,WAAWoC,MAAM,KACjBlB,WAAWR,QAAQ;QACzB,GACA;YAAE2B,OAAO;QAAK;QAEhBhB,OAAOa,IAAI,CAACN,IAAIG,EAAE;IACpB;IAEA,OAAO;QAAEV;QAAQC;QAAaC,SAASL,WAAWP,QAAQ;IAAC;AAC7D;AAEA,eAAeI,qBAAoB"}
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
* Throttle for the UNAUTHENTICATED site-member password-recovery endpoint
|
|
19
|
+
* (AGL-1966).
|
|
20
|
+
*
|
|
21
|
+
* ## The shape being closed
|
|
22
|
+
*
|
|
23
|
+
* Two unauthenticated tenant plugin endpoints compose into a mail relay on
|
|
24
|
+
* `aglyn.com`'s sending domain: `membership/register` puts an arbitrary
|
|
25
|
+
* address into `hosts/{hostId}/siteMembers` (member accounts are unlimited on
|
|
26
|
+
* every plan, AGL-889, so nothing there refuses), and `membership/recover`
|
|
27
|
+
* mails a reset link to any address that has such a row. Register N addresses,
|
|
28
|
+
* call recover N times, and every one is a real message we sent, carrying an
|
|
29
|
+
* attacker-chosen site name in the subject.
|
|
30
|
+
*
|
|
31
|
+
* The only thing in the way was the dispatcher's `visitorWriteRateLimitRefusal`
|
|
32
|
+
* — 120 writes/60s per (site, IP), a bucket register and recover SHARE. That
|
|
33
|
+
* ceiling is sized for a shopper editing cart quantities; measured as mail it
|
|
34
|
+
* is ~86k messages a day from one address.
|
|
35
|
+
*
|
|
36
|
+
* The damage is not the Resend bill. It is the sending reputation of
|
|
37
|
+
* `aglyn.com`, which is shared by every customer's transactional mail, and
|
|
38
|
+
* which cannot be bought back after the fact.
|
|
39
|
+
*
|
|
40
|
+
* ## Why this is a separate module from `password-reset-throttle.ts`
|
|
41
|
+
*
|
|
42
|
+
* That one guards ADMIN-initiated resets, and says so at length: every caller
|
|
43
|
+
* is already authenticated as staff, an org admin or a site admin, "so this is
|
|
44
|
+
* **not** the enumeration defense that guards the public `membership/recover`
|
|
45
|
+
* endpoint — nobody reaches here without already knowing the account exists."
|
|
46
|
+
* This is that defense. The caps differ (there is no actor to key on here, and
|
|
47
|
+
* an IP is not an actor), and the refusal contract differs — see below, it is
|
|
48
|
+
* the part that is easy to get wrong.
|
|
49
|
+
*
|
|
50
|
+
* It lives at this layer, beside its sibling, for the reason that one gives:
|
|
51
|
+
* the commerce plugin cannot import the console's API helpers, and a number
|
|
52
|
+
* duplicated in two places drifts.
|
|
53
|
+
*
|
|
54
|
+
* ## Two kinds of cap, because they have two different refusals
|
|
55
|
+
*
|
|
56
|
+
* This is the load-bearing distinction in the module, and it exists entirely
|
|
57
|
+
* to keep the endpoint from becoming an existence oracle.
|
|
58
|
+
*
|
|
59
|
+
* **Attempt caps** ({@link consumeMembershipRecoverAttempt}) — per recipient
|
|
60
|
+
* and per IP. Consumed BEFORE the member lookup, so a request for an address
|
|
61
|
+
* that is a member and a request for one that is not spend exactly the same
|
|
62
|
+
* budget and get exactly the same answer. Their refusal may therefore be a
|
|
63
|
+
* visible `429`: it says nothing about whether the address exists, because the
|
|
64
|
+
* counter that produced it counts both alike. Consuming these *after* the
|
|
65
|
+
* lookup, or only on the send path, would be the bug — the 429 would then
|
|
66
|
+
* appear only for real members and the endpoint's whole silent-success
|
|
67
|
+
* contract would be undone by its own rate limiter.
|
|
68
|
+
*
|
|
69
|
+
* **The send cap** ({@link consumeMembershipRecoverSend}) — per host, per day.
|
|
70
|
+
* Consumed only when a message is actually about to go out, because it is a
|
|
71
|
+
* ceiling on OUTBOUND MAIL rather than on requests. That means it is only ever
|
|
72
|
+
* reached on the member-exists branch, so its refusal must NOT be visible: the
|
|
73
|
+
* caller takes the same silent `200 {ok:true}` exit as an unknown address. The
|
|
74
|
+
* cost of that is a real member who gets no mail and no error during an
|
|
75
|
+
* attack on their site — collateral that is accepted deliberately, and sized
|
|
76
|
+
* for below.
|
|
77
|
+
*
|
|
78
|
+
* ## Backed by the durable limiter, not a `Map`
|
|
79
|
+
*
|
|
80
|
+
* `membership-recover.ts` previously carried a per-instance `Map` keyed on
|
|
81
|
+
* IP+email. On Vercel that is close to no limit at all — it resets on every
|
|
82
|
+
* cold start and each concurrent instance keeps its own, so the effective cap
|
|
83
|
+
* is `limit × instances` and an attacker widens it by going wider (AGL-794).
|
|
84
|
+
* A mail send is exactly the "consequence, not volume" case that
|
|
85
|
+
* `RATE_LIMITING.md` says to spend a Firestore transaction on.
|
|
86
|
+
*
|
|
87
|
+
* ### Contention fails CLOSED here, and that is correct (AGL-2404)
|
|
88
|
+
*
|
|
89
|
+
* `consumeRateLimit` refuses with `contended: true` when one key is hot, and
|
|
90
|
+
* only degrades when the store itself is unreachable. Both postures land well
|
|
91
|
+
* on this surface. The attempt keys are per recipient and per IP, so a
|
|
92
|
+
* legitimate visitor essentially never races themselves — concurrency on one
|
|
93
|
+
* of these keys is the attack. And the send key is per host per day, which is
|
|
94
|
+
* one document: a busy site's genuine recover traffic is a handful of requests
|
|
95
|
+
* an hour, so contention there is likewise a flood rather than a Tuesday.
|
|
96
|
+
*/
|
|
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
|
+
*/
|
|
110
|
+
export declare const RECOVER_SENDS_PER_RECIPIENT = 3;
|
|
111
|
+
/**
|
|
112
|
+
* Recovery attempts per client IP, per hour, across every site and address.
|
|
113
|
+
*
|
|
114
|
+
* Sized against the human behaviour, not the attack: a person who has
|
|
115
|
+
* forgotten a password tries it on one or two sites, mistypes the address
|
|
116
|
+
* once, and stops. Ten is already several times that. A household or office
|
|
117
|
+
* NAT sharing one address would need ten genuine forgotten passwords in one
|
|
118
|
+
* hour to notice this.
|
|
119
|
+
*
|
|
120
|
+
* This is the cap that actually prices enumeration. The per-recipient cap
|
|
121
|
+
* alone does nothing against the register-then-recover composition, because
|
|
122
|
+
* every address in that attack is fresh and none of them has a count.
|
|
123
|
+
*/
|
|
124
|
+
export declare const RECOVER_ATTEMPTS_PER_IP = 10;
|
|
125
|
+
/** Both attempt caps use a one-hour fixed window, like their sibling. */
|
|
126
|
+
export declare const RECOVER_ATTEMPT_WINDOW_MS: number;
|
|
127
|
+
/**
|
|
128
|
+
* Recovery mails one SITE may emit per day.
|
|
129
|
+
*
|
|
130
|
+
* An abuse ceiling, not a usage limit, and it is set where it is because of
|
|
131
|
+
* the collateral described above: it must sit far enough above any real site's
|
|
132
|
+
* genuine volume that reaching it is evidence of an attack. A storefront with
|
|
133
|
+
* ten thousand members sees single-digit resets a day; two hundred is more
|
|
134
|
+
* than an order of magnitude of headroom.
|
|
135
|
+
*
|
|
136
|
+
* What it buys, given the dispatcher's 120/min already upstream: it converts
|
|
137
|
+
* "~86k messages a day per site from one IP" into 200, and — because it is
|
|
138
|
+
* keyed on the site rather than on the caller — it holds no matter how many
|
|
139
|
+
* IPs the caller brings. That is the property the compound visitor-write key
|
|
140
|
+
* explicitly cannot have, and the reason this cap is per host alone.
|
|
141
|
+
*/
|
|
142
|
+
export declare const RECOVER_SENDS_PER_HOST_PER_DAY = 200;
|
|
143
|
+
/** Fixed 24h window for the send cap. */
|
|
144
|
+
export declare const RECOVER_SEND_WINDOW_MS: number;
|
|
145
|
+
/**
|
|
146
|
+
* How new a member row may be and still be worth a reset mail.
|
|
147
|
+
*
|
|
148
|
+
* The register-then-recover composition is the whole attack, and no real
|
|
149
|
+
* person needs a password reset in the first ten minutes of having chosen the
|
|
150
|
+
* password. This is the cheapest control on the list and the only one that
|
|
151
|
+
* refuses the composition itself rather than rationing it.
|
|
152
|
+
*
|
|
153
|
+
* Enforced by the handler (it needs the member document); the constant lives
|
|
154
|
+
* here so the policy is in one place.
|
|
155
|
+
*/
|
|
156
|
+
export declare const RECOVER_MIN_MEMBER_AGE_MS: number;
|
|
157
|
+
export interface MembershipRecoverThrottleResult {
|
|
158
|
+
allowed: boolean;
|
|
159
|
+
/** Seconds until the exhausted window rolls over; 0 when allowed. */
|
|
160
|
+
retryAfterSeconds: number;
|
|
161
|
+
/** Which cap refused, for logs. Never told to the caller. */
|
|
162
|
+
limited: 'recipient' | 'ip' | 'host' | null;
|
|
163
|
+
/** True when the durable store was unreachable and only a local cap applied. */
|
|
164
|
+
degraded: boolean;
|
|
165
|
+
/** True when the key was contended and the request was refused on that basis. */
|
|
166
|
+
contended: boolean;
|
|
167
|
+
}
|
|
168
|
+
export interface MembershipRecoverAttemptOptions {
|
|
169
|
+
/** The address the recovery was requested for, normalized by the caller. */
|
|
170
|
+
email: string;
|
|
171
|
+
/**
|
|
172
|
+
* The caller's address from `readClientIp`, or `null` when nothing readable
|
|
173
|
+
* named one — in which case the per-source cap is SKIPPED rather than shared.
|
|
174
|
+
* One `recover:ip:unknown` budget would spend every site's recovery
|
|
175
|
+
* allowance on behalf of whoever tried first; the per-recipient cap above is
|
|
176
|
+
* the half that stops mailbombing one person and needs no address.
|
|
177
|
+
*/
|
|
178
|
+
ip: string | null;
|
|
179
|
+
now?: number;
|
|
180
|
+
/** Injectable for tests; defaults to the Admin SDK's Firestore. */
|
|
181
|
+
firestore?: unknown;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Counts one recovery ATTEMPT against the per-recipient and per-IP caps.
|
|
185
|
+
*
|
|
186
|
+
* Call before deciding whether the address is a member — see the module note
|
|
187
|
+
* on why the ordering is the control and not an implementation detail.
|
|
188
|
+
*
|
|
189
|
+
* The recipient cap is checked first and short-circuits, matching
|
|
190
|
+
* `consumePasswordResetSend`: one address being hammered must not also spend
|
|
191
|
+
* the IP budget that a person sharing that NAT is relying on.
|
|
192
|
+
*
|
|
193
|
+
* The email is passed through to the key in the clear on purpose —
|
|
194
|
+
* `consumeRateLimit` hashes every key into the document id precisely so that
|
|
195
|
+
* caller-held identifiers do not sit in plaintext ids or index exports.
|
|
196
|
+
*/
|
|
197
|
+
export declare function consumeMembershipRecoverAttempt(options: MembershipRecoverAttemptOptions): Promise<MembershipRecoverThrottleResult>;
|
|
198
|
+
export interface MembershipRecoverSendOptions {
|
|
199
|
+
hostId: string;
|
|
200
|
+
now?: number;
|
|
201
|
+
firestore?: unknown;
|
|
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
|
+
*/
|
|
210
|
+
export declare function consumeMembershipRecoverSend(options: MembershipRecoverSendOptions): Promise<MembershipRecoverThrottleResult>;
|
|
211
|
+
export default consumeMembershipRecoverAttempt;
|