@aglyn/tenant-data-admin 1.0.0-beta.217 → 1.0.0-beta.218

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/media-download-tokens.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 * Raw-download-URL revocation for a security lockdown (AGL-1526).\n *\n * THE HOLE THIS CLOSES. AGL-1520 put a lockdown gate on the media CDN\n * (`mediaCdnServeBlock`), and that gate works because the CDN path runs OUR\n * code: a locked scope gets a 410. But a media object can also be addressed\n * directly at Google's edge:\n *\n * https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{path}\n * ?alt=media&token={firebaseStorageDownloadTokens}\n *\n * That URL is the free-tier / legacy delivery path — every upload route\n * still mints one and stores it on the media doc's `url`. **No code of ours\n * runs on that origin.** A lock cannot refuse it, a revalidation cannot\n * evict it, and the tenant middleware never sees it. It serves the locked\n * org's bytes to anyone holding the link until the token dies.\n *\n * The only thing that kills it is the token itself, which lives in the\n * object's custom metadata. Rewrite that entry and Google's edge starts\n * answering the old URL with 403 — immediately, with no cache of ours in\n * the path and nothing to revalidate.\n *\n * KNOWN CONSEQUENCE, not a bug: the media doc's own `url` field still holds\n * the OLD raw URL after rotation, so it is stale-by-design for the duration\n * of the lock — which is the intent — and stays stale after an unlock. The\n * console DAM and the public `GET /api/v1/media` both fall back to `url`\n * when an asset has no `cdnPath` (free tier, private assets, pre-AGL-829\n * uploads), so those surfaces will show broken images for a rotated org\n * once it is unlocked. Rewriting every media doc's `url` is a second,\n * Firestore-wide pass that deliberately is NOT done here: during the lock\n * the dead link is the point, and a repair pass that ran at lock time would\n * be handing back a working URL. See the AGL-1526 thread.\n *\n * WHAT THIS DOES NOT DO (AGL-1615, honestly). Rotation kills the URL, not\n * the bytes. Anything that already fetched them — a browser cache, a\n * downstream CDN, a scraper's disk, an archive.org snapshot — keeps what it\n * has. Like quarantine and like the CDN gate, this stops only NEW origin\n * fetches. It is the strongest control available on a path we do not serve,\n * and it is not a recall.\n *\n * THREE DELIBERATE NARROWINGS:\n *\n * 1. **`security` reason only, `full` mode only.** Rotation is\n * IRREVERSIBLE for embeds: the new token is a new URL, so every raw URL\n * already pasted into a screen, an email or a customer's site stays\n * dead after the lock is lifted. The stable CDN URL (AGL-829) survives\n * and is the supported reference, but pre-AGL-829 content that still\n * embeds raw URLs will visibly break. That price is worth paying to cut\n * off an org serving malware or CSAM; it is NOT worth paying because an\n * invoice failed. `billing` and `maintenance` therefore never rotate,\n * and neither does a read-only lock — a read-only lock's whole promise\n * is that the sites keep serving.\n *\n * 2. **Only objects that ALREADY carry a token are touched.** An object\n * with no `firebaseStorageDownloadTokens` entry has no raw URL to kill,\n * so rotating it would be pure cost — and worse, WRITING a token where\n * none existed would MINT a public raw URL for an object that never had\n * one. The scan skips them. This is why the result reports `scanned`\n * and `rotated` separately.\n *\n * 3. **Bounded, and awaited anyway.** AGL-1526 suggested firing this off\n * async so the lock write does not wait on it. It is awaited instead,\n * under an object cap and a wall-clock budget, because an unawaited\n * promise in a serverless invocation is frozen the moment the response\n * is returned — the classic shape of a control that exists and never\n * runs. A bounded rotation that provably ran beats an unbounded one\n * that provably did not; when a bound is hit the result says\n * `truncated` so the audit row records an incomplete revocation rather\n * than implying a clean one.\n */\n\nimport { randomUUID } from 'node:crypto'\nimport { firebaseAdmin } from './firebase-admin'\n\n/** The custom-metadata key Firebase reads the raw download token from. */\nexport const DOWNLOAD_TOKEN_METADATA_KEY = 'firebaseStorageDownloadTokens'\n\n/**\n * Per-prefix object cap. Well past any real library; a scope somehow larger\n * rotates this many and reports `truncated` rather than running unbounded\n * inside a request.\n */\nexport const MAX_ROTATED_OBJECTS = 5000\n/** Wall-clock budget per prefix, comfortably inside the route's own limit. */\nexport const ROTATION_BUDGET_MS = 20_000\n/** Objects listed per Storage page. */\nconst PAGE_SIZE = 500\n/** In-flight `setMetadata` calls; the bucket is the bottleneck, not us. */\nconst ROTATE_CONCURRENCY = 8\n\nexport interface DownloadTokenRotationResult {\n /** Storage prefix scanned, e.g. `orgs/acme/`. */\n prefix: string\n /** Objects listed under the prefix. */\n scanned: number\n /** Objects that carried a token and got a new one. */\n rotated: number\n /** Objects that carried a token and whose rewrite failed. */\n failed: number\n /** Whether a bound stopped the scan before the prefix was exhausted. */\n truncated: boolean\n ok: boolean\n reason: string\n}\n\n/**\n * The admin app is initialized without a default storageBucket, so every\n * bucket access must name it explicitly (same as `erase.ts` and the media\n * routes).\n */\nfunction storageBucket(): any {\n const name = process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']\n return firebaseAdmin.app().storage().bucket(name || undefined)\n}\n\n/**\n * Does this lock revoke raw download URLs?\n *\n * Deliberately a pure predicate over the lock's own two fields, exported so\n * the lockdown core and its specs test the SAME rule rather than two copies\n * that can drift. See narrowing (1) above for why the answer is this narrow.\n */\nexport function lockRotatesDownloadTokens(lock: {\n reason?: string\n mode?: 'full' | 'read-only'\n}): boolean {\n if (!lock) return false\n if (lock.mode === 'read-only') return false\n return lock.reason === 'security'\n}\n\n/**\n * Rewrite the download token on every already-tokened object under one\n * Storage prefix.\n *\n * Fail-soft per object: one 403 on one file must not abandon the rest of a\n * security revocation. Failures are counted and reported, never thrown —\n * the caller (a lock that has already been applied) has nothing useful to\n * do with an exception, but the audit row wants the number.\n */\nexport async function rotateDownloadTokensUnderPrefix(options: {\n prefix: string\n /** Injectable for tests; defaults to the configured media bucket. */\n bucket?: any\n maxObjects?: number\n budgetMs?: number\n now?: () => number\n}): Promise<DownloadTokenRotationResult> {\n const { prefix } = options\n const maxObjects = options.maxObjects ?? MAX_ROTATED_OBJECTS\n const budgetMs = options.budgetMs ?? ROTATION_BUDGET_MS\n const now = options.now ?? (() => Date.now())\n const startedAt = now()\n const result: DownloadTokenRotationResult = {\n prefix,\n scanned: 0,\n rotated: 0,\n failed: 0,\n truncated: false,\n ok: true,\n reason: 'ok',\n }\n if (!prefix) return { ...result, ok: false, reason: 'no-prefix' }\n\n let bucket: any\n try {\n bucket = options.bucket ?? storageBucket()\n } catch (error) {\n console.error('[lockdown] storage bucket unavailable', prefix, error)\n return { ...result, ok: false, reason: 'not-configured' }\n }\n if (!bucket) return { ...result, ok: false, reason: 'not-configured' }\n\n let pageToken: string | undefined\n try {\n do {\n const [files, nextQuery] = await bucket.getFiles({\n prefix,\n maxResults: PAGE_SIZE,\n autoPaginate: false,\n ...(pageToken ? { pageToken } : {}),\n })\n const batch: any[] = Array.isArray(files) ? files : []\n result.scanned += batch.length\n\n for (let index = 0; index < batch.length; index += ROTATE_CONCURRENCY) {\n await Promise.all(\n batch\n .slice(index, index + ROTATE_CONCURRENCY)\n .map((file: any) => rotateOne(file, result)),\n )\n }\n\n pageToken = nextQuery?.pageToken\n if (result.scanned >= maxObjects && pageToken) {\n result.truncated = true\n result.reason = 'object-cap'\n break\n }\n if (now() - startedAt >= budgetMs && pageToken) {\n result.truncated = true\n result.reason = 'time-budget'\n break\n }\n } while (pageToken)\n } catch (error) {\n console.error('[lockdown] download-token rotation failed', prefix, error)\n return { ...result, ok: false, reason: 'error' }\n }\n\n return result\n}\n\n/**\n * One object's rewrite.\n *\n * The existing custom-metadata map is READ AND RESPREAD rather than\n * replaced with a lone token key, because `setMetadata` overwrites the\n * whole custom map (the same trap `POST /api/media/folders` documents at\n * its move path). Dropping the map here would silently erase every\n * customer-set metadata pair on a locked org's library — a lock is not a\n * data-loss event.\n */\nasync function rotateOne(\n file: any,\n result: DownloadTokenRotationResult,\n): Promise<void> {\n const existing = (file?.metadata?.metadata ?? {}) as Record<string, unknown>\n // Narrowing (2): no token, no raw URL, nothing to revoke — and minting\n // one here would CREATE the exposure this function exists to remove.\n if (!existing[DOWNLOAD_TOKEN_METADATA_KEY]) return\n try {\n await file.setMetadata({\n metadata: { ...existing, [DOWNLOAD_TOKEN_METADATA_KEY]: randomUUID() },\n })\n result.rotated += 1\n } catch (error) {\n console.error('[lockdown] token rewrite failed', file?.name, error)\n result.failed += 1\n }\n}\n\n/** What happened to ONE object's token. See {@link rotateDownloadTokenForObject}. */\nexport interface ObjectTokenRotation {\n /** The token was rewritten, so the published raw URL is now dead. */\n rotated: boolean\n /**\n * Why not, when it was not. `no-token` is the common and CORRECT case —\n * the object had no raw URL to revoke. `error` and `not-configured` are\n * infrastructure; `no-path` is a document with no object behind it.\n */\n reason: 'rotated' | 'no-token' | 'no-path' | 'not-configured' | 'error'\n}\n\n/**\n * Revoke ONE object's raw download URL (AGL-1615).\n *\n * The per-asset counterpart to the prefix scan above, for asset quarantine\n * rather than a scope lockdown — and it closes a hole that is not a caching\n * residual but an un-gated origin.\n *\n * `serveMediaCdn` consults the quarantine deny list because that route is\n * ours. The media document's `url` field is not: it points at\n * `firebasestorage.googleapis.com`, where no code of ours runs, and the\n * console DAM and `GET /api/v1/media` both fall back to it whenever an asset\n * has no `cdnPath` — every free-tier workspace, every private asset, every\n * upload predating AGL-829. For those, a quarantine was not slow; it was\n * never consulted, and a DMCA'd file kept serving to anyone holding the link.\n *\n * Same three narrowings as the prefix scan, for the same reasons, and one\n * addition:\n *\n * * **Only an object that ALREADY carries a token is touched.** Writing one\n * where none existed would MINT a public raw URL for a file that never\n * had one — creating the exposure this exists to remove.\n * * **The existing custom-metadata map is read and respread**, because\n * `setMetadata` replaces the whole map and a takedown must not also be a\n * silent erasure of customer-set metadata.\n * * **Fail soft.** A Storage outage must not become a takedown outage: the\n * deny-list entry is the primary control and lands regardless. The caller\n * reports what actually happened rather than assuming success.\n * * **There is no un-rotate, and none is offered.** Releasing a quarantine\n * does not mint a fresh token: that would hand back a WORKING raw URL\n * under a new value while every embed of the old one stays broken — it\n * helps nobody and re-publishes the bytes. The irreversibility is real\n * and is stated to the operator before they act, not discovered after.\n */\nexport async function rotateDownloadTokenForObject(options: {\n storagePath?: string | null\n /** Injectable for tests; defaults to the configured media bucket. */\n bucket?: any\n}): Promise<ObjectTokenRotation> {\n const storagePath = String(options?.storagePath ?? '').trim()\n if (!storagePath) return { rotated: false, reason: 'no-path' }\n\n let bucket: any\n try {\n bucket = options.bucket ?? storageBucket()\n } catch (error) {\n console.error('[quarantine] storage bucket unavailable', storagePath, error)\n return { rotated: false, reason: 'not-configured' }\n }\n if (!bucket) return { rotated: false, reason: 'not-configured' }\n\n try {\n const file = bucket.file(storagePath)\n // FETCHED, not read off the handle (AGL-1881). `bucket.file(path)`\n // constructs a File whose `.metadata` is `{}` until something populates\n // it — the prefix scan above gets it free because `bucket.getFiles()`\n // returns hydrated handles, and this function never called anything that\n // does. So `existing[DOWNLOAD_TOKEN_METADATA_KEY]` was always undefined\n // and this returned `no-token` for every object, including the ones that\n // carried a live raw URL. `no-token` is also the CORRECT benign answer,\n // which is why nothing looked wrong: a takedown reported \"no raw URL to\n // revoke\" and the operator had no way to tell that apart from a\n // revocation that ran. A control that exists, is wired, and silently\n // never fires.\n const [fetched] = await file.getMetadata()\n const existing = (fetched?.metadata ?? {}) as Record<string, unknown>\n if (!existing[DOWNLOAD_TOKEN_METADATA_KEY]) {\n return { rotated: false, reason: 'no-token' }\n }\n await file.setMetadata({\n metadata: { ...existing, [DOWNLOAD_TOKEN_METADATA_KEY]: randomUUID() },\n })\n return { rotated: true, reason: 'rotated' }\n } catch (error) {\n console.error('[quarantine] token revocation failed', storagePath, error)\n return { rotated: false, reason: 'error' }\n }\n}\n\n/**\n * The object's CURRENT raw download URL, rebuilt from the token it already\n * carries (AGL-1881).\n *\n * The publish half of `POST /api/media/folders {action:'set-private'}`.\n * Turning an asset private now rotates its download token and drops the\n * media document's `url`, so publishing it again has to put a working `url`\n * back — for a free-tier workspace, or any asset predating AGL-829, that\n * field is the ONLY delivery path, and leaving it deleted would make\n * \"Publish\" a one-way door.\n *\n * **This mints nothing.** It reads the token the object already has and\n * reassembles the URL from it, so:\n *\n * * every URL handed out before the asset went private stays dead — the\n * rotation is not undone, only re-read;\n * * an object that never carried a token still does not get one, which is\n * the same narrowing {@link rotateDownloadTokenForObject} makes and for\n * the same reason: writing one would MINT a public URL for a file that\n * never had one.\n *\n * Returns `null` when there is nothing to rebuild, so the caller writes no\n * `url` rather than an empty one.\n */\nexport async function currentDownloadUrlForObject(options: {\n storagePath?: string | null\n /** Injectable for tests; defaults to the configured media bucket. */\n bucket?: any\n}): Promise<string | null> {\n const storagePath = String(options?.storagePath ?? '').trim()\n if (!storagePath) return null\n let bucket: any\n try {\n bucket = options.bucket ?? storageBucket()\n } catch (error) {\n console.error('[media] storage bucket unavailable', storagePath, error)\n return null\n }\n if (!bucket) return null\n try {\n const file = bucket.file(storagePath)\n const [fetched] = await file.getMetadata()\n const token = (fetched?.metadata ?? {})[DOWNLOAD_TOKEN_METADATA_KEY]\n if (!token) return null\n return (\n `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/` +\n `${encodeURIComponent(storagePath)}?alt=media&token=${String(token)}`\n )\n } catch (error) {\n console.error('[media] download url read failed', storagePath, error)\n return null\n }\n}\n\n/**\n * Rotate across every prefix a lock scope owns.\n *\n * An ORG lock passes `orgs/{orgId}/` AND `hosts/{hostId}/` for each of its\n * sites: org-scope and host-scope libraries are separate Storage trees\n * (`MediaScope.base`), and a lock that cleaned only the org tree would\n * leave every site's own assets serving — which is most of them.\n */\nexport async function rotateScopeDownloadTokens(options: {\n prefixes: readonly string[]\n bucket?: any\n maxObjects?: number\n budgetMs?: number\n now?: () => number\n}): Promise<DownloadTokenRotationResult[]> {\n const results: DownloadTokenRotationResult[] = []\n for (const prefix of options.prefixes ?? []) {\n results.push(\n await rotateDownloadTokensUnderPrefix({\n prefix,\n bucket: options.bucket,\n maxObjects: options.maxObjects,\n budgetMs: options.budgetMs,\n now: options.now,\n }),\n )\n }\n return results\n}\n"],"names":["randomUUID","firebaseAdmin","DOWNLOAD_TOKEN_METADATA_KEY","MAX_ROTATED_OBJECTS","ROTATION_BUDGET_MS","PAGE_SIZE","ROTATE_CONCURRENCY","storageBucket","name","process","env","app","storage","bucket","undefined","lockRotatesDownloadTokens","lock","mode","reason","rotateDownloadTokensUnderPrefix","options","prefix","maxObjects","budgetMs","now","Date","startedAt","result","scanned","rotated","failed","truncated","ok","error","console","pageToken","files","nextQuery","getFiles","maxResults","autoPaginate","batch","Array","isArray","length","index","Promise","all","slice","map","file","rotateOne","existing","metadata","setMetadata","rotateDownloadTokenForObject","storagePath","String","trim","fetched","getMetadata","currentDownloadUrlForObject","token","encodeURIComponent","rotateScopeDownloadTokens","results","prefixes","push"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-download-tokens.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 * Raw-download-URL revocation for a security lockdown (AGL-1526).\n *\n * THE HOLE THIS CLOSES. AGL-1520 put a lockdown gate on the media CDN\n * (`mediaCdnServeBlock`), and that gate works because the CDN path runs OUR\n * code: a locked scope gets a 410. But a media object can also be addressed\n * directly at Google's edge:\n *\n * https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{path}\n * ?alt=media&token={firebaseStorageDownloadTokens}\n *\n * That URL is the free-tier / legacy delivery path — every upload route\n * still mints one and stores it on the media doc's `url`. **No code of ours\n * runs on that origin.** A lock cannot refuse it, a revalidation cannot\n * evict it, and the tenant middleware never sees it. It serves the locked\n * org's bytes to anyone holding the link until the token dies.\n *\n * The only thing that kills it is the token itself, which lives in the\n * object's custom metadata. Rewrite that entry and Google's edge starts\n * answering the old URL with 403 — immediately, with no cache of ours in\n * the path and nothing to revalidate.\n *\n * KNOWN CONSEQUENCE, not a bug: the media doc's own `url` field still holds\n * the OLD raw URL after rotation, so it is stale-by-design for the duration\n * of the lock — which is the intent — and stays stale after an unlock. The\n * console DAM and the public `GET /api/v1/media` both fall back to `url`\n * when an asset has no `cdnPath` (free tier, private assets, pre-AGL-829\n * uploads), so those surfaces will show broken images for a rotated org\n * once it is unlocked. Rewriting every media doc's `url` is a second,\n * Firestore-wide pass that deliberately is NOT done here: during the lock\n * the dead link is the point, and a repair pass that ran at lock time would\n * be handing back a working URL. See the AGL-1526 thread.\n *\n * WHAT THIS DOES NOT DO (AGL-1615, honestly). Rotation kills the URL, not\n * the bytes. Anything that already fetched them — a browser cache, a\n * downstream CDN, a scraper's disk, an archive.org snapshot — keeps what it\n * has. Like quarantine and like the CDN gate, this stops only NEW origin\n * fetches. It is the strongest control available on a path we do not serve,\n * and it is not a recall.\n *\n * THREE DELIBERATE NARROWINGS:\n *\n * 1. **`security` reason only, `full` mode only.** Rotation is\n * IRREVERSIBLE for embeds: the new token is a new URL, so every raw URL\n * already pasted into a screen, an email or a customer's site stays\n * dead after the lock is lifted. The stable CDN URL (AGL-829) survives\n * and is the supported reference, but pre-AGL-829 content that still\n * embeds raw URLs will visibly break. That price is worth paying to cut\n * off an org serving malware or CSAM; it is NOT worth paying because an\n * invoice failed. `billing` and `maintenance` therefore never rotate,\n * and neither does a read-only lock — a read-only lock's whole promise\n * is that the sites keep serving.\n *\n * 2. **Only objects that ALREADY carry a token are touched.** An object\n * with no `firebaseStorageDownloadTokens` entry has no raw URL to kill,\n * so rotating it would be pure cost — and worse, WRITING a token where\n * none existed would MINT a public raw URL for an object that never had\n * one. The scan skips them. This is why the result reports `scanned`\n * and `rotated` separately.\n *\n * 3. **Bounded, and awaited anyway.** AGL-1526 suggested firing this off\n * async so the lock write does not wait on it. It is awaited instead,\n * under an object cap and a wall-clock budget, because an unawaited\n * promise in a serverless invocation is frozen the moment the response\n * is returned — the classic shape of a control that exists and never\n * runs. A bounded rotation that provably ran beats an unbounded one\n * that provably did not; when a bound is hit the result says\n * `truncated` so the audit row records an incomplete revocation rather\n * than implying a clean one.\n */\n\nimport { randomUUID } from 'node:crypto'\nimport { isSecurityClassLockdownReason } from '@aglyn/aglyn/app-utils/lockdown'\nimport { firebaseAdmin } from './firebase-admin'\n\n/** The custom-metadata key Firebase reads the raw download token from. */\nexport const DOWNLOAD_TOKEN_METADATA_KEY = 'firebaseStorageDownloadTokens'\n\n/**\n * Per-prefix object cap. Well past any real library; a scope somehow larger\n * rotates this many and reports `truncated` rather than running unbounded\n * inside a request.\n */\nexport const MAX_ROTATED_OBJECTS = 5000\n/** Wall-clock budget per prefix, comfortably inside the route's own limit. */\nexport const ROTATION_BUDGET_MS = 20_000\n/** Objects listed per Storage page. */\nconst PAGE_SIZE = 500\n/** In-flight `setMetadata` calls; the bucket is the bottleneck, not us. */\nconst ROTATE_CONCURRENCY = 8\n\nexport interface DownloadTokenRotationResult {\n /** Storage prefix scanned, e.g. `orgs/acme/`. */\n prefix: string\n /** Objects listed under the prefix. */\n scanned: number\n /** Objects that carried a token and got a new one. */\n rotated: number\n /** Objects that carried a token and whose rewrite failed. */\n failed: number\n /** Whether a bound stopped the scan before the prefix was exhausted. */\n truncated: boolean\n ok: boolean\n reason: string\n}\n\n/**\n * The admin app is initialized without a default storageBucket, so every\n * bucket access must name it explicitly (same as `erase.ts` and the media\n * routes).\n */\nfunction storageBucket(): any {\n const name = process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']\n return firebaseAdmin.app().storage().bucket(name || undefined)\n}\n\n/**\n * Does this lock revoke raw download URLs?\n *\n * Deliberately a pure predicate over the lock's own two fields, exported so\n * the lockdown core and its specs test the SAME rule rather than two copies\n * that can drift. See narrowing (1) above for why the answer is this narrow.\n */\nexport function lockRotatesDownloadTokens(lock: {\n reason?: string\n mode?: 'full' | 'read-only'\n}): boolean {\n if (!lock) return false\n if (lock.mode === 'read-only') return false\n return isSecurityClassLockdownReason(lock.reason)\n}\n\n/**\n * Rewrite the download token on every already-tokened object under one\n * Storage prefix.\n *\n * Fail-soft per object: one 403 on one file must not abandon the rest of a\n * security revocation. Failures are counted and reported, never thrown —\n * the caller (a lock that has already been applied) has nothing useful to\n * do with an exception, but the audit row wants the number.\n */\nexport async function rotateDownloadTokensUnderPrefix(options: {\n prefix: string\n /** Injectable for tests; defaults to the configured media bucket. */\n bucket?: any\n maxObjects?: number\n budgetMs?: number\n now?: () => number\n}): Promise<DownloadTokenRotationResult> {\n const { prefix } = options\n const maxObjects = options.maxObjects ?? MAX_ROTATED_OBJECTS\n const budgetMs = options.budgetMs ?? ROTATION_BUDGET_MS\n const now = options.now ?? (() => Date.now())\n const startedAt = now()\n const result: DownloadTokenRotationResult = {\n prefix,\n scanned: 0,\n rotated: 0,\n failed: 0,\n truncated: false,\n ok: true,\n reason: 'ok',\n }\n if (!prefix) return { ...result, ok: false, reason: 'no-prefix' }\n\n let bucket: any\n try {\n bucket = options.bucket ?? storageBucket()\n } catch (error) {\n console.error('[lockdown] storage bucket unavailable', prefix, error)\n return { ...result, ok: false, reason: 'not-configured' }\n }\n if (!bucket) return { ...result, ok: false, reason: 'not-configured' }\n\n let pageToken: string | undefined\n try {\n do {\n const [files, nextQuery] = await bucket.getFiles({\n prefix,\n maxResults: PAGE_SIZE,\n autoPaginate: false,\n ...(pageToken ? { pageToken } : {}),\n })\n const batch: any[] = Array.isArray(files) ? files : []\n result.scanned += batch.length\n\n for (let index = 0; index < batch.length; index += ROTATE_CONCURRENCY) {\n await Promise.all(\n batch\n .slice(index, index + ROTATE_CONCURRENCY)\n .map((file: any) => rotateOne(file, result)),\n )\n }\n\n pageToken = nextQuery?.pageToken\n if (result.scanned >= maxObjects && pageToken) {\n result.truncated = true\n result.reason = 'object-cap'\n break\n }\n if (now() - startedAt >= budgetMs && pageToken) {\n result.truncated = true\n result.reason = 'time-budget'\n break\n }\n } while (pageToken)\n } catch (error) {\n console.error('[lockdown] download-token rotation failed', prefix, error)\n return { ...result, ok: false, reason: 'error' }\n }\n\n return result\n}\n\n/**\n * One object's rewrite.\n *\n * The existing custom-metadata map is READ AND RESPREAD rather than\n * replaced with a lone token key, because `setMetadata` overwrites the\n * whole custom map (the same trap `POST /api/media/folders` documents at\n * its move path). Dropping the map here would silently erase every\n * customer-set metadata pair on a locked org's library — a lock is not a\n * data-loss event.\n */\nasync function rotateOne(\n file: any,\n result: DownloadTokenRotationResult,\n): Promise<void> {\n const existing = (file?.metadata?.metadata ?? {}) as Record<string, unknown>\n // Narrowing (2): no token, no raw URL, nothing to revoke — and minting\n // one here would CREATE the exposure this function exists to remove.\n if (!existing[DOWNLOAD_TOKEN_METADATA_KEY]) return\n try {\n await file.setMetadata({\n metadata: { ...existing, [DOWNLOAD_TOKEN_METADATA_KEY]: randomUUID() },\n })\n result.rotated += 1\n } catch (error) {\n console.error('[lockdown] token rewrite failed', file?.name, error)\n result.failed += 1\n }\n}\n\n/** What happened to ONE object's token. See {@link rotateDownloadTokenForObject}. */\nexport interface ObjectTokenRotation {\n /** The token was rewritten, so the published raw URL is now dead. */\n rotated: boolean\n /**\n * Why not, when it was not. `no-token` is the common and CORRECT case —\n * the object had no raw URL to revoke. `error` and `not-configured` are\n * infrastructure; `no-path` is a document with no object behind it.\n */\n reason: 'rotated' | 'no-token' | 'no-path' | 'not-configured' | 'error'\n}\n\n/**\n * Revoke ONE object's raw download URL (AGL-1615).\n *\n * The per-asset counterpart to the prefix scan above, for asset quarantine\n * rather than a scope lockdown — and it closes a hole that is not a caching\n * residual but an un-gated origin.\n *\n * `serveMediaCdn` consults the quarantine deny list because that route is\n * ours. The media document's `url` field is not: it points at\n * `firebasestorage.googleapis.com`, where no code of ours runs, and the\n * console DAM and `GET /api/v1/media` both fall back to it whenever an asset\n * has no `cdnPath` — every free-tier workspace, every private asset, every\n * upload predating AGL-829. For those, a quarantine was not slow; it was\n * never consulted, and a DMCA'd file kept serving to anyone holding the link.\n *\n * Same three narrowings as the prefix scan, for the same reasons, and one\n * addition:\n *\n * * **Only an object that ALREADY carries a token is touched.** Writing one\n * where none existed would MINT a public raw URL for a file that never\n * had one — creating the exposure this exists to remove.\n * * **The existing custom-metadata map is read and respread**, because\n * `setMetadata` replaces the whole map and a takedown must not also be a\n * silent erasure of customer-set metadata.\n * * **Fail soft.** A Storage outage must not become a takedown outage: the\n * deny-list entry is the primary control and lands regardless. The caller\n * reports what actually happened rather than assuming success.\n * * **There is no un-rotate, and none is offered.** Releasing a quarantine\n * does not mint a fresh token: that would hand back a WORKING raw URL\n * under a new value while every embed of the old one stays broken — it\n * helps nobody and re-publishes the bytes. The irreversibility is real\n * and is stated to the operator before they act, not discovered after.\n */\nexport async function rotateDownloadTokenForObject(options: {\n storagePath?: string | null\n /** Injectable for tests; defaults to the configured media bucket. */\n bucket?: any\n}): Promise<ObjectTokenRotation> {\n const storagePath = String(options?.storagePath ?? '').trim()\n if (!storagePath) return { rotated: false, reason: 'no-path' }\n\n let bucket: any\n try {\n bucket = options.bucket ?? storageBucket()\n } catch (error) {\n console.error('[quarantine] storage bucket unavailable', storagePath, error)\n return { rotated: false, reason: 'not-configured' }\n }\n if (!bucket) return { rotated: false, reason: 'not-configured' }\n\n try {\n const file = bucket.file(storagePath)\n // FETCHED, not read off the handle (AGL-1881). `bucket.file(path)`\n // constructs a File whose `.metadata` is `{}` until something populates\n // it — the prefix scan above gets it free because `bucket.getFiles()`\n // returns hydrated handles, and this function never called anything that\n // does. So `existing[DOWNLOAD_TOKEN_METADATA_KEY]` was always undefined\n // and this returned `no-token` for every object, including the ones that\n // carried a live raw URL. `no-token` is also the CORRECT benign answer,\n // which is why nothing looked wrong: a takedown reported \"no raw URL to\n // revoke\" and the operator had no way to tell that apart from a\n // revocation that ran. A control that exists, is wired, and silently\n // never fires.\n const [fetched] = await file.getMetadata()\n const existing = (fetched?.metadata ?? {}) as Record<string, unknown>\n if (!existing[DOWNLOAD_TOKEN_METADATA_KEY]) {\n return { rotated: false, reason: 'no-token' }\n }\n await file.setMetadata({\n metadata: { ...existing, [DOWNLOAD_TOKEN_METADATA_KEY]: randomUUID() },\n })\n return { rotated: true, reason: 'rotated' }\n } catch (error) {\n console.error('[quarantine] token revocation failed', storagePath, error)\n return { rotated: false, reason: 'error' }\n }\n}\n\n/**\n * The object's CURRENT raw download URL, rebuilt from the token it already\n * carries (AGL-1881).\n *\n * The publish half of `POST /api/media/folders {action:'set-private'}`.\n * Turning an asset private now rotates its download token and drops the\n * media document's `url`, so publishing it again has to put a working `url`\n * back — for a free-tier workspace, or any asset predating AGL-829, that\n * field is the ONLY delivery path, and leaving it deleted would make\n * \"Publish\" a one-way door.\n *\n * **This mints nothing.** It reads the token the object already has and\n * reassembles the URL from it, so:\n *\n * * every URL handed out before the asset went private stays dead — the\n * rotation is not undone, only re-read;\n * * an object that never carried a token still does not get one, which is\n * the same narrowing {@link rotateDownloadTokenForObject} makes and for\n * the same reason: writing one would MINT a public URL for a file that\n * never had one.\n *\n * Returns `null` when there is nothing to rebuild, so the caller writes no\n * `url` rather than an empty one.\n */\nexport async function currentDownloadUrlForObject(options: {\n storagePath?: string | null\n /** Injectable for tests; defaults to the configured media bucket. */\n bucket?: any\n}): Promise<string | null> {\n const storagePath = String(options?.storagePath ?? '').trim()\n if (!storagePath) return null\n let bucket: any\n try {\n bucket = options.bucket ?? storageBucket()\n } catch (error) {\n console.error('[media] storage bucket unavailable', storagePath, error)\n return null\n }\n if (!bucket) return null\n try {\n const file = bucket.file(storagePath)\n const [fetched] = await file.getMetadata()\n const token = (fetched?.metadata ?? {})[DOWNLOAD_TOKEN_METADATA_KEY]\n if (!token) return null\n return (\n `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/` +\n `${encodeURIComponent(storagePath)}?alt=media&token=${String(token)}`\n )\n } catch (error) {\n console.error('[media] download url read failed', storagePath, error)\n return null\n }\n}\n\n/**\n * Rotate across every prefix a lock scope owns.\n *\n * An ORG lock passes `orgs/{orgId}/` AND `hosts/{hostId}/` for each of its\n * sites: org-scope and host-scope libraries are separate Storage trees\n * (`MediaScope.base`), and a lock that cleaned only the org tree would\n * leave every site's own assets serving — which is most of them.\n */\nexport async function rotateScopeDownloadTokens(options: {\n prefixes: readonly string[]\n bucket?: any\n maxObjects?: number\n budgetMs?: number\n now?: () => number\n}): Promise<DownloadTokenRotationResult[]> {\n const results: DownloadTokenRotationResult[] = []\n for (const prefix of options.prefixes ?? []) {\n results.push(\n await rotateDownloadTokensUnderPrefix({\n prefix,\n bucket: options.bucket,\n maxObjects: options.maxObjects,\n budgetMs: options.budgetMs,\n now: options.now,\n }),\n )\n }\n return results\n}\n"],"names":["randomUUID","isSecurityClassLockdownReason","firebaseAdmin","DOWNLOAD_TOKEN_METADATA_KEY","MAX_ROTATED_OBJECTS","ROTATION_BUDGET_MS","PAGE_SIZE","ROTATE_CONCURRENCY","storageBucket","name","process","env","app","storage","bucket","undefined","lockRotatesDownloadTokens","lock","mode","reason","rotateDownloadTokensUnderPrefix","options","prefix","maxObjects","budgetMs","now","Date","startedAt","result","scanned","rotated","failed","truncated","ok","error","console","pageToken","files","nextQuery","getFiles","maxResults","autoPaginate","batch","Array","isArray","length","index","Promise","all","slice","map","file","rotateOne","existing","metadata","setMetadata","rotateDownloadTokenForObject","storagePath","String","trim","fetched","getMetadata","currentDownloadUrlForObject","token","encodeURIComponent","rotateScopeDownloadTokens","results","prefixes","push"],"mappings":"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"}
@@ -18,6 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { operatorAlertForNotificationType } from "@aglyn/aglyn/plugin-manager/operator-alerts";
19
19
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
20
20
  import { FieldValue } from "firebase-admin/firestore";
21
+ import { withoutMailWithheldAccounts } from "./account-mail.js";
21
22
  import { findUserByUidAcrossPools, listStaffUidsAcrossPools } from "./auth-pools.js";
22
23
  import { filterSuppressedEmails } from "./email-suppression.js";
23
24
  import { meterOrgEmail, meterPlatformEmail } from "./email-metering.js";
@@ -225,9 +226,14 @@ const NOTIFY_EMAIL_MAX_SENDS = 50;
225
226
  * Digests are excluded: they compose and send their own mail, under
226
227
  * their own switches, and the generic channel would send a second
227
228
  * message announcing that the first one had been sent.
229
+ *
230
+ * A locked or disabled account is not mailed (AGL-3418); its feed entry
231
+ * above still records the event.
228
232
  */ if (mailTo.length && !options.skipEmail && isEmailConfigured() && !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)) {
229
233
  var _options_emails;
230
- await emailNotification(mailTo, payload, (_options_emails = options.emails) != null ? _options_emails : {}, options.audience);
234
+ const reachable = await withoutMailWithheldAccounts(mailTo);
235
+ if (!reachable.length) return;
236
+ await emailNotification(reachable, payload, (_options_emails = options.emails) != null ? _options_emails : {}, options.audience);
231
237
  }
232
238
  } catch (error) {
233
239
  console.error('notification fan-out failed', error);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.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 buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { operatorAlertForNotificationType } from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off, so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Rendered through the `notification` system email (AGL-3367), which is what\n * gives it the header and footer every other system email wears, and makes\n * it designable on the staff System emails page. It used to hand `sendEmail`\n * bare text, which is the unbranded card the fallback HTML part draws. The\n * template is read once per fan-out and rendered per recipient.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n audience: NotifyUsersOptions['audience'],\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const context = await notificationEmailContext(payload, audience)\n const url = notificationEmailUrl(payload, context, origin)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n url,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...context.brand.merge,\n 'notification.title': payload.title,\n 'notification.body': payload.body ?? '',\n 'notification.url': url,\n settingsUrl: origin ? settingsUrl : '',\n },\n context.brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject: payload.title,\n text: fallbackText,\n }),\n ...(context.brand.fromName ? { fromName: context.brand.fromName } : {}),\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification to a workspace's people is that\n // workspace's mail, and a staff alert or an account-level notice is the\n // platform's, whichever org it is about.\n await (payload.orgId && audience !== 'staff'\n ? meterOrgEmail(payload.orgId)\n : meterPlatformEmail()\n ).catch(() => undefined)\n }\n}\n\n/** What a notification email needs from the org and site it is about. */\ninterface NotificationEmailContext {\n brand: SystemEmailBrand\n orgSlug?: string\n hostSubdomain?: string\n}\n\n/**\n * The org's brand, and the slug and subdomain its stored link is rewritten\n * with (AGL-3367): at most two reads per fan-out, made only when someone is\n * being emailed.\n *\n * A staff fan-out reads nothing. Its brand is the platform's, and its links\n * are staff routes that need no rewriting.\n */\nasync function notificationEmailContext(\n payload: NotificationPayload,\n audience: NotifyUsersOptions['audience'],\n): Promise<NotificationEmailContext> {\n if (audience === 'staff' || !payload.orgId) {\n return { brand: systemEmailBrand(null) }\n }\n try {\n const db = firestore()\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId\n ? db.collection('hosts').doc(payload.hostId).get()\n : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return {\n brand: systemEmailBrand(org.exists ? (org.data() ?? null) : null),\n ...(typeof slug === 'string' && slug ? { orgSlug: slug } : {}),\n ...(typeof subdomain === 'string' && subdomain\n ? { hostSubdomain: subdomain }\n : {}),\n }\n } catch (error) {\n console.error('notification email context failed', error)\n return { brand: systemEmailBrand(null) }\n }\n}\n\n/**\n * Where the email's button goes: the notification's own link, rewritten onto\n * today's routes the way the console rewrites it when it is clicked, else\n * the notifications page. A stored host link is `/{hostDocId}/…`, which only\n * that rewrite turns into a page that exists. Empty when the deployment has\n * no console origin to make a link absolute with.\n */\nfunction notificationEmailUrl(\n payload: NotificationPayload,\n context: NotificationEmailContext,\n origin: string,\n): string {\n const link = normalizeNotificationLink(payload.link, {\n orgSlug: context.orgSlug,\n hostId: payload.hostId,\n hostSubdomain: context.hostSubdomain,\n })\n if (link && /^https?:\\/\\//i.test(link)) return link\n if (!origin) return ''\n return `${origin}${link?.startsWith('/') ? link : buildRoute(Route.MANAGE_NOTIFICATIONS)}`\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n await emailNotification(\n mailTo,\n payload,\n options.emails ?? {},\n options.audience,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n *\n * A type the operator alert registry names (AGL-3377) — a fraud or risk\n * alert, a dispute nobody owns, a support ticket — is raised as that alert\n * instead: the same console notification, plus staff's switches, the\n * operator email or digest, and the webhook. See `raiseOperatorAlert`.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n const alert = operatorAlertForNotificationType(payload.type)\n if (alert) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(alert, {\n subject: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n ...(payload.link ? { url: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n })\n return\n }\n await notifyStaffConsole(payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * The staff console notification alone, with the per-person email channel\n * it carries: what `notifyStaff` did before the registry, and what\n * `raiseOperatorAlert` writes as its first step. Never throws.\n */\nexport async function notifyStaffConsole(\n payload: NotificationPayload,\n options: { skipEmail?: boolean } = {},\n): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(options.skipEmail ? { skipEmail: true } : {}),\n })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\n })\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","normalizeNotificationLink","notificationChannelEnabled","Route","operatorAlertForNotificationType","isEmailConfigured","sendEmail","FieldValue","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","filter","Boolean","join","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","length","rendered","brand","merge","options","result","to","subject","text","fromName","headers","orgId","undefined","org","db","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","exists","data","orgSlug","hostSubdomain","error","console","link","test","startsWith","MANAGE_NOTIFICATIONS","notifyUsers","targets","Set","slice","userDocs","getAll","map","scope","batch","count","mailTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","push","commit","skipEmail","has","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","alert","raiseOperatorAlert","notifyStaffConsole","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","memberRoles","managers","Object","entries"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.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 buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n normalizeNotificationLink,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { operatorAlertForNotificationType } from '@aglyn/aglyn/plugin-manager/operator-alerts'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type SystemEmailBrand,\n} from './render-system-email'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n /**\n * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its\n * own people, so the email wears the platform's brand and is the platform's\n * cost even when the notification is ABOUT an org, which a canceled\n * subscription or a new ticket always is. Anything else speaks in the org's\n * brand when the notification names one.\n */\n audience?: 'staff'\n /**\n * Write the console notification only, and mail nobody through the\n * per-person email channel (AGL-3375): the caller has emailed the same\n * alert to the operator's inbox, and a staff member who also switched\n * email on would otherwise get it twice.\n */\n skipEmail?: boolean\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off, so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Rendered through the `notification` system email (AGL-3367), which is what\n * gives it the header and footer every other system email wears, and makes\n * it designable on the staff System emails page. It used to hand `sendEmail`\n * bare text, which is the unbranded card the fallback HTML part draws. The\n * template is read once per fan-out and rendered per recipient.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n audience: NotifyUsersOptions['audience'],\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const context = await notificationEmailContext(payload, audience)\n const url = notificationEmailUrl(payload, context, origin)\n const loaded = await loadSystemEmail('notification').catch(() => null)\n const fallbackText = [\n payload.title,\n payload.body ?? '',\n url,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...context.brand.merge,\n 'notification.title': payload.title,\n 'notification.body': payload.body ?? '',\n 'notification.url': url,\n settingsUrl: origin ? settingsUrl : '',\n },\n context.brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject: payload.title,\n text: fallbackText,\n }),\n ...(context.brand.fromName ? { fromName: context.brand.fromName } : {}),\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification to a workspace's people is that\n // workspace's mail, and a staff alert or an account-level notice is the\n // platform's, whichever org it is about.\n await (payload.orgId && audience !== 'staff'\n ? meterOrgEmail(payload.orgId)\n : meterPlatformEmail()\n ).catch(() => undefined)\n }\n}\n\n/** What a notification email needs from the org and site it is about. */\ninterface NotificationEmailContext {\n brand: SystemEmailBrand\n orgSlug?: string\n hostSubdomain?: string\n}\n\n/**\n * The org's brand, and the slug and subdomain its stored link is rewritten\n * with (AGL-3367): at most two reads per fan-out, made only when someone is\n * being emailed.\n *\n * A staff fan-out reads nothing. Its brand is the platform's, and its links\n * are staff routes that need no rewriting.\n */\nasync function notificationEmailContext(\n payload: NotificationPayload,\n audience: NotifyUsersOptions['audience'],\n): Promise<NotificationEmailContext> {\n if (audience === 'staff' || !payload.orgId) {\n return { brand: systemEmailBrand(null) }\n }\n try {\n const db = firestore()\n const [org, host] = await Promise.all([\n db.collection('orgs').doc(payload.orgId).get(),\n payload.hostId\n ? db.collection('hosts').doc(payload.hostId).get()\n : Promise.resolve(null),\n ])\n const slug = org.get('slug')\n const subdomain = host?.get('subdomain')\n return {\n brand: systemEmailBrand(org.exists ? (org.data() ?? null) : null),\n ...(typeof slug === 'string' && slug ? { orgSlug: slug } : {}),\n ...(typeof subdomain === 'string' && subdomain\n ? { hostSubdomain: subdomain }\n : {}),\n }\n } catch (error) {\n console.error('notification email context failed', error)\n return { brand: systemEmailBrand(null) }\n }\n}\n\n/**\n * Where the email's button goes: the notification's own link, rewritten onto\n * today's routes the way the console rewrites it when it is clicked, else\n * the notifications page. A stored host link is `/{hostDocId}/…`, which only\n * that rewrite turns into a page that exists. Empty when the deployment has\n * no console origin to make a link absolute with.\n */\nfunction notificationEmailUrl(\n payload: NotificationPayload,\n context: NotificationEmailContext,\n origin: string,\n): string {\n const link = normalizeNotificationLink(payload.link, {\n orgSlug: context.orgSlug,\n hostId: payload.hostId,\n hostSubdomain: context.hostSubdomain,\n })\n if (link && /^https?:\\/\\//i.test(link)) return link\n if (!origin) return ''\n return `${origin}${link?.startsWith('/') ? link : buildRoute(Route.MANAGE_NOTIFICATIONS)}`\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n *\n * A locked or disabled account is not mailed (AGL-3418); its feed entry\n * above still records the event.\n */\n if (\n mailTo.length &&\n !options.skipEmail &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n const reachable = await withoutMailWithheldAccounts(mailTo)\n if (!reachable.length) return\n await emailNotification(\n reachable,\n payload,\n options.emails ?? {},\n options.audience,\n )\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n *\n * A type the operator alert registry names (AGL-3377) — a fraud or risk\n * alert, a dispute nobody owns, a support ticket — is raised as that alert\n * instead: the same console notification, plus staff's switches, the\n * operator email or digest, and the webhook. See `raiseOperatorAlert`.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n const alert = operatorAlertForNotificationType(payload.type)\n if (alert) {\n const { raiseOperatorAlert } = await import('./operator-alerts')\n await raiseOperatorAlert(alert, {\n subject: payload.title,\n ...(payload.body ? { body: payload.body } : {}),\n ...(payload.link ? { url: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n })\n return\n }\n await notifyStaffConsole(payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/**\n * The staff console notification alone, with the per-person email channel\n * it carries: what `notifyStaff` did before the registry, and what\n * `raiseOperatorAlert` writes as its first step. Never throws.\n */\nexport async function notifyStaffConsole(\n payload: NotificationPayload,\n options: { skipEmail?: boolean } = {},\n): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload, {\n audience: 'staff',\n ...(options.skipEmail ? { skipEmail: true } : {}),\n })\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\n })\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","normalizeNotificationLink","notificationChannelEnabled","Route","operatorAlertForNotificationType","isEmailConfigured","sendEmail","FieldValue","withoutMailWithheldAccounts","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","audience","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","context","notificationEmailContext","url","notificationEmailUrl","loaded","catch","fallbackText","title","body","filter","Boolean","join","lookups","sent","uid","address","String","toLowerCase","includes","pooled","record","email","recipients","length","rendered","brand","merge","options","result","to","subject","text","fromName","headers","orgId","undefined","org","db","host","Promise","all","collection","doc","get","hostId","resolve","slug","subdomain","exists","data","orgSlug","hostSubdomain","error","console","link","test","startsWith","MANAGE_NOTIFICATIONS","notifyUsers","targets","Set","slice","userDocs","getAll","map","scope","batch","count","mailTo","userDoc","settings","legacy","type","set","id","read","createdAt","serverTimestamp","push","commit","skipEmail","has","reachable","emails","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","alert","raiseOperatorAlert","notifyStaffConsole","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","memberRoles","managers","Object","entries"],"mappings":"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"}
@@ -15,6 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
18
+ import { withoutMailWithheldAccounts } from "./account-mail.js";
18
19
  import { findUserByUidAcrossPools } from "./auth-pools.js";
19
20
  import { filterSuppressedEmails } from "./email-suppression.js";
20
21
  import { listOrgMembers } from "./organizations.js";
@@ -43,7 +44,10 @@ const MAX_SENDS = 25;
43
44
  }
44
45
  const addresses = new Set();
45
46
  let lookups = 0;
46
- for (const uid of uids){
47
+ // A locked or disabled account is not mailed (AGL-3418).
48
+ for (const uid of (await withoutMailWithheldAccounts([
49
+ ...uids
50
+ ]))){
47
51
  var _rosterEmail_get;
48
52
  let address = (_rosterEmail_get = rosterEmail.get(uid)) != null ? _rosterEmail_get : '';
49
53
  if (!address && lookups < MAX_LOOKUPS) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/org-member-notice.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 { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { findUserByUidAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { listOrgMembers } from './organizations'\nimport { orgSystemEmailBrand, renderSystemEmailContent } from './render-system-email'\n\n/**\n * A NOTICE TO NAMED MEMBERS OF AN ORGANIZATION, BY EMAIL (AGL-3244).\n *\n * The console notification (`notifications.ts`) is the record, and its email\n * channel is a preference that defaults off. Some things cannot wait on a\n * preference: a sequence mailbox that paused itself holds every enrollment\n * on it until its owner acts, and the owner is not watching the page. This\n * sends the email regardless, to the members named and — when asked — to\n * the organization's owners and admins beside them, one send per address\n * so nobody is shown anyone else's.\n *\n * Addresses come from the roster (`orgs/{orgId}/members/{uid}.email`), and\n * for a member whose row carries none, from the directory. Suppressed\n * addresses are skipped, the send is metered to the organization, and\n * nothing here throws: the notice is a courtesy beside a state the caller\n * already wrote.\n */\n\nexport interface OrgMemberNoticeInput {\n orgId: string\n /** The members to write to, by uid. */\n uids: readonly string[]\n /** Also the organization's owners and admins. */\n includeAdmins?: boolean\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}\n\nexport interface OrgMemberNoticeResult {\n /** Addresses the notice went to. */\n sent: number\n /** Why nothing could be sent at all, when nothing could. */\n reason?: 'unconfigured' | 'no-recipients' | 'failed'\n}\n\n/** At most this many directory lookups, and this many sends, per notice. */\nconst MAX_LOOKUPS = 10\nconst MAX_SENDS = 25\n\n/** Sends one notice to each member — see the module note. */\nexport async function sendOrgMemberNotice(input: OrgMemberNoticeInput): Promise<OrgMemberNoticeResult> {\n if (!isEmailConfigured()) return { sent: 0, reason: 'unconfigured' }\n try {\n const members = await listOrgMembers(input.orgId)\n const rosterEmail = new Map<string, string>()\n for (const member of members) {\n const address = String(member.email ?? '')\n .trim()\n .toLowerCase()\n if (address.includes('@')) rosterEmail.set(member.$id, address)\n }\n const uids = new Set(input.uids.filter(Boolean))\n if (input.includeAdmins) {\n for (const member of members) {\n if (member.role === 'owner' || member.role === 'admin') uids.add(member.$id)\n }\n }\n const addresses = new Set<string>()\n let lookups = 0\n for (const uid of uids) {\n let address = rosterEmail.get(uid) ?? ''\n if (!address && lookups < MAX_LOOKUPS) {\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '')\n .trim()\n .toLowerCase()\n }\n if (address.includes('@')) addresses.add(address)\n }\n const recipients = await filterSuppressedEmails([...addresses])\n if (!recipients.length) return { sent: 0, reason: 'no-recipients' }\n // One message for everyone, in the organization's brand, as the\n // `workspace-notice` system email (AGL-3367).\n const brand = await orgSystemEmailBrand(input.orgId)\n const content = await renderSystemEmailContent(\n 'workspace-notice',\n {\n 'notice.subject': input.subject,\n 'notice.body': input.text,\n 'org.name': brand.orgName ?? '',\n },\n brand,\n { subject: input.subject, text: input.text },\n )\n let sent = 0\n for (const to of recipients.slice(0, MAX_SENDS)) {\n const result = await sendEmail({\n to,\n ...content,\n ...(brand.fromName ? { fromName: brand.fromName } : {}),\n context: input.context,\n })\n if (!result.sent) continue\n sent += 1\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n }\n return sent ? { sent } : { sent: 0, reason: 'failed' }\n } catch (error) {\n console.error('[org-member-notice] send failed', error)\n return { sent: 0, reason: 'failed' }\n }\n}\n"],"names":["isEmailConfigured","sendEmail","findUserByUidAcrossPools","filterSuppressedEmails","listOrgMembers","orgSystemEmailBrand","renderSystemEmailContent","MAX_LOOKUPS","MAX_SENDS","sendOrgMemberNotice","input","sent","reason","brand","members","orgId","rosterEmail","Map","member","address","String","email","trim","toLowerCase","includes","set","$id","uids","Set","filter","Boolean","includeAdmins","role","add","addresses","lookups","uid","get","pooled","catch","record","recipients","length","content","subject","text","orgName","to","slice","result","fromName","context","meterOrgEmail","undefined","error","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/org-member-notice.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 { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from './account-mail'\nimport { findUserByUidAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { listOrgMembers } from './organizations'\nimport { orgSystemEmailBrand, renderSystemEmailContent } from './render-system-email'\n\n/**\n * A NOTICE TO NAMED MEMBERS OF AN ORGANIZATION, BY EMAIL (AGL-3244).\n *\n * The console notification (`notifications.ts`) is the record, and its email\n * channel is a preference that defaults off. Some things cannot wait on a\n * preference: a sequence mailbox that paused itself holds every enrollment\n * on it until its owner acts, and the owner is not watching the page. This\n * sends the email regardless, to the members named and — when asked — to\n * the organization's owners and admins beside them, one send per address\n * so nobody is shown anyone else's.\n *\n * Addresses come from the roster (`orgs/{orgId}/members/{uid}.email`), and\n * for a member whose row carries none, from the directory. Suppressed\n * addresses are skipped, the send is metered to the organization, and\n * nothing here throws: the notice is a courtesy beside a state the caller\n * already wrote.\n */\n\nexport interface OrgMemberNoticeInput {\n orgId: string\n /** The members to write to, by uid. */\n uids: readonly string[]\n /** Also the organization's owners and admins. */\n includeAdmins?: boolean\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}\n\nexport interface OrgMemberNoticeResult {\n /** Addresses the notice went to. */\n sent: number\n /** Why nothing could be sent at all, when nothing could. */\n reason?: 'unconfigured' | 'no-recipients' | 'failed'\n}\n\n/** At most this many directory lookups, and this many sends, per notice. */\nconst MAX_LOOKUPS = 10\nconst MAX_SENDS = 25\n\n/** Sends one notice to each member — see the module note. */\nexport async function sendOrgMemberNotice(input: OrgMemberNoticeInput): Promise<OrgMemberNoticeResult> {\n if (!isEmailConfigured()) return { sent: 0, reason: 'unconfigured' }\n try {\n const members = await listOrgMembers(input.orgId)\n const rosterEmail = new Map<string, string>()\n for (const member of members) {\n const address = String(member.email ?? '')\n .trim()\n .toLowerCase()\n if (address.includes('@')) rosterEmail.set(member.$id, address)\n }\n const uids = new Set(input.uids.filter(Boolean))\n if (input.includeAdmins) {\n for (const member of members) {\n if (member.role === 'owner' || member.role === 'admin') uids.add(member.$id)\n }\n }\n const addresses = new Set<string>()\n let lookups = 0\n // A locked or disabled account is not mailed (AGL-3418).\n for (const uid of await withoutMailWithheldAccounts([...uids])) {\n let address = rosterEmail.get(uid) ?? ''\n if (!address && lookups < MAX_LOOKUPS) {\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '')\n .trim()\n .toLowerCase()\n }\n if (address.includes('@')) addresses.add(address)\n }\n const recipients = await filterSuppressedEmails([...addresses])\n if (!recipients.length) return { sent: 0, reason: 'no-recipients' }\n // One message for everyone, in the organization's brand, as the\n // `workspace-notice` system email (AGL-3367).\n const brand = await orgSystemEmailBrand(input.orgId)\n const content = await renderSystemEmailContent(\n 'workspace-notice',\n {\n 'notice.subject': input.subject,\n 'notice.body': input.text,\n 'org.name': brand.orgName ?? '',\n },\n brand,\n { subject: input.subject, text: input.text },\n )\n let sent = 0\n for (const to of recipients.slice(0, MAX_SENDS)) {\n const result = await sendEmail({\n to,\n ...content,\n ...(brand.fromName ? { fromName: brand.fromName } : {}),\n context: input.context,\n })\n if (!result.sent) continue\n sent += 1\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n }\n return sent ? { sent } : { sent: 0, reason: 'failed' }\n } catch (error) {\n console.error('[org-member-notice] send failed', error)\n return { sent: 0, reason: 'failed' }\n }\n}\n"],"names":["isEmailConfigured","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","filterSuppressedEmails","listOrgMembers","orgSystemEmailBrand","renderSystemEmailContent","MAX_LOOKUPS","MAX_SENDS","sendOrgMemberNotice","input","sent","reason","brand","members","orgId","rosterEmail","Map","member","address","String","email","trim","toLowerCase","includes","set","$id","uids","Set","filter","Boolean","includeAdmins","role","add","addresses","lookups","uid","get","pooled","catch","record","recipients","length","content","subject","text","orgName","to","slice","result","fromName","context","meterOrgEmail","undefined","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,EAAEC,SAAS,QAAQ,2BAA0B;AACvE,SAASC,2BAA2B,QAAQ,oBAAgB;AAC5D,SAASC,wBAAwB,QAAQ,kBAAc;AACvD,SAASC,sBAAsB,QAAQ,yBAAqB;AAC5D,SAASC,cAAc,QAAQ,qBAAiB;AAChD,SAASC,mBAAmB,EAAEC,wBAAwB,QAAQ,2BAAuB;AAuCrF,0EAA0E,GAC1E,MAAMC,cAAc;AACpB,MAAMC,YAAY;AAElB,2DAA2D,GAC3D,OAAO,eAAeC,oBAAoBC,KAA2B;IACnE,IAAI,CAACX,qBAAqB,OAAO;QAAEY,MAAM;QAAGC,QAAQ;IAAe;IACnE,IAAI;YAuCcC;QAtChB,MAAMC,UAAU,MAAMV,eAAeM,MAAMK,KAAK;QAChD,MAAMC,cAAc,IAAIC;QACxB,KAAK,MAAMC,UAAUJ,QAAS;gBACLI;YAAvB,MAAMC,UAAUC,QAAOF,gBAAAA,OAAOG,KAAK,YAAZH,gBAAgB,IACpCI,IAAI,GACJC,WAAW;YACd,IAAIJ,QAAQK,QAAQ,CAAC,MAAMR,YAAYS,GAAG,CAACP,OAAOQ,GAAG,EAAEP;QACzD;QACA,MAAMQ,OAAO,IAAIC,IAAIlB,MAAMiB,IAAI,CAACE,MAAM,CAACC;QACvC,IAAIpB,MAAMqB,aAAa,EAAE;YACvB,KAAK,MAAMb,UAAUJ,QAAS;gBAC5B,IAAII,OAAOc,IAAI,KAAK,WAAWd,OAAOc,IAAI,KAAK,SAASL,KAAKM,GAAG,CAACf,OAAOQ,GAAG;YAC7E;QACF;QACA,MAAMQ,YAAY,IAAIN;QACtB,IAAIO,UAAU;QACd,yDAAyD;QACzD,KAAK,MAAMC,OAAO,CAAA,MAAMnC,4BAA4B;eAAI0B;SAAK,CAAA,EAAG;gBAChDX;YAAd,IAAIG,WAAUH,mBAAAA,YAAYqB,GAAG,CAACD,gBAAhBpB,mBAAwB;YACtC,IAAI,CAACG,WAAWgB,UAAU5B,aAAa;;oBAGpB+B;gBAFjBH,WAAW;gBACX,MAAMG,SAAS,MAAMpC,yBAAyBkC,KAAKG,KAAK,CAAC,IAAM;gBAC/DpB,UAAUC,eAAOkB,2BAAAA,iBAAAA,OAAQE,MAAM,qBAAdF,eAAgBjB,KAAK,mBAAI,IACvCC,IAAI,GACJC,WAAW;YAChB;YACA,IAAIJ,QAAQK,QAAQ,CAAC,MAAMU,UAAUD,GAAG,CAACd;QAC3C;QACA,MAAMsB,aAAa,MAAMtC,uBAAuB;eAAI+B;SAAU;QAC9D,IAAI,CAACO,WAAWC,MAAM,EAAE,OAAO;YAAE/B,MAAM;YAAGC,QAAQ;QAAgB;QAClE,gEAAgE;QAChE,8CAA8C;QAC9C,MAAMC,QAAQ,MAAMR,oBAAoBK,MAAMK,KAAK;QACnD,MAAM4B,UAAU,MAAMrC,yBACpB,oBACA;YACE,kBAAkBI,MAAMkC,OAAO;YAC/B,eAAelC,MAAMmC,IAAI;YACzB,UAAU,GAAEhC,iBAAAA,MAAMiC,OAAO,YAAbjC,iBAAiB;QAC/B,GACAA,OACA;YAAE+B,SAASlC,MAAMkC,OAAO;YAAEC,MAAMnC,MAAMmC,IAAI;QAAC;QAE7C,IAAIlC,OAAO;QACX,KAAK,MAAMoC,MAAMN,WAAWO,KAAK,CAAC,GAAGxC,WAAY;YAC/C,MAAMyC,SAAS,MAAMjD,UAAU;gBAC7B+C;eACGJ,SACC9B,MAAMqC,QAAQ,GAAG;gBAAEA,UAAUrC,MAAMqC,QAAQ;YAAC,IAAI,CAAC;gBACrDC,SAASzC,MAAMyC,OAAO;;YAExB,IAAI,CAACF,OAAOtC,IAAI,EAAE;YAClBA,QAAQ;YACR,MAAM,EAAEyC,aAAa,EAAE,GAAG,MAAM,MAAM,CAAC;YACvC,MAAMA,cAAc1C,MAAMK,KAAK,EAAEwB,KAAK,CAAC,IAAMc;QAC/C;QACA,OAAO1C,OAAO;YAAEA;QAAK,IAAI;YAAEA,MAAM;YAAGC,QAAQ;QAAS;IACvD,EAAE,OAAO0C,OAAO;QACdC,QAAQD,KAAK,CAAC,mCAAmCA;QACjD,OAAO;YAAE3C,MAAM;YAAGC,QAAQ;QAAS;IACrC;AACF"}
@@ -251,6 +251,10 @@ reservation, now = Date.now()) {
251
251
  // organization's origin is the platform's record, never its own.
252
252
  [ACCOUNT_ACQUISITION_FIELD]: organizationAcquisition(creator.exists ? creator.get(ACCOUNT_ACQUISITION_FIELD) : null, ownerUid, Date.now()),
253
253
  hosts: {},
254
+ // Stored `false`, never left out: the staff Organizations list filters
255
+ // Suspended by equality on it, and a query cannot find a document by a
256
+ // field it lacks. The console's `suspended-flag.ts` names its other writers.
257
+ suspended: false,
254
258
  createdAt: FieldValue.serverTimestamp(),
255
259
  updatedAt: FieldValue.serverTimestamp()
256
260
  });