@aglyn/tenant-data-admin 1.0.0-beta.262 → 1.0.0-beta.263

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-delivery.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 * The native apps' push delivery (AGL-3651): a notification the fan-out\n * wrote goes to each recipient's registered devices, grouped by transport —\n * APNs for iOS and macOS (`push-apns.ts`), FCM for Android (`push-fcm.ts`).\n *\n * Server-only. `registerNativePushSenders` (`native-push.ts`) registers\n * `nativePushSender` with the fan-out and imports this file only when a\n * notification has push recipients, and this file imports each transport\n * only when a device needs it.\n *\n * A row the registry no longer accepts is pruned here: an Expo row from the\n * React Native app, a row with no transport, a token that does not fit its\n * transport, and a device not seen for `MOBILE_DEVICE_STALE_MS`. A token a\n * transport reports as gone is pruned after the send.\n */\n\nimport {\n isMobilePushToken,\n MOBILE_APP_BUNDLES,\n MOBILE_DEVICE_STALE_MS,\n MOBILE_DEVICES_COLLECTION,\n MOBILE_DEVICES_PER_USER,\n mobilePushData,\n type ApnsEnvironment,\n type MobileDeviceApp,\n type MobilePushData,\n type MobilePushTransport,\n} from '@aglyn/aglyn/app-utils/notification-push'\nimport { normalizeNotificationLink } from '@aglyn/aglyn/server'\nimport type { DocumentReference, Firestore } from 'firebase-admin/firestore'\nimport type { MobilePushPayload, MobilePushSender } from './mobile-push-switch'\n\n/** The most devices one notification is sent to, whatever the audience. */\nconst MAX_DEVICES = 1000\n\n/** One device a transport sends to. */\nexport interface PushTarget {\n token: string\n app: MobileDeviceApp\n /** APNs: the app's bundle id, which is the push topic. */\n topic: string\n apnsEnvironment?: ApnsEnvironment\n}\n\n/** What every device of one notification is sent. */\nexport interface PushMessage {\n title: string\n body?: string\n data: MobilePushData\n /** Critical and warning notifications ask the platform to deliver now. */\n urgent: boolean\n}\n\n/** A transport's verdict for each target, in order. `prune`: the token is gone; forget the row. */\nexport type PushOutcome = 'sent' | 'prune' | 'failed'\n\nexport interface PushTransport {\n send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]>\n}\n\nexport interface PushDeliveryDeps {\n db: Firestore\n /** Each transport, or null when the deployment has none configured. Resolved only when a device needs it. */\n transport: (kind: MobilePushTransport) => Promise<PushTransport | null>\n now?: () => number\n}\n\nexport interface PushDeliveryResult {\n sent: number\n /** Rows removed: stale, Expo or malformed rows, and tokens a transport reported gone. */\n pruned: number\n failed: number\n /** Devices not sent to because their transport is not configured. */\n skipped: number\n}\n\n/** A stored timestamp as epoch millis, or null when there is none. */\nfunction millis(value: unknown): number | null {\n if (!value) return null\n if (typeof value === 'number') return value\n const withMillis = value as { toMillis?: () => number }\n return typeof withMillis.toMillis === 'function' ? withMillis.toMillis() : null\n}\n\nexport function pushMessage(payload: MobilePushPayload, link: string | undefined): PushMessage {\n return {\n title: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n data: mobilePushData({ ...payload, link }),\n urgent: payload.level === 'critical' || payload.level === 'warning',\n }\n}\n\n/** The link the app opens: the stored one, rewritten onto today's console routes. */\nasync function pushLink(db: Firestore, payload: MobilePushPayload): Promise<string | undefined> {\n if (!payload.link) return undefined\n if (!payload.orgId) return normalizeNotificationLink(payload.link, {})\n try {\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId ? db.collection('hosts').doc(payload.hostId).get() : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return normalizeNotificationLink(payload.link, {\n orgSlug: typeof slug === 'string' ? slug : null,\n hostId: payload.hostId ?? null,\n hostSubdomain: typeof subdomain === 'string' ? subdomain : null,\n })\n } catch {\n return normalizeNotificationLink(payload.link, {})\n }\n}\n\nconst isApp = (value: unknown): value is MobileDeviceApp => value === 'aglyn' || value === 'aglyn-pos'\n\n/**\n * Sends one notification to every live device of `uids`, each through its\n * own transport. Never throws: the console notification is the record, and a\n * push is a courtesy beside it.\n */\nexport async function deliverNativePush(\n uids: readonly string[],\n payload: MobilePushPayload,\n deps: PushDeliveryDeps,\n): Promise<PushDeliveryResult> {\n const result: PushDeliveryResult = { sent: 0, pruned: 0, failed: 0, skipped: 0 }\n if (!uids.length) return result\n const now = (deps.now ?? Date.now)()\n const prune = async (ref: DocumentReference) => {\n await ref.delete().catch(() => undefined)\n result.pruned += 1\n }\n try {\n const { db } = deps\n const groups = new Map<MobilePushTransport, Array<{ target: PushTarget; ref: DocumentReference }>>()\n const seen = new Set<string>()\n let total = 0\n for (const uid of uids) {\n const devices = await db\n .collection('users')\n .doc(uid)\n .collection(MOBILE_DEVICES_COLLECTION)\n .limit(MOBILE_DEVICES_PER_USER)\n .get()\n for (const device of devices.docs) {\n const transport = device.get('transport')\n const token = device.get('token')\n const app = device.get('app')\n if (!isMobilePushToken(transport, token) || !isApp(app)) {\n await prune(device.ref)\n continue\n }\n const lastSeen = millis(device.get('lastSeen'))\n if (lastSeen !== null && now - lastSeen > MOBILE_DEVICE_STALE_MS) {\n await prune(device.ref)\n continue\n }\n if (seen.has(token) || total >= MAX_DEVICES) continue\n seen.add(token)\n total += 1\n const environment = device.get('apnsEnvironment')\n const target: PushTarget = {\n token,\n app,\n topic: MOBILE_APP_BUNDLES[app],\n ...(transport === 'apns' && (environment === 'sandbox' || environment === 'production')\n ? { apnsEnvironment: environment }\n : {}),\n }\n const group = groups.get(transport) ?? []\n group.push({ target, ref: device.ref })\n groups.set(transport, group)\n }\n }\n if (!groups.size) return result\n const message = pushMessage(payload, await pushLink(db, payload))\n for (const [kind, entries] of groups) {\n let transport: PushTransport | null = null\n try {\n transport = await deps.transport(kind)\n } catch (error) {\n console.error(`push transport ${kind} failed to load`, error)\n }\n if (!transport) {\n result.skipped += entries.length\n continue\n }\n let outcomes: PushOutcome[]\n try {\n outcomes = await transport.send(\n entries.map((entry) => entry.target),\n message,\n )\n } catch (error) {\n console.error(`push transport ${kind} failed`, error)\n result.failed += entries.length\n continue\n }\n for (const [index, entry] of entries.entries()) {\n const outcome = outcomes[index] ?? 'failed'\n if (outcome === 'sent') result.sent += 1\n else if (outcome === 'prune') await prune(entry.ref)\n else result.failed += 1\n }\n }\n } catch (error) {\n console.error('push delivery failed', error)\n }\n return result\n}\n\n/** The deployment's transports, each imported the first time a device needs it. */\nasync function deploymentTransport(kind: MobilePushTransport): Promise<PushTransport | null> {\n if (kind === 'apns') return (await import('./push-apns')).apnsTransport()\n return (await import('./push-fcm')).fcmTransport()\n}\n\n/** The sender `registerNativePushSenders` hands the fan-out. */\nexport const nativePushSender: MobilePushSender = async (uids, payload, { db }) => {\n await deliverNativePush(uids, payload, { db, transport: deploymentTransport })\n}\n"],"names":["isMobilePushToken","MOBILE_APP_BUNDLES","MOBILE_DEVICE_STALE_MS","MOBILE_DEVICES_COLLECTION","MOBILE_DEVICES_PER_USER","mobilePushData","normalizeNotificationLink","MAX_DEVICES","millis","value","withMillis","toMillis","pushMessage","payload","link","title","body","data","urgent","level","pushLink","db","undefined","orgId","org","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","orgSlug","hostSubdomain","isApp","deliverNativePush","uids","deps","result","sent","pruned","failed","skipped","length","now","Date","prune","ref","delete","catch","groups","Map","seen","Set","total","uid","devices","limit","device","docs","transport","token","app","lastSeen","has","add","environment","target","topic","apnsEnvironment","group","push","set","size","message","kind","entries","error","console","outcomes","send","map","entry","index","outcome","deploymentTransport","apnsTransport","fcmTransport","nativePushSender"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/push-delivery.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 * The native apps' push delivery (AGL-3651): a notification the fan-out\n * wrote goes to each recipient's registered devices, grouped by transport —\n * APNs for iOS and macOS (`push-apns.ts`), FCM for Android (`push-fcm.ts`).\n *\n * Server-only. `registerNativePushSenders` (`native-push.ts`) registers\n * `nativePushSender` with the fan-out and imports this file only when a\n * notification has push recipients, and this file imports each transport\n * only when a device needs it.\n *\n * A row the registry no longer accepts is pruned here: an Expo row from the\n * React Native app, a row with no transport, a token that does not fit its\n * transport, and a device not seen for `MOBILE_DEVICE_STALE_MS`. A token a\n * transport reports as gone is pruned after the send.\n */\n\nimport {\n isMobilePushToken,\n MOBILE_APP_BUNDLES,\n MOBILE_DEVICE_STALE_MS,\n MOBILE_DEVICES_COLLECTION,\n MOBILE_DEVICES_PER_USER,\n mobilePushData,\n type ApnsEnvironment,\n type MobileDeviceApp,\n type MobilePushData,\n type MobilePushTransport,\n} from '@aglyn/aglyn/app-utils/notification-push'\nimport { normalizeNotificationLink } from '@aglyn/aglyn/server'\nimport type { DocumentReference, Firestore } from 'firebase-admin/firestore'\nimport type { MobilePushPayload, MobilePushSender } from './mobile-push-switch'\n\n/** The most devices one notification is sent to, whatever the audience. */\nconst MAX_DEVICES = 1000\n\n/** One device a transport sends to. */\nexport interface PushTarget {\n token: string\n app: MobileDeviceApp\n /** APNs: the app's bundle id, which is the push topic. */\n topic: string\n apnsEnvironment?: ApnsEnvironment\n}\n\n/** What every device of one notification is sent. */\nexport interface PushMessage {\n title: string\n body?: string\n data: MobilePushData\n /** Critical and warning notifications ask the platform to deliver now. */\n urgent: boolean\n}\n\n/** A transport's verdict for each target, in order. `prune`: the token is gone; forget the row. */\nexport type PushOutcome = 'sent' | 'prune' | 'failed'\n\nexport interface PushTransport {\n send(targets: readonly PushTarget[], message: PushMessage): Promise<PushOutcome[]>\n}\n\nexport interface PushDeliveryDeps {\n db: Firestore\n /** Each transport, or null when the deployment has none configured. Resolved only when a device needs it. */\n transport: (kind: MobilePushTransport) => Promise<PushTransport | null>\n now?: () => number\n}\n\nexport interface PushDeliveryResult {\n sent: number\n /** Rows removed: stale, Expo or malformed rows, and tokens a transport reported gone. */\n pruned: number\n failed: number\n /** Devices not sent to because their transport is not configured. */\n skipped: number\n}\n\n/** A stored timestamp as epoch millis, or null when there is none. */\nfunction millis(value: unknown): number | null {\n if (!value) return null\n if (typeof value === 'number') return value\n const withMillis = value as { toMillis?: () => number }\n return typeof withMillis.toMillis === 'function' ? withMillis.toMillis() : null\n}\n\nexport function pushMessage(payload: MobilePushPayload, link: string | undefined): PushMessage {\n return {\n title: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n data: mobilePushData({ ...payload, link }),\n urgent: payload.level === 'critical' || payload.level === 'warning',\n }\n}\n\n/** The link the app opens: the stored one, rewritten onto today's console routes. */\nasync function pushLink(db: Firestore, payload: MobilePushPayload): Promise<string | undefined> {\n if (!payload.link) return undefined\n if (!payload.orgId) return normalizeNotificationLink(payload.link, {})\n try {\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId ? db.collection('hosts').doc(payload.hostId).get() : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return normalizeNotificationLink(payload.link, {\n orgSlug: typeof slug === 'string' ? slug : null,\n hostId: payload.hostId ?? null,\n hostSubdomain: typeof subdomain === 'string' ? subdomain : null,\n })\n } catch {\n return normalizeNotificationLink(payload.link, {})\n }\n}\n\nconst isApp = (value: unknown): value is MobileDeviceApp => value === 'aglyn' || value === 'aglyn-pos'\n\n/**\n * The notification types the Aglyn POS app is pushed. The register is not a\n * second inbox: a person who has both apps would otherwise get every\n * workspace notification twice on the same Mac or iPad, so the Aglyn app\n * carries them all and the POS app only what a register needs. None yet.\n */\nexport const POS_PUSH_TYPES: ReadonlySet<string> = new Set<string>()\n\n/** Whether `app` is sent a notification of `type`. */\nexport function appReceivesPush(app: MobileDeviceApp, type: string | undefined): boolean {\n return app === 'aglyn' || (type !== undefined && POS_PUSH_TYPES.has(type))\n}\n\n/**\n * Sends one notification to every live device of `uids`, each through its\n * own transport. Never throws: the console notification is the record, and a\n * push is a courtesy beside it.\n */\nexport async function deliverNativePush(\n uids: readonly string[],\n payload: MobilePushPayload,\n deps: PushDeliveryDeps,\n): Promise<PushDeliveryResult> {\n const result: PushDeliveryResult = { sent: 0, pruned: 0, failed: 0, skipped: 0 }\n if (!uids.length) return result\n const now = (deps.now ?? Date.now)()\n const prune = async (ref: DocumentReference) => {\n await ref.delete().catch(() => undefined)\n result.pruned += 1\n }\n try {\n const { db } = deps\n const groups = new Map<MobilePushTransport, Array<{ target: PushTarget; ref: DocumentReference }>>()\n const seen = new Set<string>()\n let total = 0\n for (const uid of uids) {\n const devices = await db\n .collection('users')\n .doc(uid)\n .collection(MOBILE_DEVICES_COLLECTION)\n .limit(MOBILE_DEVICES_PER_USER)\n .get()\n for (const device of devices.docs) {\n const transport = device.get('transport')\n const token = device.get('token')\n const app = device.get('app')\n if (!isMobilePushToken(transport, token) || !isApp(app)) {\n await prune(device.ref)\n continue\n }\n const lastSeen = millis(device.get('lastSeen'))\n if (lastSeen !== null && now - lastSeen > MOBILE_DEVICE_STALE_MS) {\n await prune(device.ref)\n continue\n }\n if (!appReceivesPush(app, payload.type)) continue\n if (seen.has(token) || total >= MAX_DEVICES) continue\n seen.add(token)\n total += 1\n const environment = device.get('apnsEnvironment')\n const target: PushTarget = {\n token,\n app,\n topic: MOBILE_APP_BUNDLES[app],\n ...(transport === 'apns' && (environment === 'sandbox' || environment === 'production')\n ? { apnsEnvironment: environment }\n : {}),\n }\n const group = groups.get(transport) ?? []\n group.push({ target, ref: device.ref })\n groups.set(transport, group)\n }\n }\n if (!groups.size) return result\n const message = pushMessage(payload, await pushLink(db, payload))\n for (const [kind, entries] of groups) {\n let transport: PushTransport | null = null\n try {\n transport = await deps.transport(kind)\n } catch (error) {\n console.error(`push transport ${kind} failed to load`, error)\n }\n if (!transport) {\n result.skipped += entries.length\n continue\n }\n let outcomes: PushOutcome[]\n try {\n outcomes = await transport.send(\n entries.map((entry) => entry.target),\n message,\n )\n } catch (error) {\n console.error(`push transport ${kind} failed`, error)\n result.failed += entries.length\n continue\n }\n for (const [index, entry] of entries.entries()) {\n const outcome = outcomes[index] ?? 'failed'\n if (outcome === 'sent') result.sent += 1\n else if (outcome === 'prune') await prune(entry.ref)\n else result.failed += 1\n }\n }\n } catch (error) {\n console.error('push delivery failed', error)\n }\n return result\n}\n\n/** The deployment's transports, each imported the first time a device needs it. */\nasync function deploymentTransport(kind: MobilePushTransport): Promise<PushTransport | null> {\n if (kind === 'apns') return (await import('./push-apns')).apnsTransport()\n return (await import('./push-fcm')).fcmTransport()\n}\n\n/** The sender `registerNativePushSenders` hands the fan-out. */\nexport const nativePushSender: MobilePushSender = async (uids, payload, { db }) => {\n await deliverNativePush(uids, payload, { db, transport: deploymentTransport })\n}\n"],"names":["isMobilePushToken","MOBILE_APP_BUNDLES","MOBILE_DEVICE_STALE_MS","MOBILE_DEVICES_COLLECTION","MOBILE_DEVICES_PER_USER","mobilePushData","normalizeNotificationLink","MAX_DEVICES","millis","value","withMillis","toMillis","pushMessage","payload","link","title","body","data","urgent","level","pushLink","db","undefined","orgId","org","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","orgSlug","hostSubdomain","isApp","POS_PUSH_TYPES","Set","appReceivesPush","app","type","has","deliverNativePush","uids","deps","result","sent","pruned","failed","skipped","length","now","Date","prune","ref","delete","catch","groups","Map","seen","total","uid","devices","limit","device","docs","transport","token","lastSeen","add","environment","target","topic","apnsEnvironment","group","push","set","size","message","kind","entries","error","console","outcomes","send","map","entry","index","outcome","deploymentTransport","apnsTransport","fcmTransport","nativePushSender"],"mappings":"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"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type SystemEmailTemplateDefinition } from '@aglyn/shared-util-email';
17
+ import { type EmailTheme, type SystemEmailTemplateDefinition } from '@aglyn/shared-util-email';
18
18
  import { type ReusableComponentTree } from '@aglyn/aglyn/server';
19
19
  import { isPlatformBrandedSend, type SystemEmailBrandOptions } from '@aglyn/aglyn/app-utils/system-email-chrome';
20
20
  export { isPlatformBrandedSend };
@@ -74,6 +74,12 @@ export interface LoadedSystemEmail {
74
74
  * send, by {@link renderLoadedSystemEmail}.
75
75
  */
76
76
  export declare function loadSystemEmail(templateKey: string): Promise<LoadedSystemEmail | null>;
77
+ /**
78
+ * The theme a system email is drawn in (AGL-3370): the console's palette,
79
+ * or, for a white-label org that set a brand color, the same palette with
80
+ * that color as its primary. Buttons use the console's corner radius.
81
+ */
82
+ export declare function systemEmailTheme(options: SystemEmailBrandOptions): EmailTheme;
77
83
  /**
78
84
  * Renders a loaded email for one recipient's merge values (AGL-768). No
79
85
  * Firestore access — call {@link loadSystemEmail} once, then this per
@@ -172,7 +172,7 @@ export { isPlatformBrandedSend };
172
172
  * The theme a system email is drawn in (AGL-3370): the console's palette,
173
173
  * or, for a white-label org that set a brand color, the same palette with
174
174
  * that color as its primary. Buttons use the console's corner radius.
175
- */ function systemEmailTheme(options) {
175
+ */ export function systemEmailTheme(options) {
176
176
  var _options_brandPrimaryColor;
177
177
  const primaryColor = String((_options_brandPrimaryColor = options.brandPrimaryColor) != null ? _options_brandPrimaryColor : '').trim();
178
178
  return {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/render-system-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 {\n EMAIL_NODE_ROOT_ID,\n PLATFORM_EMAIL_PALETTE,\n chromeForDesign,\n SYSTEM_EMAIL_COLLECTION,\n buildEmailPalette,\n buildDefaultEmailNodeMap,\n getSystemEmailTemplate,\n renderEmailHtml,\n substituteMergeTokens,\n type EmailTheme,\n type SystemEmailTemplateDefinition,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\nimport { platformMarketingHostId } from './platform-marketing-consent'\nimport {\n brandMergeTokens,\n collectReferencedComponentIds,\n composeReusableComponentNodes,\n decodeStoredNodes,\n resolveBrandingProfile,\n sanitizeAuthorHtml,\n type ReusableComponentTree,\n} from '@aglyn/aglyn/server'\nimport {\n hostComponentReader,\n loadReferencedComponents,\n type ComponentStoreLike,\n} from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport {\n PLATFORM_BRAND_MERGE_TOKENS,\n buildSystemEmailChrome,\n isPlatformBrandedSend,\n type SystemEmailBrandOptions,\n} from '@aglyn/aglyn/app-utils/system-email-chrome'\n\n// Defined beside the chrome they decide, and re-exported for the callers and\n// specs that reach them through this module.\nexport { isPlatformBrandedSend }\nexport type { SystemEmailBrandOptions }\n\n/**\n * Blanks any `{{token}}` the caller did not supply.\n *\n * `substituteMergeTokens` leaves unknown tokens untouched, which is right for\n * campaigns (an author wants to see the tag they mistyped) but wrong here —\n * a system email goes to a customer, and \"You've been invited to\n * {{org.name}}\" is worse than a gap. Applied only in this resolver so\n * campaign behaviour is unchanged.\n */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Origin a picked image in a system email is fetched from (AGL-1224).\n *\n * A system email is staff-designed and its media lives in the platform\n * library, which the CONSOLE's `/api/media/cdn` route serves — the tenant app\n * mounts the same route, but a recipient of an org invite has no site to\n * resolve against. Same expression `app/layout.tsx` uses for the console's\n * own canonical URL, so the two cannot drift per deploy.\n */\nconst CONSOLE_ORIGIN =\n process.env.NEXT_PUBLIC_CONSOLE_URL ?? 'https://app.aglyn.com'\n\n/**\n * Which copy a system email carries: a design staff published, or the\n * catalog's built-in copy, which is what every key without a usable design\n * sends (AGL-3322).\n */\nexport type SystemEmailSource = 'designed' | 'default'\n\nexport interface RenderedSystemEmail {\n subject: string\n html: string\n text: string\n source: SystemEmailSource\n}\n\n/**\n * A system email loaded once, ready to render for any number of recipients\n * without touching Firestore again (AGL-768): the published staff design, or\n * the catalog's built-in copy when there is none.\n */\nexport interface LoadedSystemEmail {\n definition: SystemEmailTemplateDefinition\n /** The design as stored, each email block still a placement; or the built-in copy's node map. */\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n source: SystemEmailSource\n /**\n * The published definitions of the email blocks `nodes` places, keyed by\n * id, read from the platform marketing site ONCE with the template\n * (AGL-3318), so a batch composes every recipient's copy without another\n * read. Absent when the design places none, when the deployment names no\n * marketing site, and when the read failed; each placement then renders as\n * nothing.\n */\n components?: Record<string, ReusableComponentTree>\n}\n\n/**\n * The email blocks a platform email places, read from the site that owns\n * them (AGL-3318), or `undefined` when there is nothing to read or the read\n * failed.\n *\n * WHICH SITE. A block is a reusable component, and a component belongs to a\n * site; a platform email belongs to none. The header and footer Aglyn's own\n * mail wears are the ones on its marketing site, which the deployment names\n * in `PLATFORM_MARKETING_HOST_ID`, the setting product-email consent already\n * reads. An install that names none reads nothing, and a block placed in its\n * system emails renders as nothing.\n *\n * NEVER A REASON NOT TO SEND. A site's own email fails its whole load when a\n * component read fails, and falls back to its built-in copy. Here the copy is\n * staff's design and the blocks are another site's chrome, so a failed read\n * costs the blocks and nothing else: it is logged, and the design renders\n * inside the coded header and footer instead (see\n * {@link renderLoadedSystemEmail}) rather than being traded for the built-in\n * copy. A password reset whose header could not be read still carries its\n * link.\n */\nasync function loadPlatformEmailBlocks(\n firestore: ComponentStoreLike,\n templateKey: string,\n nodes: Record<string, unknown>,\n): Promise<Record<string, ReusableComponentTree> | undefined> {\n // A design that places nothing costs nothing: no setting read, no read.\n if (!collectReferencedComponentIds(nodes as never).size) return undefined\n const hostId = platformMarketingHostId()\n if (!hostId) return undefined\n try {\n return await loadReferencedComponents(\n nodes,\n hostComponentReader(firestore, hostId),\n {\n onSkipped: (skipped) =>\n console.warn(\n JSON.stringify({\n tag: 'AGL-3318:platform-email-blocks-skipped',\n templateKey,\n hostId,\n skipped,\n }),\n ),\n },\n )\n } catch (error) {\n console.error(\n `system email template ${templateKey}: its email blocks failed to load from ${hostId}`,\n error,\n )\n return undefined\n }\n}\n\n/**\n * Whether one send draws the design's email blocks: a send under the\n * platform's own brand (see {@link isPlatformBrandedSend}) of a design whose\n * blocks the load could read. Every other send renders the stored nodes, each\n * block a placement that draws nothing, inside the coded chrome.\n */\nfunction drawsPlatformBlocks(\n loaded: LoadedSystemEmail,\n merged: Readonly<Record<string, string>>,\n options: SystemEmailBrandOptions,\n): loaded is LoadedSystemEmail & {\n components: Record<string, ReusableComponentTree>\n} {\n return (\n Boolean(loaded.components && Object.keys(loaded.components).length) &&\n isPlatformBrandedSend(merged, options)\n )\n}\n\n/**\n * A key's built-in copy as a loaded email (AGL-3322): the node map the editor\n * seeds a first design with, and the catalog subject. What a key sends until\n * staff publish a design, and whenever the published one cannot be read.\n */\nfunction catalogDefault(\n definition: SystemEmailTemplateDefinition,\n): LoadedSystemEmail {\n return {\n definition,\n nodes: buildDefaultEmailNodeMap(definition),\n subjectTemplate: definition.defaultSubject,\n preheaderTemplate: '',\n source: 'default',\n }\n}\n\n/**\n * Loads what a key sends — ONE Firestore read — ready to render per\n * recipient: the published staff design, or the catalog's built-in copy\n * (AGL-3322) when there is none to use (no document, no published version,\n * an empty node map, or a read failure).\n *\n * `null` only for a key that is unknown or not Resend-delivered, which has\n * nothing here to render. Split out from {@link renderSystemEmail} so a batch\n * send resolves the template once and renders it per recipient, rather than\n * one read each (AGL-768): a Firestore read per invite is fine, one per\n * recipient in a usage-email batch is not.\n *\n * Reads through the Admin SDK, which bypasses the staff-only rules on the\n * collection — the send path runs as the server, not as a signed-in user.\n *\n * A design that places email blocks costs one more read per block, from the\n * platform marketing site, made here with the template so a batch pays it\n * once too (AGL-3318). Whether a recipient's copy expands them is decided per\n * send, by {@link renderLoadedSystemEmail}.\n */\nexport async function loadSystemEmail(\n templateKey: string,\n): Promise<LoadedSystemEmail | null> {\n const definition = getSystemEmailTemplate(templateKey)\n if (!definition) return null\n // A Firebase- or Stripe-delivered email is sent by that service from its\n // own templates, so rendering one here would produce output nothing ever\n // sends (AGL-767).\n if (definition.deliveredBy !== 'resend') return null\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const templateRef = firestore\n .collection(SYSTEM_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return catalogDefault(definition)\n\n const versionId = templateSnapshot.get('versionId')\n // Cleared by \"reset to default\", which nulls the pointer rather than\n // deleting the document so the audit trail survives.\n if (!versionId) return catalogDefault(definition)\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms (AGL-1223). The email besigner compresses like every\n // other besigner document, and versions written before it did are still\n // plain maps — `decodeStoredNodes` returns those unchanged.\n //\n // Reading raw is the failure the guard below cannot catch: over a\n // `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and a staff-authored email renders empty rather than falling\n // back to its built-in copy.\n const nodes = decodeStoredNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!nodes || !Object.keys(nodes).length) return catalogDefault(definition)\n\n const components = await loadPlatformEmailBlocks(\n firestore,\n templateKey,\n nodes,\n )\n return {\n definition,\n nodes,\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n definition.defaultSubject,\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n source: 'designed',\n ...(components && { components }),\n }\n } catch (error) {\n // Never let a template problem block the send, or strip it bare: the\n // recipient gets the built-in copy, in the same header and footer.\n console.error(\n `system email template ${templateKey} failed to load; sending its built-in copy`,\n error,\n )\n return catalogDefault(definition)\n }\n}\n\n/**\n * The theme a system email is drawn in (AGL-3370): the console's palette,\n * or, for a white-label org that set a brand color, the same palette with\n * that color as its primary. Buttons use the console's corner radius.\n */\nfunction systemEmailTheme(options: SystemEmailBrandOptions): EmailTheme {\n const primaryColor = String(options.brandPrimaryColor ?? '').trim()\n return {\n palette: primaryColor\n ? buildEmailPalette({ base: 'platform', primaryColor })\n : PLATFORM_EMAIL_PALETTE,\n buttonRadius: 4,\n }\n}\n\n/**\n * Renders a loaded email for one recipient's merge values (AGL-768). No\n * Firestore access — call {@link loadSystemEmail} once, then this per\n * recipient. `null` only if the renderer produced no document at all.\n *\n * Which header and footer it wears is decided here, per send, because one\n * batch-loaded design goes to recipients of different brands:\n *\n * - A send under the platform's own brand of a design whose email blocks the\n * load read draws those blocks (AGL-3318), and nothing else: they ARE its\n * header and footer, and a second one around them would draw both.\n * - Every other send is drawn inside {@link buildSystemEmailChrome}'s chrome\n * (AGL-3322): the built-in copy, which places no blocks; a design sent to a\n * white-label org's people, whose blocks stay unexpanded and draw nothing,\n * since they are the platform's; and a design on a deployment that names no\n * marketing site, or whose blocks could not be read. The chrome is in the\n * brand of the send — the org's email logo rides in its header, so a\n * white-label mail carries exactly one logo.\n *\n * Either way, a band the design draws itself is left out of the chrome\n * (AGL-3372): a design that opens with its own Header gets the footer only.\n */\nexport function renderLoadedSystemEmail(\n loaded: LoadedSystemEmail,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): RenderedSystemEmail | null {\n const merged = { ...PLATFORM_BRAND_MERGE_TOKENS, ...merge }\n const blocks = drawsPlatformBlocks(loaded, merged, options)\n const nodes = (\n blocks\n ? composeReusableComponentNodes(loaded.nodes as never, loaded.components)\n : loaded.nodes\n ) as Record<string, unknown>\n // The brand's chrome around every copy, less any band the design draws\n // itself (AGL-3372). Unexpanded placements draw nothing in this send, so\n // they only count when the blocks are drawn.\n const chrome = chromeForDesign({\n stored: blocks ? loaded.nodes : null,\n composed: nodes,\n chrome: buildSystemEmailChrome({\n definition: loaded.definition,\n merged,\n brandLogoUrl: options.brandLogoUrl,\n brandHomeUrl: options.brandHomeUrl,\n }),\n })\n const rendered = renderEmailHtml({\n nodes: nodes as never,\n // Besigner maps are rooted at '_@_', not renderEmailHtml's default\n // 'root' — without this a designed template rendered empty and the send\n // fell back to built-in copy, so designing did nothing (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merged),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merged),\n merge: merged,\n // The same policy the console's own preview of these nodes applies —\n // `sanitizeCustomHtml` in the email plugin delegates to this exact\n // function, so what staff review and what the recipient receives are one\n // function of one string rather than two that have to be kept in step.\n sanitize: sanitizeAuthorHtml,\n // Without this a staff-picked image resolves to a site-relative CDN path,\n // which is a broken-image box in the recipient's inbox (AGL-1224). The\n // chrome's logo resolves against it too.\n mediaOrigin: CONSOLE_ORIGIN,\n // In the console's theme (AGL-3370), under a white-label org's own color\n // when it set one: a color staff picked in the Besigner is a palette\n // token, and a button nobody colored wears the brand's accent.\n ...systemEmailTheme(options),\n // No `brandLogoUrl`: the org's email logo (AGL-2139) is the chrome\n // header's logo whenever there is one to draw, and a send that draws the\n // platform's blocks is by definition one that carries none.\n ...(chrome ? { chrome } : {}),\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merged),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n source: loaded.source,\n }\n}\n\n/**\n * Renders the system email a key sends to one recipient (AGL-750): the\n * published staff design, or the catalog's built-in copy (AGL-3322), in the\n * header and footer of the brand it is sent under.\n *\n * **`null` only for a key that is unknown or not Resend-delivered**, which\n * has nothing here to render. No template document, no published version, an\n * empty node map and a Firestore failure all render the built-in copy — the\n * same copy the editor seeds a first design with — so a missing, half-saved\n * or broken template can never mean a customer receives no email, or a bare\n * one. Every caller still keeps its own hard-coded copy behind this, as the\n * last resort for a render that produced nothing.\n *\n * A single-recipient convenience over {@link loadSystemEmail} +\n * {@link renderLoadedSystemEmail}; a batch should call those two directly so\n * it reads the template once.\n */\nexport async function renderSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n const loaded = await loadSystemEmail(templateKey)\n return loaded ? renderLoadedSystemEmail(loaded, merge, options) : null\n}\n\n/**\n * The email a template sends right now, for the staff test-send endpoint\n * (AGL-766): exactly what a real send of it renders — the published design,\n * or the built-in copy, with the same header and footer — so a test shows\n * what recipients receive. `null` for an unknown or non-Resend key.\n */\nexport async function renderEffectiveSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n return renderSystemEmail(templateKey, merge, options)\n}\n\n/**\n * The brand one system email is sent under (AGL-3367): the merge tokens, the\n * chrome options and the sender name, from one place.\n *\n * `null` is the platform's own brand, and it is the answer for everything\n * the platform says on its own behalf: a staff alert, a legal receipt, an\n * enforcement notice. A workspace-context email passes the workspace's org\n * document, and `resolveBrandingProfile` decides from there: a white-label\n * org gets its own name, logo, home and support in the header and footer,\n * and every other org resolves to the platform profile, so the same call is\n * right for both.\n */\nexport interface SystemEmailBrand {\n merge: Record<string, string>\n options: SystemEmailBrandOptions\n /** The display name to send under; absent sends under the platform's. */\n fromName?: string\n /** The org's own name, for the `{{org.name}}` its copy may use. */\n orgName?: string\n}\n\n/** See {@link SystemEmailBrand}. */\nexport function systemEmailBrand(\n org: Readonly<Record<string, unknown>> | null | undefined,\n): SystemEmailBrand {\n if (!org) return { merge: {}, options: {} }\n const branding = resolveBrandingProfile(org as never)\n const orgName = typeof org['name'] === 'string' ? org['name'].trim() : ''\n return {\n ...(orgName ? { orgName } : {}),\n merge: brandMergeTokens(branding),\n options: {\n brandLogoUrl: branding.emailLogoUrl,\n brandHomeUrl: branding.homeUrl,\n brandPrimaryColor: branding.primaryColor,\n },\n fromName: branding.fromName,\n }\n}\n\n/**\n * {@link systemEmailBrand} for an org read by id, for a sender that holds\n * only the id. A failed or missing read is the platform's brand: the email\n * still goes, and a white-label org's people see the platform's chrome on\n * one message rather than no message.\n */\nexport async function orgSystemEmailBrand(\n orgId: string | null | undefined,\n): Promise<SystemEmailBrand> {\n if (!orgId) return systemEmailBrand(null)\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .get()\n return systemEmailBrand(snapshot.exists ? (snapshot.data() ?? null) : null)\n } catch (error) {\n console.error(`system email brand for org ${orgId} failed to load`, error)\n return systemEmailBrand(null)\n }\n}\n\n/** What a send takes from a rendered system email. */\nexport interface SystemEmailContent {\n subject: string\n text: string\n html?: string\n}\n\n/**\n * The subject, text and HTML to send, with the caller's own copy as the\n * last resort for a render that produced nothing: the shape every sender of\n * a system email repeats, written once.\n */\nexport function systemEmailContent(\n rendered: RenderedSystemEmail | null,\n fallback: { subject: string; text: string },\n): SystemEmailContent {\n return {\n subject: rendered?.subject || fallback.subject,\n text: rendered?.text || fallback.text,\n ...(rendered?.html ? { html: rendered.html } : {}),\n }\n}\n\n/**\n * Renders a key for one recipient in a brand and returns what to send\n * (AGL-3367): {@link renderSystemEmail} and {@link systemEmailContent} in\n * one call, for the senders with one message to build. Never throws; a\n * render that fails sends the fallback copy, which is what it sent before.\n */\nexport async function renderSystemEmailContent(\n templateKey: string,\n merge: Record<string, string>,\n brand: SystemEmailBrand,\n fallback: { subject: string; text: string },\n): Promise<SystemEmailContent> {\n const rendered = await renderSystemEmail(\n templateKey,\n { ...brand.merge, ...merge },\n brand.options,\n ).catch((error) => {\n console.error(`system email ${templateKey} failed to render`, error)\n return null\n })\n return systemEmailContent(rendered, fallback)\n}\n\nexport default renderSystemEmail\n"],"names":["process","EMAIL_NODE_ROOT_ID","PLATFORM_EMAIL_PALETTE","chromeForDesign","SYSTEM_EMAIL_COLLECTION","buildEmailPalette","buildDefaultEmailNodeMap","getSystemEmailTemplate","renderEmailHtml","substituteMergeTokens","firebaseAdmin","platformMarketingHostId","brandMergeTokens","collectReferencedComponentIds","composeReusableComponentNodes","decodeStoredNodes","resolveBrandingProfile","sanitizeAuthorHtml","hostComponentReader","loadReferencedComponents","PLATFORM_BRAND_MERGE_TOKENS","buildSystemEmailChrome","isPlatformBrandedSend","blankUnresolvedTokens","value","replace","CONSOLE_ORIGIN","env","NEXT_PUBLIC_CONSOLE_URL","loadPlatformEmailBlocks","firestore","templateKey","nodes","size","undefined","hostId","onSkipped","skipped","console","warn","JSON","stringify","tag","error","drawsPlatformBlocks","loaded","merged","options","Boolean","components","Object","keys","length","catalogDefault","definition","subjectTemplate","defaultSubject","preheaderTemplate","source","loadSystemEmail","deliveredBy","templateSnapshot","app","templateRef","collection","doc","get","exists","versionId","versionSnapshot","String","systemEmailTheme","primaryColor","brandPrimaryColor","trim","palette","base","buttonRadius","renderLoadedSystemEmail","merge","rendered","blocks","chrome","stored","composed","brandLogoUrl","brandHomeUrl","rootId","subject","preheader","sanitize","mediaOrigin","html","text","renderSystemEmail","renderEffectiveSystemEmail","systemEmailBrand","org","branding","orgName","emailLogoUrl","homeUrl","fromName","orgSystemEmailBrand","orgId","snapshot","data","systemEmailContent","fallback","renderSystemEmailContent","brand","catch"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/render-system-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 {\n EMAIL_NODE_ROOT_ID,\n PLATFORM_EMAIL_PALETTE,\n chromeForDesign,\n SYSTEM_EMAIL_COLLECTION,\n buildEmailPalette,\n buildDefaultEmailNodeMap,\n getSystemEmailTemplate,\n renderEmailHtml,\n substituteMergeTokens,\n type EmailTheme,\n type SystemEmailTemplateDefinition,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\nimport { platformMarketingHostId } from './platform-marketing-consent'\nimport {\n brandMergeTokens,\n collectReferencedComponentIds,\n composeReusableComponentNodes,\n decodeStoredNodes,\n resolveBrandingProfile,\n sanitizeAuthorHtml,\n type ReusableComponentTree,\n} from '@aglyn/aglyn/server'\nimport {\n hostComponentReader,\n loadReferencedComponents,\n type ComponentStoreLike,\n} from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport {\n PLATFORM_BRAND_MERGE_TOKENS,\n buildSystemEmailChrome,\n isPlatformBrandedSend,\n type SystemEmailBrandOptions,\n} from '@aglyn/aglyn/app-utils/system-email-chrome'\n\n// Defined beside the chrome they decide, and re-exported for the callers and\n// specs that reach them through this module.\nexport { isPlatformBrandedSend }\nexport type { SystemEmailBrandOptions }\n\n/**\n * Blanks any `{{token}}` the caller did not supply.\n *\n * `substituteMergeTokens` leaves unknown tokens untouched, which is right for\n * campaigns (an author wants to see the tag they mistyped) but wrong here —\n * a system email goes to a customer, and \"You've been invited to\n * {{org.name}}\" is worse than a gap. Applied only in this resolver so\n * campaign behaviour is unchanged.\n */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Origin a picked image in a system email is fetched from (AGL-1224).\n *\n * A system email is staff-designed and its media lives in the platform\n * library, which the CONSOLE's `/api/media/cdn` route serves — the tenant app\n * mounts the same route, but a recipient of an org invite has no site to\n * resolve against. Same expression `app/layout.tsx` uses for the console's\n * own canonical URL, so the two cannot drift per deploy.\n */\nconst CONSOLE_ORIGIN =\n process.env.NEXT_PUBLIC_CONSOLE_URL ?? 'https://app.aglyn.com'\n\n/**\n * Which copy a system email carries: a design staff published, or the\n * catalog's built-in copy, which is what every key without a usable design\n * sends (AGL-3322).\n */\nexport type SystemEmailSource = 'designed' | 'default'\n\nexport interface RenderedSystemEmail {\n subject: string\n html: string\n text: string\n source: SystemEmailSource\n}\n\n/**\n * A system email loaded once, ready to render for any number of recipients\n * without touching Firestore again (AGL-768): the published staff design, or\n * the catalog's built-in copy when there is none.\n */\nexport interface LoadedSystemEmail {\n definition: SystemEmailTemplateDefinition\n /** The design as stored, each email block still a placement; or the built-in copy's node map. */\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n source: SystemEmailSource\n /**\n * The published definitions of the email blocks `nodes` places, keyed by\n * id, read from the platform marketing site ONCE with the template\n * (AGL-3318), so a batch composes every recipient's copy without another\n * read. Absent when the design places none, when the deployment names no\n * marketing site, and when the read failed; each placement then renders as\n * nothing.\n */\n components?: Record<string, ReusableComponentTree>\n}\n\n/**\n * The email blocks a platform email places, read from the site that owns\n * them (AGL-3318), or `undefined` when there is nothing to read or the read\n * failed.\n *\n * WHICH SITE. A block is a reusable component, and a component belongs to a\n * site; a platform email belongs to none. The header and footer Aglyn's own\n * mail wears are the ones on its marketing site, which the deployment names\n * in `PLATFORM_MARKETING_HOST_ID`, the setting product-email consent already\n * reads. An install that names none reads nothing, and a block placed in its\n * system emails renders as nothing.\n *\n * NEVER A REASON NOT TO SEND. A site's own email fails its whole load when a\n * component read fails, and falls back to its built-in copy. Here the copy is\n * staff's design and the blocks are another site's chrome, so a failed read\n * costs the blocks and nothing else: it is logged, and the design renders\n * inside the coded header and footer instead (see\n * {@link renderLoadedSystemEmail}) rather than being traded for the built-in\n * copy. A password reset whose header could not be read still carries its\n * link.\n */\nasync function loadPlatformEmailBlocks(\n firestore: ComponentStoreLike,\n templateKey: string,\n nodes: Record<string, unknown>,\n): Promise<Record<string, ReusableComponentTree> | undefined> {\n // A design that places nothing costs nothing: no setting read, no read.\n if (!collectReferencedComponentIds(nodes as never).size) return undefined\n const hostId = platformMarketingHostId()\n if (!hostId) return undefined\n try {\n return await loadReferencedComponents(\n nodes,\n hostComponentReader(firestore, hostId),\n {\n onSkipped: (skipped) =>\n console.warn(\n JSON.stringify({\n tag: 'AGL-3318:platform-email-blocks-skipped',\n templateKey,\n hostId,\n skipped,\n }),\n ),\n },\n )\n } catch (error) {\n console.error(\n `system email template ${templateKey}: its email blocks failed to load from ${hostId}`,\n error,\n )\n return undefined\n }\n}\n\n/**\n * Whether one send draws the design's email blocks: a send under the\n * platform's own brand (see {@link isPlatformBrandedSend}) of a design whose\n * blocks the load could read. Every other send renders the stored nodes, each\n * block a placement that draws nothing, inside the coded chrome.\n */\nfunction drawsPlatformBlocks(\n loaded: LoadedSystemEmail,\n merged: Readonly<Record<string, string>>,\n options: SystemEmailBrandOptions,\n): loaded is LoadedSystemEmail & {\n components: Record<string, ReusableComponentTree>\n} {\n return (\n Boolean(loaded.components && Object.keys(loaded.components).length) &&\n isPlatformBrandedSend(merged, options)\n )\n}\n\n/**\n * A key's built-in copy as a loaded email (AGL-3322): the node map the editor\n * seeds a first design with, and the catalog subject. What a key sends until\n * staff publish a design, and whenever the published one cannot be read.\n */\nfunction catalogDefault(\n definition: SystemEmailTemplateDefinition,\n): LoadedSystemEmail {\n return {\n definition,\n nodes: buildDefaultEmailNodeMap(definition),\n subjectTemplate: definition.defaultSubject,\n preheaderTemplate: '',\n source: 'default',\n }\n}\n\n/**\n * Loads what a key sends — ONE Firestore read — ready to render per\n * recipient: the published staff design, or the catalog's built-in copy\n * (AGL-3322) when there is none to use (no document, no published version,\n * an empty node map, or a read failure).\n *\n * `null` only for a key that is unknown or not Resend-delivered, which has\n * nothing here to render. Split out from {@link renderSystemEmail} so a batch\n * send resolves the template once and renders it per recipient, rather than\n * one read each (AGL-768): a Firestore read per invite is fine, one per\n * recipient in a usage-email batch is not.\n *\n * Reads through the Admin SDK, which bypasses the staff-only rules on the\n * collection — the send path runs as the server, not as a signed-in user.\n *\n * A design that places email blocks costs one more read per block, from the\n * platform marketing site, made here with the template so a batch pays it\n * once too (AGL-3318). Whether a recipient's copy expands them is decided per\n * send, by {@link renderLoadedSystemEmail}.\n */\nexport async function loadSystemEmail(\n templateKey: string,\n): Promise<LoadedSystemEmail | null> {\n const definition = getSystemEmailTemplate(templateKey)\n if (!definition) return null\n // A Firebase- or Stripe-delivered email is sent by that service from its\n // own templates, so rendering one here would produce output nothing ever\n // sends (AGL-767).\n if (definition.deliveredBy !== 'resend') return null\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const templateRef = firestore\n .collection(SYSTEM_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return catalogDefault(definition)\n\n const versionId = templateSnapshot.get('versionId')\n // Cleared by \"reset to default\", which nulls the pointer rather than\n // deleting the document so the audit trail survives.\n if (!versionId) return catalogDefault(definition)\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms (AGL-1223). The email besigner compresses like every\n // other besigner document, and versions written before it did are still\n // plain maps — `decodeStoredNodes` returns those unchanged.\n //\n // Reading raw is the failure the guard below cannot catch: over a\n // `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and a staff-authored email renders empty rather than falling\n // back to its built-in copy.\n const nodes = decodeStoredNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!nodes || !Object.keys(nodes).length) return catalogDefault(definition)\n\n const components = await loadPlatformEmailBlocks(\n firestore,\n templateKey,\n nodes,\n )\n return {\n definition,\n nodes,\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n definition.defaultSubject,\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n source: 'designed',\n ...(components && { components }),\n }\n } catch (error) {\n // Never let a template problem block the send, or strip it bare: the\n // recipient gets the built-in copy, in the same header and footer.\n console.error(\n `system email template ${templateKey} failed to load; sending its built-in copy`,\n error,\n )\n return catalogDefault(definition)\n }\n}\n\n/**\n * The theme a system email is drawn in (AGL-3370): the console's palette,\n * or, for a white-label org that set a brand color, the same palette with\n * that color as its primary. Buttons use the console's corner radius.\n */\nexport function systemEmailTheme(options: SystemEmailBrandOptions): EmailTheme {\n const primaryColor = String(options.brandPrimaryColor ?? '').trim()\n return {\n palette: primaryColor\n ? buildEmailPalette({ base: 'platform', primaryColor })\n : PLATFORM_EMAIL_PALETTE,\n buttonRadius: 4,\n }\n}\n\n/**\n * Renders a loaded email for one recipient's merge values (AGL-768). No\n * Firestore access — call {@link loadSystemEmail} once, then this per\n * recipient. `null` only if the renderer produced no document at all.\n *\n * Which header and footer it wears is decided here, per send, because one\n * batch-loaded design goes to recipients of different brands:\n *\n * - A send under the platform's own brand of a design whose email blocks the\n * load read draws those blocks (AGL-3318), and nothing else: they ARE its\n * header and footer, and a second one around them would draw both.\n * - Every other send is drawn inside {@link buildSystemEmailChrome}'s chrome\n * (AGL-3322): the built-in copy, which places no blocks; a design sent to a\n * white-label org's people, whose blocks stay unexpanded and draw nothing,\n * since they are the platform's; and a design on a deployment that names no\n * marketing site, or whose blocks could not be read. The chrome is in the\n * brand of the send — the org's email logo rides in its header, so a\n * white-label mail carries exactly one logo.\n *\n * Either way, a band the design draws itself is left out of the chrome\n * (AGL-3372): a design that opens with its own Header gets the footer only.\n */\nexport function renderLoadedSystemEmail(\n loaded: LoadedSystemEmail,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): RenderedSystemEmail | null {\n const merged = { ...PLATFORM_BRAND_MERGE_TOKENS, ...merge }\n const blocks = drawsPlatformBlocks(loaded, merged, options)\n const nodes = (\n blocks\n ? composeReusableComponentNodes(loaded.nodes as never, loaded.components)\n : loaded.nodes\n ) as Record<string, unknown>\n // The brand's chrome around every copy, less any band the design draws\n // itself (AGL-3372). Unexpanded placements draw nothing in this send, so\n // they only count when the blocks are drawn.\n const chrome = chromeForDesign({\n stored: blocks ? loaded.nodes : null,\n composed: nodes,\n chrome: buildSystemEmailChrome({\n definition: loaded.definition,\n merged,\n brandLogoUrl: options.brandLogoUrl,\n brandHomeUrl: options.brandHomeUrl,\n }),\n })\n const rendered = renderEmailHtml({\n nodes: nodes as never,\n // Besigner maps are rooted at '_@_', not renderEmailHtml's default\n // 'root' — without this a designed template rendered empty and the send\n // fell back to built-in copy, so designing did nothing (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merged),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merged),\n merge: merged,\n // The same policy the console's own preview of these nodes applies —\n // `sanitizeCustomHtml` in the email plugin delegates to this exact\n // function, so what staff review and what the recipient receives are one\n // function of one string rather than two that have to be kept in step.\n sanitize: sanitizeAuthorHtml,\n // Without this a staff-picked image resolves to a site-relative CDN path,\n // which is a broken-image box in the recipient's inbox (AGL-1224). The\n // chrome's logo resolves against it too.\n mediaOrigin: CONSOLE_ORIGIN,\n // In the console's theme (AGL-3370), under a white-label org's own color\n // when it set one: a color staff picked in the Besigner is a palette\n // token, and a button nobody colored wears the brand's accent.\n ...systemEmailTheme(options),\n // No `brandLogoUrl`: the org's email logo (AGL-2139) is the chrome\n // header's logo whenever there is one to draw, and a send that draws the\n // platform's blocks is by definition one that carries none.\n ...(chrome ? { chrome } : {}),\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merged),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n source: loaded.source,\n }\n}\n\n/**\n * Renders the system email a key sends to one recipient (AGL-750): the\n * published staff design, or the catalog's built-in copy (AGL-3322), in the\n * header and footer of the brand it is sent under.\n *\n * **`null` only for a key that is unknown or not Resend-delivered**, which\n * has nothing here to render. No template document, no published version, an\n * empty node map and a Firestore failure all render the built-in copy — the\n * same copy the editor seeds a first design with — so a missing, half-saved\n * or broken template can never mean a customer receives no email, or a bare\n * one. Every caller still keeps its own hard-coded copy behind this, as the\n * last resort for a render that produced nothing.\n *\n * A single-recipient convenience over {@link loadSystemEmail} +\n * {@link renderLoadedSystemEmail}; a batch should call those two directly so\n * it reads the template once.\n */\nexport async function renderSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n const loaded = await loadSystemEmail(templateKey)\n return loaded ? renderLoadedSystemEmail(loaded, merge, options) : null\n}\n\n/**\n * The email a template sends right now, for the staff test-send endpoint\n * (AGL-766): exactly what a real send of it renders — the published design,\n * or the built-in copy, with the same header and footer — so a test shows\n * what recipients receive. `null` for an unknown or non-Resend key.\n */\nexport async function renderEffectiveSystemEmail(\n templateKey: string,\n merge: Record<string, string> = {},\n options: SystemEmailBrandOptions = {},\n): Promise<RenderedSystemEmail | null> {\n return renderSystemEmail(templateKey, merge, options)\n}\n\n/**\n * The brand one system email is sent under (AGL-3367): the merge tokens, the\n * chrome options and the sender name, from one place.\n *\n * `null` is the platform's own brand, and it is the answer for everything\n * the platform says on its own behalf: a staff alert, a legal receipt, an\n * enforcement notice. A workspace-context email passes the workspace's org\n * document, and `resolveBrandingProfile` decides from there: a white-label\n * org gets its own name, logo, home and support in the header and footer,\n * and every other org resolves to the platform profile, so the same call is\n * right for both.\n */\nexport interface SystemEmailBrand {\n merge: Record<string, string>\n options: SystemEmailBrandOptions\n /** The display name to send under; absent sends under the platform's. */\n fromName?: string\n /** The org's own name, for the `{{org.name}}` its copy may use. */\n orgName?: string\n}\n\n/** See {@link SystemEmailBrand}. */\nexport function systemEmailBrand(\n org: Readonly<Record<string, unknown>> | null | undefined,\n): SystemEmailBrand {\n if (!org) return { merge: {}, options: {} }\n const branding = resolveBrandingProfile(org as never)\n const orgName = typeof org['name'] === 'string' ? org['name'].trim() : ''\n return {\n ...(orgName ? { orgName } : {}),\n merge: brandMergeTokens(branding),\n options: {\n brandLogoUrl: branding.emailLogoUrl,\n brandHomeUrl: branding.homeUrl,\n brandPrimaryColor: branding.primaryColor,\n },\n fromName: branding.fromName,\n }\n}\n\n/**\n * {@link systemEmailBrand} for an org read by id, for a sender that holds\n * only the id. A failed or missing read is the platform's brand: the email\n * still goes, and a white-label org's people see the platform's chrome on\n * one message rather than no message.\n */\nexport async function orgSystemEmailBrand(\n orgId: string | null | undefined,\n): Promise<SystemEmailBrand> {\n if (!orgId) return systemEmailBrand(null)\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .get()\n return systemEmailBrand(snapshot.exists ? (snapshot.data() ?? null) : null)\n } catch (error) {\n console.error(`system email brand for org ${orgId} failed to load`, error)\n return systemEmailBrand(null)\n }\n}\n\n/** What a send takes from a rendered system email. */\nexport interface SystemEmailContent {\n subject: string\n text: string\n html?: string\n}\n\n/**\n * The subject, text and HTML to send, with the caller's own copy as the\n * last resort for a render that produced nothing: the shape every sender of\n * a system email repeats, written once.\n */\nexport function systemEmailContent(\n rendered: RenderedSystemEmail | null,\n fallback: { subject: string; text: string },\n): SystemEmailContent {\n return {\n subject: rendered?.subject || fallback.subject,\n text: rendered?.text || fallback.text,\n ...(rendered?.html ? { html: rendered.html } : {}),\n }\n}\n\n/**\n * Renders a key for one recipient in a brand and returns what to send\n * (AGL-3367): {@link renderSystemEmail} and {@link systemEmailContent} in\n * one call, for the senders with one message to build. Never throws; a\n * render that fails sends the fallback copy, which is what it sent before.\n */\nexport async function renderSystemEmailContent(\n templateKey: string,\n merge: Record<string, string>,\n brand: SystemEmailBrand,\n fallback: { subject: string; text: string },\n): Promise<SystemEmailContent> {\n const rendered = await renderSystemEmail(\n templateKey,\n { ...brand.merge, ...merge },\n brand.options,\n ).catch((error) => {\n console.error(`system email ${templateKey} failed to render`, error)\n return null\n })\n return systemEmailContent(rendered, fallback)\n}\n\nexport default renderSystemEmail\n"],"names":["process","EMAIL_NODE_ROOT_ID","PLATFORM_EMAIL_PALETTE","chromeForDesign","SYSTEM_EMAIL_COLLECTION","buildEmailPalette","buildDefaultEmailNodeMap","getSystemEmailTemplate","renderEmailHtml","substituteMergeTokens","firebaseAdmin","platformMarketingHostId","brandMergeTokens","collectReferencedComponentIds","composeReusableComponentNodes","decodeStoredNodes","resolveBrandingProfile","sanitizeAuthorHtml","hostComponentReader","loadReferencedComponents","PLATFORM_BRAND_MERGE_TOKENS","buildSystemEmailChrome","isPlatformBrandedSend","blankUnresolvedTokens","value","replace","CONSOLE_ORIGIN","env","NEXT_PUBLIC_CONSOLE_URL","loadPlatformEmailBlocks","firestore","templateKey","nodes","size","undefined","hostId","onSkipped","skipped","console","warn","JSON","stringify","tag","error","drawsPlatformBlocks","loaded","merged","options","Boolean","components","Object","keys","length","catalogDefault","definition","subjectTemplate","defaultSubject","preheaderTemplate","source","loadSystemEmail","deliveredBy","templateSnapshot","app","templateRef","collection","doc","get","exists","versionId","versionSnapshot","String","systemEmailTheme","primaryColor","brandPrimaryColor","trim","palette","base","buttonRadius","renderLoadedSystemEmail","merge","rendered","blocks","chrome","stored","composed","brandLogoUrl","brandHomeUrl","rootId","subject","preheader","sanitize","mediaOrigin","html","text","renderSystemEmail","renderEffectiveSystemEmail","systemEmailBrand","org","branding","orgName","emailLogoUrl","homeUrl","fromName","orgSystemEmailBrand","orgId","snapshot","data","systemEmailContent","fallback","renderSystemEmailContent","brand","catch"],"mappings":"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"}
@@ -14,6 +14,15 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ /**
18
+ * The block below runs in two places: here, at the session mint, and in the
19
+ * `cloud/functions` account-creation blocking function, which refuses the
20
+ * account before it exists. `cloud/functions` is a plain npm package that can
21
+ * import only firebase-admin and firebase-functions, so it holds a verbatim
22
+ * copy (`cloud/functions/src/sso-domain-policy.ts`), and
23
+ * `apps/console/specs/sso-domain-creation-wiring.spec.ts` fails if the two
24
+ * differ by a single character. Change it here, then copy the region across.
25
+ */
17
26
  export declare const SSO_REQUIRED_DOMAINS_ENV = "AGLYN_SSO_REQUIRED_DOMAINS";
18
27
  export type SsoRequiredDomains = Readonly<Record<string, string>>;
19
28
  /**
@@ -28,6 +37,23 @@ export declare function parseSsoRequiredDomains(raw: string | null | undefined):
28
37
  * The operator's SSO-required domains. Empty unless configured — see above.
29
38
  */
30
39
  export declare function ssoRequiredDomains(env?: Record<string, string | undefined>): SsoRequiredDomains;
40
+ /**
41
+ * Addresses on a governed domain that may still sign in OUTSIDE its tenant.
42
+ *
43
+ * For accounts that cannot use the IdP at all because someone outside the
44
+ * company has to sign in with them: the demo login an app store reviewer is
45
+ * given, for example. Exact addresses only, never a pattern, so every
46
+ * exception is named and can be read back from configuration:
47
+ *
48
+ * ```
49
+ * AGLYN_SSO_EXEMPT_EMAILS="reviewer@example.com"
50
+ * ```
51
+ *
52
+ * Comma or whitespace separated, compared lower-cased. Empty by default.
53
+ */
54
+ export declare const SSO_EXEMPT_EMAILS_ENV = "AGLYN_SSO_EXEMPT_EMAILS";
55
+ export declare function parseSsoExemptEmails(raw: string | null | undefined): ReadonlySet<string>;
56
+ export declare function ssoExemptEmails(env?: Record<string, string | undefined>): ReadonlySet<string>;
31
57
  export type SsoDomainVerdict =
32
58
  /** Domain is not governed. Any pool is fine — this is most of the world. */
33
59
  'allow-ungoverned'
@@ -39,6 +65,11 @@ export type SsoDomainVerdict =
39
65
  * nobody asked for — but surfaced so a staff-access review can see it.
40
66
  */
41
67
  | 'allow-foreign-tenant'
68
+ /**
69
+ * Governed domain, no tenant, but the address is named in
70
+ * {@link SSO_EXEMPT_EMAILS_ENV}. Allowed and reviewable.
71
+ */
72
+ | 'allow-exempt'
42
73
  /**
43
74
  * Governed domain with NO tenant at all: a project-pool password or consumer
44
75
  * Google sign-in. The only refusal this policy ever produces.
@@ -82,7 +113,7 @@ export declare function domainOf(email: string | null | undefined): string | nul
82
113
  * so a spec can drive every combination directly, and so the audit view and
83
114
  * the enforcement path can never disagree about what the policy says.
84
115
  */
85
- export declare function evaluateSsoDomainPolicy(identity: SsoDomainIdentity, domains?: SsoRequiredDomains): SsoDomainDecision;
116
+ export declare function evaluateSsoDomainPolicy(identity: SsoDomainIdentity, domains?: SsoRequiredDomains, exempt?: ReadonlySet<string>): SsoDomainDecision;
86
117
  /**
87
118
  * THE ENFORCEMENT SWITCH — ships OFF.
88
119
  *
@@ -102,18 +133,23 @@ export declare function evaluateSsoDomainPolicy(identity: SsoDomainIdentity, dom
102
133
  * **Precondition for flipping it — written down so it is not re-litigated:**
103
134
  * 1. SSO staff access verified working through a REAL sign-in (not a token
104
135
  * inspection): a staff account reaches `/admin/*` via the SAML tenant.
105
- * 2. Every identity on a governed domain has been migrated into its tenant or
106
- * retired. Measured on aglyn-main 2026-08-18: TWO would have been refused,
107
- * neither of them staff — one on a consumer `google.com` provider, and the
108
- * e2e smoke account on `password`. Flipping before migrating breaks the
109
- * e2e smoke sign-in, which is the one that bites without warning: it is an
110
- * AUTOMATION account, so nobody is watching a sign-in page when it fails.
136
+ * 2. Every identity on a governed domain has been migrated into its tenant,
137
+ * retired, or named in {@link SSO_EXEMPT_EMAILS_ENV}. An automation or
138
+ * reviewer account is the one that bites without warning: nobody is
139
+ * watching a sign-in page when it fails.
111
140
  *
112
141
  * A second staff identity is NOT a precondition: the permanent break-glass
113
142
  * account is on an ungoverned domain, so this rule can never refuse it.
114
143
  */
115
144
  export declare const SSO_DOMAIN_ENFORCEMENT_ENV = "AGLYN_SSO_DOMAIN_ENFORCEMENT";
116
145
  export declare function ssoDomainEnforcementEnabled(env?: Record<string, string | undefined>): boolean;
146
+ /**
147
+ * Whether an account about to be CREATED must be refused. Same switch, same
148
+ * domains, same exemptions as the session mint, so an address the mint would
149
+ * turn away is never created in the first place, and an address it admits
150
+ * is never refused here.
151
+ */
152
+ export declare function ssoDomainCreationRefused(identity: SsoDomainIdentity, env?: Record<string, string | undefined>): boolean;
117
153
  /**
118
154
  * What the refused person is told. Names the fix, not the mechanism.
119
155
  *
@@ -83,6 +83,15 @@
83
83
  * naming WHOSE SSO: the point is IdP lifecycle control, which only the tenant
84
84
  * wired to that domain's SAML provider delivers.
85
85
  */ import { PLATFORM_BRAND_NAME } from "@aglyn/aglyn/app-utils/platform-brand";
86
+ /**
87
+ * The block below runs in two places: here, at the session mint, and in the
88
+ * `cloud/functions` account-creation blocking function, which refuses the
89
+ * account before it exists. `cloud/functions` is a plain npm package that can
90
+ * import only firebase-admin and firebase-functions, so it holds a verbatim
91
+ * copy (`cloud/functions/src/sso-domain-policy.ts`), and
92
+ * `apps/console/specs/sso-domain-creation-wiring.spec.ts` fails if the two
93
+ * differ by a single character. Change it here, then copy the region across.
94
+ */ // #region sso-domain-policy
86
95
  export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
87
96
  /**
88
97
  * Parse the configured domain→tenant map. Total: anything unparseable is
@@ -108,6 +117,31 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
108
117
  */ export function ssoRequiredDomains(env = process.env) {
109
118
  return parseSsoRequiredDomains(env[SSO_REQUIRED_DOMAINS_ENV]);
110
119
  }
120
+ /**
121
+ * Addresses on a governed domain that may still sign in OUTSIDE its tenant.
122
+ *
123
+ * For accounts that cannot use the IdP at all because someone outside the
124
+ * company has to sign in with them: the demo login an app store reviewer is
125
+ * given, for example. Exact addresses only, never a pattern, so every
126
+ * exception is named and can be read back from configuration:
127
+ *
128
+ * ```
129
+ * AGLYN_SSO_EXEMPT_EMAILS="reviewer@example.com"
130
+ * ```
131
+ *
132
+ * Comma or whitespace separated, compared lower-cased. Empty by default.
133
+ */ export const SSO_EXEMPT_EMAILS_ENV = 'AGLYN_SSO_EXEMPT_EMAILS';
134
+ export function parseSsoExemptEmails(raw) {
135
+ const emails = new Set();
136
+ for (const entry of String(raw != null ? raw : '').split(/[,\s]+/)){
137
+ const email = entry.trim().toLowerCase();
138
+ if (email.includes('@')) emails.add(email);
139
+ }
140
+ return emails;
141
+ }
142
+ export function ssoExemptEmails(env = process.env) {
143
+ return parseSsoExemptEmails(env[SSO_EXEMPT_EMAILS_ENV]);
144
+ }
111
145
  /** The domain part of an address, lower-cased. Null when there isn't one. */ export function domainOf(email) {
112
146
  const address = String(email != null ? email : '').trim().toLowerCase();
113
147
  const at = address.lastIndexOf('@');
@@ -118,8 +152,8 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
118
152
  * Decide the policy for one identity. Pure and total — no Firebase, no env —
119
153
  * so a spec can drive every combination directly, and so the audit view and
120
154
  * the enforcement path can never disagree about what the policy says.
121
- */ export function evaluateSsoDomainPolicy(identity, domains = ssoRequiredDomains()) {
122
- var _identity_tenantId;
155
+ */ export function evaluateSsoDomainPolicy(identity, domains = ssoRequiredDomains(), exempt = ssoExemptEmails()) {
156
+ var _identity_tenantId, _identity_email;
123
157
  const domain = domainOf(identity.email);
124
158
  const tenantId = (_identity_tenantId = identity.tenantId) != null ? _identity_tenantId : null;
125
159
  // Exact-match lookup, never a suffix test: `notaglyn.com` ends with
@@ -161,6 +195,16 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
161
195
  reviewable: true
162
196
  };
163
197
  }
198
+ if (exempt.has(String((_identity_email = identity.email) != null ? _identity_email : '').trim().toLowerCase())) {
199
+ return {
200
+ verdict: 'allow-exempt',
201
+ refused: false,
202
+ domain,
203
+ requiredTenantId,
204
+ tenantId: null,
205
+ reviewable: true
206
+ };
207
+ }
164
208
  return {
165
209
  verdict: 'refuse-sso-required',
166
210
  refused: true,
@@ -189,12 +233,10 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
189
233
  * **Precondition for flipping it — written down so it is not re-litigated:**
190
234
  * 1. SSO staff access verified working through a REAL sign-in (not a token
191
235
  * inspection): a staff account reaches `/admin/*` via the SAML tenant.
192
- * 2. Every identity on a governed domain has been migrated into its tenant or
193
- * retired. Measured on aglyn-main 2026-08-18: TWO would have been refused,
194
- * neither of them staff — one on a consumer `google.com` provider, and the
195
- * e2e smoke account on `password`. Flipping before migrating breaks the
196
- * e2e smoke sign-in, which is the one that bites without warning: it is an
197
- * AUTOMATION account, so nobody is watching a sign-in page when it fails.
236
+ * 2. Every identity on a governed domain has been migrated into its tenant,
237
+ * retired, or named in {@link SSO_EXEMPT_EMAILS_ENV}. An automation or
238
+ * reviewer account is the one that bites without warning: nobody is
239
+ * watching a sign-in page when it fails.
198
240
  *
199
241
  * A second staff identity is NOT a precondition: the permanent break-glass
200
242
  * account is on an ungoverned domain, so this rule can never refuse it.
@@ -202,6 +244,16 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
202
244
  export function ssoDomainEnforcementEnabled(env = process.env) {
203
245
  return env[SSO_DOMAIN_ENFORCEMENT_ENV] === 'on';
204
246
  }
247
+ /**
248
+ * Whether an account about to be CREATED must be refused. Same switch, same
249
+ * domains, same exemptions as the session mint, so an address the mint would
250
+ * turn away is never created in the first place, and an address it admits
251
+ * is never refused here.
252
+ */ export function ssoDomainCreationRefused(identity, env = process.env) {
253
+ if (!ssoDomainEnforcementEnabled(env)) return false;
254
+ return evaluateSsoDomainPolicy(identity, ssoRequiredDomains(env), ssoExemptEmails(env)).refused;
255
+ }
256
+ // #endregion sso-domain-policy
205
257
  /**
206
258
  * What the refused person is told. Names the fix, not the mechanism.
207
259
  *
@@ -220,7 +272,7 @@ export function ssoDomainEnforcementEnabled(env = process.env) {
220
272
  * authentication path is inert until the account owner flips it — the call site is live,
221
273
  * the consequence is not.
222
274
  */ export function ssoDomainRefusal(identity, env = process.env) {
223
- const decision = evaluateSsoDomainPolicy(identity, ssoRequiredDomains(env));
275
+ const decision = evaluateSsoDomainPolicy(identity, ssoRequiredDomains(env), ssoExemptEmails(env));
224
276
  if (!decision.refused) return null;
225
277
  if (!ssoDomainEnforcementEnabled(env)) return null;
226
278
  return {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sso-domain-policy.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 * Workspace-domain SSO policy (AGL-1993).\n *\n * **The rule is about a DOMAIN, not about staff.** An `@aglyn.com` address is a\n * Google Workspace identity, and Workspace is what enforces MFA, disables an\n * account at offboarding, and lets an admin revoke a session. An `@aglyn.com`\n * address that signs in through a project-pool password or a consumer Google\n * account has none of that: it looks like a company identity and is governed\n * like a personal one. That gap is the whole reason this module exists.\n *\n * ## Staff is a SEPARATE axis and this module never reads it\n *\n * Staff may be granted to anybody — SSO or not, `@aglyn.com` or not, project\n * pool or any GCIP tenant including a *customer's*. Nothing here consults the\n * `staff` claim, and `sso-domain-policy.spec.ts` pins that by evaluating every\n * staff permutation and asserting the verdict does not move. The two axes are\n * independent; conflating them would turn a narrow domain control into an\n * accidental SSO mandate for staff, which is explicitly not the policy.\n *\n * These all remain valid, and each has a test:\n *\n * | identity | staff | verdict |\n * | ------------------------------------------ | ----- | ------------------------- |\n * | `@aglyn.com` in the designated tenant | yes | allowed |\n * | personal address, project pool (break-glass)| yes | allowed — permanent design|\n * | personal address, project pool | no | allowed |\n * | SSO identity in a CUSTOMER org's tenant | yes | allowed |\n * | `@aglyn.com` in the PROJECT pool | any | **refused** when enforced |\n *\n * Only the last row is what the switch exists for.\n *\n * ## How this differs from `sso-enforcement.ts`\n *\n * {@link enforceSsoSignInMethods} (AGL-1128/1129) strips non-IdP providers from\n * accounts that are ALREADY INSIDE a tenant. It cannot see an identity that\n * never joined the tenant at all — a project-pool `@aglyn.com` account is\n * invisible to every tenant sweep, by construction. Measured on aglyn-main on\n * 2026-08-18: two such accounts existed, one of them holding a live\n * `google.com` provider. This module covers that outside-the-tenant half.\n *\n * ## Shape note: deliberately NOT a discriminated union\n *\n * `strictNullChecks` is off repo-wide, which defeats boolean-discriminant\n * narrowing — a spec can read the wrong arm of a union and pass silently. So\n * the decision is ONE flat interface with a string `verdict`, and specs assert\n * the literal. There is no arm to read by mistake.\n */\n\n/**\n * ## Self-hosting: this policy is CONFIGURATION, never a compiled-in constant\n *\n * Aglyn ships in two shapes — the Aglyn-operated SaaS, and self-hosted installs\n * on the operator's own Firebase project. `aglyn.com` is *our* Workspace\n * domain; a self-hoster has their own domain, their own IdP, or none at all.\n * Baking our domain in would make the rule dead weight for them at best and a\n * lockout at worst, so the governed set is read from configuration and is\n * **empty by default**.\n *\n * Empty means the rule does nothing. That is simultaneously the correct\n * behaviour for a fresh self-host install and the correct OFF state for us —\n * one default serving both, rather than a special case for either.\n *\n * Format — `domain=tenantId`, comma or whitespace separated:\n *\n * ```\n * AGLYN_SSO_REQUIRED_DOMAINS=\"aglyn.com=aglyn-org-y5v14\"\n * ```\n *\n * A map rather than a bare list because \"must use SSO\" is meaningless without\n * naming WHOSE SSO: the point is IdP lifecycle control, which only the tenant\n * wired to that domain's SAML provider delivers.\n */\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\n\nexport const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS'\n\nexport type SsoRequiredDomains = Readonly<Record<string, string>>\n\n/**\n * Parse the configured domain→tenant map. Total: anything unparseable is\n * dropped rather than throwing, because this sits on the authentication path\n * and a malformed env var must not take sign-in down. A dropped entry fails\n * OPEN (the domain is simply ungoverned), which is the safe direction for a\n * rule whose only power is to refuse.\n */\nexport function parseSsoRequiredDomains(\n raw: string | null | undefined,\n): SsoRequiredDomains {\n const map: Record<string, string> = {}\n for (const pair of String(raw ?? '').split(/[,\\s]+/)) {\n if (!pair) continue\n const eq = pair.indexOf('=')\n if (eq <= 0) continue\n const domain = pair.slice(0, eq).trim().toLowerCase()\n const tenantId = pair.slice(eq + 1).trim()\n if (!domain || !tenantId) continue\n map[domain] = tenantId\n }\n return map\n}\n\n/**\n * The operator's SSO-required domains. Empty unless configured — see above.\n */\nexport function ssoRequiredDomains(\n env: Record<string, string | undefined> = process.env,\n): SsoRequiredDomains {\n return parseSsoRequiredDomains(env[SSO_REQUIRED_DOMAINS_ENV])\n}\n\nexport type SsoDomainVerdict =\n /** Domain is not governed. Any pool is fine — this is most of the world. */\n | 'allow-ungoverned'\n /** Governed domain, signed in through the tenant we designated for it. */\n | 'allow-designated-tenant'\n /**\n * Governed domain, signed in through SOME tenant, but not the designated\n * one. Allowed — they did use SSO, and refusing would invent a restriction\n * nobody asked for — but surfaced so a staff-access review can see it.\n */\n | 'allow-foreign-tenant'\n /**\n * Governed domain with NO tenant at all: a project-pool password or consumer\n * Google sign-in. The only refusal this policy ever produces.\n */\n | 'refuse-sso-required'\n\nexport interface SsoDomainDecision {\n verdict: SsoDomainVerdict\n /**\n * Whether enforcement WOULD refuse this identity. Independent of whether\n * enforcement is currently switched on — {@link ssoDomainEnforcementEnabled}\n * decides that — so the same evaluation can drive both an audit in\n * report-only mode and the refusal once the switch is flipped.\n */\n refused: boolean\n /** Lower-cased domain, or null when the identity has no email address. */\n domain: string | null\n /** Tenant this domain is required to use, or null when ungoverned. */\n requiredTenantId: string | null\n /** Tenant the identity actually signed in through; null = project pool. */\n tenantId: string | null\n /** Worth a human look even though it is allowed (foreign-tenant case). */\n reviewable: boolean\n}\n\nexport interface SsoDomainIdentity {\n /** The identity's email address. Absent/empty is treated as ungoverned. */\n email?: string | null\n /**\n * GCIP tenant the identity signed in through — `decoded.firebase?.tenant`,\n * or a `PooledUserRecord.tenantId`. Null/undefined means the project pool.\n *\n * Never inferred and never defaulted to a hard-coded tenant: a uid is only\n * unique WITHIN a pool, and assuming one is how a lookup silently lands on\n * the wrong record.\n */\n tenantId?: string | null\n}\n\n/** The domain part of an address, lower-cased. Null when there isn't one. */\nexport function domainOf(email: string | null | undefined): string | null {\n const address = String(email ?? '').trim().toLowerCase()\n const at = address.lastIndexOf('@')\n if (at < 0 || at === address.length - 1) return null\n return address.slice(at + 1)\n}\n\n/**\n * Decide the policy for one identity. Pure and total — no Firebase, no env —\n * so a spec can drive every combination directly, and so the audit view and\n * the enforcement path can never disagree about what the policy says.\n */\nexport function evaluateSsoDomainPolicy(\n identity: SsoDomainIdentity,\n domains: SsoRequiredDomains = ssoRequiredDomains(),\n): SsoDomainDecision {\n const domain = domainOf(identity.email)\n const tenantId = identity.tenantId ?? null\n // Exact-match lookup, never a suffix test: `notaglyn.com` ends with\n // `aglyn.com`, and a suffix test would refuse an unrelated company's staff.\n const requiredTenantId = domain\n ? (Object.prototype.hasOwnProperty.call(domains, domain)\n ? domains[domain]\n : null)\n : null\n\n if (!requiredTenantId) {\n // Ungoverned: a personal address, a customer's address, anything not on\n // the list. Includes the permanent break-glass account by construction\n // rather than by special case — see the staff-access doc.\n return {\n verdict: 'allow-ungoverned',\n refused: false,\n domain,\n requiredTenantId: null,\n tenantId,\n reviewable: false,\n }\n }\n\n if (tenantId === requiredTenantId) {\n return {\n verdict: 'allow-designated-tenant',\n refused: false,\n domain,\n requiredTenantId,\n tenantId,\n reviewable: false,\n }\n }\n\n if (tenantId) {\n // A governed address inside somebody else's tenant. They authenticated\n // through an IdP, so the hole this policy closes is not open here — but\n // the IdP is not one Aglyn controls, so it is flagged for review.\n return {\n verdict: 'allow-foreign-tenant',\n refused: false,\n domain,\n requiredTenantId,\n tenantId,\n reviewable: true,\n }\n }\n\n return {\n verdict: 'refuse-sso-required',\n refused: true,\n domain,\n requiredTenantId,\n tenantId: null,\n reviewable: true,\n }\n}\n\n/**\n * THE ENFORCEMENT SWITCH — ships OFF.\n *\n * Off means `evaluateSsoDomainPolicy` still computes `refused`, and callers\n * still record it, but nobody is turned away. That is deliberate: the audit\n * trail accumulates under the real rule before the rule bites, so flipping the\n * switch holds no surprises.\n *\n * Opt-in by exact string. Anything else — unset, empty, `false`, `0`, a typo —\n * is OFF. A security control that switches itself on through a misspelling is\n * a control that can lock people out through a misspelling.\n *\n * Note both halves must be set for anything to happen: this switch AND a\n * non-empty {@link SSO_REQUIRED_DOMAINS_ENV}. A self-host install that sets\n * only this switch still governs nothing.\n *\n * **Precondition for flipping it — written down so it is not re-litigated:**\n * 1. SSO staff access verified working through a REAL sign-in (not a token\n * inspection): a staff account reaches `/admin/*` via the SAML tenant.\n * 2. Every identity on a governed domain has been migrated into its tenant or\n * retired. Measured on aglyn-main 2026-08-18: TWO would have been refused,\n * neither of them staff — one on a consumer `google.com` provider, and the\n * e2e smoke account on `password`. Flipping before migrating breaks the\n * e2e smoke sign-in, which is the one that bites without warning: it is an\n * AUTOMATION account, so nobody is watching a sign-in page when it fails.\n *\n * A second staff identity is NOT a precondition: the permanent break-glass\n * account is on an ungoverned domain, so this rule can never refuse it.\n */\nexport const SSO_DOMAIN_ENFORCEMENT_ENV = 'AGLYN_SSO_DOMAIN_ENFORCEMENT'\n\nexport function ssoDomainEnforcementEnabled(\n env: Record<string, string | undefined> = process.env,\n): boolean {\n return env[SSO_DOMAIN_ENFORCEMENT_ENV] === 'on'\n}\n\n/**\n * What the refused person is told. Names the fix, not the mechanism.\n *\n * `PLATFORM_BRAND_NAME`, not a literal (AGL-2214). Everything else in this\n * module is careful that the RULE is configuration rather than a compiled-in\n * assumption about Aglyn — and then the one string a refused person actually\n * reads named us anyway. A self-hoster who governs their own domain would\n * have turned their own staff away in our name.\n *\n * Byte-identical for a deployment that has not renamed itself.\n */\nexport const SSO_DOMAIN_REFUSAL_MESSAGE =\n `Sign in with your ${PLATFORM_BRAND_NAME} Workspace account ` +\n '(Single sign-on). Accounts on this domain cannot use a password or ' +\n 'personal Google sign-in.'\n\n/**\n * The refusal a caller should return, or null to carry on.\n *\n * Returns null whenever the switch is off, so wiring this into an\n * authentication path is inert until the account owner flips it — the call site is live,\n * the consequence is not.\n */\nexport function ssoDomainRefusal(\n identity: SsoDomainIdentity,\n env: Record<string, string | undefined> = process.env,\n): { decision: SsoDomainDecision; response: Response } | null {\n const decision = evaluateSsoDomainPolicy(identity, ssoRequiredDomains(env))\n if (!decision.refused) return null\n if (!ssoDomainEnforcementEnabled(env)) return null\n return {\n decision,\n response: Response.json(\n {\n error: SSO_DOMAIN_REFUSAL_MESSAGE,\n reason: 'sso-domain-required',\n domain: decision.domain,\n },\n { status: 403 },\n ),\n }\n}\n\nexport default evaluateSsoDomainPolicy\n"],"names":["PLATFORM_BRAND_NAME","SSO_REQUIRED_DOMAINS_ENV","parseSsoRequiredDomains","raw","map","pair","String","split","eq","indexOf","domain","slice","trim","toLowerCase","tenantId","ssoRequiredDomains","env","process","domainOf","email","address","at","lastIndexOf","length","evaluateSsoDomainPolicy","identity","domains","requiredTenantId","Object","prototype","hasOwnProperty","call","verdict","refused","reviewable","SSO_DOMAIN_ENFORCEMENT_ENV","ssoDomainEnforcementEnabled","SSO_DOMAIN_REFUSAL_MESSAGE","ssoDomainRefusal","decision","response","Response","json","error","reason","status"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sso-domain-policy.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 * Workspace-domain SSO policy (AGL-1993).\n *\n * **The rule is about a DOMAIN, not about staff.** An `@aglyn.com` address is a\n * Google Workspace identity, and Workspace is what enforces MFA, disables an\n * account at offboarding, and lets an admin revoke a session. An `@aglyn.com`\n * address that signs in through a project-pool password or a consumer Google\n * account has none of that: it looks like a company identity and is governed\n * like a personal one. That gap is the whole reason this module exists.\n *\n * ## Staff is a SEPARATE axis and this module never reads it\n *\n * Staff may be granted to anybody — SSO or not, `@aglyn.com` or not, project\n * pool or any GCIP tenant including a *customer's*. Nothing here consults the\n * `staff` claim, and `sso-domain-policy.spec.ts` pins that by evaluating every\n * staff permutation and asserting the verdict does not move. The two axes are\n * independent; conflating them would turn a narrow domain control into an\n * accidental SSO mandate for staff, which is explicitly not the policy.\n *\n * These all remain valid, and each has a test:\n *\n * | identity | staff | verdict |\n * | ------------------------------------------ | ----- | ------------------------- |\n * | `@aglyn.com` in the designated tenant | yes | allowed |\n * | personal address, project pool (break-glass)| yes | allowed — permanent design|\n * | personal address, project pool | no | allowed |\n * | SSO identity in a CUSTOMER org's tenant | yes | allowed |\n * | `@aglyn.com` in the PROJECT pool | any | **refused** when enforced |\n *\n * Only the last row is what the switch exists for.\n *\n * ## How this differs from `sso-enforcement.ts`\n *\n * {@link enforceSsoSignInMethods} (AGL-1128/1129) strips non-IdP providers from\n * accounts that are ALREADY INSIDE a tenant. It cannot see an identity that\n * never joined the tenant at all — a project-pool `@aglyn.com` account is\n * invisible to every tenant sweep, by construction. Measured on aglyn-main on\n * 2026-08-18: two such accounts existed, one of them holding a live\n * `google.com` provider. This module covers that outside-the-tenant half.\n *\n * ## Shape note: deliberately NOT a discriminated union\n *\n * `strictNullChecks` is off repo-wide, which defeats boolean-discriminant\n * narrowing — a spec can read the wrong arm of a union and pass silently. So\n * the decision is ONE flat interface with a string `verdict`, and specs assert\n * the literal. There is no arm to read by mistake.\n */\n\n/**\n * ## Self-hosting: this policy is CONFIGURATION, never a compiled-in constant\n *\n * Aglyn ships in two shapes — the Aglyn-operated SaaS, and self-hosted installs\n * on the operator's own Firebase project. `aglyn.com` is *our* Workspace\n * domain; a self-hoster has their own domain, their own IdP, or none at all.\n * Baking our domain in would make the rule dead weight for them at best and a\n * lockout at worst, so the governed set is read from configuration and is\n * **empty by default**.\n *\n * Empty means the rule does nothing. That is simultaneously the correct\n * behaviour for a fresh self-host install and the correct OFF state for us —\n * one default serving both, rather than a special case for either.\n *\n * Format — `domain=tenantId`, comma or whitespace separated:\n *\n * ```\n * AGLYN_SSO_REQUIRED_DOMAINS=\"aglyn.com=aglyn-org-y5v14\"\n * ```\n *\n * A map rather than a bare list because \"must use SSO\" is meaningless without\n * naming WHOSE SSO: the point is IdP lifecycle control, which only the tenant\n * wired to that domain's SAML provider delivers.\n */\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\n\n/**\n * The block below runs in two places: here, at the session mint, and in the\n * `cloud/functions` account-creation blocking function, which refuses the\n * account before it exists. `cloud/functions` is a plain npm package that can\n * import only firebase-admin and firebase-functions, so it holds a verbatim\n * copy (`cloud/functions/src/sso-domain-policy.ts`), and\n * `apps/console/specs/sso-domain-creation-wiring.spec.ts` fails if the two\n * differ by a single character. Change it here, then copy the region across.\n */\n// #region sso-domain-policy\nexport const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS'\n\nexport type SsoRequiredDomains = Readonly<Record<string, string>>\n\n/**\n * Parse the configured domain→tenant map. Total: anything unparseable is\n * dropped rather than throwing, because this sits on the authentication path\n * and a malformed env var must not take sign-in down. A dropped entry fails\n * OPEN (the domain is simply ungoverned), which is the safe direction for a\n * rule whose only power is to refuse.\n */\nexport function parseSsoRequiredDomains(\n raw: string | null | undefined,\n): SsoRequiredDomains {\n const map: Record<string, string> = {}\n for (const pair of String(raw ?? '').split(/[,\\s]+/)) {\n if (!pair) continue\n const eq = pair.indexOf('=')\n if (eq <= 0) continue\n const domain = pair.slice(0, eq).trim().toLowerCase()\n const tenantId = pair.slice(eq + 1).trim()\n if (!domain || !tenantId) continue\n map[domain] = tenantId\n }\n return map\n}\n\n/**\n * The operator's SSO-required domains. Empty unless configured — see above.\n */\nexport function ssoRequiredDomains(\n env: Record<string, string | undefined> = process.env,\n): SsoRequiredDomains {\n return parseSsoRequiredDomains(env[SSO_REQUIRED_DOMAINS_ENV])\n}\n\n/**\n * Addresses on a governed domain that may still sign in OUTSIDE its tenant.\n *\n * For accounts that cannot use the IdP at all because someone outside the\n * company has to sign in with them: the demo login an app store reviewer is\n * given, for example. Exact addresses only, never a pattern, so every\n * exception is named and can be read back from configuration:\n *\n * ```\n * AGLYN_SSO_EXEMPT_EMAILS=\"reviewer@example.com\"\n * ```\n *\n * Comma or whitespace separated, compared lower-cased. Empty by default.\n */\nexport const SSO_EXEMPT_EMAILS_ENV = 'AGLYN_SSO_EXEMPT_EMAILS'\n\nexport function parseSsoExemptEmails(\n raw: string | null | undefined,\n): ReadonlySet<string> {\n const emails = new Set<string>()\n for (const entry of String(raw ?? '').split(/[,\\s]+/)) {\n const email = entry.trim().toLowerCase()\n if (email.includes('@')) emails.add(email)\n }\n return emails\n}\n\nexport function ssoExemptEmails(\n env: Record<string, string | undefined> = process.env,\n): ReadonlySet<string> {\n return parseSsoExemptEmails(env[SSO_EXEMPT_EMAILS_ENV])\n}\n\nexport type SsoDomainVerdict =\n /** Domain is not governed. Any pool is fine — this is most of the world. */\n | 'allow-ungoverned'\n /** Governed domain, signed in through the tenant we designated for it. */\n | 'allow-designated-tenant'\n /**\n * Governed domain, signed in through SOME tenant, but not the designated\n * one. Allowed — they did use SSO, and refusing would invent a restriction\n * nobody asked for — but surfaced so a staff-access review can see it.\n */\n | 'allow-foreign-tenant'\n /**\n * Governed domain, no tenant, but the address is named in\n * {@link SSO_EXEMPT_EMAILS_ENV}. Allowed and reviewable.\n */\n | 'allow-exempt'\n /**\n * Governed domain with NO tenant at all: a project-pool password or consumer\n * Google sign-in. The only refusal this policy ever produces.\n */\n | 'refuse-sso-required'\n\nexport interface SsoDomainDecision {\n verdict: SsoDomainVerdict\n /**\n * Whether enforcement WOULD refuse this identity. Independent of whether\n * enforcement is currently switched on — {@link ssoDomainEnforcementEnabled}\n * decides that — so the same evaluation can drive both an audit in\n * report-only mode and the refusal once the switch is flipped.\n */\n refused: boolean\n /** Lower-cased domain, or null when the identity has no email address. */\n domain: string | null\n /** Tenant this domain is required to use, or null when ungoverned. */\n requiredTenantId: string | null\n /** Tenant the identity actually signed in through; null = project pool. */\n tenantId: string | null\n /** Worth a human look even though it is allowed (foreign-tenant case). */\n reviewable: boolean\n}\n\nexport interface SsoDomainIdentity {\n /** The identity's email address. Absent/empty is treated as ungoverned. */\n email?: string | null\n /**\n * GCIP tenant the identity signed in through — `decoded.firebase?.tenant`,\n * or a `PooledUserRecord.tenantId`. Null/undefined means the project pool.\n *\n * Never inferred and never defaulted to a hard-coded tenant: a uid is only\n * unique WITHIN a pool, and assuming one is how a lookup silently lands on\n * the wrong record.\n */\n tenantId?: string | null\n}\n\n/** The domain part of an address, lower-cased. Null when there isn't one. */\nexport function domainOf(email: string | null | undefined): string | null {\n const address = String(email ?? '').trim().toLowerCase()\n const at = address.lastIndexOf('@')\n if (at < 0 || at === address.length - 1) return null\n return address.slice(at + 1)\n}\n\n/**\n * Decide the policy for one identity. Pure and total — no Firebase, no env —\n * so a spec can drive every combination directly, and so the audit view and\n * the enforcement path can never disagree about what the policy says.\n */\nexport function evaluateSsoDomainPolicy(\n identity: SsoDomainIdentity,\n domains: SsoRequiredDomains = ssoRequiredDomains(),\n exempt: ReadonlySet<string> = ssoExemptEmails(),\n): SsoDomainDecision {\n const domain = domainOf(identity.email)\n const tenantId = identity.tenantId ?? null\n // Exact-match lookup, never a suffix test: `notaglyn.com` ends with\n // `aglyn.com`, and a suffix test would refuse an unrelated company's staff.\n const requiredTenantId = domain\n ? (Object.prototype.hasOwnProperty.call(domains, domain)\n ? domains[domain]\n : null)\n : null\n\n if (!requiredTenantId) {\n // Ungoverned: a personal address, a customer's address, anything not on\n // the list. Includes the permanent break-glass account by construction\n // rather than by special case — see the staff-access doc.\n return {\n verdict: 'allow-ungoverned',\n refused: false,\n domain,\n requiredTenantId: null,\n tenantId,\n reviewable: false,\n }\n }\n\n if (tenantId === requiredTenantId) {\n return {\n verdict: 'allow-designated-tenant',\n refused: false,\n domain,\n requiredTenantId,\n tenantId,\n reviewable: false,\n }\n }\n\n if (tenantId) {\n // A governed address inside somebody else's tenant. They authenticated\n // through an IdP, so the hole this policy closes is not open here — but\n // the IdP is not one Aglyn controls, so it is flagged for review.\n return {\n verdict: 'allow-foreign-tenant',\n refused: false,\n domain,\n requiredTenantId,\n tenantId,\n reviewable: true,\n }\n }\n\n if (exempt.has(String(identity.email ?? '').trim().toLowerCase())) {\n return {\n verdict: 'allow-exempt',\n refused: false,\n domain,\n requiredTenantId,\n tenantId: null,\n reviewable: true,\n }\n }\n\n return {\n verdict: 'refuse-sso-required',\n refused: true,\n domain,\n requiredTenantId,\n tenantId: null,\n reviewable: true,\n }\n}\n\n/**\n * THE ENFORCEMENT SWITCH — ships OFF.\n *\n * Off means `evaluateSsoDomainPolicy` still computes `refused`, and callers\n * still record it, but nobody is turned away. That is deliberate: the audit\n * trail accumulates under the real rule before the rule bites, so flipping the\n * switch holds no surprises.\n *\n * Opt-in by exact string. Anything else — unset, empty, `false`, `0`, a typo —\n * is OFF. A security control that switches itself on through a misspelling is\n * a control that can lock people out through a misspelling.\n *\n * Note both halves must be set for anything to happen: this switch AND a\n * non-empty {@link SSO_REQUIRED_DOMAINS_ENV}. A self-host install that sets\n * only this switch still governs nothing.\n *\n * **Precondition for flipping it — written down so it is not re-litigated:**\n * 1. SSO staff access verified working through a REAL sign-in (not a token\n * inspection): a staff account reaches `/admin/*` via the SAML tenant.\n * 2. Every identity on a governed domain has been migrated into its tenant,\n * retired, or named in {@link SSO_EXEMPT_EMAILS_ENV}. An automation or\n * reviewer account is the one that bites without warning: nobody is\n * watching a sign-in page when it fails.\n *\n * A second staff identity is NOT a precondition: the permanent break-glass\n * account is on an ungoverned domain, so this rule can never refuse it.\n */\nexport const SSO_DOMAIN_ENFORCEMENT_ENV = 'AGLYN_SSO_DOMAIN_ENFORCEMENT'\n\nexport function ssoDomainEnforcementEnabled(\n env: Record<string, string | undefined> = process.env,\n): boolean {\n return env[SSO_DOMAIN_ENFORCEMENT_ENV] === 'on'\n}\n\n/**\n * Whether an account about to be CREATED must be refused. Same switch, same\n * domains, same exemptions as the session mint, so an address the mint would\n * turn away is never created in the first place, and an address it admits\n * is never refused here.\n */\nexport function ssoDomainCreationRefused(\n identity: SsoDomainIdentity,\n env: Record<string, string | undefined> = process.env,\n): boolean {\n if (!ssoDomainEnforcementEnabled(env)) return false\n return evaluateSsoDomainPolicy(\n identity,\n ssoRequiredDomains(env),\n ssoExemptEmails(env),\n ).refused\n}\n// #endregion sso-domain-policy\n\n/**\n * What the refused person is told. Names the fix, not the mechanism.\n *\n * `PLATFORM_BRAND_NAME`, not a literal (AGL-2214). Everything else in this\n * module is careful that the RULE is configuration rather than a compiled-in\n * assumption about Aglyn — and then the one string a refused person actually\n * reads named us anyway. A self-hoster who governs their own domain would\n * have turned their own staff away in our name.\n *\n * Byte-identical for a deployment that has not renamed itself.\n */\nexport const SSO_DOMAIN_REFUSAL_MESSAGE =\n `Sign in with your ${PLATFORM_BRAND_NAME} Workspace account ` +\n '(Single sign-on). Accounts on this domain cannot use a password or ' +\n 'personal Google sign-in.'\n\n/**\n * The refusal a caller should return, or null to carry on.\n *\n * Returns null whenever the switch is off, so wiring this into an\n * authentication path is inert until the account owner flips it — the call site is live,\n * the consequence is not.\n */\nexport function ssoDomainRefusal(\n identity: SsoDomainIdentity,\n env: Record<string, string | undefined> = process.env,\n): { decision: SsoDomainDecision; response: Response } | null {\n const decision = evaluateSsoDomainPolicy(\n identity,\n ssoRequiredDomains(env),\n ssoExemptEmails(env),\n )\n if (!decision.refused) return null\n if (!ssoDomainEnforcementEnabled(env)) return null\n return {\n decision,\n response: Response.json(\n {\n error: SSO_DOMAIN_REFUSAL_MESSAGE,\n reason: 'sso-domain-required',\n domain: decision.domain,\n },\n { status: 403 },\n ),\n }\n}\n\nexport default evaluateSsoDomainPolicy\n"],"names":["PLATFORM_BRAND_NAME","SSO_REQUIRED_DOMAINS_ENV","parseSsoRequiredDomains","raw","map","pair","String","split","eq","indexOf","domain","slice","trim","toLowerCase","tenantId","ssoRequiredDomains","env","process","SSO_EXEMPT_EMAILS_ENV","parseSsoExemptEmails","emails","Set","entry","email","includes","add","ssoExemptEmails","domainOf","address","at","lastIndexOf","length","evaluateSsoDomainPolicy","identity","domains","exempt","requiredTenantId","Object","prototype","hasOwnProperty","call","verdict","refused","reviewable","has","SSO_DOMAIN_ENFORCEMENT_ENV","ssoDomainEnforcementEnabled","ssoDomainCreationRefused","SSO_DOMAIN_REFUSAL_MESSAGE","ssoDomainRefusal","decision","response","Response","json","error","reason","status"],"mappings":"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"}