@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,298 @@
|
|
|
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
|
+
* Customer API keys (AGL-616). Keys authenticate the public REST API
|
|
18
|
+
* (`/api/v1/**`). Only a SHA-256 hash of the key is ever stored — the raw
|
|
19
|
+
* token is shown to the user exactly once, at creation. The Firestore doc id
|
|
20
|
+
* IS the hash, so verification is an O(1) `get()` and the hash never leaves
|
|
21
|
+
* the server (listings return metadata only). The `apiKeys` collection is
|
|
22
|
+
* deny-all in the security rules; all access is Admin-SDK only.
|
|
23
|
+
*
|
|
24
|
+
* The token/hash/format/scope helpers are pure and unit-tested; the storage
|
|
25
|
+
* functions wrap the Admin SDK.
|
|
26
|
+
*/ import { createHash, randomBytes } from "node:crypto";
|
|
27
|
+
import { firebaseAdmin } from "./firebase-admin.js";
|
|
28
|
+
/**
|
|
29
|
+
* Scopes a key can be granted, as `resource:action`. Every entry must be
|
|
30
|
+
* enforced by a real endpoint — a mintable scope that grants nothing reads
|
|
31
|
+
* as a broken permission (AGL-899 removed `contacts:write` on those grounds,
|
|
32
|
+
* and AGL-2276 re-added it in the same change that shipped the writes, which
|
|
33
|
+
* is the condition AGL-899 set).
|
|
34
|
+
*/ export const API_SCOPES = [
|
|
35
|
+
'datasets:read',
|
|
36
|
+
'datasets:write',
|
|
37
|
+
'contacts:read',
|
|
38
|
+
// AGL-2276. Contacts used to arrive only through the four site capture
|
|
39
|
+
// points, so an integration that OWNS the customer list upstream — a CRM,
|
|
40
|
+
// a mailing tool, a migration off another platform — could read the
|
|
41
|
+
// audience and never add to it. The write is metered against the same
|
|
42
|
+
// audience bands capture is (AGL-890): `checkContactQuota` on the create,
|
|
43
|
+
// and the monthly rollup counts `orgs/{orgId}/contacts` whatever wrote
|
|
44
|
+
// them, so an API-created contact bills exactly like a form-captured one.
|
|
45
|
+
// The scope covers the create, the tag/notes edit the console's Contacts
|
|
46
|
+
// page already makes, and the delete — never `email`, which is the dedupe
|
|
47
|
+
// key, and never `sources`/`interactions`, which are provenance.
|
|
48
|
+
'contacts:write',
|
|
49
|
+
'sites:read',
|
|
50
|
+
// AGL-2462. An integration could write a dataset record over /v1 and had no
|
|
51
|
+
// way to make a live page show it: `getDatasets` caches for 60s behind
|
|
52
|
+
// `tenant-data:{hostId}` and the catch-all page is `revalidate = 60`, and
|
|
53
|
+
// time-based ISR is stale-while-revalidate — so the visitor AFTER the window
|
|
54
|
+
// can still get the old copy. A cache expiring is not a publish.
|
|
55
|
+
//
|
|
56
|
+
// Its OWN scope rather than a `sites:write` covering two very different
|
|
57
|
+
// powers. Publishing is not a site edit: it changes no stored field, and it
|
|
58
|
+
// is the one /v1 call whose cost lands on a different service — up to 250
|
|
59
|
+
// dropped pages, each re-rendering at roughly 40 Firestore reads. A key that
|
|
60
|
+
// syncs a catalogue should not thereby be able to spend that, and an
|
|
61
|
+
// integration that only needs to flush its own writes should not have to
|
|
62
|
+
// hold a scope that could rename the site. Bounded by a per-HOST hourly
|
|
63
|
+
// budget on top of the per-key request limit, because one call and 250
|
|
64
|
+
// dropped pages are not the same unit of cost.
|
|
65
|
+
'sites:publish',
|
|
66
|
+
// AGL-2465. Everything downstream of a site was automatable and creating
|
|
67
|
+
// the site was not: `handleSites` 405'd every non-GET, so an agency
|
|
68
|
+
// onboarding a client could push its datasets, contacts and media and still
|
|
69
|
+
// had to open a browser to make the site those things go in.
|
|
70
|
+
//
|
|
71
|
+
// CREATE ONLY — no rename, no delete, and the omissions are deliberate
|
|
72
|
+
// rather than unfinished. `hosts/rename` authorizes off the host projection
|
|
73
|
+
// alone — the member's own entry in `memberRoles`, which must equal
|
|
74
|
+
// `admin` — and an API key is an ORG credential with no uid, so that gate
|
|
75
|
+
// has no translation at all; giving it one is a permission decision, not a
|
|
76
|
+
// port. `hosts/delete` runs
|
|
77
|
+
// `recursiveDelete` over the whole site plus a storage prefix wipe with no
|
|
78
|
+
// hold and no soft-delete, and the type-the-name confirmation that guards it
|
|
79
|
+
// is console UI only — a one-field irreversible delete is the last thing to
|
|
80
|
+
// put behind a bearer token, and whether it should exist at all is a product
|
|
81
|
+
// decision that has not been made.
|
|
82
|
+
//
|
|
83
|
+
// Replay-safe, which is what actually blocked this: site creation is the
|
|
84
|
+
// most expensive object here to duplicate — it takes a `hostLimit` slot,
|
|
85
|
+
// writes `hostIndex` and runs `syncOrgAuthProjections` across every member —
|
|
86
|
+
// and `/v1` publishes idempotency as a contract. A POST that succeeded
|
|
87
|
+
// server-side but lost its response now replays the original site rather
|
|
88
|
+
// than minting a second one.
|
|
89
|
+
'sites:write',
|
|
90
|
+
'forms:read',
|
|
91
|
+
// AGL-2127. A lead sync could READ a submission and never record that it
|
|
92
|
+
// had — so it re-pushed the same lead on every poll, or kept its own
|
|
93
|
+
// high-water mark against a list ordered by document id rather than by
|
|
94
|
+
// time. `read` is the flag the console already toggles on the same
|
|
95
|
+
// document, so the scope grants exactly the state the integration needs
|
|
96
|
+
// (plus the delete that lets it purge after export), and nothing that
|
|
97
|
+
// rewrites what a visitor actually typed.
|
|
98
|
+
'forms:write',
|
|
99
|
+
// Commerce and media reads (AGL-1928). Each is separate from `sites:read`
|
|
100
|
+
// for the same reason `forms:read` is: a key handed to a fulfilment or
|
|
101
|
+
// accounting integration needs the order history and nothing else, and a
|
|
102
|
+
// key that syncs a catalogue into a PIM should not thereby be able to read
|
|
103
|
+
// every form submission a site has ever taken. Read-only on purpose —
|
|
104
|
+
// writes here move money or stock and want their own decision, in the same
|
|
105
|
+
// change that ships them (the AGL-899 rule for `contacts:write`).
|
|
106
|
+
'orders:read',
|
|
107
|
+
// AGL-2461. The read-only note above says commerce writes wait for "their
|
|
108
|
+
// own decision, in the same change that ships them" because they "move
|
|
109
|
+
// money or stock". Recording a shipment does NEITHER — `fulfill-order.ts`'s
|
|
110
|
+
// own header says so: "a forward status flip plus a timeline entry, no
|
|
111
|
+
// stock moved, no money moved" — so it is precisely the write that rule
|
|
112
|
+
// admits, and it is the day-one commerce automation: a 3PL, ShipStation, or
|
|
113
|
+
// a warehouse's own system marking an order shipped with a tracking number.
|
|
114
|
+
// A public API that can take an order and never ship it is a fulfilment
|
|
115
|
+
// dead end.
|
|
116
|
+
//
|
|
117
|
+
// Fulfilment ONLY. `cancelled` and `refunded` are refused by name with a
|
|
118
|
+
// 400: those move stock and money through their own transactions, and this
|
|
119
|
+
// scope must never become the door around them. The transition rule itself
|
|
120
|
+
// is not re-implemented here at all — the write goes through the commerce
|
|
121
|
+
// plugin's own transaction via the order-fulfilment capability registry,
|
|
122
|
+
// so the API can never write a status the console forbids.
|
|
123
|
+
'orders:write',
|
|
124
|
+
'products:read',
|
|
125
|
+
'media:read',
|
|
126
|
+
// AGL-2463. `media:read` was the only media scope, so an agency onboarding a
|
|
127
|
+
// client site could automate everything about that site except putting its
|
|
128
|
+
// images in it — an integration could LIST the library and never add to it,
|
|
129
|
+
// which stops the asset pipeline at the one step that matters.
|
|
130
|
+
//
|
|
131
|
+
// Create only: no replace, no delete, no edit of an existing file's metadata.
|
|
132
|
+
// A key handed to a migration tool should be able to fill a library and not
|
|
133
|
+
// to empty one, and the destructive half wants its own decision in the change
|
|
134
|
+
// that ships it — the same rule that kept this scope out until now.
|
|
135
|
+
//
|
|
136
|
+
// Charged, not free: the create goes through `mediaStorageGate`, the same
|
|
137
|
+
// helper the console's four ingress routes use, and consumes the existing
|
|
138
|
+
// per-GB storage dimension rather than inventing a per-upload one.
|
|
139
|
+
'media:write',
|
|
140
|
+
// AGL-2606. The CRM v2 collections (AGL-2595) — companies, pipelines,
|
|
141
|
+
// deals, tasks and activities — sit beside `contacts` and were reachable
|
|
142
|
+
// only from the console, so the integration that owns the sales process
|
|
143
|
+
// upstream (a CRM sync, a dialer logging calls, a billing system closing
|
|
144
|
+
// deals) could read the people and nothing they were doing with them.
|
|
145
|
+
//
|
|
146
|
+
// ONE pair for the five resources rather than five, because a key that
|
|
147
|
+
// needs any of them needs the set: a deal names a company and a contact, a
|
|
148
|
+
// task names a deal, an activity names all three, and a key that could
|
|
149
|
+
// read deals but not the pipelines whose stages they cite would hand back
|
|
150
|
+
// stage ids it cannot explain. The contact's own CRM profile — phone, job
|
|
151
|
+
// title, company, owner, lifecycle stage — stays under `contacts:*`, since
|
|
152
|
+
// it is a field of the contact and reads with the contact.
|
|
153
|
+
//
|
|
154
|
+
// Reads are org-wide, as every read on this surface is: a key is an
|
|
155
|
+
// organization credential. Writes name the site they belong to and are
|
|
156
|
+
// stamped in that site's scope (`crmCreateStamp`), so an API-created deal
|
|
157
|
+
// lands exactly where a console-created one would, and never wider.
|
|
158
|
+
'crm:read',
|
|
159
|
+
'crm:write'
|
|
160
|
+
];
|
|
161
|
+
/** Live-key token prefix. A `_test_` variant can follow the same shape later. */ export const API_KEY_TOKEN_PREFIX = 'aglyn_sk_';
|
|
162
|
+
/** Top-level collection; doc id = the token hash. */ export const API_KEYS_COLLECTION = 'apiKeys';
|
|
163
|
+
/** How much of the raw token is retained (plaintext) for display/identification. */ const DISPLAY_BODY_CHARS = 6;
|
|
164
|
+
// ── Pure helpers (unit-tested; no I/O) ──────────────────────────────────────
|
|
165
|
+
/** True if `value` is one of the known scopes. */ export function isApiScope(value) {
|
|
166
|
+
return API_SCOPES.includes(value);
|
|
167
|
+
}
|
|
168
|
+
/** Keep only valid, de-duplicated scopes, preserving `API_SCOPES` order. */ export function normalizeScopes(scopes) {
|
|
169
|
+
const set = new Set(scopes.filter(isApiScope));
|
|
170
|
+
return API_SCOPES.filter((scope)=>set.has(scope));
|
|
171
|
+
}
|
|
172
|
+
/** SHA-256 hex of a raw token — the Firestore doc id and lookup key. */ export function hashApiKey(token) {
|
|
173
|
+
return createHash('sha256').update(token, 'utf8').digest('hex');
|
|
174
|
+
}
|
|
175
|
+
/** Structural check before hashing/looking up — cheap reject of junk input. */ export function isValidApiKeyFormat(token) {
|
|
176
|
+
return typeof token === 'string' && token.startsWith(API_KEY_TOKEN_PREFIX) && /^[A-Za-z0-9_-]{24,}$/.test(token.slice(API_KEY_TOKEN_PREFIX.length));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Generate a fresh token plus the derived hash and display prefix. The raw
|
|
180
|
+
* `token` is the only time the secret exists in plaintext — persist the hash,
|
|
181
|
+
* hand the token to the user once.
|
|
182
|
+
*/ export function generateApiKeyToken() {
|
|
183
|
+
const body = randomBytes(24).toString('base64url');
|
|
184
|
+
const token = `${API_KEY_TOKEN_PREFIX}${body}`;
|
|
185
|
+
return {
|
|
186
|
+
token,
|
|
187
|
+
hash: hashApiKey(token),
|
|
188
|
+
keyPrefix: `${API_KEY_TOKEN_PREFIX}${body.slice(0, DISPLAY_BODY_CHARS)}…`
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** True when the granted scopes include the one an operation requires. */ export function hasScope(granted, required) {
|
|
192
|
+
return granted.includes(required);
|
|
193
|
+
}
|
|
194
|
+
/** A random public key id (`key_…`), distinct from the secret and its hash. */ export function generateKeyId() {
|
|
195
|
+
return `key_${randomBytes(9).toString('base64url')}`;
|
|
196
|
+
}
|
|
197
|
+
// ── Storage (Admin SDK) ─────────────────────────────────────────────────────
|
|
198
|
+
function collection() {
|
|
199
|
+
return firebaseAdmin.app().firestore().collection(API_KEYS_COLLECTION);
|
|
200
|
+
}
|
|
201
|
+
const tsToIso = (value)=>value ? value.toDate().toISOString() : null;
|
|
202
|
+
/** Public projection of a stored key — never includes the hash/doc id. */ export function toPublicApiKey(doc) {
|
|
203
|
+
var _doc_scopes;
|
|
204
|
+
return {
|
|
205
|
+
keyId: doc.keyId,
|
|
206
|
+
name: doc.name,
|
|
207
|
+
keyPrefix: doc.keyPrefix,
|
|
208
|
+
scopes: (_doc_scopes = doc.scopes) != null ? _doc_scopes : [],
|
|
209
|
+
createdAt: tsToIso(doc.createdAt),
|
|
210
|
+
lastUsedAt: tsToIso(doc.lastUsedAt),
|
|
211
|
+
revokedAt: tsToIso(doc.revokedAt),
|
|
212
|
+
expiresAt: tsToIso(doc.expiresAt)
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Create a key for an org. Returns the raw `token` (show once) and the public
|
|
217
|
+
* metadata. Invalid scopes are dropped; at least one valid scope is required.
|
|
218
|
+
*/ export async function mintApiKey(input) {
|
|
219
|
+
const scopes = normalizeScopes(input.scopes);
|
|
220
|
+
if (scopes.length === 0) {
|
|
221
|
+
throw new Error('An API key needs at least one valid scope');
|
|
222
|
+
}
|
|
223
|
+
const { Timestamp, FieldValue } = firebaseAdmin.firestore;
|
|
224
|
+
const { token, hash, keyPrefix } = generateApiKeyToken();
|
|
225
|
+
const keyId = generateKeyId();
|
|
226
|
+
const expiresAt = input.expiresAtMs != null ? Timestamp.fromMillis(input.expiresAtMs) : null;
|
|
227
|
+
const doc = {
|
|
228
|
+
keyId,
|
|
229
|
+
orgId: input.orgId,
|
|
230
|
+
name: input.name.slice(0, 120),
|
|
231
|
+
keyPrefix,
|
|
232
|
+
scopes,
|
|
233
|
+
createdBy: input.createdBy,
|
|
234
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
235
|
+
lastUsedAt: null,
|
|
236
|
+
revokedAt: null,
|
|
237
|
+
expiresAt
|
|
238
|
+
};
|
|
239
|
+
// `create` (not set) so a hash collision can never overwrite another key.
|
|
240
|
+
await collection().doc(hash).create(doc);
|
|
241
|
+
return {
|
|
242
|
+
token,
|
|
243
|
+
key: {
|
|
244
|
+
keyId,
|
|
245
|
+
name: doc.name,
|
|
246
|
+
keyPrefix,
|
|
247
|
+
scopes,
|
|
248
|
+
createdAt: new Date().toISOString(),
|
|
249
|
+
lastUsedAt: null,
|
|
250
|
+
revokedAt: null,
|
|
251
|
+
expiresAt: expiresAt ? expiresAt.toDate().toISOString() : null
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a raw token to its org/scopes, or `null` if it is malformed,
|
|
257
|
+
* unknown, revoked, or expired. Refreshes `lastUsedAt` at most once a minute
|
|
258
|
+
* (fire-and-forget) to avoid a write on every request.
|
|
259
|
+
*/ export async function verifyApiKey(token) {
|
|
260
|
+
var _data_scopes, _data_name;
|
|
261
|
+
if (!isValidApiKeyFormat(token)) return null;
|
|
262
|
+
const snap = await collection().doc(hashApiKey(token)).get();
|
|
263
|
+
if (!snap.exists) return null;
|
|
264
|
+
const data = snap.data();
|
|
265
|
+
if (data.revokedAt) return null;
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
if (data.expiresAt && data.expiresAt.toMillis() <= now) return null;
|
|
268
|
+
const lastUsedMs = data.lastUsedAt ? data.lastUsedAt.toMillis() : 0;
|
|
269
|
+
if (now - lastUsedMs > 60000) {
|
|
270
|
+
snap.ref.update({
|
|
271
|
+
lastUsedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
272
|
+
}).catch(()=>undefined);
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
orgId: data.orgId,
|
|
276
|
+
keyId: data.keyId,
|
|
277
|
+
scopes: (_data_scopes = data.scopes) != null ? _data_scopes : [],
|
|
278
|
+
name: String((_data_name = data.name) != null ? _data_name : '')
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** All keys for an org, newest first (metadata only). */ export async function listApiKeys(orgId) {
|
|
282
|
+
const snap = await collection().where('orgId', '==', orgId).get();
|
|
283
|
+
return snap.docs.map((doc)=>toPublicApiKey(doc.data())).sort((a, b)=>{
|
|
284
|
+
var _b_createdAt, _a_createdAt;
|
|
285
|
+
return ((_b_createdAt = b.createdAt) != null ? _b_createdAt : '').localeCompare((_a_createdAt = a.createdAt) != null ? _a_createdAt : '');
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/** Revoke a key by its public id. Returns false if not found/already revoked. */ export async function revokeApiKey(orgId, keyId) {
|
|
289
|
+
const snap = await collection().where('orgId', '==', orgId).get();
|
|
290
|
+
const match = snap.docs.find((doc)=>doc.data().keyId === keyId);
|
|
291
|
+
if (!match || match.data().revokedAt) return false;
|
|
292
|
+
await match.ref.update({
|
|
293
|
+
revokedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
294
|
+
});
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
//# sourceMappingURL=api-keys.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-keys.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 * Customer API keys (AGL-616). Keys authenticate the public REST API\n * (`/api/v1/**`). Only a SHA-256 hash of the key is ever stored — the raw\n * token is shown to the user exactly once, at creation. The Firestore doc id\n * IS the hash, so verification is an O(1) `get()` and the hash never leaves\n * the server (listings return metadata only). The `apiKeys` collection is\n * deny-all in the security rules; all access is Admin-SDK only.\n *\n * The token/hash/format/scope helpers are pure and unit-tested; the storage\n * functions wrap the Admin SDK.\n */\nimport { createHash, randomBytes } from 'node:crypto'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * Scopes a key can be granted, as `resource:action`. Every entry must be\n * enforced by a real endpoint — a mintable scope that grants nothing reads\n * as a broken permission (AGL-899 removed `contacts:write` on those grounds,\n * and AGL-2276 re-added it in the same change that shipped the writes, which\n * is the condition AGL-899 set).\n */\nexport const API_SCOPES = [\n 'datasets:read',\n 'datasets:write',\n 'contacts:read',\n // AGL-2276. Contacts used to arrive only through the four site capture\n // points, so an integration that OWNS the customer list upstream — a CRM,\n // a mailing tool, a migration off another platform — could read the\n // audience and never add to it. The write is metered against the same\n // audience bands capture is (AGL-890): `checkContactQuota` on the create,\n // and the monthly rollup counts `orgs/{orgId}/contacts` whatever wrote\n // them, so an API-created contact bills exactly like a form-captured one.\n // The scope covers the create, the tag/notes edit the console's Contacts\n // page already makes, and the delete — never `email`, which is the dedupe\n // key, and never `sources`/`interactions`, which are provenance.\n 'contacts:write',\n 'sites:read',\n // AGL-2462. An integration could write a dataset record over /v1 and had no\n // way to make a live page show it: `getDatasets` caches for 60s behind\n // `tenant-data:{hostId}` and the catch-all page is `revalidate = 60`, and\n // time-based ISR is stale-while-revalidate — so the visitor AFTER the window\n // can still get the old copy. A cache expiring is not a publish.\n //\n // Its OWN scope rather than a `sites:write` covering two very different\n // powers. Publishing is not a site edit: it changes no stored field, and it\n // is the one /v1 call whose cost lands on a different service — up to 250\n // dropped pages, each re-rendering at roughly 40 Firestore reads. A key that\n // syncs a catalogue should not thereby be able to spend that, and an\n // integration that only needs to flush its own writes should not have to\n // hold a scope that could rename the site. Bounded by a per-HOST hourly\n // budget on top of the per-key request limit, because one call and 250\n // dropped pages are not the same unit of cost.\n 'sites:publish',\n // AGL-2465. Everything downstream of a site was automatable and creating\n // the site was not: `handleSites` 405'd every non-GET, so an agency\n // onboarding a client could push its datasets, contacts and media and still\n // had to open a browser to make the site those things go in.\n //\n // CREATE ONLY — no rename, no delete, and the omissions are deliberate\n // rather than unfinished. `hosts/rename` authorizes off the host projection\n // alone — the member's own entry in `memberRoles`, which must equal\n // `admin` — and an API key is an ORG credential with no uid, so that gate\n // has no translation at all; giving it one is a permission decision, not a\n // port. `hosts/delete` runs\n // `recursiveDelete` over the whole site plus a storage prefix wipe with no\n // hold and no soft-delete, and the type-the-name confirmation that guards it\n // is console UI only — a one-field irreversible delete is the last thing to\n // put behind a bearer token, and whether it should exist at all is a product\n // decision that has not been made.\n //\n // Replay-safe, which is what actually blocked this: site creation is the\n // most expensive object here to duplicate — it takes a `hostLimit` slot,\n // writes `hostIndex` and runs `syncOrgAuthProjections` across every member —\n // and `/v1` publishes idempotency as a contract. A POST that succeeded\n // server-side but lost its response now replays the original site rather\n // than minting a second one.\n 'sites:write',\n 'forms:read',\n // AGL-2127. A lead sync could READ a submission and never record that it\n // had — so it re-pushed the same lead on every poll, or kept its own\n // high-water mark against a list ordered by document id rather than by\n // time. `read` is the flag the console already toggles on the same\n // document, so the scope grants exactly the state the integration needs\n // (plus the delete that lets it purge after export), and nothing that\n // rewrites what a visitor actually typed.\n 'forms:write',\n // Commerce and media reads (AGL-1928). Each is separate from `sites:read`\n // for the same reason `forms:read` is: a key handed to a fulfilment or\n // accounting integration needs the order history and nothing else, and a\n // key that syncs a catalogue into a PIM should not thereby be able to read\n // every form submission a site has ever taken. Read-only on purpose —\n // writes here move money or stock and want their own decision, in the same\n // change that ships them (the AGL-899 rule for `contacts:write`).\n 'orders:read',\n // AGL-2461. The read-only note above says commerce writes wait for \"their\n // own decision, in the same change that ships them\" because they \"move\n // money or stock\". Recording a shipment does NEITHER — `fulfill-order.ts`'s\n // own header says so: \"a forward status flip plus a timeline entry, no\n // stock moved, no money moved\" — so it is precisely the write that rule\n // admits, and it is the day-one commerce automation: a 3PL, ShipStation, or\n // a warehouse's own system marking an order shipped with a tracking number.\n // A public API that can take an order and never ship it is a fulfilment\n // dead end.\n //\n // Fulfilment ONLY. `cancelled` and `refunded` are refused by name with a\n // 400: those move stock and money through their own transactions, and this\n // scope must never become the door around them. The transition rule itself\n // is not re-implemented here at all — the write goes through the commerce\n // plugin's own transaction via the order-fulfilment capability registry,\n // so the API can never write a status the console forbids.\n 'orders:write',\n 'products:read',\n 'media:read',\n // AGL-2463. `media:read` was the only media scope, so an agency onboarding a\n // client site could automate everything about that site except putting its\n // images in it — an integration could LIST the library and never add to it,\n // which stops the asset pipeline at the one step that matters.\n //\n // Create only: no replace, no delete, no edit of an existing file's metadata.\n // A key handed to a migration tool should be able to fill a library and not\n // to empty one, and the destructive half wants its own decision in the change\n // that ships it — the same rule that kept this scope out until now.\n //\n // Charged, not free: the create goes through `mediaStorageGate`, the same\n // helper the console's four ingress routes use, and consumes the existing\n // per-GB storage dimension rather than inventing a per-upload one.\n 'media:write',\n // AGL-2606. The CRM v2 collections (AGL-2595) — companies, pipelines,\n // deals, tasks and activities — sit beside `contacts` and were reachable\n // only from the console, so the integration that owns the sales process\n // upstream (a CRM sync, a dialer logging calls, a billing system closing\n // deals) could read the people and nothing they were doing with them.\n //\n // ONE pair for the five resources rather than five, because a key that\n // needs any of them needs the set: a deal names a company and a contact, a\n // task names a deal, an activity names all three, and a key that could\n // read deals but not the pipelines whose stages they cite would hand back\n // stage ids it cannot explain. The contact's own CRM profile — phone, job\n // title, company, owner, lifecycle stage — stays under `contacts:*`, since\n // it is a field of the contact and reads with the contact.\n //\n // Reads are org-wide, as every read on this surface is: a key is an\n // organization credential. Writes name the site they belong to and are\n // stamped in that site's scope (`crmCreateStamp`), so an API-created deal\n // lands exactly where a console-created one would, and never wider.\n 'crm:read',\n 'crm:write',\n] as const\n\nexport type ApiScope = (typeof API_SCOPES)[number]\n\n/** Live-key token prefix. A `_test_` variant can follow the same shape later. */\nexport const API_KEY_TOKEN_PREFIX = 'aglyn_sk_'\n\n/** Top-level collection; doc id = the token hash. */\nexport const API_KEYS_COLLECTION = 'apiKeys'\n\n/** How much of the raw token is retained (plaintext) for display/identification. */\nconst DISPLAY_BODY_CHARS = 6\n\n/** Stored document. `createdAt`/`lastUsedAt`/etc. are Firestore Timestamps. */\nexport interface ApiKeyDocument {\n /** Public, stable id shown in the UI and used for revoke (never the hash). */\n keyId: string\n orgId: string\n name: string\n /** e.g. `aglyn_sk_ab12cd…` — enough to recognize a key, not to use it. */\n keyPrefix: string\n scopes: ApiScope[]\n createdBy: string\n createdAt: FirebaseFirestore.Timestamp\n lastUsedAt: FirebaseFirestore.Timestamp | null\n revokedAt: FirebaseFirestore.Timestamp | null\n expiresAt: FirebaseFirestore.Timestamp | null\n}\n\n/** Metadata safe to return to a client (no hash, no raw token). */\nexport interface PublicApiKey {\n keyId: string\n name: string\n keyPrefix: string\n scopes: ApiScope[]\n createdAt: string | null\n lastUsedAt: string | null\n revokedAt: string | null\n expiresAt: string | null\n}\n\n/** The resolved principal after a successful key verification. */\nexport interface VerifiedApiKey {\n orgId: string\n keyId: string\n scopes: ApiScope[]\n /**\n * The name the organization gave the key — what a feed attributes the\n * key's writes to (AGL-2632). Every minted key carries one; empty only\n * for a document written before the field was required.\n */\n name: string\n}\n\n// ── Pure helpers (unit-tested; no I/O) ──────────────────────────────────────\n\n/** True if `value` is one of the known scopes. */\nexport function isApiScope(value: string): value is ApiScope {\n return (API_SCOPES as readonly string[]).includes(value)\n}\n\n/** Keep only valid, de-duplicated scopes, preserving `API_SCOPES` order. */\nexport function normalizeScopes(scopes: readonly string[]): ApiScope[] {\n const set = new Set(scopes.filter(isApiScope) as ApiScope[])\n return API_SCOPES.filter((scope) => set.has(scope))\n}\n\n/** SHA-256 hex of a raw token — the Firestore doc id and lookup key. */\nexport function hashApiKey(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/** Structural check before hashing/looking up — cheap reject of junk input. */\nexport function isValidApiKeyFormat(token: string): boolean {\n return (\n typeof token === 'string' &&\n token.startsWith(API_KEY_TOKEN_PREFIX) &&\n /^[A-Za-z0-9_-]{24,}$/.test(token.slice(API_KEY_TOKEN_PREFIX.length))\n )\n}\n\n/**\n * Generate a fresh token plus the derived hash and display prefix. The raw\n * `token` is the only time the secret exists in plaintext — persist the hash,\n * hand the token to the user once.\n */\nexport function generateApiKeyToken(): {\n token: string\n hash: string\n keyPrefix: string\n} {\n const body = randomBytes(24).toString('base64url')\n const token = `${API_KEY_TOKEN_PREFIX}${body}`\n return {\n token,\n hash: hashApiKey(token),\n keyPrefix: `${API_KEY_TOKEN_PREFIX}${body.slice(0, DISPLAY_BODY_CHARS)}…`,\n }\n}\n\n/** True when the granted scopes include the one an operation requires. */\nexport function hasScope(\n granted: readonly ApiScope[],\n required: ApiScope,\n): boolean {\n return granted.includes(required)\n}\n\n/** A random public key id (`key_…`), distinct from the secret and its hash. */\nexport function generateKeyId(): string {\n return `key_${randomBytes(9).toString('base64url')}`\n}\n\n// ── Storage (Admin SDK) ─────────────────────────────────────────────────────\n\nfunction collection() {\n return firebaseAdmin.app().firestore().collection(API_KEYS_COLLECTION)\n}\n\nconst tsToIso = (\n value: FirebaseFirestore.Timestamp | null | undefined,\n): string | null => (value ? value.toDate().toISOString() : null)\n\n/** Public projection of a stored key — never includes the hash/doc id. */\nexport function toPublicApiKey(doc: ApiKeyDocument): PublicApiKey {\n return {\n keyId: doc.keyId,\n name: doc.name,\n keyPrefix: doc.keyPrefix,\n scopes: doc.scopes ?? [],\n createdAt: tsToIso(doc.createdAt),\n lastUsedAt: tsToIso(doc.lastUsedAt),\n revokedAt: tsToIso(doc.revokedAt),\n expiresAt: tsToIso(doc.expiresAt),\n }\n}\n\nexport interface MintApiKeyInput {\n orgId: string\n name: string\n scopes: readonly string[]\n createdBy: string\n /** Optional absolute expiry, epoch ms. */\n expiresAtMs?: number | null\n}\n\n/**\n * Create a key for an org. Returns the raw `token` (show once) and the public\n * metadata. Invalid scopes are dropped; at least one valid scope is required.\n */\nexport async function mintApiKey(\n input: MintApiKeyInput,\n): Promise<{ token: string; key: PublicApiKey }> {\n const scopes = normalizeScopes(input.scopes)\n if (scopes.length === 0) {\n throw new Error('An API key needs at least one valid scope')\n }\n const { Timestamp, FieldValue } = firebaseAdmin.firestore\n const { token, hash, keyPrefix } = generateApiKeyToken()\n const keyId = generateKeyId()\n const expiresAt =\n input.expiresAtMs != null ? Timestamp.fromMillis(input.expiresAtMs) : null\n\n const doc = {\n keyId,\n orgId: input.orgId,\n name: input.name.slice(0, 120),\n keyPrefix,\n scopes,\n createdBy: input.createdBy,\n createdAt: FieldValue.serverTimestamp(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt,\n }\n // `create` (not set) so a hash collision can never overwrite another key.\n await collection().doc(hash).create(doc)\n\n return {\n token,\n key: {\n keyId,\n name: doc.name,\n keyPrefix,\n scopes,\n createdAt: new Date().toISOString(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt: expiresAt ? expiresAt.toDate().toISOString() : null,\n },\n }\n}\n\n/**\n * Resolve a raw token to its org/scopes, or `null` if it is malformed,\n * unknown, revoked, or expired. Refreshes `lastUsedAt` at most once a minute\n * (fire-and-forget) to avoid a write on every request.\n */\nexport async function verifyApiKey(\n token: string,\n): Promise<VerifiedApiKey | null> {\n if (!isValidApiKeyFormat(token)) return null\n const snap = await collection().doc(hashApiKey(token)).get()\n if (!snap.exists) return null\n const data = snap.data() as ApiKeyDocument\n\n if (data.revokedAt) return null\n const now = Date.now()\n if (data.expiresAt && data.expiresAt.toMillis() <= now) return null\n\n const lastUsedMs = data.lastUsedAt ? data.lastUsedAt.toMillis() : 0\n if (now - lastUsedMs > 60_000) {\n snap.ref\n .update({ lastUsedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp() })\n .catch(() => undefined)\n }\n\n return {\n orgId: data.orgId,\n keyId: data.keyId,\n scopes: data.scopes ?? [],\n name: String(data.name ?? ''),\n }\n}\n\n/** All keys for an org, newest first (metadata only). */\nexport async function listApiKeys(orgId: string): Promise<PublicApiKey[]> {\n const snap = await collection().where('orgId', '==', orgId).get()\n return snap.docs\n .map((doc) => toPublicApiKey(doc.data() as ApiKeyDocument))\n .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))\n}\n\n/** Revoke a key by its public id. Returns false if not found/already revoked. */\nexport async function revokeApiKey(\n orgId: string,\n keyId: string,\n): Promise<boolean> {\n const snap = await collection().where('orgId', '==', orgId).get()\n const match = snap.docs.find(\n (doc) => (doc.data() as ApiKeyDocument).keyId === keyId,\n )\n if (!match || (match.data() as ApiKeyDocument).revokedAt) return false\n await match.ref.update({\n revokedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return true\n}\n"],"names":["createHash","randomBytes","firebaseAdmin","API_SCOPES","API_KEY_TOKEN_PREFIX","API_KEYS_COLLECTION","DISPLAY_BODY_CHARS","isApiScope","value","includes","normalizeScopes","scopes","set","Set","filter","scope","has","hashApiKey","token","update","digest","isValidApiKeyFormat","startsWith","test","slice","length","generateApiKeyToken","body","toString","hash","keyPrefix","hasScope","granted","required","generateKeyId","collection","app","firestore","tsToIso","toDate","toISOString","toPublicApiKey","doc","keyId","name","createdAt","lastUsedAt","revokedAt","expiresAt","mintApiKey","input","Error","Timestamp","FieldValue","expiresAtMs","fromMillis","orgId","createdBy","serverTimestamp","create","key","Date","verifyApiKey","data","snap","get","exists","now","toMillis","lastUsedMs","ref","catch","undefined","String","listApiKeys","where","docs","map","sort","a","b","localeCompare","revokeApiKey","match","find"],"mappings":"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"}
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
/** Firestore collection holding pending/authorized handoffs. */
|
|
18
|
+
export declare const AUTH_HANDOFFS_COLLECTION = "authHandoffs";
|
|
19
|
+
/** Name of the host-only verifier cookie the custom domain sets. */
|
|
20
|
+
export declare const HANDOFF_VERIFIER_COOKIE = "__aglyn_handoff";
|
|
21
|
+
/** initiate → authorize. Generous; a pending record grants nothing. */
|
|
22
|
+
export declare const HANDOFF_PENDING_TTL_MS: number;
|
|
23
|
+
/** authorize → redeem. One navigation and one same-origin POST. */
|
|
24
|
+
export declare const HANDOFF_AUTHORIZED_TTL_MS: number;
|
|
25
|
+
export type HandoffStatus = 'pending' | 'authorized' | 'redeemed';
|
|
26
|
+
/** SHA-256 hex, the same shape `hashApiKey` stores. */
|
|
27
|
+
export declare function hashHandoffSecret(value: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* A `continuePath` that cannot leave the custom domain.
|
|
30
|
+
*
|
|
31
|
+
* The handoff lands the user somewhere immediately after signing them in,
|
|
32
|
+
* which is the worst possible moment to hand control to an attacker-chosen
|
|
33
|
+
* origin — so this defers to {@link safeSameOriginPath}, which resolves the
|
|
34
|
+
* value and compares origins instead of testing its shape (AGL-1881).
|
|
35
|
+
*
|
|
36
|
+
* This function used to reject `//evil.example` and `/\evil.example` by
|
|
37
|
+
* inspecting the characters. That list read as complete and was not: the URL
|
|
38
|
+
* parser deletes tab, LF and CR before parsing, so `/<TAB>/evil.example`
|
|
39
|
+
* contains neither `//` nor `\` and still resolves to `https://evil.example/`.
|
|
40
|
+
* Enumerating tricks is the bug; asking the parser is the fix.
|
|
41
|
+
*/
|
|
42
|
+
export declare function safeContinuePath(input: string | null | undefined): string;
|
|
43
|
+
export interface StartedHandoff {
|
|
44
|
+
requestId: string;
|
|
45
|
+
/** `V` — goes in the custom domain's own host-only cookie. */
|
|
46
|
+
verifier: string;
|
|
47
|
+
expiresAtMs: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Create a `pending` handoff for `targetHost`, returning the id and verifier.
|
|
51
|
+
*
|
|
52
|
+
* Called on the custom domain, unauthenticated: nothing about a pending record
|
|
53
|
+
* is a capability. It names the host the request actually arrived on — never a
|
|
54
|
+
* host from a parameter — which is what makes the `targetHost` check at
|
|
55
|
+
* redemption mean anything.
|
|
56
|
+
*/
|
|
57
|
+
export declare function startConsoleHandoff(options: {
|
|
58
|
+
targetHost: string;
|
|
59
|
+
orgSlug: string | null;
|
|
60
|
+
continuePath?: string | null;
|
|
61
|
+
nowMs?: number;
|
|
62
|
+
}): Promise<StartedHandoff | null>;
|
|
63
|
+
export type AuthorizeRefusal = 'unknown-request' | 'not-pending' | 'expired' | 'host-mismatch' | 'domain-inactive' | 'not-a-member' | 'impersonation';
|
|
64
|
+
export type AuthorizeResult = {
|
|
65
|
+
ok: true;
|
|
66
|
+
/** `S` — goes in the return URL's FRAGMENT, never its query. */
|
|
67
|
+
secret: string;
|
|
68
|
+
targetHost: string;
|
|
69
|
+
continuePath: string;
|
|
70
|
+
} | {
|
|
71
|
+
ok: false;
|
|
72
|
+
reason: AuthorizeRefusal;
|
|
73
|
+
orgSlug: string | null;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Authorize a pending handoff, on the AUTH HOST, for a signed-in user.
|
|
77
|
+
*
|
|
78
|
+
* `isMember` is injected rather than resolved here so this module stays free
|
|
79
|
+
* of the membership model, and so the test can exercise the refusal without a
|
|
80
|
+
* membership fixture. It receives the target host as well as the slug because
|
|
81
|
+
* the ORG ID lives on the claim and `resolveConsoleDomain` deliberately never
|
|
82
|
+
* returns it — a verdict is read by an unauthenticated route. It is called
|
|
83
|
+
* only after the domain has been shown to be live, so a refusal never reveals
|
|
84
|
+
* whether an org exists.
|
|
85
|
+
*
|
|
86
|
+
* The write flips `pending → authorized` inside {@link consumeOnce}, so a
|
|
87
|
+
* second authorize of the same id finds `not-pending`. That matters: without
|
|
88
|
+
* it, an attacker who obtained a request id could keep re-authorizing it and
|
|
89
|
+
* mint a fresh secret each time the victim signed in.
|
|
90
|
+
*/
|
|
91
|
+
export declare function authorizeConsoleHandoff(options: {
|
|
92
|
+
requestId: string;
|
|
93
|
+
uid: string;
|
|
94
|
+
tenantId?: string | null;
|
|
95
|
+
impersonated?: boolean;
|
|
96
|
+
isMember: (context: {
|
|
97
|
+
targetHost: string;
|
|
98
|
+
orgSlug: string | null;
|
|
99
|
+
}) => Promise<boolean>;
|
|
100
|
+
nowMs?: number;
|
|
101
|
+
}): Promise<AuthorizeResult>;
|
|
102
|
+
export type RedeemRefusal = 'unknown-request' | 'already-redeemed' | 'not-authorized' | 'expired' | 'host-mismatch' | 'bad-secret' | 'bad-verifier' | 'domain-inactive' | 'revoked';
|
|
103
|
+
export type RedeemResult = {
|
|
104
|
+
ok: true;
|
|
105
|
+
uid: string;
|
|
106
|
+
tenantId: string | null;
|
|
107
|
+
continuePath: string;
|
|
108
|
+
} | {
|
|
109
|
+
ok: false;
|
|
110
|
+
reason: RedeemRefusal;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Consume an authorized handoff, on the CUSTOM DOMAIN.
|
|
114
|
+
*
|
|
115
|
+
* Both secrets are required and both are checked INSIDE the transaction, so
|
|
116
|
+
* the checks cannot be raced against the consume.
|
|
117
|
+
*
|
|
118
|
+
* `verifiers` is every value of the `__aglyn_handoff` cookie on the request,
|
|
119
|
+
* not one. A compromised sibling host under the customer's own apex can set a
|
|
120
|
+
* `Domain=.acme-agency.com` cookie of the same name, which SHADOWS the
|
|
121
|
+
* host-only one in the `Cookie` header with no way to tell them apart — the
|
|
122
|
+
* precise failure AGL-1259 hit with duplicate `__session` cookies. Hashing
|
|
123
|
+
* every value and accepting if any matches is safe by construction (only the
|
|
124
|
+
* real `V` hashes to `verifierHash`) and turns a hijack attempt into a no-op
|
|
125
|
+
* rather than a denial of service.
|
|
126
|
+
*/
|
|
127
|
+
export declare function redeemConsoleHandoff(options: {
|
|
128
|
+
requestId: string;
|
|
129
|
+
secret: string;
|
|
130
|
+
verifiers: string[];
|
|
131
|
+
requestHost: string;
|
|
132
|
+
nowMs?: number;
|
|
133
|
+
}): Promise<RedeemResult>;
|
|
134
|
+
/**
|
|
135
|
+
* `consoleDomains/{host}.sessionEpoch`, or `0` when there is no usable one.
|
|
136
|
+
*
|
|
137
|
+
* `0` is returned for every unreadable shape — an unnormalizable host, an
|
|
138
|
+
* absent document, a Firestore error, a non-finite or non-positive value — and
|
|
139
|
+
* every caller reads `0` as "this control has nothing to say", i.e. FAILS OPEN.
|
|
140
|
+
* That is deliberate and matches every other console-domain lookup: the Vercel
|
|
141
|
+
* allowlist is the boundary and this is defence in depth, so a Firestore blip
|
|
142
|
+
* must not lock every custom domain out of its own session.
|
|
143
|
+
*
|
|
144
|
+
* Note the split with {@link consoleSessionEpochRefuses}: failing open on a
|
|
145
|
+
* missing EPOCH is not the same as failing open on a missing CREDENTIAL DATE.
|
|
146
|
+
* A cookie whose `iat` cannot be read is refused, because an undateable
|
|
147
|
+
* credential cannot be shown to postdate a revocation.
|
|
148
|
+
*/
|
|
149
|
+
export declare function readConsoleSessionEpoch(host: string): Promise<number>;
|
|
150
|
+
/**
|
|
151
|
+
* The revocation epoch on a console domain (AGL-1353 D6).
|
|
152
|
+
*
|
|
153
|
+
* A session cookie minted before the epoch is dead. Bumping `sessionEpoch` —
|
|
154
|
+
* which `suspendOnDowngrade` and `releaseConsoleDomain` already do — therefore
|
|
155
|
+
* invalidates every outstanding cookie for that host instantly, at our
|
|
156
|
+
* boundary. Our boundary is the ONLY place a Firebase session cookie has any
|
|
157
|
+
* value: it is opaque and can only be cashed by `verifySessionCookie` under
|
|
158
|
+
* our Admin credentials.
|
|
159
|
+
*
|
|
160
|
+
* Fails OPEN on a read error, matching every other console-domain lookup.
|
|
161
|
+
*/
|
|
162
|
+
export declare function consoleSessionEpochRefuses(options: {
|
|
163
|
+
host: string;
|
|
164
|
+
cookieIssuedAtMs: number | null;
|
|
165
|
+
}): Promise<boolean>;
|