@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,364 @@
|
|
|
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
|
+
*/ /**
|
|
18
|
+
* The personal profile document, `users/{uid}` (AGL-1127).
|
|
19
|
+
*
|
|
20
|
+
* It holds what Manage Account → Basic info edits (`firstName`, `lastName`,
|
|
21
|
+
* `phoneNumber`, `organization`) plus the avatar the console shows for you.
|
|
22
|
+
* It is NOT an identity source: email and display name for OTHER people are
|
|
23
|
+
* read from the org roster (`orgs/{id}/members/{uid}`), which works for SSO
|
|
24
|
+
* accounts that project-level auth cannot see at all (AGL-1122). Nothing here
|
|
25
|
+
* duplicates them, so there is nothing to drift.
|
|
26
|
+
*
|
|
27
|
+
* Until this existed, no account-creation path wrote the doc — it was born
|
|
28
|
+
* the first time someone saved Basic info, so a fresh account rendered the
|
|
29
|
+
* form against a document that did not exist. Measured on production
|
|
30
|
+
* 2026-07-30: 1 of 3 accounts had one, and the account that did had exactly
|
|
31
|
+
* the four Basic-info fields.
|
|
32
|
+
*/ import { splitDisplayName } from "@aglyn/shared-util-tools";
|
|
33
|
+
import { isBlankAddress, normalizeAddress, normalizePhone } from "@aglyn/aglyn/server";
|
|
34
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
35
|
+
import { getContactSuppression, suppressPhoneContact } from "./contact-suppression.js";
|
|
36
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
37
|
+
const firestore = ()=>firebaseAdmin.app().firestore();
|
|
38
|
+
/**
|
|
39
|
+
* Create `users/{uid}` if it is missing and prefill the name fields from the
|
|
40
|
+
* identity provider, without ever overwriting what the user has typed.
|
|
41
|
+
*
|
|
42
|
+
* Only ABSENT fields are written, so re-running on every sign-in — which is
|
|
43
|
+
* what the SSO route does — cannot undo an edit: rename yourself in Basic
|
|
44
|
+
* info and the IdP's copy stays out of it.
|
|
45
|
+
*
|
|
46
|
+
* Best-effort by contract. Provisioning access must not fail because a
|
|
47
|
+
* cosmetic prefill did, so the caller is expected to let a rejection through
|
|
48
|
+
* rather than surface it; the profile self-heals on the next sign-in.
|
|
49
|
+
*
|
|
50
|
+
* @returns what the seed actually wrote, for logging and tests.
|
|
51
|
+
*/ export async function seedUserProfile(uid, input = {}) {
|
|
52
|
+
var _input_firestore;
|
|
53
|
+
var _input_photoUrl, _input_phoneNumber;
|
|
54
|
+
const ref = ((_input_firestore = input.firestore) != null ? _input_firestore : firestore()).collection('users').doc(uid);
|
|
55
|
+
const snapshot = await ref.get();
|
|
56
|
+
const { firstName, lastName } = splitDisplayName(input.displayName);
|
|
57
|
+
const blank = (value)=>typeof value !== 'string' || !value.trim();
|
|
58
|
+
const seed = {};
|
|
59
|
+
if (firstName && blank(snapshot.get('firstName'))) seed['firstName'] = firstName;
|
|
60
|
+
if (lastName && blank(snapshot.get('lastName'))) seed['lastName'] = lastName;
|
|
61
|
+
// Same absent-only rule: a directory photo fills an empty avatar, and never
|
|
62
|
+
// replaces one somebody uploaded. `photoUrl` here, not `photoURL` — this
|
|
63
|
+
// doc's spelling differs from the roster's, and writing the roster's would
|
|
64
|
+
// add a field Manage Account does not read.
|
|
65
|
+
//
|
|
66
|
+
// THE REMOVAL MARKER (AGL-2486). Absent-only is what protects an avatar
|
|
67
|
+
// somebody SET; it does nothing for one they REMOVED, because removing it
|
|
68
|
+
// is precisely what makes the field absent again. So this seed runs on the
|
|
69
|
+
// next sign-in, finds a blank field and a directory thumbnail, and puts
|
|
70
|
+
// back the picture the person just deleted — from the customer's own IdP,
|
|
71
|
+
// on every sign-in, forever.
|
|
72
|
+
//
|
|
73
|
+
// `propagateMemberPhoto` already refuses this on the roster row, and says
|
|
74
|
+
// why: it clears with `FieldValue.delete()` rather than `''` so the
|
|
75
|
+
// backfill's identical `blank()` cannot read the clear as "never set". The
|
|
76
|
+
// roster half got that treatment and this half did not, and no deletion
|
|
77
|
+
// sentinel can fix it here — an absent field is exactly the state a
|
|
78
|
+
// removal produces, so the two are indistinguishable without a marker.
|
|
79
|
+
//
|
|
80
|
+
// Hence `photoUrlErasedAt`, the same shape as `phoneNumberErasedAt` and
|
|
81
|
+
// `addressErasedAt` above and below, and owner-writable for the same
|
|
82
|
+
// reason: the only party who can clear it is the person it protects, and a
|
|
83
|
+
// person clearing their own marker is a person asking for IdP prefill
|
|
84
|
+
// again. Manage Account drops it on any save that stores a photo, so
|
|
85
|
+
// opting back in is just setting an avatar.
|
|
86
|
+
//
|
|
87
|
+
// Not a privacy control, unlike those two — an avatar is a picture the
|
|
88
|
+
// directory published, not a contact channel. It is a "stop overruling me"
|
|
89
|
+
// control, which is the whole of what "set AND CHANGE your own avatar"
|
|
90
|
+
// requires when a directory is asserting one on every sign-in.
|
|
91
|
+
if (((_input_photoUrl = input.photoUrl) == null ? void 0 : _input_photoUrl.trim()) && blank(snapshot.get('photoUrl'))) {
|
|
92
|
+
if (!snapshot.get('photoUrlErasedAt')) seed['photoUrl'] = input.photoUrl.trim();
|
|
93
|
+
}
|
|
94
|
+
// The phone is the one seeded field with a legal dimension, so it carries
|
|
95
|
+
// two guards the others do not (AGL-1592).
|
|
96
|
+
//
|
|
97
|
+
// THE DEFECT THIS EXISTS TO PREVENT. Privacy Policy v4 §11 says a user may
|
|
98
|
+
// ask us to delete the phone number we hold. Clearing the field is not
|
|
99
|
+
// enough: `/api/auth/sso-jit` and `/api/auth/session` both run this seed on
|
|
100
|
+
// EVERY sign-in, and "absent field" is exactly the condition that makes it
|
|
101
|
+
// write. A naive deletion therefore undoes itself the next time the person
|
|
102
|
+
// signs in, from the customer's IdP, with no trace — the honoured request
|
|
103
|
+
// and the resurrected number look identical in the data.
|
|
104
|
+
//
|
|
105
|
+
// Two keys, because neither one covers the whole defect:
|
|
106
|
+
//
|
|
107
|
+
// - `phoneNumberErasedAt` on this document catches the same account. It is
|
|
108
|
+
// free (the snapshot is already in hand) and it is what runs on the hot
|
|
109
|
+
// path, so the common case adds no read at all.
|
|
110
|
+
// - the number-keyed suppression record catches the SAME PERSON ARRIVING AS
|
|
111
|
+
// A DIFFERENT ACCOUNT — reprovisioned in the IdP, or signing up
|
|
112
|
+
// self-serve alongside their SSO seat. Their new `users/{uid}` has no
|
|
113
|
+
// marker on it, so the per-account key alone would re-store a number
|
|
114
|
+
// somebody asked us to stop holding.
|
|
115
|
+
//
|
|
116
|
+
// The suppression lookup is reached only when we are actually about to write
|
|
117
|
+
// a phone, which is the first sign-in of an account and no other time; an
|
|
118
|
+
// erased account short-circuits on the marker before spending the read.
|
|
119
|
+
//
|
|
120
|
+
// Note which suppressions block this: ONLY `erasePhoneOnFile`. "Stop calling
|
|
121
|
+
// me" is not "stop holding my number" — see contact-suppression.ts — and
|
|
122
|
+
// treating a do-not-call as a deletion request would answer a question the
|
|
123
|
+
// person did not ask.
|
|
124
|
+
if (((_input_phoneNumber = input.phoneNumber) == null ? void 0 : _input_phoneNumber.trim()) && blank(snapshot.get('phoneNumber'))) {
|
|
125
|
+
let refused = Boolean(snapshot.get('phoneNumberErasedAt'));
|
|
126
|
+
if (!refused) {
|
|
127
|
+
try {
|
|
128
|
+
var _input_firestore1;
|
|
129
|
+
const suppression = await getContactSuppression(input.phoneNumber, (_input_firestore1 = input.firestore) != null ? _input_firestore1 : undefined);
|
|
130
|
+
refused = Boolean(suppression == null ? void 0 : suppression.erasePhoneOnFile) && !(suppression == null ? void 0 : suppression.revokedAt);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// Fail CLOSED, in step with `isPhoneContactSuppressed`. A lookup that
|
|
133
|
+
// throws leaves us unable to say whether this number was erased on
|
|
134
|
+
// request, and re-storing it in that state is the failure this guard
|
|
135
|
+
// exists for. A cosmetic prefill is worth nothing next to it.
|
|
136
|
+
console.error('[user-profiles] suppression lookup failed; not seeding phone', error);
|
|
137
|
+
refused = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!refused) seed['phoneNumber'] = input.phoneNumber.trim();
|
|
141
|
+
}
|
|
142
|
+
// The address (AGL-1963), the last of AGL-1133's six and the only one that
|
|
143
|
+
// did not ship — it was written as blocked on AGL-1131 mapping attributes,
|
|
144
|
+
// which completed 2026-08-01, and nothing picked it back up.
|
|
145
|
+
//
|
|
146
|
+
// NORMALIZE BEFORE DECIDING. `normalizeAddress` returns null for an address
|
|
147
|
+
// with nothing usable in it, which is what stops a sparse assertion — a
|
|
148
|
+
// country the IdP spells "United States", and no other field — from storing
|
|
149
|
+
// an object that every `if (address)` in the codebase reads as an address.
|
|
150
|
+
// Checking the raw parts first and normalizing after would write exactly
|
|
151
|
+
// that object.
|
|
152
|
+
//
|
|
153
|
+
// Absent-only, like the rest, but `isBlankAddress` rather than `blank()`:
|
|
154
|
+
// the stored value is a MAP, so the string check the other fields use would
|
|
155
|
+
// call every populated address blank and overwrite it on every sign-in.
|
|
156
|
+
//
|
|
157
|
+
// THE ERASURE GUARD, following the phone's precedent (Privacy Policy v4 §11,
|
|
158
|
+
// AGL-1592). An address is squarely personal data, and `/api/auth/sso-jit`
|
|
159
|
+
// and `/api/auth/session` both run this seed on EVERY sign-in — so "absent
|
|
160
|
+
// field" is the condition that makes it write, and a deletion would undo
|
|
161
|
+
// itself at the next SSO sign-in from the customer's own IdP, leaving the
|
|
162
|
+
// honoured request and the resurrected address identical in the data.
|
|
163
|
+
//
|
|
164
|
+
// ONE key, where the phone needs two, and the asymmetry is the point rather
|
|
165
|
+
// than an omission. `phoneNumberErasedAt` is paired with a NUMBER-KEYED
|
|
166
|
+
// suppression record because a phone number is a routing address: the same
|
|
167
|
+
// person arriving as a different account must not have it re-stored, and
|
|
168
|
+
// the number is the only thing that can recognise them. A postal address is
|
|
169
|
+
// not a channel we contact anyone on and there is no do-not-mail list for it
|
|
170
|
+
// to join, so the per-account marker is the whole of the mechanism here.
|
|
171
|
+
// Adding a second, address-keyed store would retain MORE personal data in
|
|
172
|
+
// the name of erasing it, which is the opposite of what §11 asks for.
|
|
173
|
+
//
|
|
174
|
+
// A STREET LINE IS REQUIRED, and this is the one field where the IdP's copy
|
|
175
|
+
// is held to a stricter rule than a typed one (AGL-1566).
|
|
176
|
+
//
|
|
177
|
+
// AGL-1963 asked that a partial assertion "not produce a half-address that
|
|
178
|
+
// looks populated" and named `normalizeAddress` as the mechanism. It is not
|
|
179
|
+
// sufficient: `normalizeAddress` returns null only when NOTHING survives, so
|
|
180
|
+
// a locality on its own survives, and this seed stored it. That was pinned
|
|
181
|
+
// as a judgement call, on the reasoning that a typed city-only address is
|
|
182
|
+
// stored happily too and a second rule here would give an SSO user and a
|
|
183
|
+
// typing user different profiles from the same input.
|
|
184
|
+
//
|
|
185
|
+
// AGL-1566 is the fact that reverses it. The `Aglyn Console SSO` app maps
|
|
186
|
+
// `city`, `region` and `postalCode` out of the Directory's **Home** address
|
|
187
|
+
// and maps NO street row, so a street-less fragment is not an edge case for
|
|
188
|
+
// this IdP — it is the only address it can ever assert, and the value is
|
|
189
|
+
// residential. The symmetry argument does not survive it either: the typing
|
|
190
|
+
// user CHOSE to enter a city, and the SSO user chose nothing at all. So the
|
|
191
|
+
// two inputs were never the same input.
|
|
192
|
+
//
|
|
193
|
+
// What that leaves is personal data we would receive, store, and have no use
|
|
194
|
+
// for — nothing outside the owner's own Manage Account form reads
|
|
195
|
+
// `users/{uid}.address`, and no tax, billing or shipping path touches it.
|
|
196
|
+
// Under data minimisation the honest answer to "why do we hold this
|
|
197
|
+
// person's home postcode" has to be better than "the directory offered it".
|
|
198
|
+
// For a complete address it is: the person gets a filled-in form instead of
|
|
199
|
+
// an empty one. For a city and a postcode it is nothing, because that is not
|
|
200
|
+
// an address anybody could use.
|
|
201
|
+
//
|
|
202
|
+
// Deliberately NOT pushed into `normalizeAddress` or into `resolveIdpAddress`.
|
|
203
|
+
// The resolver's job is to report what the assertion said, and lying about
|
|
204
|
+
// that would hide the exposure rather than remove it; `normalizeAddress` is
|
|
205
|
+
// shared with Manage Account, where a person storing only a city is their own
|
|
206
|
+
// business. The rule belongs exactly here, at the point where WE decide to
|
|
207
|
+
// persist something nobody asked us to hold.
|
|
208
|
+
const address = normalizeAddress(input.address);
|
|
209
|
+
if ((address == null ? void 0 : address.line1) && isBlankAddress(snapshot.get('address'))) {
|
|
210
|
+
if (!snapshot.get('addressErasedAt')) seed['address'] = address;
|
|
211
|
+
}
|
|
212
|
+
// An existing doc with nothing missing needs no write at all — the common
|
|
213
|
+
// case on every sign-in after the first.
|
|
214
|
+
if (snapshot.exists && !Object.keys(seed).length) {
|
|
215
|
+
return {
|
|
216
|
+
created: false,
|
|
217
|
+
fields: []
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// A doc with no name to seed is still worth creating: it is where the
|
|
221
|
+
// avatar and the notification mutes land, and its absence is what made
|
|
222
|
+
// those read as "this account has none" rather than "not set yet".
|
|
223
|
+
if (!snapshot.exists) seed['createdAt'] = FieldValue.serverTimestamp();
|
|
224
|
+
await ref.set(seed, {
|
|
225
|
+
merge: true
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
created: !snapshot.exists,
|
|
229
|
+
fields: Object.keys(seed).filter((key)=>key !== 'createdAt')
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* "Delete the phone number you hold for me" — the second half of Privacy
|
|
234
|
+
* Policy v4 §11 (AGL-1592). Deliberately adjacent to `seedUserProfile`,
|
|
235
|
+
* because the two are halves of one invariant: this sets the marker that one
|
|
236
|
+
* reads, and changing either without the other silently restores the defect.
|
|
237
|
+
*
|
|
238
|
+
* WHAT IT DOES, AND WHY IT IS NOT JUST A DELETE
|
|
239
|
+
*
|
|
240
|
+
* 1. Removes `users/{uid}.phoneNumber` outright (`FieldValue.delete()`, not
|
|
241
|
+
* `null` — the request was to stop holding it, and a nulled field still
|
|
242
|
+
* reads as "we have a phone slot for this person").
|
|
243
|
+
* 2. Stamps `phoneNumberErasedAt`, which is what stops the IdP re-asserting
|
|
244
|
+
* it on the next sign-in.
|
|
245
|
+
* 3. Writes a suppression record for the number, with `erasePhoneOnFile`.
|
|
246
|
+
*
|
|
247
|
+
* Step 3 is the one that looks contradictory and is not. Deleting every copy
|
|
248
|
+
* of a number does NOT protect the person from being called — it destroys the
|
|
249
|
+
* only artefact that could recognise them, so the same number arriving later
|
|
250
|
+
* from a customer's CRM, a support ticket, or their own re-typed profile is a
|
|
251
|
+
* number we have never heard of and will happily dial. The minimal retained
|
|
252
|
+
* record is what makes the deletion request mean what the person meant by it.
|
|
253
|
+
* The full reasoning, and the statutory carve-outs it rests on, are in
|
|
254
|
+
* contact-suppression.ts.
|
|
255
|
+
*
|
|
256
|
+
* §11 does not currently say this. It should — see the wording proposed on
|
|
257
|
+
* AGL-1592. Do not resolve the mismatch by making the code forget the number.
|
|
258
|
+
*
|
|
259
|
+
* WHY THE MARKER LIVES ON A CLIENT-WRITABLE DOCUMENT. `users/{uid}` is
|
|
260
|
+
* writable by its owner (and by staff) under the Firestore rules, so this
|
|
261
|
+
* marker is not tamper-proof. It does not need to be: the only party who can
|
|
262
|
+
* clear it is the person it protects, and a person clearing their own erasure
|
|
263
|
+
* marker is a person changing their mind, not an attacker. The adversary here
|
|
264
|
+
* is our own seeding code path, which is server-side and honours it. Moving
|
|
265
|
+
* the marker to a server-only document would buy nothing and cost a second
|
|
266
|
+
* read on every sign-in.
|
|
267
|
+
*
|
|
268
|
+
* A stale marker is harmless: it only ever suppresses an IdP prefill, and a
|
|
269
|
+
* user who types a number back in has a non-blank field, which the seed skips
|
|
270
|
+
* anyway.
|
|
271
|
+
*
|
|
272
|
+
* @param phoneNumber the number to suppress. Defaults to whatever the profile
|
|
273
|
+
* currently holds — pass it explicitly when the request named a number
|
|
274
|
+
* we do not have on file.
|
|
275
|
+
* @returns `suppressed` is the E.164 that went onto the do-not-contact list,
|
|
276
|
+
* or null when there was no recognizable number to record.
|
|
277
|
+
*/ export async function forgetUserPhoneNumber(input) {
|
|
278
|
+
var _input_firestore, _input_phoneNumber;
|
|
279
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : firestore();
|
|
280
|
+
const ref = db.collection('users').doc(input.uid);
|
|
281
|
+
const snapshot = await ref.get();
|
|
282
|
+
const onFile = snapshot.exists ? snapshot.get('phoneNumber') : null;
|
|
283
|
+
const target = normalizePhone((_input_phoneNumber = input.phoneNumber) != null ? _input_phoneNumber : onFile);
|
|
284
|
+
// The suppression is written FIRST. If it fails, the number is still on the
|
|
285
|
+
// profile and the request is visibly unfulfilled — which is recoverable by
|
|
286
|
+
// retrying. The other order deletes our only copy of the number and then
|
|
287
|
+
// fails to record it, leaving nothing to retry with.
|
|
288
|
+
let suppressed = null;
|
|
289
|
+
if (target) {
|
|
290
|
+
var _input_recordedByUid, _input_note;
|
|
291
|
+
const result = await suppressPhoneContact(_extends({
|
|
292
|
+
phoneNumber: target,
|
|
293
|
+
source: 'erasure-request',
|
|
294
|
+
uid: input.uid,
|
|
295
|
+
recordedByUid: (_input_recordedByUid = input.recordedByUid) != null ? _input_recordedByUid : null,
|
|
296
|
+
note: (_input_note = input.note) != null ? _input_note : null,
|
|
297
|
+
erasePhoneOnFile: true
|
|
298
|
+
}, input.firestore ? {
|
|
299
|
+
firestore: input.firestore
|
|
300
|
+
} : {}));
|
|
301
|
+
suppressed = result.phoneNumber;
|
|
302
|
+
}
|
|
303
|
+
// The marker is stamped even when no number was recognizable, so a later
|
|
304
|
+
// IdP assertion for this account is refused too. Someone who asks us to
|
|
305
|
+
// stop holding their number has asked about the account, not only about the
|
|
306
|
+
// digits we happened to have at the time.
|
|
307
|
+
await ref.set({
|
|
308
|
+
phoneNumber: FieldValue.delete(),
|
|
309
|
+
phoneNumberErasedAt: FieldValue.serverTimestamp()
|
|
310
|
+
}, {
|
|
311
|
+
merge: true
|
|
312
|
+
});
|
|
313
|
+
return {
|
|
314
|
+
cleared: Boolean(onFile),
|
|
315
|
+
suppressed
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* "Delete the address you hold for me" (AGL-1963) — the address half of the
|
|
320
|
+
* same §11 invariant `forgetUserPhoneNumber` implements for the phone, and
|
|
321
|
+
* deliberately adjacent to both for the same reason: this sets the marker
|
|
322
|
+
* that `seedUserProfile` reads, and changing either without the other
|
|
323
|
+
* silently restores the defect.
|
|
324
|
+
*
|
|
325
|
+
* A guard whose marker nothing ever writes is decorative, so this exists
|
|
326
|
+
* before anything needs it. Two callers do:
|
|
327
|
+
*
|
|
328
|
+
* - Manage Account, when someone clears their own address. That is the
|
|
329
|
+
* common case by a wide margin — nobody files a support ticket to remove
|
|
330
|
+
* a street address, they empty the field — and without a marker their
|
|
331
|
+
* next SSO sign-in puts it straight back.
|
|
332
|
+
* - Staff, handling an erasure request against an account the person can no
|
|
333
|
+
* longer sign in to.
|
|
334
|
+
*
|
|
335
|
+
* `FieldValue.delete()`, not `null`, on the same reasoning as the phone: the
|
|
336
|
+
* request was to stop holding it, and a nulled field still reads as "we have
|
|
337
|
+
* an address slot for this person".
|
|
338
|
+
*
|
|
339
|
+
* REVERSIBLE BY THE PERSON IT PROTECTS, and only by them. `users/{uid}` is
|
|
340
|
+
* writable by its owner (and by staff) under the Firestore rules, so this
|
|
341
|
+
* marker is not tamper-proof and does not need to be — the adversary it
|
|
342
|
+
* guards against is our own seeding path, which is server-side and honours
|
|
343
|
+
* it. Someone who types an address back in has a non-blank field, which the
|
|
344
|
+
* seed skips anyway — and Manage Account drops the marker on that same save,
|
|
345
|
+
* so opting back into IdP prefill is just filling the field in again.
|
|
346
|
+
*/ export async function forgetUserAddress(input) {
|
|
347
|
+
var _input_firestore;
|
|
348
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : firestore();
|
|
349
|
+
const ref = db.collection('users').doc(input.uid);
|
|
350
|
+
const snapshot = await ref.get();
|
|
351
|
+
const onFile = snapshot.exists && !isBlankAddress(snapshot.get('address'));
|
|
352
|
+
await ref.set({
|
|
353
|
+
address: FieldValue.delete(),
|
|
354
|
+
addressErasedAt: FieldValue.serverTimestamp()
|
|
355
|
+
}, {
|
|
356
|
+
merge: true
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
cleared: Boolean(onFile)
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
export default seedUserProfile;
|
|
363
|
+
|
|
364
|
+
//# sourceMappingURL=user-profiles.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/user-profiles.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 personal profile document, `users/{uid}` (AGL-1127).\n *\n * It holds what Manage Account → Basic info edits (`firstName`, `lastName`,\n * `phoneNumber`, `organization`) plus the avatar the console shows for you.\n * It is NOT an identity source: email and display name for OTHER people are\n * read from the org roster (`orgs/{id}/members/{uid}`), which works for SSO\n * accounts that project-level auth cannot see at all (AGL-1122). Nothing here\n * duplicates them, so there is nothing to drift.\n *\n * Until this existed, no account-creation path wrote the doc — it was born\n * the first time someone saved Basic info, so a fresh account rendered the\n * form against a document that did not exist. Measured on production\n * 2026-07-30: 1 of 3 accounts had one, and the account that did had exactly\n * the four Basic-info fields.\n */\n\nimport { splitDisplayName } from '@aglyn/shared-util-tools'\nimport {\n isBlankAddress,\n normalizeAddress,\n normalizePhone,\n type AglynPostalAddress,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n getContactSuppression,\n suppressPhoneContact,\n} from './contact-suppression'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport interface SeedUserProfileInput {\n /** The provider's single name string, when the assertion carries one. */\n displayName?: string | null\n /**\n * Avatar and phone from the same assertion (AGL-1131). Callers pass these\n * through `resolveIdpPhotoUrl` / `resolveIdpPhone`, which is where the\n * https-only check on the photo lives — this function does not re-validate.\n */\n photoUrl?: string | null\n phoneNumber?: string | null\n /**\n * Postal address from the same assertion (AGL-1963). Callers pass this\n * through `resolveIdpAddress`, whose loose `IdpAddressParts` is structurally\n * an `AglynPostalAddress` — it is the seed below, not the caller, that\n * decides whether the parts amount to an address at all. Stored only when\n * the assertion carries a STREET LINE (AGL-1566): a directory that releases\n * a home city and postcode and no street has given us personal data with no\n * use, not an address.\n */\n address?: AglynPostalAddress | null\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n}\n\n/**\n * Create `users/{uid}` if it is missing and prefill the name fields from the\n * identity provider, without ever overwriting what the user has typed.\n *\n * Only ABSENT fields are written, so re-running on every sign-in — which is\n * what the SSO route does — cannot undo an edit: rename yourself in Basic\n * info and the IdP's copy stays out of it.\n *\n * Best-effort by contract. Provisioning access must not fail because a\n * cosmetic prefill did, so the caller is expected to let a rejection through\n * rather than surface it; the profile self-heals on the next sign-in.\n *\n * @returns what the seed actually wrote, for logging and tests.\n */\nexport async function seedUserProfile(\n uid: string,\n input: SeedUserProfileInput = {},\n): Promise<{ created: boolean; fields: string[] }> {\n const ref = (input.firestore ?? firestore()).collection('users').doc(uid)\n const snapshot = await ref.get()\n const { firstName, lastName } = splitDisplayName(input.displayName)\n\n const blank = (value: unknown) =>\n typeof value !== 'string' || !value.trim()\n\n const seed: Record<string, unknown> = {}\n if (firstName && blank(snapshot.get('firstName'))) seed['firstName'] = firstName\n if (lastName && blank(snapshot.get('lastName'))) seed['lastName'] = lastName\n // Same absent-only rule: a directory photo fills an empty avatar, and never\n // replaces one somebody uploaded. `photoUrl` here, not `photoURL` — this\n // doc's spelling differs from the roster's, and writing the roster's would\n // add a field Manage Account does not read.\n //\n // THE REMOVAL MARKER (AGL-2486). Absent-only is what protects an avatar\n // somebody SET; it does nothing for one they REMOVED, because removing it\n // is precisely what makes the field absent again. So this seed runs on the\n // next sign-in, finds a blank field and a directory thumbnail, and puts\n // back the picture the person just deleted — from the customer's own IdP,\n // on every sign-in, forever.\n //\n // `propagateMemberPhoto` already refuses this on the roster row, and says\n // why: it clears with `FieldValue.delete()` rather than `''` so the\n // backfill's identical `blank()` cannot read the clear as \"never set\". The\n // roster half got that treatment and this half did not, and no deletion\n // sentinel can fix it here — an absent field is exactly the state a\n // removal produces, so the two are indistinguishable without a marker.\n //\n // Hence `photoUrlErasedAt`, the same shape as `phoneNumberErasedAt` and\n // `addressErasedAt` above and below, and owner-writable for the same\n // reason: the only party who can clear it is the person it protects, and a\n // person clearing their own marker is a person asking for IdP prefill\n // again. Manage Account drops it on any save that stores a photo, so\n // opting back in is just setting an avatar.\n //\n // Not a privacy control, unlike those two — an avatar is a picture the\n // directory published, not a contact channel. It is a \"stop overruling me\"\n // control, which is the whole of what \"set AND CHANGE your own avatar\"\n // requires when a directory is asserting one on every sign-in.\n if (input.photoUrl?.trim() && blank(snapshot.get('photoUrl'))) {\n if (!snapshot.get('photoUrlErasedAt')) seed['photoUrl'] = input.photoUrl.trim()\n }\n // The phone is the one seeded field with a legal dimension, so it carries\n // two guards the others do not (AGL-1592).\n //\n // THE DEFECT THIS EXISTS TO PREVENT. Privacy Policy v4 §11 says a user may\n // ask us to delete the phone number we hold. Clearing the field is not\n // enough: `/api/auth/sso-jit` and `/api/auth/session` both run this seed on\n // EVERY sign-in, and \"absent field\" is exactly the condition that makes it\n // write. A naive deletion therefore undoes itself the next time the person\n // signs in, from the customer's IdP, with no trace — the honoured request\n // and the resurrected number look identical in the data.\n //\n // Two keys, because neither one covers the whole defect:\n //\n // - `phoneNumberErasedAt` on this document catches the same account. It is\n // free (the snapshot is already in hand) and it is what runs on the hot\n // path, so the common case adds no read at all.\n // - the number-keyed suppression record catches the SAME PERSON ARRIVING AS\n // A DIFFERENT ACCOUNT — reprovisioned in the IdP, or signing up\n // self-serve alongside their SSO seat. Their new `users/{uid}` has no\n // marker on it, so the per-account key alone would re-store a number\n // somebody asked us to stop holding.\n //\n // The suppression lookup is reached only when we are actually about to write\n // a phone, which is the first sign-in of an account and no other time; an\n // erased account short-circuits on the marker before spending the read.\n //\n // Note which suppressions block this: ONLY `erasePhoneOnFile`. \"Stop calling\n // me\" is not \"stop holding my number\" — see contact-suppression.ts — and\n // treating a do-not-call as a deletion request would answer a question the\n // person did not ask.\n if (input.phoneNumber?.trim() && blank(snapshot.get('phoneNumber'))) {\n let refused = Boolean(snapshot.get('phoneNumberErasedAt'))\n if (!refused) {\n try {\n const suppression = await getContactSuppression(\n input.phoneNumber,\n input.firestore ?? undefined,\n )\n refused = Boolean(suppression?.erasePhoneOnFile) && !suppression?.revokedAt\n } catch (error) {\n // Fail CLOSED, in step with `isPhoneContactSuppressed`. A lookup that\n // throws leaves us unable to say whether this number was erased on\n // request, and re-storing it in that state is the failure this guard\n // exists for. A cosmetic prefill is worth nothing next to it.\n console.error(\n '[user-profiles] suppression lookup failed; not seeding phone',\n error,\n )\n refused = true\n }\n }\n if (!refused) seed['phoneNumber'] = input.phoneNumber.trim()\n }\n\n // The address (AGL-1963), the last of AGL-1133's six and the only one that\n // did not ship — it was written as blocked on AGL-1131 mapping attributes,\n // which completed 2026-08-01, and nothing picked it back up.\n //\n // NORMALIZE BEFORE DECIDING. `normalizeAddress` returns null for an address\n // with nothing usable in it, which is what stops a sparse assertion — a\n // country the IdP spells \"United States\", and no other field — from storing\n // an object that every `if (address)` in the codebase reads as an address.\n // Checking the raw parts first and normalizing after would write exactly\n // that object.\n //\n // Absent-only, like the rest, but `isBlankAddress` rather than `blank()`:\n // the stored value is a MAP, so the string check the other fields use would\n // call every populated address blank and overwrite it on every sign-in.\n //\n // THE ERASURE GUARD, following the phone's precedent (Privacy Policy v4 §11,\n // AGL-1592). An address is squarely personal data, and `/api/auth/sso-jit`\n // and `/api/auth/session` both run this seed on EVERY sign-in — so \"absent\n // field\" is the condition that makes it write, and a deletion would undo\n // itself at the next SSO sign-in from the customer's own IdP, leaving the\n // honoured request and the resurrected address identical in the data.\n //\n // ONE key, where the phone needs two, and the asymmetry is the point rather\n // than an omission. `phoneNumberErasedAt` is paired with a NUMBER-KEYED\n // suppression record because a phone number is a routing address: the same\n // person arriving as a different account must not have it re-stored, and\n // the number is the only thing that can recognise them. A postal address is\n // not a channel we contact anyone on and there is no do-not-mail list for it\n // to join, so the per-account marker is the whole of the mechanism here.\n // Adding a second, address-keyed store would retain MORE personal data in\n // the name of erasing it, which is the opposite of what §11 asks for.\n //\n // A STREET LINE IS REQUIRED, and this is the one field where the IdP's copy\n // is held to a stricter rule than a typed one (AGL-1566).\n //\n // AGL-1963 asked that a partial assertion \"not produce a half-address that\n // looks populated\" and named `normalizeAddress` as the mechanism. It is not\n // sufficient: `normalizeAddress` returns null only when NOTHING survives, so\n // a locality on its own survives, and this seed stored it. That was pinned\n // as a judgement call, on the reasoning that a typed city-only address is\n // stored happily too and a second rule here would give an SSO user and a\n // typing user different profiles from the same input.\n //\n // AGL-1566 is the fact that reverses it. The `Aglyn Console SSO` app maps\n // `city`, `region` and `postalCode` out of the Directory's **Home** address\n // and maps NO street row, so a street-less fragment is not an edge case for\n // this IdP — it is the only address it can ever assert, and the value is\n // residential. The symmetry argument does not survive it either: the typing\n // user CHOSE to enter a city, and the SSO user chose nothing at all. So the\n // two inputs were never the same input.\n //\n // What that leaves is personal data we would receive, store, and have no use\n // for — nothing outside the owner's own Manage Account form reads\n // `users/{uid}.address`, and no tax, billing or shipping path touches it.\n // Under data minimisation the honest answer to \"why do we hold this\n // person's home postcode\" has to be better than \"the directory offered it\".\n // For a complete address it is: the person gets a filled-in form instead of\n // an empty one. For a city and a postcode it is nothing, because that is not\n // an address anybody could use.\n //\n // Deliberately NOT pushed into `normalizeAddress` or into `resolveIdpAddress`.\n // The resolver's job is to report what the assertion said, and lying about\n // that would hide the exposure rather than remove it; `normalizeAddress` is\n // shared with Manage Account, where a person storing only a city is their own\n // business. The rule belongs exactly here, at the point where WE decide to\n // persist something nobody asked us to hold.\n const address = normalizeAddress(input.address)\n if (address?.line1 && isBlankAddress(snapshot.get('address'))) {\n if (!snapshot.get('addressErasedAt')) seed['address'] = address\n }\n\n // An existing doc with nothing missing needs no write at all — the common\n // case on every sign-in after the first.\n if (snapshot.exists && !Object.keys(seed).length) {\n return { created: false, fields: [] }\n }\n // A doc with no name to seed is still worth creating: it is where the\n // avatar and the notification mutes land, and its absence is what made\n // those read as \"this account has none\" rather than \"not set yet\".\n if (!snapshot.exists) seed['createdAt'] = FieldValue.serverTimestamp()\n\n await ref.set(seed, { merge: true })\n return {\n created: !snapshot.exists,\n fields: Object.keys(seed).filter((key) => key !== 'createdAt'),\n }\n}\n\n/**\n * \"Delete the phone number you hold for me\" — the second half of Privacy\n * Policy v4 §11 (AGL-1592). Deliberately adjacent to `seedUserProfile`,\n * because the two are halves of one invariant: this sets the marker that one\n * reads, and changing either without the other silently restores the defect.\n *\n * WHAT IT DOES, AND WHY IT IS NOT JUST A DELETE\n *\n * 1. Removes `users/{uid}.phoneNumber` outright (`FieldValue.delete()`, not\n * `null` — the request was to stop holding it, and a nulled field still\n * reads as \"we have a phone slot for this person\").\n * 2. Stamps `phoneNumberErasedAt`, which is what stops the IdP re-asserting\n * it on the next sign-in.\n * 3. Writes a suppression record for the number, with `erasePhoneOnFile`.\n *\n * Step 3 is the one that looks contradictory and is not. Deleting every copy\n * of a number does NOT protect the person from being called — it destroys the\n * only artefact that could recognise them, so the same number arriving later\n * from a customer's CRM, a support ticket, or their own re-typed profile is a\n * number we have never heard of and will happily dial. The minimal retained\n * record is what makes the deletion request mean what the person meant by it.\n * The full reasoning, and the statutory carve-outs it rests on, are in\n * contact-suppression.ts.\n *\n * §11 does not currently say this. It should — see the wording proposed on\n * AGL-1592. Do not resolve the mismatch by making the code forget the number.\n *\n * WHY THE MARKER LIVES ON A CLIENT-WRITABLE DOCUMENT. `users/{uid}` is\n * writable by its owner (and by staff) under the Firestore rules, so this\n * marker is not tamper-proof. It does not need to be: the only party who can\n * clear it is the person it protects, and a person clearing their own erasure\n * marker is a person changing their mind, not an attacker. The adversary here\n * is our own seeding code path, which is server-side and honours it. Moving\n * the marker to a server-only document would buy nothing and cost a second\n * read on every sign-in.\n *\n * A stale marker is harmless: it only ever suppresses an IdP prefill, and a\n * user who types a number back in has a non-blank field, which the seed skips\n * anyway.\n *\n * @param phoneNumber the number to suppress. Defaults to whatever the profile\n * currently holds — pass it explicitly when the request named a number\n * we do not have on file.\n * @returns `suppressed` is the E.164 that went onto the do-not-contact list,\n * or null when there was no recognizable number to record.\n */\nexport async function forgetUserPhoneNumber(input: {\n uid: string\n phoneNumber?: string | null\n /** Staff member who took the request, for the suppression record. */\n recordedByUid?: string | null\n note?: string | null\n firestore?: any\n}): Promise<{ cleared: boolean; suppressed: string | null }> {\n const db = input.firestore ?? firestore()\n const ref = db.collection('users').doc(input.uid)\n const snapshot = await ref.get()\n const onFile = snapshot.exists ? snapshot.get('phoneNumber') : null\n const target = normalizePhone(input.phoneNumber ?? onFile)\n\n // The suppression is written FIRST. If it fails, the number is still on the\n // profile and the request is visibly unfulfilled — which is recoverable by\n // retrying. The other order deletes our only copy of the number and then\n // fails to record it, leaving nothing to retry with.\n let suppressed: string | null = null\n if (target) {\n const result = await suppressPhoneContact({\n phoneNumber: target,\n source: 'erasure-request',\n uid: input.uid,\n recordedByUid: input.recordedByUid ?? null,\n note: input.note ?? null,\n erasePhoneOnFile: true,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n suppressed = result.phoneNumber\n }\n\n // The marker is stamped even when no number was recognizable, so a later\n // IdP assertion for this account is refused too. Someone who asks us to\n // stop holding their number has asked about the account, not only about the\n // digits we happened to have at the time.\n await ref.set(\n {\n phoneNumber: FieldValue.delete(),\n phoneNumberErasedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return { cleared: Boolean(onFile), suppressed }\n}\n\n/**\n * \"Delete the address you hold for me\" (AGL-1963) — the address half of the\n * same §11 invariant `forgetUserPhoneNumber` implements for the phone, and\n * deliberately adjacent to both for the same reason: this sets the marker\n * that `seedUserProfile` reads, and changing either without the other\n * silently restores the defect.\n *\n * A guard whose marker nothing ever writes is decorative, so this exists\n * before anything needs it. Two callers do:\n *\n * - Manage Account, when someone clears their own address. That is the\n * common case by a wide margin — nobody files a support ticket to remove\n * a street address, they empty the field — and without a marker their\n * next SSO sign-in puts it straight back.\n * - Staff, handling an erasure request against an account the person can no\n * longer sign in to.\n *\n * `FieldValue.delete()`, not `null`, on the same reasoning as the phone: the\n * request was to stop holding it, and a nulled field still reads as \"we have\n * an address slot for this person\".\n *\n * REVERSIBLE BY THE PERSON IT PROTECTS, and only by them. `users/{uid}` is\n * writable by its owner (and by staff) under the Firestore rules, so this\n * marker is not tamper-proof and does not need to be — the adversary it\n * guards against is our own seeding path, which is server-side and honours\n * it. Someone who types an address back in has a non-blank field, which the\n * seed skips anyway — and Manage Account drops the marker on that same save,\n * so opting back into IdP prefill is just filling the field in again.\n */\nexport async function forgetUserAddress(input: {\n uid: string\n firestore?: any\n}): Promise<{ cleared: boolean }> {\n const db = input.firestore ?? firestore()\n const ref = db.collection('users').doc(input.uid)\n const snapshot = await ref.get()\n const onFile = snapshot.exists && !isBlankAddress(snapshot.get('address'))\n await ref.set(\n {\n address: FieldValue.delete(),\n addressErasedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return { cleared: Boolean(onFile) }\n}\n\nexport default seedUserProfile\n"],"names":["splitDisplayName","isBlankAddress","normalizeAddress","normalizePhone","FieldValue","getContactSuppression","suppressPhoneContact","firebaseAdmin","firestore","app","seedUserProfile","uid","input","ref","collection","doc","snapshot","get","firstName","lastName","displayName","blank","value","trim","seed","photoUrl","phoneNumber","refused","Boolean","suppression","undefined","erasePhoneOnFile","revokedAt","error","console","address","line1","exists","Object","keys","length","created","fields","serverTimestamp","set","merge","filter","key","forgetUserPhoneNumber","db","onFile","target","suppressed","result","source","recordedByUid","note","delete","phoneNumberErasedAt","cleared","forgetUserAddress","addressErasedAt"],"mappings":"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"}
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
* Is `signature` the drain signature for `rawBody` under `secret`?
|
|
19
|
+
*
|
|
20
|
+
* `secret` is passed in rather than read from the environment here so the
|
|
21
|
+
* caller owns the "unset means reject" decision visibly, and so the test can
|
|
22
|
+
* prove rejection without mutating `process.env`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isValidDrainSignature(rawBody: string, signature: string | null | undefined, secret: string | undefined): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The signature Vercel would send for this body — test and tooling helper.
|
|
27
|
+
* Exported so the spec signs its fixtures the way the real sender does
|
|
28
|
+
* instead of restating the algorithm, which would let both drift together.
|
|
29
|
+
*/
|
|
30
|
+
export declare function drainSignatureFor(rawBody: string, secret: string): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
* `x-vercel-signature` verification for the log-drain receiver (AGL-1921).
|
|
18
|
+
*
|
|
19
|
+
* Its own module, with no dependency beyond `node:crypto`, so the security
|
|
20
|
+
* boundary can be tested without a Firebase app, a credential or a fetch —
|
|
21
|
+
* and so nothing in the ingest path can be imported without it.
|
|
22
|
+
*
|
|
23
|
+
* Vercel documents the header as `hmac-sha1(rawBody, drainSecret)` in hex
|
|
24
|
+
* (docs/drains/security). Two consequences that are easy to get wrong:
|
|
25
|
+
*
|
|
26
|
+
* 1. **RAW body.** `await request.text()` BEFORE any parse. Parsing and
|
|
27
|
+
* re-serializing changes key order and whitespace and will not reproduce
|
|
28
|
+
* the digest, and the failure looks like a wrong secret rather than a bug.
|
|
29
|
+
* 2. **Timing-safe compare.** A plain `===` on a hex digest leaks the digest
|
|
30
|
+
* a byte at a time to an attacker who can post repeatedly, which for an
|
|
31
|
+
* endpoint whose URL is discoverable is the entire attack.
|
|
32
|
+
*
|
|
33
|
+
* FAILS CLOSED at every branch: no secret configured, no header, wrong
|
|
34
|
+
* length, wrong value — all false. There is no configuration of this function
|
|
35
|
+
* under which an unsigned body is accepted.
|
|
36
|
+
*/ import { createHmac, timingSafeEqual } from "node:crypto";
|
|
37
|
+
/**
|
|
38
|
+
* Is `signature` the drain signature for `rawBody` under `secret`?
|
|
39
|
+
*
|
|
40
|
+
* `secret` is passed in rather than read from the environment here so the
|
|
41
|
+
* caller owns the "unset means reject" decision visibly, and so the test can
|
|
42
|
+
* prove rejection without mutating `process.env`.
|
|
43
|
+
*/ export function isValidDrainSignature(rawBody, signature, secret) {
|
|
44
|
+
if (!secret) return false;
|
|
45
|
+
if (!signature) return false;
|
|
46
|
+
const expected = createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex');
|
|
47
|
+
const provided = signature.trim().toLowerCase();
|
|
48
|
+
// `timingSafeEqual` THROWS on a length mismatch, which would be a 500 and a
|
|
49
|
+
// length oracle at once; compare lengths first and reject.
|
|
50
|
+
if (provided.length !== expected.length) return false;
|
|
51
|
+
return timingSafeEqual(Buffer.from(provided, 'utf8'), Buffer.from(expected, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The signature Vercel would send for this body — test and tooling helper.
|
|
55
|
+
* Exported so the spec signs its fixtures the way the real sender does
|
|
56
|
+
* instead of restating the algorithm, which would let both drift together.
|
|
57
|
+
*/ export function drainSignatureFor(rawBody, secret) {
|
|
58
|
+
return createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
//# sourceMappingURL=vercel-drain-signature.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/vercel-drain-signature.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 * `x-vercel-signature` verification for the log-drain receiver (AGL-1921).\n *\n * Its own module, with no dependency beyond `node:crypto`, so the security\n * boundary can be tested without a Firebase app, a credential or a fetch —\n * and so nothing in the ingest path can be imported without it.\n *\n * Vercel documents the header as `hmac-sha1(rawBody, drainSecret)` in hex\n * (docs/drains/security). Two consequences that are easy to get wrong:\n *\n * 1. **RAW body.** `await request.text()` BEFORE any parse. Parsing and\n * re-serializing changes key order and whitespace and will not reproduce\n * the digest, and the failure looks like a wrong secret rather than a bug.\n * 2. **Timing-safe compare.** A plain `===` on a hex digest leaks the digest\n * a byte at a time to an attacker who can post repeatedly, which for an\n * endpoint whose URL is discoverable is the entire attack.\n *\n * FAILS CLOSED at every branch: no secret configured, no header, wrong\n * length, wrong value — all false. There is no configuration of this function\n * under which an unsigned body is accepted.\n */\nimport { createHmac, timingSafeEqual } from 'node:crypto'\n\n/**\n * Is `signature` the drain signature for `rawBody` under `secret`?\n *\n * `secret` is passed in rather than read from the environment here so the\n * caller owns the \"unset means reject\" decision visibly, and so the test can\n * prove rejection without mutating `process.env`.\n */\nexport function isValidDrainSignature(\n rawBody: string,\n signature: string | null | undefined,\n secret: string | undefined,\n): boolean {\n if (!secret) return false\n if (!signature) return false\n const expected = createHmac('sha1', secret)\n .update(rawBody, 'utf8')\n .digest('hex')\n const provided = signature.trim().toLowerCase()\n // `timingSafeEqual` THROWS on a length mismatch, which would be a 500 and a\n // length oracle at once; compare lengths first and reject.\n if (provided.length !== expected.length) return false\n return timingSafeEqual(\n Buffer.from(provided, 'utf8'),\n Buffer.from(expected, 'utf8'),\n )\n}\n\n/**\n * The signature Vercel would send for this body — test and tooling helper.\n * Exported so the spec signs its fixtures the way the real sender does\n * instead of restating the algorithm, which would let both drift together.\n */\nexport function drainSignatureFor(rawBody: string, secret: string): string {\n return createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex')\n}\n"],"names":["createHmac","timingSafeEqual","isValidDrainSignature","rawBody","signature","secret","expected","update","digest","provided","trim","toLowerCase","length","Buffer","from","drainSignatureFor"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,SAASA,UAAU,EAAEC,eAAe,QAAQ,cAAa;AAEzD;;;;;;CAMC,GACD,OAAO,SAASC,sBACdC,OAAe,EACfC,SAAoC,EACpCC,MAA0B;IAE1B,IAAI,CAACA,QAAQ,OAAO;IACpB,IAAI,CAACD,WAAW,OAAO;IACvB,MAAME,WAAWN,WAAW,QAAQK,QACjCE,MAAM,CAACJ,SAAS,QAChBK,MAAM,CAAC;IACV,MAAMC,WAAWL,UAAUM,IAAI,GAAGC,WAAW;IAC7C,4EAA4E;IAC5E,2DAA2D;IAC3D,IAAIF,SAASG,MAAM,KAAKN,SAASM,MAAM,EAAE,OAAO;IAChD,OAAOX,gBACLY,OAAOC,IAAI,CAACL,UAAU,SACtBI,OAAOC,IAAI,CAACR,UAAU;AAE1B;AAEA;;;;CAIC,GACD,OAAO,SAASS,kBAAkBZ,OAAe,EAAEE,MAAc;IAC/D,OAAOL,WAAW,QAAQK,QAAQE,MAAM,CAACJ,SAAS,QAAQK,MAAM,CAAC;AACnE"}
|