@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,551 @@
|
|
|
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
|
+
* Cross-pool Firebase Auth lookups (AGL-1122).
|
|
18
|
+
*
|
|
19
|
+
* Enterprise SSO (AGL-1101) signs users into a per-org **GCIP tenant**, and a
|
|
20
|
+
* tenant has its OWN user pool. A project-level `getUserByEmail` /
|
|
21
|
+
* `listUsers` does not see those users at all — measured on aglyn-main:
|
|
22
|
+
* the owner of aglyn-org returned `auth/user-not-found` at project level and
|
|
23
|
+
* was present only under tenant `aglyn-org-y5v14`.
|
|
24
|
+
*
|
|
25
|
+
* That silently broke five separate things: staff could not find, view,
|
|
26
|
+
* disable or grant staff to an SSO user; adding one to an org or a site by
|
|
27
|
+
* email 404'd into "send them an invite" for an account that already exists;
|
|
28
|
+
* and `notifyStaff` skipped SSO staff entirely. Fixing those one route at a
|
|
29
|
+
* time would guarantee the next new route reintroduces the bug, so every
|
|
30
|
+
* caller goes through here instead.
|
|
31
|
+
*
|
|
32
|
+
* Custom claims are per-pool too: `setCustomUserClaims` on the project pool
|
|
33
|
+
* cannot grant staff to a tenant user. `authForPool` hands back the right
|
|
34
|
+
* `BaseAuth` so a mutation lands in the pool the user actually lives in.
|
|
35
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
36
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
37
|
+
const auth = ()=>firebaseAdmin.app().auth();
|
|
38
|
+
/**
|
|
39
|
+
* Does this record show that a human ever authenticated as it? (AGL-2005)
|
|
40
|
+
*
|
|
41
|
+
* An address or a provider entry is the evidence. `signInWithCustomToken`
|
|
42
|
+
* mints an account out of nothing when the uid is absent from the pool the
|
|
43
|
+
* token was minted in, and the record it manufactures has neither: measured
|
|
44
|
+
* on production, a project-pool twin of a tenant uid had `email: null` and
|
|
45
|
+
* `providerData: []` while the real SSO record carried both an address and
|
|
46
|
+
* `saml.aglyn-workspace`.
|
|
47
|
+
*
|
|
48
|
+
* `displayName` and custom claims deliberately do NOT count. Both can be
|
|
49
|
+
* written onto any record after the fact — `updateProfile` and `grantStaff`
|
|
50
|
+
* in the staff console do exactly that — so a shadow that a staff action had
|
|
51
|
+
* already landed on would start qualifying as an identity and go on winning
|
|
52
|
+
* the lookups it should be losing. The bar has to be a signal the forgery
|
|
53
|
+
* cannot acquire by being acted upon.
|
|
54
|
+
*/ export function isIdentifiedUserRecord(record) {
|
|
55
|
+
var _ref;
|
|
56
|
+
var _record_providerData;
|
|
57
|
+
if (!record) return false;
|
|
58
|
+
return Boolean(record.email) || ((_ref = (_record_providerData = record.providerData) == null ? void 0 : _record_providerData.length) != null ? _ref : 0) > 0;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* How strongly a record identifies its human, for choosing between two that
|
|
62
|
+
* share a uid (AGL-2005). Ordering only — the numbers have no other meaning.
|
|
63
|
+
*
|
|
64
|
+
* Email and provider data lead because they are what `isIdentifiedUserRecord`
|
|
65
|
+
* trusts; the rest break ties between records that both qualify.
|
|
66
|
+
*/ export function identityStrength(record) {
|
|
67
|
+
var _ref, _record_customClaims;
|
|
68
|
+
var _record_providerData;
|
|
69
|
+
if (!record) return 0;
|
|
70
|
+
let score = 0;
|
|
71
|
+
if (record.email) score += 8;
|
|
72
|
+
if (((_ref = (_record_providerData = record.providerData) == null ? void 0 : _record_providerData.length) != null ? _ref : 0) > 0) score += 4;
|
|
73
|
+
if (record.phoneNumber) score += 2;
|
|
74
|
+
if (record.displayName) score += 1;
|
|
75
|
+
if (Object.keys((_record_customClaims = record.customClaims) != null ? _record_customClaims : {}).length > 0) score += 1;
|
|
76
|
+
return score;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Collapse rows sharing a uid to ONE row apiece (AGL-2005).
|
|
80
|
+
*
|
|
81
|
+
* **decided:** "We still have two users list in this list with the
|
|
82
|
+
* same uid but one without an email attached, this needs fixed we should only
|
|
83
|
+
* see one user, even if they are sso."
|
|
84
|
+
*
|
|
85
|
+
* AGL-1962 deliberately refused to merge, on the grounds that collapsing two
|
|
86
|
+
* accounts could hide an identity split. That reasoning still stands for two
|
|
87
|
+
* DISTINCT accounts — which is why this keys on **uid** and never on email.
|
|
88
|
+
* Two people who happen to share an address are two accounts with two uids
|
|
89
|
+
* and stay two rows; merging them on email would delete a real user from the
|
|
90
|
+
* staff console, a worse bug than the one being fixed and an invisible one.
|
|
91
|
+
*
|
|
92
|
+
* A repeated uid is different in kind: a uid is unique WITHIN a pool, so the
|
|
93
|
+
* same uid in two pools is not two people, it is one person plus an artifact
|
|
94
|
+
* of a cross-pool custom-token mint. Those merge.
|
|
95
|
+
*
|
|
96
|
+
* Two rules make the merge safe:
|
|
97
|
+
*
|
|
98
|
+
* - **The identified record wins.** Ranked by `identityStrength`, so an
|
|
99
|
+
* emailless, providerless artifact can never be the row staff see — the
|
|
100
|
+
* exact inversion that let the shadow win every uid lookup.
|
|
101
|
+
* - **The collision is not discarded.** `markCrossPoolUidCollisions` runs
|
|
102
|
+
* here rather than being left to the caller, so the surviving row always
|
|
103
|
+
* carries `uidAlsoInPools` naming the pools that were folded into it. One
|
|
104
|
+
* row, and it still says it was two.
|
|
105
|
+
*
|
|
106
|
+
* The survivor takes the FIRST occurrence's position so list ordering does
|
|
107
|
+
* not shuffle, and ties keep the earlier row so the result is deterministic.
|
|
108
|
+
*/ export function collapseCrossPoolUidRows(users) {
|
|
109
|
+
// Marked here, not by the caller. A caller that forgot would silently turn
|
|
110
|
+
// this into the cover-up the merge is explicitly not allowed to be.
|
|
111
|
+
const marked = markCrossPoolUidCollisions(users);
|
|
112
|
+
const winnerByUid = new Map();
|
|
113
|
+
for (const user of marked){
|
|
114
|
+
const incumbent = winnerByUid.get(user.record.uid);
|
|
115
|
+
if (!incumbent || identityStrength(user.record) > identityStrength(incumbent.record)) {
|
|
116
|
+
winnerByUid.set(user.record.uid, user);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const emitted = new Set();
|
|
120
|
+
const collapsed = [];
|
|
121
|
+
for (const user of marked){
|
|
122
|
+
var _winnerByUid_get;
|
|
123
|
+
const uid = user.record.uid;
|
|
124
|
+
if (emitted.has(uid)) continue;
|
|
125
|
+
emitted.add(uid);
|
|
126
|
+
collapsed.push((_winnerByUid_get = winnerByUid.get(uid)) != null ? _winnerByUid_get : user);
|
|
127
|
+
}
|
|
128
|
+
return collapsed;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Flag every uid that appears in more than one pool, in place of hiding it.
|
|
132
|
+
*
|
|
133
|
+
* Pure and total, so a spec can drive it without Firebase. Callers must pass
|
|
134
|
+
* the rows they intend to show together — a collision it cannot see is one
|
|
135
|
+
* it cannot report.
|
|
136
|
+
*/ export function markCrossPoolUidCollisions(users) {
|
|
137
|
+
const poolsByUid = new Map();
|
|
138
|
+
for (const user of users){
|
|
139
|
+
var _poolsByUid_get;
|
|
140
|
+
const pools = (_poolsByUid_get = poolsByUid.get(user.record.uid)) != null ? _poolsByUid_get : [];
|
|
141
|
+
pools.push(user.tenantId);
|
|
142
|
+
poolsByUid.set(user.record.uid, pools);
|
|
143
|
+
}
|
|
144
|
+
return users.map((user)=>{
|
|
145
|
+
var _poolsByUid_get;
|
|
146
|
+
const pools = (_poolsByUid_get = poolsByUid.get(user.record.uid)) != null ? _poolsByUid_get : [];
|
|
147
|
+
if (pools.length < 2) return user;
|
|
148
|
+
// Everyone holding this uid EXCEPT this row's own pool. Compared by
|
|
149
|
+
// index, not by value: two rows from the same pool would be a Firebase
|
|
150
|
+
// impossibility, but filtering by value would silently drop a real one.
|
|
151
|
+
const others = pools.filter((_, index)=>index !== pools.indexOf(user.tenantId));
|
|
152
|
+
return _extends({}, user, {
|
|
153
|
+
uidAlsoInPools: others
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Every GCIP tenant on the project. Cached briefly: this is asked once per
|
|
159
|
+
* lookup and the set changes only when an enterprise org is onboarded.
|
|
160
|
+
* Fails soft to an empty list — a tenant-listing outage must degrade to
|
|
161
|
+
* today's project-only behaviour rather than break sign-in-adjacent routes.
|
|
162
|
+
*/ let tenantCache = null;
|
|
163
|
+
const TENANT_CACHE_MS = 60000;
|
|
164
|
+
export async function listAuthTenantIds() {
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
if (tenantCache && now - tenantCache.at < TENANT_CACHE_MS) {
|
|
167
|
+
return tenantCache.ids;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const ids = [];
|
|
171
|
+
let pageToken;
|
|
172
|
+
do {
|
|
173
|
+
const page = await auth().tenantManager().listTenants(100, pageToken);
|
|
174
|
+
ids.push(...page.tenants.map((tenant)=>tenant.tenantId));
|
|
175
|
+
pageToken = page.pageToken;
|
|
176
|
+
}while (pageToken)
|
|
177
|
+
tenantCache = {
|
|
178
|
+
ids,
|
|
179
|
+
at: now
|
|
180
|
+
};
|
|
181
|
+
return ids;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
var _ref;
|
|
184
|
+
console.error('tenant listing failed; falling back to project pool', error);
|
|
185
|
+
return (_ref = tenantCache == null ? void 0 : tenantCache.ids) != null ? _ref : [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Test seam — drops the tenant cache so a spec can vary the tenant set. */ export function resetAuthTenantCache() {
|
|
189
|
+
tenantCache = null;
|
|
190
|
+
}
|
|
191
|
+
/** The `BaseAuth` for a pool: the project pool for null, else the tenant's. */ export function authForPool(tenantId) {
|
|
192
|
+
return tenantId ? auth().tenantManager().authForTenant(tenantId) : auth();
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Find a user by email across the project pool and every GCIP tenant.
|
|
196
|
+
*
|
|
197
|
+
* The project pool is tried first — it holds every non-SSO user, which is
|
|
198
|
+
* almost all of them, so the common case stays a single call. Returns null
|
|
199
|
+
* when no pool has the address, which is the only honest answer for "this
|
|
200
|
+
* person has no Aglyn account".
|
|
201
|
+
*/ export async function findUserByEmailAcrossPools(email) {
|
|
202
|
+
const address = email.trim().toLowerCase();
|
|
203
|
+
if (!address) return null;
|
|
204
|
+
try {
|
|
205
|
+
return {
|
|
206
|
+
record: await auth().getUserByEmail(address),
|
|
207
|
+
tenantId: null
|
|
208
|
+
};
|
|
209
|
+
} catch (unused) {
|
|
210
|
+
// Not in the project pool — fall through to the tenants.
|
|
211
|
+
}
|
|
212
|
+
for (const tenantId of (await listAuthTenantIds())){
|
|
213
|
+
try {
|
|
214
|
+
const record = await authForPool(tenantId).getUserByEmail(address);
|
|
215
|
+
return {
|
|
216
|
+
record,
|
|
217
|
+
tenantId
|
|
218
|
+
};
|
|
219
|
+
} catch (unused) {
|
|
220
|
+
// Not in this tenant; keep looking.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Find a user by uid across the project pool and every GCIP tenant. Same
|
|
227
|
+
* shape as the email lookup — a uid is only unique WITHIN a pool, so the
|
|
228
|
+
* caller needs to know which pool answered before it mutates anything.
|
|
229
|
+
*
|
|
230
|
+
* **First pool to answer is NOT good enough** (AGL-2005). This used to return
|
|
231
|
+
* the project pool's record the moment it existed, which is how a forged
|
|
232
|
+
* emailless twin won every uid lookup for a real SSO user: `grantStaff`,
|
|
233
|
+
* `disable`, `setRole`, `updateProfile`, `revokeRefreshTokens`, impersonate,
|
|
234
|
+
* usage-email, resolve-people, org-lockdown and erasure all landed on a
|
|
235
|
+
* record nobody can sign in as. Two of those were caught having happened —
|
|
236
|
+
* a `revokeRefreshTokens` dated 2026-08-14 sat on the twin while the real
|
|
237
|
+
* account's `tokensValidAfterTime` never moved, so "sign out everywhere" did
|
|
238
|
+
* nothing at all.
|
|
239
|
+
*
|
|
240
|
+
* So a pool's answer is accepted only when the record it returns actually
|
|
241
|
+
* identifies someone, and the winner is chosen by the SAME rule the staff
|
|
242
|
+
* list collapses rows with. That equivalence is the point: the row staff
|
|
243
|
+
* click is the record the action mutates, by construction rather than by
|
|
244
|
+
* coincidence.
|
|
245
|
+
*
|
|
246
|
+
* The fast path survives intact — a project account with an address or a
|
|
247
|
+
* provider is returned without any tenant call, which is nearly every
|
|
248
|
+
* account. Only a record carrying no identity at all pays for the sweep.
|
|
249
|
+
*/ export async function findUserByUidAcrossPools(uid) {
|
|
250
|
+
var _collapseCrossPoolUidRows_;
|
|
251
|
+
if (!uid) return null;
|
|
252
|
+
const matches = [];
|
|
253
|
+
try {
|
|
254
|
+
const record = await auth().getUser(uid);
|
|
255
|
+
if (isIdentifiedUserRecord(record)) return {
|
|
256
|
+
record,
|
|
257
|
+
tenantId: null
|
|
258
|
+
};
|
|
259
|
+
// Held, not returned. It is still the answer if nothing better exists —
|
|
260
|
+
// an anonymous or half-created account legitimately looks like this, and
|
|
261
|
+
// refusing it here would break every caller that handles those.
|
|
262
|
+
matches.push({
|
|
263
|
+
record,
|
|
264
|
+
tenantId: null
|
|
265
|
+
});
|
|
266
|
+
} catch (unused) {
|
|
267
|
+
// Not in the project pool.
|
|
268
|
+
}
|
|
269
|
+
for (const tenantId of (await listAuthTenantIds())){
|
|
270
|
+
try {
|
|
271
|
+
const record = await authForPool(tenantId).getUser(uid);
|
|
272
|
+
matches.push({
|
|
273
|
+
record,
|
|
274
|
+
tenantId
|
|
275
|
+
});
|
|
276
|
+
// Stop at the first pool that knows who this is. Scanning the rest
|
|
277
|
+
// would cost a call per enterprise tenant on a path that runs during
|
|
278
|
+
// sign-in and billing, and a uid identified in two tenant pools is not
|
|
279
|
+
// a shape Firebase can produce — tenant uids are minted per pool.
|
|
280
|
+
if (isIdentifiedUserRecord(record)) break;
|
|
281
|
+
} catch (unused) {
|
|
282
|
+
// Keep looking.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (!matches.length) return null;
|
|
286
|
+
return (_collapseCrossPoolUidRows_ = collapseCrossPoolUidRows(matches)[0]) != null ? _collapseCrossPoolUidRows_ : null;
|
|
287
|
+
}
|
|
288
|
+
/** How many tenant users a single page will carry before reporting a cut. */ const TENANT_USER_CAP = 500;
|
|
289
|
+
/**
|
|
290
|
+
* How many pools may have a call in flight at once.
|
|
291
|
+
*
|
|
292
|
+
* Every sweep below asks each GCIP tenant pool the same question, and the
|
|
293
|
+
* pools are independent: one tenant's answer never informs another's. Asked in
|
|
294
|
+
* series that costs `N` round trips for `N` enterprise customers, so the staff
|
|
295
|
+
* list gets slower every time one is signed — the wrong direction for the
|
|
296
|
+
* feature that adds them. Asked together it costs one.
|
|
297
|
+
*
|
|
298
|
+
* The number of CALLS is identical either way, so this buys latency and the
|
|
299
|
+
* Cloud Run seconds spent waiting on it, not Auth quota. That is also why it
|
|
300
|
+
* is bounded rather than an unbounded `Promise.all`: Identity Platform rate
|
|
301
|
+
* limits `listUsers` per project, and firing one call per tenant simultaneously
|
|
302
|
+
* would convert a latency problem into a `RESOURCE_EXHAUSTED` one at exactly
|
|
303
|
+
* the customer count where the latency stopped mattering.
|
|
304
|
+
*/ const POOL_FETCH_CONCURRENCY = 8;
|
|
305
|
+
/**
|
|
306
|
+
* Run `work` over every pool id, at most {@link POOL_FETCH_CONCURRENCY} at a
|
|
307
|
+
* time, and return the results **in the order the ids were given**.
|
|
308
|
+
*
|
|
309
|
+
* Order is not cosmetic here. Callers concatenate these results into a user
|
|
310
|
+
* list and then trim it to a cap, so a result order that varied with which
|
|
311
|
+
* pool answered first would make the rows a staff member sees depend on
|
|
312
|
+
* network timing — the same query returning different people on a refresh.
|
|
313
|
+
*/ async function mapPoolsConcurrently(tenantIds, work) {
|
|
314
|
+
const results = new Array(tenantIds.length);
|
|
315
|
+
let next = 0;
|
|
316
|
+
const workers = Array.from({
|
|
317
|
+
length: Math.min(POOL_FETCH_CONCURRENCY, tenantIds.length)
|
|
318
|
+
}, async ()=>{
|
|
319
|
+
for(;;){
|
|
320
|
+
const index = next++;
|
|
321
|
+
if (index >= tenantIds.length) return;
|
|
322
|
+
results[index] = await work(tenantIds[index]);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
await Promise.all(workers);
|
|
326
|
+
return results;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* One page of users across every pool: the project pool paginates as before,
|
|
330
|
+
* and tenant users are appended on the LAST page so they can never be lost
|
|
331
|
+
* between cursors. Callers render `tenantId` so an SSO account is
|
|
332
|
+
* recognisable — it is not editable the same way a project user is.
|
|
333
|
+
*/ export async function listUsersAcrossPools(pageSize, pageToken) {
|
|
334
|
+
const page = await auth().listUsers(pageSize, pageToken);
|
|
335
|
+
const users = page.users.map((record)=>({
|
|
336
|
+
record,
|
|
337
|
+
tenantId: null
|
|
338
|
+
}));
|
|
339
|
+
if (page.pageToken) {
|
|
340
|
+
return {
|
|
341
|
+
users,
|
|
342
|
+
nextPageToken: page.pageToken,
|
|
343
|
+
tenantsIncluded: false,
|
|
344
|
+
tenantTruncated: []
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const tenantTruncated = [];
|
|
348
|
+
// Every tenant asks for the same fixed page, so no pool's request depends on
|
|
349
|
+
// another's answer and they can all be in flight at once. A failed pool
|
|
350
|
+
// contributes no rows and does not stop the others: one enterprise tenant
|
|
351
|
+
// being unreachable must not blank the whole staff list.
|
|
352
|
+
//
|
|
353
|
+
// The id list is read ONCE and reused. It is cached on a 60s TTL, so asking
|
|
354
|
+
// twice can hand back two different lists, and pairing a page from one list
|
|
355
|
+
// with an id from the other would label a user with the wrong tenant.
|
|
356
|
+
const tenantIds = await listAuthTenantIds();
|
|
357
|
+
const tenantPages = await mapPoolsConcurrently(tenantIds, async (tenantId)=>{
|
|
358
|
+
try {
|
|
359
|
+
return await authForPool(tenantId).listUsers(TENANT_USER_CAP);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
console.error(`listing users for tenant ${tenantId} failed`, error);
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
tenantPages.forEach((tenantPage, index)=>{
|
|
366
|
+
if (!tenantPage) return;
|
|
367
|
+
const tenantId = tenantIds[index];
|
|
368
|
+
users.push(...tenantPage.users.map((record)=>({
|
|
369
|
+
record,
|
|
370
|
+
tenantId
|
|
371
|
+
})));
|
|
372
|
+
if (tenantPage.pageToken) tenantTruncated.push(tenantId);
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
// Marked only here, where every pool's rows are in hand (AGL-1962). The
|
|
376
|
+
// earlier return above carries project rows alone, and a uid cannot
|
|
377
|
+
// collide with itself inside one pool, so there is nothing to find there.
|
|
378
|
+
users: markCrossPoolUidCollisions(users),
|
|
379
|
+
nextPageToken: null,
|
|
380
|
+
tenantsIncluded: true,
|
|
381
|
+
tenantTruncated
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Every account across every pool, up to a cap — the only way to FILTER them.
|
|
386
|
+
*
|
|
387
|
+
* Firebase Auth's `listUsers` takes a page size and a cursor and nothing else:
|
|
388
|
+
* no predicate, no ordering, no search. So a staff filter on anything but an
|
|
389
|
+
* exact email, uid or phone number (which have their own O(1) lookups) can
|
|
390
|
+
* only be answered by reading the accounts and matching them.
|
|
391
|
+
*
|
|
392
|
+
* That is an expensive read, so it happens on an ASK and never on a mount —
|
|
393
|
+
* the route calls this only when a request carries a filter, and pages the
|
|
394
|
+
* cheap way otherwise.
|
|
395
|
+
*
|
|
396
|
+
* `truncated` is true when the cap was reached, and it must be shown rather
|
|
397
|
+
* than swallowed: a staff list that searched part of the directory and said
|
|
398
|
+
* "no matches" is worse than one that admits where it stopped.
|
|
399
|
+
*/ export async function scanUsersAcrossPools(cap) {
|
|
400
|
+
const users = [];
|
|
401
|
+
let truncated = false;
|
|
402
|
+
let pageToken;
|
|
403
|
+
do {
|
|
404
|
+
const remaining = cap - users.length;
|
|
405
|
+
if (remaining <= 0) {
|
|
406
|
+
truncated = true;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
// `listUsers` caps a page at 1000 whatever it is asked for.
|
|
410
|
+
const page = await auth().listUsers(Math.min(remaining, 1000), pageToken);
|
|
411
|
+
users.push(...page.users.map((record)=>({
|
|
412
|
+
record,
|
|
413
|
+
tenantId: null
|
|
414
|
+
})));
|
|
415
|
+
pageToken = page.pageToken;
|
|
416
|
+
if (pageToken && users.length >= cap) truncated = true;
|
|
417
|
+
}while (pageToken && users.length < cap)
|
|
418
|
+
const tenantTruncated = [];
|
|
419
|
+
const tenantIds = await listAuthTenantIds();
|
|
420
|
+
/*
|
|
421
|
+
* The remaining budget is fixed BEFORE any pool is asked.
|
|
422
|
+
*
|
|
423
|
+
* Sizing each pool's page from the running total is what forced these calls
|
|
424
|
+
* into a series — pool `k`'s page size could not be known until pool `k-1`
|
|
425
|
+
* had answered. Nothing needs that: a page sized to the whole remaining
|
|
426
|
+
* budget is always at least as large as the shrinking one, so the first
|
|
427
|
+
* `cap` rows in tenant order are the same users either way, and the pools
|
|
428
|
+
* can be asked together.
|
|
429
|
+
*
|
|
430
|
+
* The trade is bandwidth, not quota: `listUsers` is billed per CALL, and the
|
|
431
|
+
* call count is unchanged. Only when the cap actually binds does this carry
|
|
432
|
+
* rows that are then trimmed.
|
|
433
|
+
*/ const budget = cap - users.length;
|
|
434
|
+
if (budget <= 0) {
|
|
435
|
+
truncated = true;
|
|
436
|
+
} else {
|
|
437
|
+
const pageSize = Math.min(budget, TENANT_USER_CAP);
|
|
438
|
+
const pages = await mapPoolsConcurrently(tenantIds, async (tenantId)=>{
|
|
439
|
+
try {
|
|
440
|
+
return await authForPool(tenantId).listUsers(pageSize);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
console.error(`listing users for tenant ${tenantId} failed`, error);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
pages.forEach((page, index)=>{
|
|
447
|
+
if (!page) return;
|
|
448
|
+
const tenantId = tenantIds[index];
|
|
449
|
+
const room = cap - users.length;
|
|
450
|
+
const rows = page.users.map((record)=>({
|
|
451
|
+
record,
|
|
452
|
+
tenantId
|
|
453
|
+
}));
|
|
454
|
+
// A pool whose rows will not fit is still a pool that was cut short, and
|
|
455
|
+
// it has to say so. A scan that silently dropped one would answer "no
|
|
456
|
+
// such account" for everyone in it — the exact failure this module
|
|
457
|
+
// exists to prevent, wearing the shape of a complete result.
|
|
458
|
+
if (rows.length > room) {
|
|
459
|
+
truncated = true;
|
|
460
|
+
tenantTruncated.push(tenantId);
|
|
461
|
+
} else if (page.pageToken) {
|
|
462
|
+
tenantTruncated.push(tenantId);
|
|
463
|
+
}
|
|
464
|
+
if (room > 0) users.push(...rows.slice(0, room));
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
users: markCrossPoolUidCollisions(users),
|
|
469
|
+
truncated,
|
|
470
|
+
tenantTruncated
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Write custom claims to the pool the uid ACTUALLY lives in (AGL-1993).
|
|
475
|
+
*
|
|
476
|
+
* The hazard this exists to remove: `setCustomUserClaims` is per-pool, and a
|
|
477
|
+
* grant aimed at the wrong pool does not error — on the project pool it
|
|
478
|
+
* either throws `user-not-found` (loud, fine) or, when a phantom shadow record
|
|
479
|
+
* shares the uid, silently succeeds against the WRONG record. That is not
|
|
480
|
+
* hypothetical: on 2026-08-18 a staff account's uid existed in both the
|
|
481
|
+
* project pool and `aglyn-org-y5v14` at once. That phantom won every lookup
|
|
482
|
+
* until AGL-2005 made {@link findUserByUidAcrossPools} rank an IDENTIFIED
|
|
483
|
+
* record above an unidentified one rather than taking the first pool to
|
|
484
|
+
* answer.
|
|
485
|
+
*
|
|
486
|
+
* So resolution and mutation are bound together here rather than left to each
|
|
487
|
+
* caller to pair correctly, and the resolved pool is RETURNED so the audit row
|
|
488
|
+
* can record which pool was written — "granted staff to uid X" is not a
|
|
489
|
+
* complete record when a uid can name two accounts.
|
|
490
|
+
*
|
|
491
|
+
* Never assumes a pool. Staff may live in the project pool, in Aglyn's own
|
|
492
|
+
* tenant, or in a CUSTOMER org's tenant, and hard-coding any of them would
|
|
493
|
+
* silently fail the moment a third exists.
|
|
494
|
+
*/ export async function setClaimsInOwningPool(uid, claims) {
|
|
495
|
+
var _found_record_email;
|
|
496
|
+
const found = await findUserByUidAcrossPools(uid);
|
|
497
|
+
if (!found) return null;
|
|
498
|
+
await authForPool(found.tenantId).setCustomUserClaims(uid, claims);
|
|
499
|
+
return {
|
|
500
|
+
uid,
|
|
501
|
+
tenantId: found.tenantId,
|
|
502
|
+
email: (_found_record_email = found.record.email) != null ? _found_record_email : null,
|
|
503
|
+
claims
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Every uid carrying the `staff` claim, across all pools. Staff is a custom
|
|
508
|
+
* claim, which is not queryable, so this scans — the caller caches it.
|
|
509
|
+
*/ export async function listStaffUidsAcrossPools() {
|
|
510
|
+
/*
|
|
511
|
+
* Paging WITHIN a pool stays serial — each page's cursor comes out of the
|
|
512
|
+
* one before it, so there is nothing to overlap. Across pools there is: the
|
|
513
|
+
* pools are independent, and a staff notification waiting on them one after
|
|
514
|
+
* another gets slower with every enterprise customer signed.
|
|
515
|
+
*
|
|
516
|
+
* Each scan collects into its own array rather than a shared one, so the
|
|
517
|
+
* result is ordered by POOL rather than by which pool happened to answer
|
|
518
|
+
* first. A caller diffing this list against a stored copy would otherwise
|
|
519
|
+
* see phantom changes on every run.
|
|
520
|
+
*/ const scan = async (pool)=>{
|
|
521
|
+
const found = [];
|
|
522
|
+
let pageToken;
|
|
523
|
+
do {
|
|
524
|
+
const page = await pool.listUsers(1000, pageToken);
|
|
525
|
+
for (const record of page.users){
|
|
526
|
+
var _record_customClaims;
|
|
527
|
+
if ((_record_customClaims = record.customClaims) == null ? void 0 : _record_customClaims['staff']) found.push(record.uid);
|
|
528
|
+
}
|
|
529
|
+
pageToken = page.pageToken;
|
|
530
|
+
}while (pageToken)
|
|
531
|
+
return found;
|
|
532
|
+
};
|
|
533
|
+
const tenantIds = await listAuthTenantIds();
|
|
534
|
+
const [projectUids, tenantUidLists] = await Promise.all([
|
|
535
|
+
scan(auth()),
|
|
536
|
+
mapPoolsConcurrently(tenantIds, async (tenantId)=>{
|
|
537
|
+
try {
|
|
538
|
+
return await scan(authForPool(tenantId));
|
|
539
|
+
} catch (error) {
|
|
540
|
+
console.error(`staff scan for tenant ${tenantId} failed`, error);
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
]);
|
|
545
|
+
return [
|
|
546
|
+
...projectUids,
|
|
547
|
+
...tenantUidLists.flat()
|
|
548
|
+
];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
//# sourceMappingURL=auth-pools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/auth-pools.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 * Cross-pool Firebase Auth lookups (AGL-1122).\n *\n * Enterprise SSO (AGL-1101) signs users into a per-org **GCIP tenant**, and a\n * tenant has its OWN user pool. A project-level `getUserByEmail` /\n * `listUsers` does not see those users at all — measured on aglyn-main:\n * the owner of aglyn-org returned `auth/user-not-found` at project level and\n * was present only under tenant `aglyn-org-y5v14`.\n *\n * That silently broke five separate things: staff could not find, view,\n * disable or grant staff to an SSO user; adding one to an org or a site by\n * email 404'd into \"send them an invite\" for an account that already exists;\n * and `notifyStaff` skipped SSO staff entirely. Fixing those one route at a\n * time would guarantee the next new route reintroduces the bug, so every\n * caller goes through here instead.\n *\n * Custom claims are per-pool too: `setCustomUserClaims` on the project pool\n * cannot grant staff to a tenant user. `authForPool` hands back the right\n * `BaseAuth` so a mutation lands in the pool the user actually lives in.\n */\n\nimport type { BaseAuth, UserRecord } from 'firebase-admin/auth'\nimport firebaseAdmin from './firebase-admin'\n\nconst auth = () => firebaseAdmin.app().auth()\n\n/** A user plus the pool they were found in (`null` = the project pool). */\nexport interface PooledUserRecord {\n record: UserRecord\n /** GCIP tenant id, or null when the user is in the project-level pool. */\n tenantId: string | null\n /**\n * Other pools that ALSO hold this uid (AGL-1962). Normally absent: a uid is\n * unique within a pool, and the pools are meant to hold different people.\n *\n * It is populated when the same uid turns up in more than one pool, which\n * means something minted a custom token for one pool's uid against another\n * pool — `signInWithCustomToken` CREATES the account when the uid is absent,\n * so the mistake manufactures an empty shadow account rather than failing.\n * Measured on production: `QQ7fixtureUid0000000000000001` existed in both the\n * project pool and `aglyn-org-y5v14`, the project copy created by\n * `/api/presence/token` minting an SSO user's tenant uid at project level.\n *\n * This is the marker, not a deletion: `listUsersAcrossPools` still returns\n * every record, and `collapseCrossPoolUidRows` — which is what a display\n * uses — keeps this field on the row it keeps, so a collision is still\n * legible after the rows are merged (AGL-2005).\n */\n uidAlsoInPools?: (string | null)[]\n}\n\n/**\n * Does this record show that a human ever authenticated as it? (AGL-2005)\n *\n * An address or a provider entry is the evidence. `signInWithCustomToken`\n * mints an account out of nothing when the uid is absent from the pool the\n * token was minted in, and the record it manufactures has neither: measured\n * on production, a project-pool twin of a tenant uid had `email: null` and\n * `providerData: []` while the real SSO record carried both an address and\n * `saml.aglyn-workspace`.\n *\n * `displayName` and custom claims deliberately do NOT count. Both can be\n * written onto any record after the fact — `updateProfile` and `grantStaff`\n * in the staff console do exactly that — so a shadow that a staff action had\n * already landed on would start qualifying as an identity and go on winning\n * the lookups it should be losing. The bar has to be a signal the forgery\n * cannot acquire by being acted upon.\n */\nexport function isIdentifiedUserRecord(record: UserRecord): boolean {\n if (!record) return false\n return Boolean(record.email) || (record.providerData?.length ?? 0) > 0\n}\n\n/**\n * How strongly a record identifies its human, for choosing between two that\n * share a uid (AGL-2005). Ordering only — the numbers have no other meaning.\n *\n * Email and provider data lead because they are what `isIdentifiedUserRecord`\n * trusts; the rest break ties between records that both qualify.\n */\nexport function identityStrength(record: UserRecord): number {\n if (!record) return 0\n let score = 0\n if (record.email) score += 8\n if ((record.providerData?.length ?? 0) > 0) score += 4\n if (record.phoneNumber) score += 2\n if (record.displayName) score += 1\n if (Object.keys(record.customClaims ?? {}).length > 0) score += 1\n return score\n}\n\n/**\n * Collapse rows sharing a uid to ONE row apiece (AGL-2005).\n *\n * **decided:** \"We still have two users list in this list with the\n * same uid but one without an email attached, this needs fixed we should only\n * see one user, even if they are sso.\"\n *\n * AGL-1962 deliberately refused to merge, on the grounds that collapsing two\n * accounts could hide an identity split. That reasoning still stands for two\n * DISTINCT accounts — which is why this keys on **uid** and never on email.\n * Two people who happen to share an address are two accounts with two uids\n * and stay two rows; merging them on email would delete a real user from the\n * staff console, a worse bug than the one being fixed and an invisible one.\n *\n * A repeated uid is different in kind: a uid is unique WITHIN a pool, so the\n * same uid in two pools is not two people, it is one person plus an artifact\n * of a cross-pool custom-token mint. Those merge.\n *\n * Two rules make the merge safe:\n *\n * - **The identified record wins.** Ranked by `identityStrength`, so an\n * emailless, providerless artifact can never be the row staff see — the\n * exact inversion that let the shadow win every uid lookup.\n * - **The collision is not discarded.** `markCrossPoolUidCollisions` runs\n * here rather than being left to the caller, so the surviving row always\n * carries `uidAlsoInPools` naming the pools that were folded into it. One\n * row, and it still says it was two.\n *\n * The survivor takes the FIRST occurrence's position so list ordering does\n * not shuffle, and ties keep the earlier row so the result is deterministic.\n */\nexport function collapseCrossPoolUidRows(\n users: PooledUserRecord[],\n): PooledUserRecord[] {\n // Marked here, not by the caller. A caller that forgot would silently turn\n // this into the cover-up the merge is explicitly not allowed to be.\n const marked = markCrossPoolUidCollisions(users)\n const winnerByUid = new Map<string, PooledUserRecord>()\n for (const user of marked) {\n const incumbent = winnerByUid.get(user.record.uid)\n if (\n !incumbent ||\n identityStrength(user.record) > identityStrength(incumbent.record)\n ) {\n winnerByUid.set(user.record.uid, user)\n }\n }\n const emitted = new Set<string>()\n const collapsed: PooledUserRecord[] = []\n for (const user of marked) {\n const uid = user.record.uid\n if (emitted.has(uid)) continue\n emitted.add(uid)\n collapsed.push(winnerByUid.get(uid) ?? user)\n }\n return collapsed\n}\n\n/**\n * Flag every uid that appears in more than one pool, in place of hiding it.\n *\n * Pure and total, so a spec can drive it without Firebase. Callers must pass\n * the rows they intend to show together — a collision it cannot see is one\n * it cannot report.\n */\nexport function markCrossPoolUidCollisions(\n users: PooledUserRecord[],\n): PooledUserRecord[] {\n const poolsByUid = new Map<string, (string | null)[]>()\n for (const user of users) {\n const pools = poolsByUid.get(user.record.uid) ?? []\n pools.push(user.tenantId)\n poolsByUid.set(user.record.uid, pools)\n }\n return users.map((user) => {\n const pools = poolsByUid.get(user.record.uid) ?? []\n if (pools.length < 2) return user\n // Everyone holding this uid EXCEPT this row's own pool. Compared by\n // index, not by value: two rows from the same pool would be a Firebase\n // impossibility, but filtering by value would silently drop a real one.\n const others = pools.filter((_, index) => index !== pools.indexOf(user.tenantId))\n return { ...user, uidAlsoInPools: others }\n })\n}\n\n/**\n * Every GCIP tenant on the project. Cached briefly: this is asked once per\n * lookup and the set changes only when an enterprise org is onboarded.\n * Fails soft to an empty list — a tenant-listing outage must degrade to\n * today's project-only behaviour rather than break sign-in-adjacent routes.\n */\nlet tenantCache: { ids: string[]; at: number } | null = null\nconst TENANT_CACHE_MS = 60_000\n\nexport async function listAuthTenantIds(): Promise<string[]> {\n const now = Date.now()\n if (tenantCache && now - tenantCache.at < TENANT_CACHE_MS) {\n return tenantCache.ids\n }\n try {\n const ids: string[] = []\n let pageToken: string | undefined\n do {\n const page = await auth().tenantManager().listTenants(100, pageToken)\n ids.push(...page.tenants.map((tenant) => tenant.tenantId))\n pageToken = page.pageToken\n } while (pageToken)\n tenantCache = { ids, at: now }\n return ids\n } catch (error) {\n console.error('tenant listing failed; falling back to project pool', error)\n return tenantCache?.ids ?? []\n }\n}\n\n/** Test seam — drops the tenant cache so a spec can vary the tenant set. */\nexport function resetAuthTenantCache(): void {\n tenantCache = null\n}\n\n/** The `BaseAuth` for a pool: the project pool for null, else the tenant's. */\nexport function authForPool(tenantId: string | null | undefined): BaseAuth {\n return tenantId\n ? auth().tenantManager().authForTenant(tenantId)\n : auth()\n}\n\n/**\n * Find a user by email across the project pool and every GCIP tenant.\n *\n * The project pool is tried first — it holds every non-SSO user, which is\n * almost all of them, so the common case stays a single call. Returns null\n * when no pool has the address, which is the only honest answer for \"this\n * person has no Aglyn account\".\n */\nexport async function findUserByEmailAcrossPools(\n email: string,\n): Promise<PooledUserRecord | null> {\n const address = email.trim().toLowerCase()\n if (!address) return null\n try {\n return { record: await auth().getUserByEmail(address), tenantId: null }\n } catch {\n // Not in the project pool — fall through to the tenants.\n }\n for (const tenantId of await listAuthTenantIds()) {\n try {\n const record = await authForPool(tenantId).getUserByEmail(address)\n return { record, tenantId }\n } catch {\n // Not in this tenant; keep looking.\n }\n }\n return null\n}\n\n/**\n * Find a user by uid across the project pool and every GCIP tenant. Same\n * shape as the email lookup — a uid is only unique WITHIN a pool, so the\n * caller needs to know which pool answered before it mutates anything.\n *\n * **First pool to answer is NOT good enough** (AGL-2005). This used to return\n * the project pool's record the moment it existed, which is how a forged\n * emailless twin won every uid lookup for a real SSO user: `grantStaff`,\n * `disable`, `setRole`, `updateProfile`, `revokeRefreshTokens`, impersonate,\n * usage-email, resolve-people, org-lockdown and erasure all landed on a\n * record nobody can sign in as. Two of those were caught having happened —\n * a `revokeRefreshTokens` dated 2026-08-14 sat on the twin while the real\n * account's `tokensValidAfterTime` never moved, so \"sign out everywhere\" did\n * nothing at all.\n *\n * So a pool's answer is accepted only when the record it returns actually\n * identifies someone, and the winner is chosen by the SAME rule the staff\n * list collapses rows with. That equivalence is the point: the row staff\n * click is the record the action mutates, by construction rather than by\n * coincidence.\n *\n * The fast path survives intact — a project account with an address or a\n * provider is returned without any tenant call, which is nearly every\n * account. Only a record carrying no identity at all pays for the sweep.\n */\nexport async function findUserByUidAcrossPools(\n uid: string,\n): Promise<PooledUserRecord | null> {\n if (!uid) return null\n const matches: PooledUserRecord[] = []\n try {\n const record = await auth().getUser(uid)\n if (isIdentifiedUserRecord(record)) return { record, tenantId: null }\n // Held, not returned. It is still the answer if nothing better exists —\n // an anonymous or half-created account legitimately looks like this, and\n // refusing it here would break every caller that handles those.\n matches.push({ record, tenantId: null })\n } catch {\n // Not in the project pool.\n }\n for (const tenantId of await listAuthTenantIds()) {\n try {\n const record = await authForPool(tenantId).getUser(uid)\n matches.push({ record, tenantId })\n // Stop at the first pool that knows who this is. Scanning the rest\n // would cost a call per enterprise tenant on a path that runs during\n // sign-in and billing, and a uid identified in two tenant pools is not\n // a shape Firebase can produce — tenant uids are minted per pool.\n if (isIdentifiedUserRecord(record)) break\n } catch {\n // Keep looking.\n }\n }\n if (!matches.length) return null\n return collapseCrossPoolUidRows(matches)[0] ?? null\n}\n\nexport interface PooledUserPage {\n users: PooledUserRecord[]\n /** Project-pool cursor; null when the project pool is exhausted. */\n nextPageToken: string | null\n /**\n * True once the project pool is exhausted and the tenant users have been\n * appended. Tenant pools are NOT paginated here — enterprise SSO pools are\n * small, and silently dropping their tail would recreate exactly the\n * invisibility this module exists to fix. `tenantTruncated` says when that\n * assumption stopped holding rather than letting it fail quietly.\n */\n tenantsIncluded: boolean\n /** Tenants whose users hit the cap and were cut short. */\n tenantTruncated: string[]\n}\n\n/** How many tenant users a single page will carry before reporting a cut. */\nconst TENANT_USER_CAP = 500\n\n/**\n * How many pools may have a call in flight at once.\n *\n * Every sweep below asks each GCIP tenant pool the same question, and the\n * pools are independent: one tenant's answer never informs another's. Asked in\n * series that costs `N` round trips for `N` enterprise customers, so the staff\n * list gets slower every time one is signed — the wrong direction for the\n * feature that adds them. Asked together it costs one.\n *\n * The number of CALLS is identical either way, so this buys latency and the\n * Cloud Run seconds spent waiting on it, not Auth quota. That is also why it\n * is bounded rather than an unbounded `Promise.all`: Identity Platform rate\n * limits `listUsers` per project, and firing one call per tenant simultaneously\n * would convert a latency problem into a `RESOURCE_EXHAUSTED` one at exactly\n * the customer count where the latency stopped mattering.\n */\nconst POOL_FETCH_CONCURRENCY = 8\n\n/**\n * Run `work` over every pool id, at most {@link POOL_FETCH_CONCURRENCY} at a\n * time, and return the results **in the order the ids were given**.\n *\n * Order is not cosmetic here. Callers concatenate these results into a user\n * list and then trim it to a cap, so a result order that varied with which\n * pool answered first would make the rows a staff member sees depend on\n * network timing — the same query returning different people on a refresh.\n */\nasync function mapPoolsConcurrently<T>(\n tenantIds: string[],\n work: (tenantId: string) => Promise<T>,\n): Promise<T[]> {\n const results = new Array<T>(tenantIds.length)\n let next = 0\n const workers = Array.from(\n { length: Math.min(POOL_FETCH_CONCURRENCY, tenantIds.length) },\n async () => {\n for (;;) {\n const index = next++\n if (index >= tenantIds.length) return\n results[index] = await work(tenantIds[index])\n }\n },\n )\n await Promise.all(workers)\n return results\n}\n\n/**\n * One page of users across every pool: the project pool paginates as before,\n * and tenant users are appended on the LAST page so they can never be lost\n * between cursors. Callers render `tenantId` so an SSO account is\n * recognisable — it is not editable the same way a project user is.\n */\nexport async function listUsersAcrossPools(\n pageSize: number,\n pageToken?: string,\n): Promise<PooledUserPage> {\n const page = await auth().listUsers(pageSize, pageToken)\n const users: PooledUserRecord[] = page.users.map((record) => ({\n record,\n tenantId: null,\n }))\n if (page.pageToken) {\n return {\n users,\n nextPageToken: page.pageToken,\n tenantsIncluded: false,\n tenantTruncated: [],\n }\n }\n const tenantTruncated: string[] = []\n // Every tenant asks for the same fixed page, so no pool's request depends on\n // another's answer and they can all be in flight at once. A failed pool\n // contributes no rows and does not stop the others: one enterprise tenant\n // being unreachable must not blank the whole staff list.\n //\n // The id list is read ONCE and reused. It is cached on a 60s TTL, so asking\n // twice can hand back two different lists, and pairing a page from one list\n // with an id from the other would label a user with the wrong tenant.\n const tenantIds = await listAuthTenantIds()\n const tenantPages = await mapPoolsConcurrently(tenantIds, async (tenantId) => {\n try {\n return await authForPool(tenantId).listUsers(TENANT_USER_CAP)\n } catch (error) {\n console.error(`listing users for tenant ${tenantId} failed`, error)\n return null\n }\n })\n tenantPages.forEach((tenantPage, index) => {\n if (!tenantPage) return\n const tenantId = tenantIds[index]\n users.push(...tenantPage.users.map((record) => ({ record, tenantId })))\n if (tenantPage.pageToken) tenantTruncated.push(tenantId)\n })\n return {\n // Marked only here, where every pool's rows are in hand (AGL-1962). The\n // earlier return above carries project rows alone, and a uid cannot\n // collide with itself inside one pool, so there is nothing to find there.\n users: markCrossPoolUidCollisions(users),\n nextPageToken: null,\n tenantsIncluded: true,\n tenantTruncated,\n }\n}\n\n/**\n * Every account across every pool, up to a cap — the only way to FILTER them.\n *\n * Firebase Auth's `listUsers` takes a page size and a cursor and nothing else:\n * no predicate, no ordering, no search. So a staff filter on anything but an\n * exact email, uid or phone number (which have their own O(1) lookups) can\n * only be answered by reading the accounts and matching them.\n *\n * That is an expensive read, so it happens on an ASK and never on a mount —\n * the route calls this only when a request carries a filter, and pages the\n * cheap way otherwise.\n *\n * `truncated` is true when the cap was reached, and it must be shown rather\n * than swallowed: a staff list that searched part of the directory and said\n * \"no matches\" is worse than one that admits where it stopped.\n */\nexport async function scanUsersAcrossPools(\n cap: number,\n): Promise<{\n users: PooledUserRecord[]\n truncated: boolean\n tenantTruncated: string[]\n}> {\n const users: PooledUserRecord[] = []\n let truncated = false\n let pageToken: string | undefined\n\n do {\n const remaining = cap - users.length\n if (remaining <= 0) {\n truncated = true\n break\n }\n // `listUsers` caps a page at 1000 whatever it is asked for.\n const page = await auth().listUsers(Math.min(remaining, 1000), pageToken)\n users.push(...page.users.map((record) => ({ record, tenantId: null })))\n pageToken = page.pageToken\n if (pageToken && users.length >= cap) truncated = true\n } while (pageToken && users.length < cap)\n\n const tenantTruncated: string[] = []\n const tenantIds = await listAuthTenantIds()\n /*\n * The remaining budget is fixed BEFORE any pool is asked.\n *\n * Sizing each pool's page from the running total is what forced these calls\n * into a series — pool `k`'s page size could not be known until pool `k-1`\n * had answered. Nothing needs that: a page sized to the whole remaining\n * budget is always at least as large as the shrinking one, so the first\n * `cap` rows in tenant order are the same users either way, and the pools\n * can be asked together.\n *\n * The trade is bandwidth, not quota: `listUsers` is billed per CALL, and the\n * call count is unchanged. Only when the cap actually binds does this carry\n * rows that are then trimmed.\n */\n const budget = cap - users.length\n if (budget <= 0) {\n truncated = true\n } else {\n const pageSize = Math.min(budget, TENANT_USER_CAP)\n const pages = await mapPoolsConcurrently(tenantIds, async (tenantId) => {\n try {\n return await authForPool(tenantId).listUsers(pageSize)\n } catch (error) {\n console.error(`listing users for tenant ${tenantId} failed`, error)\n return null\n }\n })\n pages.forEach((page, index) => {\n if (!page) return\n const tenantId = tenantIds[index]\n const room = cap - users.length\n const rows = page.users.map((record) => ({ record, tenantId }))\n // A pool whose rows will not fit is still a pool that was cut short, and\n // it has to say so. A scan that silently dropped one would answer \"no\n // such account\" for everyone in it — the exact failure this module\n // exists to prevent, wearing the shape of a complete result.\n if (rows.length > room) {\n truncated = true\n tenantTruncated.push(tenantId)\n } else if (page.pageToken) {\n tenantTruncated.push(tenantId)\n }\n if (room > 0) users.push(...rows.slice(0, room))\n })\n }\n\n return {\n users: markCrossPoolUidCollisions(users),\n truncated,\n tenantTruncated,\n }\n}\n\n/** Where a claim mutation landed, so a caller can audit the POOL as well. */\nexport interface StaffClaimWrite {\n uid: string\n /** The pool the claim was written to; null = the project pool. */\n tenantId: string | null\n email: string | null\n claims: Record<string, unknown>\n}\n\n/**\n * Write custom claims to the pool the uid ACTUALLY lives in (AGL-1993).\n *\n * The hazard this exists to remove: `setCustomUserClaims` is per-pool, and a\n * grant aimed at the wrong pool does not error — on the project pool it\n * either throws `user-not-found` (loud, fine) or, when a phantom shadow record\n * shares the uid, silently succeeds against the WRONG record. That is not\n * hypothetical: on 2026-08-18 a staff account's uid existed in both the\n * project pool and `aglyn-org-y5v14` at once. That phantom won every lookup\n * until AGL-2005 made {@link findUserByUidAcrossPools} rank an IDENTIFIED\n * record above an unidentified one rather than taking the first pool to\n * answer.\n *\n * So resolution and mutation are bound together here rather than left to each\n * caller to pair correctly, and the resolved pool is RETURNED so the audit row\n * can record which pool was written — \"granted staff to uid X\" is not a\n * complete record when a uid can name two accounts.\n *\n * Never assumes a pool. Staff may live in the project pool, in Aglyn's own\n * tenant, or in a CUSTOMER org's tenant, and hard-coding any of them would\n * silently fail the moment a third exists.\n */\nexport async function setClaimsInOwningPool(\n uid: string,\n claims: Record<string, unknown>,\n): Promise<StaffClaimWrite | null> {\n const found = await findUserByUidAcrossPools(uid)\n if (!found) return null\n await authForPool(found.tenantId).setCustomUserClaims(uid, claims)\n return {\n uid,\n tenantId: found.tenantId,\n email: found.record.email ?? null,\n claims,\n }\n}\n\n/**\n * Every uid carrying the `staff` claim, across all pools. Staff is a custom\n * claim, which is not queryable, so this scans — the caller caches it.\n */\nexport async function listStaffUidsAcrossPools(): Promise<string[]> {\n /*\n * Paging WITHIN a pool stays serial — each page's cursor comes out of the\n * one before it, so there is nothing to overlap. Across pools there is: the\n * pools are independent, and a staff notification waiting on them one after\n * another gets slower with every enterprise customer signed.\n *\n * Each scan collects into its own array rather than a shared one, so the\n * result is ordered by POOL rather than by which pool happened to answer\n * first. A caller diffing this list against a stored copy would otherwise\n * see phantom changes on every run.\n */\n const scan = async (pool: BaseAuth): Promise<string[]> => {\n const found: string[] = []\n let pageToken: string | undefined\n do {\n const page = await pool.listUsers(1000, pageToken)\n for (const record of page.users) {\n if (record.customClaims?.['staff']) found.push(record.uid)\n }\n pageToken = page.pageToken\n } while (pageToken)\n return found\n }\n const tenantIds = await listAuthTenantIds()\n const [projectUids, tenantUidLists] = await Promise.all([\n scan(auth()),\n mapPoolsConcurrently(tenantIds, async (tenantId) => {\n try {\n return await scan(authForPool(tenantId))\n } catch (error) {\n console.error(`staff scan for tenant ${tenantId} failed`, error)\n return [] as string[]\n }\n }),\n ])\n return [...projectUids, ...tenantUidLists.flat()]\n}\n"],"names":["firebaseAdmin","auth","app","isIdentifiedUserRecord","record","Boolean","email","providerData","length","identityStrength","score","phoneNumber","displayName","Object","keys","customClaims","collapseCrossPoolUidRows","users","marked","markCrossPoolUidCollisions","winnerByUid","Map","user","incumbent","get","uid","set","emitted","Set","collapsed","has","add","push","poolsByUid","pools","tenantId","map","others","filter","_","index","indexOf","uidAlsoInPools","tenantCache","TENANT_CACHE_MS","listAuthTenantIds","now","Date","at","ids","pageToken","page","tenantManager","listTenants","tenants","tenant","error","console","resetAuthTenantCache","authForPool","authForTenant","findUserByEmailAcrossPools","address","trim","toLowerCase","getUserByEmail","findUserByUidAcrossPools","matches","getUser","TENANT_USER_CAP","POOL_FETCH_CONCURRENCY","mapPoolsConcurrently","tenantIds","work","results","Array","next","workers","from","Math","min","Promise","all","listUsersAcrossPools","pageSize","listUsers","nextPageToken","tenantsIncluded","tenantTruncated","tenantPages","forEach","tenantPage","scanUsersAcrossPools","cap","truncated","remaining","budget","pages","room","rows","slice","setClaimsInOwningPool","claims","found","setCustomUserClaims","listStaffUidsAcrossPools","scan","pool","projectUids","tenantUidLists","flat"],"mappings":"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"}
|