@aglyn/tenant-data-admin 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/jest.integration.config.d.ts +39 -0
- package/jest.integration.setup.d.ts +17 -0
- package/package.json +51 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/render-cache.d.ts +184 -0
- package/src/lib/render-cache.js +198 -0
- package/src/lib/render-cache.js.map +1 -0
- package/src/lib/server/account-addresses.d.ts +160 -0
- package/src/lib/server/account-addresses.js +311 -0
- package/src/lib/server/account-addresses.js.map +1 -0
- package/src/lib/server/account-emails.d.ts +219 -0
- package/src/lib/server/account-emails.js +640 -0
- package/src/lib/server/account-emails.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +180 -0
- package/src/lib/server/admin-audit.js +233 -0
- package/src/lib/server/admin-audit.js.map +1 -0
- package/src/lib/server/analytics-retention.d.ts +63 -0
- package/src/lib/server/analytics-retention.js +66 -0
- package/src/lib/server/analytics-retention.js.map +1 -0
- package/src/lib/server/api-http.d.ts +159 -0
- package/src/lib/server/api-http.js +230 -0
- package/src/lib/server/api-http.js.map +1 -0
- package/src/lib/server/api-keys.d.ts +117 -0
- package/src/lib/server/api-keys.js +298 -0
- package/src/lib/server/api-keys.js.map +1 -0
- package/src/lib/server/auth-handoff.d.ts +165 -0
- package/src/lib/server/auth-handoff.js +482 -0
- package/src/lib/server/auth-handoff.js.map +1 -0
- package/src/lib/server/auth-pools.d.ts +246 -0
- package/src/lib/server/auth-pools.js +551 -0
- package/src/lib/server/auth-pools.js.map +1 -0
- package/src/lib/server/billing-addons.d.ts +171 -0
- package/src/lib/server/billing-addons.js +277 -0
- package/src/lib/server/billing-addons.js.map +1 -0
- package/src/lib/server/campaign-attribution-store.d.ts +55 -0
- package/src/lib/server/campaign-attribution-store.js +95 -0
- package/src/lib/server/campaign-attribution-store.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +205 -0
- package/src/lib/server/campaign-conversion-attribution.js +279 -0
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
- package/src/lib/server/client-error-report.d.ts +301 -0
- package/src/lib/server/client-error-report.js +755 -0
- package/src/lib/server/client-error-report.js.map +1 -0
- package/src/lib/server/connect-account-status.d.ts +74 -0
- package/src/lib/server/connect-account-status.js +94 -0
- package/src/lib/server/connect-account-status.js.map +1 -0
- package/src/lib/server/connect-payout-failure.d.ts +103 -0
- package/src/lib/server/connect-payout-failure.js +127 -0
- package/src/lib/server/connect-payout-failure.js.map +1 -0
- package/src/lib/server/console-api-rate-limit.d.ts +81 -0
- package/src/lib/server/console-api-rate-limit.js +111 -0
- package/src/lib/server/console-api-rate-limit.js.map +1 -0
- package/src/lib/server/console-domains.d.ts +326 -0
- package/src/lib/server/console-domains.js +779 -0
- package/src/lib/server/console-domains.js.map +1 -0
- package/src/lib/server/consume-once.d.ts +91 -0
- package/src/lib/server/consume-once.js +96 -0
- package/src/lib/server/consume-once.js.map +1 -0
- package/src/lib/server/contact-company-link.d.ts +85 -0
- package/src/lib/server/contact-company-link.js +105 -0
- package/src/lib/server/contact-company-link.js.map +1 -0
- package/src/lib/server/contact-email-engagement.d.ts +32 -0
- package/src/lib/server/contact-email-engagement.js +145 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-email-index.d.ts +56 -0
- package/src/lib/server/contact-email-index.js +158 -0
- package/src/lib/server/contact-email-index.js.map +1 -0
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +88 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-merge.d.ts +55 -0
- package/src/lib/server/contact-merge.js +279 -0
- package/src/lib/server/contact-merge.js.map +1 -0
- package/src/lib/server/contact-suppression.d.ts +153 -0
- package/src/lib/server/contact-suppression.js +259 -0
- package/src/lib/server/contact-suppression.js.map +1 -0
- package/src/lib/server/crm-booking-activity.d.ts +86 -0
- package/src/lib/server/crm-booking-activity.js +251 -0
- package/src/lib/server/crm-booking-activity.js.map +1 -0
- package/src/lib/server/crm-email-activity.d.ts +112 -0
- package/src/lib/server/crm-email-activity.js +143 -0
- package/src/lib/server/crm-email-activity.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +145 -0
- package/src/lib/server/crm-inbound-email.js +287 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-next-activity.d.ts +74 -0
- package/src/lib/server/crm-next-activity.js +194 -0
- package/src/lib/server/crm-next-activity.js.map +1 -0
- package/src/lib/server/crm-records.d.ts +144 -0
- package/src/lib/server/crm-records.js +218 -0
- package/src/lib/server/crm-records.js.map +1 -0
- package/src/lib/server/csp-aggregate.d.ts +62 -0
- package/src/lib/server/csp-aggregate.js +276 -0
- package/src/lib/server/csp-aggregate.js.map +1 -0
- package/src/lib/server/data-storage-gate.d.ts +66 -0
- package/src/lib/server/data-storage-gate.js +48 -0
- package/src/lib/server/data-storage-gate.js.map +1 -0
- package/src/lib/server/dataset-live-pages.d.ts +179 -0
- package/src/lib/server/dataset-live-pages.js +421 -0
- package/src/lib/server/dataset-live-pages.js.map +1 -0
- package/src/lib/server/dns-probe.d.ts +53 -0
- package/src/lib/server/dns-probe.js +184 -0
- package/src/lib/server/dns-probe.js.map +1 -0
- package/src/lib/server/document-id.d.ts +29 -0
- package/src/lib/server/document-id.js +100 -0
- package/src/lib/server/document-id.js.map +1 -0
- package/src/lib/server/domain-provider-vercel.d.ts +33 -0
- package/src/lib/server/domain-provider-vercel.js +298 -0
- package/src/lib/server/domain-provider-vercel.js.map +1 -0
- package/src/lib/server/domain-provider-webhook.d.ts +73 -0
- package/src/lib/server/domain-provider-webhook.js +223 -0
- package/src/lib/server/domain-provider-webhook.js.map +1 -0
- package/src/lib/server/domain-provider-wildcard.d.ts +54 -0
- package/src/lib/server/domain-provider-wildcard.js +163 -0
- package/src/lib/server/domain-provider-wildcard.js.map +1 -0
- package/src/lib/server/domain-provider.d.ts +197 -0
- package/src/lib/server/domain-provider.js +221 -0
- package/src/lib/server/domain-provider.js.map +1 -0
- package/src/lib/server/duplicate-activity.d.ts +54 -0
- package/src/lib/server/duplicate-activity.js +67 -0
- package/src/lib/server/duplicate-activity.js.map +1 -0
- package/src/lib/server/duplicate-resource.d.ts +55 -0
- package/src/lib/server/duplicate-resource.js +474 -0
- package/src/lib/server/duplicate-resource.js.map +1 -0
- package/src/lib/server/dynamic-list-materialize.d.ts +153 -0
- package/src/lib/server/dynamic-list-materialize.js +581 -0
- package/src/lib/server/dynamic-list-materialize.js.map +1 -0
- package/src/lib/server/edit-access-authz.d.ts +56 -0
- package/src/lib/server/edit-access-authz.js +107 -0
- package/src/lib/server/edit-access-authz.js.map +1 -0
- package/src/lib/server/edit-access-token.d.ts +72 -0
- package/src/lib/server/edit-access-token.js +116 -0
- package/src/lib/server/edit-access-token.js.map +1 -0
- package/src/lib/server/edit-hint-token.d.ts +83 -0
- package/src/lib/server/edit-hint-token.js +119 -0
- package/src/lib/server/edit-hint-token.js.map +1 -0
- package/src/lib/server/email-delivery-log.d.ts +509 -0
- package/src/lib/server/email-delivery-log.js +1043 -0
- package/src/lib/server/email-delivery-log.js.map +1 -0
- package/src/lib/server/email-flow-gate.d.ts +80 -0
- package/src/lib/server/email-flow-gate.js +153 -0
- package/src/lib/server/email-flow-gate.js.map +1 -0
- package/src/lib/server/email-marketing-gate.d.ts +333 -0
- package/src/lib/server/email-marketing-gate.js +639 -0
- package/src/lib/server/email-marketing-gate.js.map +1 -0
- package/src/lib/server/email-metering.d.ts +293 -0
- package/src/lib/server/email-metering.js +359 -0
- package/src/lib/server/email-metering.js.map +1 -0
- package/src/lib/server/email-revenue-attribution.d.ts +185 -0
- package/src/lib/server/email-revenue-attribution.js +300 -0
- package/src/lib/server/email-revenue-attribution.js.map +1 -0
- package/src/lib/server/email-send-rate.d.ts +232 -0
- package/src/lib/server/email-send-rate.js +417 -0
- package/src/lib/server/email-send-rate.js.map +1 -0
- package/src/lib/server/email-sender-reputation.d.ts +317 -0
- package/src/lib/server/email-sender-reputation.js +464 -0
- package/src/lib/server/email-sender-reputation.js.map +1 -0
- package/src/lib/server/email-suppression.d.ts +490 -0
- package/src/lib/server/email-suppression.js +648 -0
- package/src/lib/server/email-suppression.js.map +1 -0
- package/src/lib/server/email-topic-confirmation.d.ts +150 -0
- package/src/lib/server/email-topic-confirmation.js +272 -0
- package/src/lib/server/email-topic-confirmation.js.map +1 -0
- package/src/lib/server/email-unsubscribe-link.d.ts +174 -0
- package/src/lib/server/email-unsubscribe-link.js +236 -0
- package/src/lib/server/email-unsubscribe-link.js.map +1 -0
- package/src/lib/server/erase-person.d.ts +116 -0
- package/src/lib/server/erase-person.js +272 -0
- package/src/lib/server/erase-person.js.map +1 -0
- package/src/lib/server/erase.d.ts +458 -0
- package/src/lib/server/erase.js +1506 -0
- package/src/lib/server/erase.js.map +1 -0
- package/src/lib/server/firebase-admin.d.ts +71 -0
- package/src/lib/server/firebase-admin.js +343 -0
- package/src/lib/server/firebase-admin.js.map +1 -0
- package/src/lib/server/form-dataset-binding-token.d.ts +35 -0
- package/src/lib/server/form-dataset-binding-token.js +107 -0
- package/src/lib/server/form-dataset-binding-token.js.map +1 -0
- package/src/lib/server/free-workspace-cap.d.ts +201 -0
- package/src/lib/server/free-workspace-cap.js +404 -0
- package/src/lib/server/free-workspace-cap.js.map +1 -0
- package/src/lib/server/ga4-measurement-protocol.d.ts +256 -0
- package/src/lib/server/ga4-measurement-protocol.js +558 -0
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +47 -0
- package/src/lib/server/host-email-tokens.js +72 -0
- package/src/lib/server/host-email-tokens.js.map +1 -0
- package/src/lib/server/host-memberships.d.ts +54 -0
- package/src/lib/server/host-memberships.js +194 -0
- package/src/lib/server/host-memberships.js.map +1 -0
- package/src/lib/server/host-sending-domain.d.ts +292 -0
- package/src/lib/server/host-sending-domain.js +451 -0
- package/src/lib/server/host-sending-domain.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +150 -0
- package/src/lib/server/host-visitor-records.js +262 -0
- package/src/lib/server/host-visitor-records.js.map +1 -0
- package/src/lib/server/id-token-refusal.d.ts +28 -0
- package/src/lib/server/id-token-refusal.js +111 -0
- package/src/lib/server/id-token-refusal.js.map +1 -0
- package/src/lib/server/legal-acceptance.d.ts +214 -0
- package/src/lib/server/legal-acceptance.js +305 -0
- package/src/lib/server/legal-acceptance.js.map +1 -0
- package/src/lib/server/list-members.d.ts +211 -0
- package/src/lib/server/list-members.js +176 -0
- package/src/lib/server/list-members.js.map +1 -0
- package/src/lib/server/live-page-usage.d.ts +202 -0
- package/src/lib/server/live-page-usage.js +300 -0
- package/src/lib/server/live-page-usage.js.map +1 -0
- package/src/lib/server/lockdown.d.ts +313 -0
- package/src/lib/server/lockdown.js +643 -0
- package/src/lib/server/lockdown.js.map +1 -0
- package/src/lib/server/media-cdn-rate-limit.d.ts +188 -0
- package/src/lib/server/media-cdn-rate-limit.js +204 -0
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +267 -0
- package/src/lib/server/media-delivery.js +485 -0
- package/src/lib/server/media-delivery.js.map +1 -0
- package/src/lib/server/media-download-tokens.d.ts +161 -0
- package/src/lib/server/media-download-tokens.js +386 -0
- package/src/lib/server/media-download-tokens.js.map +1 -0
- package/src/lib/server/media-quarantine.d.ts +127 -0
- package/src/lib/server/media-quarantine.js +170 -0
- package/src/lib/server/media-quarantine.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +126 -0
- package/src/lib/server/media-signing.js +159 -0
- package/src/lib/server/media-signing.js.map +1 -0
- package/src/lib/server/media-storage-path.d.ts +96 -0
- package/src/lib/server/media-storage-path.js +111 -0
- package/src/lib/server/media-storage-path.js.map +1 -0
- package/src/lib/server/media-strong-digest.d.ts +85 -0
- package/src/lib/server/media-strong-digest.js +190 -0
- package/src/lib/server/media-strong-digest.js.map +1 -0
- package/src/lib/server/media-tombstone.d.ts +276 -0
- package/src/lib/server/media-tombstone.js +421 -0
- package/src/lib/server/media-tombstone.js.map +1 -0
- package/src/lib/server/media-variants.d.ts +309 -0
- package/src/lib/server/media-variants.js +428 -0
- package/src/lib/server/media-variants.js.map +1 -0
- package/src/lib/server/member-email-aliases.d.ts +180 -0
- package/src/lib/server/member-email-aliases.js +420 -0
- package/src/lib/server/member-email-aliases.js.map +1 -0
- package/src/lib/server/member-photo.d.ts +88 -0
- package/src/lib/server/member-photo.js +185 -0
- package/src/lib/server/member-photo.js.map +1 -0
- package/src/lib/server/membership-recover-throttle.d.ts +211 -0
- package/src/lib/server/membership-recover-throttle.js +231 -0
- package/src/lib/server/membership-recover-throttle.js.map +1 -0
- package/src/lib/server/notifications.d.ts +55 -0
- package/src/lib/server/notifications.js +134 -0
- package/src/lib/server/notifications.js.map +1 -0
- package/src/lib/server/org-billing.d.ts +97 -0
- package/src/lib/server/org-billing.js +236 -0
- package/src/lib/server/org-billing.js.map +1 -0
- package/src/lib/server/organizations.d.ts +837 -0
- package/src/lib/server/organizations.js +1814 -0
- package/src/lib/server/organizations.js.map +1 -0
- package/src/lib/server/password-reset-throttle.d.ts +84 -0
- package/src/lib/server/password-reset-throttle.js +102 -0
- package/src/lib/server/password-reset-throttle.js.map +1 -0
- package/src/lib/server/personal-data-export.d.ts +241 -0
- package/src/lib/server/personal-data-export.js +858 -0
- package/src/lib/server/personal-data-export.js.map +1 -0
- package/src/lib/server/platform-domain-names.d.ts +62 -0
- package/src/lib/server/platform-domain-names.js +197 -0
- package/src/lib/server/platform-domain-names.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +133 -0
- package/src/lib/server/platform-marketing-consent.js +209 -0
- package/src/lib/server/platform-marketing-consent.js.map +1 -0
- package/src/lib/server/rate-limit-store.d.ts +702 -0
- package/src/lib/server/rate-limit-store.js +1220 -0
- package/src/lib/server/rate-limit-store.js.map +1 -0
- package/src/lib/server/realm-plugins.d.ts +67 -0
- package/src/lib/server/realm-plugins.js +250 -0
- package/src/lib/server/realm-plugins.js.map +1 -0
- package/src/lib/server/recaptcha-allowlist.d.ts +90 -0
- package/src/lib/server/recaptcha-allowlist.js +409 -0
- package/src/lib/server/recaptcha-allowlist.js.map +1 -0
- package/src/lib/server/release-flags.d.ts +105 -0
- package/src/lib/server/release-flags.js +183 -0
- package/src/lib/server/release-flags.js.map +1 -0
- package/src/lib/server/replay-erasures.d.ts +126 -0
- package/src/lib/server/replay-erasures.js +217 -0
- package/src/lib/server/replay-erasures.js.map +1 -0
- package/src/lib/server/resolve-people.d.ts +66 -0
- package/src/lib/server/resolve-people.js +112 -0
- package/src/lib/server/resolve-people.js.map +1 -0
- package/src/lib/server/safe-equal.d.ts +45 -0
- package/src/lib/server/safe-equal.js +58 -0
- package/src/lib/server/safe-equal.js.map +1 -0
- package/src/lib/server/sending-domain-debt.d.ts +127 -0
- package/src/lib/server/sending-domain-debt.js +268 -0
- package/src/lib/server/sending-domain-debt.js.map +1 -0
- package/src/lib/server/sending-domain-recheck.d.ts +151 -0
- package/src/lib/server/sending-domain-recheck.js +253 -0
- package/src/lib/server/sending-domain-recheck.js.map +1 -0
- package/src/lib/server/sending-domains.d.ts +364 -0
- package/src/lib/server/sending-domains.js +683 -0
- package/src/lib/server/sending-domains.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +433 -0
- package/src/lib/server/serve-media-cdn.js +1370 -0
- package/src/lib/server/serve-media-cdn.js.map +1 -0
- package/src/lib/server/serve-plugin-fetch.d.ts +61 -0
- package/src/lib/server/serve-plugin-fetch.js +294 -0
- package/src/lib/server/serve-plugin-fetch.js.map +1 -0
- package/src/lib/server/sms-keywords.d.ts +63 -0
- package/src/lib/server/sms-keywords.js +146 -0
- package/src/lib/server/sms-keywords.js.map +1 -0
- package/src/lib/server/sso-break-glass-owners.d.ts +91 -0
- package/src/lib/server/sso-break-glass-owners.js +227 -0
- package/src/lib/server/sso-break-glass-owners.js.map +1 -0
- package/src/lib/server/sso-domain-policy.d.ts +140 -0
- package/src/lib/server/sso-domain-policy.js +239 -0
- package/src/lib/server/sso-domain-policy.js.map +1 -0
- package/src/lib/server/sso-drift-logic.d.ts +106 -0
- package/src/lib/server/sso-drift-logic.js +95 -0
- package/src/lib/server/sso-drift-logic.js.map +1 -0
- package/src/lib/server/sso-enforcement.d.ts +204 -0
- package/src/lib/server/sso-enforcement.js +309 -0
- package/src/lib/server/sso-enforcement.js.map +1 -0
- package/src/lib/server/sso-provisioning.d.ts +320 -0
- package/src/lib/server/sso-provisioning.js +715 -0
- package/src/lib/server/sso-provisioning.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +41 -0
- package/src/lib/server/staff-alert-email.js +63 -0
- package/src/lib/server/staff-alert-email.js.map +1 -0
- package/src/lib/server/stripe-account-mode.d.ts +156 -0
- package/src/lib/server/stripe-account-mode.js +162 -0
- package/src/lib/server/stripe-account-mode.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +131 -0
- package/src/lib/server/tenant-write-lockdown.js +176 -0
- package/src/lib/server/tenant-write-lockdown.js.map +1 -0
- package/src/lib/server/test-firestore.d.ts +45 -0
- package/src/lib/server/test-firestore.js +124 -0
- package/src/lib/server/test-firestore.js.map +1 -0
- package/src/lib/server/token-revocation.d.ts +140 -0
- package/src/lib/server/token-revocation.js +255 -0
- package/src/lib/server/token-revocation.js.map +1 -0
- package/src/lib/server/update-existing.d.ts +32 -0
- package/src/lib/server/update-existing.js +73 -0
- package/src/lib/server/update-existing.js.map +1 -0
- package/src/lib/server/upload-cors-reconcile.d.ts +118 -0
- package/src/lib/server/upload-cors-reconcile.js +234 -0
- package/src/lib/server/upload-cors-reconcile.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +262 -0
- package/src/lib/server/upsert-contact.js +546 -0
- package/src/lib/server/upsert-contact.js.map +1 -0
- package/src/lib/server/usage-invoice.d.ts +203 -0
- package/src/lib/server/usage-invoice.js +397 -0
- package/src/lib/server/usage-invoice.js.map +1 -0
- package/src/lib/server/user-profiles.d.ts +151 -0
- package/src/lib/server/user-profiles.js +364 -0
- package/src/lib/server/user-profiles.js.map +1 -0
- package/src/lib/server/vercel-drain-signature.d.ts +30 -0
- package/src/lib/server/vercel-drain-signature.js +61 -0
- package/src/lib/server/vercel-drain-signature.js.map +1 -0
- package/src/lib/server/vercel-log-drain.d.ts +259 -0
- package/src/lib/server/vercel-log-drain.js +479 -0
- package/src/lib/server/vercel-log-drain.js.map +1 -0
- package/src/lib/server/verify-email-cooldown.d.ts +102 -0
- package/src/lib/server/verify-email-cooldown.js +114 -0
- package/src/lib/server/verify-email-cooldown.js.map +1 -0
- package/src/lib/server/visitor-write-rate-limit.d.ts +87 -0
- package/src/lib/server/visitor-write-rate-limit.js +117 -0
- package/src/lib/server/visitor-write-rate-limit.js.map +1 -0
- package/src/lib/server/workspace-domains.d.ts +156 -0
- package/src/lib/server/workspace-domains.js +271 -0
- package/src/lib/server/workspace-domains.js.map +1 -0
- package/src/tenancy.d.ts +115 -0
- package/src/tenancy.js +142 -0
- package/src/tenancy.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dynamic-list-materialize.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 * Materializes a `kind: 'dynamic'` list's rule into its `members`\n * subcollection (`docs/specs/email-overhaul.md` §3b/§3e).\n *\n * A dynamic list does NOT resolve at send time. It stores a rule, a sweep\n * writes the matching people into the same `members` collection a manual list\n * uses, and the send path keeps reading one deterministic collection. Three\n * reasons, all of them things that have already gone wrong here:\n *\n * 1. The send must not run an unbounded scan — that is what produced an\n * arbitrary, unstable 500 of a site's 3,000 leads.\n * 2. The composer needs a cheap count, not a 5,000-document scan per\n * keystroke.\n * 3. \"Who did this campaign go to\" is a support question and a compliance\n * question. A materialized membership answers it; a rule re-evaluated\n * later does not.\n *\n * ## ⛔ A CEILING NEVER REMOVES A PERSON\n *\n * This function has no quota comparison and must never grow one. Capacity in\n * this product is enforced at the REDUCTION, not at the use: a limit that\n * refuses a person or their data leaks, because the person is already there\n * and dropping them is not a refusal, it is deletion. So a rule that matches\n * more people than a plan's audience band, or than a single send's recipient\n * cap, materializes ALL of them. The ceiling that a large audience meets is\n * `performCampaignSend`'s, which refuses the SEND and states the count it\n * found — a refusal the merchant can see, argue with, and act on, where a\n * trimmed membership is silent and unreproducible.\n *\n * What bounds this function is the per-run SCAN budget below, which is a\n * different thing: it bounds how much work one sweep does, and the cursor it\n * returns resumes the same sweep on the next run. Running out of budget\n * postpones enrollments; it never removes anybody, and it never finishes a\n * sweep it did not complete.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type {\n DocumentReference,\n Query,\n QueryDocumentSnapshot,\n} from 'firebase-admin/firestore'\nimport {\n soloConsentGroup,\n candidateMatchesDynamicListRule,\n dynamicListRuleIsEmpty,\n dynamicListRuleListIds,\n dynamicListRuleWithoutListReference,\n dynamicListRuleNeedsContactFacet,\n dynamicListDimensionsForCrmView,\n dynamicListPlanningRule,\n CRM_COLLECTIONS,\n normalizeCrmViewFilters,\n type DynamicListDimensions,\n dynamicListRuleNeedsEngagement,\n extractEmailFromFields,\n isContactLifecycleStage,\n normalizeContactEmail,\n normalizeDynamicListRule,\n personKey,\n readCampaignIds,\n readContactCampaignIds,\n readContactFacet,\n type ContactCustomValue,\n type DynamicListCandidate,\n type DynamicListRule,\n type DynamicListSource,\n type ResolvedSegmentFilters,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from './firebase-admin'\nimport { readPersonEngagementByKeys } from './email-delivery-log'\nimport { enrollListMember } from './list-members'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n scopedToHost,\n} from './organizations'\n\n/**\n * Documents one sweep run may READ across all sources.\n *\n * A bound on work, not on membership. When a run exhausts it the sweep stops\n * where it is, returns `complete: false` with the cursor it reached, and the\n * next run continues — the shape the import-history sweep already uses. The\n * alternative, an unbounded scan of every silo per list per run, is the\n * expensive-read pattern this codebase refuses.\n *\n * ⚠️ Membership reconciliation is SKIPPED on an incomplete run. A partial\n * scan has only seen part of the population, so anybody it did not reach\n * would look like a non-match — and acting on that would delete real members\n * because a budget ran out.\n */\nexport const DYNAMIC_LIST_SCAN_BUDGET = 5_000\n\n/** Documents read per source page. */\nconst PAGE_SIZE = 500\n\n/**\n * ⚠️ THE DATE FIELDS THIS FILE READS, AND WHO WRITES THEM.\n *\n * `createdAfterMs`/`createdBeforeMs` are applied IN MEMORY against\n * `createdAt`, never as a Firestore `orderBy` or `where`. That distinction is\n * the whole safety property: an `orderBy` on a data field drops every document\n * missing it from the QUERY, invisibly, where an in-memory filter still reads\n * the document and merely decides it does not match. The paging order is\n * `__name__`, the one key every document has.\n *\n * The rule's decision for an undated record is to EXCLUDE it, so the writers\n * were checked rather than assumed — a silo with an unstamped writer would\n * quietly shrink every dated rule:\n *\n * - `leads` — `addHostLead` is the single writer and always stamps it.\n * - `siteMembers` — `membership-register.ts` is the only creator; the other\n * member routes read or update an existing document.\n * - `contacts` — `upsertHostContact`'s create path and the v1 API both stamp.\n * - `formSubmissions` — the submit route is the only creator; the v1 API and\n * the inbox reply route read.\n *\n * A new writer of any of those four must stamp `createdAt` or be added here.\n *\n * ## Campaign membership is read the same way — off the paged document\n *\n * `campaignIds` is applied in memory too, and for a second reason beyond the\n * one above: it is an ARRAY, so a Firestore `array-contains-any` could express\n * only the single-campaign case and would need a composite index the moment it\n * met any other filter in the rule. The membership is already on the document\n * this scan has read, on a submission at the top and on a contact inside the\n * reading holder's facet, so matching it costs nothing the page did not\n * already pay.\n */\n\n/** Where a partial sweep stopped, so the next run resumes rather than restarts. */\nexport interface DynamicListCursor {\n /** The source being scanned when the budget ran out. */\n source: DynamicListSource\n /** The last document id read from it. */\n afterId: string\n}\n\nexport interface MaterializeDynamicListResult {\n /** People the rule matched and that are now enrolled. */\n matched: number\n /** Rows created by this run. */\n enrolled: number\n /** Rule-owned rows removed because the person no longer matches. */\n removed: number\n /** False when the scan budget ran out — `cursor` says where to resume. */\n complete: boolean\n cursor: DynamicListCursor | null\n /**\n * True when the rule selects nobody by construction (no sources). Reported\n * rather than silently materializing an empty list, because an empty list\n * reads in the composer exactly like a rule that has not run yet.\n */\n empty: boolean\n}\n\n/** What a read-only pass over the silos found. */\nexport interface DynamicListScanResult {\n /** Matched people, de-duplicated across silos by their person key. */\n candidates: DynamicListCandidate[]\n /** False when the scan budget ran out — `cursor` says where to resume. */\n complete: boolean\n cursor: DynamicListCursor | null\n /**\n * True when the rule selects nobody by construction — no sources, or a\n * `segmentId` naming a segment that no longer exists.\n */\n empty: boolean\n /** Documents read, against the budget. */\n read: number\n}\n\n/**\n * The silo collections, as queries.\n *\n * `contacts` is org-scoped and must go through `scopedToHost` — the Admin SDK\n * does not evaluate rules, so an unfiltered read would let one site's list\n * materialize another site's contacts. The other three are host-owned and\n * carry no `visibleTo` to filter on.\n */\nasync function sourceQuery(\n source: DynamicListSource,\n hostId: string,\n): Promise<Query> {\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(hostId)\n if (source === 'contacts') {\n const ref = await orgDataCollectionForHost(hostId, 'contacts')\n return scopedToHost(ref, hostId)\n }\n return hostRef.collection(source)\n}\n\nconst millis = (value: unknown): number | null => {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: unknown } | null)?.toMillis\n if (typeof toMillis === 'function') {\n const parsed = Number(toMillis.call(value))\n return Number.isFinite(parsed) ? parsed : null\n }\n return null\n}\n\n/**\n * The CRM fields a rule may read, out of ONE holder's facet (AGL-2603).\n *\n * The owner, the stage, the company and the custom values are this holder's\n * business records on a shared row. They are read through `readContactFacet`\n * for the group the sweep runs as — never off the top of the document, where\n * a pre-facet row could still carry somebody's fields, and never out of\n * another group's facet. A field the facet does not carry is left ABSENT on\n * the candidate, which the matcher reads as \"does not match\" for the first\n * three and as \"unset\" for a custom clause; a stage the model does not name\n * is dropped for the reason the normalizer drops one from a rule.\n */\nfunction crmFieldsFromFacet(\n data: Record<string, unknown>,\n groupId: string,\n): Pick<\n DynamicListCandidate,\n 'ownerUid' | 'lifecycleStage' | 'companyId' | 'custom' | 'lastEmailEngagementAtMs'\n> {\n const facet = readContactFacet(data, groupId)\n const custom =\n facet.custom && typeof facet.custom === 'object' && !Array.isArray(facet.custom)\n ? (facet.custom as Record<string, ContactCustomValue>)\n : undefined\n // The campaign engagement stamp (AGL-2616), on the same footing as the\n // owner: this holder's fact about the person, read out of this holder's\n // facet, absent when the webhook has never stamped one.\n const engaged = Number(facet.lastEmailEngagementAtMs ?? 0)\n return {\n ...(typeof facet.ownerUid === 'string' && facet.ownerUid\n ? { ownerUid: facet.ownerUid }\n : {}),\n ...(isContactLifecycleStage(facet.lifecycleStage)\n ? { lifecycleStage: facet.lifecycleStage }\n : {}),\n ...(typeof facet.companyId === 'string' && facet.companyId\n ? { companyId: facet.companyId }\n : {}),\n ...(custom ? { custom } : {}),\n ...(Number.isFinite(engaged) && engaged > 0\n ? { lastEmailEngagementAtMs: engaged }\n : {}),\n }\n}\n\n/**\n * Reads one silo document into the shape the rule matcher understands.\n *\n * @param groupId the consent group whose contact facet the campaign membership\n * and the CRM fields are read from, or `null` when the rule names none of\n * them and no group was resolved. A contact row is shared by every site in\n * the org and its membership, its owner, its stage, its company and its\n * custom values all live inside one holder's facet, so there is no\n * expression here that returns another holder's filing.\n */\nfunction toCandidate(\n source: DynamicListSource,\n doc: QueryDocumentSnapshot,\n groupId: string | null,\n): DynamicListCandidate | null {\n const data = doc.data() as Record<string, unknown>\n const email =\n source === 'formSubmissions'\n ? // A form does not guarantee a canonical email field, so this is the\n // same best-effort extraction the submissions route already uses to\n // decide whether a submission produces a contact at all.\n extractEmailFromFields(data['fields'] as Record<string, unknown>)\n : normalizeContactEmail(data['email'])\n if (!email) return null\n const createdAtMs = millis(data['createdAt'])\n return {\n silo: source,\n email,\n // `siteMembers` stores `displayName`; the rest store `name`. Reading only\n // `name` is what left every member campaign's merge tags blank.\n name:\n (typeof data['name'] === 'string' && data['name']) ||\n (typeof data['displayName'] === 'string' && data['displayName']) ||\n undefined,\n createdAtMs,\n ...(source === 'contacts'\n ? {\n tags: (data['tags'] as string[]) ?? [],\n sources: (data['sources'] as DynamicListCandidate['sources']) ?? {},\n ordersCount: Number(data['ordersCount'] ?? 0),\n ltvCents: Number(data['ltvCents'] ?? 0),\n lastPurchaseAtMs: millis(data['lastPurchaseAtMs']),\n // Inside the holder's facet, never the top of the document. A rule\n // with no facet clause resolves no group and reads nothing here,\n // which is what keeps the group lookup an opt-in cost.\n ...(groupId\n ? {\n campaignIds: readContactCampaignIds(data, groupId),\n ...crmFieldsFromFacet(data, groupId),\n }\n : {}),\n }\n : {}),\n ...(source === 'formSubmissions'\n ? {\n formName: String(data['formName'] ?? ''),\n // At the TOP of a submission, because a submission belongs to one\n // site — the same shape every other host resource carries it in.\n campaignIds: readCampaignIds(data),\n }\n : {}),\n }\n}\n\n/*==========================================\n * ENRICHMENT — the two dimensions the scan cannot read off a silo row.\n *\n * Engagement lives on `emailDeliveries/{personKey}` and list membership on\n * `orgs/{orgId}/lists/{listId}/members/{personKey}`. Both describe an ADDRESS\n * rather than a contact, a lead or a submission, so neither is in the\n * document the sweep just paged.\n *\n * Three properties, and each of them is a thing that could otherwise go\n * wrong here:\n *\n * 1. **KEYED reads, never queries.** `getAll` over document references. No\n * `where`, no `orderBy`, so no composite index, nothing that can be\n * truncated by a `limit`, and — the failure this codebase has hit\n * nineteen times — nothing that silently drops a document for missing\n * the field being ordered on.\n * 2. **Opt-in.** A rule with no engagement clause and no list reference\n * pays nothing at all. The cost belongs to the rules that ask for it,\n * not to every sweep.\n * 3. **Charged to the SAME scan budget.** Every enrichment read is counted\n * in `read`, so a rule that asks for engagement fills the budget faster\n * and its sweep simply takes more runs. The budget and the resume cursor\n * keep working exactly as they did; what changes is how much of the\n * budget one page costs.\n *=========================================*/\n\n/** What a rule needs looked up, resolved once per scan rather than per page. */\ninterface EnrichmentPlan {\n engagement: boolean\n /** `listId` → that list's `members` collection. */\n listMembers: Map<string, FirebaseFirestore.CollectionReference>\n}\n\n/**\n * Resolves what a rule needs enriched.\n *\n * A named list that no longer exists resolves to a collection with no\n * documents, which reads as \"nobody is a member\" — the same direction a\n * deleted `segmentId` takes, and the safe one: an `inListIds` naming a\n * deleted list selects nobody rather than everybody, and a `notInListIds`\n * naming one excludes nobody rather than the whole audience.\n */\nasync function planEnrichment(\n rule: DynamicListRule,\n hostId: string,\n): Promise<EnrichmentPlan> {\n const listIds = dynamicListRuleListIds(rule)\n const listMembers = new Map<string, FirebaseFirestore.CollectionReference>()\n if (listIds.length) {\n const lists = await orgDataCollectionForHost(hostId, 'lists')\n for (const listId of listIds) {\n listMembers.set(listId, lists.doc(listId).collection('members'))\n }\n }\n return { engagement: dynamicListRuleNeedsEngagement(rule), listMembers }\n}\n\n/** True when nothing needs looking up, so the page loop can skip the work. */\nfunction planIsEmpty(plan: EnrichmentPlan): boolean {\n return !plan.engagement && plan.listMembers.size === 0\n}\n\n/**\n * Fills the enrichment fields on one page's candidates.\n *\n * Keys are de-duplicated first: two silos can produce the same person on one\n * page, and looking them up twice would spend budget on an answer already in\n * hand.\n *\n * @returns how many documents were read, for the caller's budget.\n */\nasync function enrichCandidates(\n candidates: DynamicListCandidate[],\n plan: EnrichmentPlan,\n): Promise<number> {\n if (!candidates.length || planIsEmpty(plan)) return 0\n const keyed = new Map<string, DynamicListCandidate[]>()\n for (const candidate of candidates) {\n const key = personKey(candidate.email)\n if (!key) continue\n const held = keyed.get(key)\n if (held) held.push(candidate)\n else keyed.set(key, [candidate])\n }\n const keys = [...keyed.keys()]\n if (!keys.length) return 0\n const firestore = firebaseAdmin.app().firestore()\n let read = 0\n\n if (plan.engagement) {\n // The scan's own Firestore handle, passed rather than defaulted: the\n // rollup lives in another module with its own default, and two handles in\n // one sweep is two things a caller has to configure to reach one store.\n const engagement = await readPersonEngagementByKeys(keys, firestore)\n read += keys.length\n for (const [key, rows] of keyed) {\n const found = engagement.get(key)\n for (const candidate of rows) {\n candidate.lastOpenedAtMs = found?.lastOpenedAtMs ?? null\n candidate.lastClickedAtMs = found?.lastClickedAtMs ?? null\n }\n }\n }\n\n if (plan.listMembers.size) {\n // No pre-seeding of `listIds`. A candidate found on no list keeps the\n // field absent, and the matcher reads absent as \"on no list\" — see\n // `candidateMatchesDynamicListRule` for why that direction is the safe\n // one. Writing an empty array first would be a line saying the same thing\n // the matcher already says.\n for (const [listId, members] of plan.listMembers) {\n let snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n try {\n snapshots = await firestore.getAll(\n ...keys.map((key) => members.doc(key)),\n )\n } catch (error) {\n // A failed membership lookup leaves every candidate reading as a\n // non-member of this list. That keeps an `inListIds` rule from\n // enrolling people it could not confirm, and it is the direction a\n // reader can recover from — the next sweep asks again.\n console.error('[dynamic-list] list membership lookup failed', listId, error)\n }\n read += keys.length\n for (const snapshot of snapshots) {\n if (!snapshot?.exists) continue\n for (const candidate of keyed.get(snapshot.id) ?? []) {\n candidate.listIds = [...(candidate.listIds ?? []), listId]\n }\n }\n }\n }\n\n return read\n}\n\n/**\n * WHO A RULE SELECTS, without writing anything.\n *\n * The scan and the enrollment used to be one function, so \"who would this\n * match\" could only be answered by materializing it. That is the wrong shape\n * for two callers that now exist: a merchant asking to see an audience before\n * committing to it, and a fixed list using the filters to FIND people to add.\n * Neither may write memberships as a side effect of a question.\n *\n * One scanner rather than two. Every subtlety here is a place a second\n * implementation would drift — the in-memory date filter (an `orderBy` on a\n * data field would DROP undated documents rather than merely not matching\n * them), the `__name__` paging order, `scopedToHost` on `contacts` and on\n * nothing else, and a deleted `segmentId` narrowing to nobody rather than\n * widening to everybody. `materializeDynamicList` reads this and then enrolls.\n *\n * NOTHING here writes, and nothing here compares a count against a limit.\n */\nexport async function collectDynamicListCandidates(options: {\n hostId: string\n rule: unknown\n resume?: DynamicListCursor | null\n nowMs?: number\n scanBudget?: number\n}): Promise<DynamicListScanResult> {\n const rule: DynamicListRule = normalizeDynamicListRule(options.rule)\n const nowMs = options.nowMs ?? Date.now()\n const budget = options.scanBudget ?? DYNAMIC_LIST_SCAN_BUDGET\n if (dynamicListRuleIsEmpty(rule)) {\n return { candidates: [], complete: true, cursor: null, empty: true, read: 0 }\n }\n\n // A saved segment's filters, resolved ONCE rather than per candidate.\n let segment: ResolvedSegmentFilters | null = null\n if (rule.segmentId) {\n const segments = await orgDataCollectionForHost(\n options.hostId,\n 'contactSegments',\n )\n const snapshot = await segments.doc(rule.segmentId).get()\n // A deleted segment narrows to nothing rather than widening to everybody:\n // a rule whose filter vanished must not start matching the whole org.\n if (!snapshot.exists) {\n return {\n candidates: [],\n complete: true,\n cursor: null,\n empty: true,\n read: 0,\n }\n }\n segment = {\n tags: (snapshot.get('tags') as string[]) ?? [],\n sources: (snapshot.get('sources') as ResolvedSegmentFilters['sources']) ?? [],\n }\n }\n\n /*\n * A saved VIEW's filters, resolved ONCE into this language (AGL-2617).\n *\n * The same two leans the segment takes, for the same reason: a view that\n * was deleted narrows to nothing, and so does one whose clauses this\n * language cannot carry — `dynamicListDimensionsForCrmView` names them\n * rather than dropping them, and dropping one would make the audience\n * wider than the view the merchant picked. The picker offers only views\n * that translate whole, so the second case is a view edited after the\n * rule named it, and a view of another section — a list of companies is\n * not an audience of people — reads the same way.\n */\n let view: DynamicListDimensions | null = null\n if (rule.viewId) {\n const views = await orgDataCollectionForHost(\n options.hostId,\n CRM_COLLECTIONS.views,\n )\n const snapshot = await views.doc(rule.viewId).get()\n const translation =\n snapshot.exists && snapshot.get('section') === 'contacts'\n ? dynamicListDimensionsForCrmView(\n normalizeCrmViewFilters(snapshot.get('filters')),\n )\n : null\n if (!translation || translation.unsupported.length) {\n return {\n candidates: [],\n complete: true,\n cursor: null,\n empty: true,\n read: 0,\n }\n }\n view = translation.dimensions\n }\n\n /*\n * Resolved once for the whole scan: it names collections, and re-resolving\n * the org for every page would be a read per page for an unchanging answer.\n * Planned with the view's dimensions beside the rule's: a view naming an\n * owner needs the facet exactly as a rule naming one does.\n */\n const planning = dynamicListPlanningRule(rule, view)\n const enrichment = await planEnrichment(planning, options.hostId)\n\n /*\n * THE HOLDER whose contact facet a campaign or CRM clause is read from.\n *\n * The same group the writers use — the sites declared to be one sender, or\n * this site alone — because a facet written under one group id and read\n * under another is a membership that silently matches nobody. Resolved once,\n * and only for a rule that names something the facet holds: a campaign, an\n * owner, a stage, a company or a custom field. A rule naming none of them\n * pays no org read at all, which is the shape the engagement and list\n * lookups already take.\n */\n const facetGroupId = dynamicListRuleNeedsContactFacet(planning)\n ? (await consentGroupForSite(options.hostId)).groupId\n : null\n\n /** Matched people, de-duplicated across silos by their person key. */\n const matches = new Map<string, DynamicListCandidate>()\n let read = 0\n let cursor: DynamicListCursor | null = null\n let complete = true\n\n const ordered = rule.sources\n const resumeIndex = options.resume\n ? ordered.indexOf(options.resume.source)\n : -1\n\n scan: for (let index = 0; index < ordered.length; index += 1) {\n const source = ordered[index] as DynamicListSource\n // A resume skips the sources already finished and re-enters the one that\n // was interrupted at the id it stopped on.\n if (resumeIndex >= 0 && index < resumeIndex) continue\n let after = resumeIndex === index ? options.resume?.afterId ?? '' : ''\n const base = await sourceQuery(source, options.hostId)\n for (;;) {\n /*\n * Ordered by document id, and paged with a `startAfter` cursor.\n *\n * `orderBy(FieldPath.documentId())` rather than a data field on\n * purpose: an `orderBy` on a field DROPS every document missing it, and\n * these four collections have writers old enough that no data field is\n * guaranteed on every row. The id is on all of them, so this is a total\n * order that cannot silently omit a person — which is the whole point\n * of the exercise, since the defect being fixed is an unordered\n * `limit()` returning an arbitrary subset.\n */\n let page = base\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(PAGE_SIZE)\n if (after) page = page.startAfter(after)\n const snapshot = await page.get()\n if (snapshot.empty) break\n /*\n * The page is read into candidates FIRST, then enriched, then matched.\n *\n * A per-document match would mean a per-document enrichment lookup, and\n * the whole reason enrichment is affordable is that one page's people\n * are fetched in one `getAll`. Only candidates are enriched — a\n * document with no usable address is dropped by `toCandidate` before it\n * can cost a lookup.\n */\n const pageCandidates: DynamicListCandidate[] = []\n for (const doc of snapshot.docs) {\n read += 1\n after = doc.id\n const candidate = toCandidate(source, doc, facetGroupId)\n if (candidate) pageCandidates.push(candidate)\n }\n read += await enrichCandidates(pageCandidates, enrichment)\n for (const candidate of pageCandidates) {\n if (\n !candidateMatchesDynamicListRule(candidate, rule, {\n segment,\n view,\n nowMs,\n })\n ) {\n continue\n }\n const key = personKey(candidate.email)\n if (key && !matches.has(key)) matches.set(key, candidate)\n }\n if (read >= budget) {\n complete = false\n cursor = { source, afterId: after }\n break scan\n }\n if (snapshot.size < PAGE_SIZE) break\n }\n }\n\n\n return {\n candidates: [...matches.values()],\n complete,\n cursor,\n empty: false,\n read,\n }\n}\n\n/**\n * Re-evaluates one dynamic list and writes the result into its members.\n *\n * @param listRef `orgs/{orgId}/lists/{listId}` — the caller has proved it\n * exists and that it is `kind: 'dynamic'`.\n * @param hostId the site whose silos the rule draws from, and the scope the\n * org-owned contacts read is narrowed to.\n * @param resume a cursor from a previous incomplete run.\n */\nexport async function materializeDynamicList(options: {\n listRef: DocumentReference\n hostId: string\n rule: unknown\n resume?: DynamicListCursor | null\n nowMs?: number\n /**\n * The scan budget. Passed by NOTHING in production — it exists so the suite\n * can drive a sweep into an exhausted budget and require that it still\n * removes nobody. A guarantee that only holds at the production budget was\n * never that guarantee.\n */\n scanBudget?: number\n}): Promise<MaterializeDynamicListResult> {\n /*\n * The rule cannot refer to the list it materializes — see\n * `dynamicListRuleWithoutListReference` for the oscillation that produces,\n * and note that half of its beats are DELETIONS of rows this sweep created.\n * Stripped here rather than at authoring time because the rule outlives any\n * one form: an API caller, an import and a copied list would each need to\n * remember, and the sweep is the one place all of them arrive.\n */\n const rule = dynamicListRuleWithoutListReference(\n normalizeDynamicListRule(options.rule),\n options.listRef.id,\n )\n const scan = await collectDynamicListCandidates({\n hostId: options.hostId,\n rule,\n ...(options.resume ? { resume: options.resume } : {}),\n ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}),\n ...(options.scanBudget !== undefined\n ? { scanBudget: options.scanBudget }\n : {}),\n })\n if (scan.empty) {\n return {\n matched: 0,\n enrolled: 0,\n removed: 0,\n complete: true,\n cursor: null,\n empty: true,\n }\n }\n const { complete, cursor } = scan\n /*\n * Re-keyed by person rather than carried as a Map across the seam.\n *\n * Reconciliation below asks `matches.has(key)` about a stored row's id and\n * about its address, so it needs the same key the scan de-duplicated on —\n * computed by the same function, from the same addresses.\n */\n const matches = new Map<string, DynamicListCandidate>()\n for (const candidate of scan.candidates) {\n const key = personKey(candidate.email)\n if (key) matches.set(key, candidate)\n }\n\n let enrolled = 0\n for (const candidate of matches.values()) {\n const result = await enrollListMember({\n listRef: options.listRef,\n // A group of one, and it costs nothing: the materializer passes NO\n // consent (see below), so the group decides only which site is recorded\n // as having captured the row.\n group: soloConsentGroup(options.hostId),\n email: candidate.email,\n ...(candidate.name ? { name: candidate.name } : {}),\n source: `rule:${candidate.silo}`,\n via: 'rule',\n /*\n * ⛔ NO CONSENT IS PASSED, EVER.\n *\n * Matching a rule is not consenting to anything — the person is here\n * because they bought something, filled in a form or hold an account,\n * and none of those is an opt-in. Stamping a basis from a rule match\n * would let any merchant manufacture consent for their whole contact\n * list by writing a rule that selects it, which is precisely the\n * inference the consent arc refused to make. Their real basis, if they\n * have one, is on their own record and the send-time join reads it\n * there.\n */\n })\n // A refusal is not a materialization failure: an unusable address and a\n // person whose row records a refusal are both simply not on this list,\n // and a rule that selected them does not overrule either.\n if (result.enrolled && result.created) enrolled += 1\n }\n\n /*\n * Reconciliation is allowed only for a run that saw the WHOLE population:\n * one that started at the beginning AND finished.\n *\n * `complete` alone is not enough, and that is the trap. A run that RESUMES\n * from a cursor finishes the tail of the scan and reports `complete: true`\n * having examined only the tail — so its match set is the remainder, and\n * reconciling against it would delete every member found by the earlier\n * runs of the same sweep. That is a quota drop wearing the costume of a\n * membership change: nothing about it looks like a limit, and it removes\n * people for no reason but how the work was chunked.\n *\n * The cost is that a list whose population exceeds one run's scan budget\n * never reconciles, so somebody who stops matching stays enrolled until a\n * sweep fits in one run. That is the right way round: an over-full list is\n * a wrong count, which is recoverable, and a deleted enrollment destroys\n * the record that says the person asked to be there, which is not.\n */\n const sawWholePopulation = complete && !options.resume\n let removed = 0\n if (sawWholePopulation) {\n /*\n * Reconciliation.\n *\n * Only rows this materializer created are eligible (`via === 'rule'`). A\n * row somebody enrolled by hand, or that an automation's `enrollList`\n * step wrote, stays on a dynamic list forever: it was put there by a\n * decision, and a rule that does not happen to select that person is not\n * a decision to remove them. Rows written before `via` existed carry no\n * `via` at all and are therefore never eligible either, which is the\n * conservative direction for a field introduced after the data.\n *\n * This is the one removal in this file and it is a MEMBERSHIP change —\n * the person stopped matching the rule. It is not a capacity decision and\n * there is no count in the condition.\n */\n const existing = await options.listRef\n .collection('members')\n .where('via', '==', 'rule')\n .get()\n for (const doc of existing.docs) {\n if (matches.has(doc.id)) continue\n // A row adopted under a legacy id is keyed by neither `personKey` nor\n // anything this sweep computed, so it is matched by ADDRESS as well\n // before being removed. Getting that wrong un-enrolls a person who\n // still matches the rule.\n const email = normalizeContactEmail(doc.get('email'))\n const key = email ? personKey(email) : null\n if (key && matches.has(key)) continue\n await doc.ref.delete()\n removed += 1\n }\n }\n\n await options.listRef.set(\n {\n kind: 'dynamic',\n // The count the composer reads instead of scanning. Written only by a\n // run that saw everybody, for the same reason reconciliation is: a\n // partial or resumed run measured a fraction, and publishing that as\n // the list's size would under-report the audience in the one place a\n // merchant checks it before sending.\n ...(sawWholePopulation ? { memberCount: matches.size } : {}),\n lastEvaluatedAt: FieldValue.serverTimestamp(),\n ...(cursor ? { evaluationCursor: cursor } : { evaluationCursor: null }),\n },\n { merge: true },\n )\n\n return {\n matched: matches.size,\n enrolled,\n removed,\n complete,\n cursor,\n empty: false,\n }\n}\n"],"names":["FieldValue","soloConsentGroup","candidateMatchesDynamicListRule","dynamicListRuleIsEmpty","dynamicListRuleListIds","dynamicListRuleWithoutListReference","dynamicListRuleNeedsContactFacet","dynamicListDimensionsForCrmView","dynamicListPlanningRule","CRM_COLLECTIONS","normalizeCrmViewFilters","dynamicListRuleNeedsEngagement","extractEmailFromFields","isContactLifecycleStage","normalizeContactEmail","normalizeDynamicListRule","personKey","readCampaignIds","readContactCampaignIds","readContactFacet","firebaseAdmin","readPersonEngagementByKeys","enrollListMember","consentGroupForSite","orgDataCollectionForHost","scopedToHost","DYNAMIC_LIST_SCAN_BUDGET","PAGE_SIZE","sourceQuery","source","hostId","hostRef","app","firestore","collection","doc","ref","millis","value","Number","isFinite","toMillis","parsed","call","crmFieldsFromFacet","data","groupId","facet","custom","Array","isArray","undefined","engaged","lastEmailEngagementAtMs","ownerUid","lifecycleStage","companyId","toCandidate","email","createdAtMs","silo","name","tags","sources","ordersCount","ltvCents","lastPurchaseAtMs","campaignIds","formName","String","planEnrichment","rule","listIds","listMembers","Map","length","lists","listId","set","engagement","planIsEmpty","plan","size","enrichCandidates","candidates","keyed","candidate","key","held","get","push","keys","read","rows","found","lastOpenedAtMs","lastClickedAtMs","members","snapshots","getAll","map","error","console","snapshot","exists","id","collectDynamicListCandidates","options","nowMs","Date","now","budget","scanBudget","complete","cursor","empty","segment","segmentId","segments","view","viewId","views","translation","unsupported","dimensions","planning","enrichment","facetGroupId","matches","ordered","resumeIndex","resume","indexOf","scan","index","after","afterId","base","page","orderBy","FieldPath","documentId","limit","startAfter","pageCandidates","docs","has","values","materializeDynamicList","listRef","matched","enrolled","removed","result","group","via","created","sawWholePopulation","existing","where","delete","kind","memberCount","lastEvaluatedAt","serverTimestamp","evaluationCursor","merge"],"mappings":"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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { DocumentSnapshot, Firestore } from 'firebase-admin/firestore';
|
|
18
|
+
/**
|
|
19
|
+
* THE authorization gate for minting an edit-access token — extracted
|
|
20
|
+
* verbatim from the console's `/api/edit-access/token` (AGL-1302 follow-on)
|
|
21
|
+
* so the tenant's `/api/edit-access/exchange` (AGL-1842) evaluates the SAME
|
|
22
|
+
* question the same way, rather than growing a second authorization path
|
|
23
|
+
* that would drift from the first.
|
|
24
|
+
*
|
|
25
|
+
* The gate, in order:
|
|
26
|
+
*
|
|
27
|
+
* 1. `release_edit_bar` for the host's org — on for every site since its
|
|
28
|
+
* release (AGL-3041), and the kill switch for the whole surface, for
|
|
29
|
+
* everyone or for one org by staff override;
|
|
30
|
+
* 2. org roster membership — proven against the host the CALLER was already
|
|
31
|
+
* resolved to, never a caller-supplied orgId;
|
|
32
|
+
* 3. the co-editing edit gate the presence broker applies, for THIS host: a
|
|
33
|
+
* host role that may write content (admin, editor or author), from the
|
|
34
|
+
* host's `memberRoles` or the member's own `hostAccess`, or an org role
|
|
35
|
+
* above viewer held org-wide (AGL-3062). A viewer gets a plain 403 rather
|
|
36
|
+
* than a read-only bar;
|
|
37
|
+
* 4. the lockdown verdict (AGL-1506) at `intent: 'write'` — the reasoning
|
|
38
|
+
* for `write` on a stateless mint is recorded at length where this code
|
|
39
|
+
* came from and holds unchanged: the token buys entry to an editor whose
|
|
40
|
+
* saves a read-only lock denies, and it outlives this check by its TTL.
|
|
41
|
+
*
|
|
42
|
+
* Returns the refusal Response to send, or `null` when the uid may edit the
|
|
43
|
+
* host. Callers verify WHO the uid is first (a Firebase ID token at the
|
|
44
|
+
* console, a signed hint at the tenant exchange) — identity is the caller's
|
|
45
|
+
* business; permission is this function's.
|
|
46
|
+
*/
|
|
47
|
+
export declare function editAccessMintRefusal(options: {
|
|
48
|
+
request: Request;
|
|
49
|
+
firestore: Firestore;
|
|
50
|
+
/** The `hosts/{hostId}` snapshot, already confirmed to exist. */
|
|
51
|
+
host: DocumentSnapshot;
|
|
52
|
+
orgId: string;
|
|
53
|
+
uid: string;
|
|
54
|
+
/** From verified ID-token claims; a hint carries none, so it stays false. */
|
|
55
|
+
staff?: boolean;
|
|
56
|
+
}): Promise<Response | null>;
|
|
@@ -0,0 +1,107 @@
|
|
|
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 { hostRoleCanWrite, hostRoleFor, isOrgWideMember } from "@aglyn/aglyn/server";
|
|
17
|
+
import { lockdownRefusal } from "./lockdown.js";
|
|
18
|
+
import { getOrgDoc } from "./organizations.js";
|
|
19
|
+
import { isServerReleaseFlagOnForOrg } from "./release-flags.js";
|
|
20
|
+
/**
|
|
21
|
+
* THE authorization gate for minting an edit-access token — extracted
|
|
22
|
+
* verbatim from the console's `/api/edit-access/token` (AGL-1302 follow-on)
|
|
23
|
+
* so the tenant's `/api/edit-access/exchange` (AGL-1842) evaluates the SAME
|
|
24
|
+
* question the same way, rather than growing a second authorization path
|
|
25
|
+
* that would drift from the first.
|
|
26
|
+
*
|
|
27
|
+
* The gate, in order:
|
|
28
|
+
*
|
|
29
|
+
* 1. `release_edit_bar` for the host's org — on for every site since its
|
|
30
|
+
* release (AGL-3041), and the kill switch for the whole surface, for
|
|
31
|
+
* everyone or for one org by staff override;
|
|
32
|
+
* 2. org roster membership — proven against the host the CALLER was already
|
|
33
|
+
* resolved to, never a caller-supplied orgId;
|
|
34
|
+
* 3. the co-editing edit gate the presence broker applies, for THIS host: a
|
|
35
|
+
* host role that may write content (admin, editor or author), from the
|
|
36
|
+
* host's `memberRoles` or the member's own `hostAccess`, or an org role
|
|
37
|
+
* above viewer held org-wide (AGL-3062). A viewer gets a plain 403 rather
|
|
38
|
+
* than a read-only bar;
|
|
39
|
+
* 4. the lockdown verdict (AGL-1506) at `intent: 'write'` — the reasoning
|
|
40
|
+
* for `write` on a stateless mint is recorded at length where this code
|
|
41
|
+
* came from and holds unchanged: the token buys entry to an editor whose
|
|
42
|
+
* saves a read-only lock denies, and it outlives this check by its TTL.
|
|
43
|
+
*
|
|
44
|
+
* Returns the refusal Response to send, or `null` when the uid may edit the
|
|
45
|
+
* host. Callers verify WHO the uid is first (a Firebase ID token at the
|
|
46
|
+
* console, a signed hint at the tenant exchange) — identity is the caller's
|
|
47
|
+
* business; permission is this function's.
|
|
48
|
+
*/ export async function editAccessMintRefusal(options) {
|
|
49
|
+
var _membership_data, _host_get, _ref;
|
|
50
|
+
const { request, firestore, host, orgId, uid, staff = false } = options;
|
|
51
|
+
if (!await isServerReleaseFlagOnForOrg('release_edit_bar', orgId)) {
|
|
52
|
+
return Response.json({
|
|
53
|
+
error: 'Not available'
|
|
54
|
+
}, {
|
|
55
|
+
status: 404
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const membership = await firestore.collection('orgs').doc(orgId).collection('members').doc(uid).get();
|
|
59
|
+
if (!membership.exists) {
|
|
60
|
+
return Response.json({
|
|
61
|
+
error: 'Not a member of this site'
|
|
62
|
+
}, {
|
|
63
|
+
status: 403
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// A roster row proves membership of the ORG, not reach to this host. The
|
|
67
|
+
// roster holds site collaborators too, and one can carry the org role
|
|
68
|
+
// `editor` with `allHosts: false` and a `hostAccess` map naming only their
|
|
69
|
+
// own sites — so the org-role arm counts only when the member is org-wide,
|
|
70
|
+
// exactly as the presence broker asks it (AGL-1881). Otherwise that
|
|
71
|
+
// collaborator would be minted a token for every site in the org, and the
|
|
72
|
+
// bar would show them another site's screens and today's traffic
|
|
73
|
+
// (AGL-3062).
|
|
74
|
+
//
|
|
75
|
+
// `hostRoleFor` reads `hostAccess`/`allHosts` for this host, and
|
|
76
|
+
// `isOrgWideMember` sits beside it for the one shape the two read
|
|
77
|
+
// differently: a pre-`allHosts` row with no `hostAccess` is org-wide, and
|
|
78
|
+
// `hostRoleFor` alone would lock that member out of their own sites.
|
|
79
|
+
const member = (_membership_data = membership.data()) != null ? _membership_data : {};
|
|
80
|
+
const orgRole = member.role;
|
|
81
|
+
const hostRole = ((_host_get = host.get('memberRoles')) != null ? _host_get : {})[uid];
|
|
82
|
+
// `author` (AGL-2334) — the edit-bar entry token. Same reasoning as the
|
|
83
|
+
// presence broker: this buys entry to the editor, and an author's whole
|
|
84
|
+
// purpose is to be in it. What they cannot do once inside is enforced by
|
|
85
|
+
// the rules on the publish fields.
|
|
86
|
+
const canEdit = hostRoleCanWrite(hostRole) || hostRoleCanWrite(hostRoleFor(member, host.id)) || isOrgWideMember(member) && (orgRole === 'owner' || orgRole === 'admin' || orgRole === 'editor');
|
|
87
|
+
if (!canEdit) {
|
|
88
|
+
return Response.json({
|
|
89
|
+
error: 'No edit access'
|
|
90
|
+
}, {
|
|
91
|
+
status: 403
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// Host doc already in hand; the org scope rides the member doc's
|
|
95
|
+
// `orgSuspended` projection (also already read) — the org doc is fetched
|
|
96
|
+
// only when the projection trips, so the happy path adds no org read.
|
|
97
|
+
return lockdownRefusal({
|
|
98
|
+
request,
|
|
99
|
+
intent: 'write',
|
|
100
|
+
staff,
|
|
101
|
+
uid,
|
|
102
|
+
org: membership.get('orgSuspended') === true ? (_ref = await getOrgDoc(orgId)) != null ? _ref : {} : undefined,
|
|
103
|
+
host: host.data()
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//# sourceMappingURL=edit-access-authz.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/edit-access-authz.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgMember,\n hostRoleCanWrite,\n hostRoleFor,\n isOrgWideMember,\n} from '@aglyn/aglyn/server'\nimport type { DocumentSnapshot, Firestore } from 'firebase-admin/firestore'\nimport { lockdownRefusal } from './lockdown'\nimport { getOrgDoc } from './organizations'\nimport { isServerReleaseFlagOnForOrg } from './release-flags'\n\n/**\n * THE authorization gate for minting an edit-access token — extracted\n * verbatim from the console's `/api/edit-access/token` (AGL-1302 follow-on)\n * so the tenant's `/api/edit-access/exchange` (AGL-1842) evaluates the SAME\n * question the same way, rather than growing a second authorization path\n * that would drift from the first.\n *\n * The gate, in order:\n *\n * 1. `release_edit_bar` for the host's org — on for every site since its\n * release (AGL-3041), and the kill switch for the whole surface, for\n * everyone or for one org by staff override;\n * 2. org roster membership — proven against the host the CALLER was already\n * resolved to, never a caller-supplied orgId;\n * 3. the co-editing edit gate the presence broker applies, for THIS host: a\n * host role that may write content (admin, editor or author), from the\n * host's `memberRoles` or the member's own `hostAccess`, or an org role\n * above viewer held org-wide (AGL-3062). A viewer gets a plain 403 rather\n * than a read-only bar;\n * 4. the lockdown verdict (AGL-1506) at `intent: 'write'` — the reasoning\n * for `write` on a stateless mint is recorded at length where this code\n * came from and holds unchanged: the token buys entry to an editor whose\n * saves a read-only lock denies, and it outlives this check by its TTL.\n *\n * Returns the refusal Response to send, or `null` when the uid may edit the\n * host. Callers verify WHO the uid is first (a Firebase ID token at the\n * console, a signed hint at the tenant exchange) — identity is the caller's\n * business; permission is this function's.\n */\nexport async function editAccessMintRefusal(options: {\n request: Request\n firestore: Firestore\n /** The `hosts/{hostId}` snapshot, already confirmed to exist. */\n host: DocumentSnapshot\n orgId: string\n uid: string\n /** From verified ID-token claims; a hint carries none, so it stays false. */\n staff?: boolean\n}): Promise<Response | null> {\n const { request, firestore, host, orgId, uid, staff = false } = options\n\n if (!(await isServerReleaseFlagOnForOrg('release_edit_bar', orgId))) {\n return Response.json({ error: 'Not available' }, { status: 404 })\n }\n\n const membership = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .doc(uid)\n .get()\n if (!membership.exists) {\n return Response.json({ error: 'Not a member of this site' }, { status: 403 })\n }\n\n // A roster row proves membership of the ORG, not reach to this host. The\n // roster holds site collaborators too, and one can carry the org role\n // `editor` with `allHosts: false` and a `hostAccess` map naming only their\n // own sites — so the org-role arm counts only when the member is org-wide,\n // exactly as the presence broker asks it (AGL-1881). Otherwise that\n // collaborator would be minted a token for every site in the org, and the\n // bar would show them another site's screens and today's traffic\n // (AGL-3062).\n //\n // `hostRoleFor` reads `hostAccess`/`allHosts` for this host, and\n // `isOrgWideMember` sits beside it for the one shape the two read\n // differently: a pre-`allHosts` row with no `hostAccess` is org-wide, and\n // `hostRoleFor` alone would lock that member out of their own sites.\n const member = (membership.data() ?? {}) as Partial<AglynOrgMember>\n const orgRole = member.role\n const hostRole = ((host.get('memberRoles') ?? {}) as Record<string, string>)[\n uid\n ]\n // `author` (AGL-2334) — the edit-bar entry token. Same reasoning as the\n // presence broker: this buys entry to the editor, and an author's whole\n // purpose is to be in it. What they cannot do once inside is enforced by\n // the rules on the publish fields.\n const canEdit =\n hostRoleCanWrite(hostRole) ||\n hostRoleCanWrite(hostRoleFor(member, host.id)) ||\n (isOrgWideMember(member) &&\n (orgRole === 'owner' || orgRole === 'admin' || orgRole === 'editor'))\n if (!canEdit) {\n return Response.json({ error: 'No edit access' }, { status: 403 })\n }\n\n // Host doc already in hand; the org scope rides the member doc's\n // `orgSuspended` projection (also already read) — the org doc is fetched\n // only when the projection trips, so the happy path adds no org read.\n return lockdownRefusal({\n request,\n intent: 'write',\n staff,\n uid,\n org:\n membership.get('orgSuspended') === true\n ? ((await getOrgDoc(orgId)) ?? {})\n : undefined,\n host: host.data(),\n })\n}\n"],"names":["hostRoleCanWrite","hostRoleFor","isOrgWideMember","lockdownRefusal","getOrgDoc","isServerReleaseFlagOnForOrg","editAccessMintRefusal","options","membership","host","request","firestore","orgId","uid","staff","Response","json","error","status","collection","doc","get","exists","member","data","orgRole","role","hostRole","canEdit","id","intent","org","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,gBAAgB,EAChBC,WAAW,EACXC,eAAe,QACV,sBAAqB;AAE5B,SAASC,eAAe,QAAQ,gBAAY;AAC5C,SAASC,SAAS,QAAQ,qBAAiB;AAC3C,SAASC,2BAA2B,QAAQ,qBAAiB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,eAAeC,sBAAsBC,OAS3C;QA8BiBC,kBAEGC,WA0BT;IAzDV,MAAM,EAAEC,OAAO,EAAEC,SAAS,EAAEF,IAAI,EAAEG,KAAK,EAAEC,GAAG,EAAEC,QAAQ,KAAK,EAAE,GAAGP;IAEhE,IAAI,CAAE,MAAMF,4BAA4B,oBAAoBO,QAAS;QACnE,OAAOG,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAgB,GAAG;YAAEC,QAAQ;QAAI;IACjE;IAEA,MAAMV,aAAa,MAAMG,UACtBQ,UAAU,CAAC,QACXC,GAAG,CAACR,OACJO,UAAU,CAAC,WACXC,GAAG,CAACP,KACJQ,GAAG;IACN,IAAI,CAACb,WAAWc,MAAM,EAAE;QACtB,OAAOP,SAASC,IAAI,CAAC;YAAEC,OAAO;QAA4B,GAAG;YAAEC,QAAQ;QAAI;IAC7E;IAEA,yEAAyE;IACzE,sEAAsE;IACtE,2EAA2E;IAC3E,2EAA2E;IAC3E,oEAAoE;IACpE,0EAA0E;IAC1E,iEAAiE;IACjE,cAAc;IACd,EAAE;IACF,iEAAiE;IACjE,kEAAkE;IAClE,0EAA0E;IAC1E,qEAAqE;IACrE,MAAMK,UAAUf,mBAAAA,WAAWgB,IAAI,cAAfhB,mBAAqB,CAAC;IACtC,MAAMiB,UAAUF,OAAOG,IAAI;IAC3B,MAAMC,WAAW,EAAElB,YAAAA,KAAKY,GAAG,CAAC,0BAATZ,YAA2B,CAAC,EAA6B,CAC1EI,IACD;IACD,wEAAwE;IACxE,wEAAwE;IACxE,yEAAyE;IACzE,mCAAmC;IACnC,MAAMe,UACJ5B,iBAAiB2B,aACjB3B,iBAAiBC,YAAYsB,QAAQd,KAAKoB,EAAE,MAC3C3B,gBAAgBqB,WACdE,CAAAA,YAAY,WAAWA,YAAY,WAAWA,YAAY,QAAO;IACtE,IAAI,CAACG,SAAS;QACZ,OAAOb,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAiB,GAAG;YAAEC,QAAQ;QAAI;IAClE;IAEA,iEAAiE;IACjE,yEAAyE;IACzE,sEAAsE;IACtE,OAAOf,gBAAgB;QACrBO;QACAoB,QAAQ;QACRhB;QACAD;QACAkB,KACEvB,WAAWa,GAAG,CAAC,oBAAoB,QAC7B,OAAA,MAAMjB,UAAUQ,kBAAhB,OAA2B,CAAC,IAC9BoB;QACNvB,MAAMA,KAAKe,IAAI;IACjB;AACF"}
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
* Edit-access tokens for the tenant admin bar (admin edit bar, AGL-1302
|
|
19
|
+
* follow-on).
|
|
20
|
+
*
|
|
21
|
+
* The console session lives first-party on app.aglyn.com; published sites
|
|
22
|
+
* live on aglyn.app subdomains and customer domains, and with third-party
|
|
23
|
+
* cookies gone an embedded console frame cannot see its own session. So the
|
|
24
|
+
* console VERIFIES edit access server-side (the same host-scoped gate the
|
|
25
|
+
* presence broker applies for co-editing: a role on this host that may write
|
|
26
|
+
* content, or an org-wide role above viewer) and bakes the verdict into a
|
|
27
|
+
* short-lived token the tenant can check without asking anyone:
|
|
28
|
+
* `{hostId, uid, exp}` HMAC-signed with the shared, fail-closed
|
|
29
|
+
* `TOKEN_SIGNING_SECRET` (docs/COMMERCE_TOKEN_SIGNING.md) — already
|
|
30
|
+
* provisioned on both the console and tenant projects, which is the whole
|
|
31
|
+
* reason to reuse it rather than mint a second secret to keep in step.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately NOT an auth system: the token grants nothing but the admin
|
|
34
|
+
* bar's convenience UI, a read of routing facts the page HTML already
|
|
35
|
+
* carries, and the site's page-view counts for today, which are not public —
|
|
36
|
+
* the reason the mint must scope to the host. It is scoped to ONE host,
|
|
37
|
+
* expires in {@link EDIT_ACCESS_TOKEN_TTL_MS}, and revocation is the
|
|
38
|
+
* `release_edit_bar` flag — flipping it off makes every outstanding token
|
|
39
|
+
* useless at the verify site.
|
|
40
|
+
* The `edit-bar:` context string domain-separates the signature so a token
|
|
41
|
+
* minted here can never replay as a media or commerce signature (they share
|
|
42
|
+
* the secret, so the namespace is the isolation).
|
|
43
|
+
*/
|
|
44
|
+
/** 30 minutes — well under the ≤1h ceiling; reconnecting is one click. */
|
|
45
|
+
export declare const EDIT_ACCESS_TOKEN_TTL_MS: number;
|
|
46
|
+
export interface EditAccessClaims {
|
|
47
|
+
/** The ONE host the console proved the caller may edit. */
|
|
48
|
+
hostId: string;
|
|
49
|
+
uid: string;
|
|
50
|
+
/** Expiry, epoch ms. */
|
|
51
|
+
exp: number;
|
|
52
|
+
}
|
|
53
|
+
export interface MintedEditAccessToken {
|
|
54
|
+
token: string;
|
|
55
|
+
expiresAtMs: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Mints a token asserting "this uid may edit this host, until exp". Two
|
|
59
|
+
* routes call this, each only AFTER proving who the caller is and passing the
|
|
60
|
+
* shared `editAccessMintRefusal` gate: the console's `/api/edit-access/token`
|
|
61
|
+
* (a verified Firebase ID token) and the tenant's `/api/edit-access/exchange`
|
|
62
|
+
* (a verified signed hint, AGL-1842). The mint itself checks nothing, which is
|
|
63
|
+
* why it must never be reachable from unverified input.
|
|
64
|
+
*/
|
|
65
|
+
export declare function mintEditAccessToken(hostId: string, uid: string, nowMs?: number): MintedEditAccessToken;
|
|
66
|
+
/**
|
|
67
|
+
* Verifies a presented token and returns its claims, or `null` for anything
|
|
68
|
+
* else — expired, tampered, malformed, wrong version, or a deploy with no
|
|
69
|
+
* signing secret (fail closed, like the media signatures). Callers must still
|
|
70
|
+
* check `claims.hostId` against the host actually being served.
|
|
71
|
+
*/
|
|
72
|
+
export declare function verifyEditAccessToken(token: unknown, nowMs?: number): EditAccessClaims | null;
|
|
@@ -0,0 +1,116 @@
|
|
|
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 { createHmac } from "crypto";
|
|
17
|
+
import { safeEqual } from "./safe-equal.js";
|
|
18
|
+
import { tokenSigningSecret } from "./media-signing.js";
|
|
19
|
+
/**
|
|
20
|
+
* Edit-access tokens for the tenant admin bar (admin edit bar, AGL-1302
|
|
21
|
+
* follow-on).
|
|
22
|
+
*
|
|
23
|
+
* The console session lives first-party on app.aglyn.com; published sites
|
|
24
|
+
* live on aglyn.app subdomains and customer domains, and with third-party
|
|
25
|
+
* cookies gone an embedded console frame cannot see its own session. So the
|
|
26
|
+
* console VERIFIES edit access server-side (the same host-scoped gate the
|
|
27
|
+
* presence broker applies for co-editing: a role on this host that may write
|
|
28
|
+
* content, or an org-wide role above viewer) and bakes the verdict into a
|
|
29
|
+
* short-lived token the tenant can check without asking anyone:
|
|
30
|
+
* `{hostId, uid, exp}` HMAC-signed with the shared, fail-closed
|
|
31
|
+
* `TOKEN_SIGNING_SECRET` (docs/COMMERCE_TOKEN_SIGNING.md) — already
|
|
32
|
+
* provisioned on both the console and tenant projects, which is the whole
|
|
33
|
+
* reason to reuse it rather than mint a second secret to keep in step.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately NOT an auth system: the token grants nothing but the admin
|
|
36
|
+
* bar's convenience UI, a read of routing facts the page HTML already
|
|
37
|
+
* carries, and the site's page-view counts for today, which are not public —
|
|
38
|
+
* the reason the mint must scope to the host. It is scoped to ONE host,
|
|
39
|
+
* expires in {@link EDIT_ACCESS_TOKEN_TTL_MS}, and revocation is the
|
|
40
|
+
* `release_edit_bar` flag — flipping it off makes every outstanding token
|
|
41
|
+
* useless at the verify site.
|
|
42
|
+
* The `edit-bar:` context string domain-separates the signature so a token
|
|
43
|
+
* minted here can never replay as a media or commerce signature (they share
|
|
44
|
+
* the secret, so the namespace is the isolation).
|
|
45
|
+
*/ /** 30 minutes — well under the ≤1h ceiling; reconnecting is one click. */ export const EDIT_ACCESS_TOKEN_TTL_MS = 30 * 60 * 1000;
|
|
46
|
+
/**
|
|
47
|
+
* Version tag on the wire so a future claim change can reject old tokens by
|
|
48
|
+
* shape instead of by signature failure (clearer to debug).
|
|
49
|
+
*/ const TOKEN_PREFIX = 'aglyn-edit-bar-v1';
|
|
50
|
+
function signPayload(payloadB64) {
|
|
51
|
+
return createHmac('sha256', tokenSigningSecret()).update(`edit-bar:${payloadB64}`).digest('base64url');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Mints a token asserting "this uid may edit this host, until exp". Two
|
|
55
|
+
* routes call this, each only AFTER proving who the caller is and passing the
|
|
56
|
+
* shared `editAccessMintRefusal` gate: the console's `/api/edit-access/token`
|
|
57
|
+
* (a verified Firebase ID token) and the tenant's `/api/edit-access/exchange`
|
|
58
|
+
* (a verified signed hint, AGL-1842). The mint itself checks nothing, which is
|
|
59
|
+
* why it must never be reachable from unverified input.
|
|
60
|
+
*/ export function mintEditAccessToken(hostId, uid, nowMs = Date.now()) {
|
|
61
|
+
if (!hostId || !uid) throw new Error('hostId and uid are required');
|
|
62
|
+
const exp = nowMs + EDIT_ACCESS_TOKEN_TTL_MS;
|
|
63
|
+
const claims = {
|
|
64
|
+
hostId,
|
|
65
|
+
uid,
|
|
66
|
+
exp
|
|
67
|
+
};
|
|
68
|
+
const payload = Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url');
|
|
69
|
+
// The signature covers the ENCODED payload, so there is exactly one byte
|
|
70
|
+
// string being signed and verified — no re-serialization ambiguity.
|
|
71
|
+
return {
|
|
72
|
+
token: `${TOKEN_PREFIX}.${payload}.${signPayload(payload)}`,
|
|
73
|
+
expiresAtMs: exp
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Verifies a presented token and returns its claims, or `null` for anything
|
|
78
|
+
* else — expired, tampered, malformed, wrong version, or a deploy with no
|
|
79
|
+
* signing secret (fail closed, like the media signatures). Callers must still
|
|
80
|
+
* check `claims.hostId` against the host actually being served.
|
|
81
|
+
*/ export function verifyEditAccessToken(token, nowMs = Date.now()) {
|
|
82
|
+
if (typeof token !== 'string' || token.length === 0 || token.length > 4096) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const parts = token.split('.');
|
|
86
|
+
if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX) return null;
|
|
87
|
+
const [, payload, sig] = parts;
|
|
88
|
+
let expected;
|
|
89
|
+
try {
|
|
90
|
+
expected = signPayload(payload);
|
|
91
|
+
} catch (unused) {
|
|
92
|
+
// Secret missing — refuse rather than trusting anything.
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
// `safeEqual` length-checks before `timingSafeEqual`, which THROWS on
|
|
96
|
+
// mismatched lengths an attacker controls — a 500 where a 401 belongs. One
|
|
97
|
+
// implementation since AGL-1902; this was one of six hand-written copies.
|
|
98
|
+
if (!safeEqual(sig, expected)) return null;
|
|
99
|
+
let claims;
|
|
100
|
+
try {
|
|
101
|
+
claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
102
|
+
} catch (unused) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const exp = Number(claims == null ? void 0 : claims.exp);
|
|
106
|
+
if (!Number.isFinite(exp) || exp <= nowMs) return null;
|
|
107
|
+
if (typeof claims.hostId !== 'string' || !claims.hostId) return null;
|
|
108
|
+
if (typeof claims.uid !== 'string' || !claims.uid) return null;
|
|
109
|
+
return {
|
|
110
|
+
hostId: claims.hostId,
|
|
111
|
+
uid: claims.uid,
|
|
112
|
+
exp
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//# sourceMappingURL=edit-access-token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/edit-access-token.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 { createHmac } from 'crypto'\nimport { safeEqual } from './safe-equal'\nimport { tokenSigningSecret } from './media-signing'\n\n/**\n * Edit-access tokens for the tenant admin bar (admin edit bar, AGL-1302\n * follow-on).\n *\n * The console session lives first-party on app.aglyn.com; published sites\n * live on aglyn.app subdomains and customer domains, and with third-party\n * cookies gone an embedded console frame cannot see its own session. So the\n * console VERIFIES edit access server-side (the same host-scoped gate the\n * presence broker applies for co-editing: a role on this host that may write\n * content, or an org-wide role above viewer) and bakes the verdict into a\n * short-lived token the tenant can check without asking anyone:\n * `{hostId, uid, exp}` HMAC-signed with the shared, fail-closed\n * `TOKEN_SIGNING_SECRET` (docs/COMMERCE_TOKEN_SIGNING.md) — already\n * provisioned on both the console and tenant projects, which is the whole\n * reason to reuse it rather than mint a second secret to keep in step.\n *\n * Deliberately NOT an auth system: the token grants nothing but the admin\n * bar's convenience UI, a read of routing facts the page HTML already\n * carries, and the site's page-view counts for today, which are not public —\n * the reason the mint must scope to the host. It is scoped to ONE host,\n * expires in {@link EDIT_ACCESS_TOKEN_TTL_MS}, and revocation is the\n * `release_edit_bar` flag — flipping it off makes every outstanding token\n * useless at the verify site.\n * The `edit-bar:` context string domain-separates the signature so a token\n * minted here can never replay as a media or commerce signature (they share\n * the secret, so the namespace is the isolation).\n */\n\n/** 30 minutes — well under the ≤1h ceiling; reconnecting is one click. */\nexport const EDIT_ACCESS_TOKEN_TTL_MS = 30 * 60 * 1000\n\n/**\n * Version tag on the wire so a future claim change can reject old tokens by\n * shape instead of by signature failure (clearer to debug).\n */\nconst TOKEN_PREFIX = 'aglyn-edit-bar-v1'\n\nexport interface EditAccessClaims {\n /** The ONE host the console proved the caller may edit. */\n hostId: string\n uid: string\n /** Expiry, epoch ms. */\n exp: number\n}\n\nfunction signPayload(payloadB64: string): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`edit-bar:${payloadB64}`)\n .digest('base64url')\n}\n\nexport interface MintedEditAccessToken {\n token: string\n expiresAtMs: number\n}\n\n/**\n * Mints a token asserting \"this uid may edit this host, until exp\". Two\n * routes call this, each only AFTER proving who the caller is and passing the\n * shared `editAccessMintRefusal` gate: the console's `/api/edit-access/token`\n * (a verified Firebase ID token) and the tenant's `/api/edit-access/exchange`\n * (a verified signed hint, AGL-1842). The mint itself checks nothing, which is\n * why it must never be reachable from unverified input.\n */\nexport function mintEditAccessToken(\n hostId: string,\n uid: string,\n nowMs: number = Date.now(),\n): MintedEditAccessToken {\n if (!hostId || !uid) throw new Error('hostId and uid are required')\n const exp = nowMs + EDIT_ACCESS_TOKEN_TTL_MS\n const claims: EditAccessClaims = { hostId, uid, exp }\n const payload = Buffer.from(JSON.stringify(claims), 'utf8').toString(\n 'base64url',\n )\n // The signature covers the ENCODED payload, so there is exactly one byte\n // string being signed and verified — no re-serialization ambiguity.\n return {\n token: `${TOKEN_PREFIX}.${payload}.${signPayload(payload)}`,\n expiresAtMs: exp,\n }\n}\n\n/**\n * Verifies a presented token and returns its claims, or `null` for anything\n * else — expired, tampered, malformed, wrong version, or a deploy with no\n * signing secret (fail closed, like the media signatures). Callers must still\n * check `claims.hostId` against the host actually being served.\n */\nexport function verifyEditAccessToken(\n token: unknown,\n nowMs: number = Date.now(),\n): EditAccessClaims | null {\n if (typeof token !== 'string' || token.length === 0 || token.length > 4096) {\n return null\n }\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX) return null\n const [, payload, sig] = parts\n let expected: string\n try {\n expected = signPayload(payload)\n } catch {\n // Secret missing — refuse rather than trusting anything.\n return null\n }\n // `safeEqual` length-checks before `timingSafeEqual`, which THROWS on\n // mismatched lengths an attacker controls — a 500 where a 401 belongs. One\n // implementation since AGL-1902; this was one of six hand-written copies.\n if (!safeEqual(sig, expected)) return null\n let claims: EditAccessClaims\n try {\n claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))\n } catch {\n return null\n }\n const exp = Number(claims?.exp)\n if (!Number.isFinite(exp) || exp <= nowMs) return null\n if (typeof claims.hostId !== 'string' || !claims.hostId) return null\n if (typeof claims.uid !== 'string' || !claims.uid) return null\n return { hostId: claims.hostId, uid: claims.uid, exp }\n}\n"],"names":["createHmac","safeEqual","tokenSigningSecret","EDIT_ACCESS_TOKEN_TTL_MS","TOKEN_PREFIX","signPayload","payloadB64","update","digest","mintEditAccessToken","hostId","uid","nowMs","Date","now","Error","exp","claims","payload","Buffer","from","JSON","stringify","toString","token","expiresAtMs","verifyEditAccessToken","length","parts","split","sig","expected","parse","Number","isFinite"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,SAAS,QAAQ,kBAAc;AACxC,SAASC,kBAAkB,QAAQ,qBAAiB;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,wEAAwE,GACxE,OAAO,MAAMC,2BAA2B,KAAK,KAAK,KAAI;AAEtD;;;CAGC,GACD,MAAMC,eAAe;AAUrB,SAASC,YAAYC,UAAkB;IACrC,OAAON,WAAW,UAAUE,sBACzBK,MAAM,CAAC,CAAC,SAAS,EAAED,YAAY,EAC/BE,MAAM,CAAC;AACZ;AAOA;;;;;;;CAOC,GACD,OAAO,SAASC,oBACdC,MAAc,EACdC,GAAW,EACXC,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,IAAI,CAACJ,UAAU,CAACC,KAAK,MAAM,IAAII,MAAM;IACrC,MAAMC,MAAMJ,QAAQT;IACpB,MAAMc,SAA2B;QAAEP;QAAQC;QAAKK;IAAI;IACpD,MAAME,UAAUC,OAAOC,IAAI,CAACC,KAAKC,SAAS,CAACL,SAAS,QAAQM,QAAQ,CAClE;IAEF,yEAAyE;IACzE,oEAAoE;IACpE,OAAO;QACLC,OAAO,GAAGpB,aAAa,CAAC,EAAEc,QAAQ,CAAC,EAAEb,YAAYa,UAAU;QAC3DO,aAAaT;IACf;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASU,sBACdF,KAAc,EACdZ,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,IAAI,OAAOU,UAAU,YAAYA,MAAMG,MAAM,KAAK,KAAKH,MAAMG,MAAM,GAAG,MAAM;QAC1E,OAAO;IACT;IACA,MAAMC,QAAQJ,MAAMK,KAAK,CAAC;IAC1B,IAAID,MAAMD,MAAM,KAAK,KAAKC,KAAK,CAAC,EAAE,KAAKxB,cAAc,OAAO;IAC5D,MAAM,GAAGc,SAASY,IAAI,GAAGF;IACzB,IAAIG;IACJ,IAAI;QACFA,WAAW1B,YAAYa;IACzB,EAAE,eAAM;QACN,yDAAyD;QACzD,OAAO;IACT;IACA,sEAAsE;IACtE,2EAA2E;IAC3E,0EAA0E;IAC1E,IAAI,CAACjB,UAAU6B,KAAKC,WAAW,OAAO;IACtC,IAAId;IACJ,IAAI;QACFA,SAASI,KAAKW,KAAK,CAACb,OAAOC,IAAI,CAACF,SAAS,aAAaK,QAAQ,CAAC;IACjE,EAAE,eAAM;QACN,OAAO;IACT;IACA,MAAMP,MAAMiB,OAAOhB,0BAAAA,OAAQD,GAAG;IAC9B,IAAI,CAACiB,OAAOC,QAAQ,CAAClB,QAAQA,OAAOJ,OAAO,OAAO;IAClD,IAAI,OAAOK,OAAOP,MAAM,KAAK,YAAY,CAACO,OAAOP,MAAM,EAAE,OAAO;IAChE,IAAI,OAAOO,OAAON,GAAG,KAAK,YAAY,CAACM,OAAON,GAAG,EAAE,OAAO;IAC1D,OAAO;QAAED,QAAQO,OAAOP,MAAM;QAAEC,KAAKM,OAAON,GAAG;QAAEK;IAAI;AACvD"}
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
* Editor-presence HINT tokens for the tenant admin bar on `*.aglyn.app`
|
|
19
|
+
* (AGL-1842, the cross-site half AGL-1829 could not reach).
|
|
20
|
+
*
|
|
21
|
+
* The console session lives on `app.aglyn.com`; `*.aglyn.app` is a different
|
|
22
|
+
* registrable domain, so neither the AGL-1829 hint cookie nor the silent
|
|
23
|
+
* iframe probe can cross over — storage partitioning keeps the two worlds
|
|
24
|
+
* apart, correctly. What CAN cross is a top-level navigation: at console
|
|
25
|
+
* sign-in the browser is bounced once through
|
|
26
|
+
* `https://console.aglyn.app/api/edit-hint/set?sig=<bounce token>` and back,
|
|
27
|
+
* and in that first-party moment the tenant app plants a signed hint cookie
|
|
28
|
+
* on `Domain=.aglyn.app` that every tenant subdomain can present to its own
|
|
29
|
+
* server afterwards.
|
|
30
|
+
*
|
|
31
|
+
* Two kinds, deliberately non-interchangeable:
|
|
32
|
+
*
|
|
33
|
+
* - `bounce` — rides the redirect URL once. URLs are logged and shareable,
|
|
34
|
+
* so its TTL is seconds ({@link EDIT_HINT_BOUNCE_TTL_MS}).
|
|
35
|
+
* - `cookie` — the planted `aglyn_edit_hint` value, HttpOnly, living the
|
|
36
|
+
* {@link EDIT_HINT_COOKIE_TTL_MS} window between console visits.
|
|
37
|
+
*
|
|
38
|
+
* The kind is INSIDE the signed context string, so a leaked 60-second bounce
|
|
39
|
+
* URL can never be replayed as a week-long cookie, and a stolen cookie value
|
|
40
|
+
* can never be planted elsewhere through the bounce endpoint. The `edit-hint:`
|
|
41
|
+
* prefix domain-separates both from the `edit-bar:`/`media:`/commerce
|
|
42
|
+
* signatures sharing `TOKEN_SIGNING_SECRET`.
|
|
43
|
+
*
|
|
44
|
+
* A hint is NOT edit access: it names a uid and nothing else. Everything
|
|
45
|
+
* real happens server-side at the tenant's `/api/edit-access/exchange`,
|
|
46
|
+
* which re-authorizes that uid against the specific host being viewed with
|
|
47
|
+
* the same membership gate the console's token mint applies. A forged,
|
|
48
|
+
* expired, or stale hint costs one refused POST.
|
|
49
|
+
*/
|
|
50
|
+
/** Seconds, not minutes: the bounce URL is single-use inside one redirect. */
|
|
51
|
+
export declare const EDIT_HINT_BOUNCE_TTL_MS: number;
|
|
52
|
+
/** 7 days — matches the AGL-1829 marker cookie's refresh cadence. */
|
|
53
|
+
export declare const EDIT_HINT_COOKIE_TTL_MS: number;
|
|
54
|
+
/**
|
|
55
|
+
* The HttpOnly signed hint cookie on `Domain=.aglyn.app`. Sibling of the
|
|
56
|
+
* JS-visible `aglyn_editor=1` marker (`EDITOR_HINT_COOKIE`), which the
|
|
57
|
+
* admin-bar stub reads to arm; this one only ever travels back to the
|
|
58
|
+
* tenant's own exchange route, where it is verified server-side.
|
|
59
|
+
*/
|
|
60
|
+
export declare const EDIT_HINT_COOKIE = "aglyn_edit_hint";
|
|
61
|
+
export type EditHintKind = 'bounce' | 'cookie';
|
|
62
|
+
export interface EditHintClaims {
|
|
63
|
+
uid: string;
|
|
64
|
+
/** Expiry, epoch ms. */
|
|
65
|
+
exp: number;
|
|
66
|
+
}
|
|
67
|
+
export interface MintedEditHintToken {
|
|
68
|
+
token: string;
|
|
69
|
+
expiresAtMs: number;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Mints a hint of the given kind for a uid the caller has ALREADY verified
|
|
73
|
+
* (the console route verifies a Firebase ID token; the bounce endpoint
|
|
74
|
+
* verifies a `bounce` hint). The mint itself checks nothing.
|
|
75
|
+
*/
|
|
76
|
+
export declare function mintEditHintToken(kind: EditHintKind, uid: string, nowMs?: number): MintedEditHintToken;
|
|
77
|
+
/**
|
|
78
|
+
* Verifies a presented hint AS the stated kind and returns its claims, or
|
|
79
|
+
* `null` for anything else — expired, tampered, malformed, wrong version,
|
|
80
|
+
* wrong KIND (the replay wall), or a deploy with no signing secret (fail
|
|
81
|
+
* closed, like every signature in this family).
|
|
82
|
+
*/
|
|
83
|
+
export declare function verifyEditHintToken(kind: EditHintKind, token: unknown, nowMs?: number): EditHintClaims | null;
|