@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,183 @@
|
|
|
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 { filterPluginsByReleaseFlags, isReleaseFlagKey, isReleaseFlagOnForOrg, parseOrgReleaseFlagOverrides, parseReleaseFlagValue, RELEASE_FLAGS, resolveEffectivePlan } from "@aglyn/aglyn/server";
|
|
17
|
+
import { findUserByUidAcrossPools } from "./auth-pools.js";
|
|
18
|
+
import { firebaseAdmin } from "./firebase-admin.js";
|
|
19
|
+
/**
|
|
20
|
+
* Server-side release-flag verdicts (AGL-422): the tenant loader and the
|
|
21
|
+
* API dispatchers subtract flagged-off plugins, so the server needs the
|
|
22
|
+
* same Remote Config values the console client activates. The admin SDK
|
|
23
|
+
* template read is cached with a short TTL — flag flips propagate within
|
|
24
|
+
* a minute without a per-request round trip.
|
|
25
|
+
*
|
|
26
|
+
* Fail-open to the REGISTRY DEFAULTS (not "all on"): environments without
|
|
27
|
+
* Remote Config (emulator e2e, local dev) behave exactly like an
|
|
28
|
+
* unpublished template does on the client, so verdicts agree across
|
|
29
|
+
* surfaces.
|
|
30
|
+
*/ const TTL_MS = 60000;
|
|
31
|
+
let cache;
|
|
32
|
+
let pending;
|
|
33
|
+
const registryDefaults = ()=>Object.fromEntries(RELEASE_FLAGS.map((definition)=>[
|
|
34
|
+
definition.key,
|
|
35
|
+
{
|
|
36
|
+
enabled: definition.defaultEnabled
|
|
37
|
+
}
|
|
38
|
+
]));
|
|
39
|
+
export async function getServerReleaseFlagValues() {
|
|
40
|
+
if (cache && Date.now() - cache.at < TTL_MS) return cache.values;
|
|
41
|
+
if (!pending) {
|
|
42
|
+
pending = (async ()=>{
|
|
43
|
+
const values = registryDefaults();
|
|
44
|
+
try {
|
|
45
|
+
const template = await firebaseAdmin.app().remoteConfig().getTemplate();
|
|
46
|
+
for (const definition of RELEASE_FLAGS){
|
|
47
|
+
const parameter = template.parameters[definition.key];
|
|
48
|
+
const defaultValue = parameter == null ? void 0 : parameter.defaultValue;
|
|
49
|
+
const raw = defaultValue && 'value' in defaultValue ? defaultValue.value : undefined;
|
|
50
|
+
values[definition.key] = parseReleaseFlagValue(raw, definition.defaultEnabled);
|
|
51
|
+
}
|
|
52
|
+
} catch (unused) {
|
|
53
|
+
// No Remote Config here (emulator/local) — registry defaults gate.
|
|
54
|
+
}
|
|
55
|
+
cache = {
|
|
56
|
+
at: Date.now(),
|
|
57
|
+
values
|
|
58
|
+
};
|
|
59
|
+
return values;
|
|
60
|
+
})().finally(()=>{
|
|
61
|
+
pending = undefined;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return pending;
|
|
65
|
+
}
|
|
66
|
+
const orgCache = new Map();
|
|
67
|
+
/**
|
|
68
|
+
* Both halves of the per-org answer from ONE cached document read.
|
|
69
|
+
*
|
|
70
|
+
* Sharing the read is the point: `plan` and `releaseFlags` live on the same
|
|
71
|
+
* org document, this runs on every published page render and every API
|
|
72
|
+
* dispatch, and fetching the tier separately would have doubled the hot-path
|
|
73
|
+
* Firestore traffic to learn something already in hand.
|
|
74
|
+
*/ export async function getOrgReleaseFlagTargeting(orgId) {
|
|
75
|
+
if (!orgId) return {
|
|
76
|
+
overrides: {},
|
|
77
|
+
plan: null
|
|
78
|
+
};
|
|
79
|
+
const hit = orgCache.get(orgId);
|
|
80
|
+
if (hit && Date.now() - hit.at < TTL_MS) return hit.targeting;
|
|
81
|
+
const targeting = {
|
|
82
|
+
overrides: {},
|
|
83
|
+
plan: null
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get();
|
|
87
|
+
const data = snapshot.data();
|
|
88
|
+
targeting.overrides = parseOrgReleaseFlagOverrides(data == null ? void 0 : data['releaseFlags']);
|
|
89
|
+
// Only when a document actually came back. `snapshot.data()` is undefined
|
|
90
|
+
// for a missing doc, and `resolveEffectivePlan` would read that as Free.
|
|
91
|
+
if (data) targeting.plan = resolveEffectivePlan(data);
|
|
92
|
+
} catch (unused) {
|
|
93
|
+
// No Firestore here, or the doc is unreadable — inherit, never fail.
|
|
94
|
+
}
|
|
95
|
+
orgCache.set(orgId, {
|
|
96
|
+
at: Date.now(),
|
|
97
|
+
targeting
|
|
98
|
+
});
|
|
99
|
+
return targeting;
|
|
100
|
+
}
|
|
101
|
+
export async function getOrgReleaseFlagOverrides(orgId) {
|
|
102
|
+
return (await getOrgReleaseFlagTargeting(orgId)).overrides;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The verdict for ONE flag and ONE org, overrides applied (AGL-1635).
|
|
106
|
+
*
|
|
107
|
+
* The surfaces that gate a single flag server-side — the tenant admin bar
|
|
108
|
+
* slot, edit-context redemption, edit-access minting — resolve through here
|
|
109
|
+
* rather than reading `getServerReleaseFlagValues()` and calling
|
|
110
|
+
* `isReleaseFlagOn` themselves, so a per-org grant reaches all of them at
|
|
111
|
+
* once. A gate that kept the old two-step would silently ignore overrides,
|
|
112
|
+
* which is the failure this issue exists to fix.
|
|
113
|
+
*/ export async function isServerReleaseFlagOnForOrg(flagKey, orgId) {
|
|
114
|
+
const [values, targeting] = await Promise.all([
|
|
115
|
+
getServerReleaseFlagValues(),
|
|
116
|
+
getOrgReleaseFlagTargeting(orgId)
|
|
117
|
+
]);
|
|
118
|
+
return isReleaseFlagOnForOrg(flagKey, values[flagKey], orgId != null ? orgId : null, targeting.overrides, targeting.plan);
|
|
119
|
+
}
|
|
120
|
+
/** Test seam: drops both caches so a spec can change a verdict. */ export function __resetReleaseFlagCaches() {
|
|
121
|
+
cache = undefined;
|
|
122
|
+
orgCache.clear();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The server half of the plugin release gate (AGL-422): subtracts
|
|
126
|
+
* flagged-off first-party plugins from an effective set. The staff bypass is
|
|
127
|
+
* only paid for when something was actually subtracted AND the request
|
|
128
|
+
* carries a bearer token — the common all-flags-on path never verifies.
|
|
129
|
+
*
|
|
130
|
+
* `orgId` is the ONLY subject: it selects the per-org overrides (AGL-1635)
|
|
131
|
+
* and it is the rollout bucket. AGL-1635 took a separate `subjectId` here
|
|
132
|
+
* because callers fell back to a hostId when the org lookup missed, and a
|
|
133
|
+
* hostId must never be read as an org id. AGL-1656 removed that fallback
|
|
134
|
+
* instead of accommodating it — a hostId and an orgId hash to different
|
|
135
|
+
* buckets, so a mid-rollout flag could land the published site and the
|
|
136
|
+
* console on opposite sides of the same percentage for the same workspace.
|
|
137
|
+
* With one subject the two cannot disagree by construction, and there is no
|
|
138
|
+
* longer a parameter through which a non-org subject could be reintroduced.
|
|
139
|
+
*/ export async function filterEnabledPluginsByReleaseFlags(pluginIds, options) {
|
|
140
|
+
var _options_authorization;
|
|
141
|
+
const [values, targeting] = await Promise.all([
|
|
142
|
+
getServerReleaseFlagValues(),
|
|
143
|
+
getOrgReleaseFlagTargeting(options.orgId)
|
|
144
|
+
]);
|
|
145
|
+
const isFlagOn = (flagKey)=>{
|
|
146
|
+
var _options_orgId;
|
|
147
|
+
return isReleaseFlagKey(flagKey) ? isReleaseFlagOnForOrg(flagKey, values[flagKey], (_options_orgId = options.orgId) != null ? _options_orgId : null, targeting.overrides, targeting.plan) : true;
|
|
148
|
+
};
|
|
149
|
+
const filtered = filterPluginsByReleaseFlags(pluginIds, isFlagOn);
|
|
150
|
+
if (filtered.length === pluginIds.length) return filtered;
|
|
151
|
+
const authorization = (_options_authorization = options.authorization) != null ? _options_authorization : '';
|
|
152
|
+
if (authorization.startsWith('Bearer ')) {
|
|
153
|
+
try {
|
|
154
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(authorization.slice('Bearer '.length));
|
|
155
|
+
if (decoded['staff'] === true) return [
|
|
156
|
+
...pluginIds
|
|
157
|
+
];
|
|
158
|
+
} catch (unused) {
|
|
159
|
+
// Invalid token — gate as anonymous.
|
|
160
|
+
}
|
|
161
|
+
return filtered;
|
|
162
|
+
}
|
|
163
|
+
if (options.subjectUid) {
|
|
164
|
+
// The same claim the token path reads, looked up by account: a staff
|
|
165
|
+
// member's own provider redirect carries no token, and previewing a dark
|
|
166
|
+
// plugin has to survive the round trip their session started. Paid only
|
|
167
|
+
// when a flag actually subtracted something and a route vouched for the
|
|
168
|
+
// account. Across every auth pool (AGL-1122): a staff member who signs in
|
|
169
|
+
// through SSO is a tenant user the project pool does not hold.
|
|
170
|
+
try {
|
|
171
|
+
var _account_record_customClaims;
|
|
172
|
+
const account = await findUserByUidAcrossPools(options.subjectUid);
|
|
173
|
+
if ((account == null ? void 0 : (_account_record_customClaims = account.record.customClaims) == null ? void 0 : _account_record_customClaims['staff']) === true) return [
|
|
174
|
+
...pluginIds
|
|
175
|
+
];
|
|
176
|
+
} catch (unused) {
|
|
177
|
+
// Auth did not answer — gate as anonymous.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return filtered;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
//# sourceMappingURL=release-flags.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/release-flags.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 filterPluginsByReleaseFlags,\n isReleaseFlagKey,\n isReleaseFlagOnForOrg,\n parseOrgReleaseFlagOverrides,\n parseReleaseFlagValue,\n RELEASE_FLAGS,\n resolveEffectivePlan,\n type OrgPlan,\n type OrgReleaseFlagOverrides,\n type ReleaseFlagKey,\n type ReleaseFlagValue,\n} from '@aglyn/aglyn/server'\nimport { findUserByUidAcrossPools } from './auth-pools'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * Server-side release-flag verdicts (AGL-422): the tenant loader and the\n * API dispatchers subtract flagged-off plugins, so the server needs the\n * same Remote Config values the console client activates. The admin SDK\n * template read is cached with a short TTL — flag flips propagate within\n * a minute without a per-request round trip.\n *\n * Fail-open to the REGISTRY DEFAULTS (not \"all on\"): environments without\n * Remote Config (emulator e2e, local dev) behave exactly like an\n * unpublished template does on the client, so verdicts agree across\n * surfaces.\n */\n\nconst TTL_MS = 60_000\n\nlet cache:\n | { at: number; values: Record<ReleaseFlagKey, ReleaseFlagValue> }\n | undefined\nlet pending:\n | Promise<Record<ReleaseFlagKey, ReleaseFlagValue>>\n | undefined\n\nconst registryDefaults = (): Record<ReleaseFlagKey, ReleaseFlagValue> =>\n Object.fromEntries(\n RELEASE_FLAGS.map((definition) => [\n definition.key,\n { enabled: definition.defaultEnabled },\n ]),\n ) as Record<ReleaseFlagKey, ReleaseFlagValue>\n\nexport async function getServerReleaseFlagValues(): Promise<\n Record<ReleaseFlagKey, ReleaseFlagValue>\n> {\n if (cache && Date.now() - cache.at < TTL_MS) return cache.values\n if (!pending) {\n pending = (async () => {\n const values = registryDefaults()\n try {\n const template = await firebaseAdmin.app().remoteConfig().getTemplate()\n for (const definition of RELEASE_FLAGS) {\n const parameter = template.parameters[definition.key]\n const defaultValue = parameter?.defaultValue\n const raw =\n defaultValue && 'value' in defaultValue\n ? defaultValue.value\n : undefined\n values[definition.key] = parseReleaseFlagValue(\n raw,\n definition.defaultEnabled,\n )\n }\n } catch {\n // No Remote Config here (emulator/local) — registry defaults gate.\n }\n cache = { at: Date.now(), values }\n return values\n })().finally(() => {\n pending = undefined\n })\n }\n return pending\n}\n\n/**\n * Per-org release-flag overrides (AGL-1635), read off `orgs/{orgId}`.\n *\n * Cached on the same TTL as the template read above, and for the same\n * reason: these are consulted on every published page render and every API\n * dispatch, so an uncached read would add a Firestore round trip to the hot\n * path. A grant therefore takes up to a minute to land, matching how long a\n * flag flip already takes — one propagation story, not two.\n *\n * Keyed by orgId because a subject may be a uid or a hostId, and only an\n * org can carry overrides. Failure is silent and yields `{}`: a missing org\n * doc or a denied read must gate exactly as \"no override set\", never as an\n * error that takes a published page down.\n */\n/**\n * What one org read answers about release flags: the staff overrides, and\n * the org's EFFECTIVE plan for tier targeting (AGL-2486).\n *\n * `plan` is null when the read did not produce an org document — missing,\n * denied, or no Firestore at all. Deliberately not `'free'`: an org whose\n * doc could not be read has an UNKNOWN tier, and\n * `resolveEffectivePlan(undefined)` answers `'free'`, which would hand a\n * Free-targeted flag to every org the read failed for. The two cases have to\n * stay distinguishable at the boundary, because the evaluator treats an\n * unknown plan as a refusal and a known `'free'` as a match.\n */\ninterface OrgReleaseFlagTargeting {\n overrides: OrgReleaseFlagOverrides\n plan: OrgPlan | null\n}\n\nconst orgCache = new Map<string, { at: number; targeting: OrgReleaseFlagTargeting }>()\n\n/**\n * Both halves of the per-org answer from ONE cached document read.\n *\n * Sharing the read is the point: `plan` and `releaseFlags` live on the same\n * org document, this runs on every published page render and every API\n * dispatch, and fetching the tier separately would have doubled the hot-path\n * Firestore traffic to learn something already in hand.\n */\nexport async function getOrgReleaseFlagTargeting(\n orgId: string | null | undefined,\n): Promise<OrgReleaseFlagTargeting> {\n if (!orgId) return { overrides: {}, plan: null }\n const hit = orgCache.get(orgId)\n if (hit && Date.now() - hit.at < TTL_MS) return hit.targeting\n const targeting: OrgReleaseFlagTargeting = { overrides: {}, plan: null }\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .get()\n const data = snapshot.data()\n targeting.overrides = parseOrgReleaseFlagOverrides(data?.['releaseFlags'])\n // Only when a document actually came back. `snapshot.data()` is undefined\n // for a missing doc, and `resolveEffectivePlan` would read that as Free.\n if (data) targeting.plan = resolveEffectivePlan(data as any)\n } catch {\n // No Firestore here, or the doc is unreadable — inherit, never fail.\n }\n orgCache.set(orgId, { at: Date.now(), targeting })\n return targeting\n}\n\nexport async function getOrgReleaseFlagOverrides(\n orgId: string | null | undefined,\n): Promise<OrgReleaseFlagOverrides> {\n return (await getOrgReleaseFlagTargeting(orgId)).overrides\n}\n\n/**\n * The verdict for ONE flag and ONE org, overrides applied (AGL-1635).\n *\n * The surfaces that gate a single flag server-side — the tenant admin bar\n * slot, edit-context redemption, edit-access minting — resolve through here\n * rather than reading `getServerReleaseFlagValues()` and calling\n * `isReleaseFlagOn` themselves, so a per-org grant reaches all of them at\n * once. A gate that kept the old two-step would silently ignore overrides,\n * which is the failure this issue exists to fix.\n */\nexport async function isServerReleaseFlagOnForOrg(\n flagKey: ReleaseFlagKey,\n orgId: string | null | undefined,\n): Promise<boolean> {\n const [values, targeting] = await Promise.all([\n getServerReleaseFlagValues(),\n getOrgReleaseFlagTargeting(orgId),\n ])\n return isReleaseFlagOnForOrg(\n flagKey,\n values[flagKey],\n orgId ?? null,\n targeting.overrides,\n targeting.plan,\n )\n}\n\n/** Test seam: drops both caches so a spec can change a verdict. */\nexport function __resetReleaseFlagCaches(): void {\n cache = undefined\n orgCache.clear()\n}\n\n/**\n * The server half of the plugin release gate (AGL-422): subtracts\n * flagged-off first-party plugins from an effective set. The staff bypass is\n * only paid for when something was actually subtracted AND the request\n * carries a bearer token — the common all-flags-on path never verifies.\n *\n * `orgId` is the ONLY subject: it selects the per-org overrides (AGL-1635)\n * and it is the rollout bucket. AGL-1635 took a separate `subjectId` here\n * because callers fell back to a hostId when the org lookup missed, and a\n * hostId must never be read as an org id. AGL-1656 removed that fallback\n * instead of accommodating it — a hostId and an orgId hash to different\n * buckets, so a mid-rollout flag could land the published site and the\n * console on opposite sides of the same percentage for the same workspace.\n * With one subject the two cannot disagree by construction, and there is no\n * longer a parameter through which a non-org subject could be reintroduced.\n */\nexport async function filterEnabledPluginsByReleaseFlags(\n pluginIds: readonly string[],\n options: {\n /**\n * The org this request belongs to: rollout bucket AND override key.\n * Null when no org could be resolved — a subject-less request gets the\n * fully-enabled flags only, never a partial rollout.\n */\n orgId?: string | null\n /** Raw Authorization header, when the caller has one (API dispatch). */\n authorization?: string | null\n /**\n * The account a TOKENLESS request acts for, as its route proved it\n * (AGL-2978) — see `PluginApiRequestSubject.uid`. Consulted for the staff\n * preview only, and only when the request carries no bearer token: a\n * request with a token is the token's, whatever else it names.\n */\n subjectUid?: string | null\n },\n): Promise<string[]> {\n const [values, targeting] = await Promise.all([\n getServerReleaseFlagValues(),\n getOrgReleaseFlagTargeting(options.orgId),\n ])\n const isFlagOn = (flagKey: string): boolean =>\n isReleaseFlagKey(flagKey)\n ? isReleaseFlagOnForOrg(\n flagKey,\n values[flagKey],\n options.orgId ?? null,\n targeting.overrides,\n targeting.plan,\n )\n : true\n const filtered = filterPluginsByReleaseFlags(pluginIds, isFlagOn)\n if (filtered.length === pluginIds.length) return filtered\n\n const authorization = options.authorization ?? ''\n if (authorization.startsWith('Bearer ')) {\n try {\n const decoded = await firebaseAdmin\n .app()\n .auth()\n .verifyIdToken(authorization.slice('Bearer '.length))\n if (decoded['staff'] === true) return [...pluginIds]\n } catch {\n // Invalid token — gate as anonymous.\n }\n return filtered\n }\n if (options.subjectUid) {\n // The same claim the token path reads, looked up by account: a staff\n // member's own provider redirect carries no token, and previewing a dark\n // plugin has to survive the round trip their session started. Paid only\n // when a flag actually subtracted something and a route vouched for the\n // account. Across every auth pool (AGL-1122): a staff member who signs in\n // through SSO is a tenant user the project pool does not hold.\n try {\n const account = await findUserByUidAcrossPools(options.subjectUid)\n if (account?.record.customClaims?.['staff'] === true) return [...pluginIds]\n } catch {\n // Auth did not answer — gate as anonymous.\n }\n }\n return filtered\n}\n"],"names":["filterPluginsByReleaseFlags","isReleaseFlagKey","isReleaseFlagOnForOrg","parseOrgReleaseFlagOverrides","parseReleaseFlagValue","RELEASE_FLAGS","resolveEffectivePlan","findUserByUidAcrossPools","firebaseAdmin","TTL_MS","cache","pending","registryDefaults","Object","fromEntries","map","definition","key","enabled","defaultEnabled","getServerReleaseFlagValues","Date","now","at","values","template","app","remoteConfig","getTemplate","parameter","parameters","defaultValue","raw","value","undefined","finally","orgCache","Map","getOrgReleaseFlagTargeting","orgId","overrides","plan","hit","get","targeting","snapshot","firestore","collection","doc","data","set","getOrgReleaseFlagOverrides","isServerReleaseFlagOnForOrg","flagKey","Promise","all","__resetReleaseFlagCaches","clear","filterEnabledPluginsByReleaseFlags","pluginIds","options","isFlagOn","filtered","length","authorization","startsWith","decoded","auth","verifyIdToken","slice","subjectUid","account","record","customClaims"],"mappings":"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"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* How long `adminAudit` rows stay queryable before `/api/admin/audit-archive`
|
|
19
|
+
* moves them to Storage. A replay window older than this cannot be answered
|
|
20
|
+
* from Firestore at all, which is the one failure mode that must not look
|
|
21
|
+
* like "nothing to replay".
|
|
22
|
+
*/
|
|
23
|
+
export declare const AUDIT_HOT_WINDOW_MS: number;
|
|
24
|
+
/** What happened to one erasure that had to be re-applied. */
|
|
25
|
+
export interface ReplayedErasure {
|
|
26
|
+
kind: 'org' | 'user';
|
|
27
|
+
/** The org id or uid the original erasure destroyed. */
|
|
28
|
+
id: string;
|
|
29
|
+
/**
|
|
30
|
+
* - `replayed` — it had come back and has been erased again.
|
|
31
|
+
* - `absent` — nothing was resurrected; the restore did not reach it.
|
|
32
|
+
* - `blocked` — it came back and could NOT be erased again. The reason is
|
|
33
|
+
* on `detail`, and this is the outcome a human has to act on.
|
|
34
|
+
*/
|
|
35
|
+
outcome: 'replayed' | 'absent' | 'blocked';
|
|
36
|
+
detail?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ReplayErasuresResult {
|
|
39
|
+
/** Snapshot instant the restore was taken from — the replay's lower bound. */
|
|
40
|
+
sinceMs: number;
|
|
41
|
+
/** True when nothing was written: a plan, not a replay. */
|
|
42
|
+
dryRun: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Set when the answer cannot be trusted. Today the only value is
|
|
45
|
+
* `'audit-window'`: `sinceMs` predates the hot `adminAudit` window, so rows
|
|
46
|
+
* naming erasures inside the restored span may already be in the Storage
|
|
47
|
+
* archive and this run cannot see them. **An empty list under this warning
|
|
48
|
+
* is not evidence that there was nothing to replay.**
|
|
49
|
+
*/
|
|
50
|
+
incomplete?: 'audit-window';
|
|
51
|
+
/** Audit rows examined. */
|
|
52
|
+
examined: number;
|
|
53
|
+
entries: ReplayedErasure[];
|
|
54
|
+
/** Convenience for the exit code — any `blocked` entry, or `incomplete`. */
|
|
55
|
+
ok: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Re-apply every erasure a restore resurrected (AGL-1975).
|
|
59
|
+
*
|
|
60
|
+
* Live DPA §11 promises, in terms: *"a deletion instruction survives any
|
|
61
|
+
* restoration — data deleted at Customer's instruction and later restored from
|
|
62
|
+
* a backup will be deleted again."* Nothing implemented it. This does.
|
|
63
|
+
*
|
|
64
|
+
* **The reason it is needed is specific, not theoretical.** A Firestore import
|
|
65
|
+
* is **merge-by-id, not replace** (`docs/DISASTER_RECOVERY.md` Procedures C
|
|
66
|
+
* and D both say so), so importing a pre-erasure export into `(default)`
|
|
67
|
+
* silently reinstates every document an erasure deleted — the whole org tree,
|
|
68
|
+
* the profile, the support threads — alongside the recovery it was actually
|
|
69
|
+
* run for. It happens during an incident, when nobody is reading a DPA, and
|
|
70
|
+
* nothing anywhere says it happened.
|
|
71
|
+
*
|
|
72
|
+
* **It calls `eraseOrg`/`eraseUser` and implements no cascade of its own.**
|
|
73
|
+
* That is the AGL-1481 rule and this is exactly the shape it was written for:
|
|
74
|
+
* a replay tool with its own copy of the sweep list would be a second
|
|
75
|
+
* implementation of a cascade delete, and the last one drifted within a week —
|
|
76
|
+
* so a replay would faithfully re-erase the six collections somebody
|
|
77
|
+
* remembered and quietly leave the four they did not.
|
|
78
|
+
*
|
|
79
|
+
* ## The two things that make a naive replay fail
|
|
80
|
+
*
|
|
81
|
+
* 1. **`eraseOrg` refuses without a live request.** It re-reads
|
|
82
|
+
* `erasureRequestedAt` and the 7-day hold, deliberately, so a stale or
|
|
83
|
+
* cancelled request can never delete. A restored org may carry no request
|
|
84
|
+
* at all: if the customer asked AFTER the snapshot was taken, the
|
|
85
|
+
* resurrected document predates the ask. The audit row is what closes
|
|
86
|
+
* that — `after.requestedAt` records which request the original run
|
|
87
|
+
* fulfilled — so the request is reinstated at its original instant before
|
|
88
|
+
* the erasure is re-run. The hold is satisfied by construction: that
|
|
89
|
+
* instant is in the past by at least the hold, or the first erasure would
|
|
90
|
+
* not have executed either.
|
|
91
|
+
* 2. **`eraseUser` refuses an org owner.** A restore can bring back both a
|
|
92
|
+
* person and the workspace they owned, and `userErasureBlockers` then
|
|
93
|
+
* correctly refuses to cascade. That is reported as `blocked`, never
|
|
94
|
+
* forced: cascading here would delete a workspace as a side effect of
|
|
95
|
+
* replaying a personal-account erasure, which is precisely the consent
|
|
96
|
+
* boundary `eraseUser` exists to hold. Erase the org first — usually it
|
|
97
|
+
* has its own audit row in the same list — and run this again.
|
|
98
|
+
*
|
|
99
|
+
* ## Why the audit window is a first-class result and not a log line
|
|
100
|
+
*
|
|
101
|
+
* The rows this reads are hot for 90 days before the archiver moves them to
|
|
102
|
+
* Storage. That comfortably covers the 7-day PITR window and the ≤7-day usable
|
|
103
|
+
* managed backup. It does **not** cover a 90-day-old GCS export, where the
|
|
104
|
+
* erasure record and the export can age out within days of each other. A
|
|
105
|
+
* replay over a window that old returns an empty list for two
|
|
106
|
+
* indistinguishable reasons — nothing was erased, or nothing is left to read —
|
|
107
|
+
* so it says `incomplete: 'audit-window'` and reports `ok: false`. An empty
|
|
108
|
+
* list is not the same as a clean bill, and this is the one place that
|
|
109
|
+
* distinction decides whether a published promise was kept.
|
|
110
|
+
*
|
|
111
|
+
* The query is a range on `at` alone, with the action filtered in memory
|
|
112
|
+
* rather than in the query. `where('action','in',[…]).where('at','>=',…)`
|
|
113
|
+
* needs a composite index that does not exist, and an erasure replay is not
|
|
114
|
+
* where anybody should discover a `FAILED_PRECONDITION` — a single-field range
|
|
115
|
+
* is auto-indexed everywhere, including a freshly restored database whose
|
|
116
|
+
* composite indexes are still building.
|
|
117
|
+
*/
|
|
118
|
+
export declare function replayErasuresSince(options: {
|
|
119
|
+
/** The instant the restored snapshot was taken. */
|
|
120
|
+
sinceMs: number;
|
|
121
|
+
/** Report what WOULD be re-erased and change nothing. Default true. */
|
|
122
|
+
dryRun?: boolean;
|
|
123
|
+
/** Who is answerable, for the new audit rows. */
|
|
124
|
+
actorUid?: string;
|
|
125
|
+
now?: number;
|
|
126
|
+
}): Promise<ReplayErasuresResult>;
|
|
@@ -0,0 +1,217 @@
|
|
|
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 { FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
17
|
+
import { firebaseAdmin } from "./firebase-admin.js";
|
|
18
|
+
import { eraseOrg, eraseUser } from "./erase.js";
|
|
19
|
+
/**
|
|
20
|
+
* How long `adminAudit` rows stay queryable before `/api/admin/audit-archive`
|
|
21
|
+
* moves them to Storage. A replay window older than this cannot be answered
|
|
22
|
+
* from Firestore at all, which is the one failure mode that must not look
|
|
23
|
+
* like "nothing to replay".
|
|
24
|
+
*/ export const AUDIT_HOT_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* Re-apply every erasure a restore resurrected (AGL-1975).
|
|
27
|
+
*
|
|
28
|
+
* Live DPA §11 promises, in terms: *"a deletion instruction survives any
|
|
29
|
+
* restoration — data deleted at Customer's instruction and later restored from
|
|
30
|
+
* a backup will be deleted again."* Nothing implemented it. This does.
|
|
31
|
+
*
|
|
32
|
+
* **The reason it is needed is specific, not theoretical.** A Firestore import
|
|
33
|
+
* is **merge-by-id, not replace** (`docs/DISASTER_RECOVERY.md` Procedures C
|
|
34
|
+
* and D both say so), so importing a pre-erasure export into `(default)`
|
|
35
|
+
* silently reinstates every document an erasure deleted — the whole org tree,
|
|
36
|
+
* the profile, the support threads — alongside the recovery it was actually
|
|
37
|
+
* run for. It happens during an incident, when nobody is reading a DPA, and
|
|
38
|
+
* nothing anywhere says it happened.
|
|
39
|
+
*
|
|
40
|
+
* **It calls `eraseOrg`/`eraseUser` and implements no cascade of its own.**
|
|
41
|
+
* That is the AGL-1481 rule and this is exactly the shape it was written for:
|
|
42
|
+
* a replay tool with its own copy of the sweep list would be a second
|
|
43
|
+
* implementation of a cascade delete, and the last one drifted within a week —
|
|
44
|
+
* so a replay would faithfully re-erase the six collections somebody
|
|
45
|
+
* remembered and quietly leave the four they did not.
|
|
46
|
+
*
|
|
47
|
+
* ## The two things that make a naive replay fail
|
|
48
|
+
*
|
|
49
|
+
* 1. **`eraseOrg` refuses without a live request.** It re-reads
|
|
50
|
+
* `erasureRequestedAt` and the 7-day hold, deliberately, so a stale or
|
|
51
|
+
* cancelled request can never delete. A restored org may carry no request
|
|
52
|
+
* at all: if the customer asked AFTER the snapshot was taken, the
|
|
53
|
+
* resurrected document predates the ask. The audit row is what closes
|
|
54
|
+
* that — `after.requestedAt` records which request the original run
|
|
55
|
+
* fulfilled — so the request is reinstated at its original instant before
|
|
56
|
+
* the erasure is re-run. The hold is satisfied by construction: that
|
|
57
|
+
* instant is in the past by at least the hold, or the first erasure would
|
|
58
|
+
* not have executed either.
|
|
59
|
+
* 2. **`eraseUser` refuses an org owner.** A restore can bring back both a
|
|
60
|
+
* person and the workspace they owned, and `userErasureBlockers` then
|
|
61
|
+
* correctly refuses to cascade. That is reported as `blocked`, never
|
|
62
|
+
* forced: cascading here would delete a workspace as a side effect of
|
|
63
|
+
* replaying a personal-account erasure, which is precisely the consent
|
|
64
|
+
* boundary `eraseUser` exists to hold. Erase the org first — usually it
|
|
65
|
+
* has its own audit row in the same list — and run this again.
|
|
66
|
+
*
|
|
67
|
+
* ## Why the audit window is a first-class result and not a log line
|
|
68
|
+
*
|
|
69
|
+
* The rows this reads are hot for 90 days before the archiver moves them to
|
|
70
|
+
* Storage. That comfortably covers the 7-day PITR window and the ≤7-day usable
|
|
71
|
+
* managed backup. It does **not** cover a 90-day-old GCS export, where the
|
|
72
|
+
* erasure record and the export can age out within days of each other. A
|
|
73
|
+
* replay over a window that old returns an empty list for two
|
|
74
|
+
* indistinguishable reasons — nothing was erased, or nothing is left to read —
|
|
75
|
+
* so it says `incomplete: 'audit-window'` and reports `ok: false`. An empty
|
|
76
|
+
* list is not the same as a clean bill, and this is the one place that
|
|
77
|
+
* distinction decides whether a published promise was kept.
|
|
78
|
+
*
|
|
79
|
+
* The query is a range on `at` alone, with the action filtered in memory
|
|
80
|
+
* rather than in the query. `where('action','in',[…]).where('at','>=',…)`
|
|
81
|
+
* needs a composite index that does not exist, and an erasure replay is not
|
|
82
|
+
* where anybody should discover a `FAILED_PRECONDITION` — a single-field range
|
|
83
|
+
* is auto-indexed everywhere, including a freshly restored database whose
|
|
84
|
+
* composite indexes are still building.
|
|
85
|
+
*/ export async function replayErasuresSince(options) {
|
|
86
|
+
const { sinceMs, dryRun = true, actorUid = 'script:replay-erasures', now = Date.now() } = options;
|
|
87
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
88
|
+
const incomplete = now - sinceMs > AUDIT_HOT_WINDOW_MS ? 'audit-window' : undefined;
|
|
89
|
+
const rows = await firestore.collection('adminAudit').where('at', '>=', Timestamp.fromMillis(sinceMs)).orderBy('at', 'asc').get();
|
|
90
|
+
const entries = [];
|
|
91
|
+
for (const row of rows.docs){
|
|
92
|
+
var _row_get, _target_split_, _result_blockers;
|
|
93
|
+
const action = row.get('action');
|
|
94
|
+
if (action !== 'org.erased' && action !== 'user.erased') continue;
|
|
95
|
+
const target = String((_row_get = row.get('target')) != null ? _row_get : '');
|
|
96
|
+
const kind = action === 'org.erased' ? 'org' : 'user';
|
|
97
|
+
const id = (_target_split_ = target.split('/')[1]) != null ? _target_split_ : '';
|
|
98
|
+
if (!id) continue;
|
|
99
|
+
if (kind === 'org') {
|
|
100
|
+
var _ref;
|
|
101
|
+
var _row_get1;
|
|
102
|
+
const orgRef = firestore.collection('orgs').doc(id);
|
|
103
|
+
const org = await orgRef.get();
|
|
104
|
+
if (!org.exists) {
|
|
105
|
+
entries.push({
|
|
106
|
+
kind,
|
|
107
|
+
id,
|
|
108
|
+
outcome: 'absent'
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Which request the original run fulfilled. Without it a restored org
|
|
113
|
+
// that was never asked to be erased before the snapshot is refused with
|
|
114
|
+
// `no-request` — the erasure would silently not replay.
|
|
115
|
+
const requestedAt = Number((_ref = (_row_get1 = row.get('after')) == null ? void 0 : _row_get1.requestedAt) != null ? _ref : 0);
|
|
116
|
+
if (dryRun) {
|
|
117
|
+
entries.push({
|
|
118
|
+
kind,
|
|
119
|
+
id,
|
|
120
|
+
outcome: 'replayed',
|
|
121
|
+
detail: 'would re-erase (plan)'
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (requestedAt) {
|
|
126
|
+
await orgRef.set({
|
|
127
|
+
erasureRequestedAt: Timestamp.fromMillis(requestedAt)
|
|
128
|
+
}, {
|
|
129
|
+
merge: true
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const result = await eraseOrg(id, {
|
|
133
|
+
actorUid
|
|
134
|
+
});
|
|
135
|
+
entries.push(result.ok ? {
|
|
136
|
+
kind,
|
|
137
|
+
id,
|
|
138
|
+
outcome: 'replayed'
|
|
139
|
+
} : {
|
|
140
|
+
kind,
|
|
141
|
+
id,
|
|
142
|
+
outcome: 'blocked',
|
|
143
|
+
detail: result.skippedReason
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// A person. `profiles/{uid}` is checked as well as `users/{uid}`: the
|
|
148
|
+
// public identity is a separate top-level document (AGL-1970), so a
|
|
149
|
+
// restore can bring it back on its own.
|
|
150
|
+
const [user, profile] = await Promise.all([
|
|
151
|
+
firestore.collection('users').doc(id).get(),
|
|
152
|
+
firestore.collection('profiles').doc(id).get()
|
|
153
|
+
]);
|
|
154
|
+
if (!user.exists && !profile.exists) {
|
|
155
|
+
entries.push({
|
|
156
|
+
kind,
|
|
157
|
+
id,
|
|
158
|
+
outcome: 'absent'
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (dryRun) {
|
|
163
|
+
entries.push({
|
|
164
|
+
kind,
|
|
165
|
+
id,
|
|
166
|
+
outcome: 'replayed',
|
|
167
|
+
detail: 'would re-erase (plan)'
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const result = await eraseUser(id);
|
|
172
|
+
entries.push(result.ok ? {
|
|
173
|
+
kind,
|
|
174
|
+
id,
|
|
175
|
+
outcome: 'replayed'
|
|
176
|
+
} : {
|
|
177
|
+
kind,
|
|
178
|
+
id,
|
|
179
|
+
outcome: 'blocked',
|
|
180
|
+
// `owns-orgs` is the one a human has to act on: erase the
|
|
181
|
+
// workspace first, then run this again.
|
|
182
|
+
detail: result.skippedReason === 'owns-orgs' ? `owns-orgs (${((_result_blockers = result.blockers) != null ? _result_blockers : []).map((blocker)=>blocker.orgId).join(', ')})` : result.skippedReason
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const blocked = entries.some((entry)=>entry.outcome === 'blocked');
|
|
186
|
+
// A record that the promise was kept — or that it could not be. Written on
|
|
187
|
+
// a real run only: a plan did not replay anything, and an audit row saying
|
|
188
|
+
// it did is the mistake this shape has to make impossible.
|
|
189
|
+
if (!dryRun) {
|
|
190
|
+
await firestore.collection('adminAudit').add({
|
|
191
|
+
actorUid,
|
|
192
|
+
action: 'erasures.replayed',
|
|
193
|
+
target: `restore/${new Date(sinceMs).toISOString()}`,
|
|
194
|
+
before: {
|
|
195
|
+
examined: rows.size
|
|
196
|
+
},
|
|
197
|
+
after: {
|
|
198
|
+
sinceMs,
|
|
199
|
+
incomplete: incomplete != null ? incomplete : null,
|
|
200
|
+
replayed: entries.filter((e)=>e.outcome === 'replayed').length,
|
|
201
|
+
absent: entries.filter((e)=>e.outcome === 'absent').length,
|
|
202
|
+
blocked: entries.filter((e)=>e.outcome === 'blocked').length
|
|
203
|
+
},
|
|
204
|
+
at: FieldValue.serverTimestamp()
|
|
205
|
+
}).catch(()=>undefined);
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
sinceMs,
|
|
209
|
+
dryRun,
|
|
210
|
+
incomplete,
|
|
211
|
+
examined: rows.size,
|
|
212
|
+
entries,
|
|
213
|
+
ok: !blocked && !incomplete
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
//# sourceMappingURL=replay-erasures.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/replay-erasures.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 { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { eraseOrg, eraseUser } from './erase'\n\n/**\n * How long `adminAudit` rows stay queryable before `/api/admin/audit-archive`\n * moves them to Storage. A replay window older than this cannot be answered\n * from Firestore at all, which is the one failure mode that must not look\n * like \"nothing to replay\".\n */\nexport const AUDIT_HOT_WINDOW_MS = 90 * 24 * 60 * 60 * 1000\n\n/** What happened to one erasure that had to be re-applied. */\nexport interface ReplayedErasure {\n kind: 'org' | 'user'\n /** The org id or uid the original erasure destroyed. */\n id: string\n /**\n * - `replayed` — it had come back and has been erased again.\n * - `absent` — nothing was resurrected; the restore did not reach it.\n * - `blocked` — it came back and could NOT be erased again. The reason is\n * on `detail`, and this is the outcome a human has to act on.\n */\n outcome: 'replayed' | 'absent' | 'blocked'\n detail?: string\n}\n\nexport interface ReplayErasuresResult {\n /** Snapshot instant the restore was taken from — the replay's lower bound. */\n sinceMs: number\n /** True when nothing was written: a plan, not a replay. */\n dryRun: boolean\n /**\n * Set when the answer cannot be trusted. Today the only value is\n * `'audit-window'`: `sinceMs` predates the hot `adminAudit` window, so rows\n * naming erasures inside the restored span may already be in the Storage\n * archive and this run cannot see them. **An empty list under this warning\n * is not evidence that there was nothing to replay.**\n */\n incomplete?: 'audit-window'\n /** Audit rows examined. */\n examined: number\n entries: ReplayedErasure[]\n /** Convenience for the exit code — any `blocked` entry, or `incomplete`. */\n ok: boolean\n}\n\n/**\n * Re-apply every erasure a restore resurrected (AGL-1975).\n *\n * Live DPA §11 promises, in terms: *\"a deletion instruction survives any\n * restoration — data deleted at Customer's instruction and later restored from\n * a backup will be deleted again.\"* Nothing implemented it. This does.\n *\n * **The reason it is needed is specific, not theoretical.** A Firestore import\n * is **merge-by-id, not replace** (`docs/DISASTER_RECOVERY.md` Procedures C\n * and D both say so), so importing a pre-erasure export into `(default)`\n * silently reinstates every document an erasure deleted — the whole org tree,\n * the profile, the support threads — alongside the recovery it was actually\n * run for. It happens during an incident, when nobody is reading a DPA, and\n * nothing anywhere says it happened.\n *\n * **It calls `eraseOrg`/`eraseUser` and implements no cascade of its own.**\n * That is the AGL-1481 rule and this is exactly the shape it was written for:\n * a replay tool with its own copy of the sweep list would be a second\n * implementation of a cascade delete, and the last one drifted within a week —\n * so a replay would faithfully re-erase the six collections somebody\n * remembered and quietly leave the four they did not.\n *\n * ## The two things that make a naive replay fail\n *\n * 1. **`eraseOrg` refuses without a live request.** It re-reads\n * `erasureRequestedAt` and the 7-day hold, deliberately, so a stale or\n * cancelled request can never delete. A restored org may carry no request\n * at all: if the customer asked AFTER the snapshot was taken, the\n * resurrected document predates the ask. The audit row is what closes\n * that — `after.requestedAt` records which request the original run\n * fulfilled — so the request is reinstated at its original instant before\n * the erasure is re-run. The hold is satisfied by construction: that\n * instant is in the past by at least the hold, or the first erasure would\n * not have executed either.\n * 2. **`eraseUser` refuses an org owner.** A restore can bring back both a\n * person and the workspace they owned, and `userErasureBlockers` then\n * correctly refuses to cascade. That is reported as `blocked`, never\n * forced: cascading here would delete a workspace as a side effect of\n * replaying a personal-account erasure, which is precisely the consent\n * boundary `eraseUser` exists to hold. Erase the org first — usually it\n * has its own audit row in the same list — and run this again.\n *\n * ## Why the audit window is a first-class result and not a log line\n *\n * The rows this reads are hot for 90 days before the archiver moves them to\n * Storage. That comfortably covers the 7-day PITR window and the ≤7-day usable\n * managed backup. It does **not** cover a 90-day-old GCS export, where the\n * erasure record and the export can age out within days of each other. A\n * replay over a window that old returns an empty list for two\n * indistinguishable reasons — nothing was erased, or nothing is left to read —\n * so it says `incomplete: 'audit-window'` and reports `ok: false`. An empty\n * list is not the same as a clean bill, and this is the one place that\n * distinction decides whether a published promise was kept.\n *\n * The query is a range on `at` alone, with the action filtered in memory\n * rather than in the query. `where('action','in',[…]).where('at','>=',…)`\n * needs a composite index that does not exist, and an erasure replay is not\n * where anybody should discover a `FAILED_PRECONDITION` — a single-field range\n * is auto-indexed everywhere, including a freshly restored database whose\n * composite indexes are still building.\n */\nexport async function replayErasuresSince(options: {\n /** The instant the restored snapshot was taken. */\n sinceMs: number\n /** Report what WOULD be re-erased and change nothing. Default true. */\n dryRun?: boolean\n /** Who is answerable, for the new audit rows. */\n actorUid?: string\n now?: number\n}): Promise<ReplayErasuresResult> {\n const {\n sinceMs,\n dryRun = true,\n actorUid = 'script:replay-erasures',\n now = Date.now(),\n } = options\n const firestore = firebaseAdmin.app().firestore()\n\n const incomplete =\n now - sinceMs > AUDIT_HOT_WINDOW_MS ? ('audit-window' as const) : undefined\n\n const rows = await firestore\n .collection('adminAudit')\n .where('at', '>=', Timestamp.fromMillis(sinceMs))\n .orderBy('at', 'asc')\n .get()\n\n const entries: ReplayedErasure[] = []\n for (const row of rows.docs) {\n const action = row.get('action') as string | undefined\n if (action !== 'org.erased' && action !== 'user.erased') continue\n const target = String(row.get('target') ?? '')\n const kind = action === 'org.erased' ? 'org' : 'user'\n const id = target.split('/')[1] ?? ''\n if (!id) continue\n\n if (kind === 'org') {\n const orgRef = firestore.collection('orgs').doc(id)\n const org = await orgRef.get()\n if (!org.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n // Which request the original run fulfilled. Without it a restored org\n // that was never asked to be erased before the snapshot is refused with\n // `no-request` — the erasure would silently not replay.\n const requestedAt = Number(row.get('after')?.requestedAt ?? 0)\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n if (requestedAt) {\n await orgRef.set(\n { erasureRequestedAt: Timestamp.fromMillis(requestedAt) },\n { merge: true },\n )\n }\n const result = await eraseOrg(id, { actorUid })\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : { kind, id, outcome: 'blocked', detail: result.skippedReason },\n )\n continue\n }\n\n // A person. `profiles/{uid}` is checked as well as `users/{uid}`: the\n // public identity is a separate top-level document (AGL-1970), so a\n // restore can bring it back on its own.\n const [user, profile] = await Promise.all([\n firestore.collection('users').doc(id).get(),\n firestore.collection('profiles').doc(id).get(),\n ])\n if (!user.exists && !profile.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n const result = await eraseUser(id)\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : {\n kind,\n id,\n outcome: 'blocked',\n // `owns-orgs` is the one a human has to act on: erase the\n // workspace first, then run this again.\n detail:\n result.skippedReason === 'owns-orgs'\n ? `owns-orgs (${(result.blockers ?? [])\n .map((blocker) => blocker.orgId)\n .join(', ')})`\n : result.skippedReason,\n },\n )\n }\n\n const blocked = entries.some((entry) => entry.outcome === 'blocked')\n\n // A record that the promise was kept — or that it could not be. Written on\n // a real run only: a plan did not replay anything, and an audit row saying\n // it did is the mistake this shape has to make impossible.\n if (!dryRun) {\n await firestore\n .collection('adminAudit')\n .add({\n actorUid,\n action: 'erasures.replayed',\n target: `restore/${new Date(sinceMs).toISOString()}`,\n before: { examined: rows.size },\n after: {\n sinceMs,\n incomplete: incomplete ?? null,\n replayed: entries.filter((e) => e.outcome === 'replayed').length,\n absent: entries.filter((e) => e.outcome === 'absent').length,\n blocked: entries.filter((e) => e.outcome === 'blocked').length,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n }\n\n return {\n sinceMs,\n dryRun,\n incomplete,\n examined: rows.size,\n entries,\n ok: !blocked && !incomplete,\n }\n}\n"],"names":["FieldValue","Timestamp","firebaseAdmin","eraseOrg","eraseUser","AUDIT_HOT_WINDOW_MS","replayErasuresSince","options","sinceMs","dryRun","actorUid","now","Date","firestore","app","incomplete","undefined","rows","collection","where","fromMillis","orderBy","get","entries","row","docs","target","result","action","String","kind","id","split","orgRef","doc","org","exists","push","outcome","requestedAt","Number","detail","set","erasureRequestedAt","merge","ok","skippedReason","user","profile","Promise","all","blockers","map","blocker","orgId","join","blocked","some","entry","add","toISOString","before","examined","size","after","replayed","filter","e","length","absent","at","serverTimestamp","catch"],"mappings":"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"}
|