@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/account-emails.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The store behind several email addresses per account (AGL-2486).\n *\n * The policy — which address anything is allowed to READ — lives in\n * `@aglyn/aglyn/app-utils/account-emails`, pure and client-importable. This\n * module is the Admin-SDK half: the writes, the uniqueness guard, and the\n * verification round-trip.\n *\n * ## Storage, and why every bit of it is server-only\n *\n * - `users/{uid}/emails/{address}` — the rows. SERVER-WRITE ONLY. This is\n * not a style choice: `users/{uid}` itself is `allow read, write` for its\n * owner under the rules, with no field validation whatsoever, so anything\n * stored ON that document is a field its owner can set to whatever they\n * like. An owner-writable `verified: true` is the entire feature defeated\n * — claim `ceo@acme.com`, flip the bit, and you hold a sign-in identifier\n * for an address you have never been able to receive mail at. So the rows\n * live in a subcollection with `allow write: if false`, exactly like\n * `passkeys` and `legalAcceptances`, and for exactly the same reason:\n * a client-writable credential store is an auth bypass.\n * - `emailIdentityIndex/{address}` — `{ uid }`, deny-all. THE uniqueness\n * guard. Two accounts holding the same verified address makes the sign-in\n * identifier ambiguous, which is an account-takeover vector, not a\n * cosmetic clash. Modelled on `passkeyCredentialIndex`.\n * - `emailVerifications/{tokenId}` — `{ uid, address, digest, expiresAt }`,\n * deny-all, single-use. Only the DIGEST is stored, so a database read does\n * not yield a working confirmation link. Modelled on\n * `webauthnChallenges`.\n *\n * ## What is deliberately NOT here\n *\n * There is no `domainsFor(uid)`, no `emailsInDomain(domain)`, and no lookup\n * that answers \"which orgs could this account belong to\". Adding one would be\n * the whole hazard: `sso-jit` grants org membership from a verified email's\n * domain, and a helper that handed it this account's OTHER addresses would\n * turn \"add an address\" into \"join an organization\". The SSO paths read the\n * address the IdP asserted at sign-in and nothing else — see\n * `apps/console/specs/account-emails-never-reach-sso.spec.ts`, which fails if\n * that stops being true.\n */\n\nimport { createHash, randomBytes, timingSafeEqual } from 'node:crypto'\nimport {\n MAX_ACCOUNT_EMAILS,\n evaluatePrimaryChange,\n canRemoveAccountEmail,\n normalizeAccountEmail,\n type AccountEmail,\n type PrimaryChangeVerdict,\n} from '@aglyn/aglyn/app-utils/account-emails'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { authForPool, findUserByEmailAcrossPools } from './auth-pools'\nimport { ssoDomainEnforcementEnabled, ssoRequiredDomains } from './sso-domain-policy'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport const ACCOUNT_EMAILS_SUBCOLLECTION = 'emails'\nexport const EMAIL_IDENTITY_INDEX_COLLECTION = 'emailIdentityIndex'\nexport const EMAIL_VERIFICATIONS_COLLECTION = 'emailVerifications'\n\n/**\n * Long enough that a confirmation link survives a mail client's queue and a\n * user who reads mail in the evening; short enough that a link sitting in a\n * forwarded thread stops working. Firebase's own action links use one day.\n */\nexport const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000\n\n/** Stored row shape. `address` mirrors the document id. */\ninterface StoredAccountEmail {\n address: string\n verified: boolean\n primary: boolean\n createdAt?: unknown\n verifiedAt?: unknown\n}\n\nexport interface AccountEmailRow extends AccountEmail {\n /** Null until the round-trip completes. ISO string for the API surface. */\n verifiedAt: string | null\n}\n\n/** Every refusal this module can produce, as a stable machine-readable code. */\nexport type AccountEmailRefusal =\n | 'invalid-address'\n | 'cap-reached'\n | 'already-on-this-account'\n | 'claimed-by-another-account'\n | 'unknown-address'\n | 'cannot-remove-primary'\n | 'last-verified-address'\n | 'token-invalid'\n | 'token-expired'\n | PrimaryChangeVerdict\n\nexport interface AccountEmailResult {\n ok: boolean\n refusal: AccountEmailRefusal | null\n message: string | null\n}\n\nconst ok = (): AccountEmailResult => ({ ok: true, refusal: null, message: null })\nconst refuse = (\n refusal: AccountEmailRefusal,\n message: string,\n): AccountEmailResult => ({ ok: false, refusal, message })\n\nfunction emailsRef(uid: string) {\n return firestore().collection('users').doc(uid).collection(ACCOUNT_EMAILS_SUBCOLLECTION)\n}\n\nfunction indexRef(address: string) {\n return firestore().collection(EMAIL_IDENTITY_INDEX_COLLECTION).doc(address)\n}\n\nfunction toRow(data: StoredAccountEmail): AccountEmailRow {\n const verifiedAt = data.verifiedAt as { toDate?: () => Date } | undefined\n return {\n address: data.address,\n verified: data.verified === true,\n primary: data.primary === true,\n verifiedAt:\n verifiedAt !== undefined &&\n verifiedAt !== null &&\n typeof verifiedAt.toDate === 'function'\n ? verifiedAt.toDate().toISOString()\n : null,\n }\n}\n\n/**\n * Read the account's addresses, seeding the primary row from Firebase Auth on\n * first read.\n *\n * THE BACKFILL IS NOT OPTIONAL. Every account that exists today predates this\n * subcollection, so without the seed an established user opens the card and\n * is told they have no addresses — while `decoded.email` says otherwise — and\n * then \"you cannot remove your last verified address\" would be enforced\n * against an empty set. The Firebase Auth record is the authority for the\n * primary, so the seed is a mirror rather than a new fact.\n *\n * `authEmailVerified` is carried through honestly: an account that has never\n * confirmed its own sign-up address gets an UNVERIFIED primary row, which is\n * the truth and which the card then offers to fix.\n */\nexport async function listAccountEmails(\n uid: string,\n authEmail: string | null | undefined,\n authEmailVerified: boolean,\n): Promise<AccountEmailRow[]> {\n const snapshot = await emailsRef(uid).get()\n const rows = snapshot.docs.map((doc) => toRow(doc.data() as StoredAccountEmail))\n\n const primaryAddress = normalizeAccountEmail(authEmail)\n if (primaryAddress === null) return rows\n\n const known = rows.find((row) => row.address === primaryAddress)\n if (known !== undefined) {\n // The Auth record is the authority for WHICH address is primary. If they\n // ever disagree — a staff email change through the admin console, say —\n // Auth wins and the mirror is corrected, rather than the card showing a\n // primary the token does not carry.\n if (known.primary !== true) {\n await reconcilePrimaryFlag(uid, primaryAddress)\n return listAccountEmails(uid, authEmail, authEmailVerified)\n }\n return rows\n }\n\n await emailsRef(uid)\n .doc(primaryAddress)\n .set(\n {\n address: primaryAddress,\n verified: authEmailVerified === true,\n primary: true,\n createdAt: FieldValue.serverTimestamp(),\n ...(authEmailVerified === true\n ? { verifiedAt: FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n /*\n * CLEAR THE FLAG OFF WHATEVER USED TO HOLD IT.\n *\n * This branch runs when Auth's address is not among the stored rows — which\n * is exactly what a staff email change produces, since it writes the Auth\n * record directly. Setting the new row primary without clearing the old one\n * left TWO rows flagged primary, and every reader that takes the first match\n * then answers with whichever Firestore returned first.\n *\n * Only when there were pre-existing rows: a first-ever seed has nothing to\n * reconcile and must not pay for a second read and a batch commit.\n */\n if (rows.length > 0) await reconcilePrimaryFlag(uid, primaryAddress)\n // Claim the index for it too, but never steal one: an address already\n // indexed to somebody else is a real conflict that a backfill must not\n // paper over. Best-effort — a failed claim must not stop someone reading\n // their own settings page.\n if (authEmailVerified === true) {\n try {\n await claimIndexEntry(primaryAddress, uid)\n } catch (error) {\n console.error('[account-emails] primary index backfill failed', error)\n }\n }\n return [\n ...rows,\n {\n address: primaryAddress,\n verified: authEmailVerified === true,\n primary: true,\n verifiedAt: null,\n },\n ]\n}\n\nasync function reconcilePrimaryFlag(uid: string, primaryAddress: string): Promise<void> {\n const snapshot = await emailsRef(uid).get()\n const batch = firestore().batch()\n for (const doc of snapshot.docs) {\n const shouldBePrimary = doc.id === primaryAddress\n if ((doc.get('primary') === true) !== shouldBePrimary) {\n batch.set(doc.ref, { primary: shouldBePrimary }, { merge: true })\n }\n }\n await batch.commit()\n}\n\n/**\n * Claim `address` for `uid` in the uniqueness index, transactionally.\n *\n * Returns false when another account already holds it. Re-claiming your own\n * is a no-op success, which is what makes the whole flow idempotent.\n */\nasync function claimIndexEntry(address: string, uid: string): Promise<boolean> {\n return firestore().runTransaction(async (transaction) => {\n const ref = indexRef(address)\n const existing = await transaction.get(ref)\n if (existing.exists) {\n const owner = existing.get('uid')\n if (owner !== uid) return false\n return true\n }\n transaction.set(ref, { uid, address, claimedAt: FieldValue.serverTimestamp() })\n return true\n })\n}\n\n/**\n * Is `address` spoken for by an account other than `uid`?\n *\n * Consults BOTH the index and Firebase Auth, and the Auth half is the\n * load-bearing one. The index only knows about addresses that have been\n * through this feature; every account that existed before it has its primary\n * in the Auth record and nowhere else. Checking the index alone would let a\n * brand-new account claim an established user's sign-up address, which is the\n * exact ambiguity the uniqueness rule exists to prevent.\n *\n * `findUserByEmailAcrossPools` rather than a project-pool `getUserByEmail`:\n * an SSO user lives in their org's GCIP tenant and is invisible to\n * project-level lookups (AGL-1122), so the narrow call would report an\n * enterprise customer's address as free.\n */\nasync function addressTakenByAnother(address: string, uid: string): Promise<boolean> {\n const indexed = await indexRef(address).get()\n if (indexed.exists && indexed.get('uid') !== uid) return true\n const pooled = await findUserByEmailAcrossPools(address)\n if (pooled !== null && pooled !== undefined && pooled.record.uid !== uid) return true\n return false\n}\n\n/**\n * Stage a new address and return the secret for its confirmation link.\n *\n * The address is stored UNVERIFIED and does nothing until the round-trip\n * completes: it is not a sign-in identifier, it receives no account mail, and\n * — importantly — it does NOT claim the uniqueness index. Claiming on `add`\n * would make this endpoint a squatting tool: anyone could park an address\n * they do not own and lock its real owner out of adding it. The index is\n * claimed by {@link confirmAccountEmail}, so the first account to prove\n * delivery wins, and proving delivery is the only way to win.\n *\n * The pre-flight `addressTakenByAnother` here is a courtesy, not the guard —\n * it turns the common case into an immediate, honest error instead of a\n * confirmation email that fails at the end. The real check runs again inside\n * the confirmation transaction, because anything can change in between.\n */\nexport async function addAccountEmail(\n uid: string,\n input: unknown,\n): Promise<AccountEmailResult & { address: string | null; secret: string | null }> {\n const address = normalizeAccountEmail(input)\n if (address === null) {\n return {\n ...refuse('invalid-address', 'Enter a valid email address.'),\n address: null,\n secret: null,\n }\n }\n\n const existing = await emailsRef(uid).get()\n if (existing.docs.some((doc) => doc.id === address)) {\n return {\n ...refuse('already-on-this-account', 'That address is already on this account.'),\n address,\n secret: null,\n }\n }\n // THE CAP (see MAX_ACCOUNT_EMAILS). Counted from a server read immediately\n // before the write — a number read anywhere else is a number that can be\n // stale by the time it is trusted.\n if (existing.size >= MAX_ACCOUNT_EMAILS) {\n return {\n ...refuse(\n 'cap-reached',\n `An account can hold ${MAX_ACCOUNT_EMAILS} email addresses. ` +\n 'Remove one before adding another.',\n ),\n address,\n secret: null,\n }\n }\n if (await addressTakenByAnother(address, uid)) {\n return {\n ...refuse(\n 'claimed-by-another-account',\n 'That address is already in use on another account.',\n ),\n address,\n secret: null,\n }\n }\n\n await emailsRef(uid).doc(address).set(\n {\n address,\n verified: false,\n primary: false,\n createdAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n const secret = await issueVerificationToken(uid, address)\n return { ...ok(), address, secret }\n}\n\n/**\n * Mint a single-use confirmation secret and store only its digest.\n *\n * The returned string is `{tokenId}.{secret}`; the caller puts it in the\n * emailed link and never persists it. A reader of the database therefore\n * cannot confirm anybody's address — they hold a SHA-256 of the half that\n * matters.\n */\nexport async function issueVerificationToken(\n uid: string,\n address: string,\n): Promise<string> {\n const tokenId = randomBytes(16).toString('hex')\n const secret = randomBytes(32).toString('hex')\n await firestore()\n .collection(EMAIL_VERIFICATIONS_COLLECTION)\n .doc(tokenId)\n .set({\n uid,\n address,\n digest: createHash('sha256').update(secret).digest('hex'),\n createdAt: FieldValue.serverTimestamp(),\n expiresAt: Date.now() + EMAIL_VERIFICATION_TTL_MS,\n })\n return `${tokenId}.${secret}`\n}\n\n/** Constant-time compare of two hex digests of equal length. */\nfunction digestsMatch(a: unknown, b: string): boolean {\n if (typeof a !== 'string' || a.length !== b.length) return false\n return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))\n}\n\n/**\n * Complete the round-trip: the bearer of this link can receive mail at the\n * address, so the address becomes verified and the account claims it.\n *\n * Deliberately NOT authenticated against the current session. The person\n * clicking the link is whoever opened the mailbox, and requiring them to be\n * signed in as the right account first is how confirmation links strand\n * people who read mail on a different device. The token itself carries the\n * uid, and it can do nothing except verify the one address it was minted for.\n *\n * The uniqueness claim happens HERE and inside the same logical step as the\n * flag flip, so two accounts racing on one address cannot both end up\n * verified.\n */\nexport async function confirmAccountEmail(\n token: unknown,\n): Promise<AccountEmailResult & { address: string | null; uid: string | null }> {\n const raw = String(token ?? '')\n const separator = raw.indexOf('.')\n if (separator <= 0) {\n return {\n ...refuse('token-invalid', 'That confirmation link is not valid.'),\n address: null,\n uid: null,\n }\n }\n const tokenId = raw.slice(0, separator)\n const secret = raw.slice(separator + 1)\n if (!/^[a-f0-9]{32}$/.test(tokenId) || !/^[a-f0-9]{64}$/.test(secret)) {\n return {\n ...refuse('token-invalid', 'That confirmation link is not valid.'),\n address: null,\n uid: null,\n }\n }\n\n const tokenRef = firestore().collection(EMAIL_VERIFICATIONS_COLLECTION).doc(tokenId)\n const snapshot = await tokenRef.get()\n if (!snapshot.exists) {\n return {\n ...refuse('token-invalid', 'That confirmation link has already been used.'),\n address: null,\n uid: null,\n }\n }\n if (!digestsMatch(snapshot.get('digest'), createHash('sha256').update(secret).digest('hex'))) {\n return {\n ...refuse('token-invalid', 'That confirmation link is not valid.'),\n address: null,\n uid: null,\n }\n }\n const expiresAt = Number(snapshot.get('expiresAt') ?? 0)\n if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) {\n await tokenRef.delete()\n return {\n ...refuse('token-expired', 'That confirmation link has expired — send a new one.'),\n address: null,\n uid: null,\n }\n }\n\n const uid = String(snapshot.get('uid') ?? '')\n const address = String(snapshot.get('address') ?? '')\n if (!uid || !address) {\n await tokenRef.delete()\n return {\n ...refuse('token-invalid', 'That confirmation link is not valid.'),\n address: null,\n uid: null,\n }\n }\n\n // Single-use: burned before the claim, so a link cannot be replayed even if\n // the claim below fails.\n await tokenRef.delete()\n\n if (await addressTakenByAnother(address, uid)) {\n return {\n ...refuse(\n 'claimed-by-another-account',\n 'That address has since been confirmed on another account.',\n ),\n address,\n uid,\n }\n }\n const claimed = await claimIndexEntry(address, uid)\n if (!claimed) {\n return {\n ...refuse(\n 'claimed-by-another-account',\n 'That address has since been confirmed on another account.',\n ),\n address,\n uid,\n }\n }\n\n await emailsRef(uid)\n .doc(address)\n .set(\n { address, verified: true, verifiedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ...ok(), address, uid }\n}\n\n/**\n * Remove an address, freeing its index entry.\n *\n * The index entry is released so the address can be used again — on this\n * account or another. Leaving it behind would make removal a way to burn an\n * address permanently, including somebody else's if they ever wanted it.\n */\nexport async function removeAccountEmail(\n uid: string,\n input: unknown,\n): Promise<AccountEmailResult> {\n const address = normalizeAccountEmail(input)\n if (address === null) return refuse('invalid-address', 'Enter a valid email address.')\n\n const snapshot = await emailsRef(uid).get()\n const rows: AccountEmail[] = snapshot.docs.map((doc) => ({\n address: String(doc.get('address') ?? doc.id),\n verified: doc.get('verified') === true,\n primary: doc.get('primary') === true,\n }))\n const verdict = canRemoveAccountEmail(address, rows)\n if (!verdict.allowed) {\n const row = rows.find((entry) => entry.address === address)\n // Distinct codes so the card can say WHICH floor it hit — \"make another\n // address primary first\" and \"this is your only confirmed address\" are\n // different instructions, and a single code would collapse them into one\n // unhelpful sentence.\n const code: AccountEmailRefusal =\n row === undefined\n ? 'unknown-address'\n : row.primary === true\n ? 'cannot-remove-primary'\n : 'last-verified-address'\n return refuse(code, String(verdict.message ?? 'That address cannot be removed.'))\n }\n\n await emailsRef(uid).doc(address).delete()\n const indexed = await indexRef(address).get()\n if (indexed.exists && indexed.get('uid') === uid) {\n await indexRef(address).delete()\n }\n return ok()\n}\n\nexport interface SetPrimaryContext {\n /** `decoded.firebase?.tenant` — the pool the caller signed in through. */\n tenantId: string | null\n}\n\n/**\n * Re-designate the primary address.\n *\n * THE MOST SECURITY-SENSITIVE WRITE IN THIS MODULE, because the primary IS\n * the Firebase Auth record's email, which is what `decoded.email` carries,\n * which is what `evaluateSsoDomainPolicy` reads at the session mint and what\n * the invite-accept comparison matches on. Changing it changes an\n * authorization input — so it goes through `evaluatePrimaryChange` first, and\n * that function refuses to move a primary off a domain the deployment\n * requires SSO for. See its docstring for why that refusal ignores the\n * enforcement switch.\n *\n * The Auth record is updated in the account's OWN pool via `authForPool`: a\n * project-level `updateUser` cannot see a GCIP tenant account, so the narrow\n * call would silently fail for exactly the enterprise users this policy is\n * about.\n *\n * `emailVerified: true` is set alongside the address, and it is honest — the\n * only addresses that can reach this point are ones this module verified by\n * an emailed round-trip. Omitting it would flip the account to unverified and\n * lock it out of every route behind `emailUnverifiedResponse()`.\n */\nexport async function setPrimaryAccountEmail(\n uid: string,\n input: unknown,\n context: SetPrimaryContext,\n): Promise<AccountEmailResult> {\n const address = normalizeAccountEmail(input)\n if (address === null) return refuse('invalid-address', 'Enter a valid email address.')\n\n const snapshot = await emailsRef(uid).get()\n const rows: AccountEmail[] = snapshot.docs.map((doc) => ({\n address: String(doc.get('address') ?? doc.id),\n verified: doc.get('verified') === true,\n primary: doc.get('primary') === true,\n }))\n const current = rows.find((row) => row.primary === true) ?? null\n const next = rows.find((row) => row.address === address) ?? null\n\n const decision = evaluatePrimaryChange({\n current,\n next,\n requiredDomains: ssoRequiredDomains(),\n tenantId: context.tenantId ?? null,\n enforcementEnabled: ssoDomainEnforcementEnabled(),\n })\n if (!decision.allowed) {\n return refuse(decision.verdict, String(decision.message ?? 'That change is not allowed.'))\n }\n\n await authForPool(context.tenantId).updateUser(uid, {\n email: address,\n emailVerified: true,\n })\n await reconcilePrimaryFlag(uid, address)\n return ok()\n}\n\n/**\n * The account that holds `address` as a VERIFIED alias, or null.\n *\n * ## Read this before calling it\n *\n * This is the one lookup that maps an arbitrary address to an account, and it\n * exists for exactly two callers: resolving a sign-in identifier, and\n * matching an org invitation. Both are safe for the same reason — neither\n * GRANTS anything on the strength of the address:\n *\n * - a sign-in identifier still has to be followed by the account's password;\n * - an invitation is an explicit grant the ORG made, and this only decides\n * which of the recipient's mailboxes it may arrive at.\n *\n * It must never be wired into an SSO path. `sso-jit` resolves the org from\n * the GCIP tenant on a re-verified token and matches the domain the IdP\n * asserted; giving it this function would let a user add an address and be\n * provisioned into the matching organization, which is the escalation this\n * whole feature was designed around.\n *\n * Returns null for an unverified row by construction: the index is only ever\n * written by the confirmation path.\n */\nexport async function findAccountByVerifiedAlias(\n input: unknown,\n): Promise<{ uid: string; address: string } | null> {\n const address = normalizeAccountEmail(input)\n if (address === null) return null\n const indexed = await indexRef(address).get()\n if (!indexed.exists) return null\n const uid = String(indexed.get('uid') ?? '')\n if (!uid) return null\n return { uid, address }\n}\n\n/*==========================================\n * PROVIDER-SUPPLIED ADDRESSES.\n *\n * A federated provider asserts its own address, and `providerData[].email`\n * can differ from the primary. Until now nothing registered one: it was\n * absent from `users/{uid}/emails`, held no `emailIdentityIndex` entry, and\n * so lived entirely OUTSIDE the uniqueness guard — while remaining a real\n * mailbox, a working sign-in identifier, and a recipient of real mail.\n *\n * The consequence is a collision the guard was built to prevent and could not\n * see: one address that is one account's primary and another account's Google\n * provider address. Anything mapping address → uid then answers confidently\n * and wrongly.\n *=========================================*/\n\n/** What one {@link registerProviderAddresses} pass did. */\nexport interface ProviderAddressRegistration {\n /** Addresses newly claimed for this account. */\n claimed: string[]\n /** Addresses another account already holds — recorded, never taken. */\n conflicted: string[]\n}\n\n/**\n * Providers whose asserted address counts as proven.\n *\n * `password` is excluded because its address is the primary, already handled,\n * and its verification state lives on the Auth record rather than in the\n * provider entry. Everything else here is a federated IdP that has itself\n * established control of the mailbox — the same standard\n * {@link confirmAccountEmail} applies, met by a different party.\n */\nfunction isFederatedProvider(providerId: string | null | undefined): boolean {\n const id = String(providerId ?? '')\n return id !== '' && id !== 'password' && id !== 'phone' && id !== 'anonymous'\n}\n\n/**\n * Register the addresses a federated provider asserts for this account.\n *\n * Called from the session mint — the one place every interactive sign-in\n * passes through with a verified token — and BEST-EFFORT by contract.\n *\n * ## Three rules, in priority order\n *\n * 1. **Sign-in must not depend on this.** It is bookkeeping. A person locked\n * out because an index write failed is a worse outcome than the collision\n * it was preventing, so every failure here is swallowed and logged, and\n * the caller runs it off the critical path.\n * 2. **A claim never takes an entry another account holds.**\n * {@link claimIndexEntry} already refuses; what was missing is that the\n * refusal went nowhere. A conflict now lands on the row as\n * `indexConflict`, which the staff account page reads — silently skipping\n * is exactly what produced the live collision.\n * 3. **A conflicted address is stored UNVERIFIED.** This is the conservative\n * half and it matters: `verifiedAccountEmails` feeds invitation matching,\n * so marking a contested address verified on both accounts would make one\n * invitation match two people. Unverified, the row is a record that the\n * address exists on this account and grants nothing.\n *\n * ⛔ It does not merge, reassign or disable anything. Two real accounts\n * sharing an address is a human decision — the row makes it visible and stops\n * there.\n *\n * ⚠️ Registering here CANNOT reach SSO provisioning. `sso-jit` reads the\n * address the IdP asserted at sign-in and never consults this store;\n * `apps/console/specs/account-emails-never-reach-sso.spec.ts` fails if that\n * stops being true. That guard is what makes this safe to write at all.\n */\nexport async function registerProviderAddresses(\n uid: string,\n record: {\n email?: string | null\n providerData?: readonly {\n providerId?: string | null\n email?: string | null\n }[]\n } | null,\n): Promise<ProviderAddressRegistration> {\n const claimed: string[] = []\n const conflicted: string[] = []\n const primary = normalizeAccountEmail(record?.email ?? null)\n\n const candidates = new Set<string>()\n for (const provider of record?.providerData ?? []) {\n if (!isFederatedProvider(provider?.providerId)) continue\n const address = normalizeAccountEmail(provider?.email ?? null)\n // The primary is registered by `listAccountEmails`' own backfill, on the\n // Auth record's authority. Re-doing it here would race that seed.\n if (address === null || address === primary) continue\n candidates.add(address)\n }\n\n for (const address of candidates) {\n try {\n const won = await claimIndexEntry(address, uid)\n if (won) claimed.push(address)\n else conflicted.push(address)\n\n await emailsRef(uid)\n .doc(address)\n .set(\n {\n address,\n // Only when the claim was won — see rule 3.\n verified: won,\n primary: false,\n source: 'provider',\n ...(won\n ? { verifiedAt: FieldValue.serverTimestamp(), indexConflict: false }\n : { indexConflict: true, indexConflictAtMs: Date.now() }),\n createdAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n } catch (error) {\n // Rule 1. Never rethrow: the caller is a sign-in.\n console.error('[account-emails] provider address registration failed', uid, error)\n }\n }\n\n if (conflicted.length) {\n // Loud, per rule 2 — a conflict that only ever landed in a document would\n // be discovered by whoever happened to open the right page.\n console.error(\n '[account-emails] provider address already claimed by another account',\n JSON.stringify({ uid, conflicted: conflicted.length }),\n )\n }\n return { claimed, conflicted }\n}\n\n/**\n * Every VERIFIED address on an account, for matching an invitation.\n *\n * Bounded by {@link MAX_ACCOUNT_EMAILS}, which is what makes it safe to feed\n * straight into a Firestore `in` query (limit 30).\n *\n * ⛔ NOT the answer to \"every address this account holds\" — it is verified-only,\n * subcollection-only and silently truncating, all correct here and all wrong\n * there. `account-addresses.ts` is that resolver and explains why at length.\n */\nexport async function verifiedAccountEmails(uid: string): Promise<string[]> {\n const snapshot = await emailsRef(uid).get()\n return snapshot.docs\n .filter((doc) => doc.get('verified') === true)\n .map((doc) => String(doc.get('address') ?? doc.id))\n .slice(0, MAX_ACCOUNT_EMAILS)\n}\n"],"names":["createHash","randomBytes","timingSafeEqual","MAX_ACCOUNT_EMAILS","evaluatePrimaryChange","canRemoveAccountEmail","normalizeAccountEmail","FieldValue","firebaseAdmin","authForPool","findUserByEmailAcrossPools","ssoDomainEnforcementEnabled","ssoRequiredDomains","firestore","app","ACCOUNT_EMAILS_SUBCOLLECTION","EMAIL_IDENTITY_INDEX_COLLECTION","EMAIL_VERIFICATIONS_COLLECTION","EMAIL_VERIFICATION_TTL_MS","ok","refusal","message","refuse","emailsRef","uid","collection","doc","indexRef","address","toRow","data","verifiedAt","verified","primary","undefined","toDate","toISOString","listAccountEmails","authEmail","authEmailVerified","snapshot","get","rows","docs","map","primaryAddress","known","find","row","reconcilePrimaryFlag","set","createdAt","serverTimestamp","merge","length","claimIndexEntry","error","console","batch","shouldBePrimary","id","ref","commit","runTransaction","transaction","existing","exists","owner","claimedAt","addressTakenByAnother","indexed","pooled","record","addAccountEmail","input","secret","some","size","issueVerificationToken","tokenId","toString","digest","update","expiresAt","Date","now","digestsMatch","a","b","Buffer","from","confirmAccountEmail","token","raw","String","separator","indexOf","slice","test","tokenRef","Number","isFinite","delete","claimed","removeAccountEmail","verdict","allowed","entry","code","setPrimaryAccountEmail","context","current","next","decision","requiredDomains","tenantId","enforcementEnabled","updateUser","email","emailVerified","findAccountByVerifiedAlias","isFederatedProvider","providerId","registerProviderAddresses","conflicted","candidates","Set","provider","providerData","add","won","push","source","indexConflict","indexConflictAtMs","JSON","stringify","verifiedAccountEmails","filter"],"mappings":"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"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* THE STAFF AUDIT TRAIL: WHO A ROW IS ABOUT, AND HOW MANY TIMES IT HAPPENED.
|
|
19
|
+
*
|
|
20
|
+
* `adminAudit` is not a convenience log. Firestore rules close
|
|
21
|
+
* `emailDeliveries` to EVERYONE, staff included, and the console route is
|
|
22
|
+
* allowed to read it only because it establishes a staff claim and records
|
|
23
|
+
* who looked. The row this module writes is that compensating control, so
|
|
24
|
+
* the two properties it exists to provide are:
|
|
25
|
+
*
|
|
26
|
+
* 1. **No access is lost.** Two views minutes apart are two accesses. A
|
|
27
|
+
* collapse that dropped one would answer "nobody read your mail" about a
|
|
28
|
+
* read that happened, which is the single failure this collection cannot
|
|
29
|
+
* have.
|
|
30
|
+
* 2. **The subject is answerable.** `target` names the THING acted on; it
|
|
31
|
+
* cannot also name the person, because a message id is not a user path.
|
|
32
|
+
* `subjectUid` is the separate fact, and it is what makes "who at Aglyn
|
|
33
|
+
* read my email" a query rather than a manual trawl.
|
|
34
|
+
*/
|
|
35
|
+
export declare const ADMIN_AUDIT_COLLECTION = "adminAudit";
|
|
36
|
+
/**
|
|
37
|
+
* How close together two identical acts must be to count as ONE act
|
|
38
|
+
* recorded twice.
|
|
39
|
+
*
|
|
40
|
+
* A single click can reach this writer more than once — a re-run effect, a
|
|
41
|
+
* retried request, a double submit — and each arrival is the same access.
|
|
42
|
+
* Ten seconds sits an order of magnitude above that (the observed re-entry
|
|
43
|
+
* was one second apart) and well below a person deliberately re-opening a
|
|
44
|
+
* record (observed at fifty-three seconds). Nothing a human does twice on
|
|
45
|
+
* purpose lands inside it.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ A collapse still RECORDS the repeat: `repeatCount` and `lastAt` carry
|
|
48
|
+
* it, and the console renders both. Widening this window trades away the
|
|
49
|
+
* separateness of two accesses, so it must stay far shorter than the
|
|
50
|
+
* interval at which a second look is a second decision.
|
|
51
|
+
*/
|
|
52
|
+
export declare const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10000;
|
|
53
|
+
/** Access looked at data; change altered something or acted on someone. */
|
|
54
|
+
export type AdminAuditKind = 'access' | 'change';
|
|
55
|
+
/**
|
|
56
|
+
* An access when the platform or a plugin declares the action a read — a
|
|
57
|
+
* plugin's staff card opening on an org or an account names its own read
|
|
58
|
+
* actions through its activity group (AGL-2939) — and a change otherwise.
|
|
59
|
+
*/
|
|
60
|
+
export declare function adminAuditKind(action: string | null | undefined): AdminAuditKind;
|
|
61
|
+
export interface AdminAuditWrite {
|
|
62
|
+
/** The staff account performing the act. */
|
|
63
|
+
actorUid: string;
|
|
64
|
+
action: string;
|
|
65
|
+
/** The THING acted on, as a path or a stable identifier. */
|
|
66
|
+
target: string;
|
|
67
|
+
/**
|
|
68
|
+
* The person the act is ABOUT, when one can be resolved.
|
|
69
|
+
*
|
|
70
|
+
* Absent is a correct answer, not a failure: a recipient may be a site
|
|
71
|
+
* member, a prospect or a bare address with no platform account behind it,
|
|
72
|
+
* and inventing a uid for one of those would put an act on an innocent
|
|
73
|
+
* person's page.
|
|
74
|
+
*/
|
|
75
|
+
subjectUid?: string | null;
|
|
76
|
+
/**
|
|
77
|
+
* `sha256` of the recipient address the act was about, when it had one.
|
|
78
|
+
*
|
|
79
|
+
* ## Why a hash and not the address
|
|
80
|
+
*
|
|
81
|
+
* The SAME derivation `emailSuppressionKey` uses, so this joins the
|
|
82
|
+
* delivery log without a second key space. It is hashed for the reason
|
|
83
|
+
* {@link maskEmailAddress} exists: `adminAudit` is readable by any staff
|
|
84
|
+
* role, and a dump of it must not yield a mailing list.
|
|
85
|
+
*
|
|
86
|
+
* ## Why it exists at all, next to `subjectUid`
|
|
87
|
+
*
|
|
88
|
+
* `subjectUid` can only be written when the address resolves to exactly one
|
|
89
|
+
* account, and an address is not reliably resolvable to one — a
|
|
90
|
+
* provider-supplied address may be held by a second account with nothing
|
|
91
|
+
* recording it. So the subject is now OMITTED whenever the answer is
|
|
92
|
+
* ambiguous, and omitting it alone would make the access invisible on every
|
|
93
|
+
* page, which is worse than the guess it replaced.
|
|
94
|
+
*
|
|
95
|
+
* This is the fact that needs no guess. The account page queries it with
|
|
96
|
+
* the keys of every address that account holds, so one access appears on
|
|
97
|
+
* the page of each account holding the address — which is the honest answer
|
|
98
|
+
* when the mail went to a mailbox rather than to a uid.
|
|
99
|
+
*
|
|
100
|
+
* ## It opens no tier side-door
|
|
101
|
+
*
|
|
102
|
+
* `adminAudit` is `allow read, create: if isStaff()` with NO tier gate, so
|
|
103
|
+
* anything written here is readable by every staff session whatever its
|
|
104
|
+
* role — which makes an audit row a way around a tier restriction on the
|
|
105
|
+
* surface the value came from. Checked, and it is not one here: the writer
|
|
106
|
+
* is the message route, which gates on `staff` alone and nothing finer, so
|
|
107
|
+
* this field is no more widely readable than its source.
|
|
108
|
+
*
|
|
109
|
+
* The hash adds no reach of its own either. It is the document id
|
|
110
|
+
* `emailDeliveries` is already filed under, so any staff session that can
|
|
111
|
+
* read this row could already derive the same value from an address it
|
|
112
|
+
* guessed. A field whose source DID restrict by tier would not belong here.
|
|
113
|
+
*/
|
|
114
|
+
subjectAddressKey?: string | null;
|
|
115
|
+
/** Human-readable context. ⚠️ Never a raw address — see `maskEmailAddress`. */
|
|
116
|
+
note?: string | null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Write one audit row, collapsing an immediate repeat of the same act onto
|
|
120
|
+
* the row already there.
|
|
121
|
+
*
|
|
122
|
+
* The candidate is the MOST RECENT row on the same target, which the live
|
|
123
|
+
* `target ASC, at DESC` index already answers — so this costs one indexed
|
|
124
|
+
* single-document read and needs no index of its own. Anything else landing
|
|
125
|
+
* on that target in between ends the run, which is the conservative
|
|
126
|
+
* direction: an interleaved act means the two views were not one action.
|
|
127
|
+
*
|
|
128
|
+
* The read and the write are one TRANSACTION because the duplicates this
|
|
129
|
+
* exists to catch are CONCURRENT. Two requests from one click race each
|
|
130
|
+
* other; a plain read-then-write would let both see "nothing recent" and
|
|
131
|
+
* both insert, defeating the collapse exactly when it is needed.
|
|
132
|
+
*/
|
|
133
|
+
export declare function recordAdminAudit(entry: AdminAuditWrite): Promise<void>;
|
|
134
|
+
/**
|
|
135
|
+
* An address a human can recognize without the log holding the address.
|
|
136
|
+
*
|
|
137
|
+
* `emailDeliveries` is keyed by `sha256(address)` precisely so we do not keep
|
|
138
|
+
* a readable list of who we mail. An audit row echoing the address in the
|
|
139
|
+
* clear made this collection the less careful of the two for the same data,
|
|
140
|
+
* and `adminAudit` is readable by any staff role.
|
|
141
|
+
*
|
|
142
|
+
* The first character and the domain survive because an audit entry a human
|
|
143
|
+
* cannot read is a weaker control: a staffer checking their own access, or an
|
|
144
|
+
* investigator confirming which of a shortlist was opened, needs to recognize
|
|
145
|
+
* the row. What does not survive is the harvestable part — a dump of this
|
|
146
|
+
* collection yields no mailing list.
|
|
147
|
+
*/
|
|
148
|
+
export declare function maskEmailAddress(address: string | null | undefined): string;
|
|
149
|
+
/** Every recipient, masked, in one note-sized string. */
|
|
150
|
+
export declare function maskEmailAddresses(addresses: readonly string[] | null | undefined): string;
|
|
151
|
+
/**
|
|
152
|
+
* The uid behind a recipient address, or null when there is not one.
|
|
153
|
+
*
|
|
154
|
+
* Across POOLS, not the project pool alone: an enterprise account signing in
|
|
155
|
+
* through GCIP lives in a tenant, and a project-pool `getUserByEmail` would
|
|
156
|
+
* report the customer most in need of this record as "not a user".
|
|
157
|
+
*
|
|
158
|
+
* Null is a real answer. Most addresses we send to belong to site members,
|
|
159
|
+
* prospects and plain contacts with no platform account, and this must never
|
|
160
|
+
* guess one for them.
|
|
161
|
+
*/
|
|
162
|
+
export declare function resolveSubjectUidByEmail(address: string | null | undefined): Promise<string | null>;
|
|
163
|
+
/**
|
|
164
|
+
* The hashed recipient for {@link AdminAuditWrite.subjectAddressKey}.
|
|
165
|
+
*
|
|
166
|
+
* The FIRST recipient, matching `resolveSubjectUidForRecipients` — one scalar
|
|
167
|
+
* cannot represent a multi-recipient message, and the masked note carries the
|
|
168
|
+
* full list either way.
|
|
169
|
+
*/
|
|
170
|
+
export declare function subjectAddressKeyForRecipients(addresses: readonly string[] | null | undefined): string | null;
|
|
171
|
+
/**
|
|
172
|
+
* The first recipient with a platform account.
|
|
173
|
+
*
|
|
174
|
+
* A single scalar subject cannot represent a message sent to several people,
|
|
175
|
+
* and multi-recipient system mail is rare enough that an array field plus its
|
|
176
|
+
* own `array-contains` index would be carried by every writer to serve almost
|
|
177
|
+
* no rows. The full (masked) recipient list stays on the note, so a row for a
|
|
178
|
+
* multi-recipient message still shows that the others were involved.
|
|
179
|
+
*/
|
|
180
|
+
export declare function resolveSubjectUidForRecipients(addresses: readonly string[] | null | undefined): Promise<string | null>;
|
|
@@ -0,0 +1,233 @@
|
|
|
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 { isPluginStaffAuditAccess } from "@aglyn/aglyn/plugin-manager/plugin-activity-actions";
|
|
18
|
+
import { attributableAccountForAddress } from "./account-addresses.js";
|
|
19
|
+
import { emailSuppressionKey } from "./email-suppression.js";
|
|
20
|
+
import { firebaseAdmin } from "./firebase-admin.js";
|
|
21
|
+
/**
|
|
22
|
+
* THE STAFF AUDIT TRAIL: WHO A ROW IS ABOUT, AND HOW MANY TIMES IT HAPPENED.
|
|
23
|
+
*
|
|
24
|
+
* `adminAudit` is not a convenience log. Firestore rules close
|
|
25
|
+
* `emailDeliveries` to EVERYONE, staff included, and the console route is
|
|
26
|
+
* allowed to read it only because it establishes a staff claim and records
|
|
27
|
+
* who looked. The row this module writes is that compensating control, so
|
|
28
|
+
* the two properties it exists to provide are:
|
|
29
|
+
*
|
|
30
|
+
* 1. **No access is lost.** Two views minutes apart are two accesses. A
|
|
31
|
+
* collapse that dropped one would answer "nobody read your mail" about a
|
|
32
|
+
* read that happened, which is the single failure this collection cannot
|
|
33
|
+
* have.
|
|
34
|
+
* 2. **The subject is answerable.** `target` names the THING acted on; it
|
|
35
|
+
* cannot also name the person, because a message id is not a user path.
|
|
36
|
+
* `subjectUid` is the separate fact, and it is what makes "who at Aglyn
|
|
37
|
+
* read my email" a query rather than a manual trawl.
|
|
38
|
+
*/ export const ADMIN_AUDIT_COLLECTION = 'adminAudit';
|
|
39
|
+
/**
|
|
40
|
+
* How close together two identical acts must be to count as ONE act
|
|
41
|
+
* recorded twice.
|
|
42
|
+
*
|
|
43
|
+
* A single click can reach this writer more than once — a re-run effect, a
|
|
44
|
+
* retried request, a double submit — and each arrival is the same access.
|
|
45
|
+
* Ten seconds sits an order of magnitude above that (the observed re-entry
|
|
46
|
+
* was one second apart) and well below a person deliberately re-opening a
|
|
47
|
+
* record (observed at fifty-three seconds). Nothing a human does twice on
|
|
48
|
+
* purpose lands inside it.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ A collapse still RECORDS the repeat: `repeatCount` and `lastAt` carry
|
|
51
|
+
* it, and the console renders both. Widening this window trades away the
|
|
52
|
+
* separateness of two accesses, so it must stay far shorter than the
|
|
53
|
+
* interval at which a second look is a second decision.
|
|
54
|
+
*/ export const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10000;
|
|
55
|
+
/**
|
|
56
|
+
* The actions that only LOOKED.
|
|
57
|
+
*
|
|
58
|
+
* An exception list, not a classification of everything, and the default
|
|
59
|
+
* matters more than the membership: anything absent is a `change`. A change
|
|
60
|
+
* is the louder half of the console's audit card, so an action nobody has
|
|
61
|
+
* classified yet gets the MORE prominent treatment rather than the quieter
|
|
62
|
+
* one. The failure mode of the opposite default is an unclassified
|
|
63
|
+
* impersonation rendering as routine browsing.
|
|
64
|
+
*
|
|
65
|
+
* An export is deliberately NOT here. Data leaving the platform is a
|
|
66
|
+
* high-consequence act even though it mutates nothing, and it belongs beside
|
|
67
|
+
* the impersonations rather than beside the record views.
|
|
68
|
+
*/ const ADMIN_AUDIT_ACCESS_ACTIONS = new Set([
|
|
69
|
+
'email.message-viewed'
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* An access when the platform or a plugin declares the action a read — a
|
|
73
|
+
* plugin's staff card opening on an org or an account names its own read
|
|
74
|
+
* actions through its activity group (AGL-2939) — and a change otherwise.
|
|
75
|
+
*/ export function adminAuditKind(action) {
|
|
76
|
+
return action && (ADMIN_AUDIT_ACCESS_ACTIONS.has(action) || isPluginStaffAuditAccess(action)) ? 'access' : 'change';
|
|
77
|
+
}
|
|
78
|
+
function toDate(value) {
|
|
79
|
+
var _value_toDate;
|
|
80
|
+
const converted = value == null ? void 0 : (_value_toDate = value.toDate) == null ? void 0 : _value_toDate.call(value);
|
|
81
|
+
if (converted instanceof Date) return converted;
|
|
82
|
+
return value instanceof Date ? value : null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Write one audit row, collapsing an immediate repeat of the same act onto
|
|
86
|
+
* the row already there.
|
|
87
|
+
*
|
|
88
|
+
* The candidate is the MOST RECENT row on the same target, which the live
|
|
89
|
+
* `target ASC, at DESC` index already answers — so this costs one indexed
|
|
90
|
+
* single-document read and needs no index of its own. Anything else landing
|
|
91
|
+
* on that target in between ends the run, which is the conservative
|
|
92
|
+
* direction: an interleaved act means the two views were not one action.
|
|
93
|
+
*
|
|
94
|
+
* The read and the write are one TRANSACTION because the duplicates this
|
|
95
|
+
* exists to catch are CONCURRENT. Two requests from one click race each
|
|
96
|
+
* other; a plain read-then-write would let both see "nothing recent" and
|
|
97
|
+
* both insert, defeating the collapse exactly when it is needed.
|
|
98
|
+
*/ export async function recordAdminAudit(entry) {
|
|
99
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
100
|
+
const collection = firestore.collection(ADMIN_AUDIT_COLLECTION);
|
|
101
|
+
const now = new Date();
|
|
102
|
+
const mostRecentOnTarget = collection.where('target', '==', entry.target).orderBy('at', 'desc').limit(1);
|
|
103
|
+
await firestore.runTransaction(async (transaction)=>{
|
|
104
|
+
var _toDate, _entry_note;
|
|
105
|
+
const previous = (await transaction.get(mostRecentOnTarget)).docs[0];
|
|
106
|
+
const previousAt = previous ? (_toDate = toDate(previous.get('lastAt'))) != null ? _toDate : toDate(previous.get('at')) : null;
|
|
107
|
+
const sameAct = !!previous && previous.get('actorUid') === entry.actorUid && previous.get('action') === entry.action;
|
|
108
|
+
/*
|
|
109
|
+
* Absolute distance, because the rows this compares against are not all
|
|
110
|
+
* written with the same clock: most writers use a server timestamp and
|
|
111
|
+
* this one uses the request's own instant, so a neighboring row can
|
|
112
|
+
* carry a moment slightly ahead of `now`. A signed comparison would read
|
|
113
|
+
* that as "not recent" and stop collapsing.
|
|
114
|
+
*/ const withinWindow = !!previousAt && Math.abs(now.getTime() - previousAt.getTime()) <= ADMIN_AUDIT_DEDUPE_WINDOW_MS;
|
|
115
|
+
if (previous && sameAct && withinWindow) {
|
|
116
|
+
transaction.update(previous.ref, {
|
|
117
|
+
repeatCount: (Number(previous.get('repeatCount')) || 1) + 1,
|
|
118
|
+
lastAt: now
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
transaction.create(collection.doc(), _extends({
|
|
123
|
+
actorUid: entry.actorUid,
|
|
124
|
+
action: entry.action,
|
|
125
|
+
target: entry.target
|
|
126
|
+
}, entry.subjectUid ? {
|
|
127
|
+
subjectUid: entry.subjectUid
|
|
128
|
+
} : {}, entry.subjectAddressKey ? {
|
|
129
|
+
subjectAddressKey: entry.subjectAddressKey
|
|
130
|
+
} : {}, {
|
|
131
|
+
note: (_entry_note = entry.note) != null ? _entry_note : null,
|
|
132
|
+
at: now,
|
|
133
|
+
/*
|
|
134
|
+
* `lastAt` and `repeatCount` are set on the FIRST write, not added on
|
|
135
|
+
* the first repeat. A reader that has to treat a missing field as "one,
|
|
136
|
+
* at `at`" will eventually forget to, and the row it gets wrong is the
|
|
137
|
+
* row that says how often somebody looked.
|
|
138
|
+
*/ lastAt: now,
|
|
139
|
+
repeatCount: 1
|
|
140
|
+
}));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* An address a human can recognize without the log holding the address.
|
|
145
|
+
*
|
|
146
|
+
* `emailDeliveries` is keyed by `sha256(address)` precisely so we do not keep
|
|
147
|
+
* a readable list of who we mail. An audit row echoing the address in the
|
|
148
|
+
* clear made this collection the less careful of the two for the same data,
|
|
149
|
+
* and `adminAudit` is readable by any staff role.
|
|
150
|
+
*
|
|
151
|
+
* The first character and the domain survive because an audit entry a human
|
|
152
|
+
* cannot read is a weaker control: a staffer checking their own access, or an
|
|
153
|
+
* investigator confirming which of a shortlist was opened, needs to recognize
|
|
154
|
+
* the row. What does not survive is the harvestable part — a dump of this
|
|
155
|
+
* collection yields no mailing list.
|
|
156
|
+
*/ export function maskEmailAddress(address) {
|
|
157
|
+
const trimmed = String(address != null ? address : '').trim();
|
|
158
|
+
const at = trimmed.lastIndexOf('@');
|
|
159
|
+
if (at <= 0 || at === trimmed.length - 1) return '***';
|
|
160
|
+
return `${trimmed.slice(0, 1)}***@${trimmed.slice(at + 1)}`;
|
|
161
|
+
}
|
|
162
|
+
/** Every recipient, masked, in one note-sized string. */ export function maskEmailAddresses(addresses) {
|
|
163
|
+
return (addresses != null ? addresses : []).map(maskEmailAddress).join(', ');
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The uid behind a recipient address, or null when there is not one.
|
|
167
|
+
*
|
|
168
|
+
* Across POOLS, not the project pool alone: an enterprise account signing in
|
|
169
|
+
* through GCIP lives in a tenant, and a project-pool `getUserByEmail` would
|
|
170
|
+
* report the customer most in need of this record as "not a user".
|
|
171
|
+
*
|
|
172
|
+
* Null is a real answer. Most addresses we send to belong to site members,
|
|
173
|
+
* prospects and plain contacts with no platform account, and this must never
|
|
174
|
+
* guess one for them.
|
|
175
|
+
*/ export async function resolveSubjectUidByEmail(address) {
|
|
176
|
+
const email = String(address != null ? address : '').trim();
|
|
177
|
+
if (!email) return null;
|
|
178
|
+
try {
|
|
179
|
+
/*
|
|
180
|
+
* AMBIGUITY RESOLVES TO NOBODY.
|
|
181
|
+
*
|
|
182
|
+
* This was `findUserByEmailAcrossPools(...).uid` — the first account
|
|
183
|
+
* whose PRIMARY matched. That is a guess whenever a second account holds
|
|
184
|
+
* the same address, which is a real shape: a federated provider's address
|
|
185
|
+
* never entered the uniqueness index, so an account can hold one that is
|
|
186
|
+
* also somebody else's primary with nothing recording the clash.
|
|
187
|
+
*
|
|
188
|
+
* `attributableAccountForAddress` returns null for "more than one" as
|
|
189
|
+
* well as for "nobody". Both are the same instruction to this writer:
|
|
190
|
+
* name no subject. A row naming the wrong customer is not a weaker answer
|
|
191
|
+
* to "who read my data" — it is a false one, and it puts one customer's
|
|
192
|
+
* name on another's data access.
|
|
193
|
+
*
|
|
194
|
+
* The access is still reachable: `subjectAddressKey` carries the hashed
|
|
195
|
+
* recipient, and the account page finds it from the addresses it holds.
|
|
196
|
+
*/ return await attributableAccountForAddress(email);
|
|
197
|
+
} catch (unused) {
|
|
198
|
+
// A lookup that could not run leaves the row without a subject, which is
|
|
199
|
+
// the same answer as "no account" and is safe: it under-reports rather
|
|
200
|
+
// than attributing an access to the wrong person.
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The hashed recipient for {@link AdminAuditWrite.subjectAddressKey}.
|
|
206
|
+
*
|
|
207
|
+
* The FIRST recipient, matching `resolveSubjectUidForRecipients` — one scalar
|
|
208
|
+
* cannot represent a multi-recipient message, and the masked note carries the
|
|
209
|
+
* full list either way.
|
|
210
|
+
*/ export function subjectAddressKeyForRecipients(addresses) {
|
|
211
|
+
for (const address of addresses != null ? addresses : []){
|
|
212
|
+
const key = emailSuppressionKey(address);
|
|
213
|
+
if (key) return key;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The first recipient with a platform account.
|
|
219
|
+
*
|
|
220
|
+
* A single scalar subject cannot represent a message sent to several people,
|
|
221
|
+
* and multi-recipient system mail is rare enough that an array field plus its
|
|
222
|
+
* own `array-contains` index would be carried by every writer to serve almost
|
|
223
|
+
* no rows. The full (masked) recipient list stays on the note, so a row for a
|
|
224
|
+
* multi-recipient message still shows that the others were involved.
|
|
225
|
+
*/ export async function resolveSubjectUidForRecipients(addresses) {
|
|
226
|
+
for (const address of addresses != null ? addresses : []){
|
|
227
|
+
const uid = await resolveSubjectUidByEmail(address);
|
|
228
|
+
if (uid) return uid;
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
//# sourceMappingURL=admin-audit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/admin-audit.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 { isPluginStaffAuditAccess } from '@aglyn/aglyn/plugin-manager/plugin-activity-actions'\nimport { attributableAccountForAddress } from './account-addresses'\nimport { emailSuppressionKey } from './email-suppression'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * THE STAFF AUDIT TRAIL: WHO A ROW IS ABOUT, AND HOW MANY TIMES IT HAPPENED.\n *\n * `adminAudit` is not a convenience log. Firestore rules close\n * `emailDeliveries` to EVERYONE, staff included, and the console route is\n * allowed to read it only because it establishes a staff claim and records\n * who looked. The row this module writes is that compensating control, so\n * the two properties it exists to provide are:\n *\n * 1. **No access is lost.** Two views minutes apart are two accesses. A\n * collapse that dropped one would answer \"nobody read your mail\" about a\n * read that happened, which is the single failure this collection cannot\n * have.\n * 2. **The subject is answerable.** `target` names the THING acted on; it\n * cannot also name the person, because a message id is not a user path.\n * `subjectUid` is the separate fact, and it is what makes \"who at Aglyn\n * read my email\" a query rather than a manual trawl.\n */\n\nexport const ADMIN_AUDIT_COLLECTION = 'adminAudit'\n\n/**\n * How close together two identical acts must be to count as ONE act\n * recorded twice.\n *\n * A single click can reach this writer more than once — a re-run effect, a\n * retried request, a double submit — and each arrival is the same access.\n * Ten seconds sits an order of magnitude above that (the observed re-entry\n * was one second apart) and well below a person deliberately re-opening a\n * record (observed at fifty-three seconds). Nothing a human does twice on\n * purpose lands inside it.\n *\n * ⚠️ A collapse still RECORDS the repeat: `repeatCount` and `lastAt` carry\n * it, and the console renders both. Widening this window trades away the\n * separateness of two accesses, so it must stay far shorter than the\n * interval at which a second look is a second decision.\n */\nexport const ADMIN_AUDIT_DEDUPE_WINDOW_MS = 10_000\n\n/** Access looked at data; change altered something or acted on someone. */\nexport type AdminAuditKind = 'access' | 'change'\n\n/**\n * The actions that only LOOKED.\n *\n * An exception list, not a classification of everything, and the default\n * matters more than the membership: anything absent is a `change`. A change\n * is the louder half of the console's audit card, so an action nobody has\n * classified yet gets the MORE prominent treatment rather than the quieter\n * one. The failure mode of the opposite default is an unclassified\n * impersonation rendering as routine browsing.\n *\n * An export is deliberately NOT here. Data leaving the platform is a\n * high-consequence act even though it mutates nothing, and it belongs beside\n * the impersonations rather than beside the record views.\n */\nconst ADMIN_AUDIT_ACCESS_ACTIONS: ReadonlySet<string> = new Set([\n 'email.message-viewed',\n])\n\n/**\n * An access when the platform or a plugin declares the action a read — a\n * plugin's staff card opening on an org or an account names its own read\n * actions through its activity group (AGL-2939) — and a change otherwise.\n */\nexport function adminAuditKind(\n action: string | null | undefined,\n): AdminAuditKind {\n return action && (ADMIN_AUDIT_ACCESS_ACTIONS.has(action) || isPluginStaffAuditAccess(action))\n ? 'access'\n : 'change'\n}\n\nexport interface AdminAuditWrite {\n /** The staff account performing the act. */\n actorUid: string\n action: string\n /** The THING acted on, as a path or a stable identifier. */\n target: string\n /**\n * The person the act is ABOUT, when one can be resolved.\n *\n * Absent is a correct answer, not a failure: a recipient may be a site\n * member, a prospect or a bare address with no platform account behind it,\n * and inventing a uid for one of those would put an act on an innocent\n * person's page.\n */\n subjectUid?: string | null\n /**\n * `sha256` of the recipient address the act was about, when it had one.\n *\n * ## Why a hash and not the address\n *\n * The SAME derivation `emailSuppressionKey` uses, so this joins the\n * delivery log without a second key space. It is hashed for the reason\n * {@link maskEmailAddress} exists: `adminAudit` is readable by any staff\n * role, and a dump of it must not yield a mailing list.\n *\n * ## Why it exists at all, next to `subjectUid`\n *\n * `subjectUid` can only be written when the address resolves to exactly one\n * account, and an address is not reliably resolvable to one — a\n * provider-supplied address may be held by a second account with nothing\n * recording it. So the subject is now OMITTED whenever the answer is\n * ambiguous, and omitting it alone would make the access invisible on every\n * page, which is worse than the guess it replaced.\n *\n * This is the fact that needs no guess. The account page queries it with\n * the keys of every address that account holds, so one access appears on\n * the page of each account holding the address — which is the honest answer\n * when the mail went to a mailbox rather than to a uid.\n *\n * ## It opens no tier side-door\n *\n * `adminAudit` is `allow read, create: if isStaff()` with NO tier gate, so\n * anything written here is readable by every staff session whatever its\n * role — which makes an audit row a way around a tier restriction on the\n * surface the value came from. Checked, and it is not one here: the writer\n * is the message route, which gates on `staff` alone and nothing finer, so\n * this field is no more widely readable than its source.\n *\n * The hash adds no reach of its own either. It is the document id\n * `emailDeliveries` is already filed under, so any staff session that can\n * read this row could already derive the same value from an address it\n * guessed. A field whose source DID restrict by tier would not belong here.\n */\n subjectAddressKey?: string | null\n /** Human-readable context. ⚠️ Never a raw address — see `maskEmailAddress`. */\n note?: string | null\n}\n\nfunction toDate(value: unknown): Date | null {\n const converted = (value as { toDate?: () => Date } | null)?.toDate?.()\n if (converted instanceof Date) return converted\n return value instanceof Date ? value : null\n}\n\n/**\n * Write one audit row, collapsing an immediate repeat of the same act onto\n * the row already there.\n *\n * The candidate is the MOST RECENT row on the same target, which the live\n * `target ASC, at DESC` index already answers — so this costs one indexed\n * single-document read and needs no index of its own. Anything else landing\n * on that target in between ends the run, which is the conservative\n * direction: an interleaved act means the two views were not one action.\n *\n * The read and the write are one TRANSACTION because the duplicates this\n * exists to catch are CONCURRENT. Two requests from one click race each\n * other; a plain read-then-write would let both see \"nothing recent\" and\n * both insert, defeating the collapse exactly when it is needed.\n */\nexport async function recordAdminAudit(entry: AdminAuditWrite): Promise<void> {\n const firestore = firebaseAdmin.app().firestore()\n const collection = firestore.collection(ADMIN_AUDIT_COLLECTION)\n const now = new Date()\n const mostRecentOnTarget = collection\n .where('target', '==', entry.target)\n .orderBy('at', 'desc')\n .limit(1)\n\n await firestore.runTransaction(async (transaction) => {\n const previous = (await transaction.get(mostRecentOnTarget)).docs[0]\n const previousAt = previous\n ? (toDate(previous.get('lastAt')) ?? toDate(previous.get('at')))\n : null\n const sameAct =\n !!previous &&\n previous.get('actorUid') === entry.actorUid &&\n previous.get('action') === entry.action\n /*\n * Absolute distance, because the rows this compares against are not all\n * written with the same clock: most writers use a server timestamp and\n * this one uses the request's own instant, so a neighboring row can\n * carry a moment slightly ahead of `now`. A signed comparison would read\n * that as \"not recent\" and stop collapsing.\n */\n const withinWindow =\n !!previousAt &&\n Math.abs(now.getTime() - previousAt.getTime()) <=\n ADMIN_AUDIT_DEDUPE_WINDOW_MS\n\n if (previous && sameAct && withinWindow) {\n transaction.update(previous.ref, {\n repeatCount: (Number(previous.get('repeatCount')) || 1) + 1,\n lastAt: now,\n })\n return\n }\n\n transaction.create(collection.doc(), {\n actorUid: entry.actorUid,\n action: entry.action,\n target: entry.target,\n /*\n * OMITTED rather than null when there is nobody to name. The subject\n * query is `where('subjectUid','==',uid).orderBy('at','desc')`, which\n * matches only documents that HAVE the field — so leaving it out keeps\n * the index to the rows that can actually answer a subject question.\n */\n ...(entry.subjectUid ? { subjectUid: entry.subjectUid } : {}),\n // Omitted rather than null on the same reasoning as `subjectUid`: the\n // address query matches only documents that HAVE the field.\n ...(entry.subjectAddressKey\n ? { subjectAddressKey: entry.subjectAddressKey }\n : {}),\n note: entry.note ?? null,\n at: now,\n /*\n * `lastAt` and `repeatCount` are set on the FIRST write, not added on\n * the first repeat. A reader that has to treat a missing field as \"one,\n * at `at`\" will eventually forget to, and the row it gets wrong is the\n * row that says how often somebody looked.\n */\n lastAt: now,\n repeatCount: 1,\n })\n })\n}\n\n/**\n * An address a human can recognize without the log holding the address.\n *\n * `emailDeliveries` is keyed by `sha256(address)` precisely so we do not keep\n * a readable list of who we mail. An audit row echoing the address in the\n * clear made this collection the less careful of the two for the same data,\n * and `adminAudit` is readable by any staff role.\n *\n * The first character and the domain survive because an audit entry a human\n * cannot read is a weaker control: a staffer checking their own access, or an\n * investigator confirming which of a shortlist was opened, needs to recognize\n * the row. What does not survive is the harvestable part — a dump of this\n * collection yields no mailing list.\n */\nexport function maskEmailAddress(address: string | null | undefined): string {\n const trimmed = String(address ?? '').trim()\n const at = trimmed.lastIndexOf('@')\n if (at <= 0 || at === trimmed.length - 1) return '***'\n return `${trimmed.slice(0, 1)}***@${trimmed.slice(at + 1)}`\n}\n\n/** Every recipient, masked, in one note-sized string. */\nexport function maskEmailAddresses(\n addresses: readonly string[] | null | undefined,\n): string {\n return (addresses ?? []).map(maskEmailAddress).join(', ')\n}\n\n/**\n * The uid behind a recipient address, or null when there is not one.\n *\n * Across POOLS, not the project pool alone: an enterprise account signing in\n * through GCIP lives in a tenant, and a project-pool `getUserByEmail` would\n * report the customer most in need of this record as \"not a user\".\n *\n * Null is a real answer. Most addresses we send to belong to site members,\n * prospects and plain contacts with no platform account, and this must never\n * guess one for them.\n */\nexport async function resolveSubjectUidByEmail(\n address: string | null | undefined,\n): Promise<string | null> {\n const email = String(address ?? '').trim()\n if (!email) return null\n try {\n /*\n * AMBIGUITY RESOLVES TO NOBODY.\n *\n * This was `findUserByEmailAcrossPools(...).uid` — the first account\n * whose PRIMARY matched. That is a guess whenever a second account holds\n * the same address, which is a real shape: a federated provider's address\n * never entered the uniqueness index, so an account can hold one that is\n * also somebody else's primary with nothing recording the clash.\n *\n * `attributableAccountForAddress` returns null for \"more than one\" as\n * well as for \"nobody\". Both are the same instruction to this writer:\n * name no subject. A row naming the wrong customer is not a weaker answer\n * to \"who read my data\" — it is a false one, and it puts one customer's\n * name on another's data access.\n *\n * The access is still reachable: `subjectAddressKey` carries the hashed\n * recipient, and the account page finds it from the addresses it holds.\n */\n return await attributableAccountForAddress(email)\n } catch {\n // A lookup that could not run leaves the row without a subject, which is\n // the same answer as \"no account\" and is safe: it under-reports rather\n // than attributing an access to the wrong person.\n return null\n }\n}\n\n/**\n * The hashed recipient for {@link AdminAuditWrite.subjectAddressKey}.\n *\n * The FIRST recipient, matching `resolveSubjectUidForRecipients` — one scalar\n * cannot represent a multi-recipient message, and the masked note carries the\n * full list either way.\n */\nexport function subjectAddressKeyForRecipients(\n addresses: readonly string[] | null | undefined,\n): string | null {\n for (const address of addresses ?? []) {\n const key = emailSuppressionKey(address)\n if (key) return key\n }\n return null\n}\n\n/**\n * The first recipient with a platform account.\n *\n * A single scalar subject cannot represent a message sent to several people,\n * and multi-recipient system mail is rare enough that an array field plus its\n * own `array-contains` index would be carried by every writer to serve almost\n * no rows. The full (masked) recipient list stays on the note, so a row for a\n * multi-recipient message still shows that the others were involved.\n */\nexport async function resolveSubjectUidForRecipients(\n addresses: readonly string[] | null | undefined,\n): Promise<string | null> {\n for (const address of addresses ?? []) {\n const uid = await resolveSubjectUidByEmail(address)\n if (uid) return uid\n }\n return null\n}\n"],"names":["isPluginStaffAuditAccess","attributableAccountForAddress","emailSuppressionKey","firebaseAdmin","ADMIN_AUDIT_COLLECTION","ADMIN_AUDIT_DEDUPE_WINDOW_MS","ADMIN_AUDIT_ACCESS_ACTIONS","Set","adminAuditKind","action","has","toDate","value","converted","Date","recordAdminAudit","entry","firestore","app","collection","now","mostRecentOnTarget","where","target","orderBy","limit","runTransaction","transaction","previous","get","docs","previousAt","sameAct","actorUid","withinWindow","Math","abs","getTime","update","ref","repeatCount","Number","lastAt","create","doc","subjectUid","subjectAddressKey","note","at","maskEmailAddress","address","trimmed","String","trim","lastIndexOf","length","slice","maskEmailAddresses","addresses","map","join","resolveSubjectUidByEmail","email","subjectAddressKeyForRecipients","key","resolveSubjectUidForRecipients","uid"],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
* Retention for the AGL-82 analytics day docs (AGL-1844).
|
|
19
|
+
*
|
|
20
|
+
* Day docs accumulated forever: every pageview, media serve and redirect hit
|
|
21
|
+
* left a `{hosts|orgs}/{id}/analytics/{YYYY-MM-DD}` (or
|
|
22
|
+
* `hosts/{id}/screenAnalytics/{screenId}:{day}`) document behind, and
|
|
23
|
+
* nothing ever deleted one. Every WRITER now stamps `expiresAt`, and a
|
|
24
|
+
* Firestore TTL policy on that field (declared as `fieldOverrides` in
|
|
25
|
+
* `cloud/firebase-firestore.indexes.json`, on the `analytics` and
|
|
26
|
+
* `screenAnalytics` collection groups) sweeps the old ones.
|
|
27
|
+
*
|
|
28
|
+
* ## Why 400 days
|
|
29
|
+
*
|
|
30
|
+
* Long enough that every reader's window closes with room to spare:
|
|
31
|
+
*
|
|
32
|
+
* - the console's widest range is 90 days;
|
|
33
|
+
* - /api/billing/report-usage meters `analytics/{day}.total` for the CURRENT
|
|
34
|
+
* period — an invoice dispute a year later still finds its days;
|
|
35
|
+
* - a year-over-year comparison (no surface does one yet) would still have
|
|
36
|
+
* both years.
|
|
37
|
+
*
|
|
38
|
+
* Short enough that storage is bounded: ~400 docs per host (plus per-screen
|
|
39
|
+
* and per-org docs) at steady state, instead of unbounded growth.
|
|
40
|
+
*
|
|
41
|
+
* ## Why TTL and NOT monthly rollups (the AGL-1844 decision)
|
|
42
|
+
*
|
|
43
|
+
* Rollups were considered and deliberately not built: no console surface
|
|
44
|
+
* reads more than 90 dailies (bounded, and blunted by the AGL-1440 cache),
|
|
45
|
+
* so a monthly doc would be written by a scheduled job — new moving part,
|
|
46
|
+
* new failure mode — to serve a view nothing renders. If a >90-day console
|
|
47
|
+
* range ever ships, rollups become that feature's first commit.
|
|
48
|
+
*
|
|
49
|
+
* TTL deletion is Firestore's own sweeper: typically within 24h of expiry,
|
|
50
|
+
* no code of ours runs. Docs written BEFORE this shipped carry no
|
|
51
|
+
* `expiresAt` and are never swept — the trickle of writes to a live site
|
|
52
|
+
* stamps its recent days naturally, and old unstamped days are exactly the
|
|
53
|
+
* ones nothing reads.
|
|
54
|
+
*/
|
|
55
|
+
/** See the module doc for why 400. */
|
|
56
|
+
export declare const ANALYTICS_DAY_RETENTION_DAYS = 400;
|
|
57
|
+
/**
|
|
58
|
+
* When the day doc named `day` (YYYY-MM-DD, UTC) may be swept. Anchored on
|
|
59
|
+
* the day itself, not the write clock, so every write to one doc agrees on
|
|
60
|
+
* one expiry. An unparsable day anchors on "now", which only ever shortens
|
|
61
|
+
* retention.
|
|
62
|
+
*/
|
|
63
|
+
export declare function analyticsDayExpiresAt(day: string): Date;
|