@aglyn/tenant-data-admin 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/jest.integration.config.d.ts +39 -0
- package/jest.integration.setup.d.ts +17 -0
- package/package.json +51 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/render-cache.d.ts +184 -0
- package/src/lib/render-cache.js +198 -0
- package/src/lib/render-cache.js.map +1 -0
- package/src/lib/server/account-addresses.d.ts +160 -0
- package/src/lib/server/account-addresses.js +311 -0
- package/src/lib/server/account-addresses.js.map +1 -0
- package/src/lib/server/account-emails.d.ts +219 -0
- package/src/lib/server/account-emails.js +640 -0
- package/src/lib/server/account-emails.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +180 -0
- package/src/lib/server/admin-audit.js +233 -0
- package/src/lib/server/admin-audit.js.map +1 -0
- package/src/lib/server/analytics-retention.d.ts +63 -0
- package/src/lib/server/analytics-retention.js +66 -0
- package/src/lib/server/analytics-retention.js.map +1 -0
- package/src/lib/server/api-http.d.ts +159 -0
- package/src/lib/server/api-http.js +230 -0
- package/src/lib/server/api-http.js.map +1 -0
- package/src/lib/server/api-keys.d.ts +117 -0
- package/src/lib/server/api-keys.js +298 -0
- package/src/lib/server/api-keys.js.map +1 -0
- package/src/lib/server/auth-handoff.d.ts +165 -0
- package/src/lib/server/auth-handoff.js +482 -0
- package/src/lib/server/auth-handoff.js.map +1 -0
- package/src/lib/server/auth-pools.d.ts +246 -0
- package/src/lib/server/auth-pools.js +551 -0
- package/src/lib/server/auth-pools.js.map +1 -0
- package/src/lib/server/billing-addons.d.ts +171 -0
- package/src/lib/server/billing-addons.js +277 -0
- package/src/lib/server/billing-addons.js.map +1 -0
- package/src/lib/server/campaign-attribution-store.d.ts +55 -0
- package/src/lib/server/campaign-attribution-store.js +95 -0
- package/src/lib/server/campaign-attribution-store.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +205 -0
- package/src/lib/server/campaign-conversion-attribution.js +279 -0
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
- package/src/lib/server/client-error-report.d.ts +301 -0
- package/src/lib/server/client-error-report.js +755 -0
- package/src/lib/server/client-error-report.js.map +1 -0
- package/src/lib/server/connect-account-status.d.ts +74 -0
- package/src/lib/server/connect-account-status.js +94 -0
- package/src/lib/server/connect-account-status.js.map +1 -0
- package/src/lib/server/connect-payout-failure.d.ts +103 -0
- package/src/lib/server/connect-payout-failure.js +127 -0
- package/src/lib/server/connect-payout-failure.js.map +1 -0
- package/src/lib/server/console-api-rate-limit.d.ts +81 -0
- package/src/lib/server/console-api-rate-limit.js +111 -0
- package/src/lib/server/console-api-rate-limit.js.map +1 -0
- package/src/lib/server/console-domains.d.ts +326 -0
- package/src/lib/server/console-domains.js +779 -0
- package/src/lib/server/console-domains.js.map +1 -0
- package/src/lib/server/consume-once.d.ts +91 -0
- package/src/lib/server/consume-once.js +96 -0
- package/src/lib/server/consume-once.js.map +1 -0
- package/src/lib/server/contact-company-link.d.ts +85 -0
- package/src/lib/server/contact-company-link.js +105 -0
- package/src/lib/server/contact-company-link.js.map +1 -0
- package/src/lib/server/contact-email-engagement.d.ts +32 -0
- package/src/lib/server/contact-email-engagement.js +145 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-email-index.d.ts +56 -0
- package/src/lib/server/contact-email-index.js +158 -0
- package/src/lib/server/contact-email-index.js.map +1 -0
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +88 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-merge.d.ts +55 -0
- package/src/lib/server/contact-merge.js +279 -0
- package/src/lib/server/contact-merge.js.map +1 -0
- package/src/lib/server/contact-suppression.d.ts +153 -0
- package/src/lib/server/contact-suppression.js +259 -0
- package/src/lib/server/contact-suppression.js.map +1 -0
- package/src/lib/server/crm-booking-activity.d.ts +86 -0
- package/src/lib/server/crm-booking-activity.js +251 -0
- package/src/lib/server/crm-booking-activity.js.map +1 -0
- package/src/lib/server/crm-email-activity.d.ts +112 -0
- package/src/lib/server/crm-email-activity.js +143 -0
- package/src/lib/server/crm-email-activity.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +145 -0
- package/src/lib/server/crm-inbound-email.js +287 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-next-activity.d.ts +74 -0
- package/src/lib/server/crm-next-activity.js +194 -0
- package/src/lib/server/crm-next-activity.js.map +1 -0
- package/src/lib/server/crm-records.d.ts +144 -0
- package/src/lib/server/crm-records.js +218 -0
- package/src/lib/server/crm-records.js.map +1 -0
- package/src/lib/server/csp-aggregate.d.ts +62 -0
- package/src/lib/server/csp-aggregate.js +276 -0
- package/src/lib/server/csp-aggregate.js.map +1 -0
- package/src/lib/server/data-storage-gate.d.ts +66 -0
- package/src/lib/server/data-storage-gate.js +48 -0
- package/src/lib/server/data-storage-gate.js.map +1 -0
- package/src/lib/server/dataset-live-pages.d.ts +179 -0
- package/src/lib/server/dataset-live-pages.js +421 -0
- package/src/lib/server/dataset-live-pages.js.map +1 -0
- package/src/lib/server/dns-probe.d.ts +53 -0
- package/src/lib/server/dns-probe.js +184 -0
- package/src/lib/server/dns-probe.js.map +1 -0
- package/src/lib/server/document-id.d.ts +29 -0
- package/src/lib/server/document-id.js +100 -0
- package/src/lib/server/document-id.js.map +1 -0
- package/src/lib/server/domain-provider-vercel.d.ts +33 -0
- package/src/lib/server/domain-provider-vercel.js +298 -0
- package/src/lib/server/domain-provider-vercel.js.map +1 -0
- package/src/lib/server/domain-provider-webhook.d.ts +73 -0
- package/src/lib/server/domain-provider-webhook.js +223 -0
- package/src/lib/server/domain-provider-webhook.js.map +1 -0
- package/src/lib/server/domain-provider-wildcard.d.ts +54 -0
- package/src/lib/server/domain-provider-wildcard.js +163 -0
- package/src/lib/server/domain-provider-wildcard.js.map +1 -0
- package/src/lib/server/domain-provider.d.ts +197 -0
- package/src/lib/server/domain-provider.js +221 -0
- package/src/lib/server/domain-provider.js.map +1 -0
- package/src/lib/server/duplicate-activity.d.ts +54 -0
- package/src/lib/server/duplicate-activity.js +67 -0
- package/src/lib/server/duplicate-activity.js.map +1 -0
- package/src/lib/server/duplicate-resource.d.ts +55 -0
- package/src/lib/server/duplicate-resource.js +474 -0
- package/src/lib/server/duplicate-resource.js.map +1 -0
- package/src/lib/server/dynamic-list-materialize.d.ts +153 -0
- package/src/lib/server/dynamic-list-materialize.js +581 -0
- package/src/lib/server/dynamic-list-materialize.js.map +1 -0
- package/src/lib/server/edit-access-authz.d.ts +56 -0
- package/src/lib/server/edit-access-authz.js +107 -0
- package/src/lib/server/edit-access-authz.js.map +1 -0
- package/src/lib/server/edit-access-token.d.ts +72 -0
- package/src/lib/server/edit-access-token.js +116 -0
- package/src/lib/server/edit-access-token.js.map +1 -0
- package/src/lib/server/edit-hint-token.d.ts +83 -0
- package/src/lib/server/edit-hint-token.js +119 -0
- package/src/lib/server/edit-hint-token.js.map +1 -0
- package/src/lib/server/email-delivery-log.d.ts +509 -0
- package/src/lib/server/email-delivery-log.js +1043 -0
- package/src/lib/server/email-delivery-log.js.map +1 -0
- package/src/lib/server/email-flow-gate.d.ts +80 -0
- package/src/lib/server/email-flow-gate.js +153 -0
- package/src/lib/server/email-flow-gate.js.map +1 -0
- package/src/lib/server/email-marketing-gate.d.ts +333 -0
- package/src/lib/server/email-marketing-gate.js +639 -0
- package/src/lib/server/email-marketing-gate.js.map +1 -0
- package/src/lib/server/email-metering.d.ts +293 -0
- package/src/lib/server/email-metering.js +359 -0
- package/src/lib/server/email-metering.js.map +1 -0
- package/src/lib/server/email-revenue-attribution.d.ts +185 -0
- package/src/lib/server/email-revenue-attribution.js +300 -0
- package/src/lib/server/email-revenue-attribution.js.map +1 -0
- package/src/lib/server/email-send-rate.d.ts +232 -0
- package/src/lib/server/email-send-rate.js +417 -0
- package/src/lib/server/email-send-rate.js.map +1 -0
- package/src/lib/server/email-sender-reputation.d.ts +317 -0
- package/src/lib/server/email-sender-reputation.js +464 -0
- package/src/lib/server/email-sender-reputation.js.map +1 -0
- package/src/lib/server/email-suppression.d.ts +490 -0
- package/src/lib/server/email-suppression.js +648 -0
- package/src/lib/server/email-suppression.js.map +1 -0
- package/src/lib/server/email-topic-confirmation.d.ts +150 -0
- package/src/lib/server/email-topic-confirmation.js +272 -0
- package/src/lib/server/email-topic-confirmation.js.map +1 -0
- package/src/lib/server/email-unsubscribe-link.d.ts +174 -0
- package/src/lib/server/email-unsubscribe-link.js +236 -0
- package/src/lib/server/email-unsubscribe-link.js.map +1 -0
- package/src/lib/server/erase-person.d.ts +116 -0
- package/src/lib/server/erase-person.js +272 -0
- package/src/lib/server/erase-person.js.map +1 -0
- package/src/lib/server/erase.d.ts +458 -0
- package/src/lib/server/erase.js +1506 -0
- package/src/lib/server/erase.js.map +1 -0
- package/src/lib/server/firebase-admin.d.ts +71 -0
- package/src/lib/server/firebase-admin.js +343 -0
- package/src/lib/server/firebase-admin.js.map +1 -0
- package/src/lib/server/form-dataset-binding-token.d.ts +35 -0
- package/src/lib/server/form-dataset-binding-token.js +107 -0
- package/src/lib/server/form-dataset-binding-token.js.map +1 -0
- package/src/lib/server/free-workspace-cap.d.ts +201 -0
- package/src/lib/server/free-workspace-cap.js +404 -0
- package/src/lib/server/free-workspace-cap.js.map +1 -0
- package/src/lib/server/ga4-measurement-protocol.d.ts +256 -0
- package/src/lib/server/ga4-measurement-protocol.js +558 -0
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +47 -0
- package/src/lib/server/host-email-tokens.js +72 -0
- package/src/lib/server/host-email-tokens.js.map +1 -0
- package/src/lib/server/host-memberships.d.ts +54 -0
- package/src/lib/server/host-memberships.js +194 -0
- package/src/lib/server/host-memberships.js.map +1 -0
- package/src/lib/server/host-sending-domain.d.ts +292 -0
- package/src/lib/server/host-sending-domain.js +451 -0
- package/src/lib/server/host-sending-domain.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +150 -0
- package/src/lib/server/host-visitor-records.js +262 -0
- package/src/lib/server/host-visitor-records.js.map +1 -0
- package/src/lib/server/id-token-refusal.d.ts +28 -0
- package/src/lib/server/id-token-refusal.js +111 -0
- package/src/lib/server/id-token-refusal.js.map +1 -0
- package/src/lib/server/legal-acceptance.d.ts +214 -0
- package/src/lib/server/legal-acceptance.js +305 -0
- package/src/lib/server/legal-acceptance.js.map +1 -0
- package/src/lib/server/list-members.d.ts +211 -0
- package/src/lib/server/list-members.js +176 -0
- package/src/lib/server/list-members.js.map +1 -0
- package/src/lib/server/live-page-usage.d.ts +202 -0
- package/src/lib/server/live-page-usage.js +300 -0
- package/src/lib/server/live-page-usage.js.map +1 -0
- package/src/lib/server/lockdown.d.ts +313 -0
- package/src/lib/server/lockdown.js +643 -0
- package/src/lib/server/lockdown.js.map +1 -0
- package/src/lib/server/media-cdn-rate-limit.d.ts +188 -0
- package/src/lib/server/media-cdn-rate-limit.js +204 -0
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +267 -0
- package/src/lib/server/media-delivery.js +485 -0
- package/src/lib/server/media-delivery.js.map +1 -0
- package/src/lib/server/media-download-tokens.d.ts +161 -0
- package/src/lib/server/media-download-tokens.js +386 -0
- package/src/lib/server/media-download-tokens.js.map +1 -0
- package/src/lib/server/media-quarantine.d.ts +127 -0
- package/src/lib/server/media-quarantine.js +170 -0
- package/src/lib/server/media-quarantine.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +126 -0
- package/src/lib/server/media-signing.js +159 -0
- package/src/lib/server/media-signing.js.map +1 -0
- package/src/lib/server/media-storage-path.d.ts +96 -0
- package/src/lib/server/media-storage-path.js +111 -0
- package/src/lib/server/media-storage-path.js.map +1 -0
- package/src/lib/server/media-strong-digest.d.ts +85 -0
- package/src/lib/server/media-strong-digest.js +190 -0
- package/src/lib/server/media-strong-digest.js.map +1 -0
- package/src/lib/server/media-tombstone.d.ts +276 -0
- package/src/lib/server/media-tombstone.js +421 -0
- package/src/lib/server/media-tombstone.js.map +1 -0
- package/src/lib/server/media-variants.d.ts +309 -0
- package/src/lib/server/media-variants.js +428 -0
- package/src/lib/server/media-variants.js.map +1 -0
- package/src/lib/server/member-email-aliases.d.ts +180 -0
- package/src/lib/server/member-email-aliases.js +420 -0
- package/src/lib/server/member-email-aliases.js.map +1 -0
- package/src/lib/server/member-photo.d.ts +88 -0
- package/src/lib/server/member-photo.js +185 -0
- package/src/lib/server/member-photo.js.map +1 -0
- package/src/lib/server/membership-recover-throttle.d.ts +211 -0
- package/src/lib/server/membership-recover-throttle.js +231 -0
- package/src/lib/server/membership-recover-throttle.js.map +1 -0
- package/src/lib/server/notifications.d.ts +55 -0
- package/src/lib/server/notifications.js +134 -0
- package/src/lib/server/notifications.js.map +1 -0
- package/src/lib/server/org-billing.d.ts +97 -0
- package/src/lib/server/org-billing.js +236 -0
- package/src/lib/server/org-billing.js.map +1 -0
- package/src/lib/server/organizations.d.ts +837 -0
- package/src/lib/server/organizations.js +1814 -0
- package/src/lib/server/organizations.js.map +1 -0
- package/src/lib/server/password-reset-throttle.d.ts +84 -0
- package/src/lib/server/password-reset-throttle.js +102 -0
- package/src/lib/server/password-reset-throttle.js.map +1 -0
- package/src/lib/server/personal-data-export.d.ts +241 -0
- package/src/lib/server/personal-data-export.js +858 -0
- package/src/lib/server/personal-data-export.js.map +1 -0
- package/src/lib/server/platform-domain-names.d.ts +62 -0
- package/src/lib/server/platform-domain-names.js +197 -0
- package/src/lib/server/platform-domain-names.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +133 -0
- package/src/lib/server/platform-marketing-consent.js +209 -0
- package/src/lib/server/platform-marketing-consent.js.map +1 -0
- package/src/lib/server/rate-limit-store.d.ts +702 -0
- package/src/lib/server/rate-limit-store.js +1220 -0
- package/src/lib/server/rate-limit-store.js.map +1 -0
- package/src/lib/server/realm-plugins.d.ts +67 -0
- package/src/lib/server/realm-plugins.js +250 -0
- package/src/lib/server/realm-plugins.js.map +1 -0
- package/src/lib/server/recaptcha-allowlist.d.ts +90 -0
- package/src/lib/server/recaptcha-allowlist.js +409 -0
- package/src/lib/server/recaptcha-allowlist.js.map +1 -0
- package/src/lib/server/release-flags.d.ts +105 -0
- package/src/lib/server/release-flags.js +183 -0
- package/src/lib/server/release-flags.js.map +1 -0
- package/src/lib/server/replay-erasures.d.ts +126 -0
- package/src/lib/server/replay-erasures.js +217 -0
- package/src/lib/server/replay-erasures.js.map +1 -0
- package/src/lib/server/resolve-people.d.ts +66 -0
- package/src/lib/server/resolve-people.js +112 -0
- package/src/lib/server/resolve-people.js.map +1 -0
- package/src/lib/server/safe-equal.d.ts +45 -0
- package/src/lib/server/safe-equal.js +58 -0
- package/src/lib/server/safe-equal.js.map +1 -0
- package/src/lib/server/sending-domain-debt.d.ts +127 -0
- package/src/lib/server/sending-domain-debt.js +268 -0
- package/src/lib/server/sending-domain-debt.js.map +1 -0
- package/src/lib/server/sending-domain-recheck.d.ts +151 -0
- package/src/lib/server/sending-domain-recheck.js +253 -0
- package/src/lib/server/sending-domain-recheck.js.map +1 -0
- package/src/lib/server/sending-domains.d.ts +364 -0
- package/src/lib/server/sending-domains.js +683 -0
- package/src/lib/server/sending-domains.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +433 -0
- package/src/lib/server/serve-media-cdn.js +1370 -0
- package/src/lib/server/serve-media-cdn.js.map +1 -0
- package/src/lib/server/serve-plugin-fetch.d.ts +61 -0
- package/src/lib/server/serve-plugin-fetch.js +294 -0
- package/src/lib/server/serve-plugin-fetch.js.map +1 -0
- package/src/lib/server/sms-keywords.d.ts +63 -0
- package/src/lib/server/sms-keywords.js +146 -0
- package/src/lib/server/sms-keywords.js.map +1 -0
- package/src/lib/server/sso-break-glass-owners.d.ts +91 -0
- package/src/lib/server/sso-break-glass-owners.js +227 -0
- package/src/lib/server/sso-break-glass-owners.js.map +1 -0
- package/src/lib/server/sso-domain-policy.d.ts +140 -0
- package/src/lib/server/sso-domain-policy.js +239 -0
- package/src/lib/server/sso-domain-policy.js.map +1 -0
- package/src/lib/server/sso-drift-logic.d.ts +106 -0
- package/src/lib/server/sso-drift-logic.js +95 -0
- package/src/lib/server/sso-drift-logic.js.map +1 -0
- package/src/lib/server/sso-enforcement.d.ts +204 -0
- package/src/lib/server/sso-enforcement.js +309 -0
- package/src/lib/server/sso-enforcement.js.map +1 -0
- package/src/lib/server/sso-provisioning.d.ts +320 -0
- package/src/lib/server/sso-provisioning.js +715 -0
- package/src/lib/server/sso-provisioning.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +41 -0
- package/src/lib/server/staff-alert-email.js +63 -0
- package/src/lib/server/staff-alert-email.js.map +1 -0
- package/src/lib/server/stripe-account-mode.d.ts +156 -0
- package/src/lib/server/stripe-account-mode.js +162 -0
- package/src/lib/server/stripe-account-mode.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +131 -0
- package/src/lib/server/tenant-write-lockdown.js +176 -0
- package/src/lib/server/tenant-write-lockdown.js.map +1 -0
- package/src/lib/server/test-firestore.d.ts +45 -0
- package/src/lib/server/test-firestore.js +124 -0
- package/src/lib/server/test-firestore.js.map +1 -0
- package/src/lib/server/token-revocation.d.ts +140 -0
- package/src/lib/server/token-revocation.js +255 -0
- package/src/lib/server/token-revocation.js.map +1 -0
- package/src/lib/server/update-existing.d.ts +32 -0
- package/src/lib/server/update-existing.js +73 -0
- package/src/lib/server/update-existing.js.map +1 -0
- package/src/lib/server/upload-cors-reconcile.d.ts +118 -0
- package/src/lib/server/upload-cors-reconcile.js +234 -0
- package/src/lib/server/upload-cors-reconcile.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +262 -0
- package/src/lib/server/upsert-contact.js +546 -0
- package/src/lib/server/upsert-contact.js.map +1 -0
- package/src/lib/server/usage-invoice.d.ts +203 -0
- package/src/lib/server/usage-invoice.js +397 -0
- package/src/lib/server/usage-invoice.js.map +1 -0
- package/src/lib/server/user-profiles.d.ts +151 -0
- package/src/lib/server/user-profiles.js +364 -0
- package/src/lib/server/user-profiles.js.map +1 -0
- package/src/lib/server/vercel-drain-signature.d.ts +30 -0
- package/src/lib/server/vercel-drain-signature.js +61 -0
- package/src/lib/server/vercel-drain-signature.js.map +1 -0
- package/src/lib/server/vercel-log-drain.d.ts +259 -0
- package/src/lib/server/vercel-log-drain.js +479 -0
- package/src/lib/server/vercel-log-drain.js.map +1 -0
- package/src/lib/server/verify-email-cooldown.d.ts +102 -0
- package/src/lib/server/verify-email-cooldown.js +114 -0
- package/src/lib/server/verify-email-cooldown.js.map +1 -0
- package/src/lib/server/visitor-write-rate-limit.d.ts +87 -0
- package/src/lib/server/visitor-write-rate-limit.js +117 -0
- package/src/lib/server/visitor-write-rate-limit.js.map +1 -0
- package/src/lib/server/workspace-domains.d.ts +156 -0
- package/src/lib/server/workspace-domains.js +271 -0
- package/src/lib/server/workspace-domains.js.map +1 -0
- package/src/tenancy.d.ts +115 -0
- package/src/tenancy.js +142 -0
- package/src/tenancy.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/serve-plugin-fetch.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n isPluginNetworkAllowed,\n PLUGIN_FETCH_MAX_BODY_BYTES,\n} from '@aglyn/aglyn/server'\nimport { lookup } from 'dns/promises'\nimport { isIPv4, isIPv6 } from 'net'\nimport type { NextApiRequest, NextApiResponse } from 'next'\nimport { Agent } from 'undici'\nimport { firebaseAdmin } from './firebase-admin'\n\nconst FETCH_TIMEOUT_MS = 8000\n/**\n * Redirect hops the proxy will follow, each one re-validated (AGL-1881).\n * Small on purpose: a legitimate API redirects once (http->https is refused\n * outright by the allowlist, so in practice this is a path or host alias).\n */\nconst MAX_REDIRECT_HOPS = 3\n/** The statuses `redirect: 'manual'` hands back instead of following. */\nconst REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])\n\n/** True for loopback / private / link-local / CGNAT / ULA addresses. */\nfunction isPrivateIp(ip: string): boolean {\n if (isIPv4(ip)) {\n const [a, b] = ip.split('.').map(Number)\n if (a === 0 || a === 10 || a === 127) return true\n if (a === 169 && b === 254) return true // link-local + cloud metadata\n if (a === 172 && b >= 16 && b <= 31) return true\n if (a === 192 && b === 168) return true\n if (a === 100 && b >= 64 && b <= 127) return true // CGNAT\n // The ranges AGL-1881 found missing. None is routable on the public\n // internet, so refusing them costs a plugin nothing:\n if (a === 192 && b === 0) return true // 192.0.0.0/24 IETF protocol\n if (a === 198 && (b === 18 || b === 19)) return true // 198.18/15 benchmark\n if (a >= 224) return true // 224/4 multicast, 240/4 reserved, broadcast\n return false\n }\n if (isIPv6(ip)) {\n const low = ip.toLowerCase()\n if (low === '::1' || low === '::') return true\n if (low.startsWith('fc') || low.startsWith('fd')) return true // ULA\n // Link-local is fe80::/10 — fe80 through febf, not the fe80 prefix\n // alone (AGL-1881). `fec0::/10` site-local is deprecated but inside the\n // same span, so one test covers both.\n if (/^fe[89ab]/.test(low)) return true // link-local fe80::/10\n if (low.startsWith('::ffff:')) return isPrivateIp(low.slice(7)) // v4-mapped\n return false\n }\n return true // unparseable → refuse\n}\n\n/**\n * SSRF hardening (AGL-515): even an https, allowlisted origin can resolve to\n * an internal address (a plugin author pointing DNS at 127.0.0.1 / the cloud\n * metadata endpoint). Resolve the host and return a single validated PUBLIC\n * address to pin the connection to (null if any resolved address is private).\n * Pinning the exact IP the fetch connects to closes the DNS-rebinding TOCTOU —\n * the name can't re-resolve to an internal target between check and connect.\n */\nexport async function resolvePublicIp(hostname: string): Promise<string | null> {\n try {\n const addresses = await lookup(hostname, { all: true })\n if (!addresses.length) return null\n if (addresses.some((a) => isPrivateIp(a.address))) return null\n return addresses[0].address\n } catch {\n return null\n }\n}\n\n/**\n * Build the dispatcher that pins the socket to `pinnedIp` (AGL-515). A custom\n * `connect.lookup` forces the connection to the address `resolvePublicIp`\n * already validated, so the name can't be rebound to an internal target\n * between the check and the connect. TLS SNI / certificate validation still\n * use the original hostname, because only the address resolution is replaced.\n *\n * Exported so the transport itself is covered by a spec (AGL-2480). `undici`\n * is a **declared direct dependency** for exactly this reason: it used to\n * arrive only as a transitive of the dev-only `jsdom`, which meant a\n * `--omit=dev` install had no `undici` at all and a jsdom bump silently\n * re-versioned the HTTP client behind a production security control. undici 8\n * rejects this `lookup` shape with `UND_ERR_INVALID_ARG` on every request —\n * fail-closed, but the plugin-fetch route stops working outright. The spec\n * drives a real request through this dispatcher so that break is a red test,\n * not a production discovery.\n */\nexport function createPinnedDispatcher(pinnedIp: string): Agent {\n const family = isIPv6(pinnedIp) ? 6 : 4\n return new Agent({\n connect: {\n lookup: (_host, options, cb) =>\n options && options.all\n ? cb(null, [{ address: pinnedIp, family }])\n : cb(null, pinnedIp, family),\n },\n })\n}\n\n/**\n * Host-mediated plugin fetch (AGL-191). The sandboxed plugin can't reach\n * the network directly under a strict plugin-origin CSP, so `PluginFrame`\n * forwards a `fetch-request` here and the host proxies it. Mounted in BOTH\n * apps (console for the editor/preview, tenant for the published site) so\n * plugin fetches work wherever the plugin renders — hence public, no\n * admin auth: the security boundary is the ALLOWLIST, re-derived from the\n * host's server-side install record and never trusting the client's claim.\n * https-only, exact-origin, method/body/time capped, and no host\n * credentials are ever forwarded — a plugin can only reach origins it\n * declared at install (surfaced on the install screen).\n *\n * Abuse note: this uses platform egress to origins the plugin already\n * declared; a per-listing rate limit is a sensible follow-up.\n */\nexport async function servePluginFetch(\n req: NextApiRequest,\n res: NextApiResponse,\n): Promise<void> {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ ok: false, status: 0, error: 'Method not allowed' })\n return\n }\n const body =\n typeof req.body === 'string' ? safeJson(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const listingId = String(body.listingId ?? '')\n const url = String(body.url ?? '')\n const method = body.method === 'POST' ? 'POST' : 'GET'\n const requestBody =\n typeof body.body === 'string' ? (body.body as string) : undefined\n if (!hostId || !listingId || !url) {\n res.status(400).json({ ok: false, status: 0, error: 'Bad request' })\n return\n }\n if (requestBody && Buffer.byteLength(requestBody) > PLUGIN_FETCH_MAX_BODY_BYTES) {\n res.status(413).json({ ok: false, status: 0, error: 'Body too large' })\n return\n }\n\n try {\n const install = (\n await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('installs')\n .doc(listingId)\n .get()\n ).data() as { manifest?: { capabilities?: unknown } } | undefined\n if (!install?.manifest) {\n res.status(404).json({ ok: false, status: 0, error: 'Not installed' })\n return\n }\n if (\n !isPluginNetworkAllowed(\n url,\n (install.manifest as { capabilities?: any }).capabilities,\n )\n ) {\n res\n .status(403)\n .json({ ok: false, status: 0, error: 'URL not in allowlist' })\n return\n }\n // Follow redirects OURSELVES, re-validating every hop (AGL-1881).\n //\n // `fetch` defaults to `redirect: 'follow'`, and every control above runs\n // on the FIRST url only: the allowlist, the https-only check and the\n // pinned dispatcher. A declared, allowlisted, genuinely public origin\n // answering `302 Location: http://169.254.169.254/...` therefore reached\n // the cloud metadata service and returned its body to the caller — on an\n // unauthenticated route. The IP pin did not stop it: Node skips a custom\n // `connect.lookup` when the target is an IP literal, so the redirect\n // connected directly and the pinned lookup was consulted exactly once,\n // for the original hostname.\n //\n // `redirect: 'manual'` hands us each hop instead. Every one re-enters the\n // same two gates the first url passed — the manifest allowlist (which is\n // also what enforces https and the exact origin) and a fresh\n // `resolvePublicIp` with its own pinned dispatcher — so a redirect can\n // only ever reach somewhere the plugin could have named directly.\n let currentUrl = url\n let upstream: Response\n for (let hop = 0; ; hop++) {\n if (hop > MAX_REDIRECT_HOPS) {\n res\n .status(403)\n .json({ ok: false, status: 0, error: 'Too many redirects' })\n return\n }\n const pinnedIp = await resolvePublicIp(new URL(currentUrl).hostname)\n if (!pinnedIp) {\n res.status(403).json({\n ok: false,\n status: 0,\n error: 'URL resolves to a non-public address',\n })\n return\n }\n\n // Pin the socket to the address we just validated (AGL-515).\n const dispatcher = createPinnedDispatcher(pinnedIp)\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)\n // `dispatcher` is an undici extension not in the DOM RequestInit type;\n // assigning to a variable first avoids the object-literal excess-property\n // check while still passing it through to the (undici) global fetch.\n const requestInit = {\n method,\n signal: controller.signal,\n dispatcher,\n redirect: 'manual' as const,\n headers: { Accept: 'application/json, text/*;q=0.9, */*;q=0.1' },\n ...(method === 'POST' && requestBody ? { body: requestBody } : {}),\n }\n try {\n upstream = await fetch(currentUrl, requestInit)\n } finally {\n clearTimeout(timeout)\n await dispatcher.close().catch(() => undefined)\n }\n\n if (!REDIRECT_STATUSES.has(upstream.status)) break\n\n const location = upstream.headers.get('location')\n if (!location) break // a redirect status with no target is just a body\n let nextUrl: string\n try {\n nextUrl = new URL(location, currentUrl).toString()\n } catch {\n res\n .status(403)\n .json({ ok: false, status: 0, error: 'Redirect target is not a URL' })\n return\n }\n if (\n !isPluginNetworkAllowed(\n nextUrl,\n (install.manifest as { capabilities?: any }).capabilities,\n )\n ) {\n res.status(403).json({\n ok: false,\n status: 0,\n error: 'Redirect target not in allowlist',\n })\n return\n }\n currentUrl = nextUrl\n }\n\n const text = await upstream.text()\n const clipped =\n Buffer.byteLength(text) > PLUGIN_FETCH_MAX_BODY_BYTES\n ? text.slice(0, PLUGIN_FETCH_MAX_BODY_BYTES)\n : text\n res.status(200).json({\n ok: upstream.ok,\n status: upstream.status,\n body: clipped,\n })\n } catch (error) {\n console.error('servePluginFetch failed', hostId, listingId, error)\n res.status(502).json({ ok: false, status: 0, error: 'Fetch failed' })\n }\n}\n\nfunction safeJson(value: string): Record<string, unknown> {\n try {\n return JSON.parse(value)\n } catch {\n return {}\n }\n}\n"],"names":["isPluginNetworkAllowed","PLUGIN_FETCH_MAX_BODY_BYTES","lookup","isIPv4","isIPv6","Agent","firebaseAdmin","FETCH_TIMEOUT_MS","MAX_REDIRECT_HOPS","REDIRECT_STATUSES","Set","isPrivateIp","ip","a","b","split","map","Number","low","toLowerCase","startsWith","test","slice","resolvePublicIp","hostname","addresses","all","length","some","address","createPinnedDispatcher","pinnedIp","family","connect","_host","options","cb","servePluginFetch","req","res","body","method","setHeader","status","json","ok","error","safeJson","hostId","String","listingId","url","requestBody","undefined","Buffer","byteLength","install","app","firestore","collection","doc","get","data","manifest","capabilities","currentUrl","upstream","hop","URL","dispatcher","controller","AbortController","timeout","setTimeout","abort","requestInit","signal","redirect","headers","Accept","fetch","clearTimeout","close","catch","has","location","nextUrl","toString","text","clipped","console","value","JSON","parse"],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* CTIA's standard opt-out set. These are the words a carrier expects every
|
|
19
|
+
* program to honour, and several carriers enforce STOP at the network level
|
|
20
|
+
* whether or not the program does.
|
|
21
|
+
*/
|
|
22
|
+
export declare const SMS_STOP_KEYWORDS: readonly ["stop", "stopall", "unsubscribe", "cancel", "end", "quit", "optout"];
|
|
23
|
+
/** The standard opt-back-in set. */
|
|
24
|
+
export declare const SMS_START_KEYWORDS: readonly ["start", "unstop", "yes", "optin"];
|
|
25
|
+
/** The standard help set. */
|
|
26
|
+
export declare const SMS_HELP_KEYWORDS: readonly ["help", "info"];
|
|
27
|
+
export type SmsKeywordVerdict = 'stop' | 'start' | 'help' | null;
|
|
28
|
+
/**
|
|
29
|
+
* Classify an inbound message body.
|
|
30
|
+
*
|
|
31
|
+
* Matching is deliberately STRICT — the whole message, once trimmed and
|
|
32
|
+
* stripped of surrounding punctuation, must be the keyword. A substring match
|
|
33
|
+
* would read "please don't stop sending these" as an opt-out, and "yes I want
|
|
34
|
+
* to cancel my account" as both a START and a STOP depending on scan order.
|
|
35
|
+
* Carriers apply the same whole-word rule.
|
|
36
|
+
*
|
|
37
|
+
* The one deliberate looseness is Unicode: smart quotes and full-width forms
|
|
38
|
+
* arrive from real handsets, so the body is normalized before comparison.
|
|
39
|
+
* Getting this wrong fails in the direction that matters — an unrecognised
|
|
40
|
+
* STOP is an opt-out we ignored.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseSmsKeyword(body: string | null | undefined): SmsKeywordVerdict;
|
|
43
|
+
/**
|
|
44
|
+
* Apply an inbound keyword to the suppression list.
|
|
45
|
+
*
|
|
46
|
+
* A STOP suppresses TEXTS ONLY, not calls. That is a real distinction and not
|
|
47
|
+
* an oversight: the person replied to a text message, so a texting opt-out is
|
|
48
|
+
* what they asked for, and silently widening it to calls would be us recording
|
|
49
|
+
* a request nobody made. §11 offers the call opt-out through the other two
|
|
50
|
+
* routes, and the staff intake records exactly what was asked for.
|
|
51
|
+
*
|
|
52
|
+
* @param from the originating number, as the provider reports it. MUST have
|
|
53
|
+
* been authenticated by the caller — see the file header.
|
|
54
|
+
* @returns the verdict acted on, so the webhook knows which reply to send.
|
|
55
|
+
*/
|
|
56
|
+
export declare function applyInboundSmsKeyword(input: {
|
|
57
|
+
from: string;
|
|
58
|
+
body: string | null | undefined;
|
|
59
|
+
firestore?: any;
|
|
60
|
+
}): Promise<{
|
|
61
|
+
verdict: SmsKeywordVerdict;
|
|
62
|
+
applied: boolean;
|
|
63
|
+
}>;
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
* Inbound SMS keyword handling — the STOP half of Privacy Policy v4 §11
|
|
19
|
+
* (AGL-1592).
|
|
20
|
+
*
|
|
21
|
+
* THIS IS A SEAM, NOT AN INTEGRATION. There is no SMS pipeline in this product
|
|
22
|
+
* yet: nothing sends a text, so nothing receives a reply, and there is no
|
|
23
|
+
* provider to name. Inventing one here would mean guessing a webhook payload
|
|
24
|
+
* shape and a signature scheme, and a guess that is never exercised is not a
|
|
25
|
+
* mechanism — it is a second promise with nothing behind it.
|
|
26
|
+
*
|
|
27
|
+
* What is real and worth having in advance is the part that is provider-
|
|
28
|
+
* independent and that a provider integration cannot get right by itself:
|
|
29
|
+
* WHICH words are an opt-out, and WHAT an opt-out does to our records. Those
|
|
30
|
+
* are decided by CTIA's messaging principles and by TCPA, not by a vendor.
|
|
31
|
+
*
|
|
32
|
+
* WIRING THIS UP LATER — the checklist, deliberately written down here because
|
|
33
|
+
* the thing that goes wrong is skipping step 1:
|
|
34
|
+
*
|
|
35
|
+
* 1. VERIFY THE PROVIDER'S SIGNATURE on the webhook request BEFORE calling
|
|
36
|
+
* `applyInboundSmsKeyword`. The `from` number is the entire authorization
|
|
37
|
+
* for the write: an unauthenticated caller who can post `{from, body}` can
|
|
38
|
+
* opt a stranger out (annoying) or, via START, opt a stranger BACK IN
|
|
39
|
+
* (a statutory violation with our name on it). Nothing in this file
|
|
40
|
+
* authenticates anything.
|
|
41
|
+
* 2. Reply to the opt-out. CTIA expects a single confirmation message, and
|
|
42
|
+
* that reply is itself permitted after a STOP — it is the one message you
|
|
43
|
+
* may still send.
|
|
44
|
+
* 3. Answer HELP with the program name and contact details.
|
|
45
|
+
* 4. Only THEN is there anything to send. See the note on `releasePhoneContact`:
|
|
46
|
+
* clearing a suppression is not consent, and the consent mechanism is a
|
|
47
|
+
* different piece of work (AGL-1564).
|
|
48
|
+
*/ import { releasePhoneContact, suppressPhoneContact } from "./contact-suppression.js";
|
|
49
|
+
/**
|
|
50
|
+
* CTIA's standard opt-out set. These are the words a carrier expects every
|
|
51
|
+
* program to honour, and several carriers enforce STOP at the network level
|
|
52
|
+
* whether or not the program does.
|
|
53
|
+
*/ export const SMS_STOP_KEYWORDS = [
|
|
54
|
+
'stop',
|
|
55
|
+
'stopall',
|
|
56
|
+
'unsubscribe',
|
|
57
|
+
'cancel',
|
|
58
|
+
'end',
|
|
59
|
+
'quit',
|
|
60
|
+
'optout'
|
|
61
|
+
];
|
|
62
|
+
/** The standard opt-back-in set. */ export const SMS_START_KEYWORDS = [
|
|
63
|
+
'start',
|
|
64
|
+
'unstop',
|
|
65
|
+
'yes',
|
|
66
|
+
'optin'
|
|
67
|
+
];
|
|
68
|
+
/** The standard help set. */ export const SMS_HELP_KEYWORDS = [
|
|
69
|
+
'help',
|
|
70
|
+
'info'
|
|
71
|
+
];
|
|
72
|
+
/**
|
|
73
|
+
* Classify an inbound message body.
|
|
74
|
+
*
|
|
75
|
+
* Matching is deliberately STRICT — the whole message, once trimmed and
|
|
76
|
+
* stripped of surrounding punctuation, must be the keyword. A substring match
|
|
77
|
+
* would read "please don't stop sending these" as an opt-out, and "yes I want
|
|
78
|
+
* to cancel my account" as both a START and a STOP depending on scan order.
|
|
79
|
+
* Carriers apply the same whole-word rule.
|
|
80
|
+
*
|
|
81
|
+
* The one deliberate looseness is Unicode: smart quotes and full-width forms
|
|
82
|
+
* arrive from real handsets, so the body is normalized before comparison.
|
|
83
|
+
* Getting this wrong fails in the direction that matters — an unrecognised
|
|
84
|
+
* STOP is an opt-out we ignored.
|
|
85
|
+
*/ export function parseSmsKeyword(body) {
|
|
86
|
+
const cleaned = String(body != null ? body : '').normalize('NFKC').trim()// Strip surrounding punctuation and whitespace only: "STOP." and "«STOP»"
|
|
87
|
+
// are opt-outs, "STOP CALLING ME" is handled below.
|
|
88
|
+
.replace(RegExp("^[\\s\\u0021-\\u0023\\u0025-\\u002A\\u002C-\\u002F\\u003A-\\u003B\\u003F-\\u0040\\u005B-\\u005D\\u005F\\u007B\\u007D\\u00A1\\u00A7\\u00AB\\u00B6-\\u00B7\\u00BB\\u00BF\\u037E\\u0387\\u055A-\\u055F\\u0589-\\u058A\\u05BE\\u05C0\\u05C3\\u05C6\\u05F3-\\u05F4\\u0609-\\u060A\\u060C-\\u060D\\u061B\\u061D-\\u061F\\u066A-\\u066D\\u06D4\\u0700-\\u070D\\u07F7-\\u07F9\\u0830-\\u083E\\u085E\\u0964-\\u0965\\u0970\\u09FD\\u0A76\\u0AF0\\u0C77\\u0C84\\u0DF4\\u0E4F\\u0E5A-\\u0E5B\\u0F04-\\u0F12\\u0F14\\u0F3A-\\u0F3D\\u0F85\\u0FD0-\\u0FD4\\u0FD9-\\u0FDA\\u104A-\\u104F\\u10FB\\u1360-\\u1368\\u1400\\u166E\\u169B-\\u169C\\u16EB-\\u16ED\\u1735-\\u1736\\u17D4-\\u17D6\\u17D8-\\u17DA\\u1800-\\u180A\\u1944-\\u1945\\u1A1E-\\u1A1F\\u1AA0-\\u1AA6\\u1AA8-\\u1AAD\\u1B4E-\\u1B4F\\u1B5A-\\u1B60\\u1B7D-\\u1B7F\\u1BFC-\\u1BFF\\u1C3B-\\u1C3F\\u1C7E-\\u1C7F\\u1CC0-\\u1CC7\\u1CD3\\u2010-\\u2027\\u2030-\\u2043\\u2045-\\u2051\\u2053-\\u205E\\u207D-\\u207E\\u208D-\\u208E\\u2308-\\u230B\\u2329-\\u232A\\u2768-\\u2775\\u27C5-\\u27C6\\u27E6-\\u27EF\\u2983-\\u2998\\u29D8-\\u29DB\\u29FC-\\u29FD\\u2CF9-\\u2CFC\\u2CFE-\\u2CFF\\u2D70\\u2E00-\\u2E2E\\u2E30-\\u2E4F\\u2E52-\\u2E5D\\u3001-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u3030\\u303D\\u30A0\\u30FB\\uA4FE-\\uA4FF\\uA60D-\\uA60F\\uA673\\uA67E\\uA6F2-\\uA6F7\\uA874-\\uA877\\uA8CE-\\uA8CF\\uA8F8-\\uA8FA\\uA8FC\\uA92E-\\uA92F\\uA95F\\uA9C1-\\uA9CD\\uA9DE-\\uA9DF\\uAA5C-\\uAA5F\\uAADE-\\uAADF\\uAAF0-\\uAAF1\\uABEB\\uFD3E-\\uFD3F\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE61\\uFE63\\uFE68\\uFE6A-\\uFE6B\\uFF01-\\uFF03\\uFF05-\\uFF0A\\uFF0C-\\uFF0F\\uFF1A-\\uFF1B\\uFF1F-\\uFF20\\uFF3B-\\uFF3D\\uFF3F\\uFF5B\\uFF5D\\uFF5F-\\uFF65\\u{10100}-\\u{10102}\\u{1039F}\\u{103D0}\\u{1056F}\\u{10857}\\u{1091F}\\u{1093F}\\u{10A50}-\\u{10A58}\\u{10A7F}\\u{10AF0}-\\u{10AF6}\\u{10B39}-\\u{10B3F}\\u{10B99}-\\u{10B9C}\\u{10D6E}\\u{10EAD}\\u{10ED0}\\u{10F55}-\\u{10F59}\\u{10F86}-\\u{10F89}\\u{11047}-\\u{1104D}\\u{110BB}-\\u{110BC}\\u{110BE}-\\u{110C1}\\u{11140}-\\u{11143}\\u{11174}-\\u{11175}\\u{111C5}-\\u{111C8}\\u{111CD}\\u{111DB}\\u{111DD}-\\u{111DF}\\u{11238}-\\u{1123D}\\u{112A9}\\u{113D4}-\\u{113D5}\\u{113D7}-\\u{113D8}\\u{1144B}-\\u{1144F}\\u{1145A}-\\u{1145B}\\u{1145D}\\u{114C6}\\u{115C1}-\\u{115D7}\\u{11641}-\\u{11643}\\u{11660}-\\u{1166C}\\u{116B9}\\u{1173C}-\\u{1173E}\\u{1183B}\\u{11944}-\\u{11946}\\u{119E2}\\u{11A3F}-\\u{11A46}\\u{11A9A}-\\u{11A9C}\\u{11A9E}-\\u{11AA2}\\u{11B00}-\\u{11B09}\\u{11BE1}\\u{11C41}-\\u{11C45}\\u{11C70}-\\u{11C71}\\u{11EF7}-\\u{11EF8}\\u{11F43}-\\u{11F4F}\\u{11FFF}\\u{12470}-\\u{12474}\\u{12FF1}-\\u{12FF2}\\u{16A6E}-\\u{16A6F}\\u{16AF5}\\u{16B37}-\\u{16B3B}\\u{16B44}\\u{16D6D}-\\u{16D6F}\\u{16E97}-\\u{16E9A}\\u{16FE2}\\u{1BC9F}\\u{1DA87}-\\u{1DA8B}\\u{1E5FF}\\u{1E95E}-\\u{1E95F}\\u0024\\u002B\\u003C-\\u003E\\u005E\\u0060\\u007C\\u007E\\u00A2-\\u00A6\\u00A8-\\u00A9\\u00AC\\u00AE-\\u00B1\\u00B4\\u00B8\\u00D7\\u00F7\\u02C2-\\u02C5\\u02D2-\\u02DF\\u02E5-\\u02EB\\u02ED\\u02EF-\\u02FF\\u0375\\u0384-\\u0385\\u03F6\\u0482\\u058D-\\u058F\\u0606-\\u0608\\u060B\\u060E-\\u060F\\u06DE\\u06E9\\u06FD-\\u06FE\\u07F6\\u07FE-\\u07FF\\u0888\\u09F2-\\u09F3\\u09FA-\\u09FB\\u0AF1\\u0B70\\u0BF3-\\u0BFA\\u0C7F\\u0D4F\\u0D79\\u0E3F\\u0F01-\\u0F03\\u0F13\\u0F15-\\u0F17\\u0F1A-\\u0F1F\\u0F34\\u0F36\\u0F38\\u0FBE-\\u0FC5\\u0FC7-\\u0FCC\\u0FCE-\\u0FCF\\u0FD5-\\u0FD8\\u109E-\\u109F\\u1390-\\u1399\\u166D\\u17DB\\u1940\\u19DE-\\u19FF\\u1B61-\\u1B6A\\u1B74-\\u1B7C\\u1FBD\\u1FBF-\\u1FC1\\u1FCD-\\u1FCF\\u1FDD-\\u1FDF\\u1FED-\\u1FEF\\u1FFD-\\u1FFE\\u2044\\u2052\\u207A-\\u207C\\u208A-\\u208C\\u20A0-\\u20C1\\u2100-\\u2101\\u2103-\\u2106\\u2108-\\u2109\\u2114\\u2116-\\u2118\\u211E-\\u2123\\u2125\\u2127\\u2129\\u212E\\u213A-\\u213B\\u2140-\\u2144\\u214A-\\u214D\\u214F\\u218A-\\u218B\\u2190-\\u2307\\u230C-\\u2328\\u232B-\\u2429\\u2440-\\u244A\\u249C-\\u24E9\\u2500-\\u2767\\u2794-\\u27C4\\u27C7-\\u27E5\\u27F0-\\u2982\\u2999-\\u29D7\\u29DC-\\u29FB\\u29FE-\\u2B73\\u2B76-\\u2BFF\\u2CE5-\\u2CEA\\u2E50-\\u2E51\\u2E80-\\u2E99\\u2E9B-\\u2EF3\\u2F00-\\u2FD5\\u2FF0-\\u2FFF\\u3004\\u3012-\\u3013\\u3020\\u3036-\\u3037\\u303E-\\u303F\\u309B-\\u309C\\u3190-\\u3191\\u3196-\\u319F\\u31C0-\\u31E5\\u31EF\\u3200-\\u321E\\u322A-\\u3247\\u3250\\u3260-\\u327F\\u328A-\\u32B0\\u32C0-\\u33FF\\u4DC0-\\u4DFF\\uA490-\\uA4C6\\uA700-\\uA716\\uA720-\\uA721\\uA789-\\uA78A\\uA828-\\uA82B\\uA836-\\uA839\\uAA77-\\uAA79\\uAB5B\\uAB6A-\\uAB6B\\uFB29\\uFBB2-\\uFBD2\\uFD40-\\uFD4F\\uFD90-\\uFD91\\uFDC8-\\uFDCF\\uFDFC-\\uFDFF\\uFE62\\uFE64-\\uFE66\\uFE69\\uFF04\\uFF0B\\uFF1C-\\uFF1E\\uFF3E\\uFF40\\uFF5C\\uFF5E\\uFFE0-\\uFFE6\\uFFE8-\\uFFEE\\uFFFC-\\uFFFD\\u{10137}-\\u{1013F}\\u{10179}-\\u{10189}\\u{1018C}-\\u{1018E}\\u{10190}-\\u{1019C}\\u{101A0}\\u{101D0}-\\u{101FC}\\u{10877}-\\u{10878}\\u{10AC8}\\u{10D8E}-\\u{10D8F}\\u{10ED1}-\\u{10ED8}\\u{1173F}\\u{11FD5}-\\u{11FF1}\\u{16B3C}-\\u{16B3F}\\u{16B45}\\u{1BC9C}\\u{1CC00}-\\u{1CCEF}\\u{1CCFA}-\\u{1CCFC}\\u{1CD00}-\\u{1CEB3}\\u{1CEBA}-\\u{1CED0}\\u{1CEE0}-\\u{1CEF0}\\u{1CF50}-\\u{1CFC3}\\u{1D000}-\\u{1D0F5}\\u{1D100}-\\u{1D126}\\u{1D129}-\\u{1D164}\\u{1D16A}-\\u{1D16C}\\u{1D183}-\\u{1D184}\\u{1D18C}-\\u{1D1A9}\\u{1D1AE}-\\u{1D1EA}\\u{1D200}-\\u{1D241}\\u{1D245}\\u{1D300}-\\u{1D356}\\u{1D6C1}\\u{1D6DB}\\u{1D6FB}\\u{1D715}\\u{1D735}\\u{1D74F}\\u{1D76F}\\u{1D789}\\u{1D7A9}\\u{1D7C3}\\u{1D800}-\\u{1D9FF}\\u{1DA37}-\\u{1DA3A}\\u{1DA6D}-\\u{1DA74}\\u{1DA76}-\\u{1DA83}\\u{1DA85}-\\u{1DA86}\\u{1E14F}\\u{1E2FF}\\u{1ECAC}\\u{1ECB0}\\u{1ED2E}\\u{1EEF0}-\\u{1EEF1}\\u{1F000}-\\u{1F02B}\\u{1F030}-\\u{1F093}\\u{1F0A0}-\\u{1F0AE}\\u{1F0B1}-\\u{1F0BF}\\u{1F0C1}-\\u{1F0CF}\\u{1F0D1}-\\u{1F0F5}\\u{1F10D}-\\u{1F1AD}\\u{1F1E6}-\\u{1F202}\\u{1F210}-\\u{1F23B}\\u{1F240}-\\u{1F248}\\u{1F250}-\\u{1F251}\\u{1F260}-\\u{1F265}\\u{1F300}-\\u{1F6D8}\\u{1F6DC}-\\u{1F6EC}\\u{1F6F0}-\\u{1F6FC}\\u{1F700}-\\u{1F7D9}\\u{1F7E0}-\\u{1F7EB}\\u{1F7F0}\\u{1F800}-\\u{1F80B}\\u{1F810}-\\u{1F847}\\u{1F850}-\\u{1F859}\\u{1F860}-\\u{1F887}\\u{1F890}-\\u{1F8AD}\\u{1F8B0}-\\u{1F8BB}\\u{1F8C0}-\\u{1F8C1}\\u{1F8D0}-\\u{1F8D8}\\u{1F900}-\\u{1FA57}\\u{1FA60}-\\u{1FA6D}\\u{1FA70}-\\u{1FA7C}\\u{1FA80}-\\u{1FA8A}\\u{1FA8E}-\\u{1FAC6}\\u{1FAC8}\\u{1FACD}-\\u{1FADC}\\u{1FADF}-\\u{1FAEA}\\u{1FAEF}-\\u{1FAF8}\\u{1FB00}-\\u{1FB92}\\u{1FB94}-\\u{1FBEF}\\u{1FBFA}]+|[\\s\\u0021-\\u0023\\u0025-\\u002A\\u002C-\\u002F\\u003A-\\u003B\\u003F-\\u0040\\u005B-\\u005D\\u005F\\u007B\\u007D\\u00A1\\u00A7\\u00AB\\u00B6-\\u00B7\\u00BB\\u00BF\\u037E\\u0387\\u055A-\\u055F\\u0589-\\u058A\\u05BE\\u05C0\\u05C3\\u05C6\\u05F3-\\u05F4\\u0609-\\u060A\\u060C-\\u060D\\u061B\\u061D-\\u061F\\u066A-\\u066D\\u06D4\\u0700-\\u070D\\u07F7-\\u07F9\\u0830-\\u083E\\u085E\\u0964-\\u0965\\u0970\\u09FD\\u0A76\\u0AF0\\u0C77\\u0C84\\u0DF4\\u0E4F\\u0E5A-\\u0E5B\\u0F04-\\u0F12\\u0F14\\u0F3A-\\u0F3D\\u0F85\\u0FD0-\\u0FD4\\u0FD9-\\u0FDA\\u104A-\\u104F\\u10FB\\u1360-\\u1368\\u1400\\u166E\\u169B-\\u169C\\u16EB-\\u16ED\\u1735-\\u1736\\u17D4-\\u17D6\\u17D8-\\u17DA\\u1800-\\u180A\\u1944-\\u1945\\u1A1E-\\u1A1F\\u1AA0-\\u1AA6\\u1AA8-\\u1AAD\\u1B4E-\\u1B4F\\u1B5A-\\u1B60\\u1B7D-\\u1B7F\\u1BFC-\\u1BFF\\u1C3B-\\u1C3F\\u1C7E-\\u1C7F\\u1CC0-\\u1CC7\\u1CD3\\u2010-\\u2027\\u2030-\\u2043\\u2045-\\u2051\\u2053-\\u205E\\u207D-\\u207E\\u208D-\\u208E\\u2308-\\u230B\\u2329-\\u232A\\u2768-\\u2775\\u27C5-\\u27C6\\u27E6-\\u27EF\\u2983-\\u2998\\u29D8-\\u29DB\\u29FC-\\u29FD\\u2CF9-\\u2CFC\\u2CFE-\\u2CFF\\u2D70\\u2E00-\\u2E2E\\u2E30-\\u2E4F\\u2E52-\\u2E5D\\u3001-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u3030\\u303D\\u30A0\\u30FB\\uA4FE-\\uA4FF\\uA60D-\\uA60F\\uA673\\uA67E\\uA6F2-\\uA6F7\\uA874-\\uA877\\uA8CE-\\uA8CF\\uA8F8-\\uA8FA\\uA8FC\\uA92E-\\uA92F\\uA95F\\uA9C1-\\uA9CD\\uA9DE-\\uA9DF\\uAA5C-\\uAA5F\\uAADE-\\uAADF\\uAAF0-\\uAAF1\\uABEB\\uFD3E-\\uFD3F\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE61\\uFE63\\uFE68\\uFE6A-\\uFE6B\\uFF01-\\uFF03\\uFF05-\\uFF0A\\uFF0C-\\uFF0F\\uFF1A-\\uFF1B\\uFF1F-\\uFF20\\uFF3B-\\uFF3D\\uFF3F\\uFF5B\\uFF5D\\uFF5F-\\uFF65\\u{10100}-\\u{10102}\\u{1039F}\\u{103D0}\\u{1056F}\\u{10857}\\u{1091F}\\u{1093F}\\u{10A50}-\\u{10A58}\\u{10A7F}\\u{10AF0}-\\u{10AF6}\\u{10B39}-\\u{10B3F}\\u{10B99}-\\u{10B9C}\\u{10D6E}\\u{10EAD}\\u{10ED0}\\u{10F55}-\\u{10F59}\\u{10F86}-\\u{10F89}\\u{11047}-\\u{1104D}\\u{110BB}-\\u{110BC}\\u{110BE}-\\u{110C1}\\u{11140}-\\u{11143}\\u{11174}-\\u{11175}\\u{111C5}-\\u{111C8}\\u{111CD}\\u{111DB}\\u{111DD}-\\u{111DF}\\u{11238}-\\u{1123D}\\u{112A9}\\u{113D4}-\\u{113D5}\\u{113D7}-\\u{113D8}\\u{1144B}-\\u{1144F}\\u{1145A}-\\u{1145B}\\u{1145D}\\u{114C6}\\u{115C1}-\\u{115D7}\\u{11641}-\\u{11643}\\u{11660}-\\u{1166C}\\u{116B9}\\u{1173C}-\\u{1173E}\\u{1183B}\\u{11944}-\\u{11946}\\u{119E2}\\u{11A3F}-\\u{11A46}\\u{11A9A}-\\u{11A9C}\\u{11A9E}-\\u{11AA2}\\u{11B00}-\\u{11B09}\\u{11BE1}\\u{11C41}-\\u{11C45}\\u{11C70}-\\u{11C71}\\u{11EF7}-\\u{11EF8}\\u{11F43}-\\u{11F4F}\\u{11FFF}\\u{12470}-\\u{12474}\\u{12FF1}-\\u{12FF2}\\u{16A6E}-\\u{16A6F}\\u{16AF5}\\u{16B37}-\\u{16B3B}\\u{16B44}\\u{16D6D}-\\u{16D6F}\\u{16E97}-\\u{16E9A}\\u{16FE2}\\u{1BC9F}\\u{1DA87}-\\u{1DA8B}\\u{1E5FF}\\u{1E95E}-\\u{1E95F}\\u0024\\u002B\\u003C-\\u003E\\u005E\\u0060\\u007C\\u007E\\u00A2-\\u00A6\\u00A8-\\u00A9\\u00AC\\u00AE-\\u00B1\\u00B4\\u00B8\\u00D7\\u00F7\\u02C2-\\u02C5\\u02D2-\\u02DF\\u02E5-\\u02EB\\u02ED\\u02EF-\\u02FF\\u0375\\u0384-\\u0385\\u03F6\\u0482\\u058D-\\u058F\\u0606-\\u0608\\u060B\\u060E-\\u060F\\u06DE\\u06E9\\u06FD-\\u06FE\\u07F6\\u07FE-\\u07FF\\u0888\\u09F2-\\u09F3\\u09FA-\\u09FB\\u0AF1\\u0B70\\u0BF3-\\u0BFA\\u0C7F\\u0D4F\\u0D79\\u0E3F\\u0F01-\\u0F03\\u0F13\\u0F15-\\u0F17\\u0F1A-\\u0F1F\\u0F34\\u0F36\\u0F38\\u0FBE-\\u0FC5\\u0FC7-\\u0FCC\\u0FCE-\\u0FCF\\u0FD5-\\u0FD8\\u109E-\\u109F\\u1390-\\u1399\\u166D\\u17DB\\u1940\\u19DE-\\u19FF\\u1B61-\\u1B6A\\u1B74-\\u1B7C\\u1FBD\\u1FBF-\\u1FC1\\u1FCD-\\u1FCF\\u1FDD-\\u1FDF\\u1FED-\\u1FEF\\u1FFD-\\u1FFE\\u2044\\u2052\\u207A-\\u207C\\u208A-\\u208C\\u20A0-\\u20C1\\u2100-\\u2101\\u2103-\\u2106\\u2108-\\u2109\\u2114\\u2116-\\u2118\\u211E-\\u2123\\u2125\\u2127\\u2129\\u212E\\u213A-\\u213B\\u2140-\\u2144\\u214A-\\u214D\\u214F\\u218A-\\u218B\\u2190-\\u2307\\u230C-\\u2328\\u232B-\\u2429\\u2440-\\u244A\\u249C-\\u24E9\\u2500-\\u2767\\u2794-\\u27C4\\u27C7-\\u27E5\\u27F0-\\u2982\\u2999-\\u29D7\\u29DC-\\u29FB\\u29FE-\\u2B73\\u2B76-\\u2BFF\\u2CE5-\\u2CEA\\u2E50-\\u2E51\\u2E80-\\u2E99\\u2E9B-\\u2EF3\\u2F00-\\u2FD5\\u2FF0-\\u2FFF\\u3004\\u3012-\\u3013\\u3020\\u3036-\\u3037\\u303E-\\u303F\\u309B-\\u309C\\u3190-\\u3191\\u3196-\\u319F\\u31C0-\\u31E5\\u31EF\\u3200-\\u321E\\u322A-\\u3247\\u3250\\u3260-\\u327F\\u328A-\\u32B0\\u32C0-\\u33FF\\u4DC0-\\u4DFF\\uA490-\\uA4C6\\uA700-\\uA716\\uA720-\\uA721\\uA789-\\uA78A\\uA828-\\uA82B\\uA836-\\uA839\\uAA77-\\uAA79\\uAB5B\\uAB6A-\\uAB6B\\uFB29\\uFBB2-\\uFBD2\\uFD40-\\uFD4F\\uFD90-\\uFD91\\uFDC8-\\uFDCF\\uFDFC-\\uFDFF\\uFE62\\uFE64-\\uFE66\\uFE69\\uFF04\\uFF0B\\uFF1C-\\uFF1E\\uFF3E\\uFF40\\uFF5C\\uFF5E\\uFFE0-\\uFFE6\\uFFE8-\\uFFEE\\uFFFC-\\uFFFD\\u{10137}-\\u{1013F}\\u{10179}-\\u{10189}\\u{1018C}-\\u{1018E}\\u{10190}-\\u{1019C}\\u{101A0}\\u{101D0}-\\u{101FC}\\u{10877}-\\u{10878}\\u{10AC8}\\u{10D8E}-\\u{10D8F}\\u{10ED1}-\\u{10ED8}\\u{1173F}\\u{11FD5}-\\u{11FF1}\\u{16B3C}-\\u{16B3F}\\u{16B45}\\u{1BC9C}\\u{1CC00}-\\u{1CCEF}\\u{1CCFA}-\\u{1CCFC}\\u{1CD00}-\\u{1CEB3}\\u{1CEBA}-\\u{1CED0}\\u{1CEE0}-\\u{1CEF0}\\u{1CF50}-\\u{1CFC3}\\u{1D000}-\\u{1D0F5}\\u{1D100}-\\u{1D126}\\u{1D129}-\\u{1D164}\\u{1D16A}-\\u{1D16C}\\u{1D183}-\\u{1D184}\\u{1D18C}-\\u{1D1A9}\\u{1D1AE}-\\u{1D1EA}\\u{1D200}-\\u{1D241}\\u{1D245}\\u{1D300}-\\u{1D356}\\u{1D6C1}\\u{1D6DB}\\u{1D6FB}\\u{1D715}\\u{1D735}\\u{1D74F}\\u{1D76F}\\u{1D789}\\u{1D7A9}\\u{1D7C3}\\u{1D800}-\\u{1D9FF}\\u{1DA37}-\\u{1DA3A}\\u{1DA6D}-\\u{1DA74}\\u{1DA76}-\\u{1DA83}\\u{1DA85}-\\u{1DA86}\\u{1E14F}\\u{1E2FF}\\u{1ECAC}\\u{1ECB0}\\u{1ED2E}\\u{1EEF0}-\\u{1EEF1}\\u{1F000}-\\u{1F02B}\\u{1F030}-\\u{1F093}\\u{1F0A0}-\\u{1F0AE}\\u{1F0B1}-\\u{1F0BF}\\u{1F0C1}-\\u{1F0CF}\\u{1F0D1}-\\u{1F0F5}\\u{1F10D}-\\u{1F1AD}\\u{1F1E6}-\\u{1F202}\\u{1F210}-\\u{1F23B}\\u{1F240}-\\u{1F248}\\u{1F250}-\\u{1F251}\\u{1F260}-\\u{1F265}\\u{1F300}-\\u{1F6D8}\\u{1F6DC}-\\u{1F6EC}\\u{1F6F0}-\\u{1F6FC}\\u{1F700}-\\u{1F7D9}\\u{1F7E0}-\\u{1F7EB}\\u{1F7F0}\\u{1F800}-\\u{1F80B}\\u{1F810}-\\u{1F847}\\u{1F850}-\\u{1F859}\\u{1F860}-\\u{1F887}\\u{1F890}-\\u{1F8AD}\\u{1F8B0}-\\u{1F8BB}\\u{1F8C0}-\\u{1F8C1}\\u{1F8D0}-\\u{1F8D8}\\u{1F900}-\\u{1FA57}\\u{1FA60}-\\u{1FA6D}\\u{1FA70}-\\u{1FA7C}\\u{1FA80}-\\u{1FA8A}\\u{1FA8E}-\\u{1FAC6}\\u{1FAC8}\\u{1FACD}-\\u{1FADC}\\u{1FADF}-\\u{1FAEA}\\u{1FAEF}-\\u{1FAF8}\\u{1FB00}-\\u{1FB92}\\u{1FB94}-\\u{1FBEF}\\u{1FBFA}]+$", "gu"), '').toLowerCase();
|
|
89
|
+
if (!cleaned) return null;
|
|
90
|
+
if (SMS_STOP_KEYWORDS.includes(cleaned)) return 'stop';
|
|
91
|
+
if (SMS_START_KEYWORDS.includes(cleaned)) return 'start';
|
|
92
|
+
if (SMS_HELP_KEYWORDS.includes(cleaned)) return 'help';
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Apply an inbound keyword to the suppression list.
|
|
97
|
+
*
|
|
98
|
+
* A STOP suppresses TEXTS ONLY, not calls. That is a real distinction and not
|
|
99
|
+
* an oversight: the person replied to a text message, so a texting opt-out is
|
|
100
|
+
* what they asked for, and silently widening it to calls would be us recording
|
|
101
|
+
* a request nobody made. §11 offers the call opt-out through the other two
|
|
102
|
+
* routes, and the staff intake records exactly what was asked for.
|
|
103
|
+
*
|
|
104
|
+
* @param from the originating number, as the provider reports it. MUST have
|
|
105
|
+
* been authenticated by the caller — see the file header.
|
|
106
|
+
* @returns the verdict acted on, so the webhook knows which reply to send.
|
|
107
|
+
*/ export async function applyInboundSmsKeyword(input) {
|
|
108
|
+
const verdict = parseSmsKeyword(input.body);
|
|
109
|
+
if (verdict === 'stop') {
|
|
110
|
+
const channels = [
|
|
111
|
+
'texts'
|
|
112
|
+
];
|
|
113
|
+
await suppressPhoneContact(_extends({
|
|
114
|
+
phoneNumber: input.from,
|
|
115
|
+
channels,
|
|
116
|
+
source: 'sms-keyword',
|
|
117
|
+
note: 'Inbound keyword opt-out'
|
|
118
|
+
}, input.firestore ? {
|
|
119
|
+
firestore: input.firestore
|
|
120
|
+
} : {}));
|
|
121
|
+
return {
|
|
122
|
+
verdict,
|
|
123
|
+
applied: true
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (verdict === 'start') {
|
|
127
|
+
const applied = await releasePhoneContact(_extends({
|
|
128
|
+
phoneNumber: input.from,
|
|
129
|
+
note: 'Inbound keyword opt-in'
|
|
130
|
+
}, input.firestore ? {
|
|
131
|
+
firestore: input.firestore
|
|
132
|
+
} : {}));
|
|
133
|
+
return {
|
|
134
|
+
verdict,
|
|
135
|
+
applied
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// HELP and unrecognised bodies change no records. HELP still needs a reply,
|
|
139
|
+
// which is the webhook's job.
|
|
140
|
+
return {
|
|
141
|
+
verdict,
|
|
142
|
+
applied: false
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
//# sourceMappingURL=sms-keywords.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sms-keywords.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 * Inbound SMS keyword handling — the STOP half of Privacy Policy v4 §11\n * (AGL-1592).\n *\n * THIS IS A SEAM, NOT AN INTEGRATION. There is no SMS pipeline in this product\n * yet: nothing sends a text, so nothing receives a reply, and there is no\n * provider to name. Inventing one here would mean guessing a webhook payload\n * shape and a signature scheme, and a guess that is never exercised is not a\n * mechanism — it is a second promise with nothing behind it.\n *\n * What is real and worth having in advance is the part that is provider-\n * independent and that a provider integration cannot get right by itself:\n * WHICH words are an opt-out, and WHAT an opt-out does to our records. Those\n * are decided by CTIA's messaging principles and by TCPA, not by a vendor.\n *\n * WIRING THIS UP LATER — the checklist, deliberately written down here because\n * the thing that goes wrong is skipping step 1:\n *\n * 1. VERIFY THE PROVIDER'S SIGNATURE on the webhook request BEFORE calling\n * `applyInboundSmsKeyword`. The `from` number is the entire authorization\n * for the write: an unauthenticated caller who can post `{from, body}` can\n * opt a stranger out (annoying) or, via START, opt a stranger BACK IN\n * (a statutory violation with our name on it). Nothing in this file\n * authenticates anything.\n * 2. Reply to the opt-out. CTIA expects a single confirmation message, and\n * that reply is itself permitted after a STOP — it is the one message you\n * may still send.\n * 3. Answer HELP with the program name and contact details.\n * 4. Only THEN is there anything to send. See the note on `releasePhoneContact`:\n * clearing a suppression is not consent, and the consent mechanism is a\n * different piece of work (AGL-1564).\n */\n\nimport {\n releasePhoneContact,\n suppressPhoneContact,\n type ContactChannel,\n} from './contact-suppression'\n\n/**\n * CTIA's standard opt-out set. These are the words a carrier expects every\n * program to honour, and several carriers enforce STOP at the network level\n * whether or not the program does.\n */\nexport const SMS_STOP_KEYWORDS = [\n 'stop',\n 'stopall',\n 'unsubscribe',\n 'cancel',\n 'end',\n 'quit',\n 'optout',\n] as const\n\n/** The standard opt-back-in set. */\nexport const SMS_START_KEYWORDS = ['start', 'unstop', 'yes', 'optin'] as const\n\n/** The standard help set. */\nexport const SMS_HELP_KEYWORDS = ['help', 'info'] as const\n\nexport type SmsKeywordVerdict = 'stop' | 'start' | 'help' | null\n\n/**\n * Classify an inbound message body.\n *\n * Matching is deliberately STRICT — the whole message, once trimmed and\n * stripped of surrounding punctuation, must be the keyword. A substring match\n * would read \"please don't stop sending these\" as an opt-out, and \"yes I want\n * to cancel my account\" as both a START and a STOP depending on scan order.\n * Carriers apply the same whole-word rule.\n *\n * The one deliberate looseness is Unicode: smart quotes and full-width forms\n * arrive from real handsets, so the body is normalized before comparison.\n * Getting this wrong fails in the direction that matters — an unrecognised\n * STOP is an opt-out we ignored.\n */\nexport function parseSmsKeyword(body: string | null | undefined): SmsKeywordVerdict {\n const cleaned = String(body ?? '')\n .normalize('NFKC')\n .trim()\n // Strip surrounding punctuation and whitespace only: \"STOP.\" and \"«STOP»\"\n // are opt-outs, \"STOP CALLING ME\" is handled below.\n .replace(/^[\\s\\p{P}\\p{S}]+|[\\s\\p{P}\\p{S}]+$/gu, '')\n .toLowerCase()\n if (!cleaned) return null\n if ((SMS_STOP_KEYWORDS as readonly string[]).includes(cleaned)) return 'stop'\n if ((SMS_START_KEYWORDS as readonly string[]).includes(cleaned)) return 'start'\n if ((SMS_HELP_KEYWORDS as readonly string[]).includes(cleaned)) return 'help'\n return null\n}\n\n/**\n * Apply an inbound keyword to the suppression list.\n *\n * A STOP suppresses TEXTS ONLY, not calls. That is a real distinction and not\n * an oversight: the person replied to a text message, so a texting opt-out is\n * what they asked for, and silently widening it to calls would be us recording\n * a request nobody made. §11 offers the call opt-out through the other two\n * routes, and the staff intake records exactly what was asked for.\n *\n * @param from the originating number, as the provider reports it. MUST have\n * been authenticated by the caller — see the file header.\n * @returns the verdict acted on, so the webhook knows which reply to send.\n */\nexport async function applyInboundSmsKeyword(input: {\n from: string\n body: string | null | undefined\n firestore?: any\n}): Promise<{ verdict: SmsKeywordVerdict; applied: boolean }> {\n const verdict = parseSmsKeyword(input.body)\n if (verdict === 'stop') {\n const channels: ContactChannel[] = ['texts']\n await suppressPhoneContact({\n phoneNumber: input.from,\n channels,\n source: 'sms-keyword',\n note: 'Inbound keyword opt-out',\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n return { verdict, applied: true }\n }\n if (verdict === 'start') {\n const applied = await releasePhoneContact({\n phoneNumber: input.from,\n note: 'Inbound keyword opt-in',\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n return { verdict, applied }\n }\n // HELP and unrecognised bodies change no records. HELP still needs a reply,\n // which is the webhook's job.\n return { verdict, applied: false }\n}\n"],"names":["releasePhoneContact","suppressPhoneContact","SMS_STOP_KEYWORDS","SMS_START_KEYWORDS","SMS_HELP_KEYWORDS","parseSmsKeyword","body","cleaned","String","normalize","trim","replace","toLowerCase","includes","applyInboundSmsKeyword","input","verdict","channels","phoneNumber","from","source","note","firestore","applied"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GAED,SACEA,mBAAmB,EACnBC,oBAAoB,QAEf,2BAAuB;AAE9B;;;;CAIC,GACD,OAAO,MAAMC,oBAAoB;IAC/B;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAS;AAEV,kCAAkC,GAClC,OAAO,MAAMC,qBAAqB;IAAC;IAAS;IAAU;IAAO;CAAQ,CAAS;AAE9E,2BAA2B,GAC3B,OAAO,MAAMC,oBAAoB;IAAC;IAAQ;CAAO,CAAS;AAI1D;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,gBAAgBC,IAA+B;IAC7D,MAAMC,UAAUC,OAAOF,eAAAA,OAAQ,IAC5BG,SAAS,CAAC,QACVC,IAAI,EACL,0EAA0E;IAC1E,oDAAoD;KACnDC,OAAO,CAAC,6nYAAuC,IAC/CC,WAAW;IACd,IAAI,CAACL,SAAS,OAAO;IACrB,IAAI,AAACL,kBAAwCW,QAAQ,CAACN,UAAU,OAAO;IACvE,IAAI,AAACJ,mBAAyCU,QAAQ,CAACN,UAAU,OAAO;IACxE,IAAI,AAACH,kBAAwCS,QAAQ,CAACN,UAAU,OAAO;IACvE,OAAO;AACT;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeO,uBAAuBC,KAI5C;IACC,MAAMC,UAAUX,gBAAgBU,MAAMT,IAAI;IAC1C,IAAIU,YAAY,QAAQ;QACtB,MAAMC,WAA6B;YAAC;SAAQ;QAC5C,MAAMhB,qBAAqB;YACzBiB,aAAaH,MAAMI,IAAI;YACvBF;YACAG,QAAQ;YACRC,MAAM;WACFN,MAAMO,SAAS,GAAG;YAAEA,WAAWP,MAAMO,SAAS;QAAC,IAAI,CAAC;QAE1D,OAAO;YAAEN;YAASO,SAAS;QAAK;IAClC;IACA,IAAIP,YAAY,SAAS;QACvB,MAAMO,UAAU,MAAMvB,oBAAoB;YACxCkB,aAAaH,MAAMI,IAAI;YACvBE,MAAM;WACFN,MAAMO,SAAS,GAAG;YAAEA,WAAWP,MAAMO,SAAS;QAAC,IAAI,CAAC;QAE1D,OAAO;YAAEN;YAASO;QAAQ;IAC5B;IACA,4EAA4E;IAC5E,8BAA8B;IAC9B,OAAO;QAAEP;QAASO,SAAS;IAAM;AACnC"}
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
* ## One predicate, two callers
|
|
19
|
+
*
|
|
20
|
+
* The seven conditions live in {@link qualifiesAsBreakGlassOwner}, asked of
|
|
21
|
+
* ONE uid, because two places need the same answer about different people:
|
|
22
|
+
*
|
|
23
|
+
* - {@link findBreakGlassOrgOwners} asks it of every owner on the roster,
|
|
24
|
+
* which is what the enforcement pre-flight consumes;
|
|
25
|
+
* - the ownership-transfer guard asks it of the person about to BECOME the
|
|
26
|
+
* only owner, before the transfer happens (AGL-1888).
|
|
27
|
+
*
|
|
28
|
+
* The second caller exists because an org has exactly one owner at a time —
|
|
29
|
+
* `transferOrgOwnership` moves the role rather than adding a second holder —
|
|
30
|
+
* so a transfer can move the whole of an org's break-glass protection onto an
|
|
31
|
+
* account inside the pool, after the pre-flight has already passed, with the
|
|
32
|
+
* sweep long since finished. The pre-flight is a gate at one moment; this is
|
|
33
|
+
* the same question asked again at the only other moment the answer can
|
|
34
|
+
* change. Written as one function rather than two so the transfer guard
|
|
35
|
+
* cannot drift into a second, laxer definition of who counts.
|
|
36
|
+
*/
|
|
37
|
+
/** An org owner who would still be able to sign in without the org's IdP. */
|
|
38
|
+
export interface SsoBreakGlassOwner {
|
|
39
|
+
uid: string;
|
|
40
|
+
email: string | null;
|
|
41
|
+
/** Project-pool sign-in methods, none of them the org's IdP. */
|
|
42
|
+
providers: string[];
|
|
43
|
+
}
|
|
44
|
+
export interface SsoBreakGlassOwnerLookup {
|
|
45
|
+
owners: SsoBreakGlassOwner[];
|
|
46
|
+
/**
|
|
47
|
+
* True when a lookup failed for a reason that is not "no such user" — an
|
|
48
|
+
* Auth or Firestore outage. The owner list is then INCOMPLETE, not empty,
|
|
49
|
+
* and the caller must not report "this org has nobody" on the strength of
|
|
50
|
+
* it.
|
|
51
|
+
*/
|
|
52
|
+
unavailable: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** Whether ONE account would still be a way in without the org's IdP. */
|
|
55
|
+
export interface SsoBreakGlassVerdict {
|
|
56
|
+
/** The qualifying owner, or null when this account does not count. */
|
|
57
|
+
owner: SsoBreakGlassOwner | null;
|
|
58
|
+
/**
|
|
59
|
+
* A lookup failed for a reason that is not "no such user". `owner` is then
|
|
60
|
+
* null because we could not establish that it should be anything else —
|
|
61
|
+
* never because we established the account does not qualify. A caller must
|
|
62
|
+
* refuse on this, not treat it as a clean negative.
|
|
63
|
+
*/
|
|
64
|
+
unavailable: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The seven conditions, asked of one uid.
|
|
68
|
+
*
|
|
69
|
+
* Callers must distinguish the two answers that both carry a null owner. A
|
|
70
|
+
* null owner with `unavailable` false means "we checked, and no"; a null owner
|
|
71
|
+
* with `unavailable` true means "we could not check". Collapsing them is how a
|
|
72
|
+
* swallowed query starts rendering as a measured zero.
|
|
73
|
+
*
|
|
74
|
+
* @param uid - the account being judged. NOT checked for org membership here;
|
|
75
|
+
* both callers establish that first, by different routes (a roster query,
|
|
76
|
+
* and `transferOrgOwnership`'s own "must already be a member" transaction).
|
|
77
|
+
* @param providerId - the org's SAML provider id, so a project-pool record
|
|
78
|
+
* holding only that provider cannot qualify
|
|
79
|
+
* @param tenantId - the org's GCIP pool, checked for the collision in (4)
|
|
80
|
+
*/
|
|
81
|
+
export declare function qualifiesAsBreakGlassOwner(uid: string, providerId: string, tenantId: string): Promise<SsoBreakGlassVerdict>;
|
|
82
|
+
/**
|
|
83
|
+
* Org owners who hold a credential the org's IdP does not mediate.
|
|
84
|
+
*
|
|
85
|
+
* @param orgId - the organization being enforced
|
|
86
|
+
* @param providerId - the org's SAML provider id, so a project-pool record
|
|
87
|
+
* holding only that provider cannot qualify
|
|
88
|
+
* @param tenantId - the org's GCIP pool, checked for the collision in (4)
|
|
89
|
+
*/
|
|
90
|
+
export declare function findBreakGlassOrgOwners(orgId: string, providerId: string, tenantId: string): Promise<SsoBreakGlassOwnerLookup>;
|
|
91
|
+
export default findBreakGlassOrgOwners;
|
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
* Break-glass by way of an org owner who lives OUTSIDE the SSO pool
|
|
18
|
+
* (AGL-1888, option (a) — decided).
|
|
19
|
+
*
|
|
20
|
+
* ## Why this exists
|
|
21
|
+
*
|
|
22
|
+
* `assessSsoLockoutRisk` only ever looked inside the org's GCIP tenant, and
|
|
23
|
+
* nothing inside that tenant can hold a credential the IdP does not mediate:
|
|
24
|
+
* `provisionSsoPool` creates the pool with `emailSignInConfig.enabled: false`,
|
|
25
|
+
* `/api/orgs/members/password` refuses outright on `tenantId`, and a social
|
|
26
|
+
* login cannot be linked to a governed account at all. So the pre-flight was
|
|
27
|
+
* unsatisfiable and enforcement was unreachable for every org.
|
|
28
|
+
*
|
|
29
|
+
* The way out is the account the pre-flight was always described as wanting:
|
|
30
|
+
* *"at least one org owner holding a project-level credential"*. A
|
|
31
|
+
* project-pool account is not in the tenant, so the enforcement sweep cannot
|
|
32
|
+
* see it, let alone strip it — the same invisibility that made SSO users
|
|
33
|
+
* disappear from the staff console (AGL-1122) is what makes this work.
|
|
34
|
+
*
|
|
35
|
+
* ## The property being checked
|
|
36
|
+
*
|
|
37
|
+
* **Could this person still sign in if the org's IdP stopped answering?** A
|
|
38
|
+
* lapsed certificate, a deleted SAML application, a revoked provider config.
|
|
39
|
+
* Every condition below exists because failing it means the answer is no —
|
|
40
|
+
* or means we cannot establish that the answer is yes, which for a control
|
|
41
|
+
* whose failure mode is "a paying customer loses its console permanently" is
|
|
42
|
+
* the same thing.
|
|
43
|
+
*
|
|
44
|
+
* 1. **Role `owner`, not `admin`.** The narrow reading of the decision, and
|
|
45
|
+
* the strict one. An org whose only owner is an SSO account can promote a
|
|
46
|
+
* project-level member to owner — a self-serve action — so requiring the
|
|
47
|
+
* stronger role does not put anybody back in front of a support queue.
|
|
48
|
+
* 2. **Present in the PROJECT pool**, resolved there directly rather than
|
|
49
|
+
* through {@link findUserByUidAcrossPools}: the question is not "where
|
|
50
|
+
* does this uid live", it is "does a project-pool record exist", and a
|
|
51
|
+
* cross-pool resolver answering with the tenant record would silently
|
|
52
|
+
* turn a pooled account into a qualifying one.
|
|
53
|
+
* 3. **Not the emailless twin.** A cross-pool `signInWithCustomToken`
|
|
54
|
+
* manufactures a project-pool record with no address and no providers
|
|
55
|
+
* (AGL-1962/2005) — a record nobody can sign in as, and exactly the shape
|
|
56
|
+
* a bare existence check accepts. There is no separate test for it here:
|
|
57
|
+
* conditions (5) and (6) are each strictly stronger than
|
|
58
|
+
* {@link isIdentifiedUserRecord}, so calling that as well would be a
|
|
59
|
+
* guard no mutation can make fail — a control that reads as a check and
|
|
60
|
+
* is not one. The twin is rejected, and the spec names which condition
|
|
61
|
+
* does it.
|
|
62
|
+
* 4. **NOT also in the org's tenant pool.** A uid in both pools is that same
|
|
63
|
+
* collision seen from the other side, and it makes the identity ambiguous
|
|
64
|
+
* at the moment it matters most. Refused rather than guessed.
|
|
65
|
+
* 5. **Not disabled**, and **email verified**. The console refuses an
|
|
66
|
+
* unverified session before it reaches any org setting
|
|
67
|
+
* (`emailUnverifiedResponse`), so an unverified owner cannot turn
|
|
68
|
+
* enforcement back off — which is the entire job of a break-glass
|
|
69
|
+
* account. A credential that cannot be used is not a credential.
|
|
70
|
+
* 6. **Holds a provider that is not the org's IdP.** Same rule the in-pool
|
|
71
|
+
* assessment uses, applied to the project-pool record.
|
|
72
|
+
* 7. **Not on a domain the operator REQUIRES SSO for**
|
|
73
|
+
* ({@link evaluateSsoDomainPolicy}). This is the subtle one. A
|
|
74
|
+
* project-pool `@aglyn.com` password is a perfectly good credential right
|
|
75
|
+
* up until `AGLYN_SSO_DOMAIN_ENFORCEMENT` is switched on, at which point
|
|
76
|
+
* that identity is refused at sign-in and the break-glass account
|
|
77
|
+
* silently stops being one. `refused` is computed independently of the
|
|
78
|
+
* switch by design, so this reads the rule rather than today's setting.
|
|
79
|
+
*
|
|
80
|
+
* Note what is deliberately NOT required: that the credential be a password.
|
|
81
|
+
* A project-pool `google.com` link survives a lapsed SAML certificate just as
|
|
82
|
+
* a password does — the org's IdP does not mediate it. The residual is that a
|
|
83
|
+
* customer whose IdP and mailbox are the same vendor (Workspace SAML plus
|
|
84
|
+
* Workspace Google sign-in) has a correlated failure if the whole vendor
|
|
85
|
+
* account is deleted rather than the SAML app; that is a narrower failure
|
|
86
|
+
* than the one this control exists for, and the docs say so.
|
|
87
|
+
*
|
|
88
|
+
* ## Failing safe
|
|
89
|
+
*
|
|
90
|
+
* Every error path yields **no owners**, which makes the assessment unsafe
|
|
91
|
+
* and enforcement refuse. `unavailable` is carried alongside so the refusal
|
|
92
|
+
* can say "we could not check" instead of "you have nobody" — a swallowed
|
|
93
|
+
* query that renders as a measured zero is worse than an error, because
|
|
94
|
+
* nothing looks wrong.
|
|
95
|
+
*/ import { authForPool } from "./auth-pools.js";
|
|
96
|
+
import { evaluateSsoDomainPolicy } from "./sso-domain-policy.js";
|
|
97
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
98
|
+
/** Owners walked in one lookup. An org with more has other problems. */ const OWNER_SCAN_CAP = 25;
|
|
99
|
+
/** Firebase's "this pool does not have that uid" — a real negative. */ const NOT_FOUND = 'auth/user-not-found';
|
|
100
|
+
const codeOf = (error)=>{
|
|
101
|
+
var _ref;
|
|
102
|
+
return String((_ref = error == null ? void 0 : error.code) != null ? _ref : '');
|
|
103
|
+
};
|
|
104
|
+
const DOES_NOT_QUALIFY = {
|
|
105
|
+
owner: null,
|
|
106
|
+
unavailable: false
|
|
107
|
+
};
|
|
108
|
+
const COULD_NOT_CHECK = {
|
|
109
|
+
owner: null,
|
|
110
|
+
unavailable: true
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The seven conditions, asked of one uid.
|
|
114
|
+
*
|
|
115
|
+
* Callers must distinguish the two answers that both carry a null owner. A
|
|
116
|
+
* null owner with `unavailable` false means "we checked, and no"; a null owner
|
|
117
|
+
* with `unavailable` true means "we could not check". Collapsing them is how a
|
|
118
|
+
* swallowed query starts rendering as a measured zero.
|
|
119
|
+
*
|
|
120
|
+
* @param uid - the account being judged. NOT checked for org membership here;
|
|
121
|
+
* both callers establish that first, by different routes (a roster query,
|
|
122
|
+
* and `transferOrgOwnership`'s own "must already be a member" transaction).
|
|
123
|
+
* @param providerId - the org's SAML provider id, so a project-pool record
|
|
124
|
+
* holding only that provider cannot qualify
|
|
125
|
+
* @param tenantId - the org's GCIP pool, checked for the collision in (4)
|
|
126
|
+
*/ export async function qualifiesAsBreakGlassOwner(uid, providerId, tenantId) {
|
|
127
|
+
var _record_providerData, _record_email;
|
|
128
|
+
let record;
|
|
129
|
+
try {
|
|
130
|
+
// Written inline rather than through a hoisted `projectPool` const so
|
|
131
|
+
// the AGL-1122 guard can SEE that the receiver is pool-scoped: it
|
|
132
|
+
// matches on the receiver text, and a variable named `projectPool` is
|
|
133
|
+
// indistinguishable to it from a bare `auth().getUser`. A guard that
|
|
134
|
+
// cannot read a legitimate call is one somebody widens, so the call
|
|
135
|
+
// site moves instead of the guard.
|
|
136
|
+
record = await authForPool(null).getUser(uid);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// Not in the project pool is the ORDINARY answer here — it means this
|
|
139
|
+
// owner signs in through the IdP like everyone else. Anything else is
|
|
140
|
+
// an outage, and an outage must not read as a clean negative.
|
|
141
|
+
if (codeOf(error) !== NOT_FOUND) {
|
|
142
|
+
console.error(`[sso] project-pool lookup failed for ${uid}`, error);
|
|
143
|
+
return COULD_NOT_CHECK;
|
|
144
|
+
}
|
|
145
|
+
return DOES_NOT_QUALIFY;
|
|
146
|
+
}
|
|
147
|
+
// (5) Both halves. A disabled account is not a way in, and an unverified
|
|
148
|
+
// address cannot reach an org setting to undo enforcement.
|
|
149
|
+
//
|
|
150
|
+
// These two also subsume (3): the emailless cross-pool artifact has no
|
|
151
|
+
// address, so it never gets past the line below. That is why there is no
|
|
152
|
+
// `isIdentifiedUserRecord` call here — it could never be the condition
|
|
153
|
+
// that rejects anything, and a guard no mutation can break is not a
|
|
154
|
+
// guard.
|
|
155
|
+
if (record.disabled) return DOES_NOT_QUALIFY;
|
|
156
|
+
if (!record.email || !record.emailVerified) return DOES_NOT_QUALIFY;
|
|
157
|
+
// (6) The same rule the in-pool assessment applies. Also the second half
|
|
158
|
+
// of (3): a record with no providers at all cannot qualify.
|
|
159
|
+
const providers = ((_record_providerData = record.providerData) != null ? _record_providerData : []).map((info)=>info == null ? void 0 : info.providerId).filter((id)=>Boolean(id) && id !== providerId);
|
|
160
|
+
if (!providers.length) return DOES_NOT_QUALIFY;
|
|
161
|
+
// (7) A domain the operator requires SSO for. Read from the RULE, not
|
|
162
|
+
// from whether the switch happens to be on today.
|
|
163
|
+
if (evaluateSsoDomainPolicy({
|
|
164
|
+
email: record.email,
|
|
165
|
+
tenantId: null
|
|
166
|
+
}).refused) {
|
|
167
|
+
return DOES_NOT_QUALIFY;
|
|
168
|
+
}
|
|
169
|
+
// (4) Last, because it costs a call: the same uid in the org's own pool.
|
|
170
|
+
try {
|
|
171
|
+
await authForPool(tenantId).getUser(uid);
|
|
172
|
+
// It answered — this uid names an account in the pool as well, so the
|
|
173
|
+
// sweep will touch it and "which record is this owner" has no answer.
|
|
174
|
+
return DOES_NOT_QUALIFY;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (codeOf(error) !== NOT_FOUND) {
|
|
177
|
+
console.error(`[sso] tenant-pool lookup failed for ${uid}`, error);
|
|
178
|
+
return COULD_NOT_CHECK;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
owner: {
|
|
183
|
+
uid,
|
|
184
|
+
email: (_record_email = record.email) != null ? _record_email : null,
|
|
185
|
+
providers
|
|
186
|
+
},
|
|
187
|
+
unavailable: false
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Org owners who hold a credential the org's IdP does not mediate.
|
|
192
|
+
*
|
|
193
|
+
* @param orgId - the organization being enforced
|
|
194
|
+
* @param providerId - the org's SAML provider id, so a project-pool record
|
|
195
|
+
* holding only that provider cannot qualify
|
|
196
|
+
* @param tenantId - the org's GCIP pool, checked for the collision in (4)
|
|
197
|
+
*/ export async function findBreakGlassOrgOwners(orgId, providerId, tenantId) {
|
|
198
|
+
let ownerUids;
|
|
199
|
+
try {
|
|
200
|
+
const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('members').where('role', '==', 'owner').get();
|
|
201
|
+
ownerUids = snapshot.docs.slice(0, OWNER_SCAN_CAP).map((doc)=>doc.id);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.error(`[sso] owner roster read failed for ${orgId}`, error);
|
|
204
|
+
return {
|
|
205
|
+
owners: [],
|
|
206
|
+
unavailable: true
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const owners = [];
|
|
210
|
+
let unavailable = false;
|
|
211
|
+
for (const uid of ownerUids){
|
|
212
|
+
const verdict = await qualifiesAsBreakGlassOwner(uid, providerId, tenantId);
|
|
213
|
+
// Accumulated rather than returned early: one unreachable owner must not
|
|
214
|
+
// hide the qualifying one standing next to them, and it must not be
|
|
215
|
+
// forgotten either. An org is safe if ANY owner qualifies, and the caller
|
|
216
|
+
// still gets told the picture is incomplete.
|
|
217
|
+
if (verdict.unavailable) unavailable = true;
|
|
218
|
+
if (verdict.owner) owners.push(verdict.owner);
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
owners,
|
|
222
|
+
unavailable
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
export default findBreakGlassOrgOwners;
|
|
226
|
+
|
|
227
|
+
//# sourceMappingURL=sso-break-glass-owners.js.map
|