@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,96 @@
|
|
|
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
|
+
* `storagePath` is CLIENT DATA, and it addresses the shared bucket (AGL-1881).
|
|
19
|
+
*
|
|
20
|
+
* ## The hole this closes
|
|
21
|
+
*
|
|
22
|
+
* A media document records the object key its bytes live at, and seven code
|
|
23
|
+
* paths read that field and hand it straight to `bucket.file(...)` on the
|
|
24
|
+
* ADMIN SDK — which bypasses `cloud/firebase-storage.rules` entirely, because
|
|
25
|
+
* Admin credentials are not subject to them. The field was writable by any org
|
|
26
|
+
* editor or host editor:
|
|
27
|
+
*
|
|
28
|
+
* - `orgs/{orgId}/media/{id}` — the dedicated rule freezes only `visibleTo`.
|
|
29
|
+
* - `hosts/{hostId}/media/{id}` — `media` appears in NEITHER the create nor the
|
|
30
|
+
* update exclusion list of the host catch-all, so `canWriteHostContent` is
|
|
31
|
+
* the whole gate.
|
|
32
|
+
*
|
|
33
|
+
* So a legitimate editor could point their own media document at any object
|
|
34
|
+
* key in the bucket and then reach it through the paths that trust the field:
|
|
35
|
+
* `serveMediaCdn` streams it to an anonymous caller behind an edge-cached URL,
|
|
36
|
+
* `/api/media/replace` overwrites it, and the folder delete removes it. Read,
|
|
37
|
+
* overwrite and destroy, all cross-tenant, all from a document the attacker is
|
|
38
|
+
* supposed to own.
|
|
39
|
+
*
|
|
40
|
+
* The bucket is shared and holds more than customer media: `orgs/{orgId}/`,
|
|
41
|
+
* the legacy `hosts/{hostId}/media/`, `users/{uid}/` avatars, and — the part
|
|
42
|
+
* that makes this worse than "read another tenant's images" — the
|
|
43
|
+
* `adminAudit-archive/` and `erasures/` retention prefixes, which are FIXED and
|
|
44
|
+
* guessable rather than needing a stolen object key.
|
|
45
|
+
*
|
|
46
|
+
* ## Why the fix lives here and not only in the rules
|
|
47
|
+
*
|
|
48
|
+
* Freezing the field in the rules is right and is being done alongside this,
|
|
49
|
+
* but it is the second layer, not the first. Rules are deployed separately
|
|
50
|
+
* from code, staff writes bypass them, and a future writer could reintroduce
|
|
51
|
+
* the field legitimately. The durable invariant is at the SINK: an object key
|
|
52
|
+
* derived from a document must address that document's own scope, and that is
|
|
53
|
+
* checkable without trusting anything.
|
|
54
|
+
*
|
|
55
|
+
* ## The invariant
|
|
56
|
+
*
|
|
57
|
+
* Every writer in the repo — `media/upload`, `media/upload-url`, the v1 media
|
|
58
|
+
* create, and the folder relocation — builds the key as
|
|
59
|
+
* `` `${base}/media/` + optional folder path + mediaId ``, where `base` is
|
|
60
|
+
* `orgs/{orgId}` or `hosts/{hostId}`. There is no legitimate key outside
|
|
61
|
+
* `${base}/media/`, so that is the prefix required here rather than the looser
|
|
62
|
+
* `${base}/`.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* True when `candidate` is an object key that genuinely lives under this
|
|
66
|
+
* scope's media prefix.
|
|
67
|
+
*
|
|
68
|
+
* Traversal is rejected rather than normalized. GCS object keys are literal
|
|
69
|
+
* strings — `a/../b` is a key containing dots, not a path that resolves — so
|
|
70
|
+
* `..` cannot escape a prefix in the bucket itself. It is refused anyway
|
|
71
|
+
* because these keys are also handed to `File.copy`, tooling and log lines
|
|
72
|
+
* that may not share that property, and because a `..` in a key this repo
|
|
73
|
+
* writes is by construction not one this repo wrote.
|
|
74
|
+
*/
|
|
75
|
+
export declare function isMediaStoragePathInScope(candidate: unknown, base: string): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* The object key for a media document: the recorded `storagePath` when it is
|
|
78
|
+
* genuinely in scope, otherwise the legacy flat layout.
|
|
79
|
+
*
|
|
80
|
+
* Falling back rather than throwing is deliberate. The fallback names the
|
|
81
|
+
* document's OWN object, so a tampered record addresses nothing but itself —
|
|
82
|
+
* a read 404s, a delete removes a key that does not exist, and a replace
|
|
83
|
+
* writes where the asset should have been. Throwing would turn a tampered
|
|
84
|
+
* document into a 500 on a route that other, innocent assets share, which
|
|
85
|
+
* trades a contained failure for a noisy one.
|
|
86
|
+
*
|
|
87
|
+
* `onRefused` exists because a refusal here is not a normal condition: every
|
|
88
|
+
* writer in the repo produces an in-scope key, so a refused one means the
|
|
89
|
+
* document was written by something else. Callers that can log should.
|
|
90
|
+
*/
|
|
91
|
+
export declare function mediaStoragePathInScope(options: {
|
|
92
|
+
storagePath: unknown;
|
|
93
|
+
base: string;
|
|
94
|
+
mediaId: string;
|
|
95
|
+
onRefused?: (candidate: unknown) => void;
|
|
96
|
+
}): string;
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
* `storagePath` is CLIENT DATA, and it addresses the shared bucket (AGL-1881).
|
|
18
|
+
*
|
|
19
|
+
* ## The hole this closes
|
|
20
|
+
*
|
|
21
|
+
* A media document records the object key its bytes live at, and seven code
|
|
22
|
+
* paths read that field and hand it straight to `bucket.file(...)` on the
|
|
23
|
+
* ADMIN SDK — which bypasses `cloud/firebase-storage.rules` entirely, because
|
|
24
|
+
* Admin credentials are not subject to them. The field was writable by any org
|
|
25
|
+
* editor or host editor:
|
|
26
|
+
*
|
|
27
|
+
* - `orgs/{orgId}/media/{id}` — the dedicated rule freezes only `visibleTo`.
|
|
28
|
+
* - `hosts/{hostId}/media/{id}` — `media` appears in NEITHER the create nor the
|
|
29
|
+
* update exclusion list of the host catch-all, so `canWriteHostContent` is
|
|
30
|
+
* the whole gate.
|
|
31
|
+
*
|
|
32
|
+
* So a legitimate editor could point their own media document at any object
|
|
33
|
+
* key in the bucket and then reach it through the paths that trust the field:
|
|
34
|
+
* `serveMediaCdn` streams it to an anonymous caller behind an edge-cached URL,
|
|
35
|
+
* `/api/media/replace` overwrites it, and the folder delete removes it. Read,
|
|
36
|
+
* overwrite and destroy, all cross-tenant, all from a document the attacker is
|
|
37
|
+
* supposed to own.
|
|
38
|
+
*
|
|
39
|
+
* The bucket is shared and holds more than customer media: `orgs/{orgId}/`,
|
|
40
|
+
* the legacy `hosts/{hostId}/media/`, `users/{uid}/` avatars, and — the part
|
|
41
|
+
* that makes this worse than "read another tenant's images" — the
|
|
42
|
+
* `adminAudit-archive/` and `erasures/` retention prefixes, which are FIXED and
|
|
43
|
+
* guessable rather than needing a stolen object key.
|
|
44
|
+
*
|
|
45
|
+
* ## Why the fix lives here and not only in the rules
|
|
46
|
+
*
|
|
47
|
+
* Freezing the field in the rules is right and is being done alongside this,
|
|
48
|
+
* but it is the second layer, not the first. Rules are deployed separately
|
|
49
|
+
* from code, staff writes bypass them, and a future writer could reintroduce
|
|
50
|
+
* the field legitimately. The durable invariant is at the SINK: an object key
|
|
51
|
+
* derived from a document must address that document's own scope, and that is
|
|
52
|
+
* checkable without trusting anything.
|
|
53
|
+
*
|
|
54
|
+
* ## The invariant
|
|
55
|
+
*
|
|
56
|
+
* Every writer in the repo — `media/upload`, `media/upload-url`, the v1 media
|
|
57
|
+
* create, and the folder relocation — builds the key as
|
|
58
|
+
* `` `${base}/media/` + optional folder path + mediaId ``, where `base` is
|
|
59
|
+
* `orgs/{orgId}` or `hosts/{hostId}`. There is no legitimate key outside
|
|
60
|
+
* `${base}/media/`, so that is the prefix required here rather than the looser
|
|
61
|
+
* `${base}/`.
|
|
62
|
+
*/ /**
|
|
63
|
+
* True when `candidate` is an object key that genuinely lives under this
|
|
64
|
+
* scope's media prefix.
|
|
65
|
+
*
|
|
66
|
+
* Traversal is rejected rather than normalized. GCS object keys are literal
|
|
67
|
+
* strings — `a/../b` is a key containing dots, not a path that resolves — so
|
|
68
|
+
* `..` cannot escape a prefix in the bucket itself. It is refused anyway
|
|
69
|
+
* because these keys are also handed to `File.copy`, tooling and log lines
|
|
70
|
+
* that may not share that property, and because a `..` in a key this repo
|
|
71
|
+
* writes is by construction not one this repo wrote.
|
|
72
|
+
*/ export function isMediaStoragePathInScope(candidate, base) {
|
|
73
|
+
if (typeof candidate !== 'string') return false;
|
|
74
|
+
const value = candidate.trim();
|
|
75
|
+
if (!value || value !== candidate) return false;
|
|
76
|
+
if (!base) return false;
|
|
77
|
+
const prefix = `${base}/media/`;
|
|
78
|
+
if (!value.startsWith(prefix)) return false;
|
|
79
|
+
// Something has to follow the prefix; the prefix alone is a folder.
|
|
80
|
+
if (value.length === prefix.length) return false;
|
|
81
|
+
if (value.includes('//')) return false;
|
|
82
|
+
return !value.split('/').includes('..');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The object key for a media document: the recorded `storagePath` when it is
|
|
86
|
+
* genuinely in scope, otherwise the legacy flat layout.
|
|
87
|
+
*
|
|
88
|
+
* Falling back rather than throwing is deliberate. The fallback names the
|
|
89
|
+
* document's OWN object, so a tampered record addresses nothing but itself —
|
|
90
|
+
* a read 404s, a delete removes a key that does not exist, and a replace
|
|
91
|
+
* writes where the asset should have been. Throwing would turn a tampered
|
|
92
|
+
* document into a 500 on a route that other, innocent assets share, which
|
|
93
|
+
* trades a contained failure for a noisy one.
|
|
94
|
+
*
|
|
95
|
+
* `onRefused` exists because a refusal here is not a normal condition: every
|
|
96
|
+
* writer in the repo produces an in-scope key, so a refused one means the
|
|
97
|
+
* document was written by something else. Callers that can log should.
|
|
98
|
+
*/ export function mediaStoragePathInScope(options) {
|
|
99
|
+
const { storagePath, base, mediaId } = options;
|
|
100
|
+
const fallback = `${base}/media/${mediaId}`;
|
|
101
|
+
if (storagePath === undefined || storagePath === null || storagePath === '') {
|
|
102
|
+
// The ordinary legacy case — the field predates real folders. Not a
|
|
103
|
+
// refusal, and must not be reported as one.
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
if (isMediaStoragePathInScope(storagePath, base)) return storagePath;
|
|
107
|
+
options.onRefused == null ? void 0 : options.onRefused.call(options, storagePath);
|
|
108
|
+
return fallback;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
//# sourceMappingURL=media-storage-path.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-storage-path.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 * `storagePath` is CLIENT DATA, and it addresses the shared bucket (AGL-1881).\n *\n * ## The hole this closes\n *\n * A media document records the object key its bytes live at, and seven code\n * paths read that field and hand it straight to `bucket.file(...)` on the\n * ADMIN SDK — which bypasses `cloud/firebase-storage.rules` entirely, because\n * Admin credentials are not subject to them. The field was writable by any org\n * editor or host editor:\n *\n * - `orgs/{orgId}/media/{id}` — the dedicated rule freezes only `visibleTo`.\n * - `hosts/{hostId}/media/{id}` — `media` appears in NEITHER the create nor the\n * update exclusion list of the host catch-all, so `canWriteHostContent` is\n * the whole gate.\n *\n * So a legitimate editor could point their own media document at any object\n * key in the bucket and then reach it through the paths that trust the field:\n * `serveMediaCdn` streams it to an anonymous caller behind an edge-cached URL,\n * `/api/media/replace` overwrites it, and the folder delete removes it. Read,\n * overwrite and destroy, all cross-tenant, all from a document the attacker is\n * supposed to own.\n *\n * The bucket is shared and holds more than customer media: `orgs/{orgId}/`,\n * the legacy `hosts/{hostId}/media/`, `users/{uid}/` avatars, and — the part\n * that makes this worse than \"read another tenant's images\" — the\n * `adminAudit-archive/` and `erasures/` retention prefixes, which are FIXED and\n * guessable rather than needing a stolen object key.\n *\n * ## Why the fix lives here and not only in the rules\n *\n * Freezing the field in the rules is right and is being done alongside this,\n * but it is the second layer, not the first. Rules are deployed separately\n * from code, staff writes bypass them, and a future writer could reintroduce\n * the field legitimately. The durable invariant is at the SINK: an object key\n * derived from a document must address that document's own scope, and that is\n * checkable without trusting anything.\n *\n * ## The invariant\n *\n * Every writer in the repo — `media/upload`, `media/upload-url`, the v1 media\n * create, and the folder relocation — builds the key as\n * `` `${base}/media/` + optional folder path + mediaId ``, where `base` is\n * `orgs/{orgId}` or `hosts/{hostId}`. There is no legitimate key outside\n * `${base}/media/`, so that is the prefix required here rather than the looser\n * `${base}/`.\n */\n\n/**\n * True when `candidate` is an object key that genuinely lives under this\n * scope's media prefix.\n *\n * Traversal is rejected rather than normalized. GCS object keys are literal\n * strings — `a/../b` is a key containing dots, not a path that resolves — so\n * `..` cannot escape a prefix in the bucket itself. It is refused anyway\n * because these keys are also handed to `File.copy`, tooling and log lines\n * that may not share that property, and because a `..` in a key this repo\n * writes is by construction not one this repo wrote.\n */\nexport function isMediaStoragePathInScope(\n candidate: unknown,\n base: string,\n): boolean {\n if (typeof candidate !== 'string') return false\n const value = candidate.trim()\n if (!value || value !== candidate) return false\n if (!base) return false\n const prefix = `${base}/media/`\n if (!value.startsWith(prefix)) return false\n // Something has to follow the prefix; the prefix alone is a folder.\n if (value.length === prefix.length) return false\n if (value.includes('//')) return false\n return !value.split('/').includes('..')\n}\n\n/**\n * The object key for a media document: the recorded `storagePath` when it is\n * genuinely in scope, otherwise the legacy flat layout.\n *\n * Falling back rather than throwing is deliberate. The fallback names the\n * document's OWN object, so a tampered record addresses nothing but itself —\n * a read 404s, a delete removes a key that does not exist, and a replace\n * writes where the asset should have been. Throwing would turn a tampered\n * document into a 500 on a route that other, innocent assets share, which\n * trades a contained failure for a noisy one.\n *\n * `onRefused` exists because a refusal here is not a normal condition: every\n * writer in the repo produces an in-scope key, so a refused one means the\n * document was written by something else. Callers that can log should.\n */\nexport function mediaStoragePathInScope(options: {\n storagePath: unknown\n base: string\n mediaId: string\n onRefused?: (candidate: unknown) => void\n}): string {\n const { storagePath, base, mediaId } = options\n const fallback = `${base}/media/${mediaId}`\n if (storagePath === undefined || storagePath === null || storagePath === '') {\n // The ordinary legacy case — the field predates real folders. Not a\n // refusal, and must not be reported as one.\n return fallback\n }\n if (isMediaStoragePathInScope(storagePath, base)) return storagePath as string\n options.onRefused?.(storagePath)\n return fallback\n}\n"],"names":["isMediaStoragePathInScope","candidate","base","value","trim","prefix","startsWith","length","includes","split","mediaStoragePathInScope","options","storagePath","mediaId","fallback","undefined","onRefused"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8CC,GAED;;;;;;;;;;CAUC,GACD,OAAO,SAASA,0BACdC,SAAkB,EAClBC,IAAY;IAEZ,IAAI,OAAOD,cAAc,UAAU,OAAO;IAC1C,MAAME,QAAQF,UAAUG,IAAI;IAC5B,IAAI,CAACD,SAASA,UAAUF,WAAW,OAAO;IAC1C,IAAI,CAACC,MAAM,OAAO;IAClB,MAAMG,SAAS,GAAGH,KAAK,OAAO,CAAC;IAC/B,IAAI,CAACC,MAAMG,UAAU,CAACD,SAAS,OAAO;IACtC,oEAAoE;IACpE,IAAIF,MAAMI,MAAM,KAAKF,OAAOE,MAAM,EAAE,OAAO;IAC3C,IAAIJ,MAAMK,QAAQ,CAAC,OAAO,OAAO;IACjC,OAAO,CAACL,MAAMM,KAAK,CAAC,KAAKD,QAAQ,CAAC;AACpC;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASE,wBAAwBC,OAKvC;IACC,MAAM,EAAEC,WAAW,EAAEV,IAAI,EAAEW,OAAO,EAAE,GAAGF;IACvC,MAAMG,WAAW,GAAGZ,KAAK,OAAO,EAAEW,SAAS;IAC3C,IAAID,gBAAgBG,aAAaH,gBAAgB,QAAQA,gBAAgB,IAAI;QAC3E,oEAAoE;QACpE,4CAA4C;QAC5C,OAAOE;IACT;IACA,IAAId,0BAA0BY,aAAaV,OAAO,OAAOU;IACzDD,QAAQK,SAAS,oBAAjBL,QAAQK,SAAS,MAAjBL,SAAoBC;IACpB,OAAOE;AACT"}
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
* The largest object this path will digest.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately equal to the largest non-video `signedMaxBytes` in
|
|
21
|
+
* `apps/console/utils/media-upload-limits.ts` (ZIP, `.pptx`). Raising it
|
|
22
|
+
* only ever adds video; lowering it silently strips the strong key off a
|
|
23
|
+
* document type that has one today, which is why the spec asserts the
|
|
24
|
+
* number instead of trusting it.
|
|
25
|
+
*/
|
|
26
|
+
export declare const MEDIA_STRONG_DIGEST_MAX_BYTES: number;
|
|
27
|
+
/** Why no digest was taken. Absent when one was. */
|
|
28
|
+
export type StrongDigestSkip = 'over-ceiling' | 'unreadable';
|
|
29
|
+
export interface StoredObjectDigest {
|
|
30
|
+
/** Lower-case hex sha256 of the whole object, or absent. */
|
|
31
|
+
sha256?: string;
|
|
32
|
+
/** Set when — and only when — `sha256` is absent. */
|
|
33
|
+
skipped?: StrongDigestSkip;
|
|
34
|
+
/** Bytes actually pulled out of the bucket. Zero when the bound bit. */
|
|
35
|
+
bytesRead: number;
|
|
36
|
+
/**
|
|
37
|
+
* The largest single buffer held while hashing.
|
|
38
|
+
*
|
|
39
|
+
* Exported because it is the only externally checkable evidence that this
|
|
40
|
+
* function streams rather than buffers — a caller, a log line or a spec
|
|
41
|
+
* can distinguish the two without reading the implementation. It is also
|
|
42
|
+
* the number that would move first if someone "simplified" this back to
|
|
43
|
+
* `file.download()`.
|
|
44
|
+
*/
|
|
45
|
+
peakChunkBytes: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* May an object of this size be digested?
|
|
49
|
+
*
|
|
50
|
+
* A pure predicate so the route and the spec (AGL-1630) apply ONE rule
|
|
51
|
+
* rather than copies that drift.
|
|
52
|
+
*
|
|
53
|
+
* An unknown, zero or non-finite size answers `false`, never `true`. GCS
|
|
54
|
+
* reports no size for a composite object, and guessing "small" there means
|
|
55
|
+
* starting an unbounded read on the exact shape that cannot be bounded.
|
|
56
|
+
*/
|
|
57
|
+
export declare function strongDigestWithinCeiling(sizeBytes: number, maxBytes?: number): boolean;
|
|
58
|
+
/** The minimal Storage surface this needs; injectable for tests. */
|
|
59
|
+
export interface DigestibleStorageFile {
|
|
60
|
+
createReadStream: (options?: unknown) => NodeJS.ReadableStream;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Stream one stored object through sha256.
|
|
64
|
+
*
|
|
65
|
+
* FAILS SOFT, in both directions, and that is the whole safety argument:
|
|
66
|
+
*
|
|
67
|
+
* * An unreadable object returns `unreadable`, not a throw. The callers are
|
|
68
|
+
* an upload finalize and a backfill; a Storage blip must not start
|
|
69
|
+
* refusing uploads, matching the fail-open posture of the ranged
|
|
70
|
+
* inspection above it and the deny-list read below it.
|
|
71
|
+
* * A stream that ends SHORT — a truncated range, a proxy cutting the body,
|
|
72
|
+
* a mid-flight reset — returns `unreadable` rather than the digest of
|
|
73
|
+
* what did arrive. A partial digest is worse than none: it is a
|
|
74
|
+
* well-formed 64-hex value that matches nothing, and because
|
|
75
|
+
* `mediaQuarantineKeys` PREFERS the strong digest, writing one would
|
|
76
|
+
* shadow the legacy hash that would otherwise have matched. That is a
|
|
77
|
+
* takedown quietly lifting itself, which is the one failure this whole
|
|
78
|
+
* subsystem may not have.
|
|
79
|
+
*/
|
|
80
|
+
export declare function storedObjectSha256(options: {
|
|
81
|
+
file: DigestibleStorageFile;
|
|
82
|
+
/** The object's real size, as Storage reports it. */
|
|
83
|
+
sizeBytes: number;
|
|
84
|
+
maxBytes?: number;
|
|
85
|
+
}): Promise<StoredObjectDigest>;
|
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
* A BOUNDED strong content digest for objects the server never held
|
|
18
|
+
* (AGL-1629) — the decision AGL-1614 deferred, taken now that AGL-1475
|
|
19
|
+
* changed what it costs.
|
|
20
|
+
*
|
|
21
|
+
* ## What changed since the question was asked
|
|
22
|
+
*
|
|
23
|
+
* AGL-1614 wrote `contentSha256` only where the bytes were already in hand,
|
|
24
|
+
* and argued — correctly, at the time — that the signed-upload route could
|
|
25
|
+
* not have one: the browser PUTs straight to GCS, GCS computes md5 and
|
|
26
|
+
* crc32c but never sha256, and `file.download()` on a 200 MB video is the
|
|
27
|
+
* exact cost the route exists to avoid. The conclusion was "needs a
|
|
28
|
+
* product call on cost".
|
|
29
|
+
*
|
|
30
|
+
* AGL-1475 (`c607678ee`) then made finalize read bytes anyway — a 4 KB head
|
|
31
|
+
* and, for document archives, a 64 KB tail — to inspect structure. The
|
|
32
|
+
* route is no longer byte-blind, and the honest question is no longer
|
|
33
|
+
* "download or not" but "how much".
|
|
34
|
+
*
|
|
35
|
+
* ## The measurement the decision rests on
|
|
36
|
+
*
|
|
37
|
+
* Production media bucket, `gs://aglyn-main.appspot.com`, read-only listing
|
|
38
|
+
* on 2026-08-20: **182 source objects, 44,774,216 bytes (42.70 MiB)** —
|
|
39
|
+
* plus 269 derived `__wNNN.webp` variants, which carry no media document
|
|
40
|
+
* and need no digest. Largest single object **7,742,225 B (7.38 MiB)**;
|
|
41
|
+
* mean 246 KB; 177 of 182 under 1 MB; **nothing above 10 MiB and no video
|
|
42
|
+
* at all**. Hashing the entire back catalogue is ~$0.005 of egress.
|
|
43
|
+
*
|
|
44
|
+
* So the "200 MB video" that made this expensive is a CEILING, not a
|
|
45
|
+
* population. It still has to be handled — a ceiling is what an attacker
|
|
46
|
+
* aims at — but it must not be allowed to price the other 99%.
|
|
47
|
+
*
|
|
48
|
+
* ## The decision
|
|
49
|
+
*
|
|
50
|
+
* Digest every signed upload up to {@link MEDIA_STRONG_DIGEST_MAX_BYTES},
|
|
51
|
+
* by STREAMING; above it, write nothing and degrade exactly as before.
|
|
52
|
+
*
|
|
53
|
+
* **Why 50 MiB and not a rounder number.** It is precisely the largest
|
|
54
|
+
* NON-VIDEO ceiling on the signed route: ZIP is 50 MB and `.pptx` is 50 MB,
|
|
55
|
+
* PDF and the Office types are 25 MB, images are 15 MB. At 50 MiB every
|
|
56
|
+
* type except video is covered *at its own maximum* — there is no size at
|
|
57
|
+
* which a PDF or a brand-kit zip silently loses its strong digest. Only
|
|
58
|
+
* video (200 MB) can exceed it, and only past 50 MB.
|
|
59
|
+
*
|
|
60
|
+
* **What the bound costs, precisely.** One additional full read of the
|
|
61
|
+
* object, capped at 50 MiB. GCS internet egress is ~$0.12/GiB, so the
|
|
62
|
+
* worst case is **$0.0057 per upload** and the measured mean case is
|
|
63
|
+
* **$0.00003**. sha256 in Node runs at roughly 500 MB/s, so the CPU is
|
|
64
|
+
* ~0.1 s at the ceiling. The route already declares `maxDuration = 60` and
|
|
65
|
+
* already budgets a 15 MB download plus three `sharp` encodes inside it.
|
|
66
|
+
*
|
|
67
|
+
* **What the bound buys.**
|
|
68
|
+
*
|
|
69
|
+
* 1. **Cross-route matching, which was previously impossible.** The
|
|
70
|
+
* in-process routes key on sha256 of the bytes; this route keyed on a
|
|
71
|
+
* 16-hex truncation of GCS's md5. The same file through the two paths
|
|
72
|
+
* produced two unrelated keys, so "re-uploading the same bytes stays
|
|
73
|
+
* quarantined" held within an ingestion path and not across them. Under
|
|
74
|
+
* the ceiling it now holds across them. That is the headline.
|
|
75
|
+
* 2. **The md5 collision target goes away** for everything under the
|
|
76
|
+
* ceiling. A chosen-prefix md5 collision is hours of ordinary compute
|
|
77
|
+
* and yields two files sharing the 64-bit truncation — a cross-tenant
|
|
78
|
+
* availability attack an outsider could mount. The preferred key stops
|
|
79
|
+
* being md5-derived.
|
|
80
|
+
* 3. **Ingestion refusal actually works** on the signed route for known
|
|
81
|
+
* bytes, rather than only at delivery via the per-asset fallback.
|
|
82
|
+
*
|
|
83
|
+
* **What it does not buy, stated rather than implied.** Video above 50 MB
|
|
84
|
+
* carries no `contentSha256` and never will on this path. That is the
|
|
85
|
+
* bounded alternative, not a pretence of completeness: such an object
|
|
86
|
+
* matches on the legacy truncated hash and on its per-asset key, exactly as
|
|
87
|
+
* every signed upload did before this module existed. Video is also the
|
|
88
|
+
* type least plausibly a chosen-prefix collision target or a
|
|
89
|
+
* shared-bytes malware carrier, which is why it is the class the bound
|
|
90
|
+
* sacrifices.
|
|
91
|
+
*
|
|
92
|
+
* ## Why streaming rather than `download()`
|
|
93
|
+
*
|
|
94
|
+
* `file.download()` resolves a single Buffer. At the ceiling that is 50 MiB
|
|
95
|
+
* of function heap on a path that is concurrently holding a `sharp`
|
|
96
|
+
* pipeline. Streaming through the hash keeps the resident set at one chunk
|
|
97
|
+
* regardless of object size, which is what makes a 50 MiB bound safe to
|
|
98
|
+
* state at all. The spec asserts the peak rather than only the hex, because
|
|
99
|
+
* a hex-only assertion passes just as happily against a buffering
|
|
100
|
+
* implementation.
|
|
101
|
+
*/ import { createHash } from "node:crypto";
|
|
102
|
+
/**
|
|
103
|
+
* The largest object this path will digest.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately equal to the largest non-video `signedMaxBytes` in
|
|
106
|
+
* `apps/console/utils/media-upload-limits.ts` (ZIP, `.pptx`). Raising it
|
|
107
|
+
* only ever adds video; lowering it silently strips the strong key off a
|
|
108
|
+
* document type that has one today, which is why the spec asserts the
|
|
109
|
+
* number instead of trusting it.
|
|
110
|
+
*/ export const MEDIA_STRONG_DIGEST_MAX_BYTES = 50 * 1024 * 1024;
|
|
111
|
+
/**
|
|
112
|
+
* May an object of this size be digested?
|
|
113
|
+
*
|
|
114
|
+
* A pure predicate so the route and the spec (AGL-1630) apply ONE rule
|
|
115
|
+
* rather than copies that drift.
|
|
116
|
+
*
|
|
117
|
+
* An unknown, zero or non-finite size answers `false`, never `true`. GCS
|
|
118
|
+
* reports no size for a composite object, and guessing "small" there means
|
|
119
|
+
* starting an unbounded read on the exact shape that cannot be bounded.
|
|
120
|
+
*/ export function strongDigestWithinCeiling(sizeBytes, maxBytes = MEDIA_STRONG_DIGEST_MAX_BYTES) {
|
|
121
|
+
if (typeof sizeBytes !== 'number' || !Number.isFinite(sizeBytes)) return false;
|
|
122
|
+
if (sizeBytes <= 0) return false;
|
|
123
|
+
return sizeBytes <= maxBytes;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Stream one stored object through sha256.
|
|
127
|
+
*
|
|
128
|
+
* FAILS SOFT, in both directions, and that is the whole safety argument:
|
|
129
|
+
*
|
|
130
|
+
* * An unreadable object returns `unreadable`, not a throw. The callers are
|
|
131
|
+
* an upload finalize and a backfill; a Storage blip must not start
|
|
132
|
+
* refusing uploads, matching the fail-open posture of the ranged
|
|
133
|
+
* inspection above it and the deny-list read below it.
|
|
134
|
+
* * A stream that ends SHORT — a truncated range, a proxy cutting the body,
|
|
135
|
+
* a mid-flight reset — returns `unreadable` rather than the digest of
|
|
136
|
+
* what did arrive. A partial digest is worse than none: it is a
|
|
137
|
+
* well-formed 64-hex value that matches nothing, and because
|
|
138
|
+
* `mediaQuarantineKeys` PREFERS the strong digest, writing one would
|
|
139
|
+
* shadow the legacy hash that would otherwise have matched. That is a
|
|
140
|
+
* takedown quietly lifting itself, which is the one failure this whole
|
|
141
|
+
* subsystem may not have.
|
|
142
|
+
*/ export async function storedObjectSha256(options) {
|
|
143
|
+
var _options_maxBytes;
|
|
144
|
+
const { file, sizeBytes } = options;
|
|
145
|
+
const maxBytes = (_options_maxBytes = options.maxBytes) != null ? _options_maxBytes : MEDIA_STRONG_DIGEST_MAX_BYTES;
|
|
146
|
+
// Before any egress, not after — a bound that reads and discards costs
|
|
147
|
+
// exactly what it exists to save.
|
|
148
|
+
if (!strongDigestWithinCeiling(sizeBytes, maxBytes)) {
|
|
149
|
+
return {
|
|
150
|
+
skipped: 'over-ceiling',
|
|
151
|
+
bytesRead: 0,
|
|
152
|
+
peakChunkBytes: 0
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const hash = createHash('sha256');
|
|
156
|
+
let bytesRead = 0;
|
|
157
|
+
let peakChunkBytes = 0;
|
|
158
|
+
try {
|
|
159
|
+
const stream = file.createReadStream();
|
|
160
|
+
for await (const chunk of stream){
|
|
161
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
162
|
+
if (buffer.length > peakChunkBytes) peakChunkBytes = buffer.length;
|
|
163
|
+
bytesRead += buffer.length;
|
|
164
|
+
hash.update(new Uint8Array(buffer));
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error('[media] strong digest read failed', error);
|
|
168
|
+
return {
|
|
169
|
+
skipped: 'unreadable',
|
|
170
|
+
bytesRead,
|
|
171
|
+
peakChunkBytes
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (bytesRead !== sizeBytes) {
|
|
175
|
+
// See the fail-soft note above: a short read is not a small file.
|
|
176
|
+
console.error('[media] strong digest read short', `expected ${sizeBytes} bytes, read ${bytesRead}`);
|
|
177
|
+
return {
|
|
178
|
+
skipped: 'unreadable',
|
|
179
|
+
bytesRead,
|
|
180
|
+
peakChunkBytes
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
sha256: hash.digest('hex'),
|
|
185
|
+
bytesRead,
|
|
186
|
+
peakChunkBytes
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
//# sourceMappingURL=media-strong-digest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-strong-digest.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 * A BOUNDED strong content digest for objects the server never held\n * (AGL-1629) — the decision AGL-1614 deferred, taken now that AGL-1475\n * changed what it costs.\n *\n * ## What changed since the question was asked\n *\n * AGL-1614 wrote `contentSha256` only where the bytes were already in hand,\n * and argued — correctly, at the time — that the signed-upload route could\n * not have one: the browser PUTs straight to GCS, GCS computes md5 and\n * crc32c but never sha256, and `file.download()` on a 200 MB video is the\n * exact cost the route exists to avoid. The conclusion was \"needs a\n * product call on cost\".\n *\n * AGL-1475 (`c607678ee`) then made finalize read bytes anyway — a 4 KB head\n * and, for document archives, a 64 KB tail — to inspect structure. The\n * route is no longer byte-blind, and the honest question is no longer\n * \"download or not\" but \"how much\".\n *\n * ## The measurement the decision rests on\n *\n * Production media bucket, `gs://aglyn-main.appspot.com`, read-only listing\n * on 2026-08-20: **182 source objects, 44,774,216 bytes (42.70 MiB)** —\n * plus 269 derived `__wNNN.webp` variants, which carry no media document\n * and need no digest. Largest single object **7,742,225 B (7.38 MiB)**;\n * mean 246 KB; 177 of 182 under 1 MB; **nothing above 10 MiB and no video\n * at all**. Hashing the entire back catalogue is ~$0.005 of egress.\n *\n * So the \"200 MB video\" that made this expensive is a CEILING, not a\n * population. It still has to be handled — a ceiling is what an attacker\n * aims at — but it must not be allowed to price the other 99%.\n *\n * ## The decision\n *\n * Digest every signed upload up to {@link MEDIA_STRONG_DIGEST_MAX_BYTES},\n * by STREAMING; above it, write nothing and degrade exactly as before.\n *\n * **Why 50 MiB and not a rounder number.** It is precisely the largest\n * NON-VIDEO ceiling on the signed route: ZIP is 50 MB and `.pptx` is 50 MB,\n * PDF and the Office types are 25 MB, images are 15 MB. At 50 MiB every\n * type except video is covered *at its own maximum* — there is no size at\n * which a PDF or a brand-kit zip silently loses its strong digest. Only\n * video (200 MB) can exceed it, and only past 50 MB.\n *\n * **What the bound costs, precisely.** One additional full read of the\n * object, capped at 50 MiB. GCS internet egress is ~$0.12/GiB, so the\n * worst case is **$0.0057 per upload** and the measured mean case is\n * **$0.00003**. sha256 in Node runs at roughly 500 MB/s, so the CPU is\n * ~0.1 s at the ceiling. The route already declares `maxDuration = 60` and\n * already budgets a 15 MB download plus three `sharp` encodes inside it.\n *\n * **What the bound buys.**\n *\n * 1. **Cross-route matching, which was previously impossible.** The\n * in-process routes key on sha256 of the bytes; this route keyed on a\n * 16-hex truncation of GCS's md5. The same file through the two paths\n * produced two unrelated keys, so \"re-uploading the same bytes stays\n * quarantined\" held within an ingestion path and not across them. Under\n * the ceiling it now holds across them. That is the headline.\n * 2. **The md5 collision target goes away** for everything under the\n * ceiling. A chosen-prefix md5 collision is hours of ordinary compute\n * and yields two files sharing the 64-bit truncation — a cross-tenant\n * availability attack an outsider could mount. The preferred key stops\n * being md5-derived.\n * 3. **Ingestion refusal actually works** on the signed route for known\n * bytes, rather than only at delivery via the per-asset fallback.\n *\n * **What it does not buy, stated rather than implied.** Video above 50 MB\n * carries no `contentSha256` and never will on this path. That is the\n * bounded alternative, not a pretence of completeness: such an object\n * matches on the legacy truncated hash and on its per-asset key, exactly as\n * every signed upload did before this module existed. Video is also the\n * type least plausibly a chosen-prefix collision target or a\n * shared-bytes malware carrier, which is why it is the class the bound\n * sacrifices.\n *\n * ## Why streaming rather than `download()`\n *\n * `file.download()` resolves a single Buffer. At the ceiling that is 50 MiB\n * of function heap on a path that is concurrently holding a `sharp`\n * pipeline. Streaming through the hash keeps the resident set at one chunk\n * regardless of object size, which is what makes a 50 MiB bound safe to\n * state at all. The spec asserts the peak rather than only the hex, because\n * a hex-only assertion passes just as happily against a buffering\n * implementation.\n */\n\nimport { createHash } from 'node:crypto'\n\n/**\n * The largest object this path will digest.\n *\n * Deliberately equal to the largest non-video `signedMaxBytes` in\n * `apps/console/utils/media-upload-limits.ts` (ZIP, `.pptx`). Raising it\n * only ever adds video; lowering it silently strips the strong key off a\n * document type that has one today, which is why the spec asserts the\n * number instead of trusting it.\n */\nexport const MEDIA_STRONG_DIGEST_MAX_BYTES = 50 * 1024 * 1024\n\n/** Why no digest was taken. Absent when one was. */\nexport type StrongDigestSkip = 'over-ceiling' | 'unreadable'\n\nexport interface StoredObjectDigest {\n /** Lower-case hex sha256 of the whole object, or absent. */\n sha256?: string\n /** Set when — and only when — `sha256` is absent. */\n skipped?: StrongDigestSkip\n /** Bytes actually pulled out of the bucket. Zero when the bound bit. */\n bytesRead: number\n /**\n * The largest single buffer held while hashing.\n *\n * Exported because it is the only externally checkable evidence that this\n * function streams rather than buffers — a caller, a log line or a spec\n * can distinguish the two without reading the implementation. It is also\n * the number that would move first if someone \"simplified\" this back to\n * `file.download()`.\n */\n peakChunkBytes: number\n}\n\n/**\n * May an object of this size be digested?\n *\n * A pure predicate so the route and the spec (AGL-1630) apply ONE rule\n * rather than copies that drift.\n *\n * An unknown, zero or non-finite size answers `false`, never `true`. GCS\n * reports no size for a composite object, and guessing \"small\" there means\n * starting an unbounded read on the exact shape that cannot be bounded.\n */\nexport function strongDigestWithinCeiling(\n sizeBytes: number,\n maxBytes: number = MEDIA_STRONG_DIGEST_MAX_BYTES,\n): boolean {\n if (typeof sizeBytes !== 'number' || !Number.isFinite(sizeBytes)) return false\n if (sizeBytes <= 0) return false\n return sizeBytes <= maxBytes\n}\n\n/** The minimal Storage surface this needs; injectable for tests. */\nexport interface DigestibleStorageFile {\n createReadStream: (options?: unknown) => NodeJS.ReadableStream\n}\n\n/**\n * Stream one stored object through sha256.\n *\n * FAILS SOFT, in both directions, and that is the whole safety argument:\n *\n * * An unreadable object returns `unreadable`, not a throw. The callers are\n * an upload finalize and a backfill; a Storage blip must not start\n * refusing uploads, matching the fail-open posture of the ranged\n * inspection above it and the deny-list read below it.\n * * A stream that ends SHORT — a truncated range, a proxy cutting the body,\n * a mid-flight reset — returns `unreadable` rather than the digest of\n * what did arrive. A partial digest is worse than none: it is a\n * well-formed 64-hex value that matches nothing, and because\n * `mediaQuarantineKeys` PREFERS the strong digest, writing one would\n * shadow the legacy hash that would otherwise have matched. That is a\n * takedown quietly lifting itself, which is the one failure this whole\n * subsystem may not have.\n */\nexport async function storedObjectSha256(options: {\n file: DigestibleStorageFile\n /** The object's real size, as Storage reports it. */\n sizeBytes: number\n maxBytes?: number\n}): Promise<StoredObjectDigest> {\n const { file, sizeBytes } = options\n const maxBytes = options.maxBytes ?? MEDIA_STRONG_DIGEST_MAX_BYTES\n\n // Before any egress, not after — a bound that reads and discards costs\n // exactly what it exists to save.\n if (!strongDigestWithinCeiling(sizeBytes, maxBytes)) {\n return { skipped: 'over-ceiling', bytesRead: 0, peakChunkBytes: 0 }\n }\n\n const hash = createHash('sha256')\n let bytesRead = 0\n let peakChunkBytes = 0\n try {\n const stream = file.createReadStream()\n for await (const chunk of stream as AsyncIterable<Buffer | string>) {\n const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))\n if (buffer.length > peakChunkBytes) peakChunkBytes = buffer.length\n bytesRead += buffer.length\n hash.update(new Uint8Array(buffer))\n }\n } catch (error) {\n console.error('[media] strong digest read failed', error)\n return { skipped: 'unreadable', bytesRead, peakChunkBytes }\n }\n\n if (bytesRead !== sizeBytes) {\n // See the fail-soft note above: a short read is not a small file.\n console.error(\n '[media] strong digest read short',\n `expected ${sizeBytes} bytes, read ${bytesRead}`,\n )\n return { skipped: 'unreadable', bytesRead, peakChunkBytes }\n }\n\n return { sha256: hash.digest('hex'), bytesRead, peakChunkBytes }\n}\n"],"names":["createHash","MEDIA_STRONG_DIGEST_MAX_BYTES","strongDigestWithinCeiling","sizeBytes","maxBytes","Number","isFinite","storedObjectSha256","options","file","skipped","bytesRead","peakChunkBytes","hash","stream","createReadStream","chunk","buffer","Buffer","isBuffer","from","String","length","update","Uint8Array","error","console","sha256","digest"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqFC,GAED,SAASA,UAAU,QAAQ,cAAa;AAExC;;;;;;;;CAQC,GACD,OAAO,MAAMC,gCAAgC,KAAK,OAAO,KAAI;AAwB7D;;;;;;;;;CASC,GACD,OAAO,SAASC,0BACdC,SAAiB,EACjBC,WAAmBH,6BAA6B;IAEhD,IAAI,OAAOE,cAAc,YAAY,CAACE,OAAOC,QAAQ,CAACH,YAAY,OAAO;IACzE,IAAIA,aAAa,GAAG,OAAO;IAC3B,OAAOA,aAAaC;AACtB;AAOA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAeG,mBAAmBC,OAKxC;QAEkBA;IADjB,MAAM,EAAEC,IAAI,EAAEN,SAAS,EAAE,GAAGK;IAC5B,MAAMJ,YAAWI,oBAAAA,QAAQJ,QAAQ,YAAhBI,oBAAoBP;IAErC,uEAAuE;IACvE,kCAAkC;IAClC,IAAI,CAACC,0BAA0BC,WAAWC,WAAW;QACnD,OAAO;YAAEM,SAAS;YAAgBC,WAAW;YAAGC,gBAAgB;QAAE;IACpE;IAEA,MAAMC,OAAOb,WAAW;IACxB,IAAIW,YAAY;IAChB,IAAIC,iBAAiB;IACrB,IAAI;QACF,MAAME,SAASL,KAAKM,gBAAgB;QACpC,WAAW,MAAMC,SAASF,OAA0C;YAClE,MAAMG,SAASC,OAAOC,QAAQ,CAACH,SAASA,QAAQE,OAAOE,IAAI,CAACC,OAAOL;YACnE,IAAIC,OAAOK,MAAM,GAAGV,gBAAgBA,iBAAiBK,OAAOK,MAAM;YAClEX,aAAaM,OAAOK,MAAM;YAC1BT,KAAKU,MAAM,CAAC,IAAIC,WAAWP;QAC7B;IACF,EAAE,OAAOQ,OAAO;QACdC,QAAQD,KAAK,CAAC,qCAAqCA;QACnD,OAAO;YAAEf,SAAS;YAAcC;YAAWC;QAAe;IAC5D;IAEA,IAAID,cAAcR,WAAW;QAC3B,kEAAkE;QAClEuB,QAAQD,KAAK,CACX,oCACA,CAAC,SAAS,EAAEtB,UAAU,aAAa,EAAEQ,WAAW;QAElD,OAAO;YAAED,SAAS;YAAcC;YAAWC;QAAe;IAC5D;IAEA,OAAO;QAAEe,QAAQd,KAAKe,MAAM,CAAC;QAAQjB;QAAWC;IAAe;AACjE"}
|