@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.220
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/package.json +8 -8
- package/src/lib/server/api-http.js +5 -1
- package/src/lib/server/api-http.js.map +1 -1
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +163 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +143 -0
- package/src/lib/server/api-v1-resources.js +125 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
- package/src/lib/server/campaign-conversion-attribution.js +3 -3
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/card-payment-velocity.d.ts +9 -0
- package/src/lib/server/card-payment-velocity.js +28 -2
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +1 -1
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +43 -18
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +6 -3
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +2 -2
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +37 -2
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +32 -4
- package/src/lib/server/host-visitor-records.js +73 -14
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +27 -0
- package/src/lib/server/hosted-page-review.js +149 -12
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
- package/src/lib/server/media-cdn-rate-limit.js +6 -3
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +10 -2
- package/src/lib/server/member-email-aliases.js +26 -2
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +22 -2
- package/src/lib/server/notifications.js +40 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -1
- package/src/lib/server/operator-alerts.js +5 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/org-containers.d.ts +66 -0
- package/src/lib/server/org-containers.js +95 -0
- package/src/lib/server/org-containers.js.map +1 -0
- package/src/lib/server/org-member-notice.js +3 -2
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +1 -1
- package/src/lib/server/payment-risk-record.js +3 -1
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +1 -1
- package/src/lib/server/platform-marketing-consent.js +1 -1
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +32 -1
- package/src/lib/server/risk-notice.js +90 -28
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +17 -0
- package/src/lib/server/sending-domains.js +28 -4
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +7 -2
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/staff-alert-email.js +4 -2
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
- package/src/lib/server/topic-subscriptions.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +87 -20
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +5 -8
- package/src/tenancy.js +12 -13
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-email-engagement.d.ts +0 -32
- package/src/lib/server/contact-email-engagement.js +0 -145
- package/src/lib/server/contact-email-engagement.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/crm-booking-activity.d.ts +0 -86
- package/src/lib/server/crm-booking-activity.js +0 -257
- package/src/lib/server/crm-booking-activity.js.map +0 -1
- package/src/lib/server/crm-inbound-email.d.ts +0 -145
- package/src/lib/server/crm-inbound-email.js +0 -297
- package/src/lib/server/crm-inbound-email.js.map +0 -1
- package/src/lib/server/email-topic-confirmation.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
- /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -64,8 +64,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
64
64
|
* refused. `requestSendingDomain` still stops at `requested`, and a domain
|
|
65
65
|
* stopped there refuses sends, which is the correct behavior for a domain that
|
|
66
66
|
* has no signing key.
|
|
67
|
-
*/ import { assessDmarc, assessSendingRecords, isPlatformSendingDomain, isSharedSendingDomain, normalizeLocalPart, normalizeSendingDomain, resolveSendingIdentity, safeProviderDetail, SENDING_SUBDOMAIN, sendingDnsRecords, sendingTrackingHost, sendingDomainRequiredRecords, sendingRecordKey, sharedTenantSendingFrom, validateSendingDomain } from "@aglyn/shared-util-email";
|
|
68
|
-
import { lockdownMode } from "@aglyn/aglyn/server";
|
|
67
|
+
*/ import { assessDmarc, assessSendingRecords, headerSafeText, isPlatformSendingDomain, isSharedSendingDomain, normalizeLocalPart, normalizeSendingDomain, resolveSendingIdentity, safeProviderDetail, SENDING_FROM_NAME_MAX, SENDING_SUBDOMAIN, sendingDnsRecords, sendingTrackingHost, sendingDomainRequiredRecords, sendingRecordKey, sharedTenantSendingFrom, validateSendingDomain } from "@aglyn/shared-util-email";
|
|
68
|
+
import { lockdownMode, resolveBrandingProfile, resolveHostToken } from "@aglyn/aglyn/server";
|
|
69
69
|
import firebaseAdmin from "./firebase-admin.js";
|
|
70
70
|
import { lookupCaa, lookupMx, lookupTxt } from "./dns-probe.js";
|
|
71
71
|
import { getOrgForHost } from "./organizations.js";
|
|
@@ -670,7 +670,7 @@ export async function listSendingDomains(orgId) {
|
|
|
670
670
|
* a site sending on a domain that no longer authenticates. A caller that
|
|
671
671
|
* declares a cache has also declared how long it may be trusted.
|
|
672
672
|
*/ export async function hostSendingIdentity(hostId, cache) {
|
|
673
|
-
var _ref, _snapshot_data, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _snapshot_data1;
|
|
673
|
+
var _ref, _snapshot_data, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _snapshot_data1, _ref7, _snapshot_data2;
|
|
674
674
|
const id = String(hostId != null ? hostId : '').trim();
|
|
675
675
|
if (!id) {
|
|
676
676
|
/*
|
|
@@ -741,10 +741,34 @@ export async function listSendingDomains(orgId) {
|
|
|
741
741
|
org: (_ref6 = owner == null ? void 0 : owner.org) != null ? _ref6 : null,
|
|
742
742
|
host: (snapshot == null ? void 0 : snapshot.exists) ? (_snapshot_data1 = snapshot.data()) != null ? _snapshot_data1 : null : null
|
|
743
743
|
})
|
|
744
|
-
}
|
|
744
|
+
}, siteSenderNames({
|
|
745
|
+
org: (_ref7 = owner == null ? void 0 : owner.org) != null ? _ref7 : null,
|
|
746
|
+
host: (snapshot == null ? void 0 : snapshot.exists) ? (_snapshot_data2 = snapshot.data()) != null ? _snapshot_data2 : null : null
|
|
747
|
+
}));
|
|
745
748
|
cache == null ? void 0 : cache.set(id, verdict);
|
|
746
749
|
return verdict;
|
|
747
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* The display name one site's mail leaves under, and the org default it
|
|
753
|
+
* replaces (AGL-3442).
|
|
754
|
+
*
|
|
755
|
+
* The site's business name — what its emails render as
|
|
756
|
+
* `{{host.businessName}}`, which is its SEO entity name or else its display
|
|
757
|
+
* name — and the org's branding default when the site has neither, which is
|
|
758
|
+
* "Aglyn" for an org without white-label. The name sent is flattened as every
|
|
759
|
+
* sender name is, because it reaches a `From:` header and the site's owner
|
|
760
|
+
* typed it. The default is kept exactly as `resolveBrandingProfile` returns
|
|
761
|
+
* it, because `sendEmail` recognizes it by comparing it with what a sender
|
|
762
|
+
* passed, and senders pass that value unchanged.
|
|
763
|
+
*/ export function siteSenderNames(input) {
|
|
764
|
+
var _resolveBrandingProfile_fromName;
|
|
765
|
+
const brandFromName = String((_resolveBrandingProfile_fromName = resolveBrandingProfile(input.org).fromName) != null ? _resolveBrandingProfile_fromName : '').trim();
|
|
766
|
+
const siteName = headerSafeText(resolveHostToken('businessName', input.host), SENDING_FROM_NAME_MAX);
|
|
767
|
+
return {
|
|
768
|
+
fromName: siteName || headerSafeText(brandFromName, SENDING_FROM_NAME_MAX),
|
|
769
|
+
brandFromName
|
|
770
|
+
};
|
|
771
|
+
}
|
|
748
772
|
/**
|
|
749
773
|
* The verdict a suspended site's mail gets: a refusal, whatever identity the
|
|
750
774
|
* site would otherwise have sent on. `sendEmail` reads it and answers
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sending-domains.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 * CUSTOM SENDING DOMAINS — durable half.\n *\n * The record, the DNS lookups and the status transitions. The decisions all\n * live in `@aglyn/shared-util-email`'s `sending-domain.ts`, which is pure;\n * this module is the I/O around them, and it holds no policy of its own.\n *\n * ## Per-org record, per-host selection\n *\n * The record is at `orgs/{orgId}/sendingDomains/{domain}`, alongside\n * `orgs/{orgId}/ssoDomains/{domain}` and for the same reason: proving control\n * of a zone is a property of the ORG that proved it, and an agency running\n * four sites on `client.com` should publish the DKIM record once rather than\n * four times.\n *\n * Which identity a given site SENDS on is a separate per-host field,\n * `hosts/{hostId}.sendingDomain`. That split is what answers the two halves of\n * the question at once: per-org verification keeps the DNS chore down, and a\n * per-host selection is what an agency's client actually wants, because the\n * `From:` their recipients see belongs to the site, not to the agency.\n *\n * ## Reads are cheap on purpose\n *\n * `resolveHostSendingIdentity` is on the path of a campaign send, so it reads\n * the host document the caller already has and at most ONE org subcollection\n * document. It never scans, never lists, and never touches DNS — the standing\n * rule against unrequested reads on a hot path applies here more than most,\n * because a campaign resolves an identity once for thousands of messages.\n *\n * ## Why the provider call is not in here\n *\n * Issuing a DKIM key means creating a domain at the mail provider, which needs\n * a credential that can create things — a different one from the send-only\n * `RESEND_API_KEY`, which is exactly why `email-health.ts` can use the domains\n * endpoint as a read-only credential probe.\n *\n * That credential belongs to the console alone, so the driver that reads it\n * lives in the console app — `apps/console/utils/server/` — and not here.\n * **This library is imported by the tenant runtime**, which\n * serves untrusted published sites; a module it can import is a module whose\n * environment read is one bug away from being reachable from a site request.\n * A file in `apps/console` is not importable from the tenant app at all —\n * there is no path mapping to it and nx's module boundaries forbid app→app —\n * so the isolation is structural rather than a convention.\n *\n * What stays here is the seam: {@link recordIssuedSendingDomain} takes what a\n * provider returned, and {@link recordSendingDomainIssueFailure} takes what it\n * refused. `requestSendingDomain` still stops at `requested`, and a domain\n * stopped there refuses sends, which is the correct behavior for a domain that\n * has no signing key.\n */\n\nimport {\n assessDmarc,\n assessSendingRecords,\n isPlatformSendingDomain,\n isSharedSendingDomain,\n normalizeLocalPart,\n normalizeSendingDomain,\n resolveSendingIdentity,\n safeProviderDetail,\n SENDING_SUBDOMAIN,\n sendingDnsRecords,\n sendingTrackingHost,\n sendingDomainRequiredRecords,\n sendingRecordKey,\n sharedTenantSendingFrom,\n validateSendingDomain,\n type DmarcAssessment,\n type SendingDnsRecord,\n type SendingDomainRecord,\n type SendingDomainSelection,\n type SendingIdentityPurpose,\n type SendingIdentityVerdict,\n type SendingVerification,\n} from '@aglyn/shared-util-email'\nimport { lockdownMode } from '@aglyn/aglyn/server'\nimport firebaseAdmin from './firebase-admin'\nimport { lookupCaa, lookupMx, lookupTxt } from './dns-probe'\nimport { getOrgForHost } from './organizations'\nimport { getSiteLockdown } from './tenant-write-lockdown'\n// Also INSTALLS the outbound phishing screen's review store on `sendEmail`\n// (AGL-3356): every sender whose identity comes from `hostSendingIdentity`\n// has therefore installed the gate the workspace stamped below is read by.\nimport { sendingWorkspaceFor } from './outbound-send-review'\nimport { flagLookalikeDomain } from './hosted-page-review'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Subcollection under the owning org. */\nexport const SENDING_DOMAINS_COLLECTION = 'sendingDomains'\n\nconst domainRef = (orgId: string, domain: string) =>\n firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .doc(domain)\n\n/**\n * The DKIM selector issued to one org for one domain.\n *\n * Includes the org id so two orgs verifying the same name occupy different\n * record names. Without that they would share `resend._domainkey.<domain>`,\n * and whichever verified second would overwrite the first — or, worse, inherit\n * the first's verification without ever publishing anything.\n */\nexport function sendingDkimSelector(orgId: string): string {\n const safe = String(orgId ?? '')\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '')\n .slice(0, 24)\n return `aglyn${safe ? `-${safe}` : ''}`\n}\n\n/** Both keys always present, one always null — `strictNullChecks` is off. */\nexport interface SendingDomainResult {\n record: SendingDomainRecord | null\n error: string | null\n /** The HTTP status a route should answer with. */\n status: number\n}\n\n/**\n * One stored document as a record.\n *\n * Exported so the re-check sweep, which reaches these documents through a\n * collection-group query rather than by org and name, shapes them the same way\n * every other reader here does — a second reader with its own defaults is how\n * two callers come to disagree about what an absent field means.\n */\nexport function readSendingDomainRecord(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): SendingDomainRecord | null {\n if (!snapshot?.exists) return null\n const data = snapshot.data() ?? {}\n return {\n domain: String(data.domain ?? snapshot.id),\n status: data.status ?? 'requested',\n dkimSelector: String(data.dkimSelector ?? ''),\n dkimPublicKey: data.dkimPublicKey ?? null,\n returnPathHost: data.returnPathHost ?? null,\n providerDomainId: data.providerDomainId ?? null,\n trackingTarget: data.trackingTarget ?? null,\n createdAtMs: Number(data.createdAtMs) || null,\n verifiedAtMs: Number(data.verifiedAtMs) || null,\n lastCheckedAtMs: Number(data.lastCheckedAtMs) || null,\n lastIssueError: data.lastIssueError ?? null,\n lastIssueAtMs: Number(data.lastIssueAtMs) || null,\n lastMissing: Array.isArray(data.lastMissing) ? data.lastMissing : null,\n }\n}\n\n/**\n * Start (or re-read) a claim on a sending domain.\n *\n * Idempotent, and deliberately non-destructive on a re-request: an existing\n * record keeps its selector and its key, because reissuing either would\n * invalidate a record the customer may already have published and turn a\n * working setup into a mysterious failure. This mirrors `issueDomainClaim`.\n */\nexport async function requestSendingDomain(options: {\n orgId: string\n domain: string\n}): Promise<SendingDomainResult> {\n const { domain, error } = validateSendingDomain(options?.domain)\n if (!domain) return { record: null, error, status: 400 }\n if (!options?.orgId) {\n return { record: null, error: 'Missing organization', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const existing = await ref.get()\n if (existing.exists) {\n return { record: readSendingDomainRecord(existing), error: null, status: 200 }\n }\n\n const record: SendingDomainRecord = {\n domain,\n status: 'requested',\n dkimSelector: sendingDkimSelector(options.orgId),\n dkimPublicKey: null,\n returnPathHost: null,\n createdAtMs: Date.now(),\n }\n await ref.set(record, { merge: true })\n // A domain that wears a brand (`paypa1.com`) goes to staff in the abuse\n // queue the moment it is claimed — flagged, not refused (AGL-3362). Mail\n // from it is held at the send seam whatever staff decide. Never throws.\n await flagLookalikeDomain({ kind: 'sending', hostId: null, orgId: options.orgId, domain })\n return { record, error: null, status: 201 }\n}\n\n/**\n * Record the key the mail provider issued, moving the domain to\n * `records-issued` so the customer has something to publish.\n *\n * Separated from {@link requestSendingDomain} because it needs a credential\n * that may not exist. Nothing here calls the provider: the caller supplies\n * what it was given, so a deployment whose key cannot create domains still has\n * a working path — an operator can complete this step by hand — and this\n * module never grows a dependency on a credential it cannot assume. The\n * console holds the credential and the driver that uses it; this library,\n * which the tenant runtime also imports, holds neither.\n *\n * ## `records-issued` is a promise that records exist\n *\n * The write is refused unless the resulting record actually yields a DKIM\n * record with a value. The alternative is the failure this whole feature is\n * arranged against: a status saying the customer has records to publish, next\n * to a records table with an empty DKIM row, which cannot ever verify and\n * reads to the customer as our bug.\n *\n * ## An issued key is never overwritten\n *\n * A second call carrying a DIFFERENT key is refused rather than applied. The\n * customer may already have published the first one, and replacing it turns a\n * finished setup into a domain that silently stops signing. Re-recording the\n * SAME key is a no-op and succeeds, so a retried request is safe.\n *\n * The SELECTOR, by contrast, comes from the provider when it supplies one:\n * the record a customer publishes has to be the name the provider will sign\n * under, and `sendingDkimSelector` only proposes it.\n */\nexport async function recordIssuedSendingDomain(options: {\n orgId: string\n domain: string\n dkimPublicKey: string\n /** The selector the provider issued, when it chose its own. */\n dkimSelector?: string\n returnPathHost?: string\n providerDomainId?: string\n /**\n * The provider's tracking host, when it issued one — see\n * `SendingDomainRecord.trackingTarget`. Absent leaves the domain sending\n * and unmeasured rather than unsent.\n */\n trackingTarget?: string | null\n}): Promise<SendingDomainResult> {\n const domain = normalizeSendingDomain(options?.domain)\n const key = String(options?.dkimPublicKey ?? '').trim()\n if (!domain) {\n return { record: null, error: 'Missing domain', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n return { record: null, error: 'No claim on that domain', status: 404 }\n }\n\n const existing = readSendingDomainRecord(snapshot)\n const held = String(existing?.dkimPublicKey ?? '').trim()\n if (held && held !== key) {\n return {\n record: existing,\n error:\n 'That domain already has an issued signing key. Release the domain ' +\n 'and add it again to start over.',\n status: 409,\n }\n }\n\n const selector =\n String(options?.dkimSelector ?? '').trim() || existing?.dkimSelector || ''\n const returnPathHost = options.returnPathHost || existing?.returnPathHost\n\n /*\n * The only thing standing between a caller and `records-issued`, and it is\n * asked of the SAME function that prints the records for the customer and\n * that the verifier compares against — not a truthiness check on the key,\n * which would be this write's private opinion of what \"publishable\" means\n * and could agree with nothing else.\n */\n const issued = sendingDomainRequiredRecords({\n domain,\n dkimSelector: selector,\n dkimPublicKey: key,\n returnPathHost,\n })\n if (!issued.some((entry) => entry.purpose === 'dkim')) {\n return {\n record: existing,\n error: 'Refusing to issue records for a domain with no publishable DKIM record',\n status: 400,\n }\n }\n\n await ref.set(\n {\n status: 'records-issued',\n dkimPublicKey: key,\n ...(selector ? { dkimSelector: selector } : {}),\n ...(options.returnPathHost\n ? { returnPathHost: options.returnPathHost }\n : {}),\n ...(options.providerDomainId\n ? { providerDomainId: String(options.providerDomainId) }\n : {}),\n // Only when the provider issued one. Writing an empty value would put a\n // tracking CNAME with no target in front of a customer, which is the\n // blank-record shape `sendingDnsRecords` refuses everywhere else.\n ...(String(options.trackingTarget ?? '').trim()\n ? { trackingTarget: String(options.trackingTarget).trim() }\n : {}),\n // A previous failure is not part of the record once it succeeded.\n lastIssueError: firebaseAdmin.firestore.FieldValue.delete(),\n },\n { merge: true },\n )\n return { record: readSendingDomainRecord(await ref.get()), error: null, status: 200 }\n}\n\n/**\n * Record that the provider did not issue anything, WITHOUT moving the status.\n *\n * The point of a separate function is that there is no path from a provider\n * failure to `records-issued`. A `4xx` or `5xx` means no key exists, so the\n * domain stays `requested` — where it refuses sends, which is correct for a\n * domain that cannot sign — and carries a reason an admin can act on instead\n * of appearing to have finished.\n *\n * `detail` is a short code the caller built from a fixed vocabulary, never a\n * provider's response body. {@link safeProviderDetail} is the second line:\n * an `Authorization` header echoed back by a provider must not become a\n * Firestore document.\n */\nexport async function recordSendingDomainIssueFailure(options: {\n orgId: string\n domain: string\n detail: string\n}): Promise<void> {\n const domain = normalizeSendingDomain(options?.domain)\n if (!options?.orgId || !domain) return\n const ref = domainRef(options.orgId, domain)\n if (!(await ref.get()).exists) return\n await ref.set(\n {\n lastIssueError: safeProviderDetail(options.detail) || 'unknown',\n lastIssueAtMs: Date.now(),\n },\n { merge: true },\n )\n}\n\nexport interface SendingDomainView {\n record: SendingDomainRecord\n /** Exactly what the customer must publish. */\n records: SendingDnsRecord[]\n /** Their DMARC policy, read and never written. */\n dmarc: DmarcAssessment | null\n}\n\n/** One domain plus the records it needs, for a surface that shows both. */\nexport async function getSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<SendingDomainView | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return null\n const record = readSendingDomainRecord(await domainRef(orgId, domain).get())\n if (!record) return null\n return { record, records: sendingDnsRecords(record), dmarc: null }\n}\n\nexport async function listSendingDomains(\n orgId: string,\n): Promise<SendingDomainRecord[]> {\n if (!orgId) return []\n const snapshot = await firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .get()\n return snapshot.docs.map(readSendingDomainRecord).filter(Boolean)\n}\n\n/**\n * Read the customer's DMARC policy.\n *\n * Advisory in both directions: it never blocks verification and it is never\n * written. A domain under `p=reject` with our DKIM unpublished hard-fails\n * every message, and a customer deserves to be told that before they wonder\n * why their campaign vanished.\n */\nexport async function readDmarcPolicy(\n rawDomain: string,\n): Promise<DmarcAssessment | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!domain) return null\n const lookup = await lookupTxt(`_dmarc.${domain}`)\n // An unreachable lookup is not \"they have no policy\" — saying so would tell\n // a customer under p=reject that they have no protection.\n if (!lookup.answered) return null\n return assessDmarc(lookup.records)\n}\n\n/**\n * Start (or read) the hold that keeps a tracked domain's links alive.\n *\n * Stamped on FIRST sighting rather than computed from an orphan timestamp,\n * because there is no reliable one: a label reassignment leaves no record of\n * when it happened, and the reaper re-infers the orphan on every pass. The\n * stamp is the record of when the hold began, so a domain cannot be held\n * forever by a sweep that keeps starting the clock over.\n *\n * @returns the instant the domain may be released, or `null` when there is\n * nothing to stamp it on — a domain with no org record cannot be\n * held, and refusing to release it on the strength of a hold nobody\n * can see would strand a provider slot permanently.\n */\nexport async function holdTrackedSendingDomain(options: {\n orgId: string | null\n domain: string\n nowMs: number\n windowMs: number\n}): Promise<number | null> {\n const orgId = String(options?.orgId ?? '').trim()\n const domain = normalizeSendingDomain(options?.domain)\n if (!orgId || !domain || options.windowMs <= 0) return null\n\n const ref = domainRef(orgId, domain)\n const snapshot = await ref.get().catch(() => null)\n if (!snapshot?.exists) return null\n\n const held = Number(snapshot.get('trackingRetentionUntilMs')) || 0\n if (held > 0) return held\n\n const until = options.nowMs + options.windowMs\n await ref\n .set({ trackingRetentionUntilMs: until }, { merge: true })\n // A stamp we could not write is a hold we cannot prove later, so the\n // caller is told there is none rather than being asked to wait on a\n // deadline that will restart on the next pass.\n .catch(() => null)\n return until\n}\n\n/**\n * Whether this domain publishes a CAA policy that would REFUSE the tracking\n * host's certificate.\n *\n * The question decides whether a customer is shown a CAA record at all, and\n * asking it is the difference between guidance and a footgun. CAA restricts\n * which authorities may issue, and the lookup stops at the first name in the\n * tree publishing any record — so:\n *\n * - A domain publishing NOTHING needs nothing. Any authority may already\n * issue, and handing over a CAA record to paste would be the change that\n * STARTS restricting them, breaking whatever else renews on that name.\n * - A domain that publishes some, none of which name our authority, has to\n * add one ALONGSIDE what it has.\n * - A domain that already names our authority is done.\n *\n * Walks up from the tracking host exactly as a certificate authority does,\n * and stops where a real lookup stops: at the first name with any record.\n *\n * @returns `null` when DNS could not be reached, which is not \"they publish\n * none\" — telling a customer to add a CAA on the strength of an\n * unanswered lookup is how a working zone gets narrowed by mistake.\n */\nexport async function readTrackingCaaNeed(\n rawDomain: string,\n authority: string,\n): Promise<'not-needed' | 'must-add' | 'satisfied' | null> {\n const domain = normalizeSendingDomain(rawDomain)\n const ca = String(authority ?? '').trim().toLowerCase()\n if (!domain || !ca) return null\n\n /*\n * From the tracking host upward. Every label is asked because a CAA on a\n * closer name overrides a broader one — a customer who already scoped a\n * policy to `links.` must not be told to widen their root.\n */\n const labels = sendingTrackingHost(domain).split('.')\n for (let index = 0; index < labels.length - 1; index += 1) {\n const name = labels.slice(index).join('.')\n const lookup = await lookupCaa(name)\n if (!lookup.answered) return null\n if (!lookup.records.length) continue\n // The first name with any record is where issuance is decided.\n const issuers = lookup.records\n .filter((entry) => / issue \"/.test(entry))\n .map((entry) => entry.toLowerCase())\n return issuers.some((entry) => entry.includes(`\"${ca}\"`))\n ? 'satisfied'\n : 'must-add'\n }\n return 'not-needed'\n}\n\n/**\n * Read the live DNS for one record and say what it establishes. Writes\n * nothing.\n *\n * Split out so the two callers that need this answer ask the SAME question of\n * the SAME resolvers: {@link verifySendingDomain}, which acts on it\n * immediately, and the unattended re-check sweep, which counts conclusive\n * failures before it acts. A sweep that assessed the records its own way would\n * be a second opinion on \"are these published\", and the customer would meet\n * whichever one happened to run last.\n *\n * The three lookups run together because they are independent and the caller\n * is waiting on the slowest either way.\n */\nexport async function probeSendingRecords(\n record: SendingDomainRecord,\n): Promise<SendingVerification> {\n const domain = normalizeSendingDomain(record?.domain ?? '')\n const sendHost = `${SENDING_SUBDOMAIN}.${domain}`\n const dkimHost = `${record.dkimSelector}._domainkey.${domain}`\n const [spf, dkim, mx] = await Promise.all([\n lookupTxt(sendHost),\n lookupTxt(dkimHost),\n lookupMx(sendHost),\n ])\n\n return assessSendingRecords(record, {\n spfTxt: spf.records,\n dkimTxt: dkim.records,\n mx: mx.records,\n // All three, not any: a record we could not read is not a record that is\n // absent, and one unreadable lookup is enough to make the whole answer\n // evidence of nothing.\n conclusive: spf.answered && dkim.answered && mx.answered,\n })\n}\n\n/**\n * Check the live DNS and move the record to `verified` or `failed`.\n *\n * Never throws: a sweep across every org must not stop at the first bad zone.\n *\n * An INCONCLUSIVE result writes nothing but the check time. A resolver outage\n * must not un-verify a working domain — which would silently stop that\n * tenant's mail — and must not fail a customer who is midway through\n * publishing either. This is the `hold` arm the SSO drift sweep uses, and the\n * reason `assessSendingRecords` has three outcomes rather than two.\n *\n * Acts on ONE conclusive answer, and that is right for this caller: an admin\n * pressed Verify and is watching the result, so the check is attended and its\n * outcome is immediately visible and immediately retryable. The unattended\n * sweep over already-verified domains is the caller that cannot say that, and\n * `sending-domain-recheck.ts` is where the extra evidence it needs is\n * gathered.\n */\nexport async function verifySendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<{\n record: SendingDomainRecord | null\n missing: string[]\n inconclusive: boolean\n error: string | null\n}> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) {\n return { record: null, missing: [], inconclusive: false, error: 'Invalid domain' }\n }\n\n const ref = domainRef(orgId, domain)\n const record = readSendingDomainRecord(await ref.get())\n if (!record) {\n return {\n record: null,\n missing: [],\n inconclusive: false,\n error: 'No claim on that domain — add it first.',\n }\n }\n\n const verdict = await probeSendingRecords(record)\n\n const now = Date.now()\n if (verdict.status === 'inconclusive') {\n await ref.set({ lastCheckedAtMs: now }, { merge: true })\n return { record, missing: [], inconclusive: true, error: null }\n }\n\n const verified = verdict.status === 'verified'\n await ref.set(\n {\n status: verified ? 'verified' : 'failed',\n lastCheckedAtMs: now,\n ...(verified\n ? {\n verifiedAtMs: now,\n lastMissing: firebaseAdmin.firestore.FieldValue.delete(),\n }\n : { lastMissing: verdict.missing }),\n },\n { merge: true },\n )\n\n // Again on verification, the moment it can actually send (AGL-3362): a\n // claim added before this check existed is caught here. One row per\n // domain, so this counts rather than re-alerts.\n if (verified) {\n await flagLookalikeDomain({ kind: 'sending', hostId: null, orgId, domain })\n }\n\n return {\n record: readSendingDomainRecord(await ref.get()),\n missing: verdict.missing,\n inconclusive: false,\n error: null,\n }\n}\n\n/**\n * Drop a claim.\n *\n * The host selections pointing at it are NOT rewritten to the platform\n * identity. A site that was sending as its own domain and now has no verified\n * one must refuse, not quietly revert — silently moving a tenant's mail back\n * onto the shared domain is the exact fallback this feature exists to prevent,\n * and doing it during a delete would be no better than doing it during a send.\n */\nexport async function releaseSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<void> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return\n await domainRef(orgId, domain)\n .delete()\n .catch(() => undefined)\n}\n\n/*==========================================\n The read on the send path\n==========================================*/\n\n/**\n * The identity one host sends on, ready to hand to `sendEmail`.\n *\n * Two document reads at most, and none at all for a host that has no\n * selection. The host document is passed in rather than re-fetched because\n * every caller already holds it.\n *\n * ## This function cannot return an `aglyn.com` address, and that is the point\n *\n * Every caller here is resolving mail FOR A SITE — that is what \"host\" in the\n * name means — so every verdict it produces is tenant mail by construction.\n * `audience: 'tenant'` is therefore passed unconditionally and is not a\n * parameter: a caller that could choose would eventually choose wrong, and the\n * wrong choice puts a merchant's list quality onto the domain the platform's\n * own invoices and password resets leave on.\n *\n * A site that has selected NOTHING does not reach `USAGE_EMAIL_FROM` either.\n * All of its mail goes on the shared pool member `hostSharedFrom` assigns it,\n * marketing included. What keeps the pool usable is measurement rather than\n * prohibition: a pooled campaign is graded against the stricter reputation\n * thresholds, so the site earning complaints is the one that stops.\n */\nexport async function resolveHostSendingIdentity(options: {\n orgId: string | null | undefined\n /** `hosts/{hostId}.sendingDomain`, the site's selection. */\n selectedDomain: string | null | undefined\n /** `hosts/{hostId}.sendingLocalPart`, defaulted when unset. */\n selectedLocalPart?: string | null\n platformFrom?: string | null\n /**\n * The site, so an unselected host can be assigned a pool member.\n *\n * Optional because two of the three callers resolve an identity for a host\n * they are already holding and one — the console's identity view — is asking\n * about a specific site by id. A caller that omits it gets no shared\n * identity, which is the honest answer: the pool assignment is per site, and\n * \"some pool member\" is not a thing to send a receipt from.\n */\n hostId?: string | null\n /**\n * A pool member this host is pinned to, overriding the hash.\n *\n * `hosts/{hostId}.sendingPoolMember`. The QUARANTINE lever: a site whose\n * transactional mail is generating complaints can be moved onto a member set\n * aside for it, without disturbing any of the other sites that hash to the\n * member it was on. Ignored unless it names a real member of the current\n * pool, so a stale or hand-edited value degrades to the ordinary assignment\n * rather than to an address nothing signs for.\n */\n poolMember?: string | null\n /** See `SendingIdentityPurpose`. Defaults to transactional. */\n purpose?: SendingIdentityPurpose\n}): Promise<SendingIdentityVerdict> {\n const platformFrom =\n options?.platformFrom ?? process.env.USAGE_EMAIL_FROM ?? null\n const domain = normalizeSendingDomain(options?.selectedDomain ?? '')\n const purpose = options?.purpose\n const sharedFrom = hostSharedFrom(options?.hostId, options?.poolMember)\n\n if (!domain || !options?.orgId) {\n return resolveSendingIdentity({\n selection: null,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n }\n\n const record = readSendingDomainRecord(await domainRef(options.orgId, domain).get())\n\n /*\n * Whose domain this is, decided from the NAME rather than from the record.\n *\n * The record is what a released domain no longer has, and a site pointed at\n * a subdomain of our own apex is still pointed at a subdomain of our own\n * apex whether or not one exists for it. Deriving this from the name keeps\n * the two possible failures apart: a missing record for OUR name is our\n * provisioning that did not finish, a missing record for THEIRS is a domain\n * they asked to send as and nothing has verified.\n */\n const platformIssued = isPlatformSendingDomain(domain)\n\n /*\n * A selection naming a CUSTOMER'S domain with no record refuses rather than\n * falling back. The record can be gone because it was released, or because\n * the selection was written against a different org — both mean the site is\n * configured to send as a domain nothing has verified, and neither is a\n * reason to send as somebody else.\n *\n * `platformIssued` rides on the selection either way, and the resolver reads\n * it only on the unverified branch: a platform subdomain that has not\n * finished provisioning drops to the pool for transactional mail instead,\n * because nothing about it is a merchant instruction to contradict.\n */\n const selection: SendingDomainSelection = record\n ? {\n domain: record.domain,\n status: record.status,\n localPart: normalizeLocalPart(options.selectedLocalPart ?? '') || 'hello',\n missing: record.lastMissing ?? [],\n platformIssued,\n }\n : { domain, status: 'failed', localPart: '', missing: [], platformIssued }\n\n /*\n * `sharedFrom` is passed even here, where a selection exists and will decide\n * the outcome. It is inert by construction — the selection branch of\n * `resolveSendingIdentity` never reads it — and passing it unconditionally\n * keeps the call shape identical between the two arms, so a future edit\n * cannot produce a path that had a pool available and did not offer it.\n *\n * It must NOT become a fallback for an unverified selection, and it is the\n * resolver's structure rather than this call site that guarantees that.\n */\n return resolveSendingIdentity({\n selection,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n}\n\n/**\n * The pool address for one host: the pinned member if it names a real one, the\n * hash assignment otherwise.\n *\n * The override is validated against the LIVE pool rather than trusted, because\n * a stale value is the expected case — an operator shrinks the pool, or a\n * quarantine was lifted by deleting the member rather than the field — and an\n * address on a domain that no longer exists is a send that fails at the\n * provider instead of one that quietly goes back to normal.\n */\nfunction hostSharedFrom(\n hostId: string | null | undefined,\n poolMember: string | null | undefined,\n): string {\n const id = String(hostId ?? '').trim()\n if (!id) return ''\n\n const pinned = normalizeSendingDomain(String(poolMember ?? ''))\n if (pinned && isSharedSendingDomain(pinned)) {\n const shared = sharedTenantSendingFrom(id)\n const at = shared.lastIndexOf('@')\n return at > 0 ? `${shared.slice(0, at)}@${pinned}` : ''\n }\n\n return sharedTenantSendingFrom(id)\n}\n\n/**\n * The identity one site sends on, from a `hostId` alone.\n *\n * The door every tenant sender uses. {@link resolveHostSendingIdentity} needs\n * the org id and the host's two selection fields, and a survey of the tenant\n * send sites found that all of them hold a `hostId` while only three hold an\n * org id and half never read the host document at all. Asking nineteen call\n * sites to each assemble the same three values is the shape that produces a\n * twentieth which does not — and the cost of forgetting here is a message\n * leaving on the platform's own domain.\n *\n * ## The cache is per-call-graph, not per-process\n *\n * Passed a `cache`, repeated resolutions for one host cost one pair of reads.\n * A sweep that mails two hundred abandoned carts across a dozen sites, or a\n * webhook that sends a receipt and a seller notice for the same order, holds\n * one map for the run and pays for each site once.\n *\n * Deliberately NOT a module-level cache. Verification status is exactly the\n * thing that changes underneath a long-lived process — the re-check sweep\n * un-verifies a domain whose records have gone — and a stale entry would keep\n * a site sending on a domain that no longer authenticates. A caller that\n * declares a cache has also declared how long it may be trusted.\n */\nexport async function hostSendingIdentity(\n hostId: string | null | undefined,\n cache?: Map<string, SendingIdentityVerdict>,\n): Promise<SendingIdentityVerdict> {\n const id = String(hostId ?? '').trim()\n if (!id) {\n /*\n * No host is not \"the platform is speaking\", and it is not \"any pool\n * member will do\" either. It is a caller that does not know which site it\n * is sending for, and the honest answer to that is a refusal.\n *\n * No `sharedFrom` is passed, which is what makes that structural rather\n * than a rule: the pool is assigned FROM the host id, so a caller without\n * one has nothing to be assigned.\n */\n return resolveSendingIdentity({ selection: null, audience: 'tenant' })\n }\n\n const hit = cache?.get(id)\n if (hit) return hit\n\n const snapshot = await firestore()\n .collection('hosts')\n .doc(id)\n .get()\n .catch(() => null)\n const owner = await getOrgForHost(id).catch(() => null)\n\n /*\n * A SUSPENDED SITE SENDS NOTHING (AGL-3356).\n *\n * This is the door every tenant sender but the campaign core uses, so it\n * is where one check covers them all — workflow and automation steps, the\n * CRM's one-off mail, inbox replies, member posts, receipts, reminders.\n * Refusing here rather than at each caller is the same argument this\n * function's own docblock makes about identities: nineteen call sites\n * asked to remember produce a twentieth that does not, and the incident\n * behind this was exactly that — a locked workspace whose workflow could\n * still mail for it.\n *\n * FULL locks only. A read-only lock is a maintenance window whose point\n * is that the site keeps serving and earning, and a receipt for an order\n * taken before it began must still arrive. Staff suspensions for abuse,\n * fraud and billing are full locks, which is the case this closes.\n *\n * The documents read above are handed to the verdict, so the check costs\n * the TTL-cached platform read and nothing else. It inherits the\n * verdict's fail-open posture and its takedown ratchet.\n */\n const lock = await getSiteLockdown(id, Date.now(), {\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n host: snapshot?.exists\n ? ((snapshot.data() as Record<string, unknown>) ?? null)\n : null,\n })\n if (lock && lockdownMode(lock) === 'full') {\n const suspended = suspendedSendingIdentity(lock.scope)\n cache?.set(id, suspended)\n return suspended\n }\n\n const resolved = await resolveHostSendingIdentity({\n orgId: owner?.orgId ?? null,\n selectedDomain: snapshot?.get('sendingDomain') ?? '',\n selectedLocalPart: snapshot?.get('sendingLocalPart') ?? '',\n hostId: id,\n poolMember: snapshot?.get('sendingPoolMember') ?? '',\n })\n\n /*\n * THE WORKSPACE, STAMPED FOR THE PHISHING SCREEN (AGL-3356).\n *\n * `sendEmail` screens every tenant message whose identity carries this,\n * so stamping it here is what puts CRM mail, inbox replies, newsletters,\n * member posts, sweeps, flow steps and receipts behind one screen without\n * any of them passing anything. Built from the two documents read above:\n * the screen costs no read of its own.\n */\n const verdict: SendingIdentityVerdict = {\n ...resolved,\n workspace: sendingWorkspaceFor({\n hostId: id,\n orgId: owner?.orgId ?? null,\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n host: snapshot?.exists\n ? ((snapshot.data() as Record<string, unknown>) ?? null)\n : null,\n }),\n }\n\n cache?.set(id, verdict)\n return verdict\n}\n\n/**\n * The verdict a suspended site's mail gets: a refusal, whatever identity the\n * site would otherwise have sent on. `sendEmail` reads it and answers\n * `suspended` without calling the provider.\n */\nexport function suspendedSendingIdentity(\n scope: string | null | undefined,\n): SendingIdentityVerdict {\n const what =\n scope === 'platform'\n ? 'Sending is paused platform-wide'\n : scope === 'org'\n ? 'This workspace is suspended'\n : 'This site is suspended'\n const message = `${what}, so no email is sent on its behalf until the suspension is lifted.`\n return {\n from: null,\n source: null,\n domain: null,\n summary: message,\n refusal: {\n code: 'workspace-suspended',\n domain: null,\n message,\n missing: [],\n },\n }\n}\n\n/** The record keys a surface highlights as outstanding. */\nexport function outstandingSendingRecords(\n record: SendingDomainRecord,\n): SendingDnsRecord[] {\n const missing = new Set(record?.lastMissing ?? [])\n if (!missing.size) return []\n return sendingDnsRecords(record).filter((entry) =>\n missing.has(sendingRecordKey(entry)),\n )\n}\n"],"names":["assessDmarc","assessSendingRecords","isPlatformSendingDomain","isSharedSendingDomain","normalizeLocalPart","normalizeSendingDomain","resolveSendingIdentity","safeProviderDetail","SENDING_SUBDOMAIN","sendingDnsRecords","sendingTrackingHost","sendingDomainRequiredRecords","sendingRecordKey","sharedTenantSendingFrom","validateSendingDomain","lockdownMode","firebaseAdmin","lookupCaa","lookupMx","lookupTxt","getOrgForHost","getSiteLockdown","sendingWorkspaceFor","flagLookalikeDomain","firestore","app","SENDING_DOMAINS_COLLECTION","domainRef","orgId","domain","collection","doc","sendingDkimSelector","safe","String","toLowerCase","replace","slice","readSendingDomainRecord","snapshot","data","exists","id","status","dkimSelector","dkimPublicKey","returnPathHost","providerDomainId","trackingTarget","createdAtMs","Number","verifiedAtMs","lastCheckedAtMs","lastIssueError","lastIssueAtMs","lastMissing","Array","isArray","requestSendingDomain","options","error","record","ref","existing","get","Date","now","set","merge","kind","hostId","recordIssuedSendingDomain","key","trim","held","selector","issued","some","entry","purpose","FieldValue","delete","recordSendingDomainIssueFailure","detail","getSendingDomain","rawDomain","records","dmarc","listSendingDomains","docs","map","filter","Boolean","readDmarcPolicy","lookup","answered","holdTrackedSendingDomain","windowMs","catch","until","nowMs","trackingRetentionUntilMs","readTrackingCaaNeed","authority","ca","labels","split","index","length","name","join","issuers","test","includes","probeSendingRecords","sendHost","dkimHost","spf","dkim","mx","Promise","all","spfTxt","dkimTxt","conclusive","verifySendingDomain","missing","inconclusive","verdict","verified","releaseSendingDomain","undefined","resolveHostSendingIdentity","platformFrom","process","env","USAGE_EMAIL_FROM","selectedDomain","sharedFrom","hostSharedFrom","poolMember","selection","audience","platformIssued","localPart","selectedLocalPart","pinned","shared","at","lastIndexOf","hostSendingIdentity","cache","hit","owner","lock","org","host","suspended","suspendedSendingIdentity","scope","resolved","workspace","what","message","from","source","summary","refusal","code","outstandingSendingRecords","Set","size","has"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sending-domains.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 * CUSTOM SENDING DOMAINS — durable half.\n *\n * The record, the DNS lookups and the status transitions. The decisions all\n * live in `@aglyn/shared-util-email`'s `sending-domain.ts`, which is pure;\n * this module is the I/O around them, and it holds no policy of its own.\n *\n * ## Per-org record, per-host selection\n *\n * The record is at `orgs/{orgId}/sendingDomains/{domain}`, alongside\n * `orgs/{orgId}/ssoDomains/{domain}` and for the same reason: proving control\n * of a zone is a property of the ORG that proved it, and an agency running\n * four sites on `client.com` should publish the DKIM record once rather than\n * four times.\n *\n * Which identity a given site SENDS on is a separate per-host field,\n * `hosts/{hostId}.sendingDomain`. That split is what answers the two halves of\n * the question at once: per-org verification keeps the DNS chore down, and a\n * per-host selection is what an agency's client actually wants, because the\n * `From:` their recipients see belongs to the site, not to the agency.\n *\n * ## Reads are cheap on purpose\n *\n * `resolveHostSendingIdentity` is on the path of a campaign send, so it reads\n * the host document the caller already has and at most ONE org subcollection\n * document. It never scans, never lists, and never touches DNS — the standing\n * rule against unrequested reads on a hot path applies here more than most,\n * because a campaign resolves an identity once for thousands of messages.\n *\n * ## Why the provider call is not in here\n *\n * Issuing a DKIM key means creating a domain at the mail provider, which needs\n * a credential that can create things — a different one from the send-only\n * `RESEND_API_KEY`, which is exactly why `email-health.ts` can use the domains\n * endpoint as a read-only credential probe.\n *\n * That credential belongs to the console alone, so the driver that reads it\n * lives in the console app — `apps/console/utils/server/` — and not here.\n * **This library is imported by the tenant runtime**, which\n * serves untrusted published sites; a module it can import is a module whose\n * environment read is one bug away from being reachable from a site request.\n * A file in `apps/console` is not importable from the tenant app at all —\n * there is no path mapping to it and nx's module boundaries forbid app→app —\n * so the isolation is structural rather than a convention.\n *\n * What stays here is the seam: {@link recordIssuedSendingDomain} takes what a\n * provider returned, and {@link recordSendingDomainIssueFailure} takes what it\n * refused. `requestSendingDomain` still stops at `requested`, and a domain\n * stopped there refuses sends, which is the correct behavior for a domain that\n * has no signing key.\n */\n\nimport {\n assessDmarc,\n assessSendingRecords,\n headerSafeText,\n isPlatformSendingDomain,\n isSharedSendingDomain,\n normalizeLocalPart,\n normalizeSendingDomain,\n resolveSendingIdentity,\n safeProviderDetail,\n SENDING_FROM_NAME_MAX,\n SENDING_SUBDOMAIN,\n sendingDnsRecords,\n sendingTrackingHost,\n sendingDomainRequiredRecords,\n sendingRecordKey,\n sharedTenantSendingFrom,\n validateSendingDomain,\n type DmarcAssessment,\n type SendingDnsRecord,\n type SendingDomainRecord,\n type SendingDomainSelection,\n type SendingIdentityPurpose,\n type SendingIdentityVerdict,\n type SendingVerification,\n} from '@aglyn/shared-util-email'\nimport {\n lockdownMode,\n resolveBrandingProfile,\n resolveHostToken,\n} from '@aglyn/aglyn/server'\nimport firebaseAdmin from './firebase-admin'\nimport { lookupCaa, lookupMx, lookupTxt } from './dns-probe'\nimport { getOrgForHost } from './organizations'\nimport { getSiteLockdown } from './tenant-write-lockdown'\n// Also INSTALLS the outbound phishing screen's review store on `sendEmail`\n// (AGL-3356): every sender whose identity comes from `hostSendingIdentity`\n// has therefore installed the gate the workspace stamped below is read by.\nimport { sendingWorkspaceFor } from './outbound-send-review'\nimport { flagLookalikeDomain } from './hosted-page-review'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Subcollection under the owning org. */\nexport const SENDING_DOMAINS_COLLECTION = 'sendingDomains'\n\nconst domainRef = (orgId: string, domain: string) =>\n firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .doc(domain)\n\n/**\n * The DKIM selector issued to one org for one domain.\n *\n * Includes the org id so two orgs verifying the same name occupy different\n * record names. Without that they would share `resend._domainkey.<domain>`,\n * and whichever verified second would overwrite the first — or, worse, inherit\n * the first's verification without ever publishing anything.\n */\nexport function sendingDkimSelector(orgId: string): string {\n const safe = String(orgId ?? '')\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '')\n .slice(0, 24)\n return `aglyn${safe ? `-${safe}` : ''}`\n}\n\n/** Both keys always present, one always null — `strictNullChecks` is off. */\nexport interface SendingDomainResult {\n record: SendingDomainRecord | null\n error: string | null\n /** The HTTP status a route should answer with. */\n status: number\n}\n\n/**\n * One stored document as a record.\n *\n * Exported so the re-check sweep, which reaches these documents through a\n * collection-group query rather than by org and name, shapes them the same way\n * every other reader here does — a second reader with its own defaults is how\n * two callers come to disagree about what an absent field means.\n */\nexport function readSendingDomainRecord(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): SendingDomainRecord | null {\n if (!snapshot?.exists) return null\n const data = snapshot.data() ?? {}\n return {\n domain: String(data.domain ?? snapshot.id),\n status: data.status ?? 'requested',\n dkimSelector: String(data.dkimSelector ?? ''),\n dkimPublicKey: data.dkimPublicKey ?? null,\n returnPathHost: data.returnPathHost ?? null,\n providerDomainId: data.providerDomainId ?? null,\n trackingTarget: data.trackingTarget ?? null,\n createdAtMs: Number(data.createdAtMs) || null,\n verifiedAtMs: Number(data.verifiedAtMs) || null,\n lastCheckedAtMs: Number(data.lastCheckedAtMs) || null,\n lastIssueError: data.lastIssueError ?? null,\n lastIssueAtMs: Number(data.lastIssueAtMs) || null,\n lastMissing: Array.isArray(data.lastMissing) ? data.lastMissing : null,\n }\n}\n\n/**\n * Start (or re-read) a claim on a sending domain.\n *\n * Idempotent, and deliberately non-destructive on a re-request: an existing\n * record keeps its selector and its key, because reissuing either would\n * invalidate a record the customer may already have published and turn a\n * working setup into a mysterious failure. This mirrors `issueDomainClaim`.\n */\nexport async function requestSendingDomain(options: {\n orgId: string\n domain: string\n}): Promise<SendingDomainResult> {\n const { domain, error } = validateSendingDomain(options?.domain)\n if (!domain) return { record: null, error, status: 400 }\n if (!options?.orgId) {\n return { record: null, error: 'Missing organization', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const existing = await ref.get()\n if (existing.exists) {\n return { record: readSendingDomainRecord(existing), error: null, status: 200 }\n }\n\n const record: SendingDomainRecord = {\n domain,\n status: 'requested',\n dkimSelector: sendingDkimSelector(options.orgId),\n dkimPublicKey: null,\n returnPathHost: null,\n createdAtMs: Date.now(),\n }\n await ref.set(record, { merge: true })\n // A domain that wears a brand (`paypa1.com`) goes to staff in the abuse\n // queue the moment it is claimed — flagged, not refused (AGL-3362). Mail\n // from it is held at the send seam whatever staff decide. Never throws.\n await flagLookalikeDomain({ kind: 'sending', hostId: null, orgId: options.orgId, domain })\n return { record, error: null, status: 201 }\n}\n\n/**\n * Record the key the mail provider issued, moving the domain to\n * `records-issued` so the customer has something to publish.\n *\n * Separated from {@link requestSendingDomain} because it needs a credential\n * that may not exist. Nothing here calls the provider: the caller supplies\n * what it was given, so a deployment whose key cannot create domains still has\n * a working path — an operator can complete this step by hand — and this\n * module never grows a dependency on a credential it cannot assume. The\n * console holds the credential and the driver that uses it; this library,\n * which the tenant runtime also imports, holds neither.\n *\n * ## `records-issued` is a promise that records exist\n *\n * The write is refused unless the resulting record actually yields a DKIM\n * record with a value. The alternative is the failure this whole feature is\n * arranged against: a status saying the customer has records to publish, next\n * to a records table with an empty DKIM row, which cannot ever verify and\n * reads to the customer as our bug.\n *\n * ## An issued key is never overwritten\n *\n * A second call carrying a DIFFERENT key is refused rather than applied. The\n * customer may already have published the first one, and replacing it turns a\n * finished setup into a domain that silently stops signing. Re-recording the\n * SAME key is a no-op and succeeds, so a retried request is safe.\n *\n * The SELECTOR, by contrast, comes from the provider when it supplies one:\n * the record a customer publishes has to be the name the provider will sign\n * under, and `sendingDkimSelector` only proposes it.\n */\nexport async function recordIssuedSendingDomain(options: {\n orgId: string\n domain: string\n dkimPublicKey: string\n /** The selector the provider issued, when it chose its own. */\n dkimSelector?: string\n returnPathHost?: string\n providerDomainId?: string\n /**\n * The provider's tracking host, when it issued one — see\n * `SendingDomainRecord.trackingTarget`. Absent leaves the domain sending\n * and unmeasured rather than unsent.\n */\n trackingTarget?: string | null\n}): Promise<SendingDomainResult> {\n const domain = normalizeSendingDomain(options?.domain)\n const key = String(options?.dkimPublicKey ?? '').trim()\n if (!domain) {\n return { record: null, error: 'Missing domain', status: 400 }\n }\n\n const ref = domainRef(options.orgId, domain)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n return { record: null, error: 'No claim on that domain', status: 404 }\n }\n\n const existing = readSendingDomainRecord(snapshot)\n const held = String(existing?.dkimPublicKey ?? '').trim()\n if (held && held !== key) {\n return {\n record: existing,\n error:\n 'That domain already has an issued signing key. Release the domain ' +\n 'and add it again to start over.',\n status: 409,\n }\n }\n\n const selector =\n String(options?.dkimSelector ?? '').trim() || existing?.dkimSelector || ''\n const returnPathHost = options.returnPathHost || existing?.returnPathHost\n\n /*\n * The only thing standing between a caller and `records-issued`, and it is\n * asked of the SAME function that prints the records for the customer and\n * that the verifier compares against — not a truthiness check on the key,\n * which would be this write's private opinion of what \"publishable\" means\n * and could agree with nothing else.\n */\n const issued = sendingDomainRequiredRecords({\n domain,\n dkimSelector: selector,\n dkimPublicKey: key,\n returnPathHost,\n })\n if (!issued.some((entry) => entry.purpose === 'dkim')) {\n return {\n record: existing,\n error: 'Refusing to issue records for a domain with no publishable DKIM record',\n status: 400,\n }\n }\n\n await ref.set(\n {\n status: 'records-issued',\n dkimPublicKey: key,\n ...(selector ? { dkimSelector: selector } : {}),\n ...(options.returnPathHost\n ? { returnPathHost: options.returnPathHost }\n : {}),\n ...(options.providerDomainId\n ? { providerDomainId: String(options.providerDomainId) }\n : {}),\n // Only when the provider issued one. Writing an empty value would put a\n // tracking CNAME with no target in front of a customer, which is the\n // blank-record shape `sendingDnsRecords` refuses everywhere else.\n ...(String(options.trackingTarget ?? '').trim()\n ? { trackingTarget: String(options.trackingTarget).trim() }\n : {}),\n // A previous failure is not part of the record once it succeeded.\n lastIssueError: firebaseAdmin.firestore.FieldValue.delete(),\n },\n { merge: true },\n )\n return { record: readSendingDomainRecord(await ref.get()), error: null, status: 200 }\n}\n\n/**\n * Record that the provider did not issue anything, WITHOUT moving the status.\n *\n * The point of a separate function is that there is no path from a provider\n * failure to `records-issued`. A `4xx` or `5xx` means no key exists, so the\n * domain stays `requested` — where it refuses sends, which is correct for a\n * domain that cannot sign — and carries a reason an admin can act on instead\n * of appearing to have finished.\n *\n * `detail` is a short code the caller built from a fixed vocabulary, never a\n * provider's response body. {@link safeProviderDetail} is the second line:\n * an `Authorization` header echoed back by a provider must not become a\n * Firestore document.\n */\nexport async function recordSendingDomainIssueFailure(options: {\n orgId: string\n domain: string\n detail: string\n}): Promise<void> {\n const domain = normalizeSendingDomain(options?.domain)\n if (!options?.orgId || !domain) return\n const ref = domainRef(options.orgId, domain)\n if (!(await ref.get()).exists) return\n await ref.set(\n {\n lastIssueError: safeProviderDetail(options.detail) || 'unknown',\n lastIssueAtMs: Date.now(),\n },\n { merge: true },\n )\n}\n\nexport interface SendingDomainView {\n record: SendingDomainRecord\n /** Exactly what the customer must publish. */\n records: SendingDnsRecord[]\n /** Their DMARC policy, read and never written. */\n dmarc: DmarcAssessment | null\n}\n\n/** One domain plus the records it needs, for a surface that shows both. */\nexport async function getSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<SendingDomainView | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return null\n const record = readSendingDomainRecord(await domainRef(orgId, domain).get())\n if (!record) return null\n return { record, records: sendingDnsRecords(record), dmarc: null }\n}\n\nexport async function listSendingDomains(\n orgId: string,\n): Promise<SendingDomainRecord[]> {\n if (!orgId) return []\n const snapshot = await firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(SENDING_DOMAINS_COLLECTION)\n .get()\n return snapshot.docs.map(readSendingDomainRecord).filter(Boolean)\n}\n\n/**\n * Read the customer's DMARC policy.\n *\n * Advisory in both directions: it never blocks verification and it is never\n * written. A domain under `p=reject` with our DKIM unpublished hard-fails\n * every message, and a customer deserves to be told that before they wonder\n * why their campaign vanished.\n */\nexport async function readDmarcPolicy(\n rawDomain: string,\n): Promise<DmarcAssessment | null> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!domain) return null\n const lookup = await lookupTxt(`_dmarc.${domain}`)\n // An unreachable lookup is not \"they have no policy\" — saying so would tell\n // a customer under p=reject that they have no protection.\n if (!lookup.answered) return null\n return assessDmarc(lookup.records)\n}\n\n/**\n * Start (or read) the hold that keeps a tracked domain's links alive.\n *\n * Stamped on FIRST sighting rather than computed from an orphan timestamp,\n * because there is no reliable one: a label reassignment leaves no record of\n * when it happened, and the reaper re-infers the orphan on every pass. The\n * stamp is the record of when the hold began, so a domain cannot be held\n * forever by a sweep that keeps starting the clock over.\n *\n * @returns the instant the domain may be released, or `null` when there is\n * nothing to stamp it on — a domain with no org record cannot be\n * held, and refusing to release it on the strength of a hold nobody\n * can see would strand a provider slot permanently.\n */\nexport async function holdTrackedSendingDomain(options: {\n orgId: string | null\n domain: string\n nowMs: number\n windowMs: number\n}): Promise<number | null> {\n const orgId = String(options?.orgId ?? '').trim()\n const domain = normalizeSendingDomain(options?.domain)\n if (!orgId || !domain || options.windowMs <= 0) return null\n\n const ref = domainRef(orgId, domain)\n const snapshot = await ref.get().catch(() => null)\n if (!snapshot?.exists) return null\n\n const held = Number(snapshot.get('trackingRetentionUntilMs')) || 0\n if (held > 0) return held\n\n const until = options.nowMs + options.windowMs\n await ref\n .set({ trackingRetentionUntilMs: until }, { merge: true })\n // A stamp we could not write is a hold we cannot prove later, so the\n // caller is told there is none rather than being asked to wait on a\n // deadline that will restart on the next pass.\n .catch(() => null)\n return until\n}\n\n/**\n * Whether this domain publishes a CAA policy that would REFUSE the tracking\n * host's certificate.\n *\n * The question decides whether a customer is shown a CAA record at all, and\n * asking it is the difference between guidance and a footgun. CAA restricts\n * which authorities may issue, and the lookup stops at the first name in the\n * tree publishing any record — so:\n *\n * - A domain publishing NOTHING needs nothing. Any authority may already\n * issue, and handing over a CAA record to paste would be the change that\n * STARTS restricting them, breaking whatever else renews on that name.\n * - A domain that publishes some, none of which name our authority, has to\n * add one ALONGSIDE what it has.\n * - A domain that already names our authority is done.\n *\n * Walks up from the tracking host exactly as a certificate authority does,\n * and stops where a real lookup stops: at the first name with any record.\n *\n * @returns `null` when DNS could not be reached, which is not \"they publish\n * none\" — telling a customer to add a CAA on the strength of an\n * unanswered lookup is how a working zone gets narrowed by mistake.\n */\nexport async function readTrackingCaaNeed(\n rawDomain: string,\n authority: string,\n): Promise<'not-needed' | 'must-add' | 'satisfied' | null> {\n const domain = normalizeSendingDomain(rawDomain)\n const ca = String(authority ?? '').trim().toLowerCase()\n if (!domain || !ca) return null\n\n /*\n * From the tracking host upward. Every label is asked because a CAA on a\n * closer name overrides a broader one — a customer who already scoped a\n * policy to `links.` must not be told to widen their root.\n */\n const labels = sendingTrackingHost(domain).split('.')\n for (let index = 0; index < labels.length - 1; index += 1) {\n const name = labels.slice(index).join('.')\n const lookup = await lookupCaa(name)\n if (!lookup.answered) return null\n if (!lookup.records.length) continue\n // The first name with any record is where issuance is decided.\n const issuers = lookup.records\n .filter((entry) => / issue \"/.test(entry))\n .map((entry) => entry.toLowerCase())\n return issuers.some((entry) => entry.includes(`\"${ca}\"`))\n ? 'satisfied'\n : 'must-add'\n }\n return 'not-needed'\n}\n\n/**\n * Read the live DNS for one record and say what it establishes. Writes\n * nothing.\n *\n * Split out so the two callers that need this answer ask the SAME question of\n * the SAME resolvers: {@link verifySendingDomain}, which acts on it\n * immediately, and the unattended re-check sweep, which counts conclusive\n * failures before it acts. A sweep that assessed the records its own way would\n * be a second opinion on \"are these published\", and the customer would meet\n * whichever one happened to run last.\n *\n * The three lookups run together because they are independent and the caller\n * is waiting on the slowest either way.\n */\nexport async function probeSendingRecords(\n record: SendingDomainRecord,\n): Promise<SendingVerification> {\n const domain = normalizeSendingDomain(record?.domain ?? '')\n const sendHost = `${SENDING_SUBDOMAIN}.${domain}`\n const dkimHost = `${record.dkimSelector}._domainkey.${domain}`\n const [spf, dkim, mx] = await Promise.all([\n lookupTxt(sendHost),\n lookupTxt(dkimHost),\n lookupMx(sendHost),\n ])\n\n return assessSendingRecords(record, {\n spfTxt: spf.records,\n dkimTxt: dkim.records,\n mx: mx.records,\n // All three, not any: a record we could not read is not a record that is\n // absent, and one unreadable lookup is enough to make the whole answer\n // evidence of nothing.\n conclusive: spf.answered && dkim.answered && mx.answered,\n })\n}\n\n/**\n * Check the live DNS and move the record to `verified` or `failed`.\n *\n * Never throws: a sweep across every org must not stop at the first bad zone.\n *\n * An INCONCLUSIVE result writes nothing but the check time. A resolver outage\n * must not un-verify a working domain — which would silently stop that\n * tenant's mail — and must not fail a customer who is midway through\n * publishing either. This is the `hold` arm the SSO drift sweep uses, and the\n * reason `assessSendingRecords` has three outcomes rather than two.\n *\n * Acts on ONE conclusive answer, and that is right for this caller: an admin\n * pressed Verify and is watching the result, so the check is attended and its\n * outcome is immediately visible and immediately retryable. The unattended\n * sweep over already-verified domains is the caller that cannot say that, and\n * `sending-domain-recheck.ts` is where the extra evidence it needs is\n * gathered.\n */\nexport async function verifySendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<{\n record: SendingDomainRecord | null\n missing: string[]\n inconclusive: boolean\n error: string | null\n}> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) {\n return { record: null, missing: [], inconclusive: false, error: 'Invalid domain' }\n }\n\n const ref = domainRef(orgId, domain)\n const record = readSendingDomainRecord(await ref.get())\n if (!record) {\n return {\n record: null,\n missing: [],\n inconclusive: false,\n error: 'No claim on that domain — add it first.',\n }\n }\n\n const verdict = await probeSendingRecords(record)\n\n const now = Date.now()\n if (verdict.status === 'inconclusive') {\n await ref.set({ lastCheckedAtMs: now }, { merge: true })\n return { record, missing: [], inconclusive: true, error: null }\n }\n\n const verified = verdict.status === 'verified'\n await ref.set(\n {\n status: verified ? 'verified' : 'failed',\n lastCheckedAtMs: now,\n ...(verified\n ? {\n verifiedAtMs: now,\n lastMissing: firebaseAdmin.firestore.FieldValue.delete(),\n }\n : { lastMissing: verdict.missing }),\n },\n { merge: true },\n )\n\n // Again on verification, the moment it can actually send (AGL-3362): a\n // claim added before this check existed is caught here. One row per\n // domain, so this counts rather than re-alerts.\n if (verified) {\n await flagLookalikeDomain({ kind: 'sending', hostId: null, orgId, domain })\n }\n\n return {\n record: readSendingDomainRecord(await ref.get()),\n missing: verdict.missing,\n inconclusive: false,\n error: null,\n }\n}\n\n/**\n * Drop a claim.\n *\n * The host selections pointing at it are NOT rewritten to the platform\n * identity. A site that was sending as its own domain and now has no verified\n * one must refuse, not quietly revert — silently moving a tenant's mail back\n * onto the shared domain is the exact fallback this feature exists to prevent,\n * and doing it during a delete would be no better than doing it during a send.\n */\nexport async function releaseSendingDomain(\n orgId: string,\n rawDomain: string,\n): Promise<void> {\n const domain = normalizeSendingDomain(rawDomain)\n if (!orgId || !domain) return\n await domainRef(orgId, domain)\n .delete()\n .catch(() => undefined)\n}\n\n/*==========================================\n The read on the send path\n==========================================*/\n\n/**\n * The identity one host sends on, ready to hand to `sendEmail`.\n *\n * Two document reads at most, and none at all for a host that has no\n * selection. The host document is passed in rather than re-fetched because\n * every caller already holds it.\n *\n * ## This function cannot return an `aglyn.com` address, and that is the point\n *\n * Every caller here is resolving mail FOR A SITE — that is what \"host\" in the\n * name means — so every verdict it produces is tenant mail by construction.\n * `audience: 'tenant'` is therefore passed unconditionally and is not a\n * parameter: a caller that could choose would eventually choose wrong, and the\n * wrong choice puts a merchant's list quality onto the domain the platform's\n * own invoices and password resets leave on.\n *\n * A site that has selected NOTHING does not reach `USAGE_EMAIL_FROM` either.\n * All of its mail goes on the shared pool member `hostSharedFrom` assigns it,\n * marketing included. What keeps the pool usable is measurement rather than\n * prohibition: a pooled campaign is graded against the stricter reputation\n * thresholds, so the site earning complaints is the one that stops.\n */\nexport async function resolveHostSendingIdentity(options: {\n orgId: string | null | undefined\n /** `hosts/{hostId}.sendingDomain`, the site's selection. */\n selectedDomain: string | null | undefined\n /** `hosts/{hostId}.sendingLocalPart`, defaulted when unset. */\n selectedLocalPart?: string | null\n platformFrom?: string | null\n /**\n * The site, so an unselected host can be assigned a pool member.\n *\n * Optional because two of the three callers resolve an identity for a host\n * they are already holding and one — the console's identity view — is asking\n * about a specific site by id. A caller that omits it gets no shared\n * identity, which is the honest answer: the pool assignment is per site, and\n * \"some pool member\" is not a thing to send a receipt from.\n */\n hostId?: string | null\n /**\n * A pool member this host is pinned to, overriding the hash.\n *\n * `hosts/{hostId}.sendingPoolMember`. The QUARANTINE lever: a site whose\n * transactional mail is generating complaints can be moved onto a member set\n * aside for it, without disturbing any of the other sites that hash to the\n * member it was on. Ignored unless it names a real member of the current\n * pool, so a stale or hand-edited value degrades to the ordinary assignment\n * rather than to an address nothing signs for.\n */\n poolMember?: string | null\n /** See `SendingIdentityPurpose`. Defaults to transactional. */\n purpose?: SendingIdentityPurpose\n}): Promise<SendingIdentityVerdict> {\n const platformFrom =\n options?.platformFrom ?? process.env.USAGE_EMAIL_FROM ?? null\n const domain = normalizeSendingDomain(options?.selectedDomain ?? '')\n const purpose = options?.purpose\n const sharedFrom = hostSharedFrom(options?.hostId, options?.poolMember)\n\n if (!domain || !options?.orgId) {\n return resolveSendingIdentity({\n selection: null,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n }\n\n const record = readSendingDomainRecord(await domainRef(options.orgId, domain).get())\n\n /*\n * Whose domain this is, decided from the NAME rather than from the record.\n *\n * The record is what a released domain no longer has, and a site pointed at\n * a subdomain of our own apex is still pointed at a subdomain of our own\n * apex whether or not one exists for it. Deriving this from the name keeps\n * the two possible failures apart: a missing record for OUR name is our\n * provisioning that did not finish, a missing record for THEIRS is a domain\n * they asked to send as and nothing has verified.\n */\n const platformIssued = isPlatformSendingDomain(domain)\n\n /*\n * A selection naming a CUSTOMER'S domain with no record refuses rather than\n * falling back. The record can be gone because it was released, or because\n * the selection was written against a different org — both mean the site is\n * configured to send as a domain nothing has verified, and neither is a\n * reason to send as somebody else.\n *\n * `platformIssued` rides on the selection either way, and the resolver reads\n * it only on the unverified branch: a platform subdomain that has not\n * finished provisioning drops to the pool for transactional mail instead,\n * because nothing about it is a merchant instruction to contradict.\n */\n const selection: SendingDomainSelection = record\n ? {\n domain: record.domain,\n status: record.status,\n localPart: normalizeLocalPart(options.selectedLocalPart ?? '') || 'hello',\n missing: record.lastMissing ?? [],\n platformIssued,\n }\n : { domain, status: 'failed', localPart: '', missing: [], platformIssued }\n\n /*\n * `sharedFrom` is passed even here, where a selection exists and will decide\n * the outcome. It is inert by construction — the selection branch of\n * `resolveSendingIdentity` never reads it — and passing it unconditionally\n * keeps the call shape identical between the two arms, so a future edit\n * cannot produce a path that had a pool available and did not offer it.\n *\n * It must NOT become a fallback for an unverified selection, and it is the\n * resolver's structure rather than this call site that guarantees that.\n */\n return resolveSendingIdentity({\n selection,\n platformFrom,\n sharedFrom,\n purpose,\n audience: 'tenant',\n })\n}\n\n/**\n * The pool address for one host: the pinned member if it names a real one, the\n * hash assignment otherwise.\n *\n * The override is validated against the LIVE pool rather than trusted, because\n * a stale value is the expected case — an operator shrinks the pool, or a\n * quarantine was lifted by deleting the member rather than the field — and an\n * address on a domain that no longer exists is a send that fails at the\n * provider instead of one that quietly goes back to normal.\n */\nfunction hostSharedFrom(\n hostId: string | null | undefined,\n poolMember: string | null | undefined,\n): string {\n const id = String(hostId ?? '').trim()\n if (!id) return ''\n\n const pinned = normalizeSendingDomain(String(poolMember ?? ''))\n if (pinned && isSharedSendingDomain(pinned)) {\n const shared = sharedTenantSendingFrom(id)\n const at = shared.lastIndexOf('@')\n return at > 0 ? `${shared.slice(0, at)}@${pinned}` : ''\n }\n\n return sharedTenantSendingFrom(id)\n}\n\n/**\n * The identity one site sends on, from a `hostId` alone.\n *\n * The door every tenant sender uses. {@link resolveHostSendingIdentity} needs\n * the org id and the host's two selection fields, and a survey of the tenant\n * send sites found that all of them hold a `hostId` while only three hold an\n * org id and half never read the host document at all. Asking nineteen call\n * sites to each assemble the same three values is the shape that produces a\n * twentieth which does not — and the cost of forgetting here is a message\n * leaving on the platform's own domain.\n *\n * ## The cache is per-call-graph, not per-process\n *\n * Passed a `cache`, repeated resolutions for one host cost one pair of reads.\n * A sweep that mails two hundred abandoned carts across a dozen sites, or a\n * webhook that sends a receipt and a seller notice for the same order, holds\n * one map for the run and pays for each site once.\n *\n * Deliberately NOT a module-level cache. Verification status is exactly the\n * thing that changes underneath a long-lived process — the re-check sweep\n * un-verifies a domain whose records have gone — and a stale entry would keep\n * a site sending on a domain that no longer authenticates. A caller that\n * declares a cache has also declared how long it may be trusted.\n */\nexport async function hostSendingIdentity(\n hostId: string | null | undefined,\n cache?: Map<string, SendingIdentityVerdict>,\n): Promise<SendingIdentityVerdict> {\n const id = String(hostId ?? '').trim()\n if (!id) {\n /*\n * No host is not \"the platform is speaking\", and it is not \"any pool\n * member will do\" either. It is a caller that does not know which site it\n * is sending for, and the honest answer to that is a refusal.\n *\n * No `sharedFrom` is passed, which is what makes that structural rather\n * than a rule: the pool is assigned FROM the host id, so a caller without\n * one has nothing to be assigned.\n */\n return resolveSendingIdentity({ selection: null, audience: 'tenant' })\n }\n\n const hit = cache?.get(id)\n if (hit) return hit\n\n const snapshot = await firestore()\n .collection('hosts')\n .doc(id)\n .get()\n .catch(() => null)\n const owner = await getOrgForHost(id).catch(() => null)\n\n /*\n * A SUSPENDED SITE SENDS NOTHING (AGL-3356).\n *\n * This is the door every tenant sender but the campaign core uses, so it\n * is where one check covers them all — workflow and automation steps, the\n * CRM's one-off mail, inbox replies, member posts, receipts, reminders.\n * Refusing here rather than at each caller is the same argument this\n * function's own docblock makes about identities: nineteen call sites\n * asked to remember produce a twentieth that does not, and the incident\n * behind this was exactly that — a locked workspace whose workflow could\n * still mail for it.\n *\n * FULL locks only. A read-only lock is a maintenance window whose point\n * is that the site keeps serving and earning, and a receipt for an order\n * taken before it began must still arrive. Staff suspensions for abuse,\n * fraud and billing are full locks, which is the case this closes.\n *\n * The documents read above are handed to the verdict, so the check costs\n * the TTL-cached platform read and nothing else. It inherits the\n * verdict's fail-open posture and its takedown ratchet.\n */\n const lock = await getSiteLockdown(id, Date.now(), {\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n host: snapshot?.exists\n ? ((snapshot.data() as Record<string, unknown>) ?? null)\n : null,\n })\n if (lock && lockdownMode(lock) === 'full') {\n const suspended = suspendedSendingIdentity(lock.scope)\n cache?.set(id, suspended)\n return suspended\n }\n\n const resolved = await resolveHostSendingIdentity({\n orgId: owner?.orgId ?? null,\n selectedDomain: snapshot?.get('sendingDomain') ?? '',\n selectedLocalPart: snapshot?.get('sendingLocalPart') ?? '',\n hostId: id,\n poolMember: snapshot?.get('sendingPoolMember') ?? '',\n })\n\n /*\n * THE WORKSPACE, STAMPED FOR THE PHISHING SCREEN (AGL-3356).\n *\n * `sendEmail` screens every tenant message whose identity carries this,\n * so stamping it here is what puts CRM mail, inbox replies, newsletters,\n * member posts, sweeps, flow steps and receipts behind one screen without\n * any of them passing anything. Built from the two documents read above:\n * the screen costs no read of its own.\n */\n const verdict: SendingIdentityVerdict = {\n ...resolved,\n workspace: sendingWorkspaceFor({\n hostId: id,\n orgId: owner?.orgId ?? null,\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n host: snapshot?.exists\n ? ((snapshot.data() as Record<string, unknown>) ?? null)\n : null,\n }),\n /*\n * THE NAME THE SITE SENDS UNDER (AGL-3442), from the same two documents,\n * so it costs no read. `sendEmail` puts it where a sender passed the\n * org's branding default; the address above is untouched.\n */\n ...siteSenderNames({\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n host: snapshot?.exists\n ? ((snapshot.data() as Record<string, unknown>) ?? null)\n : null,\n }),\n }\n\n cache?.set(id, verdict)\n return verdict\n}\n\n/**\n * The display name one site's mail leaves under, and the org default it\n * replaces (AGL-3442).\n *\n * The site's business name — what its emails render as\n * `{{host.businessName}}`, which is its SEO entity name or else its display\n * name — and the org's branding default when the site has neither, which is\n * \"Aglyn\" for an org without white-label. The name sent is flattened as every\n * sender name is, because it reaches a `From:` header and the site's owner\n * typed it. The default is kept exactly as `resolveBrandingProfile` returns\n * it, because `sendEmail` recognizes it by comparing it with what a sender\n * passed, and senders pass that value unchanged.\n */\nexport function siteSenderNames(input: {\n org: Record<string, unknown> | null\n host: Record<string, unknown> | null\n}): Pick<SendingIdentityVerdict, 'fromName' | 'brandFromName'> {\n const brandFromName = String(\n resolveBrandingProfile(input.org as never).fromName ?? '',\n ).trim()\n const siteName = headerSafeText(\n resolveHostToken('businessName', input.host as never),\n SENDING_FROM_NAME_MAX,\n )\n return {\n fromName: siteName || headerSafeText(brandFromName, SENDING_FROM_NAME_MAX),\n brandFromName,\n }\n}\n\n/**\n * The verdict a suspended site's mail gets: a refusal, whatever identity the\n * site would otherwise have sent on. `sendEmail` reads it and answers\n * `suspended` without calling the provider.\n */\nexport function suspendedSendingIdentity(\n scope: string | null | undefined,\n): SendingIdentityVerdict {\n const what =\n scope === 'platform'\n ? 'Sending is paused platform-wide'\n : scope === 'org'\n ? 'This workspace is suspended'\n : 'This site is suspended'\n const message = `${what}, so no email is sent on its behalf until the suspension is lifted.`\n return {\n from: null,\n source: null,\n domain: null,\n summary: message,\n refusal: {\n code: 'workspace-suspended',\n domain: null,\n message,\n missing: [],\n },\n }\n}\n\n/** The record keys a surface highlights as outstanding. */\nexport function outstandingSendingRecords(\n record: SendingDomainRecord,\n): SendingDnsRecord[] {\n const missing = new Set(record?.lastMissing ?? [])\n if (!missing.size) return []\n return sendingDnsRecords(record).filter((entry) =>\n missing.has(sendingRecordKey(entry)),\n )\n}\n"],"names":["assessDmarc","assessSendingRecords","headerSafeText","isPlatformSendingDomain","isSharedSendingDomain","normalizeLocalPart","normalizeSendingDomain","resolveSendingIdentity","safeProviderDetail","SENDING_FROM_NAME_MAX","SENDING_SUBDOMAIN","sendingDnsRecords","sendingTrackingHost","sendingDomainRequiredRecords","sendingRecordKey","sharedTenantSendingFrom","validateSendingDomain","lockdownMode","resolveBrandingProfile","resolveHostToken","firebaseAdmin","lookupCaa","lookupMx","lookupTxt","getOrgForHost","getSiteLockdown","sendingWorkspaceFor","flagLookalikeDomain","firestore","app","SENDING_DOMAINS_COLLECTION","domainRef","orgId","domain","collection","doc","sendingDkimSelector","safe","String","toLowerCase","replace","slice","readSendingDomainRecord","snapshot","data","exists","id","status","dkimSelector","dkimPublicKey","returnPathHost","providerDomainId","trackingTarget","createdAtMs","Number","verifiedAtMs","lastCheckedAtMs","lastIssueError","lastIssueAtMs","lastMissing","Array","isArray","requestSendingDomain","options","error","record","ref","existing","get","Date","now","set","merge","kind","hostId","recordIssuedSendingDomain","key","trim","held","selector","issued","some","entry","purpose","FieldValue","delete","recordSendingDomainIssueFailure","detail","getSendingDomain","rawDomain","records","dmarc","listSendingDomains","docs","map","filter","Boolean","readDmarcPolicy","lookup","answered","holdTrackedSendingDomain","windowMs","catch","until","nowMs","trackingRetentionUntilMs","readTrackingCaaNeed","authority","ca","labels","split","index","length","name","join","issuers","test","includes","probeSendingRecords","sendHost","dkimHost","spf","dkim","mx","Promise","all","spfTxt","dkimTxt","conclusive","verifySendingDomain","missing","inconclusive","verdict","verified","releaseSendingDomain","undefined","resolveHostSendingIdentity","platformFrom","process","env","USAGE_EMAIL_FROM","selectedDomain","sharedFrom","hostSharedFrom","poolMember","selection","audience","platformIssued","localPart","selectedLocalPart","pinned","shared","at","lastIndexOf","hostSendingIdentity","cache","hit","owner","lock","org","host","suspended","suspendedSendingIdentity","scope","resolved","workspace","siteSenderNames","input","brandFromName","fromName","siteName","what","message","from","source","summary","refusal","code","outstandingSendingRecords","Set","size","has"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GAED,SACEA,WAAW,EACXC,oBAAoB,EACpBC,cAAc,EACdC,uBAAuB,EACvBC,qBAAqB,EACrBC,kBAAkB,EAClBC,sBAAsB,EACtBC,sBAAsB,EACtBC,kBAAkB,EAClBC,qBAAqB,EACrBC,iBAAiB,EACjBC,iBAAiB,EACjBC,mBAAmB,EACnBC,4BAA4B,EAC5BC,gBAAgB,EAChBC,uBAAuB,EACvBC,qBAAqB,QAQhB,2BAA0B;AACjC,SACEC,YAAY,EACZC,sBAAsB,EACtBC,gBAAgB,QACX,sBAAqB;AAC5B,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,SAAS,EAAEC,QAAQ,EAAEC,SAAS,QAAQ,iBAAa;AAC5D,SAASC,aAAa,QAAQ,qBAAiB;AAC/C,SAASC,eAAe,QAAQ,6BAAyB;AACzD,2EAA2E;AAC3E,2EAA2E;AAC3E,2EAA2E;AAC3E,SAASC,mBAAmB,QAAQ,4BAAwB;AAC5D,SAASC,mBAAmB,QAAQ,0BAAsB;AAE1D,MAAMC,YAAY,IAAMR,cAAcS,GAAG,GAAGD,SAAS;AAErD,wCAAwC,GACxC,OAAO,MAAME,6BAA6B,iBAAgB;AAE1D,MAAMC,YAAY,CAACC,OAAeC,SAChCL,YACGM,UAAU,CAAC,QACXC,GAAG,CAACH,OACJE,UAAU,CAACJ,4BACXK,GAAG,CAACF;AAET;;;;;;;CAOC,GACD,OAAO,SAASG,oBAAoBJ,KAAa;IAC/C,MAAMK,OAAOC,OAAON,gBAAAA,QAAS,IAC1BO,WAAW,GACXC,OAAO,CAAC,cAAc,IACtBC,KAAK,CAAC,GAAG;IACZ,OAAO,CAAC,KAAK,EAAEJ,OAAO,CAAC,CAAC,EAAEA,MAAM,GAAG,IAAI;AACzC;AAUA;;;;;;;CAOC,GACD,OAAO,SAASK,wBACdC,QAA4C;QAG/BA,gBAEIC,cACPA,cACaA,oBACNA,qBACCA,sBACEA,wBACFA,sBAIAA;IAblB,IAAI,EAACD,4BAAAA,SAAUE,MAAM,GAAE,OAAO;IAC9B,MAAMD,QAAOD,iBAAAA,SAASC,IAAI,cAAbD,iBAAmB,CAAC;IACjC,OAAO;QACLV,QAAQK,QAAOM,eAAAA,KAAKX,MAAM,YAAXW,eAAeD,SAASG,EAAE;QACzCC,MAAM,GAAEH,eAAAA,KAAKG,MAAM,YAAXH,eAAe;QACvBI,cAAcV,QAAOM,qBAAAA,KAAKI,YAAY,YAAjBJ,qBAAqB;QAC1CK,aAAa,GAAEL,sBAAAA,KAAKK,aAAa,YAAlBL,sBAAsB;QACrCM,cAAc,GAAEN,uBAAAA,KAAKM,cAAc,YAAnBN,uBAAuB;QACvCO,gBAAgB,GAAEP,yBAAAA,KAAKO,gBAAgB,YAArBP,yBAAyB;QAC3CQ,cAAc,GAAER,uBAAAA,KAAKQ,cAAc,YAAnBR,uBAAuB;QACvCS,aAAaC,OAAOV,KAAKS,WAAW,KAAK;QACzCE,cAAcD,OAAOV,KAAKW,YAAY,KAAK;QAC3CC,iBAAiBF,OAAOV,KAAKY,eAAe,KAAK;QACjDC,cAAc,GAAEb,uBAAAA,KAAKa,cAAc,YAAnBb,uBAAuB;QACvCc,eAAeJ,OAAOV,KAAKc,aAAa,KAAK;QAC7CC,aAAaC,MAAMC,OAAO,CAACjB,KAAKe,WAAW,IAAIf,KAAKe,WAAW,GAAG;IACpE;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeG,qBAAqBC,OAG1C;IACC,MAAM,EAAE9B,MAAM,EAAE+B,KAAK,EAAE,GAAGhD,sBAAsB+C,2BAAAA,QAAS9B,MAAM;IAC/D,IAAI,CAACA,QAAQ,OAAO;QAAEgC,QAAQ;QAAMD;QAAOjB,QAAQ;IAAI;IACvD,IAAI,EAACgB,2BAAAA,QAAS/B,KAAK,GAAE;QACnB,OAAO;YAAEiC,QAAQ;YAAMD,OAAO;YAAwBjB,QAAQ;QAAI;IACpE;IAEA,MAAMmB,MAAMnC,UAAUgC,QAAQ/B,KAAK,EAAEC;IACrC,MAAMkC,WAAW,MAAMD,IAAIE,GAAG;IAC9B,IAAID,SAAStB,MAAM,EAAE;QACnB,OAAO;YAAEoB,QAAQvB,wBAAwByB;YAAWH,OAAO;YAAMjB,QAAQ;QAAI;IAC/E;IAEA,MAAMkB,SAA8B;QAClChC;QACAc,QAAQ;QACRC,cAAcZ,oBAAoB2B,QAAQ/B,KAAK;QAC/CiB,eAAe;QACfC,gBAAgB;QAChBG,aAAagB,KAAKC,GAAG;IACvB;IACA,MAAMJ,IAAIK,GAAG,CAACN,QAAQ;QAAEO,OAAO;IAAK;IACpC,wEAAwE;IACxE,yEAAyE;IACzE,wEAAwE;IACxE,MAAM7C,oBAAoB;QAAE8C,MAAM;QAAWC,QAAQ;QAAM1C,OAAO+B,QAAQ/B,KAAK;QAAEC;IAAO;IACxF,OAAO;QAAEgC;QAAQD,OAAO;QAAMjB,QAAQ;IAAI;AAC5C;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,eAAe4B,0BAA0BZ,OAc/C;4BAgEgBA;IA/Df,MAAM9B,SAAS3B,uBAAuByD,2BAAAA,QAAS9B,MAAM;IACrD,MAAM2C,MAAMtC,eAAOyB,2BAAAA,QAASd,aAAa,mBAAI,IAAI4B,IAAI;IACrD,IAAI,CAAC5C,QAAQ;QACX,OAAO;YAAEgC,QAAQ;YAAMD,OAAO;YAAkBjB,QAAQ;QAAI;IAC9D;IAEA,MAAMmB,MAAMnC,UAAUgC,QAAQ/B,KAAK,EAAEC;IACrC,MAAMU,WAAW,MAAMuB,IAAIE,GAAG;IAC9B,IAAI,CAACzB,SAASE,MAAM,EAAE;QACpB,OAAO;YAAEoB,QAAQ;YAAMD,OAAO;YAA2BjB,QAAQ;QAAI;IACvE;IAEA,MAAMoB,WAAWzB,wBAAwBC;IACzC,MAAMmC,OAAOxC,gBAAO6B,4BAAAA,SAAUlB,aAAa,oBAAI,IAAI4B,IAAI;IACvD,IAAIC,QAAQA,SAASF,KAAK;QACxB,OAAO;YACLX,QAAQE;YACRH,OACE,uEACA;YACFjB,QAAQ;QACV;IACF;IAEA,MAAMgC,WACJzC,gBAAOyB,2BAAAA,QAASf,YAAY,oBAAI,IAAI6B,IAAI,OAAMV,4BAAAA,SAAUnB,YAAY,KAAI;IAC1E,MAAME,iBAAiBa,QAAQb,cAAc,KAAIiB,4BAAAA,SAAUjB,cAAc;IAEzE;;;;;;GAMC,GACD,MAAM8B,SAASnE,6BAA6B;QAC1CoB;QACAe,cAAc+B;QACd9B,eAAe2B;QACf1B;IACF;IACA,IAAI,CAAC8B,OAAOC,IAAI,CAAC,CAACC,QAAUA,MAAMC,OAAO,KAAK,SAAS;QACrD,OAAO;YACLlB,QAAQE;YACRH,OAAO;YACPjB,QAAQ;QACV;IACF;IAEA,MAAMmB,IAAIK,GAAG,CACX;QACExB,QAAQ;QACRE,eAAe2B;OACXG,WAAW;QAAE/B,cAAc+B;IAAS,IAAI,CAAC,GACzChB,QAAQb,cAAc,GACtB;QAAEA,gBAAgBa,QAAQb,cAAc;IAAC,IACzC,CAAC,GACDa,QAAQZ,gBAAgB,GACxB;QAAEA,kBAAkBb,OAAOyB,QAAQZ,gBAAgB;IAAE,IACrD,CAAC,GAIDb,QAAOyB,0BAAAA,QAAQX,cAAc,YAAtBW,0BAA0B,IAAIc,IAAI,KACzC;QAAEzB,gBAAgBd,OAAOyB,QAAQX,cAAc,EAAEyB,IAAI;IAAG,IACxD,CAAC;QACL,kEAAkE;QAClEpB,gBAAgBrC,cAAcQ,SAAS,CAACwD,UAAU,CAACC,MAAM;QAE3D;QAAEb,OAAO;IAAK;IAEhB,OAAO;QAAEP,QAAQvB,wBAAwB,MAAMwB,IAAIE,GAAG;QAAKJ,OAAO;QAAMjB,QAAQ;IAAI;AACtF;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeuC,gCAAgCvB,OAIrD;IACC,MAAM9B,SAAS3B,uBAAuByD,2BAAAA,QAAS9B,MAAM;IACrD,IAAI,EAAC8B,2BAAAA,QAAS/B,KAAK,KAAI,CAACC,QAAQ;IAChC,MAAMiC,MAAMnC,UAAUgC,QAAQ/B,KAAK,EAAEC;IACrC,IAAI,CAAC,AAAC,CAAA,MAAMiC,IAAIE,GAAG,EAAC,EAAGvB,MAAM,EAAE;IAC/B,MAAMqB,IAAIK,GAAG,CACX;QACEd,gBAAgBjD,mBAAmBuD,QAAQwB,MAAM,KAAK;QACtD7B,eAAeW,KAAKC,GAAG;IACzB,GACA;QAAEE,OAAO;IAAK;AAElB;AAUA,yEAAyE,GACzE,OAAO,eAAegB,iBACpBxD,KAAa,EACbyD,SAAiB;IAEjB,MAAMxD,SAAS3B,uBAAuBmF;IACtC,IAAI,CAACzD,SAAS,CAACC,QAAQ,OAAO;IAC9B,MAAMgC,SAASvB,wBAAwB,MAAMX,UAAUC,OAAOC,QAAQmC,GAAG;IACzE,IAAI,CAACH,QAAQ,OAAO;IACpB,OAAO;QAAEA;QAAQyB,SAAS/E,kBAAkBsD;QAAS0B,OAAO;IAAK;AACnE;AAEA,OAAO,eAAeC,mBACpB5D,KAAa;IAEb,IAAI,CAACA,OAAO,OAAO,EAAE;IACrB,MAAMW,WAAW,MAAMf,YACpBM,UAAU,CAAC,QACXC,GAAG,CAACH,OACJE,UAAU,CAACJ,4BACXsC,GAAG;IACN,OAAOzB,SAASkD,IAAI,CAACC,GAAG,CAACpD,yBAAyBqD,MAAM,CAACC;AAC3D;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeC,gBACpBR,SAAiB;IAEjB,MAAMxD,SAAS3B,uBAAuBmF;IACtC,IAAI,CAACxD,QAAQ,OAAO;IACpB,MAAMiE,SAAS,MAAM3E,UAAU,CAAC,OAAO,EAAEU,QAAQ;IACjD,4EAA4E;IAC5E,0DAA0D;IAC1D,IAAI,CAACiE,OAAOC,QAAQ,EAAE,OAAO;IAC7B,OAAOnG,YAAYkG,OAAOR,OAAO;AACnC;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeU,yBAAyBrC,OAK9C;;IACC,MAAM/B,QAAQM,eAAOyB,2BAAAA,QAAS/B,KAAK,mBAAI,IAAI6C,IAAI;IAC/C,MAAM5C,SAAS3B,uBAAuByD,2BAAAA,QAAS9B,MAAM;IACrD,IAAI,CAACD,SAAS,CAACC,UAAU8B,QAAQsC,QAAQ,IAAI,GAAG,OAAO;IAEvD,MAAMnC,MAAMnC,UAAUC,OAAOC;IAC7B,MAAMU,WAAW,MAAMuB,IAAIE,GAAG,GAAGkC,KAAK,CAAC,IAAM;IAC7C,IAAI,EAAC3D,4BAAAA,SAAUE,MAAM,GAAE,OAAO;IAE9B,MAAMiC,OAAOxB,OAAOX,SAASyB,GAAG,CAAC,gCAAgC;IACjE,IAAIU,OAAO,GAAG,OAAOA;IAErB,MAAMyB,QAAQxC,QAAQyC,KAAK,GAAGzC,QAAQsC,QAAQ;IAC9C,MAAMnC,IACHK,GAAG,CAAC;QAAEkC,0BAA0BF;IAAM,GAAG;QAAE/B,OAAO;IAAK,EACxD,qEAAqE;IACrE,oEAAoE;IACpE,+CAA+C;KAC9C8B,KAAK,CAAC,IAAM;IACf,OAAOC;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,eAAeG,oBACpBjB,SAAiB,EACjBkB,SAAiB;IAEjB,MAAM1E,SAAS3B,uBAAuBmF;IACtC,MAAMmB,KAAKtE,OAAOqE,oBAAAA,YAAa,IAAI9B,IAAI,GAAGtC,WAAW;IACrD,IAAI,CAACN,UAAU,CAAC2E,IAAI,OAAO;IAE3B;;;;GAIC,GACD,MAAMC,SAASjG,oBAAoBqB,QAAQ6E,KAAK,CAAC;IACjD,IAAK,IAAIC,QAAQ,GAAGA,QAAQF,OAAOG,MAAM,GAAG,GAAGD,SAAS,EAAG;QACzD,MAAME,OAAOJ,OAAOpE,KAAK,CAACsE,OAAOG,IAAI,CAAC;QACtC,MAAMhB,SAAS,MAAM7E,UAAU4F;QAC/B,IAAI,CAACf,OAAOC,QAAQ,EAAE,OAAO;QAC7B,IAAI,CAACD,OAAOR,OAAO,CAACsB,MAAM,EAAE;QAC5B,+DAA+D;QAC/D,MAAMG,UAAUjB,OAAOR,OAAO,CAC3BK,MAAM,CAAC,CAACb,QAAU,WAAWkC,IAAI,CAAClC,QAClCY,GAAG,CAAC,CAACZ,QAAUA,MAAM3C,WAAW;QACnC,OAAO4E,QAAQlC,IAAI,CAAC,CAACC,QAAUA,MAAMmC,QAAQ,CAAC,CAAC,CAAC,EAAET,GAAG,CAAC,CAAC,KACnD,cACA;IACN;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeU,oBACpBrD,MAA2B;;IAE3B,MAAMhC,SAAS3B,+BAAuB2D,0BAAAA,OAAQhC,MAAM,mBAAI;IACxD,MAAMsF,WAAW,GAAG7G,kBAAkB,CAAC,EAAEuB,QAAQ;IACjD,MAAMuF,WAAW,GAAGvD,OAAOjB,YAAY,CAAC,YAAY,EAAEf,QAAQ;IAC9D,MAAM,CAACwF,KAAKC,MAAMC,GAAG,GAAG,MAAMC,QAAQC,GAAG,CAAC;QACxCtG,UAAUgG;QACVhG,UAAUiG;QACVlG,SAASiG;KACV;IAED,OAAOtH,qBAAqBgE,QAAQ;QAClC6D,QAAQL,IAAI/B,OAAO;QACnBqC,SAASL,KAAKhC,OAAO;QACrBiC,IAAIA,GAAGjC,OAAO;QACd,yEAAyE;QACzE,uEAAuE;QACvE,uBAAuB;QACvBsC,YAAYP,IAAItB,QAAQ,IAAIuB,KAAKvB,QAAQ,IAAIwB,GAAGxB,QAAQ;IAC1D;AACF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAe8B,oBACpBjG,KAAa,EACbyD,SAAiB;IAOjB,MAAMxD,SAAS3B,uBAAuBmF;IACtC,IAAI,CAACzD,SAAS,CAACC,QAAQ;QACrB,OAAO;YAAEgC,QAAQ;YAAMiE,SAAS,EAAE;YAAEC,cAAc;YAAOnE,OAAO;QAAiB;IACnF;IAEA,MAAME,MAAMnC,UAAUC,OAAOC;IAC7B,MAAMgC,SAASvB,wBAAwB,MAAMwB,IAAIE,GAAG;IACpD,IAAI,CAACH,QAAQ;QACX,OAAO;YACLA,QAAQ;YACRiE,SAAS,EAAE;YACXC,cAAc;YACdnE,OAAO;QACT;IACF;IAEA,MAAMoE,UAAU,MAAMd,oBAAoBrD;IAE1C,MAAMK,MAAMD,KAAKC,GAAG;IACpB,IAAI8D,QAAQrF,MAAM,KAAK,gBAAgB;QACrC,MAAMmB,IAAIK,GAAG,CAAC;YAAEf,iBAAiBc;QAAI,GAAG;YAAEE,OAAO;QAAK;QACtD,OAAO;YAAEP;YAAQiE,SAAS,EAAE;YAAEC,cAAc;YAAMnE,OAAO;QAAK;IAChE;IAEA,MAAMqE,WAAWD,QAAQrF,MAAM,KAAK;IACpC,MAAMmB,IAAIK,GAAG,CACX;QACExB,QAAQsF,WAAW,aAAa;QAChC7E,iBAAiBc;OACb+D,WACA;QACE9E,cAAce;QACdX,aAAavC,cAAcQ,SAAS,CAACwD,UAAU,CAACC,MAAM;IACxD,IACA;QAAE1B,aAAayE,QAAQF,OAAO;IAAC,IAErC;QAAE1D,OAAO;IAAK;IAGhB,uEAAuE;IACvE,oEAAoE;IACpE,gDAAgD;IAChD,IAAI6D,UAAU;QACZ,MAAM1G,oBAAoB;YAAE8C,MAAM;YAAWC,QAAQ;YAAM1C;YAAOC;QAAO;IAC3E;IAEA,OAAO;QACLgC,QAAQvB,wBAAwB,MAAMwB,IAAIE,GAAG;QAC7C8D,SAASE,QAAQF,OAAO;QACxBC,cAAc;QACdnE,OAAO;IACT;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAesE,qBACpBtG,KAAa,EACbyD,SAAiB;IAEjB,MAAMxD,SAAS3B,uBAAuBmF;IACtC,IAAI,CAACzD,SAAS,CAACC,QAAQ;IACvB,MAAMF,UAAUC,OAAOC,QACpBoD,MAAM,GACNiB,KAAK,CAAC,IAAMiC;AACjB;AAEA;;0CAE0C,GAE1C;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,eAAeC,2BAA2BzE,OA8BhD;QAEGA,oBA6CkCA,4BACrBE;IA/Cf,MAAMwE,gBACJ1E,gBAAAA,2BAAAA,QAAS0E,YAAY,oBAAIC,QAAQC,GAAG,CAACC,gBAAgB,YAArD7E,OAAyD;IAC3D,MAAM9B,SAAS3B,gCAAuByD,2BAAAA,QAAS8E,cAAc,oBAAI;IACjE,MAAM1D,UAAUpB,2BAAAA,QAASoB,OAAO;IAChC,MAAM2D,aAAaC,eAAehF,2BAAAA,QAASW,MAAM,EAAEX,2BAAAA,QAASiF,UAAU;IAEtE,IAAI,CAAC/G,UAAU,EAAC8B,2BAAAA,QAAS/B,KAAK,GAAE;QAC9B,OAAOzB,uBAAuB;YAC5B0I,WAAW;YACXR;YACAK;YACA3D;YACA+D,UAAU;QACZ;IACF;IAEA,MAAMjF,SAASvB,wBAAwB,MAAMX,UAAUgC,QAAQ/B,KAAK,EAAEC,QAAQmC,GAAG;IAEjF;;;;;;;;;GASC,GACD,MAAM+E,iBAAiBhJ,wBAAwB8B;IAE/C;;;;;;;;;;;GAWC,GACD,MAAMgH,YAAoChF,SACtC;QACEhC,QAAQgC,OAAOhC,MAAM;QACrBc,QAAQkB,OAAOlB,MAAM;QACrBqG,WAAW/I,oBAAmB0D,6BAAAA,QAAQsF,iBAAiB,YAAzBtF,6BAA6B,OAAO;QAClEmE,OAAO,GAAEjE,sBAAAA,OAAON,WAAW,YAAlBM,sBAAsB,EAAE;QACjCkF;IACF,IACA;QAAElH;QAAQc,QAAQ;QAAUqG,WAAW;QAAIlB,SAAS,EAAE;QAAEiB;IAAe;IAE3E;;;;;;;;;GASC,GACD,OAAO5I,uBAAuB;QAC5B0I;QACAR;QACAK;QACA3D;QACA+D,UAAU;IACZ;AACF;AAEA;;;;;;;;;CASC,GACD,SAASH,eACPrE,MAAiC,EACjCsE,UAAqC;IAErC,MAAMlG,KAAKR,OAAOoC,iBAAAA,SAAU,IAAIG,IAAI;IACpC,IAAI,CAAC/B,IAAI,OAAO;IAEhB,MAAMwG,SAAShJ,uBAAuBgC,OAAO0G,qBAAAA,aAAc;IAC3D,IAAIM,UAAUlJ,sBAAsBkJ,SAAS;QAC3C,MAAMC,SAASxI,wBAAwB+B;QACvC,MAAM0G,KAAKD,OAAOE,WAAW,CAAC;QAC9B,OAAOD,KAAK,IAAI,GAAGD,OAAO9G,KAAK,CAAC,GAAG+G,IAAI,CAAC,EAAEF,QAAQ,GAAG;IACvD;IAEA,OAAOvI,wBAAwB+B;AACjC;AAEA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,eAAe4G,oBACpBhF,MAAiC,EACjCiF,KAA2C;cAkDnChH,0DAiCEA,wBAWAA;IA5FV,MAAMG,KAAKR,OAAOoC,iBAAAA,SAAU,IAAIG,IAAI;IACpC,IAAI,CAAC/B,IAAI;QACP;;;;;;;;KAQC,GACD,OAAOvC,uBAAuB;YAAE0I,WAAW;YAAMC,UAAU;QAAS;IACtE;IAEA,MAAMU,MAAMD,yBAAAA,MAAOvF,GAAG,CAACtB;IACvB,IAAI8G,KAAK,OAAOA;IAEhB,MAAMjH,WAAW,MAAMf,YACpBM,UAAU,CAAC,SACXC,GAAG,CAACW,IACJsB,GAAG,GACHkC,KAAK,CAAC,IAAM;IACf,MAAMuD,QAAQ,MAAMrI,cAAcsB,IAAIwD,KAAK,CAAC,IAAM;IAElD;;;;;;;;;;;;;;;;;;;;GAoBC,GACD,MAAMwD,OAAO,MAAMrI,gBAAgBqB,IAAIuB,KAAKC,GAAG,IAAI;QACjDyF,GAAG,UAAGF,yBAAAA,MAAOE,GAAG,mBAA4C;QAC5DC,MAAMrH,CAAAA,4BAAAA,SAAUE,MAAM,KAChBF,iBAAAA,SAASC,IAAI,cAAbD,iBAA+C,OACjD;IACN;IACA,IAAImH,QAAQ7I,aAAa6I,UAAU,QAAQ;QACzC,MAAMG,YAAYC,yBAAyBJ,KAAKK,KAAK;QACrDR,yBAAAA,MAAOpF,GAAG,CAACzB,IAAImH;QACf,OAAOA;IACT;IAEA,MAAMG,WAAW,MAAM5B,2BAA2B;QAChDxG,KAAK,WAAE6H,yBAAAA,MAAO7H,KAAK,oBAAI;QACvB6G,cAAc,WAAElG,4BAAAA,SAAUyB,GAAG,CAAC,oCAAoB;QAClDiF,iBAAiB,WAAE1G,4BAAAA,SAAUyB,GAAG,CAAC,uCAAuB;QACxDM,QAAQ5B;QACRkG,UAAU,WAAErG,4BAAAA,SAAUyB,GAAG,CAAC,wCAAwB;IACpD;IAEA;;;;;;;;GAQC,GACD,MAAMgE,UAAkC,aACnCgC;QACHC,WAAW3I,oBAAoB;YAC7BgD,QAAQ5B;YACRd,KAAK,WAAE6H,yBAAAA,MAAO7H,KAAK,oBAAI;YACvB+H,GAAG,WAAGF,yBAAAA,MAAOE,GAAG,oBAA4C;YAC5DC,MAAMrH,CAAAA,4BAAAA,SAAUE,MAAM,KAChBF,kBAAAA,SAASC,IAAI,cAAbD,kBAA+C,OACjD;QACN;OAMG2H,gBAAgB;QACjBP,GAAG,WAAGF,yBAAAA,MAAOE,GAAG,oBAA4C;QAC5DC,MAAMrH,CAAAA,4BAAAA,SAAUE,MAAM,KAChBF,kBAAAA,SAASC,IAAI,cAAbD,kBAA+C,OACjD;IACN;IAGFgH,yBAAAA,MAAOpF,GAAG,CAACzB,IAAIsF;IACf,OAAOA;AACT;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASkC,gBAAgBC,KAG/B;QAEGrJ;IADF,MAAMsJ,gBAAgBlI,QACpBpB,mCAAAA,uBAAuBqJ,MAAMR,GAAG,EAAWU,QAAQ,YAAnDvJ,mCAAuD,IACvD2D,IAAI;IACN,MAAM6F,WAAWxK,eACfiB,iBAAiB,gBAAgBoJ,MAAMP,IAAI,GAC3CvJ;IAEF,OAAO;QACLgK,UAAUC,YAAYxK,eAAesK,eAAe/J;QACpD+J;IACF;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASN,yBACdC,KAAgC;IAEhC,MAAMQ,OACJR,UAAU,aACN,oCACAA,UAAU,QACR,gCACA;IACR,MAAMS,UAAU,GAAGD,KAAK,mEAAmE,CAAC;IAC5F,OAAO;QACLE,MAAM;QACNC,QAAQ;QACR7I,QAAQ;QACR8I,SAASH;QACTI,SAAS;YACPC,MAAM;YACNhJ,QAAQ;YACR2I;YACA1C,SAAS,EAAE;QACb;IACF;AACF;AAEA,yDAAyD,GACzD,OAAO,SAASgD,0BACdjH,MAA2B;;IAE3B,MAAMiE,UAAU,IAAIiD,YAAIlH,0BAAAA,OAAQN,WAAW,mBAAI,EAAE;IACjD,IAAI,CAACuE,QAAQkD,IAAI,EAAE,OAAO,EAAE;IAC5B,OAAOzK,kBAAkBsD,QAAQ8B,MAAM,CAAC,CAACb,QACvCgD,QAAQmD,GAAG,CAACvK,iBAAiBoE;AAEjC"}
|
|
@@ -156,11 +156,16 @@ export class SsoEnforcementError extends Error {
|
|
|
156
156
|
changedUids.push(record.uid);
|
|
157
157
|
}
|
|
158
158
|
if (!dryRun && changedUids.length) {
|
|
159
|
+
var _orgSnapshot_get;
|
|
159
160
|
// Explaining it beats a mysteriously missing button on their next visit.
|
|
161
|
+
// The organization is named, because a person can belong to several, and
|
|
162
|
+
// the sign-out is said, because the refresh tokens were revoked above
|
|
163
|
+
// (AGL-3432).
|
|
164
|
+
const orgName = String((_orgSnapshot_get = orgSnapshot.get('name')) != null ? _orgSnapshot_get : '').trim();
|
|
160
165
|
await notifyUsers(changedUids, {
|
|
161
166
|
type: 'system.signInMethodRemoved',
|
|
162
|
-
title: 'Your sign-in methods changed',
|
|
163
|
-
body: 'Your organization now requires single sign-on, so other sign-in
|
|
167
|
+
title: orgName ? `${orgName} now requires single sign-on` : 'Your sign-in methods changed',
|
|
168
|
+
body: `${orgName || 'Your organization'} now requires single sign-on, so ` + 'the other sign-in methods on your account were removed and you were ' + 'signed out. Sign in with ' + `${orgName ? `${orgName}'s` : "your organization's"} single sign-on ` + 'from now on.',
|
|
164
169
|
orgId,
|
|
165
170
|
link: '/manage/user'
|
|
166
171
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sso-enforcement.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 * SSO enforcement sweep (AGL-1129): when an org turns on `sso.enforced`,\n * every account in its GCIP tenant loses any sign-in method that is not the\n * org's own IdP.\n *\n * AGL-1128 stopped SSO-governed accounts from linking NEW consumer providers.\n * This is the grandfathering half: an account that linked Google before\n * enforcement keeps a way in that the customer's IdP cannot see or revoke, and\n * a bypass that predates the switch is still a bypass.\n *\n * Runs against the TENANT pool via `authForPool` — project-level Admin SDK\n * calls cannot see, let alone mutate, these accounts (AGL-1122).\n *\n * Three properties this must have, in order of how badly they bite:\n *\n * 1. **Never unlink down to zero.** An account whose only provider is the one\n * we are about to remove would be orphaned — reachable by nobody, not even\n * the IdP. Skipped and reported, never assumed away: being in the tenant\n * does not prove the SAML link is present.\n * 2. **Revoke refresh tokens for accounts it actually changed.** Unlinking\n * does not end a live session, so without this the bypass simply has a\n * clock on it. Only for accounts that changed — a sweep that revoked\n * everyone would sign out the org's whole staff on a no-op run.\n * 3. **Idempotent.** Re-running is the natural way to catch accounts created\n * between the flag flip and the sweep, so a second run must be a no-op\n * rather than a second round of revocations.\n * 4. **Never leave the ORG with no way in** (AGL-1888). Property 1 protects\n * one account at a time and is blind to the org-level failure: strip every\n * account down to the SAML link and the pool is perfectly consistent right\n * up until the IdP stops answering, at which point nobody can sign in and\n * we cannot let them back in either. The `aglyn-org-y5v14` tenant is in\n * that state today. So enforcement REFUSES unless the org keeps a way in\n * that does\n * not depend on the IdP: either a DESIGNATED break-glass account inside\n * the pool, or an org owner who lives OUTSIDE it\n * ({@link findBreakGlassOrgOwners}). The second is what makes the first\n * satisfiable at all — nothing inside a pool we provisioned can hold a\n * password, so for a while this guard could only ever say no.\n */\n\nimport type { UserRecord } from 'firebase-admin/auth'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authForPool } from './auth-pools'\nimport {\n findBreakGlassOrgOwners,\n type SsoBreakGlassOwner,\n} from './sso-break-glass-owners'\nimport { invalidateTokenRevocationCache } from './token-revocation'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyUsers } from './notifications'\nimport { addAdminAudit } from './admin-audit-write'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** How many tenant accounts one sweep will walk. Enterprise pools are small. */\nconst TENANT_USER_CAP = 1000\n\nexport interface SsoEnforcementOptions {\n /** Staff uid recorded on the audit rows; `system` for an automated run. */\n actorUid?: string\n /** Report what would change without touching anything. */\n dryRun?: boolean\n /**\n * Run even when `sso.enforced` is false. ONLY for a rehearsal against a\n * throwaway account — the flag is the customer's instruction, and acting\n * without it removes sign-in methods nobody asked us to remove.\n */\n force?: boolean\n}\n\n/** Why an account with removable providers was left alone. */\nexport type SsoEnforcementSkip = 'would-orphan' | 'break-glass'\n\nexport interface SsoEnforcementAccount {\n uid: string\n email: string | null\n /** Providers removed (or that would be, in a dry run). */\n unlinked: string[]\n /** Providers left in place. */\n kept: string[]\n /** Why an account with removable providers was left alone. */\n skipped?: SsoEnforcementSkip\n}\n\n/**\n * Whether this org would still have a way in if its IdP stopped answering\n * (AGL-1888).\n */\nexport interface SsoLockoutAssessment {\n /**\n * True when the org keeps at least one way in that its IdP does not\n * mediate: a DESIGNATED break-glass account inside the pool, or an org\n * owner outside it.\n */\n safe: boolean\n /** Designated break-glass uids that actually retain a non-IdP credential. */\n retainedBy: string[]\n /**\n * Designated uids that do NOT protect anybody — either absent from the pool,\n * or holding nothing but the IdP link. Named rather than silently ignored:\n * a designation that protects nothing is worse than none, because it reads\n * as protection.\n */\n ineffective: string[]\n /**\n * Org owners in the PROJECT pool (AGL-1888 option (a)). The sweep cannot\n * see these accounts, so an IdP that stops answering cannot lock them out.\n * Named, not counted: the admin has to recognise the person.\n */\n ownersOutsidePool: SsoBreakGlassOwner[]\n /**\n * The owner lookup could not be completed. `ownersOutsidePool` is then\n * INCOMPLETE rather than empty, so the refusal must say we could not check\n * instead of asserting the org has nobody. Never makes an org safe.\n */\n ownerLookupFailed: boolean\n}\n\nexport interface SsoEnforcementResult {\n orgId: string\n tenantId: string\n providerId: string\n dryRun: boolean\n /** Accounts walked. */\n scanned: number\n /** Accounts whose providers changed (0 on a converged re-run). */\n changed: number\n accounts: SsoEnforcementAccount[]\n /** True when the tenant holds more accounts than one sweep walks. */\n truncated: boolean\n /**\n * Whether the org retains a way in that does not depend on its IdP\n * (AGL-1888). Present on the DRY RUN too — that is the point, since the\n * rehearsal is where an org is supposed to discover it needs one.\n */\n lockout: SsoLockoutAssessment\n}\n\nexport class SsoEnforcementError extends Error {}\n\n/**\n * Strip non-IdP sign-in methods from every account in an org's SSO tenant.\n *\n * Throws when the org has no active SSO config, or when `sso.enforced` is\n * false without `force` — enforcement is the customer's switch, and running\n * this without it would be us deciding to remove their people's sign-in\n * methods on our own.\n */\nexport async function enforceSsoSignInMethods(\n orgId: string,\n options: SsoEnforcementOptions = {},\n): Promise<SsoEnforcementResult> {\n const { actorUid = 'system', dryRun = false, force = false } = options\n const db = firestore()\n const orgSnapshot = await db.collection('orgs').doc(orgId).get()\n if (!orgSnapshot.exists) {\n throw new SsoEnforcementError(`No such organization: ${orgId}`)\n }\n const sso = orgSnapshot.get('sso') as\n | {\n tenantId?: string\n providerId?: string\n enforced?: boolean\n status?: string\n /** Accounts the org designated to keep a non-IdP way in (AGL-1888). */\n breakGlassUids?: unknown\n }\n | undefined\n const tenantId = sso?.tenantId\n const providerId = sso?.providerId\n if (!tenantId || !providerId) {\n throw new SsoEnforcementError(`${orgId} has no SSO tenant configured`)\n }\n if (sso?.status !== 'active') {\n throw new SsoEnforcementError(\n `${orgId} SSO status is \"${sso?.status ?? 'unset'}\", not active`,\n )\n }\n if (!sso?.enforced && !force) {\n throw new SsoEnforcementError(\n `${orgId} has sso.enforced = false — nothing to enforce`,\n )\n }\n\n const breakGlassUids = Array.isArray(sso?.breakGlassUids)\n ? (sso.breakGlassUids as unknown[]).map(String).filter(Boolean)\n : []\n\n const pool = authForPool(tenantId)\n const page = await pool.listUsers(TENANT_USER_CAP)\n const accounts: SsoEnforcementAccount[] = []\n const changedUids: string[] = []\n const breakGlass = new Set(breakGlassUids)\n\n for (const record of page.users) {\n const summary = planAccount(record, providerId, breakGlass)\n accounts.push(summary)\n }\n\n // The half the pool cannot supply (AGL-1888 option (a)). Run for the DRY\n // RUN too: the rehearsal is where an org learns whether it may enforce, and\n // a rehearsal that skipped this would report a lockout risk the real run\n // then disagrees with.\n const ownerLookup = await findBreakGlassOrgOwners(orgId, providerId, tenantId)\n const lockout = assessSsoLockoutRisk(\n accounts,\n providerId,\n breakGlassUids,\n ownerLookup.owners,\n ownerLookup.unavailable,\n )\n\n // THE PRE-FLIGHT (AGL-1888). Planned first, refused before a single\n // `updateUser` — this is the whole reason the plan is computed separately\n // from being carried out. Half a sweep is the worst outcome available: the\n // accounts already stripped are stripped, and the org is locked out with no\n // record of how far it got.\n //\n // The dry run is exempt, and has to be. The rehearsal is exactly how an org\n // FINDS OUT it needs a break-glass account, so refusing to rehearse would\n // leave them guessing at the requirement they are being held to.\n //\n // No override parameter. An `acknowledgeLockoutRisk: true` a caller can pass\n // is not a guard, it is a spelling of the guard being off — and the caller\n // that would pass it is the one already convinced enforcement is fine.\n if (!dryRun && !lockout.safe) {\n throw new SsoEnforcementError(SSO_LOCKOUT_REFUSAL)\n }\n\n for (const summary of accounts) {\n const record = page.users.find((user) => user.uid === summary.uid)\n if (!record) continue\n if (!summary.unlinked.length || summary.skipped) continue\n if (!dryRun) {\n await pool.updateUser(record.uid, {\n providersToUnlink: summary.unlinked,\n })\n // Only for accounts that actually changed (property 2 above). An\n // existing session outlives the unlink otherwise.\n await pool.revokeRefreshTokens(record.uid)\n // This process refuses the old token NOW (AGL-1881); every other one\n // converges within TOKEN_REVOCATION_TTL_MS.\n invalidateTokenRevocationCache(record.uid, tenantId)\n await addAdminAudit(db, {\n actorUid,\n action: 'org.sso.enforceSignInMethods',\n target: `users/${record.uid}`,\n before: { providers: providerIdsOf(record) },\n after: {\n providers: summary.kept,\n orgId,\n tenantId,\n unlinked: summary.unlinked,\n tokensRevoked: true,\n },\n at: FieldValue.serverTimestamp(),\n })\n }\n changedUids.push(record.uid)\n }\n\n if (!dryRun && changedUids.length) {\n // Explaining it beats a mysteriously missing button on their next visit.\n await notifyUsers(changedUids, {\n type: 'system.signInMethodRemoved',\n title: 'Your sign-in methods changed',\n body:\n 'Your organization now requires single sign-on, so other sign-in ' +\n 'methods have been removed from your account. Sign in through your ' +\n 'organization instead.',\n orgId,\n link: '/manage/user',\n })\n }\n\n return {\n orgId,\n tenantId,\n providerId,\n dryRun,\n scanned: page.users.length,\n changed: changedUids.length,\n accounts,\n truncated: Boolean(page.pageToken),\n lockout,\n }\n}\n\nconst providerIdsOf = (record: UserRecord): string[] =>\n (record.providerData ?? [])\n .map((info) => info?.providerId)\n .filter((id): id is string => Boolean(id))\n\n/**\n * What one account loses, decided without mutating anything so the dry run\n * and the real run cannot disagree.\n *\n * `password` counts as a provider to strip: an email/password credential on a\n * governed account is exactly the sort of standing bypass enforcement is\n * bought to remove, and it is not the org's IdP.\n *\n * @param breakGlass uids the org has DESIGNATED to keep their other sign-in\n * methods (AGL-1888). Deliberately a parameter rather than a lookup:\n * this function is the whole decision and stays pure, so the rehearsal\n * and the real run cannot diverge on it either.\n */\nexport function planAccount(\n record: UserRecord,\n providerId: string,\n breakGlass: ReadonlySet<string> = new Set(),\n): SsoEnforcementAccount {\n const providers = providerIdsOf(record)\n const unlinked = providers.filter((id) => id !== providerId)\n const kept = providers.filter((id) => id === providerId)\n const summary: SsoEnforcementAccount = {\n uid: record.uid,\n email: record.email ?? null,\n unlinked,\n kept,\n }\n // Property 1: an account with nothing but removable providers keeps them.\n // It is misconfigured, not a bypass to close, and orphaning it would turn a\n // security tightening into a lockout with no way back.\n if (unlinked.length && !kept.length) {\n return { ...summary, unlinked: [], kept: providers, skipped: 'would-orphan' }\n }\n // Property 4 (AGL-1888): a designated break-glass account keeps everything.\n //\n // This IS a standing bypass of SSO enforcement, and it is meant to be —\n // that is what makes it a way back in when the IdP stops answering. The\n // alternative is the state the `aglyn-org-y5v14` tenant is in today: every\n // account in the pool holds nothing but a SAML link, and a lapsed\n // certificate or a deleted SAML app locks the organization out of itself\n // permanently.\n //\n // Checked AFTER the orphan rule so the two cannot disagree about an account\n // that is both, and it changes nothing for a break-glass account that has\n // no removable provider anyway — `skipped` marks a decision, so it is set\n // only where there was something to decide.\n if (unlinked.length && breakGlass.has(record.uid)) {\n return { ...summary, unlinked: [], kept: providers, skipped: 'break-glass' }\n }\n return summary\n}\n\n/**\n * Would this org still have a way in after the sweep? (AGL-1888)\n *\n * Pure, and over the PLANNED accounts rather than the raw records, so it\n * answers the question about the state enforcement is actually about to\n * create rather than the one it is in now.\n *\n * ONLY A DESIGNATED ACCOUNT COUNTS. An account that happens to retain a\n * password because it is misconfigured — the `would-orphan` case, which has no\n * IdP link at all — is not break-glass: nobody chose it, it may be a stale\n * record or a service account, and an org protected by an accident it does not\n * know about is not protected. Requiring the designation is what makes this a\n * control rather than a coincidence.\n *\n * A designation only counts when the account actually holds something OTHER\n * than the org's IdP. Naming an account whose sole credential is the SAML link\n * is the most natural way to get this wrong — it looks like a break-glass\n * account and provides nothing, because it fails in exactly the situation it\n * exists for.\n *\n * THE SECOND WAY IN IS NOT IN THE POOL AT ALL (AGL-1888 option (a)). An org\n * owner holding a project-level credential is invisible to the sweep — the\n * enforcement pass lists `authForPool(tenantId)` and never touches the\n * project pool — so a lapsed certificate cannot reach them. No designation is\n * required for those: nobody has to be told to keep the key they already\n * hold, and requiring a tick would put the requirement back out of reach for\n * exactly the org that already satisfies it. Whether such an owner exists is\n * decided by {@link findBreakGlassOrgOwners}, which is where the seven\n * conditions live; this function only counts what it is handed, so that the\n * rehearsal and the real run cannot disagree.\n *\n * `ownerLookupFailed` deliberately does NOT make an org safe. It exists so a\n * refusal can distinguish \"you have nobody\" from \"we could not check\" — an\n * error swallowed into an empty list renders as a measured zero, and nothing\n * about it looks wrong.\n */\nexport function assessSsoLockoutRisk(\n accounts: readonly SsoEnforcementAccount[],\n providerId: string,\n breakGlass: readonly string[],\n ownersOutsidePool: readonly SsoBreakGlassOwner[] = [],\n ownerLookupFailed = false,\n): SsoLockoutAssessment {\n const designated = [...new Set(breakGlass.filter(Boolean))]\n const byUid = new Map(accounts.map((account) => [account.uid, account]))\n const retainedBy: string[] = []\n const ineffective: string[] = []\n for (const uid of designated) {\n const account = byUid.get(uid)\n const protects = Boolean(\n account?.kept.some((id) => id !== providerId),\n )\n if (protects) retainedBy.push(uid)\n else ineffective.push(uid)\n }\n const owners = [...ownersOutsidePool]\n return {\n safe: retainedBy.length > 0 || owners.length > 0,\n retainedBy,\n ineffective,\n ownersOutsidePool: owners,\n ownerLookupFailed,\n }\n}\n\n/**\n * The refusal an org sees when enforcing would lock it out of itself.\n *\n * Says what to do, because a refusal that only says no sends someone to turn\n * the guard off. Deliberately free of a product name — this string is read by\n * self-host operators too.\n */\nexport const SSO_LOCKOUT_REFUSAL =\n 'Enforcing single sign-on would remove every sign-in method that does not ' +\n 'go through your identity provider, and this organization would be left ' +\n 'with no way in that survives your identity provider failing. If its ' +\n 'certificate lapses or its application is removed, nobody would be able ' +\n 'to sign in and we could not let you back in either. Give the ' +\n 'organization one of two things first: an owner who signs in outside your ' +\n 'identity pool, or a designated break-glass account inside it that keeps ' +\n 'a password.'\n\nexport default enforceSsoSignInMethods\n"],"names":["FieldValue","authForPool","findBreakGlassOrgOwners","invalidateTokenRevocationCache","firebaseAdmin","notifyUsers","addAdminAudit","firestore","app","TENANT_USER_CAP","SsoEnforcementError","Error","enforceSsoSignInMethods","orgId","options","actorUid","dryRun","force","db","orgSnapshot","collection","doc","get","exists","sso","tenantId","providerId","status","enforced","breakGlassUids","Array","isArray","map","String","filter","Boolean","pool","page","listUsers","accounts","changedUids","breakGlass","Set","record","users","summary","planAccount","push","ownerLookup","lockout","assessSsoLockoutRisk","owners","unavailable","safe","SSO_LOCKOUT_REFUSAL","find","user","uid","unlinked","length","skipped","updateUser","providersToUnlink","revokeRefreshTokens","action","target","before","providers","providerIdsOf","after","kept","tokensRevoked","at","serverTimestamp","type","title","body","link","scanned","changed","truncated","pageToken","providerData","info","id","email","has","ownersOutsidePool","ownerLookupFailed","designated","byUid","Map","account","retainedBy","ineffective","protects","some"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sso-enforcement.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 * SSO enforcement sweep (AGL-1129): when an org turns on `sso.enforced`,\n * every account in its GCIP tenant loses any sign-in method that is not the\n * org's own IdP.\n *\n * AGL-1128 stopped SSO-governed accounts from linking NEW consumer providers.\n * This is the grandfathering half: an account that linked Google before\n * enforcement keeps a way in that the customer's IdP cannot see or revoke, and\n * a bypass that predates the switch is still a bypass.\n *\n * Runs against the TENANT pool via `authForPool` — project-level Admin SDK\n * calls cannot see, let alone mutate, these accounts (AGL-1122).\n *\n * Three properties this must have, in order of how badly they bite:\n *\n * 1. **Never unlink down to zero.** An account whose only provider is the one\n * we are about to remove would be orphaned — reachable by nobody, not even\n * the IdP. Skipped and reported, never assumed away: being in the tenant\n * does not prove the SAML link is present.\n * 2. **Revoke refresh tokens for accounts it actually changed.** Unlinking\n * does not end a live session, so without this the bypass simply has a\n * clock on it. Only for accounts that changed — a sweep that revoked\n * everyone would sign out the org's whole staff on a no-op run.\n * 3. **Idempotent.** Re-running is the natural way to catch accounts created\n * between the flag flip and the sweep, so a second run must be a no-op\n * rather than a second round of revocations.\n * 4. **Never leave the ORG with no way in** (AGL-1888). Property 1 protects\n * one account at a time and is blind to the org-level failure: strip every\n * account down to the SAML link and the pool is perfectly consistent right\n * up until the IdP stops answering, at which point nobody can sign in and\n * we cannot let them back in either. The `aglyn-org-y5v14` tenant is in\n * that state today. So enforcement REFUSES unless the org keeps a way in\n * that does\n * not depend on the IdP: either a DESIGNATED break-glass account inside\n * the pool, or an org owner who lives OUTSIDE it\n * ({@link findBreakGlassOrgOwners}). The second is what makes the first\n * satisfiable at all — nothing inside a pool we provisioned can hold a\n * password, so for a while this guard could only ever say no.\n */\n\nimport type { UserRecord } from 'firebase-admin/auth'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authForPool } from './auth-pools'\nimport {\n findBreakGlassOrgOwners,\n type SsoBreakGlassOwner,\n} from './sso-break-glass-owners'\nimport { invalidateTokenRevocationCache } from './token-revocation'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyUsers } from './notifications'\nimport { addAdminAudit } from './admin-audit-write'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** How many tenant accounts one sweep will walk. Enterprise pools are small. */\nconst TENANT_USER_CAP = 1000\n\nexport interface SsoEnforcementOptions {\n /** Staff uid recorded on the audit rows; `system` for an automated run. */\n actorUid?: string\n /** Report what would change without touching anything. */\n dryRun?: boolean\n /**\n * Run even when `sso.enforced` is false. ONLY for a rehearsal against a\n * throwaway account — the flag is the customer's instruction, and acting\n * without it removes sign-in methods nobody asked us to remove.\n */\n force?: boolean\n}\n\n/** Why an account with removable providers was left alone. */\nexport type SsoEnforcementSkip = 'would-orphan' | 'break-glass'\n\nexport interface SsoEnforcementAccount {\n uid: string\n email: string | null\n /** Providers removed (or that would be, in a dry run). */\n unlinked: string[]\n /** Providers left in place. */\n kept: string[]\n /** Why an account with removable providers was left alone. */\n skipped?: SsoEnforcementSkip\n}\n\n/**\n * Whether this org would still have a way in if its IdP stopped answering\n * (AGL-1888).\n */\nexport interface SsoLockoutAssessment {\n /**\n * True when the org keeps at least one way in that its IdP does not\n * mediate: a DESIGNATED break-glass account inside the pool, or an org\n * owner outside it.\n */\n safe: boolean\n /** Designated break-glass uids that actually retain a non-IdP credential. */\n retainedBy: string[]\n /**\n * Designated uids that do NOT protect anybody — either absent from the pool,\n * or holding nothing but the IdP link. Named rather than silently ignored:\n * a designation that protects nothing is worse than none, because it reads\n * as protection.\n */\n ineffective: string[]\n /**\n * Org owners in the PROJECT pool (AGL-1888 option (a)). The sweep cannot\n * see these accounts, so an IdP that stops answering cannot lock them out.\n * Named, not counted: the admin has to recognise the person.\n */\n ownersOutsidePool: SsoBreakGlassOwner[]\n /**\n * The owner lookup could not be completed. `ownersOutsidePool` is then\n * INCOMPLETE rather than empty, so the refusal must say we could not check\n * instead of asserting the org has nobody. Never makes an org safe.\n */\n ownerLookupFailed: boolean\n}\n\nexport interface SsoEnforcementResult {\n orgId: string\n tenantId: string\n providerId: string\n dryRun: boolean\n /** Accounts walked. */\n scanned: number\n /** Accounts whose providers changed (0 on a converged re-run). */\n changed: number\n accounts: SsoEnforcementAccount[]\n /** True when the tenant holds more accounts than one sweep walks. */\n truncated: boolean\n /**\n * Whether the org retains a way in that does not depend on its IdP\n * (AGL-1888). Present on the DRY RUN too — that is the point, since the\n * rehearsal is where an org is supposed to discover it needs one.\n */\n lockout: SsoLockoutAssessment\n}\n\nexport class SsoEnforcementError extends Error {}\n\n/**\n * Strip non-IdP sign-in methods from every account in an org's SSO tenant.\n *\n * Throws when the org has no active SSO config, or when `sso.enforced` is\n * false without `force` — enforcement is the customer's switch, and running\n * this without it would be us deciding to remove their people's sign-in\n * methods on our own.\n */\nexport async function enforceSsoSignInMethods(\n orgId: string,\n options: SsoEnforcementOptions = {},\n): Promise<SsoEnforcementResult> {\n const { actorUid = 'system', dryRun = false, force = false } = options\n const db = firestore()\n const orgSnapshot = await db.collection('orgs').doc(orgId).get()\n if (!orgSnapshot.exists) {\n throw new SsoEnforcementError(`No such organization: ${orgId}`)\n }\n const sso = orgSnapshot.get('sso') as\n | {\n tenantId?: string\n providerId?: string\n enforced?: boolean\n status?: string\n /** Accounts the org designated to keep a non-IdP way in (AGL-1888). */\n breakGlassUids?: unknown\n }\n | undefined\n const tenantId = sso?.tenantId\n const providerId = sso?.providerId\n if (!tenantId || !providerId) {\n throw new SsoEnforcementError(`${orgId} has no SSO tenant configured`)\n }\n if (sso?.status !== 'active') {\n throw new SsoEnforcementError(\n `${orgId} SSO status is \"${sso?.status ?? 'unset'}\", not active`,\n )\n }\n if (!sso?.enforced && !force) {\n throw new SsoEnforcementError(\n `${orgId} has sso.enforced = false — nothing to enforce`,\n )\n }\n\n const breakGlassUids = Array.isArray(sso?.breakGlassUids)\n ? (sso.breakGlassUids as unknown[]).map(String).filter(Boolean)\n : []\n\n const pool = authForPool(tenantId)\n const page = await pool.listUsers(TENANT_USER_CAP)\n const accounts: SsoEnforcementAccount[] = []\n const changedUids: string[] = []\n const breakGlass = new Set(breakGlassUids)\n\n for (const record of page.users) {\n const summary = planAccount(record, providerId, breakGlass)\n accounts.push(summary)\n }\n\n // The half the pool cannot supply (AGL-1888 option (a)). Run for the DRY\n // RUN too: the rehearsal is where an org learns whether it may enforce, and\n // a rehearsal that skipped this would report a lockout risk the real run\n // then disagrees with.\n const ownerLookup = await findBreakGlassOrgOwners(orgId, providerId, tenantId)\n const lockout = assessSsoLockoutRisk(\n accounts,\n providerId,\n breakGlassUids,\n ownerLookup.owners,\n ownerLookup.unavailable,\n )\n\n // THE PRE-FLIGHT (AGL-1888). Planned first, refused before a single\n // `updateUser` — this is the whole reason the plan is computed separately\n // from being carried out. Half a sweep is the worst outcome available: the\n // accounts already stripped are stripped, and the org is locked out with no\n // record of how far it got.\n //\n // The dry run is exempt, and has to be. The rehearsal is exactly how an org\n // FINDS OUT it needs a break-glass account, so refusing to rehearse would\n // leave them guessing at the requirement they are being held to.\n //\n // No override parameter. An `acknowledgeLockoutRisk: true` a caller can pass\n // is not a guard, it is a spelling of the guard being off — and the caller\n // that would pass it is the one already convinced enforcement is fine.\n if (!dryRun && !lockout.safe) {\n throw new SsoEnforcementError(SSO_LOCKOUT_REFUSAL)\n }\n\n for (const summary of accounts) {\n const record = page.users.find((user) => user.uid === summary.uid)\n if (!record) continue\n if (!summary.unlinked.length || summary.skipped) continue\n if (!dryRun) {\n await pool.updateUser(record.uid, {\n providersToUnlink: summary.unlinked,\n })\n // Only for accounts that actually changed (property 2 above). An\n // existing session outlives the unlink otherwise.\n await pool.revokeRefreshTokens(record.uid)\n // This process refuses the old token NOW (AGL-1881); every other one\n // converges within TOKEN_REVOCATION_TTL_MS.\n invalidateTokenRevocationCache(record.uid, tenantId)\n await addAdminAudit(db, {\n actorUid,\n action: 'org.sso.enforceSignInMethods',\n target: `users/${record.uid}`,\n before: { providers: providerIdsOf(record) },\n after: {\n providers: summary.kept,\n orgId,\n tenantId,\n unlinked: summary.unlinked,\n tokensRevoked: true,\n },\n at: FieldValue.serverTimestamp(),\n })\n }\n changedUids.push(record.uid)\n }\n\n if (!dryRun && changedUids.length) {\n // Explaining it beats a mysteriously missing button on their next visit.\n // The organization is named, because a person can belong to several, and\n // the sign-out is said, because the refresh tokens were revoked above\n // (AGL-3432).\n const orgName = String(orgSnapshot.get('name') ?? '').trim()\n await notifyUsers(changedUids, {\n type: 'system.signInMethodRemoved',\n title: orgName\n ? `${orgName} now requires single sign-on`\n : 'Your sign-in methods changed',\n body:\n `${orgName || 'Your organization'} now requires single sign-on, so ` +\n 'the other sign-in methods on your account were removed and you were ' +\n 'signed out. Sign in with ' +\n `${orgName ? `${orgName}'s` : \"your organization's\"} single sign-on ` +\n 'from now on.',\n orgId,\n link: '/manage/user',\n })\n }\n\n return {\n orgId,\n tenantId,\n providerId,\n dryRun,\n scanned: page.users.length,\n changed: changedUids.length,\n accounts,\n truncated: Boolean(page.pageToken),\n lockout,\n }\n}\n\nconst providerIdsOf = (record: UserRecord): string[] =>\n (record.providerData ?? [])\n .map((info) => info?.providerId)\n .filter((id): id is string => Boolean(id))\n\n/**\n * What one account loses, decided without mutating anything so the dry run\n * and the real run cannot disagree.\n *\n * `password` counts as a provider to strip: an email/password credential on a\n * governed account is exactly the sort of standing bypass enforcement is\n * bought to remove, and it is not the org's IdP.\n *\n * @param breakGlass uids the org has DESIGNATED to keep their other sign-in\n * methods (AGL-1888). Deliberately a parameter rather than a lookup:\n * this function is the whole decision and stays pure, so the rehearsal\n * and the real run cannot diverge on it either.\n */\nexport function planAccount(\n record: UserRecord,\n providerId: string,\n breakGlass: ReadonlySet<string> = new Set(),\n): SsoEnforcementAccount {\n const providers = providerIdsOf(record)\n const unlinked = providers.filter((id) => id !== providerId)\n const kept = providers.filter((id) => id === providerId)\n const summary: SsoEnforcementAccount = {\n uid: record.uid,\n email: record.email ?? null,\n unlinked,\n kept,\n }\n // Property 1: an account with nothing but removable providers keeps them.\n // It is misconfigured, not a bypass to close, and orphaning it would turn a\n // security tightening into a lockout with no way back.\n if (unlinked.length && !kept.length) {\n return { ...summary, unlinked: [], kept: providers, skipped: 'would-orphan' }\n }\n // Property 4 (AGL-1888): a designated break-glass account keeps everything.\n //\n // This IS a standing bypass of SSO enforcement, and it is meant to be —\n // that is what makes it a way back in when the IdP stops answering. The\n // alternative is the state the `aglyn-org-y5v14` tenant is in today: every\n // account in the pool holds nothing but a SAML link, and a lapsed\n // certificate or a deleted SAML app locks the organization out of itself\n // permanently.\n //\n // Checked AFTER the orphan rule so the two cannot disagree about an account\n // that is both, and it changes nothing for a break-glass account that has\n // no removable provider anyway — `skipped` marks a decision, so it is set\n // only where there was something to decide.\n if (unlinked.length && breakGlass.has(record.uid)) {\n return { ...summary, unlinked: [], kept: providers, skipped: 'break-glass' }\n }\n return summary\n}\n\n/**\n * Would this org still have a way in after the sweep? (AGL-1888)\n *\n * Pure, and over the PLANNED accounts rather than the raw records, so it\n * answers the question about the state enforcement is actually about to\n * create rather than the one it is in now.\n *\n * ONLY A DESIGNATED ACCOUNT COUNTS. An account that happens to retain a\n * password because it is misconfigured — the `would-orphan` case, which has no\n * IdP link at all — is not break-glass: nobody chose it, it may be a stale\n * record or a service account, and an org protected by an accident it does not\n * know about is not protected. Requiring the designation is what makes this a\n * control rather than a coincidence.\n *\n * A designation only counts when the account actually holds something OTHER\n * than the org's IdP. Naming an account whose sole credential is the SAML link\n * is the most natural way to get this wrong — it looks like a break-glass\n * account and provides nothing, because it fails in exactly the situation it\n * exists for.\n *\n * THE SECOND WAY IN IS NOT IN THE POOL AT ALL (AGL-1888 option (a)). An org\n * owner holding a project-level credential is invisible to the sweep — the\n * enforcement pass lists `authForPool(tenantId)` and never touches the\n * project pool — so a lapsed certificate cannot reach them. No designation is\n * required for those: nobody has to be told to keep the key they already\n * hold, and requiring a tick would put the requirement back out of reach for\n * exactly the org that already satisfies it. Whether such an owner exists is\n * decided by {@link findBreakGlassOrgOwners}, which is where the seven\n * conditions live; this function only counts what it is handed, so that the\n * rehearsal and the real run cannot disagree.\n *\n * `ownerLookupFailed` deliberately does NOT make an org safe. It exists so a\n * refusal can distinguish \"you have nobody\" from \"we could not check\" — an\n * error swallowed into an empty list renders as a measured zero, and nothing\n * about it looks wrong.\n */\nexport function assessSsoLockoutRisk(\n accounts: readonly SsoEnforcementAccount[],\n providerId: string,\n breakGlass: readonly string[],\n ownersOutsidePool: readonly SsoBreakGlassOwner[] = [],\n ownerLookupFailed = false,\n): SsoLockoutAssessment {\n const designated = [...new Set(breakGlass.filter(Boolean))]\n const byUid = new Map(accounts.map((account) => [account.uid, account]))\n const retainedBy: string[] = []\n const ineffective: string[] = []\n for (const uid of designated) {\n const account = byUid.get(uid)\n const protects = Boolean(\n account?.kept.some((id) => id !== providerId),\n )\n if (protects) retainedBy.push(uid)\n else ineffective.push(uid)\n }\n const owners = [...ownersOutsidePool]\n return {\n safe: retainedBy.length > 0 || owners.length > 0,\n retainedBy,\n ineffective,\n ownersOutsidePool: owners,\n ownerLookupFailed,\n }\n}\n\n/**\n * The refusal an org sees when enforcing would lock it out of itself.\n *\n * Says what to do, because a refusal that only says no sends someone to turn\n * the guard off. Deliberately free of a product name — this string is read by\n * self-host operators too.\n */\nexport const SSO_LOCKOUT_REFUSAL =\n 'Enforcing single sign-on would remove every sign-in method that does not ' +\n 'go through your identity provider, and this organization would be left ' +\n 'with no way in that survives your identity provider failing. If its ' +\n 'certificate lapses or its application is removed, nobody would be able ' +\n 'to sign in and we could not let you back in either. Give the ' +\n 'organization one of two things first: an owner who signs in outside your ' +\n 'identity pool, or a designated break-glass account inside it that keeps ' +\n 'a password.'\n\nexport default enforceSsoSignInMethods\n"],"names":["FieldValue","authForPool","findBreakGlassOrgOwners","invalidateTokenRevocationCache","firebaseAdmin","notifyUsers","addAdminAudit","firestore","app","TENANT_USER_CAP","SsoEnforcementError","Error","enforceSsoSignInMethods","orgId","options","actorUid","dryRun","force","db","orgSnapshot","collection","doc","get","exists","sso","tenantId","providerId","status","enforced","breakGlassUids","Array","isArray","map","String","filter","Boolean","pool","page","listUsers","accounts","changedUids","breakGlass","Set","record","users","summary","planAccount","push","ownerLookup","lockout","assessSsoLockoutRisk","owners","unavailable","safe","SSO_LOCKOUT_REFUSAL","find","user","uid","unlinked","length","skipped","updateUser","providersToUnlink","revokeRefreshTokens","action","target","before","providers","providerIdsOf","after","kept","tokensRevoked","at","serverTimestamp","orgName","trim","type","title","body","link","scanned","changed","truncated","pageToken","providerData","info","id","email","has","ownersOutsidePool","ownerLookupFailed","designated","byUid","Map","account","retainedBy","ineffective","protects","some"],"mappings":"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"}
|
|
@@ -127,9 +127,10 @@ export async function sendStaffAlertEmail(input) {
|
|
|
127
127
|
const content = await renderSystemEmailContent('staff-alert', {
|
|
128
128
|
'alert.subject': input.subject,
|
|
129
129
|
'alert.body': input.text
|
|
130
|
-
}, systemEmailBrand(null),
|
|
130
|
+
}, systemEmailBrand(null), // The last resort reads as the built-in copy does: subject, then body.
|
|
131
|
+
{
|
|
131
132
|
subject: input.subject,
|
|
132
|
-
text: input.text
|
|
133
|
+
text: `${input.subject}\n\n${input.text}`
|
|
133
134
|
});
|
|
134
135
|
return await sendToOperators(content, input.context);
|
|
135
136
|
} catch (error) {
|
|
@@ -183,6 +184,7 @@ export async function sendStaffAlertEmail(input) {
|
|
|
183
184
|
const subject = `${input.count} operator alert${input.count === 1 ? '' : 's'} on ${input.date}`;
|
|
184
185
|
const url = String((_input_url = input.url) != null ? _input_url : '').trim();
|
|
185
186
|
const content = await renderSystemEmailContent('operator-alert-digest', {
|
|
187
|
+
'digest.title': subject,
|
|
186
188
|
'digest.date': input.date,
|
|
187
189
|
'digest.count': String(input.count),
|
|
188
190
|
'digest.body': input.body,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.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 { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * Staff alerts by email, from LIBRARY code (AGL-2925).\n *\n * One destination for every subsystem's alarm — the console's\n * `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends\n * through here too — resolved by {@link resolveStaffAlertRecipients}\n * (AGL-3375). Nothing here throws, because the mail is a courtesy and the\n * refusal it announces is the control; with no address anywhere the answer\n * is `unconfigured`.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer and the staff\n * roster, which read through the same admin app, are loaded the same way.\n */\n\n/** At most this many staff accounts are mailed when no inbox is configured. */\nconst STAFF_FALLBACK_MAX = 10\n\n/**\n * Where a staff alarm goes (AGL-3375), first answer wins:\n *\n * 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.\n * 2. The operator's published support address,\n * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is\n * asked for and most set.\n * 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.\n *\n * Only the first applies on a preview or development deployment.\n *\n * The chain exists because the alarms that matter most are the ones an\n * install nobody tuned would otherwise send to no one: card testing on its\n * checkout, a stolen card on its first subscription, a phishing campaign\n * held on its sending domain. An unset variable used to mean silence.\n *\n * Empty only when none of the three yields an address.\n */\nexport async function resolveStaffAlertRecipients(): Promise<string[]> {\n const configured = String(process.env.STAFF_ALERT_EMAIL ?? '').trim().toLowerCase()\n if (configured.includes('@')) return [configured]\n // A preview or development deployment says what it is; only a production\n // one, or a self-hosted install that sets no such thing, falls back. A\n // preview's alarms reaching the operator's real inbox would be noise.\n const deployment = String(process.env.VERCEL_ENV ?? '').trim()\n if (deployment && deployment !== 'production') return []\n const operator = String(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL ?? '')\n .trim()\n .toLowerCase()\n if (operator.includes('@')) return [operator]\n try {\n const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import(\n './auth-pools'\n )\n const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX)\n const addresses = new Set<string>()\n for (const uid of uids) {\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n const address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n if (address.includes('@')) addresses.add(address)\n }\n return [...addresses]\n } catch (error) {\n console.error('[staff-alert-email] staff roster unavailable', error)\n return []\n }\n}\n\n/**\n * Sends one rendered alert to each operator recipient, one message each so\n * no staff member is shown another's address. Metered to the platform: our\n * own alarm is our own cost (AGL-1438). Never throws.\n */\nasync function sendToOperators(\n content: { subject: string; text: string; html?: string },\n context: string,\n): Promise<SendEmailResult> {\n const recipients = await resolveStaffAlertRecipients()\n if (!recipients.length) return { sent: false, reason: 'unconfigured' }\n let delivered: SendEmailResult | null = null\n let failure: SendEmailResult = { sent: false, reason: 'unconfigured' }\n let sent = 0\n for (const to of recipients) {\n const result = await sendEmail({ to, ...content, context })\n if (result.sent) {\n sent += 1\n delivered ??= result\n } else {\n failure = result\n }\n }\n if (sent) {\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail(sent).catch(() => undefined)\n }\n // Delivered to anyone is delivered: one dead staff mailbox does not make\n // the alert a failure for the caller.\n return delivered ?? failure\n}\n\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * One operator alert (AGL-3375, AGL-3377), as the `operator-alert` system\n * email: the alert, a button to the item it is about, in the operator's own\n * brand. Sent by `raiseOperatorAlert` for every alert delivered immediately;\n * nothing else should call it. Never throws.\n */\nexport async function sendOperatorAlertEmail(input: {\n title: string\n body?: string | null\n /** Absolute link to the item, when there is one. */\n url?: string | null\n context: string\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const body = String(input.body ?? '').trim()\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert',\n { 'alert.title': input.title, 'alert.body': body, 'alert.url': url },\n systemEmailBrand(null),\n { subject: input.title, text: [input.title, body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] operator alert failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * The day's batched operator alerts (AGL-3377), as the\n * `operator-alert-digest` system email. Sent by `sendOperatorAlertDigest`.\n * Never throws.\n */\nexport async function sendOperatorAlertDigestEmail(input: {\n /** The UTC day, `YYYY-MM-DD`. */\n date: string\n count: number\n /** The alerts, composed as one block of text. */\n body: string\n /** Absolute link to the staff console, when there is one. */\n url?: string | null\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const subject = `${input.count} operator alert${input.count === 1 ? '' : 's'} on ${input.date}`\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert-digest',\n {\n 'digest.date': input.date,\n 'digest.count': String(input.count),\n 'digest.body': input.body,\n 'digest.url': url,\n },\n systemEmailBrand(null),\n { subject, text: [subject, input.body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, 'operator-alert-digest')\n } catch (error) {\n console.error('[staff-alert-email] operator digest failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","STAFF_FALLBACK_MAX","resolveStaffAlertRecipients","process","configured","String","env","STAFF_ALERT_EMAIL","trim","toLowerCase","includes","deployment","VERCEL_ENV","operator","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","findUserByUidAcrossPools","listStaffUidsAcrossPools","uids","slice","addresses","Set","uid","pooled","catch","address","record","email","add","error","console","sendToOperators","content","context","recipients","length","sent","reason","delivered","failure","to","result","meterPlatformEmail","undefined","sendStaffAlertEmail","input","renderSystemEmailContent","systemEmailBrand","subject","text","sendOperatorAlertEmail","body","url","title","filter","Boolean","join","sendOperatorAlertDigestEmail","count","date"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.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 { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * Staff alerts by email, from LIBRARY code (AGL-2925).\n *\n * One destination for every subsystem's alarm — the console's\n * `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends\n * through here too — resolved by {@link resolveStaffAlertRecipients}\n * (AGL-3375). Nothing here throws, because the mail is a courtesy and the\n * refusal it announces is the control; with no address anywhere the answer\n * is `unconfigured`.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer and the staff\n * roster, which read through the same admin app, are loaded the same way.\n */\n\n/** At most this many staff accounts are mailed when no inbox is configured. */\nconst STAFF_FALLBACK_MAX = 10\n\n/**\n * Where a staff alarm goes (AGL-3375), first answer wins:\n *\n * 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.\n * 2. The operator's published support address,\n * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is\n * asked for and most set.\n * 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.\n *\n * Only the first applies on a preview or development deployment.\n *\n * The chain exists because the alarms that matter most are the ones an\n * install nobody tuned would otherwise send to no one: card testing on its\n * checkout, a stolen card on its first subscription, a phishing campaign\n * held on its sending domain. An unset variable used to mean silence.\n *\n * Empty only when none of the three yields an address.\n */\nexport async function resolveStaffAlertRecipients(): Promise<string[]> {\n const configured = String(process.env.STAFF_ALERT_EMAIL ?? '').trim().toLowerCase()\n if (configured.includes('@')) return [configured]\n // A preview or development deployment says what it is; only a production\n // one, or a self-hosted install that sets no such thing, falls back. A\n // preview's alarms reaching the operator's real inbox would be noise.\n const deployment = String(process.env.VERCEL_ENV ?? '').trim()\n if (deployment && deployment !== 'production') return []\n const operator = String(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL ?? '')\n .trim()\n .toLowerCase()\n if (operator.includes('@')) return [operator]\n try {\n const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import(\n './auth-pools'\n )\n const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX)\n const addresses = new Set<string>()\n for (const uid of uids) {\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n const address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n if (address.includes('@')) addresses.add(address)\n }\n return [...addresses]\n } catch (error) {\n console.error('[staff-alert-email] staff roster unavailable', error)\n return []\n }\n}\n\n/**\n * Sends one rendered alert to each operator recipient, one message each so\n * no staff member is shown another's address. Metered to the platform: our\n * own alarm is our own cost (AGL-1438). Never throws.\n */\nasync function sendToOperators(\n content: { subject: string; text: string; html?: string },\n context: string,\n): Promise<SendEmailResult> {\n const recipients = await resolveStaffAlertRecipients()\n if (!recipients.length) return { sent: false, reason: 'unconfigured' }\n let delivered: SendEmailResult | null = null\n let failure: SendEmailResult = { sent: false, reason: 'unconfigured' }\n let sent = 0\n for (const to of recipients) {\n const result = await sendEmail({ to, ...content, context })\n if (result.sent) {\n sent += 1\n delivered ??= result\n } else {\n failure = result\n }\n }\n if (sent) {\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail(sent).catch(() => undefined)\n }\n // Delivered to anyone is delivered: one dead staff mailbox does not make\n // the alert a failure for the caller.\n return delivered ?? failure\n}\n\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n // The last resort reads as the built-in copy does: subject, then body.\n { subject: input.subject, text: `${input.subject}\\n\\n${input.text}` },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * One operator alert (AGL-3375, AGL-3377), as the `operator-alert` system\n * email: the alert, a button to the item it is about, in the operator's own\n * brand. Sent by `raiseOperatorAlert` for every alert delivered immediately;\n * nothing else should call it. Never throws.\n */\nexport async function sendOperatorAlertEmail(input: {\n title: string\n body?: string | null\n /** Absolute link to the item, when there is one. */\n url?: string | null\n context: string\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const body = String(input.body ?? '').trim()\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert',\n { 'alert.title': input.title, 'alert.body': body, 'alert.url': url },\n systemEmailBrand(null),\n { subject: input.title, text: [input.title, body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] operator alert failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * The day's batched operator alerts (AGL-3377), as the\n * `operator-alert-digest` system email. Sent by `sendOperatorAlertDigest`.\n * Never throws.\n */\nexport async function sendOperatorAlertDigestEmail(input: {\n /** The UTC day, `YYYY-MM-DD`. */\n date: string\n count: number\n /** The alerts, composed as one block of text. */\n body: string\n /** Absolute link to the staff console, when there is one. */\n url?: string | null\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const subject = `${input.count} operator alert${input.count === 1 ? '' : 's'} on ${input.date}`\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert-digest',\n {\n 'digest.title': subject,\n 'digest.date': input.date,\n 'digest.count': String(input.count),\n 'digest.body': input.body,\n 'digest.url': url,\n },\n systemEmailBrand(null),\n { subject, text: [subject, input.body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, 'operator-alert-digest')\n } catch (error) {\n console.error('[staff-alert-email] operator digest failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","STAFF_FALLBACK_MAX","resolveStaffAlertRecipients","process","configured","String","env","STAFF_ALERT_EMAIL","trim","toLowerCase","includes","deployment","VERCEL_ENV","operator","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","findUserByUidAcrossPools","listStaffUidsAcrossPools","uids","slice","addresses","Set","uid","pooled","catch","address","record","email","add","error","console","sendToOperators","content","context","recipients","length","sent","reason","delivered","failure","to","result","meterPlatformEmail","undefined","sendStaffAlertEmail","input","renderSystemEmailContent","systemEmailBrand","subject","text","sendOperatorAlertEmail","body","url","title","filter","Boolean","join","sendOperatorAlertDigestEmail","count","date"],"mappings":"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"}
|
|
@@ -31,6 +31,13 @@ export interface QueryFakeFirestore {
|
|
|
31
31
|
/** Committed writes since the last reset. */
|
|
32
32
|
writes(): number;
|
|
33
33
|
resetWrites(): void;
|
|
34
|
+
/**
|
|
35
|
+
* Read ROUND TRIPS since the last reset: one per document `get`, query
|
|
36
|
+
* `get`, `count()`, `getAll` or transaction read, however many documents
|
|
37
|
+
* it returned — the unit a request's latency is spent in.
|
|
38
|
+
*/
|
|
39
|
+
reads(): number;
|
|
40
|
+
resetReads(): void;
|
|
34
41
|
/** The clock `serverTimestamp()` and every `updateTime` read. */
|
|
35
42
|
setNow(ms: number): void;
|
|
36
43
|
/** Makes the next writes to `path` fail with `error` — a stand-in for an outage. */
|