@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/lockdown.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 * Server half of the lockdown system (AGL-1501): the verdict every\n * enforcement chokepoint asks for. The pure shape/precedence logic lives in\n * `@aglyn/aglyn` `app-utils/lockdown.ts`; this module adds the Admin-SDK\n * reads and the two properties only the server can hold:\n *\n * **The un-panic invariant.** A verified `staff` claim bypasses EVERY scope,\n * unconditionally, before any read happens. A platform-wide lockdown must\n * never lock out the staff who can lift it — a panic button that panics its\n * own operator is worse than none. Nothing may be added to\n * `getLockdownVerdict` above the staff check.\n *\n * **Fail-open on infrastructure error.** If the `lockdowns/platform` read\n * throws (Firestore outage, emulator without the collection), the verdict is\n * \"not locked\": an infrastructure blip must not weld the whole platform\n * shut. The same posture as the sanctions gate on an absent geo signal\n * (AGL-1492) and the release-flag reader on a missing template.\n *\n * **…except for takedowns (AGL-1621).** One class of lock inverts that cost\n * function: a legal or abuse takedown must keep holding when Firestore is\n * unreachable, because \"we served it, our database was down\" answers no\n * court order. The class is an EXPLICIT stored field (`enforcement`), never\n * inferred from scope or reason, and absent means `standard` — so every\n * lock that exists today, and every lock an operator does not classify,\n * keeps failing open exactly as before. See the takedown ledger below for\n * how a classification survives the read that loses it.\n */\n\nimport {\n featureLockdownDocId,\n orgFeatureLockdownDocId,\n isLockdownActive,\n isTakedownLockdown,\n lockdownFeatureStaffBypass,\n type LockdownFeatureKey,\n LOCKDOWNS_COLLECTION,\n lockdownBlocks,\n type LockdownDoc,\n type LockdownIntent,\n lockdownIntentForMethod,\n type LockdownNotice,\n lockdownNotice,\n lockdownRetryAfterSeconds,\n type LockdownState,\n domainLockdownDocId,\n normalizeHostLockdown,\n normalizeLockdownDoc,\n normalizeOrgLockdown,\n PLATFORM_LOCKDOWN_DOC_ID,\n resolveLockdown,\n userLockdownDocId,\n} from '@aglyn/aglyn/server'\nimport { getApp } from 'firebase-admin/app'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * Platform-doc TTL. Short on purpose: this is the panic path, and 15s is the\n * worst-case lag between staff pressing the button and a warm server process\n * refusing its next request. Per-request reads would be ~1 Firestore read\n * per API call platform-wide, which AGL-1302 showed is real money at scale.\n */\nconst PLATFORM_TTL_MS = 15_000\n\n/**\n * ===================== THE TAKEDOWN LEDGER (AGL-1621) =====================\n *\n * Lockdown fails open, and that stays true for ordinary locks: an\n * unreachable Firestore is an outage, not a lockdown, and a blip must never\n * weld every customer site shut. But a legal or abuse TAKEDOWN has the\n * opposite cost function — \"we served the infringing content because our\n * database was down\" is not an answer to a court order. So takedown-class\n * locks (`enforcement: 'takedown'`) hold through a failed read, and\n * everything else keeps today's behaviour exactly.\n *\n * ## The ordering problem this exists to solve\n *\n * The classification is stored ON the lock document. When the read throws\n * there IS no document, so the class cannot be consulted at failure time —\n * the obvious implementation (\"if the lock is a takedown, fail closed\") is\n * unimplementable as written, because the failure is precisely the loss of\n * the thing it wants to branch on.\n *\n * The only honest source is what this process last SUCCESSFULLY read. So\n * every successful read records its verdict here: a takedown is remembered,\n * and anything else RETIRES the entry. A subsequent failed read consults\n * the ledger instead of falling to null.\n *\n * ## What this does and does not guarantee — stated, not implied\n *\n * HOLDS: a takedown that a process has already observed keeps being\n * enforced by that process for as long as Firestore is unreachable. That is\n * the real incident shape — a takedown placed hours or days ago, then a\n * Firestore blip — and it is what the decision asks for.\n *\n * DOES NOT HOLD: a COLD process that has never once read the takedown has\n * nothing to remember and fails open, like everything else. Closing that\n * gap needs a second carrier more available than Firestore (edge config or\n * similar), which is a different and much larger change; pretending\n * otherwise here would be worse than naming it. A takedown placed DURING a\n * total Firestore outage is likewise not enforceable — the write itself\n * cannot land.\n *\n * ## Two things it deliberately does not do\n *\n * It is NOT cleared by `invalidate*LockdownCache`. Those run after a staff\n * write, and clearing the ledger there would mean a lock-then-outage\n * sequence forgot the takedown at the exact moment it mattered. The next\n * successful read is what retires an entry, and a lift IS such a read.\n *\n * It does not outlive the lock's own expiry: a held takedown is still\n * checked against `isLockdownActive`, so a `untilMs` that has passed\n * releases on schedule even while Firestore is down. A dead-man expiry must\n * not become un-liftable by being classified.\n */\nconst takedownLedger = new Map<string, LockdownState>()\n\n/**\n * Bounded like the user and domain caches, and for the same reason: two of\n * the four keyed readers take their key from caller-controlled input.\n * Eviction is least-recently-USED.\n */\nexport const TAKEDOWN_LEDGER_MAX = 5000\n\n/**\n * Record what a SUCCESSFUL read saw. A takedown is remembered; anything\n * else — no lock, an ordinary lock, or a lock whose class this build cannot\n * interpret — retires the entry, which is how a lift takes effect.\n */\nfunction rememberTakedown(key: string, state: LockdownState | null): void {\n if (!state || !isTakedownLockdown(state)) {\n takedownLedger.delete(key)\n return\n }\n // Re-insert to refresh recency before the LRU trim below.\n takedownLedger.delete(key)\n takedownLedger.set(key, state)\n while (takedownLedger.size > TAKEDOWN_LEDGER_MAX) {\n const oldest = takedownLedger.keys().next().value\n if (oldest === undefined) break\n takedownLedger.delete(oldest)\n }\n}\n\n/**\n * What a FAILED read should serve: a remembered, still-active takedown, or\n * null — which is the shipped fail-open answer and therefore the default\n * for every lock that is not an observed takedown.\n */\nfunction heldTakedown(key: string, nowMs: number): LockdownState | null {\n const state = takedownLedger.get(key)\n if (!state) return null\n if (!isLockdownActive(state, nowMs)) {\n takedownLedger.delete(key)\n return null\n }\n return state\n}\n\n/**\n * Forget every remembered takedown. NOT part of the panic path and not\n * called by the admin route — this exists for process-boundary and test\n * setup, where a ledger surviving between cases would make one test's lock\n * another test's fail-closed verdict.\n */\nexport function resetTakedownLedger(): void {\n takedownLedger.clear()\n}\n\n/** Ledger keys. Disjoint by construction — the doc-id prefixes already are. */\nconst PLATFORM_LEDGER_KEY = 'platform'\nconst featureLedgerKey = (feature: LockdownFeatureKey): string =>\n featureLockdownDocId(feature)\nconst userLedgerKey = (uid: string): string => userLockdownDocId(uid)\nconst domainLedgerKey = (hostname: string): string =>\n domainLockdownDocId(hostname)\n/**\n * ORG and HOST scope (AGL-1881). These two locks have no document in\n * `lockdowns/*` — they live as the `suspended*` family ON the org and host\n * documents — so they never passed through a ledgered reader, and\n * `getSiteLockdown` answered `null` flat when its reads threw. The route\n * persists `suspendedEnforcement: 'takedown'` for both scopes all the same,\n * from a control that promises the lock \"keeps holding if Aglyn cannot reach\n * the database\". A court-ordered host takedown plus a partial Firestore\n * outage therefore served the infringing site for the duration of the\n * outage, which is the precise failure the mode exists to prevent.\n *\n * Keyed by HOST ID, both of them, including the org one. Every reader on\n * this path is host-addressed, and on a failed read the org id is exactly as\n * unreadable as the lock — `getOrgForHost` is the read that just threw. So\n * \"the org lock as seen through this host\" is the only thing a failed read\n * can look up, and it matches the ledger's existing warm-process semantics:\n * an org takedown holds for each site a process has already read once.\n *\n * Distinct prefixes keep them disjoint from the four doc-id keys above\n * (`platform`, `feature--`, `user--`, `domain--`).\n */\nconst orgForHostLedgerKey = (hostId: string): string =>\n `org--for-host--${hostId}`\nconst hostLedgerKey = (hostId: string): string => `host--${hostId}`\n\n/**\n * Record what a SUCCESSFUL site read saw, per scope. Same contract as the\n * four `lockdowns/*` readers: a takedown is remembered, and anything else —\n * no lock, an ordinary lock, a takedown DOWNGRADED to one — retires the\n * entry, which is how a lift takes effect.\n */\nexport function rememberSiteTakedown(\n hostId: string,\n states: { org: LockdownState | null; host: LockdownState | null },\n): void {\n rememberTakedown(orgForHostLedgerKey(hostId), states.org)\n rememberTakedown(hostLedgerKey(hostId), states.host)\n}\n\n/**\n * What a FAILED site read may still serve: the remembered, still-active\n * takedowns at org and host scope, or nulls — which is the shipped fail-open\n * answer and therefore the default for every lock that is not an observed\n * takedown.\n */\nexport function heldSiteTakedown(\n hostId: string,\n nowMs: number,\n): { org: LockdownState | null; host: LockdownState | null } {\n return {\n org: heldTakedown(orgForHostLedgerKey(hostId), nowMs),\n host: heldTakedown(hostLedgerKey(hostId), nowMs),\n }\n}\n\nlet platformCache: { at: number; state: LockdownState | null } | undefined\nlet platformPending: Promise<LockdownState | null> | undefined\n\n/**\n * Drop the in-process platform cache. Called by /api/admin/lockdown after a\n * platform write so the process that took the action serves fresh verdicts\n * immediately; other processes converge within PLATFORM_TTL_MS.\n */\nexport function invalidatePlatformLockdownCache(): void {\n platformCache = undefined\n platformPending = undefined\n}\n\n/** `lockdowns/platform`, normalized; null = not locked (incl. on error). */\nexport async function getPlatformLockdown(): Promise<LockdownState | null> {\n if (platformCache && Date.now() - platformCache.at < PLATFORM_TTL_MS) {\n return platformCache.state\n }\n if (!platformPending) {\n platformPending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(PLATFORM_LOCKDOWN_DOC_ID)\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(\n snapshot.data() as Partial<LockdownDoc>,\n 'platform',\n )\n : null\n rememberTakedown(PLATFORM_LEDGER_KEY, state)\n } catch {\n // Fail open: an unreachable Firestore is an outage, not a lockdown\n // — UNLESS this process has already seen a takedown here, which is\n // the one class that must survive the read failing (AGL-1621).\n state = heldTakedown(PLATFORM_LEDGER_KEY, Date.now())\n }\n platformCache = { at: Date.now(), state }\n return state\n })().finally(() => {\n platformPending = undefined\n })\n }\n return platformPending\n}\n\n/**\n * Feature-doc cache (AGL-1510): same TTL and the same reasoning as the\n * platform doc — feature gates sit on hot chokepoints (the session mint,\n * every media ingress, the plugin dispatcher), and 15s is the worst-case\n * lag between staff flipping a feature switch and a warm process enforcing\n * it. One map for all keys; the admin route invalidates after every write.\n */\n/**\n * Keyed by DOCUMENT ID rather than by feature key since AGL-2927, so the\n * platform-wide `feature--{key}` entry and each workspace's\n * `feature--{key}--org--{orgId}` entry share one map, one TTL and one\n * invalidation — the admin route's write clears both kinds at once.\n */\nconst featureCache = new Map<string, { at: number; state: LockdownState | null }>()\nconst featurePending = new Map<string, Promise<LockdownState | null>>()\n\n/** Drop the in-process feature cache (all keys) after an admin write. */\nexport function invalidateFeatureLockdownCache(): void {\n featureCache.clear()\n featurePending.clear()\n}\n\n/** One feature-scope carrier, by document id — see `getFeatureLockdown`. */\nasync function readFeatureLockdownDoc(docId: string): Promise<LockdownState | null> {\n const cached = featureCache.get(docId)\n if (cached && Date.now() - cached.at < PLATFORM_TTL_MS) return cached.state\n let pending = featurePending.get(docId)\n if (!pending) {\n pending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(docId)\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(\n snapshot.data() as Partial<LockdownDoc>,\n 'feature',\n )\n : null\n rememberTakedown(docId, state)\n } catch {\n // Fail open, matching the platform read: an unreachable Firestore is\n // an outage, not a feature lockdown. A takedown-class feature lock\n // this process has already observed still holds (AGL-1621).\n state = heldTakedown(docId, Date.now())\n }\n featureCache.set(docId, { at: Date.now(), state })\n return state\n })().finally(() => {\n featurePending.delete(docId)\n })\n featurePending.set(docId, pending)\n }\n return pending\n}\n\n/** `lockdowns/feature--{key}`, normalized; null = not locked (incl. error). */\nexport async function getFeatureLockdown(\n feature: LockdownFeatureKey,\n): Promise<LockdownState | null> {\n return readFeatureLockdownDoc(featureLedgerKey(feature))\n}\n\n/**\n * `lockdowns/feature--{key}--org--{orgId}` (AGL-2927): the same capability\n * paused for ONE workspace. Same cache, same TTL, same fail-open posture as\n * the platform-wide document; null = not paused for that org.\n */\nexport async function getOrgFeatureLockdown(\n feature: LockdownFeatureKey,\n orgId: string,\n): Promise<LockdownState | null> {\n return readFeatureLockdownDoc(orgFeatureLockdownDocId(feature, orgId))\n}\n\n/**\n * The feature verdict and refusal in one call (AGL-1510) — the one-line\n * wiring a feature chokepoint carries:\n *\n * const locked = await featureLockdownRefusal({ feature: 'checkout', staff })\n * if (locked) return locked\n *\n * Composition, not ranking: a PLATFORM lock implies every feature, so it is\n * checked first (TTL-cached — routes that already ran the scope verdict pay\n * no extra read), and the platform-scope staff bypass there is UNCHANGED\n * and unconditional. The feature doc is checked second, and its staff\n * bypass is per-feature (`lockdownFeatureStaffBypass`): granted where a\n * staff action aids incident response (uploads, installs, ai-assist),\n * withheld where it would BE the incident (checkout — a staff checkout\n * session is still a real charge). A feature lock implies nothing about the\n * org/host/user scopes — those stay with `lockdownRefusal` at the routes\n * that carry it.\n *\n * A door that names the org it serves passes `orgId` (AGL-2927), and the\n * workspace-scoped carrier for the same feature is checked third, under the\n * same per-feature staff bypass: the staff AI pause is a spend stop on one\n * customer, and a staff call made to verify the pause is ours to spend. A\n * door that names no org — the plugin dispatcher, which gates by path\n * before the body is read — sees the platform-wide document only, which is\n * what it saw before.\n */\nexport async function featureLockdownRefusal(options: {\n feature: LockdownFeatureKey\n /** Verified `staff` custom claim — from a VERIFIED token only. */\n staff?: boolean\n /** The workspace the request is metered against, where the door knows it. */\n orgId?: string | null\n nowMs?: number\n}): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const platform = await getPlatformLockdown()\n if (isLockdownActive(platform, nowMs)) {\n // The un-panic invariant at the platform scope, unchanged.\n if (options.staff === true) return null\n return lockdownJsonResponse(platform as LockdownState)\n }\n const bypass =\n options.staff === true && lockdownFeatureStaffBypass(options.feature)\n const state = await getFeatureLockdown(options.feature)\n if (isLockdownActive(state, nowMs)) {\n return bypass ? null : lockdownJsonResponse(state as LockdownState)\n }\n const orgId = options.orgId?.trim()\n if (!orgId) return null\n const paused = await getOrgFeatureLockdown(options.feature, orgId)\n if (!isLockdownActive(paused, nowMs)) return null\n return bypass ? null : lockdownJsonResponse(paused as LockdownState)\n}\n\n/**\n * User-doc cache (AGL-1522): the last per-call read on the verdict path.\n * AGL-1506 wired the verdict into ~36 routes plus the session mint/exchange,\n * and every call carrying a `uid` paid one Firestore get on\n * `lockdowns/user--{uid}` — an active console editor generated one read per\n * wired mutation, against the AGL-1302 read budget. Same TTL, same pending\n * dedupe, same fail-open posture as the platform and feature caches above —\n * ONE cache pattern in this module, not three implementations.\n *\n * The staleness tradeoff, stated: a user locked mid-window keeps passing\n * this check for up to PLATFORM_TTL_MS (15s) plus the caller's polling\n * cadence — on every process EXCEPT the one that took the action, which\n * refuses immediately because /api/admin/lockdown invalidates this uid's\n * entry after the write (the platform/feature invalidation hook, mirrored).\n * That bound is acceptable here for the same reason `media/sign` omits the\n * user scope entirely: the hard kill never rode this read. A user lock also\n * disables the Auth account and revokes refresh tokens, so the ID token\n * stops refreshing and the session cookie dies at its next\n * `verifySessionCookie(…, true)` — the cache can only delay the DISTINCT\n * 423 body, not access itself, and 15s matches the platform scope's stated\n * worst case and the drill's measured flip times.\n *\n * Bounded so a scan of uids cannot balloon a warm process: past\n * USER_LOCKDOWN_CACHE_MAX entries the least-recently-USED is evicted (a\n * cache hit refreshes recency, so active sessions survive a scan).\n */\nexport const USER_LOCKDOWN_CACHE_MAX = 5000\n\nconst userCache = new Map<\n string,\n { at: number; state: LockdownState | null }\n>()\nconst userPending = new Map<string, Promise<LockdownState | null>>()\n\n/**\n * Drop one uid's cached verdict (after a user lock/unlock write), or the\n * whole cache when called bare. The acting process serves a fresh verdict\n * immediately; other processes converge within PLATFORM_TTL_MS.\n */\nexport function invalidateUserLockdownCache(uid?: string): void {\n if (uid === undefined) {\n userCache.clear()\n userPending.clear()\n return\n }\n userCache.delete(uid)\n userPending.delete(uid)\n}\n\n/** `lockdowns/user--{uid}`, normalized; null = not locked (incl. on error). */\nexport async function getUserLockdown(\n uid: string,\n): Promise<LockdownState | null> {\n const cached = userCache.get(uid)\n if (cached && Date.now() - cached.at < PLATFORM_TTL_MS) {\n // Re-insert to refresh recency: eviction is by least-recently-used, and\n // an ACTIVE session must not be the one a uid scan pushes out.\n userCache.delete(uid)\n userCache.set(uid, cached)\n return cached.state\n }\n let pending = userPending.get(uid)\n if (!pending) {\n pending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(userLockdownDocId(uid))\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(snapshot.data() as Partial<LockdownDoc>, 'user')\n : null\n rememberTakedown(userLedgerKey(uid), state)\n } catch {\n // Fail open, matching the platform and feature reads: an unreachable\n // Firestore is an outage, not a lockdown. An observed takedown-class\n // user lock holds through it (AGL-1621).\n state = heldTakedown(userLedgerKey(uid), Date.now())\n }\n userCache.delete(uid)\n userCache.set(uid, { at: Date.now(), state })\n while (userCache.size > USER_LOCKDOWN_CACHE_MAX) {\n const oldest = userCache.keys().next().value\n if (oldest === undefined) break\n userCache.delete(oldest)\n }\n return state\n })().finally(() => {\n userPending.delete(uid)\n })\n userPending.set(uid, pending)\n }\n return pending\n}\n\n/**\n * DOMAIN-lock cache (AGL-1513), same shape and same TTL as the user cache.\n *\n * Bounded for the same reason and one more: the key is a HOSTNAME taken from\n * the request, so an unbounded map here would be attacker-keyed by anyone who\n * can point a DNS record at us — which, for the hijack incident this scope\n * exists for, is the adversary by definition.\n */\nexport const DOMAIN_LOCKDOWN_CACHE_MAX = 5000\n\nconst domainCache = new Map<\n string,\n { at: number; state: LockdownState | null }\n>()\nconst domainPending = new Map<string, Promise<LockdownState | null>>()\n\n/**\n * Drop one hostname's cached verdict (after a domain lock/unlock write), or\n * the whole cache when called bare.\n */\nexport function invalidateDomainLockdownCache(hostname?: string): void {\n if (hostname === undefined) {\n domainCache.clear()\n domainPending.clear()\n return\n }\n const key = hostname.trim().toLowerCase()\n domainCache.delete(key)\n domainPending.delete(key)\n}\n\n/**\n * `lockdowns/domain--{hostname}`, normalized; null = not locked (incl. on\n * error), matching every other reader here — an unreachable Firestore is an\n * outage, not a lockdown.\n */\nexport async function getDomainLockdown(\n hostname: string,\n): Promise<LockdownState | null> {\n const key = String(hostname ?? '')\n .trim()\n .toLowerCase()\n if (!key) return null\n const cached = domainCache.get(key)\n if (cached && Date.now() - cached.at < PLATFORM_TTL_MS) {\n // Re-insert to refresh recency, like the user cache: eviction is\n // least-recently-USED, and a domain under active dispute is exactly the\n // one a scan must not push out.\n domainCache.delete(key)\n domainCache.set(key, cached)\n return cached.state\n }\n let pending = domainPending.get(key)\n if (!pending) {\n pending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(domainLockdownDocId(key))\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(\n snapshot.data() as Partial<LockdownDoc>,\n 'domain',\n )\n : null\n rememberTakedown(domainLedgerKey(key), state)\n } catch {\n // Fail open, as above — and, as above, an observed takedown holds.\n // This is the scope where that matters most: AGL-1513's hijack and\n // dispute incidents are takedowns almost by definition.\n state = heldTakedown(domainLedgerKey(key), Date.now())\n }\n domainCache.delete(key)\n domainCache.set(key, { at: Date.now(), state })\n while (domainCache.size > DOMAIN_LOCKDOWN_CACHE_MAX) {\n const oldest = domainCache.keys().next().value\n if (oldest === undefined) break\n domainCache.delete(oldest)\n }\n return state\n })().finally(() => {\n domainPending.delete(key)\n })\n domainPending.set(key, pending)\n }\n return pending\n}\n\nexport interface LockdownVerdictOptions {\n /**\n * The verified `staff` custom claim of the caller — from a VERIFIED token\n * only, never from a header or body. True bypasses every scope: this is\n * the un-panic invariant.\n */\n staff?: boolean\n /** Verified caller uid; enables the user scope. */\n uid?: string | null\n /**\n * Already-loaded org doc (or its suspension fields), when the caller has\n * one in hand — org scope costs no extra read. Absent = scope not\n * evaluated.\n */\n org?: Parameters<typeof normalizeOrgLockdown>[0]\n /** Already-loaded host doc; same contract as `org`. */\n host?: Parameters<typeof normalizeHostLockdown>[0]\n /**\n * READ-ONLY discrimination (AGL-1511). A `read-only` lock refuses writes\n * and passes reads; a `full` lock refuses both, so this changes nothing\n * for every lock written before the mode existed.\n *\n * The request the chokepoint is answering — its METHOD decides. Passing\n * the route's own `request` is the wiring, because a single console\n * handler is usually exported as both GET and POST and the verdict runs\n * before the method branch: derive the intent, never restate it.\n */\n request?: { method?: string } | null\n /**\n * Explicit intent, overriding `request`. For the routes whose method lies\n * about what they do — a POST that is really a query (`where-used`,\n * `plugin-impact`), or a GET that mutates.\n *\n * Neither given = `write`, the fail-safe: a chokepoint that never declared\n * its intent refuses during a migration rather than letting an\n * unconsidered write race the repair.\n */\n intent?: LockdownIntent\n nowMs?: number\n}\n\n/** The declared or derived intent of a verdict request; `write` by default. */\nfunction verdictIntent(options: LockdownVerdictOptions): LockdownIntent {\n if (options.intent) return options.intent\n if (options.request) return lockdownIntentForMethod(options.request.method)\n return 'write'\n}\n\n/**\n * The one verdict: the lockdown that REFUSES this caller's request, or null.\n * Precedence platform > org > host > user, strictness before width (from\n * `resolveLockdown`).\n *\n * \"Refuses this request\" rather than \"covers this caller\" since AGL-1511: a\n * read-only lock is active but does not refuse a read, and returning it\n * anyway would make every wired chokepoint 423 a GET — a read-only mode that\n * behaves exactly like a full one. The active state is still what\n * `resolveLockdown` chose, so the staff probe and the notice copy see the\n * real lock; only the ANSWER is intent-aware.\n */\nexport async function getLockdownVerdict(\n options: LockdownVerdictOptions,\n): Promise<LockdownState | null> {\n // THE UN-PANIC INVARIANT. Staff are never locked out, by any scope, ever\n // — they are the only ones who can lift a lockdown. Keep this line first:\n // everything below it may read Firestore, and a staff verdict must not\n // depend on any read succeeding.\n //\n // This line is also the whole of AGL-1511's \"staff writes bypass\n // read-only\": staff bypass every scope AND every mode, unconditionally,\n // and read-only exists precisely so staff can work while the world reads.\n // A mode-aware staff rule below would be a second bypass to keep correct.\n if (options.staff === true) return null\n\n const nowMs = options.nowMs ?? Date.now()\n const [platform, user] = await Promise.all([\n getPlatformLockdown(),\n options.uid ? getUserLockdown(options.uid) : Promise.resolve(null),\n ])\n const state = resolveLockdown(\n {\n platform,\n org: normalizeOrgLockdown(options.org),\n host: normalizeHostLockdown(options.host),\n user,\n },\n nowMs,\n )\n return lockdownBlocks(state, verdictIntent(options)) ? state : null\n}\n\n/**\n * The refusal an API route returns for a locked caller: **423 Locked** with\n * a machine-readable body, so an API consumer sees \"suspended: billing\" and\n * not a mystery 403. Carries only the sanitized, user-facing subset — never\n * the actor uid or staff rationale.\n */\n/**\n * Verdict and refusal in one call — the two-line wiring every org-scoped\n * API route carries (AGL-1506):\n *\n * const locked = await lockdownRefusal({ staff, uid, org, host })\n * if (locked) return locked\n *\n * Null means \"not locked, proceed\". One mechanism: this delegates to\n * `getLockdownVerdict` (so the un-panic invariant and fail-open posture are\n * inherited, never re-implemented) and to `lockdownJsonResponse` for the\n * distinct 423 body. Routes with a richer flow (the session mint clears\n * cookies on refusal) keep calling the two halves directly.\n */\nexport async function lockdownRefusal(\n options: LockdownVerdictOptions & {\n /** Visitor-facing copy override; see `lockdownJsonResponse`. */\n notice?: LockdownNotice\n },\n): Promise<Response | null> {\n const state = await getLockdownVerdict(options)\n return state ? lockdownJsonResponse(state, { notice: options.notice }) : null\n}\n\nexport function lockdownJsonResponse(\n state: LockdownState,\n options?: {\n /**\n * Substitute copy for a surface whose reader is not the account holder\n * — the tenant runtime's visitor-facing pause (AGL-1511). ONE wire\n * shape and ONE writer either way: only the words change, so\n * `parseLockdownRefusal` reads a paused checkout exactly as it reads a\n * locked console.\n */\n notice?: LockdownNotice\n },\n): Response {\n const notice = options?.notice ?? lockdownNotice(state)\n const retryAfter = lockdownRetryAfterSeconds(state, Date.now())\n return Response.json(\n {\n error: 'locked',\n scope: state.scope,\n // The strictness, on the wire (AGL-1511): a client that can tell\n // \"paused\" from \"down\" renders the difference; one that cannot still\n // has the title and message, which already say it in words.\n ...(state.mode ? { mode: state.mode } : {}),\n // Distinct per-feature body (AGL-1510): an API consumer sees WHICH\n // capability is off (\"uploads\"), not a generic locked platform.\n ...(state.feature ? { feature: state.feature } : {}),\n reason: state.reason,\n title: notice.title,\n message: notice.body,\n ...(notice.contact ? { contact: notice.contact } : {}),\n ...(typeof state.untilMs === 'number' ? { untilMs: state.untilMs } : {}),\n },\n {\n status: 423,\n headers: {\n 'Cache-Control': 'no-store',\n ...(retryAfter ? { 'Retry-After': String(retryAfter) } : {}),\n },\n },\n )\n}\n\n/**\n * IS THE CREATION-LEVEL VALVE ACTUALLY ARMED? (AGL-1531)\n *\n * The signups feature lock refuses the session mint, the acceptance recorder\n * and the signup-page doors from code that ships with every deploy. It also\n * refuses account CREATION — but only through a Firebase Auth\n * `beforeUserCreated` blocking function in `cloud/functions`, which merging\n * does not deploy and deploying does not necessarily register. Identity\n * Platform holds that registration, not this repo.\n *\n * So the staff page had a way to be confidently wrong: the switch reads\n * LOCKED either way, and an operator during a bot wave would have no way to\n * tell \"creation is refused\" from \"creation is still wide open and only\n * sessions are being turned away\". This is the read that closes that gap —\n * the panic surface states which of the two it is instead of implying the\n * stronger one.\n *\n * `unknown` is a first-class answer and is NEVER rendered as armed. The\n * probe needs a credential with the identitytoolkit scope and an outbound\n * call; a deployment where either is missing must report that it does not\n * know, because \"we could not check\" and \"it is on\" are the two answers an\n * incident cannot afford to have confused.\n */\n/**\n * A STRING discriminant, not a `boolean | null`. `strictNullChecks` is off\n * repo-wide, so `null` is assignable to every member and a `armed: null` arm\n * cannot narrow — the compiler collapses the union and the \"unknown\" case\n * silently stops being distinguishable from the other two. Which is the one\n * distinction this type exists to hold.\n */\nexport type SignupsCreationTriggerStatus =\n | { status: 'armed'; functionUri: string | null; updateTime: string | null }\n | { status: 'absent' }\n | { status: 'unknown'; reason: string }\n\n/** Bounded: the panic page must render even when this call is the slow one. */\nconst IDENTITY_CONFIG_TIMEOUT_MS = 4_000\n\nexport async function readSignupsCreationTriggerStatus(): Promise<SignupsCreationTriggerStatus> {\n let token: string | undefined\n let projectId: string | undefined\n try {\n // `getApp()`, not `firebaseAdmin.app()`: the local shim narrows the app\n // to its four accessors and drops `options`, which is where the\n // credential and the project id live. Same acquisition path as the\n // error-beacon target (`client-error-report.ts`).\n const app = getApp()\n projectId = (app.options.projectId ??\n process.env['NEXT_PUBLIC_FIREBASE_PROJECT_ID']) as string | undefined\n token = (await app.options.credential?.getAccessToken())?.access_token\n } catch {\n token = undefined\n }\n if (!token || !projectId) {\n return {\n status: 'unknown',\n reason:\n 'No admin credential or project id on this deployment, so the ' +\n 'Identity Platform config could not be read.',\n }\n }\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), IDENTITY_CONFIG_TIMEOUT_MS)\n try {\n const response = await fetch(\n `https://identitytoolkit.googleapis.com/admin/v2/projects/${encodeURIComponent(projectId)}/config`,\n {\n headers: { Authorization: `Bearer ${token}` },\n signal: controller.signal,\n },\n )\n if (!response.ok) {\n return {\n status: 'unknown',\n reason: `Identity Platform config read returned ${response.status}.`,\n }\n }\n const payload = (await response.json()) as {\n blockingFunctions?: {\n triggers?: Record<\n string,\n { functionUri?: string; updateTime?: string } | undefined\n >\n }\n }\n // The trigger key Identity Platform uses is `beforeCreate`; the Firebase\n // SDK's `beforeUserCreated` is what registers it. Absent means the valve\n // is not deployed OR was deployed and never registered — indistinguishable\n // from here, and both mean the same thing to an operator: creation is not\n // being refused.\n const trigger = payload.blockingFunctions?.triggers?.['beforeCreate']\n if (!trigger) return { status: 'absent' }\n return {\n status: 'armed',\n functionUri: trigger.functionUri ?? null,\n updateTime: trigger.updateTime ?? null,\n }\n } catch (error: any) {\n return {\n status: 'unknown',\n reason:\n error?.name === 'AbortError'\n ? 'Identity Platform config read timed out.'\n : 'Identity Platform config could not be read.',\n }\n } finally {\n clearTimeout(timer)\n }\n}\n"],"names":["featureLockdownDocId","orgFeatureLockdownDocId","isLockdownActive","isTakedownLockdown","lockdownFeatureStaffBypass","LOCKDOWNS_COLLECTION","lockdownBlocks","lockdownIntentForMethod","lockdownNotice","lockdownRetryAfterSeconds","domainLockdownDocId","normalizeHostLockdown","normalizeLockdownDoc","normalizeOrgLockdown","PLATFORM_LOCKDOWN_DOC_ID","resolveLockdown","userLockdownDocId","getApp","firebaseAdmin","PLATFORM_TTL_MS","takedownLedger","Map","TAKEDOWN_LEDGER_MAX","rememberTakedown","key","state","delete","set","size","oldest","keys","next","value","undefined","heldTakedown","nowMs","get","resetTakedownLedger","clear","PLATFORM_LEDGER_KEY","featureLedgerKey","feature","userLedgerKey","uid","domainLedgerKey","hostname","orgForHostLedgerKey","hostId","hostLedgerKey","rememberSiteTakedown","states","org","host","heldSiteTakedown","platformCache","platformPending","invalidatePlatformLockdownCache","getPlatformLockdown","Date","now","at","snapshot","app","firestore","collection","doc","exists","data","finally","featureCache","featurePending","invalidateFeatureLockdownCache","readFeatureLockdownDoc","docId","cached","pending","getFeatureLockdown","getOrgFeatureLockdown","orgId","featureLockdownRefusal","options","platform","staff","lockdownJsonResponse","bypass","trim","paused","USER_LOCKDOWN_CACHE_MAX","userCache","userPending","invalidateUserLockdownCache","getUserLockdown","DOMAIN_LOCKDOWN_CACHE_MAX","domainCache","domainPending","invalidateDomainLockdownCache","toLowerCase","getDomainLockdown","String","verdictIntent","intent","request","method","getLockdownVerdict","user","Promise","all","resolve","lockdownRefusal","notice","retryAfter","Response","json","error","scope","mode","reason","title","message","body","contact","untilMs","status","headers","IDENTITY_CONFIG_TIMEOUT_MS","readSignupsCreationTriggerStatus","token","projectId","process","env","credential","getAccessToken","access_token","controller","AbortController","timer","setTimeout","abort","trigger","payload","response","fetch","encodeURIComponent","Authorization","signal","ok","blockingFunctions","triggers","functionUri","updateTime","name","clearTimeout"],"mappings":"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"}
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
import { type ClientIpHeaders } from '@aglyn/aglyn/app-utils/request-ip';
|
|
18
|
+
/**
|
|
19
|
+
* The media CDN's per-caller limit (AGL-2812).
|
|
20
|
+
*
|
|
21
|
+
* `/api/media/cdn/*` is anonymous public delivery, and nothing bounded how
|
|
22
|
+
* much of it one caller could take. The firewall bypasses the route on
|
|
23
|
+
* purpose, because link-preview crawlers and Gmail's image proxy cannot solve
|
|
24
|
+
* a challenge ("Public asset delivery bypass" in
|
|
25
|
+
* `tools/scripts/lib/firewall-posture.mjs`). Both middlewares exclude `/api/*`,
|
|
26
|
+
* and no WAF rule covers the path. `serveMediaCdn` is the one piece of code
|
|
27
|
+
* every request passes through, so the limit lives there.
|
|
28
|
+
*
|
|
29
|
+
* ## What is counted
|
|
30
|
+
*
|
|
31
|
+
* A GET that gets past every access gate, the lockdown and quarantine
|
|
32
|
+
* refusals and the 304 exit, which is where the Storage reads begin. Those
|
|
33
|
+
* refusals, a 304 and a HEAD send no file, so a revalidating browser, a
|
|
34
|
+
* crawler's HEAD and a probe for a deleted id spend nothing. The counter's
|
|
35
|
+
* Firestore write is paid only where the delivery it guards is about to cost
|
|
36
|
+
* more. The count starts beside the metadata read, so a request that then
|
|
37
|
+
* finds no object in Storage, or asks for an unsatisfiable range, has been
|
|
38
|
+
* counted too.
|
|
39
|
+
*
|
|
40
|
+
* ## Two budgets, split where the edge splits them
|
|
41
|
+
*
|
|
42
|
+
* Images and everything else are counted separately, on the line
|
|
43
|
+
* `mediaCdnEdgeCacheable` already draws (AGL-1515):
|
|
44
|
+
*
|
|
45
|
+
* - **Images** are held by the edge, so only a miss reaches the function, and
|
|
46
|
+
* images are what link-preview crawlers and Gmail's image proxy fetch.
|
|
47
|
+
* - **Everything else** (video, audio, PDFs, documents) is `private`, so every
|
|
48
|
+
* request reaches the function, and it is where the bytes are: a video
|
|
49
|
+
* master is a single request for up to 200 MB.
|
|
50
|
+
*
|
|
51
|
+
* Separate keys also mean a DAM grid's burst of tiles cannot spend the budget
|
|
52
|
+
* the video on the same page needs, or the reverse.
|
|
53
|
+
*
|
|
54
|
+
* ## Thresholds, from measured traffic
|
|
55
|
+
*
|
|
56
|
+
* Read from both mounts' production request logs on 2026-09-11: the tenant's
|
|
57
|
+
* last seven days (2026-09-04 08:01 to 2026-09-11 07:56 UTC), and the
|
|
58
|
+
* console's last sixteen hours, which is all it retains. Only requests that
|
|
59
|
+
* reached the function count, since an edge hit never runs this code.
|
|
60
|
+
*
|
|
61
|
+
* | measured | busiest minute, whole route |
|
|
62
|
+
* | -- | -- |
|
|
63
|
+
* | image deliveries (200 and 206) | 98 |
|
|
64
|
+
* | every other delivery (200 and 206) | 31 |
|
|
65
|
+
* | every request, with 304s, 404s and edge hits | 236 |
|
|
66
|
+
* | link-preview crawlers and other bots, together | 32 |
|
|
67
|
+
* | Gmail's image proxy | none in the sample |
|
|
68
|
+
*
|
|
69
|
+
* Each of those minutes was one browser: the image peak was a single Chrome
|
|
70
|
+
* session pulling 129 distinct assets, and the non-image peak a single session
|
|
71
|
+
* following 31 `?download=1` links. No asset took more than 4 range requests
|
|
72
|
+
* in a minute. Over thirty days the day documents count 30,855 origin serves
|
|
73
|
+
* in all, and 4,234 on the busiest day.
|
|
74
|
+
*
|
|
75
|
+
* Each ceiling is at least five times the busiest minute the whole route
|
|
76
|
+
* served in its class. No single address can reach it with a pattern the route
|
|
77
|
+
* has actually seen, even if every request in that minute had been its own:
|
|
78
|
+
*
|
|
79
|
+
* - {@link MEDIA_CDN_IMAGE_RATE_LIMIT}, 600: 6.1 times the image peak, and more
|
|
80
|
+
* than 18 times every crawler together.
|
|
81
|
+
* - {@link MEDIA_CDN_NON_IMAGE_RATE_LIMIT}, 180: 5.8 times the non-image peak.
|
|
82
|
+
* Tenant video uploads are paused (AGL-2830), so film playback is barely in
|
|
83
|
+
* the sample. A page of twelve films at four ranges a minute each still fits
|
|
84
|
+
* more than three times over.
|
|
85
|
+
*
|
|
86
|
+
* ## Keyed on the caller alone
|
|
87
|
+
*
|
|
88
|
+
* The key is the caller's address and the class: not the site, the asset or
|
|
89
|
+
* the query string. Cycling media ids, cache-busting a URL with a junk query
|
|
90
|
+
* parameter, or switching between the tenant and console mounts all land in
|
|
91
|
+
* the same counter.
|
|
92
|
+
*
|
|
93
|
+
* The address comes from `readClientIp`, which reads the hop the platform
|
|
94
|
+
* wrote rather than one a caller can prepend. An IPv6 address counts by its
|
|
95
|
+
* `/64`: one subscriber is routinely handed a whole `/64`, so keying on the
|
|
96
|
+
* full address would give that subscriber 2^64 budgets.
|
|
97
|
+
*
|
|
98
|
+
* A request with no readable address is not counted at all. One shared bucket
|
|
99
|
+
* for every unaddressable caller would refuse all of them together the moment
|
|
100
|
+
* a proxy stopped naming callers, which is media delivery taken down by its
|
|
101
|
+
* own limiter (see `NO_CLIENT_ADDRESS_BUCKET`). Vercel always names the caller,
|
|
102
|
+
* so production does not reach that branch.
|
|
103
|
+
*
|
|
104
|
+
* ## Fails open
|
|
105
|
+
*
|
|
106
|
+
* Only a durable count past the ceiling refuses. Everything else admits:
|
|
107
|
+
*
|
|
108
|
+
* - **Contended, or no answer inside the budget.** `consumeRateLimit` reports
|
|
109
|
+
* both as `allowed: false` with `contended: true`. That is the store saying
|
|
110
|
+
* it could not count, not that the caller is over, and reading it as a
|
|
111
|
+
* refusal is how a slow counter fails every request closed.
|
|
112
|
+
* - **The store is down.** `consumeRateLimit` falls back to its per-instance
|
|
113
|
+
* count and flags `degraded`. The episode is still recorded and still
|
|
114
|
+
* reaches `/api/health/rate-limits`; the request is admitted.
|
|
115
|
+
* - **Anything thrown.** Counting may never be the reason a delivery fails.
|
|
116
|
+
*
|
|
117
|
+
* Delivery is the product, and the counter and the media document share one
|
|
118
|
+
* Firestore. While the counter cannot answer, the document read in front of it
|
|
119
|
+
* is usually failing too, and refusing would add a second outage to the first.
|
|
120
|
+
*
|
|
121
|
+
* ## Latency
|
|
122
|
+
*
|
|
123
|
+
* `serveMediaCdn` starts the count beside the Storage metadata read, so its
|
|
124
|
+
* two round trips overlap one the request already pays. It gets
|
|
125
|
+
* {@link MEDIA_CDN_RATE_LIMIT_BUDGET_MS} rather than the store's 2.5 s
|
|
126
|
+
* default, and a request whose count has not come back by then is admitted.
|
|
127
|
+
*
|
|
128
|
+
* ## What it does not bound
|
|
129
|
+
*
|
|
130
|
+
* The bytes in one request, a caller spread across many addresses, and an
|
|
131
|
+
* org's total delivery. A WAF rule, a signed or referrer-checked public URL and
|
|
132
|
+
* a per-org byte cap are the controls for those. Each one changes what an
|
|
133
|
+
* `og:image`, a mailed image or an embed is able to fetch, so none of them is
|
|
134
|
+
* decided here.
|
|
135
|
+
*/
|
|
136
|
+
/** Fixed 60 s window, the same as every other limiter on the store. */
|
|
137
|
+
export declare const MEDIA_CDN_RATE_WINDOW_MS = 60000;
|
|
138
|
+
/** Image deliveries one caller may start in a window; see "Thresholds". */
|
|
139
|
+
export declare const MEDIA_CDN_IMAGE_RATE_LIMIT = 600;
|
|
140
|
+
/** Non-image deliveries one caller may start in a window; see "Thresholds". */
|
|
141
|
+
export declare const MEDIA_CDN_NON_IMAGE_RATE_LIMIT = 180;
|
|
142
|
+
/**
|
|
143
|
+
* How long a delivery waits for its count before it is admitted uncounted.
|
|
144
|
+
*
|
|
145
|
+
* The store's default is 2.5 s, sized for a password attempt that can afford
|
|
146
|
+
* to wait. A video seek cannot. The count's two round trips were measured at
|
|
147
|
+
* about 0.6 s end to end including the request itself (`rate-limit-store.ts`),
|
|
148
|
+
* and they run beside the metadata read, so 1 s is ample for an answer and
|
|
149
|
+
* short enough that a stalled store costs a player one short pause.
|
|
150
|
+
*/
|
|
151
|
+
export declare const MEDIA_CDN_RATE_LIMIT_BUDGET_MS = 1000;
|
|
152
|
+
/** Which budget a delivery spends: `mediaCdnEdgeCacheable` of what it serves. */
|
|
153
|
+
export type MediaCdnRateClass = 'image' | 'non-image';
|
|
154
|
+
/**
|
|
155
|
+
* The address a caller is counted under: an IPv4 address as it is, an IPv6
|
|
156
|
+
* address as its `/64`.
|
|
157
|
+
*
|
|
158
|
+
* `readClientIp` has already normalized the address, so an IPv6 value here is
|
|
159
|
+
* lower-case hex and colons. Anything that does not expand to eight groups is
|
|
160
|
+
* counted under the value as given, which can only be narrower than a `/64`.
|
|
161
|
+
*/
|
|
162
|
+
export declare function mediaCdnRateLimitCaller(address: string): string;
|
|
163
|
+
/** The counter key for one caller's budget in one class. */
|
|
164
|
+
export declare function mediaCdnRateLimitKey(rateClass: MediaCdnRateClass, address: string): string;
|
|
165
|
+
export interface MediaCdnRateLimitOptions {
|
|
166
|
+
/** The request's headers, node-style or `Headers`-shaped. */
|
|
167
|
+
headers: ClientIpHeaders;
|
|
168
|
+
/** `req.socket.remoteAddress`, which `readClientIp` consults last. */
|
|
169
|
+
remoteAddress?: string | null;
|
|
170
|
+
rateClass: MediaCdnRateClass;
|
|
171
|
+
limit?: number;
|
|
172
|
+
windowMs?: number;
|
|
173
|
+
budgetMs?: number;
|
|
174
|
+
nowMs?: number;
|
|
175
|
+
/** Injectable for tests; defaults to the Admin SDK's Firestore. */
|
|
176
|
+
firestore?: unknown;
|
|
177
|
+
}
|
|
178
|
+
export interface MediaCdnRateLimitRefusal {
|
|
179
|
+
/** Whole seconds until the caller's window ends, never less than 1. */
|
|
180
|
+
retryAfterSeconds: number;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Counts one delivery against its caller, and answers with a refusal only
|
|
184
|
+
* when the durable count is past the ceiling. `null` means serve.
|
|
185
|
+
*
|
|
186
|
+
* Never rejects: see "Fails open" above.
|
|
187
|
+
*/
|
|
188
|
+
export declare function mediaCdnRateLimitRefusal(options: MediaCdnRateLimitOptions): Promise<MediaCdnRateLimitRefusal | null>;
|
|
@@ -0,0 +1,204 @@
|
|
|
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
|
+
*/ import { readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
|
|
17
|
+
import { consumeRateLimit } from "./rate-limit-store.js";
|
|
18
|
+
/**
|
|
19
|
+
* The media CDN's per-caller limit (AGL-2812).
|
|
20
|
+
*
|
|
21
|
+
* `/api/media/cdn/*` is anonymous public delivery, and nothing bounded how
|
|
22
|
+
* much of it one caller could take. The firewall bypasses the route on
|
|
23
|
+
* purpose, because link-preview crawlers and Gmail's image proxy cannot solve
|
|
24
|
+
* a challenge ("Public asset delivery bypass" in
|
|
25
|
+
* `tools/scripts/lib/firewall-posture.mjs`). Both middlewares exclude `/api/*`,
|
|
26
|
+
* and no WAF rule covers the path. `serveMediaCdn` is the one piece of code
|
|
27
|
+
* every request passes through, so the limit lives there.
|
|
28
|
+
*
|
|
29
|
+
* ## What is counted
|
|
30
|
+
*
|
|
31
|
+
* A GET that gets past every access gate, the lockdown and quarantine
|
|
32
|
+
* refusals and the 304 exit, which is where the Storage reads begin. Those
|
|
33
|
+
* refusals, a 304 and a HEAD send no file, so a revalidating browser, a
|
|
34
|
+
* crawler's HEAD and a probe for a deleted id spend nothing. The counter's
|
|
35
|
+
* Firestore write is paid only where the delivery it guards is about to cost
|
|
36
|
+
* more. The count starts beside the metadata read, so a request that then
|
|
37
|
+
* finds no object in Storage, or asks for an unsatisfiable range, has been
|
|
38
|
+
* counted too.
|
|
39
|
+
*
|
|
40
|
+
* ## Two budgets, split where the edge splits them
|
|
41
|
+
*
|
|
42
|
+
* Images and everything else are counted separately, on the line
|
|
43
|
+
* `mediaCdnEdgeCacheable` already draws (AGL-1515):
|
|
44
|
+
*
|
|
45
|
+
* - **Images** are held by the edge, so only a miss reaches the function, and
|
|
46
|
+
* images are what link-preview crawlers and Gmail's image proxy fetch.
|
|
47
|
+
* - **Everything else** (video, audio, PDFs, documents) is `private`, so every
|
|
48
|
+
* request reaches the function, and it is where the bytes are: a video
|
|
49
|
+
* master is a single request for up to 200 MB.
|
|
50
|
+
*
|
|
51
|
+
* Separate keys also mean a DAM grid's burst of tiles cannot spend the budget
|
|
52
|
+
* the video on the same page needs, or the reverse.
|
|
53
|
+
*
|
|
54
|
+
* ## Thresholds, from measured traffic
|
|
55
|
+
*
|
|
56
|
+
* Read from both mounts' production request logs on 2026-09-11: the tenant's
|
|
57
|
+
* last seven days (2026-09-04 08:01 to 2026-09-11 07:56 UTC), and the
|
|
58
|
+
* console's last sixteen hours, which is all it retains. Only requests that
|
|
59
|
+
* reached the function count, since an edge hit never runs this code.
|
|
60
|
+
*
|
|
61
|
+
* | measured | busiest minute, whole route |
|
|
62
|
+
* | -- | -- |
|
|
63
|
+
* | image deliveries (200 and 206) | 98 |
|
|
64
|
+
* | every other delivery (200 and 206) | 31 |
|
|
65
|
+
* | every request, with 304s, 404s and edge hits | 236 |
|
|
66
|
+
* | link-preview crawlers and other bots, together | 32 |
|
|
67
|
+
* | Gmail's image proxy | none in the sample |
|
|
68
|
+
*
|
|
69
|
+
* Each of those minutes was one browser: the image peak was a single Chrome
|
|
70
|
+
* session pulling 129 distinct assets, and the non-image peak a single session
|
|
71
|
+
* following 31 `?download=1` links. No asset took more than 4 range requests
|
|
72
|
+
* in a minute. Over thirty days the day documents count 30,855 origin serves
|
|
73
|
+
* in all, and 4,234 on the busiest day.
|
|
74
|
+
*
|
|
75
|
+
* Each ceiling is at least five times the busiest minute the whole route
|
|
76
|
+
* served in its class. No single address can reach it with a pattern the route
|
|
77
|
+
* has actually seen, even if every request in that minute had been its own:
|
|
78
|
+
*
|
|
79
|
+
* - {@link MEDIA_CDN_IMAGE_RATE_LIMIT}, 600: 6.1 times the image peak, and more
|
|
80
|
+
* than 18 times every crawler together.
|
|
81
|
+
* - {@link MEDIA_CDN_NON_IMAGE_RATE_LIMIT}, 180: 5.8 times the non-image peak.
|
|
82
|
+
* Tenant video uploads are paused (AGL-2830), so film playback is barely in
|
|
83
|
+
* the sample. A page of twelve films at four ranges a minute each still fits
|
|
84
|
+
* more than three times over.
|
|
85
|
+
*
|
|
86
|
+
* ## Keyed on the caller alone
|
|
87
|
+
*
|
|
88
|
+
* The key is the caller's address and the class: not the site, the asset or
|
|
89
|
+
* the query string. Cycling media ids, cache-busting a URL with a junk query
|
|
90
|
+
* parameter, or switching between the tenant and console mounts all land in
|
|
91
|
+
* the same counter.
|
|
92
|
+
*
|
|
93
|
+
* The address comes from `readClientIp`, which reads the hop the platform
|
|
94
|
+
* wrote rather than one a caller can prepend. An IPv6 address counts by its
|
|
95
|
+
* `/64`: one subscriber is routinely handed a whole `/64`, so keying on the
|
|
96
|
+
* full address would give that subscriber 2^64 budgets.
|
|
97
|
+
*
|
|
98
|
+
* A request with no readable address is not counted at all. One shared bucket
|
|
99
|
+
* for every unaddressable caller would refuse all of them together the moment
|
|
100
|
+
* a proxy stopped naming callers, which is media delivery taken down by its
|
|
101
|
+
* own limiter (see `NO_CLIENT_ADDRESS_BUCKET`). Vercel always names the caller,
|
|
102
|
+
* so production does not reach that branch.
|
|
103
|
+
*
|
|
104
|
+
* ## Fails open
|
|
105
|
+
*
|
|
106
|
+
* Only a durable count past the ceiling refuses. Everything else admits:
|
|
107
|
+
*
|
|
108
|
+
* - **Contended, or no answer inside the budget.** `consumeRateLimit` reports
|
|
109
|
+
* both as `allowed: false` with `contended: true`. That is the store saying
|
|
110
|
+
* it could not count, not that the caller is over, and reading it as a
|
|
111
|
+
* refusal is how a slow counter fails every request closed.
|
|
112
|
+
* - **The store is down.** `consumeRateLimit` falls back to its per-instance
|
|
113
|
+
* count and flags `degraded`. The episode is still recorded and still
|
|
114
|
+
* reaches `/api/health/rate-limits`; the request is admitted.
|
|
115
|
+
* - **Anything thrown.** Counting may never be the reason a delivery fails.
|
|
116
|
+
*
|
|
117
|
+
* Delivery is the product, and the counter and the media document share one
|
|
118
|
+
* Firestore. While the counter cannot answer, the document read in front of it
|
|
119
|
+
* is usually failing too, and refusing would add a second outage to the first.
|
|
120
|
+
*
|
|
121
|
+
* ## Latency
|
|
122
|
+
*
|
|
123
|
+
* `serveMediaCdn` starts the count beside the Storage metadata read, so its
|
|
124
|
+
* two round trips overlap one the request already pays. It gets
|
|
125
|
+
* {@link MEDIA_CDN_RATE_LIMIT_BUDGET_MS} rather than the store's 2.5 s
|
|
126
|
+
* default, and a request whose count has not come back by then is admitted.
|
|
127
|
+
*
|
|
128
|
+
* ## What it does not bound
|
|
129
|
+
*
|
|
130
|
+
* The bytes in one request, a caller spread across many addresses, and an
|
|
131
|
+
* org's total delivery. A WAF rule, a signed or referrer-checked public URL and
|
|
132
|
+
* a per-org byte cap are the controls for those. Each one changes what an
|
|
133
|
+
* `og:image`, a mailed image or an embed is able to fetch, so none of them is
|
|
134
|
+
* decided here.
|
|
135
|
+
*/ /** Fixed 60 s window, the same as every other limiter on the store. */ export const MEDIA_CDN_RATE_WINDOW_MS = 60000;
|
|
136
|
+
/** Image deliveries one caller may start in a window; see "Thresholds". */ export const MEDIA_CDN_IMAGE_RATE_LIMIT = 600;
|
|
137
|
+
/** Non-image deliveries one caller may start in a window; see "Thresholds". */ export const MEDIA_CDN_NON_IMAGE_RATE_LIMIT = 180;
|
|
138
|
+
/**
|
|
139
|
+
* How long a delivery waits for its count before it is admitted uncounted.
|
|
140
|
+
*
|
|
141
|
+
* The store's default is 2.5 s, sized for a password attempt that can afford
|
|
142
|
+
* to wait. A video seek cannot. The count's two round trips were measured at
|
|
143
|
+
* about 0.6 s end to end including the request itself (`rate-limit-store.ts`),
|
|
144
|
+
* and they run beside the metadata read, so 1 s is ample for an answer and
|
|
145
|
+
* short enough that a stalled store costs a player one short pause.
|
|
146
|
+
*/ export const MEDIA_CDN_RATE_LIMIT_BUDGET_MS = 1000;
|
|
147
|
+
/**
|
|
148
|
+
* The address a caller is counted under: an IPv4 address as it is, an IPv6
|
|
149
|
+
* address as its `/64`.
|
|
150
|
+
*
|
|
151
|
+
* `readClientIp` has already normalized the address, so an IPv6 value here is
|
|
152
|
+
* lower-case hex and colons. Anything that does not expand to eight groups is
|
|
153
|
+
* counted under the value as given, which can only be narrower than a `/64`.
|
|
154
|
+
*/ export function mediaCdnRateLimitCaller(address) {
|
|
155
|
+
if (!address.includes(':')) return address;
|
|
156
|
+
const halves = address.split('::');
|
|
157
|
+
if (halves.length > 2) return address;
|
|
158
|
+
const head = halves[0] ? halves[0].split(':') : [];
|
|
159
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
160
|
+
const elided = halves.length === 2 ? 8 - head.length - tail.length : 0;
|
|
161
|
+
if (elided < 0 || halves.length === 2 && elided === 0) return address;
|
|
162
|
+
const groups = [
|
|
163
|
+
...head,
|
|
164
|
+
...Array(elided).fill('0'),
|
|
165
|
+
...tail
|
|
166
|
+
];
|
|
167
|
+
if (groups.length !== 8 || !groups.every((group)=>/^[0-9a-f]{1,4}$/.test(group))) {
|
|
168
|
+
return address;
|
|
169
|
+
}
|
|
170
|
+
return `${groups.slice(0, 4).map((group)=>group.replace(/^0+(?=[0-9a-f])/, '')).join(':')}::/64`;
|
|
171
|
+
}
|
|
172
|
+
/** The counter key for one caller's budget in one class. */ export function mediaCdnRateLimitKey(rateClass, address) {
|
|
173
|
+
return `media-cdn:${rateClass}:${mediaCdnRateLimitCaller(address)}`;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Counts one delivery against its caller, and answers with a refusal only
|
|
177
|
+
* when the durable count is past the ceiling. `null` means serve.
|
|
178
|
+
*
|
|
179
|
+
* Never rejects: see "Fails open" above.
|
|
180
|
+
*/ export async function mediaCdnRateLimitRefusal(options) {
|
|
181
|
+
try {
|
|
182
|
+
var _options_nowMs, _options_limit, _options_windowMs, _options_budgetMs;
|
|
183
|
+
const address = readClientIp(options.headers, {
|
|
184
|
+
remoteAddress: options.remoteAddress
|
|
185
|
+
});
|
|
186
|
+
if (!address) return null;
|
|
187
|
+
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
188
|
+
const rate = await consumeRateLimit(mediaCdnRateLimitKey(options.rateClass, address), {
|
|
189
|
+
limit: (_options_limit = options.limit) != null ? _options_limit : options.rateClass === 'image' ? MEDIA_CDN_IMAGE_RATE_LIMIT : MEDIA_CDN_NON_IMAGE_RATE_LIMIT,
|
|
190
|
+
windowMs: (_options_windowMs = options.windowMs) != null ? _options_windowMs : MEDIA_CDN_RATE_WINDOW_MS,
|
|
191
|
+
budgetMs: (_options_budgetMs = options.budgetMs) != null ? _options_budgetMs : MEDIA_CDN_RATE_LIMIT_BUDGET_MS,
|
|
192
|
+
now: nowMs,
|
|
193
|
+
firestore: options.firestore
|
|
194
|
+
});
|
|
195
|
+
if (rate.allowed || rate.contended || rate.degraded) return null;
|
|
196
|
+
return {
|
|
197
|
+
retryAfterSeconds: Math.max(1, Math.ceil((rate.resetMs - nowMs) / 1000))
|
|
198
|
+
};
|
|
199
|
+
} catch (unused) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
//# sourceMappingURL=media-cdn-rate-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-cdn-rate-limit.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 type ClientIpHeaders,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\nimport { consumeRateLimit } from './rate-limit-store'\n\n/**\n * The media CDN's per-caller limit (AGL-2812).\n *\n * `/api/media/cdn/*` is anonymous public delivery, and nothing bounded how\n * much of it one caller could take. The firewall bypasses the route on\n * purpose, because link-preview crawlers and Gmail's image proxy cannot solve\n * a challenge (\"Public asset delivery bypass\" in\n * `tools/scripts/lib/firewall-posture.mjs`). Both middlewares exclude `/api/*`,\n * and no WAF rule covers the path. `serveMediaCdn` is the one piece of code\n * every request passes through, so the limit lives there.\n *\n * ## What is counted\n *\n * A GET that gets past every access gate, the lockdown and quarantine\n * refusals and the 304 exit, which is where the Storage reads begin. Those\n * refusals, a 304 and a HEAD send no file, so a revalidating browser, a\n * crawler's HEAD and a probe for a deleted id spend nothing. The counter's\n * Firestore write is paid only where the delivery it guards is about to cost\n * more. The count starts beside the metadata read, so a request that then\n * finds no object in Storage, or asks for an unsatisfiable range, has been\n * counted too.\n *\n * ## Two budgets, split where the edge splits them\n *\n * Images and everything else are counted separately, on the line\n * `mediaCdnEdgeCacheable` already draws (AGL-1515):\n *\n * - **Images** are held by the edge, so only a miss reaches the function, and\n * images are what link-preview crawlers and Gmail's image proxy fetch.\n * - **Everything else** (video, audio, PDFs, documents) is `private`, so every\n * request reaches the function, and it is where the bytes are: a video\n * master is a single request for up to 200 MB.\n *\n * Separate keys also mean a DAM grid's burst of tiles cannot spend the budget\n * the video on the same page needs, or the reverse.\n *\n * ## Thresholds, from measured traffic\n *\n * Read from both mounts' production request logs on 2026-09-11: the tenant's\n * last seven days (2026-09-04 08:01 to 2026-09-11 07:56 UTC), and the\n * console's last sixteen hours, which is all it retains. Only requests that\n * reached the function count, since an edge hit never runs this code.\n *\n * | measured | busiest minute, whole route |\n * | -- | -- |\n * | image deliveries (200 and 206) | 98 |\n * | every other delivery (200 and 206) | 31 |\n * | every request, with 304s, 404s and edge hits | 236 |\n * | link-preview crawlers and other bots, together | 32 |\n * | Gmail's image proxy | none in the sample |\n *\n * Each of those minutes was one browser: the image peak was a single Chrome\n * session pulling 129 distinct assets, and the non-image peak a single session\n * following 31 `?download=1` links. No asset took more than 4 range requests\n * in a minute. Over thirty days the day documents count 30,855 origin serves\n * in all, and 4,234 on the busiest day.\n *\n * Each ceiling is at least five times the busiest minute the whole route\n * served in its class. No single address can reach it with a pattern the route\n * has actually seen, even if every request in that minute had been its own:\n *\n * - {@link MEDIA_CDN_IMAGE_RATE_LIMIT}, 600: 6.1 times the image peak, and more\n * than 18 times every crawler together.\n * - {@link MEDIA_CDN_NON_IMAGE_RATE_LIMIT}, 180: 5.8 times the non-image peak.\n * Tenant video uploads are paused (AGL-2830), so film playback is barely in\n * the sample. A page of twelve films at four ranges a minute each still fits\n * more than three times over.\n *\n * ## Keyed on the caller alone\n *\n * The key is the caller's address and the class: not the site, the asset or\n * the query string. Cycling media ids, cache-busting a URL with a junk query\n * parameter, or switching between the tenant and console mounts all land in\n * the same counter.\n *\n * The address comes from `readClientIp`, which reads the hop the platform\n * wrote rather than one a caller can prepend. An IPv6 address counts by its\n * `/64`: one subscriber is routinely handed a whole `/64`, so keying on the\n * full address would give that subscriber 2^64 budgets.\n *\n * A request with no readable address is not counted at all. One shared bucket\n * for every unaddressable caller would refuse all of them together the moment\n * a proxy stopped naming callers, which is media delivery taken down by its\n * own limiter (see `NO_CLIENT_ADDRESS_BUCKET`). Vercel always names the caller,\n * so production does not reach that branch.\n *\n * ## Fails open\n *\n * Only a durable count past the ceiling refuses. Everything else admits:\n *\n * - **Contended, or no answer inside the budget.** `consumeRateLimit` reports\n * both as `allowed: false` with `contended: true`. That is the store saying\n * it could not count, not that the caller is over, and reading it as a\n * refusal is how a slow counter fails every request closed.\n * - **The store is down.** `consumeRateLimit` falls back to its per-instance\n * count and flags `degraded`. The episode is still recorded and still\n * reaches `/api/health/rate-limits`; the request is admitted.\n * - **Anything thrown.** Counting may never be the reason a delivery fails.\n *\n * Delivery is the product, and the counter and the media document share one\n * Firestore. While the counter cannot answer, the document read in front of it\n * is usually failing too, and refusing would add a second outage to the first.\n *\n * ## Latency\n *\n * `serveMediaCdn` starts the count beside the Storage metadata read, so its\n * two round trips overlap one the request already pays. It gets\n * {@link MEDIA_CDN_RATE_LIMIT_BUDGET_MS} rather than the store's 2.5 s\n * default, and a request whose count has not come back by then is admitted.\n *\n * ## What it does not bound\n *\n * The bytes in one request, a caller spread across many addresses, and an\n * org's total delivery. A WAF rule, a signed or referrer-checked public URL and\n * a per-org byte cap are the controls for those. Each one changes what an\n * `og:image`, a mailed image or an embed is able to fetch, so none of them is\n * decided here.\n */\n\n/** Fixed 60 s window, the same as every other limiter on the store. */\nexport const MEDIA_CDN_RATE_WINDOW_MS = 60_000\n\n/** Image deliveries one caller may start in a window; see \"Thresholds\". */\nexport const MEDIA_CDN_IMAGE_RATE_LIMIT = 600\n\n/** Non-image deliveries one caller may start in a window; see \"Thresholds\". */\nexport const MEDIA_CDN_NON_IMAGE_RATE_LIMIT = 180\n\n/**\n * How long a delivery waits for its count before it is admitted uncounted.\n *\n * The store's default is 2.5 s, sized for a password attempt that can afford\n * to wait. A video seek cannot. The count's two round trips were measured at\n * about 0.6 s end to end including the request itself (`rate-limit-store.ts`),\n * and they run beside the metadata read, so 1 s is ample for an answer and\n * short enough that a stalled store costs a player one short pause.\n */\nexport const MEDIA_CDN_RATE_LIMIT_BUDGET_MS = 1_000\n\n/** Which budget a delivery spends: `mediaCdnEdgeCacheable` of what it serves. */\nexport type MediaCdnRateClass = 'image' | 'non-image'\n\n/**\n * The address a caller is counted under: an IPv4 address as it is, an IPv6\n * address as its `/64`.\n *\n * `readClientIp` has already normalized the address, so an IPv6 value here is\n * lower-case hex and colons. Anything that does not expand to eight groups is\n * counted under the value as given, which can only be narrower than a `/64`.\n */\nexport function mediaCdnRateLimitCaller(address: string): string {\n if (!address.includes(':')) return address\n const halves = address.split('::')\n if (halves.length > 2) return address\n const head = halves[0] ? halves[0].split(':') : []\n const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []\n const elided = halves.length === 2 ? 8 - head.length - tail.length : 0\n if (elided < 0 || (halves.length === 2 && elided === 0)) return address\n const groups = [...head, ...Array<string>(elided).fill('0'), ...tail]\n if (groups.length !== 8 || !groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) {\n return address\n }\n return `${groups\n .slice(0, 4)\n .map((group) => group.replace(/^0+(?=[0-9a-f])/, ''))\n .join(':')}::/64`\n}\n\n/** The counter key for one caller's budget in one class. */\nexport function mediaCdnRateLimitKey(\n rateClass: MediaCdnRateClass,\n address: string,\n): string {\n return `media-cdn:${rateClass}:${mediaCdnRateLimitCaller(address)}`\n}\n\nexport interface MediaCdnRateLimitOptions {\n /** The request's headers, node-style or `Headers`-shaped. */\n headers: ClientIpHeaders\n /** `req.socket.remoteAddress`, which `readClientIp` consults last. */\n remoteAddress?: string | null\n rateClass: MediaCdnRateClass\n limit?: number\n windowMs?: number\n budgetMs?: number\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n}\n\nexport interface MediaCdnRateLimitRefusal {\n /** Whole seconds until the caller's window ends, never less than 1. */\n retryAfterSeconds: number\n}\n\n/**\n * Counts one delivery against its caller, and answers with a refusal only\n * when the durable count is past the ceiling. `null` means serve.\n *\n * Never rejects: see \"Fails open\" above.\n */\nexport async function mediaCdnRateLimitRefusal(\n options: MediaCdnRateLimitOptions,\n): Promise<MediaCdnRateLimitRefusal | null> {\n try {\n const address = readClientIp(options.headers, {\n remoteAddress: options.remoteAddress,\n })\n if (!address) return null\n const nowMs = options.nowMs ?? Date.now()\n const rate = await consumeRateLimit(\n mediaCdnRateLimitKey(options.rateClass, address),\n {\n limit:\n options.limit ??\n (options.rateClass === 'image'\n ? MEDIA_CDN_IMAGE_RATE_LIMIT\n : MEDIA_CDN_NON_IMAGE_RATE_LIMIT),\n windowMs: options.windowMs ?? MEDIA_CDN_RATE_WINDOW_MS,\n budgetMs: options.budgetMs ?? MEDIA_CDN_RATE_LIMIT_BUDGET_MS,\n now: nowMs,\n firestore: options.firestore,\n },\n )\n if (rate.allowed || rate.contended || rate.degraded) return null\n return {\n retryAfterSeconds: Math.max(1, Math.ceil((rate.resetMs - nowMs) / 1000)),\n }\n } catch {\n return null\n }\n}\n"],"names":["readClientIp","consumeRateLimit","MEDIA_CDN_RATE_WINDOW_MS","MEDIA_CDN_IMAGE_RATE_LIMIT","MEDIA_CDN_NON_IMAGE_RATE_LIMIT","MEDIA_CDN_RATE_LIMIT_BUDGET_MS","mediaCdnRateLimitCaller","address","includes","halves","split","length","head","tail","elided","groups","Array","fill","every","group","test","slice","map","replace","join","mediaCdnRateLimitKey","rateClass","mediaCdnRateLimitRefusal","options","headers","remoteAddress","nowMs","Date","now","rate","limit","windowMs","budgetMs","firestore","allowed","contended","degraded","retryAfterSeconds","Math","max","ceil","resetMs"],"mappings":"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"}
|