@aglyn/tenant-data-admin 1.0.0-beta.217 → 1.0.0-beta.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -9
- package/src/lib/server/account-lock-mail.d.ts +74 -0
- package/src/lib/server/account-lock-mail.js +139 -0
- package/src/lib/server/account-lock-mail.js.map +1 -0
- package/src/lib/server/account-mail.d.ts +41 -0
- package/src/lib/server/account-mail.js +52 -0
- package/src/lib/server/account-mail.js.map +1 -0
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +156 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +144 -0
- package/src/lib/server/api-v1-resources.js +126 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +41 -16
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.d.ts +23 -0
- package/src/lib/server/crm-inbound-email.js +1 -16
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +4 -1
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-deliverability.js +21 -6
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +83 -3
- package/src/lib/server/email-suppression.js +229 -3
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/lockdown.js +2 -0
- package/src/lib/server/lockdown.js.map +1 -1
- package/src/lib/server/media-download-tokens.js +2 -1
- package/src/lib/server/media-download-tokens.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +1 -1
- package/src/lib/server/member-email-aliases.js +1 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +8 -2
- package/src/lib/server/notifications.js +18 -4
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/org-member-notice.js +5 -1
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/organizations.js +4 -0
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/risk-notice.js +21 -3
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.js +2 -2
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/serve-plugin-fetch.d.ts +17 -7
- package/src/lib/server/serve-plugin-fetch.js +19 -8
- package/src/lib/server/serve-plugin-fetch.js.map +1 -1
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +85 -18
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +4 -5
- package/src/tenancy.js +11 -6
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
|
@@ -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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"}
|
|
@@ -32,7 +32,7 @@ export interface MemberEmailAliasRefused {
|
|
|
32
32
|
message: string;
|
|
33
33
|
}
|
|
34
34
|
/** The addresses one member has added in one workspace, confirmed or not. */
|
|
35
|
-
export declare function
|
|
35
|
+
export declare function loadMemberEmailAliases(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string): Promise<MemberEmailAlias[]>;
|
|
36
36
|
export type MemberEmailAliasAdded = {
|
|
37
37
|
ok: true;
|
|
38
38
|
alias: MemberEmailAlias;
|
|
@@ -84,7 +84,7 @@ function aliasesRef(firestore, orgId, uid) {
|
|
|
84
84
|
function memberRef(firestore, orgId, uid) {
|
|
85
85
|
return firestore.collection('orgs').doc(orgId).collection('members').doc(uid);
|
|
86
86
|
}
|
|
87
|
-
/** The addresses one member has added in one workspace, confirmed or not. */ export async function
|
|
87
|
+
/** The addresses one member has added in one workspace, confirmed or not. */ export async function loadMemberEmailAliases(firestore, orgId, uid) {
|
|
88
88
|
const snapshot = await aliasesRef(firestore, orgId, uid).get();
|
|
89
89
|
return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null);
|
|
90
90
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/member-email-aliases.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 evaluateMemberEmailAliasAdd,\n isVerifiedMemberEmailAlias,\n MEMBER_EMAIL_ALIAS_CONFIRM_PARAM,\n MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n MEMBER_EMAIL_ALIASES_COLLECTION,\n type MemberEmailAlias,\n type MemberEmailAliasAddRefusal,\n normalizeMemberEmailAlias,\n readMemberEmailAliases,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { createHmac } from 'crypto'\nimport { tokenSigningSecret } from './media-signing'\nimport { renderSystemEmailContent, systemEmailBrand } from './render-system-email'\nimport { safeEqual } from './safe-equal'\n\n/**\n * A MEMBER'S OWN ADDRESSES (AGL-2975): the half with Firestore.\n *\n * The pure rules — what an address is, which are confirmed, whose an\n * address is — are `@aglyn/aglyn`'s `member-email-aliases.ts`. This module\n * is the store under `orgs/{orgId}/memberEmailAliases/{uid}`, the signed\n * confirmation link, and the email that carries it.\n *\n * ## Every write is here\n *\n * The rules deny the collection to every client, reads included, so\n * `verifiedAtMs` is only ever set here, by one of two writers, each running\n * behind the member's own verified session:\n * {@link confirmMemberEmailAlias}, for a member who opened the link mailed\n * to the address, and {@link confirmMemberEmailAliasesByProvider}, for a\n * member whose connected mail account lists the address as one its provider\n * has already verified they may send as. A client that could write the\n * document could mark any address it liked as its own, and the capture\n * webhook would then file a stranger's mail as the member's.\n *\n * ## The confirmation link\n *\n * `{ org, uid, address, addedAtMs, exp }`, HMAC-signed with the shared,\n * fail-closed `TOKEN_SIGNING_SECRET` under its own `member-email-alias:`\n * prefix, so it can never be replayed as a media, edit-bar or form token.\n * Nothing is stored for it: the entry's `addedAtMs` is what binds a link to\n * one request, so removing an address and adding it again retires every\n * link sent before, and a link for an address already confirmed confirms\n * nothing new.\n *\n * Confirming takes the session of the member who added the address — not\n * merely the link. Opening the mailbox proves the address receives mail;\n * it does not prove the mailbox is the member's. A prospect whose address\n * was pasted into the wrong field receives the link too, and a link that\n * worked for whoever opened it would let that prospect turn their own\n * address into the member's, after which every reply they sent would be\n * filed as the member writing to nobody.\n *\n * Firestore is a parameter, as in `crm-inbound-email.ts`, so a spec needs a\n * store and nothing else; the rate limiter and the email meter are loaded\n * only when a message is actually sent.\n */\n\n/** The version tag at the head of a token, so a later claim change is refused by shape. */\nconst TOKEN_VERSION = 'mea1'\n\n/** Domain separation from every other token the shared secret signs. */\nconst TOKEN_CONTEXT = 'member-email-alias:v1'\n\n/** Far above any real token, so an oversized value is refused unparsed. */\nconst TOKEN_MAX_CHARS = 2048\n\n/** Confirmation emails one member may cause per hour, across addresses. */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR = 10\n\n/**\n * Confirmation emails one address may receive per hour, from anybody.\n * Per member alone would let many members aim at one mailbox; per address\n * alone would let one member walk a list — the two limits the account\n * address route applies, for the same mailbomb shape.\n */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR = 3\n\n/** Every refusal this module answers with, as a stable code. */\nexport type MemberEmailAliasRefusal =\n | MemberEmailAliasAddRefusal\n | 'not-a-member'\n | 'unknown-address'\n | 'token-invalid'\n | 'token-expired'\n | 'wrong-member'\n | 'link-retired'\n\nexport interface MemberEmailAliasRefused {\n ok: false\n refusal: MemberEmailAliasRefusal\n message: string\n}\n\nconst refused = (refusal: MemberEmailAliasRefusal, message: string): MemberEmailAliasRefused => ({\n ok: false,\n refusal,\n message,\n})\n\nconst NOT_A_MEMBER = 'Only a member of this workspace can add addresses to it.'\n\nfunction aliasesRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(MEMBER_EMAIL_ALIASES_COLLECTION)\n .doc(uid)\n}\n\nfunction memberRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore.collection('orgs').doc(orgId).collection('members').doc(uid)\n}\n\n/** The addresses one member has added in one workspace, confirmed or not. */\nexport async function listMemberEmailAliases(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): Promise<MemberEmailAlias[]> {\n const snapshot = await aliasesRef(firestore, orgId, uid).get()\n return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null)\n}\n\nexport type MemberEmailAliasAdded =\n | {\n ok: true\n alias: MemberEmailAlias\n /** False when the address was already waiting for confirmation. */\n created: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Adds an address, unconfirmed, for a member of the workspace — or answers\n * the entry already waiting for it, so the caller sends another link. One\n * transaction over the roster row and the member's document, so the\n * ceiling is counted from what is stored at the moment of the write and an\n * address is never added for somebody who is no longer a member.\n */\nexport async function addMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n uid: string\n address: unknown\n /** The address the member signs in with, which is theirs already. */\n signInEmail?: string | null\n /** Domains the deployment receives mail at — the capture domain. */\n reservedDomains?: readonly string[]\n nowMs?: number\n },\n): Promise<MemberEmailAliasAdded> {\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasAdded>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const verdict = evaluateMemberEmailAliasAdd({\n address: input.address,\n signInEmail: input.signInEmail ?? null,\n aliases,\n reservedDomains: input.reservedDomains ?? [],\n })\n if (verdict.ok === false) return refused(verdict.refusal, verdict.message)\n if (verdict.existing) return { ok: true, alias: verdict.existing, created: false }\n const alias: MemberEmailAlias = { address: verdict.address, addedAtMs: nowMs }\n tx.set(ref, { uid: input.uid, aliases: [...aliases, alias], updatedAtMs: nowMs })\n return { ok: true, alias, created: true }\n })\n}\n\n/**\n * Removes one address, confirmed or not; the document goes with its last\n * address. Removal retires every link sent for the address, because a link\n * names the entry's `addedAtMs` and there is no longer an entry to match.\n */\nexport type MemberEmailAliasRemoved =\n | { ok: true; removed: MemberEmailAlias }\n | MemberEmailAliasRefused\n\nexport async function removeMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; address: unknown; nowMs?: number },\n): Promise<MemberEmailAliasRemoved> {\n const address = normalizeMemberEmailAlias(input.address)\n if (!address) return refused('invalid-address', 'Enter a valid email address.')\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasRemoved>(async (tx) => {\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const removed = aliases.find((alias) => alias.address === address)\n if (!removed) return refused('unknown-address', 'That address is not on your list.')\n const remaining = aliases.filter((alias) => alias.address !== address)\n if (remaining.length) {\n tx.set(ref, { uid: input.uid, aliases: remaining, updatedAtMs: nowMs })\n } else {\n tx.delete(ref)\n }\n return { ok: true, removed }\n })\n}\n\n/** What a confirmation link asserts. */\nexport interface MemberEmailAliasClaims {\n orgId: string\n uid: string\n address: string\n /** The `addedAtMs` of the entry the link was sent for. */\n addedAtMs: number\n /** Expiry, epoch ms. */\n exp: number\n}\n\ninterface WireClaims {\n o: string\n u: string\n a: string\n t: number\n e: number\n}\n\nconst signature = (payload: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${TOKEN_CONTEXT}:${payload}`).digest('base64url')\n\n/**\n * The token a confirmation link carries for one entry. Throws when\n * `TOKEN_SIGNING_SECRET` is not configured, like every token that secret\n * signs; the route answers that as a failure to send.\n */\nexport function mintMemberEmailAliasToken(\n claims: Omit<MemberEmailAliasClaims, 'exp'>,\n nowMs: number = Date.now(),\n): string {\n const wire: WireClaims = {\n o: claims.orgId,\n u: claims.uid,\n a: claims.address,\n t: claims.addedAtMs,\n e: nowMs + MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return `${TOKEN_VERSION}.${payload}.${signature(payload)}`\n}\n\n/**\n * The claims a token carries, or why it carries none: `token-expired` for a\n * genuine link past its day, `token-invalid` for anything else — tampered,\n * truncated, another version, or a deployment with no signing secret, whose\n * only safe reading of a link is that it proves nothing.\n */\nexport function readMemberEmailAliasToken(\n token: unknown,\n nowMs: number = Date.now(),\n): { ok: true; claims: MemberEmailAliasClaims } | MemberEmailAliasRefused {\n const invalid = refused('token-invalid', 'That confirmation link is not valid.')\n if (typeof token !== 'string' || !token || token.length > TOKEN_MAX_CHARS) return invalid\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return invalid\n const [, payload, signed] = parts\n let expected: string\n try {\n expected = signature(payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signed, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const address = normalizeMemberEmailAlias(wire?.a)\n const orgId = typeof wire?.o === 'string' ? wire.o : ''\n const uid = typeof wire?.u === 'string' ? wire.u : ''\n const addedAtMs = Number(wire?.t)\n const exp = Number(wire?.e)\n if (!orgId || !uid || !address || !Number.isFinite(addedAtMs) || !Number.isFinite(exp)) {\n return invalid\n }\n if (exp <= nowMs) {\n return refused('token-expired', 'That confirmation link has expired. Send a new one from the list.')\n }\n return { ok: true, claims: { orgId, uid, address, addedAtMs, exp } }\n}\n\nexport type MemberEmailAliasConfirmed =\n | {\n ok: true\n orgId: string\n address: string\n /** True when the address had been confirmed before this call. */\n alreadyConfirmed: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms the address a link was sent for, for the member signed in — who\n * must be the member who added it (see the module comment). A link for an\n * entry that has since been removed or added again is refused as retired,\n * and opening a link twice is not an error.\n */\nexport async function confirmMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { token: unknown; callerUid: string; nowMs?: number },\n): Promise<MemberEmailAliasConfirmed> {\n const nowMs = input.nowMs ?? Date.now()\n const read = readMemberEmailAliasToken(input.token, nowMs)\n if (read.ok === false) return read\n const { claims } = read\n if (!input.callerUid || claims.uid !== input.callerUid) {\n return refused(\n 'wrong-member',\n 'This link confirms an address for another member. Sign in as the member who added it, then open the link again.',\n )\n }\n const ref = aliasesRef(firestore, claims.orgId, claims.uid)\n const roster = memberRef(firestore, claims.orgId, claims.uid)\n return firestore.runTransaction<MemberEmailAliasConfirmed>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', 'You are no longer a member of that workspace.')\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const entry = aliases.find((alias) => alias.address === claims.address)\n if (!entry || entry.addedAtMs !== claims.addedAtMs) {\n return refused(\n 'link-retired',\n 'This link is for an address that has since been removed or added again. Send a new link from the list.',\n )\n }\n if (isVerifiedMemberEmailAlias(entry)) {\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: true }\n }\n const next = aliases.map((alias) =>\n alias.address === entry.address ? { ...alias, verifiedAtMs: nowMs } : alias,\n )\n tx.set(ref, { uid: claims.uid, aliases: next, updatedAtMs: nowMs })\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: false }\n })\n}\n\nexport type MemberEmailAliasesConfirmedByProvider =\n | {\n ok: true\n /** The addresses this call confirmed, normalized, in list order. */\n confirmed: string[]\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms a member's PENDING addresses that their connected mail account\n * already proves are theirs (AGL-2978).\n *\n * The confirmation link proves an address receives mail and asks the member\n * to open it. A provider's verified send-as list proves more: the provider\n * confirmed the account may send as the address, and the member proved they\n * hold that account by connecting it under their own session. So an address\n * the member added and has not yet confirmed counts as confirmed once it\n * appears in that list.\n *\n * Only addresses the member ALREADY added are touched — this never adds one,\n * because an address the member did not ask for is not a claim they made —\n * and an address already confirmed keeps its original `verifiedAtMs`. The\n * caller passes only the provider's VERIFIED addresses; which of a\n * provider's listings count as verified is the caller's rule, not this\n * store's. Nothing is written when nothing changes.\n */\nexport async function confirmMemberEmailAliasesByProvider(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; addresses: readonly unknown[]; nowMs?: number },\n): Promise<MemberEmailAliasesConfirmedByProvider> {\n const nowMs = input.nowMs ?? Date.now()\n const proven = new Set(\n input.addresses\n .map((address) => normalizeMemberEmailAlias(address))\n .filter((address): address is string => Boolean(address)),\n )\n if (!input.orgId || !input.uid) return refused('not-a-member', NOT_A_MEMBER)\n if (!proven.size) return { ok: true, confirmed: [] }\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasesConfirmedByProvider>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const confirmed: string[] = []\n const next = aliases.map((alias) => {\n if (isVerifiedMemberEmailAlias(alias) || !proven.has(alias.address)) return alias\n confirmed.push(alias.address)\n return { ...alias, verifiedAtMs: nowMs }\n })\n if (confirmed.length) {\n tx.set(ref, { uid: input.uid, aliases: next, updatedAtMs: nowMs })\n }\n return { ok: true, confirmed }\n })\n}\n\n/**\n * A console path a link may land on: absolute, on the console's own\n * origin, with no query or fragment of its own. Anything else lands on\n * `/`, which still signs the member in.\n */\nconst RETURN_PATH = /^\\/(?![/\\\\])[^\\s?#\\\\]{0,511}$/\n\n/**\n * The link a confirmation email carries: the console origin the caller\n * resolved from server configuration — never from a request header — the\n * page the member added the address on, and the token.\n */\nexport function memberEmailAliasConfirmUrl(input: {\n origin: string\n returnPath?: unknown\n token: string\n}): string {\n const origin = String(input.origin ?? '').replace(/\\/+$/, '')\n const raw = typeof input.returnPath === 'string' ? input.returnPath : ''\n const path = RETURN_PATH.test(raw) ? raw : '/'\n return `${origin}${path}?${MEMBER_EMAIL_ALIAS_CONFIRM_PARAM}=${encodeURIComponent(input.token)}`\n}\n\nexport type MemberEmailAliasConfirmationSent =\n | { sent: true }\n | { sent: false; status: 429 | 501 | 502; error: string }\n\n/**\n * Emails the confirmation link to the address, in the workspace's brand.\n *\n * Rate-limited per member and per destination address before anything is\n * sent (see the two ceilings above), and metered to the workspace, whose\n * member asked for it.\n */\nexport async function sendMemberEmailAliasConfirmation(input: {\n orgId: string\n /** The org document, for its name and its brand. */\n org: Record<string, unknown> | null\n uid: string\n /** The member's display name, when the roster row carries one. */\n memberName?: string | null\n address: string\n confirmUrl: string\n}): Promise<MemberEmailAliasConfirmationSent> {\n if (!isEmailConfigured()) {\n return { sent: false, status: 501, error: 'Email is not configured on this deployment.' }\n }\n const { consumeRateLimit } = await import('./rate-limit-store')\n const perMember = await consumeRateLimit(`member-email-alias-confirm:${input.uid}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perMember.allowed) {\n return { sent: false, status: 429, error: 'Too many confirmation emails. Try again in an hour.' }\n }\n const perAddress = await consumeRateLimit(`member-email-alias-target:${input.address}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perAddress.allowed) {\n return {\n sent: false,\n status: 429,\n error: 'Too many confirmation emails for that address. Try again later.',\n }\n }\n\n const branding = resolveBrandingProfile((input.org ?? null) as never)\n const workspace = String(input.org?.['name'] ?? '').trim() || 'your workspace'\n const member = String(input.memberName ?? '').trim()\n const text = [\n `${member || 'A member'} asked to add ${input.address} as one of their own email ` +\n `addresses in the ${workspace} workspace on ${branding.productName}.`,\n '',\n `To confirm it, open this link while signed in as ${member || 'that member'}:`,\n '',\n input.confirmUrl,\n '',\n `Once it is confirmed, email sent from ${input.address} with the workspace's capture ` +\n 'address in BCC is filed under the person it was written to. The link works for 24 hours.',\n '',\n 'If you did not expect this email, ignore it. Nothing changes unless the member who asked opens the link.',\n ].join('\\n')\n // The `member-email-alias-confirmation` system email (AGL-3367), in the\n // workspace's brand; `text` above is its last resort.\n const content = await renderSystemEmailContent(\n 'member-email-alias-confirmation',\n {\n 'member.name': member || 'A member',\n address: input.address,\n 'org.name': workspace,\n confirmUrl: input.confirmUrl,\n },\n systemEmailBrand((input.org ?? null) as Record<string, unknown> | null),\n { subject: `Confirm your address for ${workspace}`, text },\n )\n const result = await sendEmail({\n to: input.address,\n ...content,\n fromName: branding.fromName,\n context: 'member-email-alias-confirmation',\n })\n if (!result.sent) {\n return { sent: false, status: 502, error: 'The confirmation email could not be sent.' }\n }\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n return { sent: true }\n}\n"],"names":["evaluateMemberEmailAliasAdd","isVerifiedMemberEmailAlias","MEMBER_EMAIL_ALIAS_CONFIRM_PARAM","MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS","MEMBER_EMAIL_ALIASES_COLLECTION","normalizeMemberEmailAlias","readMemberEmailAliases","resolveBrandingProfile","isEmailConfigured","sendEmail","createHmac","tokenSigningSecret","renderSystemEmailContent","systemEmailBrand","safeEqual","TOKEN_VERSION","TOKEN_CONTEXT","TOKEN_MAX_CHARS","MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR","MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR","refused","refusal","message","ok","NOT_A_MEMBER","aliasesRef","firestore","orgId","uid","collection","doc","memberRef","listMemberEmailAliases","snapshot","get","exists","data","addMemberEmailAlias","input","nowMs","Date","now","ref","roster","runTransaction","tx","member","stored","aliases","verdict","address","signInEmail","reservedDomains","existing","alias","created","addedAtMs","set","updatedAtMs","removeMemberEmailAlias","removed","find","remaining","filter","length","delete","signature","payload","update","digest","mintMemberEmailAliasToken","claims","wire","o","u","a","t","e","Buffer","from","JSON","stringify","toString","readMemberEmailAliasToken","token","invalid","parts","split","signed","expected","parse","Number","exp","isFinite","confirmMemberEmailAlias","read","callerUid","entry","alreadyConfirmed","next","map","verifiedAtMs","confirmMemberEmailAliasesByProvider","proven","Set","addresses","Boolean","size","confirmed","has","push","RETURN_PATH","memberEmailAliasConfirmUrl","origin","String","replace","raw","returnPath","path","test","encodeURIComponent","sendMemberEmailAliasConfirmation","sent","status","error","consumeRateLimit","perMember","limit","windowMs","allowed","perAddress","branding","org","workspace","trim","memberName","text","productName","confirmUrl","join","content","subject","result","to","fromName","context","meterOrgEmail","catch","undefined"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/member-email-aliases.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 evaluateMemberEmailAliasAdd,\n isVerifiedMemberEmailAlias,\n MEMBER_EMAIL_ALIAS_CONFIRM_PARAM,\n MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n MEMBER_EMAIL_ALIASES_COLLECTION,\n type MemberEmailAlias,\n type MemberEmailAliasAddRefusal,\n normalizeMemberEmailAlias,\n readMemberEmailAliases,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { createHmac } from 'crypto'\nimport { tokenSigningSecret } from './media-signing'\nimport { renderSystemEmailContent, systemEmailBrand } from './render-system-email'\nimport { safeEqual } from './safe-equal'\n\n/**\n * A MEMBER'S OWN ADDRESSES (AGL-2975): the half with Firestore.\n *\n * The pure rules — what an address is, which are confirmed, whose an\n * address is — are `@aglyn/aglyn`'s `member-email-aliases.ts`. This module\n * is the store under `orgs/{orgId}/memberEmailAliases/{uid}`, the signed\n * confirmation link, and the email that carries it.\n *\n * ## Every write is here\n *\n * The rules deny the collection to every client, reads included, so\n * `verifiedAtMs` is only ever set here, by one of two writers, each running\n * behind the member's own verified session:\n * {@link confirmMemberEmailAlias}, for a member who opened the link mailed\n * to the address, and {@link confirmMemberEmailAliasesByProvider}, for a\n * member whose connected mail account lists the address as one its provider\n * has already verified they may send as. A client that could write the\n * document could mark any address it liked as its own, and the capture\n * webhook would then file a stranger's mail as the member's.\n *\n * ## The confirmation link\n *\n * `{ org, uid, address, addedAtMs, exp }`, HMAC-signed with the shared,\n * fail-closed `TOKEN_SIGNING_SECRET` under its own `member-email-alias:`\n * prefix, so it can never be replayed as a media, edit-bar or form token.\n * Nothing is stored for it: the entry's `addedAtMs` is what binds a link to\n * one request, so removing an address and adding it again retires every\n * link sent before, and a link for an address already confirmed confirms\n * nothing new.\n *\n * Confirming takes the session of the member who added the address — not\n * merely the link. Opening the mailbox proves the address receives mail;\n * it does not prove the mailbox is the member's. A prospect whose address\n * was pasted into the wrong field receives the link too, and a link that\n * worked for whoever opened it would let that prospect turn their own\n * address into the member's, after which every reply they sent would be\n * filed as the member writing to nobody.\n *\n * Firestore is a parameter, as in `crm-inbound-email.ts`, so a spec needs a\n * store and nothing else; the rate limiter and the email meter are loaded\n * only when a message is actually sent.\n */\n\n/** The version tag at the head of a token, so a later claim change is refused by shape. */\nconst TOKEN_VERSION = 'mea1'\n\n/** Domain separation from every other token the shared secret signs. */\nconst TOKEN_CONTEXT = 'member-email-alias:v1'\n\n/** Far above any real token, so an oversized value is refused unparsed. */\nconst TOKEN_MAX_CHARS = 2048\n\n/** Confirmation emails one member may cause per hour, across addresses. */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR = 10\n\n/**\n * Confirmation emails one address may receive per hour, from anybody.\n * Per member alone would let many members aim at one mailbox; per address\n * alone would let one member walk a list — the two limits the account\n * address route applies, for the same mailbomb shape.\n */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR = 3\n\n/** Every refusal this module answers with, as a stable code. */\nexport type MemberEmailAliasRefusal =\n | MemberEmailAliasAddRefusal\n | 'not-a-member'\n | 'unknown-address'\n | 'token-invalid'\n | 'token-expired'\n | 'wrong-member'\n | 'link-retired'\n\nexport interface MemberEmailAliasRefused {\n ok: false\n refusal: MemberEmailAliasRefusal\n message: string\n}\n\nconst refused = (refusal: MemberEmailAliasRefusal, message: string): MemberEmailAliasRefused => ({\n ok: false,\n refusal,\n message,\n})\n\nconst NOT_A_MEMBER = 'Only a member of this workspace can add addresses to it.'\n\nfunction aliasesRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(MEMBER_EMAIL_ALIASES_COLLECTION)\n .doc(uid)\n}\n\nfunction memberRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore.collection('orgs').doc(orgId).collection('members').doc(uid)\n}\n\n/** The addresses one member has added in one workspace, confirmed or not. */\nexport async function loadMemberEmailAliases(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): Promise<MemberEmailAlias[]> {\n const snapshot = await aliasesRef(firestore, orgId, uid).get()\n return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null)\n}\n\nexport type MemberEmailAliasAdded =\n | {\n ok: true\n alias: MemberEmailAlias\n /** False when the address was already waiting for confirmation. */\n created: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Adds an address, unconfirmed, for a member of the workspace — or answers\n * the entry already waiting for it, so the caller sends another link. One\n * transaction over the roster row and the member's document, so the\n * ceiling is counted from what is stored at the moment of the write and an\n * address is never added for somebody who is no longer a member.\n */\nexport async function addMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n uid: string\n address: unknown\n /** The address the member signs in with, which is theirs already. */\n signInEmail?: string | null\n /** Domains the deployment receives mail at — the capture domain. */\n reservedDomains?: readonly string[]\n nowMs?: number\n },\n): Promise<MemberEmailAliasAdded> {\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasAdded>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const verdict = evaluateMemberEmailAliasAdd({\n address: input.address,\n signInEmail: input.signInEmail ?? null,\n aliases,\n reservedDomains: input.reservedDomains ?? [],\n })\n if (verdict.ok === false) return refused(verdict.refusal, verdict.message)\n if (verdict.existing) return { ok: true, alias: verdict.existing, created: false }\n const alias: MemberEmailAlias = { address: verdict.address, addedAtMs: nowMs }\n tx.set(ref, { uid: input.uid, aliases: [...aliases, alias], updatedAtMs: nowMs })\n return { ok: true, alias, created: true }\n })\n}\n\n/**\n * Removes one address, confirmed or not; the document goes with its last\n * address. Removal retires every link sent for the address, because a link\n * names the entry's `addedAtMs` and there is no longer an entry to match.\n */\nexport type MemberEmailAliasRemoved =\n | { ok: true; removed: MemberEmailAlias }\n | MemberEmailAliasRefused\n\nexport async function removeMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; address: unknown; nowMs?: number },\n): Promise<MemberEmailAliasRemoved> {\n const address = normalizeMemberEmailAlias(input.address)\n if (!address) return refused('invalid-address', 'Enter a valid email address.')\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasRemoved>(async (tx) => {\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const removed = aliases.find((alias) => alias.address === address)\n if (!removed) return refused('unknown-address', 'That address is not on your list.')\n const remaining = aliases.filter((alias) => alias.address !== address)\n if (remaining.length) {\n tx.set(ref, { uid: input.uid, aliases: remaining, updatedAtMs: nowMs })\n } else {\n tx.delete(ref)\n }\n return { ok: true, removed }\n })\n}\n\n/** What a confirmation link asserts. */\nexport interface MemberEmailAliasClaims {\n orgId: string\n uid: string\n address: string\n /** The `addedAtMs` of the entry the link was sent for. */\n addedAtMs: number\n /** Expiry, epoch ms. */\n exp: number\n}\n\ninterface WireClaims {\n o: string\n u: string\n a: string\n t: number\n e: number\n}\n\nconst signature = (payload: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${TOKEN_CONTEXT}:${payload}`).digest('base64url')\n\n/**\n * The token a confirmation link carries for one entry. Throws when\n * `TOKEN_SIGNING_SECRET` is not configured, like every token that secret\n * signs; the route answers that as a failure to send.\n */\nexport function mintMemberEmailAliasToken(\n claims: Omit<MemberEmailAliasClaims, 'exp'>,\n nowMs: number = Date.now(),\n): string {\n const wire: WireClaims = {\n o: claims.orgId,\n u: claims.uid,\n a: claims.address,\n t: claims.addedAtMs,\n e: nowMs + MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return `${TOKEN_VERSION}.${payload}.${signature(payload)}`\n}\n\n/**\n * The claims a token carries, or why it carries none: `token-expired` for a\n * genuine link past its day, `token-invalid` for anything else — tampered,\n * truncated, another version, or a deployment with no signing secret, whose\n * only safe reading of a link is that it proves nothing.\n */\nexport function readMemberEmailAliasToken(\n token: unknown,\n nowMs: number = Date.now(),\n): { ok: true; claims: MemberEmailAliasClaims } | MemberEmailAliasRefused {\n const invalid = refused('token-invalid', 'That confirmation link is not valid.')\n if (typeof token !== 'string' || !token || token.length > TOKEN_MAX_CHARS) return invalid\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return invalid\n const [, payload, signed] = parts\n let expected: string\n try {\n expected = signature(payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signed, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const address = normalizeMemberEmailAlias(wire?.a)\n const orgId = typeof wire?.o === 'string' ? wire.o : ''\n const uid = typeof wire?.u === 'string' ? wire.u : ''\n const addedAtMs = Number(wire?.t)\n const exp = Number(wire?.e)\n if (!orgId || !uid || !address || !Number.isFinite(addedAtMs) || !Number.isFinite(exp)) {\n return invalid\n }\n if (exp <= nowMs) {\n return refused('token-expired', 'That confirmation link has expired. Send a new one from the list.')\n }\n return { ok: true, claims: { orgId, uid, address, addedAtMs, exp } }\n}\n\nexport type MemberEmailAliasConfirmed =\n | {\n ok: true\n orgId: string\n address: string\n /** True when the address had been confirmed before this call. */\n alreadyConfirmed: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms the address a link was sent for, for the member signed in — who\n * must be the member who added it (see the module comment). A link for an\n * entry that has since been removed or added again is refused as retired,\n * and opening a link twice is not an error.\n */\nexport async function confirmMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { token: unknown; callerUid: string; nowMs?: number },\n): Promise<MemberEmailAliasConfirmed> {\n const nowMs = input.nowMs ?? Date.now()\n const read = readMemberEmailAliasToken(input.token, nowMs)\n if (read.ok === false) return read\n const { claims } = read\n if (!input.callerUid || claims.uid !== input.callerUid) {\n return refused(\n 'wrong-member',\n 'This link confirms an address for another member. Sign in as the member who added it, then open the link again.',\n )\n }\n const ref = aliasesRef(firestore, claims.orgId, claims.uid)\n const roster = memberRef(firestore, claims.orgId, claims.uid)\n return firestore.runTransaction<MemberEmailAliasConfirmed>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', 'You are no longer a member of that workspace.')\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const entry = aliases.find((alias) => alias.address === claims.address)\n if (!entry || entry.addedAtMs !== claims.addedAtMs) {\n return refused(\n 'link-retired',\n 'This link is for an address that has since been removed or added again. Send a new link from the list.',\n )\n }\n if (isVerifiedMemberEmailAlias(entry)) {\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: true }\n }\n const next = aliases.map((alias) =>\n alias.address === entry.address ? { ...alias, verifiedAtMs: nowMs } : alias,\n )\n tx.set(ref, { uid: claims.uid, aliases: next, updatedAtMs: nowMs })\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: false }\n })\n}\n\nexport type MemberEmailAliasesConfirmedByProvider =\n | {\n ok: true\n /** The addresses this call confirmed, normalized, in list order. */\n confirmed: string[]\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms a member's PENDING addresses that their connected mail account\n * already proves are theirs (AGL-2978).\n *\n * The confirmation link proves an address receives mail and asks the member\n * to open it. A provider's verified send-as list proves more: the provider\n * confirmed the account may send as the address, and the member proved they\n * hold that account by connecting it under their own session. So an address\n * the member added and has not yet confirmed counts as confirmed once it\n * appears in that list.\n *\n * Only addresses the member ALREADY added are touched — this never adds one,\n * because an address the member did not ask for is not a claim they made —\n * and an address already confirmed keeps its original `verifiedAtMs`. The\n * caller passes only the provider's VERIFIED addresses; which of a\n * provider's listings count as verified is the caller's rule, not this\n * store's. Nothing is written when nothing changes.\n */\nexport async function confirmMemberEmailAliasesByProvider(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; addresses: readonly unknown[]; nowMs?: number },\n): Promise<MemberEmailAliasesConfirmedByProvider> {\n const nowMs = input.nowMs ?? Date.now()\n const proven = new Set(\n input.addresses\n .map((address) => normalizeMemberEmailAlias(address))\n .filter((address): address is string => Boolean(address)),\n )\n if (!input.orgId || !input.uid) return refused('not-a-member', NOT_A_MEMBER)\n if (!proven.size) return { ok: true, confirmed: [] }\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasesConfirmedByProvider>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const confirmed: string[] = []\n const next = aliases.map((alias) => {\n if (isVerifiedMemberEmailAlias(alias) || !proven.has(alias.address)) return alias\n confirmed.push(alias.address)\n return { ...alias, verifiedAtMs: nowMs }\n })\n if (confirmed.length) {\n tx.set(ref, { uid: input.uid, aliases: next, updatedAtMs: nowMs })\n }\n return { ok: true, confirmed }\n })\n}\n\n/**\n * A console path a link may land on: absolute, on the console's own\n * origin, with no query or fragment of its own. Anything else lands on\n * `/`, which still signs the member in.\n */\nconst RETURN_PATH = /^\\/(?![/\\\\])[^\\s?#\\\\]{0,511}$/\n\n/**\n * The link a confirmation email carries: the console origin the caller\n * resolved from server configuration — never from a request header — the\n * page the member added the address on, and the token.\n */\nexport function memberEmailAliasConfirmUrl(input: {\n origin: string\n returnPath?: unknown\n token: string\n}): string {\n const origin = String(input.origin ?? '').replace(/\\/+$/, '')\n const raw = typeof input.returnPath === 'string' ? input.returnPath : ''\n const path = RETURN_PATH.test(raw) ? raw : '/'\n return `${origin}${path}?${MEMBER_EMAIL_ALIAS_CONFIRM_PARAM}=${encodeURIComponent(input.token)}`\n}\n\nexport type MemberEmailAliasConfirmationSent =\n | { sent: true }\n | { sent: false; status: 429 | 501 | 502; error: string }\n\n/**\n * Emails the confirmation link to the address, in the workspace's brand.\n *\n * Rate-limited per member and per destination address before anything is\n * sent (see the two ceilings above), and metered to the workspace, whose\n * member asked for it.\n */\nexport async function sendMemberEmailAliasConfirmation(input: {\n orgId: string\n /** The org document, for its name and its brand. */\n org: Record<string, unknown> | null\n uid: string\n /** The member's display name, when the roster row carries one. */\n memberName?: string | null\n address: string\n confirmUrl: string\n}): Promise<MemberEmailAliasConfirmationSent> {\n if (!isEmailConfigured()) {\n return { sent: false, status: 501, error: 'Email is not configured on this deployment.' }\n }\n const { consumeRateLimit } = await import('./rate-limit-store')\n const perMember = await consumeRateLimit(`member-email-alias-confirm:${input.uid}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perMember.allowed) {\n return { sent: false, status: 429, error: 'Too many confirmation emails. Try again in an hour.' }\n }\n const perAddress = await consumeRateLimit(`member-email-alias-target:${input.address}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perAddress.allowed) {\n return {\n sent: false,\n status: 429,\n error: 'Too many confirmation emails for that address. Try again later.',\n }\n }\n\n const branding = resolveBrandingProfile((input.org ?? null) as never)\n const workspace = String(input.org?.['name'] ?? '').trim() || 'your workspace'\n const member = String(input.memberName ?? '').trim()\n const text = [\n `${member || 'A member'} asked to add ${input.address} as one of their own email ` +\n `addresses in the ${workspace} workspace on ${branding.productName}.`,\n '',\n `To confirm it, open this link while signed in as ${member || 'that member'}:`,\n '',\n input.confirmUrl,\n '',\n `Once it is confirmed, email sent from ${input.address} with the workspace's capture ` +\n 'address in BCC is filed under the person it was written to. The link works for 24 hours.',\n '',\n 'If you did not expect this email, ignore it. Nothing changes unless the member who asked opens the link.',\n ].join('\\n')\n // The `member-email-alias-confirmation` system email (AGL-3367), in the\n // workspace's brand; `text` above is its last resort.\n const content = await renderSystemEmailContent(\n 'member-email-alias-confirmation',\n {\n 'member.name': member || 'A member',\n address: input.address,\n 'org.name': workspace,\n confirmUrl: input.confirmUrl,\n },\n systemEmailBrand((input.org ?? null) as Record<string, unknown> | null),\n { subject: `Confirm your address for ${workspace}`, text },\n )\n const result = await sendEmail({\n to: input.address,\n ...content,\n fromName: branding.fromName,\n context: 'member-email-alias-confirmation',\n })\n if (!result.sent) {\n return { sent: false, status: 502, error: 'The confirmation email could not be sent.' }\n }\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n return { sent: true }\n}\n"],"names":["evaluateMemberEmailAliasAdd","isVerifiedMemberEmailAlias","MEMBER_EMAIL_ALIAS_CONFIRM_PARAM","MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS","MEMBER_EMAIL_ALIASES_COLLECTION","normalizeMemberEmailAlias","readMemberEmailAliases","resolveBrandingProfile","isEmailConfigured","sendEmail","createHmac","tokenSigningSecret","renderSystemEmailContent","systemEmailBrand","safeEqual","TOKEN_VERSION","TOKEN_CONTEXT","TOKEN_MAX_CHARS","MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR","MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR","refused","refusal","message","ok","NOT_A_MEMBER","aliasesRef","firestore","orgId","uid","collection","doc","memberRef","loadMemberEmailAliases","snapshot","get","exists","data","addMemberEmailAlias","input","nowMs","Date","now","ref","roster","runTransaction","tx","member","stored","aliases","verdict","address","signInEmail","reservedDomains","existing","alias","created","addedAtMs","set","updatedAtMs","removeMemberEmailAlias","removed","find","remaining","filter","length","delete","signature","payload","update","digest","mintMemberEmailAliasToken","claims","wire","o","u","a","t","e","Buffer","from","JSON","stringify","toString","readMemberEmailAliasToken","token","invalid","parts","split","signed","expected","parse","Number","exp","isFinite","confirmMemberEmailAlias","read","callerUid","entry","alreadyConfirmed","next","map","verifiedAtMs","confirmMemberEmailAliasesByProvider","proven","Set","addresses","Boolean","size","confirmed","has","push","RETURN_PATH","memberEmailAliasConfirmUrl","origin","String","replace","raw","returnPath","path","test","encodeURIComponent","sendMemberEmailAliasConfirmation","sent","status","error","consumeRateLimit","perMember","limit","windowMs","allowed","perAddress","branding","org","workspace","trim","memberName","text","productName","confirmUrl","join","content","subject","result","to","fromName","context","meterOrgEmail","catch","undefined"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,2BAA2B,EAC3BC,0BAA0B,EAC1BC,gCAAgC,EAChCC,iCAAiC,EACjCC,+BAA+B,EAG/BC,yBAAyB,EACzBC,sBAAsB,QACjB,8CAA6C;AACpD,SAASC,sBAAsB,QAAQ,sBAAqB;AAC5D,SAASC,iBAAiB,EAAEC,SAAS,QAAQ,2BAA0B;AACvE,SAASC,UAAU,QAAQ,SAAQ;AACnC,SAASC,kBAAkB,QAAQ,qBAAiB;AACpD,SAASC,wBAAwB,EAAEC,gBAAgB,QAAQ,2BAAuB;AAClF,SAASC,SAAS,QAAQ,kBAAc;AAExC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,GAED,yFAAyF,GACzF,MAAMC,gBAAgB;AAEtB,sEAAsE,GACtE,MAAMC,gBAAgB;AAEtB,yEAAyE,GACzE,MAAMC,kBAAkB;AAExB,yEAAyE,GACzE,OAAO,MAAMC,+CAA+C,GAAE;AAE9D;;;;;CAKC,GACD,OAAO,MAAMC,gDAAgD,EAAC;AAkB9D,MAAMC,UAAU,CAACC,SAAkCC,UAA8C,CAAA;QAC/FC,IAAI;QACJF;QACAC;IACF,CAAA;AAEA,MAAME,eAAe;AAErB,SAASC,WAAWC,SAAsC,EAAEC,KAAa,EAAEC,GAAW;IACpF,OAAOF,UACJG,UAAU,CAAC,QACXC,GAAG,CAACH,OACJE,UAAU,CAACzB,iCACX0B,GAAG,CAACF;AACT;AAEA,SAASG,UAAUL,SAAsC,EAAEC,KAAa,EAAEC,GAAW;IACnF,OAAOF,UAAUG,UAAU,CAAC,QAAQC,GAAG,CAACH,OAAOE,UAAU,CAAC,WAAWC,GAAG,CAACF;AAC3E;AAEA,2EAA2E,GAC3E,OAAO,eAAeI,uBACpBN,SAAsC,EACtCC,KAAa,EACbC,GAAW;IAEX,MAAMK,WAAW,MAAMR,WAAWC,WAAWC,OAAOC,KAAKM,GAAG;IAC5D,OAAO5B,uBAAuB2B,SAASE,MAAM,GAAGF,SAASG,IAAI,KAAK;AACpE;AAWA;;;;;;CAMC,GACD,OAAO,eAAeC,oBACpBX,SAAsC,EACtCY,KASC;QAEaA;IAAd,MAAMC,SAAQD,eAAAA,MAAMC,KAAK,YAAXD,eAAeE,KAAKC,GAAG;IACrC,MAAMC,MAAMjB,WAAWC,WAAWY,MAAMX,KAAK,EAAEW,MAAMV,GAAG;IACxD,MAAMe,SAASZ,UAAUL,WAAWY,MAAMX,KAAK,EAAEW,MAAMV,GAAG;IAC1D,OAAOF,UAAUkB,cAAc,CAAwB,OAAOC;YAO7CP,oBAEIA;QARnB,MAAMQ,SAAS,MAAMD,GAAGX,GAAG,CAACS;QAC5B,IAAI,CAACG,OAAOX,MAAM,EAAE,OAAOf,QAAQ,gBAAgBI;QACnD,MAAMuB,SAAS,MAAMF,GAAGX,GAAG,CAACQ;QAC5B,MAAMM,UAAU1C,uBAAuByC,OAAOZ,MAAM,GAAGY,OAAOX,IAAI,KAAK;QACvE,MAAMa,UAAUjD,4BAA4B;YAC1CkD,SAASZ,MAAMY,OAAO;YACtBC,WAAW,GAAEb,qBAAAA,MAAMa,WAAW,YAAjBb,qBAAqB;YAClCU;YACAI,eAAe,GAAEd,yBAAAA,MAAMc,eAAe,YAArBd,yBAAyB,EAAE;QAC9C;QACA,IAAIW,QAAQ1B,EAAE,KAAK,OAAO,OAAOH,QAAQ6B,QAAQ5B,OAAO,EAAE4B,QAAQ3B,OAAO;QACzE,IAAI2B,QAAQI,QAAQ,EAAE,OAAO;YAAE9B,IAAI;YAAM+B,OAAOL,QAAQI,QAAQ;YAAEE,SAAS;QAAM;QACjF,MAAMD,QAA0B;YAAEJ,SAASD,QAAQC,OAAO;YAAEM,WAAWjB;QAAM;QAC7EM,GAAGY,GAAG,CAACf,KAAK;YAAEd,KAAKU,MAAMV,GAAG;YAAEoB,SAAS;mBAAIA;gBAASM;aAAM;YAAEI,aAAanB;QAAM;QAC/E,OAAO;YAAEhB,IAAI;YAAM+B;YAAOC,SAAS;QAAK;IAC1C;AACF;AAWA,OAAO,eAAeI,uBACpBjC,SAAsC,EACtCY,KAAuE;QAIzDA;IAFd,MAAMY,UAAU7C,0BAA0BiC,MAAMY,OAAO;IACvD,IAAI,CAACA,SAAS,OAAO9B,QAAQ,mBAAmB;IAChD,MAAMmB,SAAQD,eAAAA,MAAMC,KAAK,YAAXD,eAAeE,KAAKC,GAAG;IACrC,MAAMC,MAAMjB,WAAWC,WAAWY,MAAMX,KAAK,EAAEW,MAAMV,GAAG;IACxD,OAAOF,UAAUkB,cAAc,CAA0B,OAAOC;QAC9D,MAAME,SAAS,MAAMF,GAAGX,GAAG,CAACQ;QAC5B,MAAMM,UAAU1C,uBAAuByC,OAAOZ,MAAM,GAAGY,OAAOX,IAAI,KAAK;QACvE,MAAMwB,UAAUZ,QAAQa,IAAI,CAAC,CAACP,QAAUA,MAAMJ,OAAO,KAAKA;QAC1D,IAAI,CAACU,SAAS,OAAOxC,QAAQ,mBAAmB;QAChD,MAAM0C,YAAYd,QAAQe,MAAM,CAAC,CAACT,QAAUA,MAAMJ,OAAO,KAAKA;QAC9D,IAAIY,UAAUE,MAAM,EAAE;YACpBnB,GAAGY,GAAG,CAACf,KAAK;gBAAEd,KAAKU,MAAMV,GAAG;gBAAEoB,SAASc;gBAAWJ,aAAanB;YAAM;QACvE,OAAO;YACLM,GAAGoB,MAAM,CAACvB;QACZ;QACA,OAAO;YAAEnB,IAAI;YAAMqC;QAAQ;IAC7B;AACF;AAqBA,MAAMM,YAAY,CAACC,UACjBzD,WAAW,UAAUC,sBAAsByD,MAAM,CAAC,GAAGpD,cAAc,CAAC,EAAEmD,SAAS,EAAEE,MAAM,CAAC;AAE1F;;;;CAIC,GACD,OAAO,SAASC,0BACdC,MAA2C,EAC3ChC,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,MAAM+B,OAAmB;QACvBC,GAAGF,OAAO5C,KAAK;QACf+C,GAAGH,OAAO3C,GAAG;QACb+C,GAAGJ,OAAOrB,OAAO;QACjB0B,GAAGL,OAAOf,SAAS;QACnBqB,GAAGtC,QAAQpC;IACb;IACA,MAAMgE,UAAUW,OAAOC,IAAI,CAACC,KAAKC,SAAS,CAACT,OAAO,QAAQU,QAAQ,CAAC;IACnE,OAAO,GAAGnE,cAAc,CAAC,EAAEoD,QAAQ,CAAC,EAAED,UAAUC,UAAU;AAC5D;AAEA;;;;;CAKC,GACD,OAAO,SAASgB,0BACdC,KAAc,EACd7C,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,MAAM4C,UAAUjE,QAAQ,iBAAiB;IACzC,IAAI,OAAOgE,UAAU,YAAY,CAACA,SAASA,MAAMpB,MAAM,GAAG/C,iBAAiB,OAAOoE;IAClF,MAAMC,QAAQF,MAAMG,KAAK,CAAC;IAC1B,IAAID,MAAMtB,MAAM,KAAK,KAAKsB,KAAK,CAAC,EAAE,KAAKvE,eAAe,OAAOsE;IAC7D,MAAM,GAAGlB,SAASqB,OAAO,GAAGF;IAC5B,IAAIG;IACJ,IAAI;QACFA,WAAWvB,UAAUC;IACvB,EAAE,eAAM;QACN,OAAOkB;IACT;IACA,IAAI,CAACvE,UAAU0E,QAAQC,WAAW,OAAOJ;IACzC,IAAIb;IACJ,IAAI;QACFA,OAAOQ,KAAKU,KAAK,CAACZ,OAAOC,IAAI,CAACZ,SAAS,aAAae,QAAQ,CAAC;IAC/D,EAAE,eAAM;QACN,OAAOG;IACT;IACA,MAAMnC,UAAU7C,0BAA0BmE,wBAAAA,KAAMG,CAAC;IACjD,MAAMhD,QAAQ,QAAO6C,wBAAAA,KAAMC,CAAC,MAAK,WAAWD,KAAKC,CAAC,GAAG;IACrD,MAAM7C,MAAM,QAAO4C,wBAAAA,KAAME,CAAC,MAAK,WAAWF,KAAKE,CAAC,GAAG;IACnD,MAAMlB,YAAYmC,OAAOnB,wBAAAA,KAAMI,CAAC;IAChC,MAAMgB,MAAMD,OAAOnB,wBAAAA,KAAMK,CAAC;IAC1B,IAAI,CAAClD,SAAS,CAACC,OAAO,CAACsB,WAAW,CAACyC,OAAOE,QAAQ,CAACrC,cAAc,CAACmC,OAAOE,QAAQ,CAACD,MAAM;QACtF,OAAOP;IACT;IACA,IAAIO,OAAOrD,OAAO;QAChB,OAAOnB,QAAQ,iBAAiB;IAClC;IACA,OAAO;QAAEG,IAAI;QAAMgD,QAAQ;YAAE5C;YAAOC;YAAKsB;YAASM;YAAWoC;QAAI;IAAE;AACrE;AAYA;;;;;CAKC,GACD,OAAO,eAAeE,wBACpBpE,SAAsC,EACtCY,KAA4D;QAE9CA;IAAd,MAAMC,SAAQD,eAAAA,MAAMC,KAAK,YAAXD,eAAeE,KAAKC,GAAG;IACrC,MAAMsD,OAAOZ,0BAA0B7C,MAAM8C,KAAK,EAAE7C;IACpD,IAAIwD,KAAKxE,EAAE,KAAK,OAAO,OAAOwE;IAC9B,MAAM,EAAExB,MAAM,EAAE,GAAGwB;IACnB,IAAI,CAACzD,MAAM0D,SAAS,IAAIzB,OAAO3C,GAAG,KAAKU,MAAM0D,SAAS,EAAE;QACtD,OAAO5E,QACL,gBACA;IAEJ;IACA,MAAMsB,MAAMjB,WAAWC,WAAW6C,OAAO5C,KAAK,EAAE4C,OAAO3C,GAAG;IAC1D,MAAMe,SAASZ,UAAUL,WAAW6C,OAAO5C,KAAK,EAAE4C,OAAO3C,GAAG;IAC5D,OAAOF,UAAUkB,cAAc,CAA4B,OAAOC;QAChE,MAAMC,SAAS,MAAMD,GAAGX,GAAG,CAACS;QAC5B,IAAI,CAACG,OAAOX,MAAM,EAAE,OAAOf,QAAQ,gBAAgB;QACnD,MAAM2B,SAAS,MAAMF,GAAGX,GAAG,CAACQ;QAC5B,MAAMM,UAAU1C,uBAAuByC,OAAOZ,MAAM,GAAGY,OAAOX,IAAI,KAAK;QACvE,MAAM6D,QAAQjD,QAAQa,IAAI,CAAC,CAACP,QAAUA,MAAMJ,OAAO,KAAKqB,OAAOrB,OAAO;QACtE,IAAI,CAAC+C,SAASA,MAAMzC,SAAS,KAAKe,OAAOf,SAAS,EAAE;YAClD,OAAOpC,QACL,gBACA;QAEJ;QACA,IAAInB,2BAA2BgG,QAAQ;YACrC,OAAO;gBAAE1E,IAAI;gBAAMI,OAAO4C,OAAO5C,KAAK;gBAAEuB,SAAS+C,MAAM/C,OAAO;gBAAEgD,kBAAkB;YAAK;QACzF;QACA,MAAMC,OAAOnD,QAAQoD,GAAG,CAAC,CAAC9C,QACxBA,MAAMJ,OAAO,KAAK+C,MAAM/C,OAAO,GAAG,aAAKI;gBAAO+C,cAAc9D;iBAAUe;QAExET,GAAGY,GAAG,CAACf,KAAK;YAAEd,KAAK2C,OAAO3C,GAAG;YAAEoB,SAASmD;YAAMzC,aAAanB;QAAM;QACjE,OAAO;YAAEhB,IAAI;YAAMI,OAAO4C,OAAO5C,KAAK;YAAEuB,SAAS+C,MAAM/C,OAAO;YAAEgD,kBAAkB;QAAM;IAC1F;AACF;AAUA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,eAAeI,oCACpB5E,SAAsC,EACtCY,KAAoF;QAEtEA;IAAd,MAAMC,SAAQD,eAAAA,MAAMC,KAAK,YAAXD,eAAeE,KAAKC,GAAG;IACrC,MAAM8D,SAAS,IAAIC,IACjBlE,MAAMmE,SAAS,CACZL,GAAG,CAAC,CAAClD,UAAY7C,0BAA0B6C,UAC3Ca,MAAM,CAAC,CAACb,UAA+BwD,QAAQxD;IAEpD,IAAI,CAACZ,MAAMX,KAAK,IAAI,CAACW,MAAMV,GAAG,EAAE,OAAOR,QAAQ,gBAAgBI;IAC/D,IAAI,CAAC+E,OAAOI,IAAI,EAAE,OAAO;QAAEpF,IAAI;QAAMqF,WAAW,EAAE;IAAC;IACnD,MAAMlE,MAAMjB,WAAWC,WAAWY,MAAMX,KAAK,EAAEW,MAAMV,GAAG;IACxD,MAAMe,SAASZ,UAAUL,WAAWY,MAAMX,KAAK,EAAEW,MAAMV,GAAG;IAC1D,OAAOF,UAAUkB,cAAc,CAAwC,OAAOC;QAC5E,MAAMC,SAAS,MAAMD,GAAGX,GAAG,CAACS;QAC5B,IAAI,CAACG,OAAOX,MAAM,EAAE,OAAOf,QAAQ,gBAAgBI;QACnD,MAAMuB,SAAS,MAAMF,GAAGX,GAAG,CAACQ;QAC5B,MAAMM,UAAU1C,uBAAuByC,OAAOZ,MAAM,GAAGY,OAAOX,IAAI,KAAK;QACvE,MAAMwE,YAAsB,EAAE;QAC9B,MAAMT,OAAOnD,QAAQoD,GAAG,CAAC,CAAC9C;YACxB,IAAIrD,2BAA2BqD,UAAU,CAACiD,OAAOM,GAAG,CAACvD,MAAMJ,OAAO,GAAG,OAAOI;YAC5EsD,UAAUE,IAAI,CAACxD,MAAMJ,OAAO;YAC5B,OAAO,aAAKI;gBAAO+C,cAAc9D;;QACnC;QACA,IAAIqE,UAAU5C,MAAM,EAAE;YACpBnB,GAAGY,GAAG,CAACf,KAAK;gBAAEd,KAAKU,MAAMV,GAAG;gBAAEoB,SAASmD;gBAAMzC,aAAanB;YAAM;QAClE;QACA,OAAO;YAAEhB,IAAI;YAAMqF;QAAU;IAC/B;AACF;AAEA;;;;CAIC,GACD,MAAMG,cAAc;AAEpB;;;;CAIC,GACD,OAAO,SAASC,2BAA2B1E,KAI1C;QACuBA;IAAtB,MAAM2E,SAASC,QAAO5E,gBAAAA,MAAM2E,MAAM,YAAZ3E,gBAAgB,IAAI6E,OAAO,CAAC,QAAQ;IAC1D,MAAMC,MAAM,OAAO9E,MAAM+E,UAAU,KAAK,WAAW/E,MAAM+E,UAAU,GAAG;IACtE,MAAMC,OAAOP,YAAYQ,IAAI,CAACH,OAAOA,MAAM;IAC3C,OAAO,GAAGH,SAASK,KAAK,CAAC,EAAEpH,iCAAiC,CAAC,EAAEsH,mBAAmBlF,MAAM8C,KAAK,GAAG;AAClG;AAMA;;;;;;CAMC,GACD,OAAO,eAAeqC,iCAAiCnF,KAStD;QAwB0CA,kBAEnBA,mBAwBFA;QAzBKA;IAxBzB,IAAI,CAAC9B,qBAAqB;QACxB,OAAO;YAAEkH,MAAM;YAAOC,QAAQ;YAAKC,OAAO;QAA8C;IAC1F;IACA,MAAM,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CAAC;IAC1C,MAAMC,YAAY,MAAMD,iBAAiB,CAAC,2BAA2B,EAAEvF,MAAMV,GAAG,EAAE,EAAE;QAClFmG,OAAO7G;QACP8G,UAAU,KAAK,KAAK;IACtB;IACA,IAAI,CAACF,UAAUG,OAAO,EAAE;QACtB,OAAO;YAAEP,MAAM;YAAOC,QAAQ;YAAKC,OAAO;QAAsD;IAClG;IACA,MAAMM,aAAa,MAAML,iBAAiB,CAAC,0BAA0B,EAAEvF,MAAMY,OAAO,EAAE,EAAE;QACtF6E,OAAO5G;QACP6G,UAAU,KAAK,KAAK;IACtB;IACA,IAAI,CAACE,WAAWD,OAAO,EAAE;QACvB,OAAO;YACLP,MAAM;YACNC,QAAQ;YACRC,OAAO;QACT;IACF;IAEA,MAAMO,WAAW5H,wBAAwB+B,aAAAA,MAAM8F,GAAG,YAAT9F,aAAa;IACtD,MAAM+F,YAAYnB,gBAAO5E,cAAAA,MAAM8F,GAAG,qBAAT9F,WAAW,CAAC,OAAO,mBAAI,IAAIgG,IAAI,MAAM;IAC9D,MAAMxF,SAASoE,QAAO5E,oBAAAA,MAAMiG,UAAU,YAAhBjG,oBAAoB,IAAIgG,IAAI;IAClD,MAAME,OAAO;QACX,GAAG1F,UAAU,WAAW,cAAc,EAAER,MAAMY,OAAO,CAAC,2BAA2B,CAAC,GAChF,CAAC,iBAAiB,EAAEmF,UAAU,cAAc,EAAEF,SAASM,WAAW,CAAC,CAAC,CAAC;QACvE;QACA,CAAC,iDAAiD,EAAE3F,UAAU,cAAc,CAAC,CAAC;QAC9E;QACAR,MAAMoG,UAAU;QAChB;QACA,CAAC,sCAAsC,EAAEpG,MAAMY,OAAO,CAAC,8BAA8B,CAAC,GACpF;QACF;QACA;KACD,CAACyF,IAAI,CAAC;IACP,wEAAwE;IACxE,sDAAsD;IACtD,MAAMC,UAAU,MAAMhI,yBACpB,mCACA;QACE,eAAekC,UAAU;QACzBI,SAASZ,MAAMY,OAAO;QACtB,YAAYmF;QACZK,YAAYpG,MAAMoG,UAAU;IAC9B,GACA7H,kBAAkByB,cAAAA,MAAM8F,GAAG,YAAT9F,cAAa,OAC/B;QAAEuG,SAAS,CAAC,yBAAyB,EAAER,WAAW;QAAEG;IAAK;IAE3D,MAAMM,SAAS,MAAMrI,UAAU;QAC7BsI,IAAIzG,MAAMY,OAAO;OACd0F;QACHI,UAAUb,SAASa,QAAQ;QAC3BC,SAAS;;IAEX,IAAI,CAACH,OAAOpB,IAAI,EAAE;QAChB,OAAO;YAAEA,MAAM;YAAOC,QAAQ;YAAKC,OAAO;QAA4C;IACxF;IACA,MAAM,EAAEsB,aAAa,EAAE,GAAG,MAAM,MAAM,CAAC;IACvC,MAAMA,cAAc5G,MAAMX,KAAK,EAAEwH,KAAK,CAAC,IAAMC;IAC7C,OAAO;QAAE1B,MAAM;IAAK;AACtB"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The one "New account" staff notice per account (AGL-3225).
|
|
19
|
+
*
|
|
20
|
+
* Whether an account is NEW is read from its auth record's creation time,
|
|
21
|
+
* never from whether `users/{uid}` happens to exist yet. That document has
|
|
22
|
+
* several writers that can reach it first — the sign-up page's own writes
|
|
23
|
+
* (profile, plan intent, campaign, consent), the acquisition record, the
|
|
24
|
+
* /signin consent bounce's acquisition record, the SSO JIT seed — so "the
|
|
25
|
+
* session mint's seed created it" was true only for whichever sign-up won a
|
|
26
|
+
* race, and never for a password account, whose first mint waits on email
|
|
27
|
+
* verification while the page writes the document on the spot.
|
|
28
|
+
*
|
|
29
|
+
* Exactly once is the marker's job: `accountAnnouncements/{uid}` is created
|
|
30
|
+
* with `create()`, which Firestore refuses when the document exists, so two
|
|
31
|
+
* mints racing each other — a second tab, a retry — cannot both win. It is a
|
|
32
|
+
* collection of its own rather than a field on `users/{uid}` because the
|
|
33
|
+
* owner may delete that document, and a marker its subject can erase is a
|
|
34
|
+
* notice its subject can replay. No rule matches the collection, so every
|
|
35
|
+
* client is denied it and the Admin SDK is the only writer.
|
|
36
|
+
*/
|
|
37
|
+
/** The server-only collection that remembers which accounts were announced. */
|
|
38
|
+
export declare const NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION = "accountAnnouncements";
|
|
39
|
+
/**
|
|
40
|
+
* How long after its creation an account's first session may still announce
|
|
41
|
+
* it. A password account mints its first session only once the address is
|
|
42
|
+
* verified, and a Google account bounced from /signin for consent only when
|
|
43
|
+
* the person comes back to /signup — both can be days. Seven matches how long
|
|
44
|
+
* the sign-up workspace hold waits for the same first session. Every account
|
|
45
|
+
* older than this predates the marker, so without the window each one's next
|
|
46
|
+
* sign-in would be announced as new.
|
|
47
|
+
*/
|
|
48
|
+
export declare const NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS: number;
|
|
49
|
+
export interface ClaimNewAccountAnnouncementInput {
|
|
50
|
+
uid: string;
|
|
51
|
+
/** The auth record's creation time; null or unparseable claims nothing. */
|
|
52
|
+
accountCreatedAtMs: number | null;
|
|
53
|
+
nowMs?: number;
|
|
54
|
+
firestore?: FirebaseFirestore.Firestore;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* True exactly once per account, and only while the account is new: the
|
|
58
|
+
* caller that gets `true` sends the notice. Throws when the marker cannot be
|
|
59
|
+
* written for any reason but "already announced", so a caller's catch — not a
|
|
60
|
+
* silent `false` — is what a Firestore outage reaches.
|
|
61
|
+
*/
|
|
62
|
+
export declare function claimNewAccountAnnouncement(input: ClaimNewAccountAnnouncementInput): Promise<boolean>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { FieldValue } from "firebase-admin/firestore";
|
|
17
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
18
|
+
/**
|
|
19
|
+
* The one "New account" staff notice per account (AGL-3225).
|
|
20
|
+
*
|
|
21
|
+
* Whether an account is NEW is read from its auth record's creation time,
|
|
22
|
+
* never from whether `users/{uid}` happens to exist yet. That document has
|
|
23
|
+
* several writers that can reach it first — the sign-up page's own writes
|
|
24
|
+
* (profile, plan intent, campaign, consent), the acquisition record, the
|
|
25
|
+
* /signin consent bounce's acquisition record, the SSO JIT seed — so "the
|
|
26
|
+
* session mint's seed created it" was true only for whichever sign-up won a
|
|
27
|
+
* race, and never for a password account, whose first mint waits on email
|
|
28
|
+
* verification while the page writes the document on the spot.
|
|
29
|
+
*
|
|
30
|
+
* Exactly once is the marker's job: `accountAnnouncements/{uid}` is created
|
|
31
|
+
* with `create()`, which Firestore refuses when the document exists, so two
|
|
32
|
+
* mints racing each other — a second tab, a retry — cannot both win. It is a
|
|
33
|
+
* collection of its own rather than a field on `users/{uid}` because the
|
|
34
|
+
* owner may delete that document, and a marker its subject can erase is a
|
|
35
|
+
* notice its subject can replay. No rule matches the collection, so every
|
|
36
|
+
* client is denied it and the Admin SDK is the only writer.
|
|
37
|
+
*/ /** The server-only collection that remembers which accounts were announced. */ export const NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION = 'accountAnnouncements';
|
|
38
|
+
/**
|
|
39
|
+
* How long after its creation an account's first session may still announce
|
|
40
|
+
* it. A password account mints its first session only once the address is
|
|
41
|
+
* verified, and a Google account bounced from /signin for consent only when
|
|
42
|
+
* the person comes back to /signup — both can be days. Seven matches how long
|
|
43
|
+
* the sign-up workspace hold waits for the same first session. Every account
|
|
44
|
+
* older than this predates the marker, so without the window each one's next
|
|
45
|
+
* sign-in would be announced as new.
|
|
46
|
+
*/ export const NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
47
|
+
/**
|
|
48
|
+
* True exactly once per account, and only while the account is new: the
|
|
49
|
+
* caller that gets `true` sends the notice. Throws when the marker cannot be
|
|
50
|
+
* written for any reason but "already announced", so a caller's catch — not a
|
|
51
|
+
* silent `false` — is what a Firestore outage reaches.
|
|
52
|
+
*/ export async function claimNewAccountAnnouncement(input) {
|
|
53
|
+
var _input_nowMs, _input_firestore;
|
|
54
|
+
const createdAtMs = input.accountCreatedAtMs;
|
|
55
|
+
if (!input.uid || createdAtMs === null || !Number.isFinite(createdAtMs)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const nowMs = (_input_nowMs = input.nowMs) != null ? _input_nowMs : Date.now();
|
|
59
|
+
if (nowMs - createdAtMs > NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS) return false;
|
|
60
|
+
const firestore = (_input_firestore = input.firestore) != null ? _input_firestore : firebaseAdmin.app().firestore();
|
|
61
|
+
try {
|
|
62
|
+
await firestore.collection(NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION).doc(input.uid).create({
|
|
63
|
+
announcedAt: FieldValue.serverTimestamp(),
|
|
64
|
+
accountCreatedAtMs: createdAtMs
|
|
65
|
+
});
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (isAlreadyExists(error)) return false;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Firestore's ALREADY_EXISTS, as the Admin SDK reports it (gRPC code 6). */ function isAlreadyExists(error) {
|
|
73
|
+
const code = error == null ? void 0 : error.code;
|
|
74
|
+
return code === 6 || code === 'already-exists' || code === 'ALREADY_EXISTS';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
//# sourceMappingURL=new-account-announcement.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/new-account-announcement.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 { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * The one \"New account\" staff notice per account (AGL-3225).\n *\n * Whether an account is NEW is read from its auth record's creation time,\n * never from whether `users/{uid}` happens to exist yet. That document has\n * several writers that can reach it first — the sign-up page's own writes\n * (profile, plan intent, campaign, consent), the acquisition record, the\n * /signin consent bounce's acquisition record, the SSO JIT seed — so \"the\n * session mint's seed created it\" was true only for whichever sign-up won a\n * race, and never for a password account, whose first mint waits on email\n * verification while the page writes the document on the spot.\n *\n * Exactly once is the marker's job: `accountAnnouncements/{uid}` is created\n * with `create()`, which Firestore refuses when the document exists, so two\n * mints racing each other — a second tab, a retry — cannot both win. It is a\n * collection of its own rather than a field on `users/{uid}` because the\n * owner may delete that document, and a marker its subject can erase is a\n * notice its subject can replay. No rule matches the collection, so every\n * client is denied it and the Admin SDK is the only writer.\n */\n\n/** The server-only collection that remembers which accounts were announced. */\nexport const NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION = 'accountAnnouncements'\n\n/**\n * How long after its creation an account's first session may still announce\n * it. A password account mints its first session only once the address is\n * verified, and a Google account bounced from /signin for consent only when\n * the person comes back to /signup — both can be days. Seven matches how long\n * the sign-up workspace hold waits for the same first session. Every account\n * older than this predates the marker, so without the window each one's next\n * sign-in would be announced as new.\n */\nexport const NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000\n\nexport interface ClaimNewAccountAnnouncementInput {\n uid: string\n /** The auth record's creation time; null or unparseable claims nothing. */\n accountCreatedAtMs: number | null\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}\n\n/**\n * True exactly once per account, and only while the account is new: the\n * caller that gets `true` sends the notice. Throws when the marker cannot be\n * written for any reason but \"already announced\", so a caller's catch — not a\n * silent `false` — is what a Firestore outage reaches.\n */\nexport async function claimNewAccountAnnouncement(\n input: ClaimNewAccountAnnouncementInput,\n): Promise<boolean> {\n const createdAtMs = input.accountCreatedAtMs\n if (!input.uid || createdAtMs === null || !Number.isFinite(createdAtMs)) {\n return false\n }\n const nowMs = input.nowMs ?? Date.now()\n if (nowMs - createdAtMs > NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS) return false\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n try {\n await firestore\n .collection(NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION)\n .doc(input.uid)\n .create({\n announcedAt: FieldValue.serverTimestamp(),\n accountCreatedAtMs: createdAtMs,\n })\n return true\n } catch (error) {\n if (isAlreadyExists(error)) return false\n throw error\n }\n}\n\n/** Firestore's ALREADY_EXISTS, as the Admin SDK reports it (gRPC code 6). */\nfunction isAlreadyExists(error: unknown): boolean {\n const code = (error as { code?: unknown } | null)?.code\n return code === 6 || code === 'already-exists' || code === 'ALREADY_EXISTS'\n}\n"],"names":["FieldValue","firebaseAdmin","NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION","NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS","claimNewAccountAnnouncement","input","createdAtMs","accountCreatedAtMs","uid","Number","isFinite","nowMs","Date","now","firestore","app","collection","doc","create","announcedAt","serverTimestamp","error","isAlreadyExists","code"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AACrD,OAAOC,mBAAmB,sBAAkB;AAE5C;;;;;;;;;;;;;;;;;;;CAmBC,GAED,6EAA6E,GAC7E,OAAO,MAAMC,uCAAuC,uBAAsB;AAE1E;;;;;;;;CAQC,GACD,OAAO,MAAMC,qCAAqC,IAAI,KAAK,KAAK,KAAK,KAAI;AAUzE;;;;;CAKC,GACD,OAAO,eAAeC,4BACpBC,KAAuC;QAMzBA,cAEIA;IANlB,MAAMC,cAAcD,MAAME,kBAAkB;IAC5C,IAAI,CAACF,MAAMG,GAAG,IAAIF,gBAAgB,QAAQ,CAACG,OAAOC,QAAQ,CAACJ,cAAc;QACvE,OAAO;IACT;IACA,MAAMK,SAAQN,eAAAA,MAAMM,KAAK,YAAXN,eAAeO,KAAKC,GAAG;IACrC,IAAIF,QAAQL,cAAcH,oCAAoC,OAAO;IACrE,MAAMW,aAAYT,mBAAAA,MAAMS,SAAS,YAAfT,mBAAmBJ,cAAcc,GAAG,GAAGD,SAAS;IAClE,IAAI;QACF,MAAMA,UACHE,UAAU,CAACd,sCACXe,GAAG,CAACZ,MAAMG,GAAG,EACbU,MAAM,CAAC;YACNC,aAAanB,WAAWoB,eAAe;YACvCb,oBAAoBD;QACtB;QACF,OAAO;IACT,EAAE,OAAOe,OAAO;QACd,IAAIC,gBAAgBD,QAAQ,OAAO;QACnC,MAAMA;IACR;AACF;AAEA,2EAA2E,GAC3E,SAASC,gBAAgBD,KAAc;IACrC,MAAME,OAAQF,yBAAD,AAACA,MAAqCE,IAAI;IACvD,OAAOA,SAAS,KAAKA,SAAS,oBAAoBA,SAAS;AAC7D"}
|
|
@@ -71,8 +71,14 @@ export declare function notifyStaff(payload: NotificationPayload): Promise<void>
|
|
|
71
71
|
export declare function notifyStaffConsole(payload: NotificationPayload, options?: {
|
|
72
72
|
skipEmail?: boolean;
|
|
73
73
|
}): Promise<void>;
|
|
74
|
-
/**
|
|
75
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Notifies the org's owner + admins (billing, membership, org events).
|
|
76
|
+
*
|
|
77
|
+
* `skipEmail` is for a caller that emails the same notice to the same people
|
|
78
|
+
* itself — the usage alerts do (AGL-3431) — so an admin who switched the
|
|
79
|
+
* email channel on is not sent it twice.
|
|
80
|
+
*/
|
|
81
|
+
export declare function notifyOrgAdmins(orgId: string, payload: NotificationPayload, options?: Pick<NotifyUsersOptions, 'skipEmail'>): Promise<void>;
|
|
76
82
|
/**
|
|
77
83
|
* Notifies everyone who manages a host (admin/editor in the host doc's
|
|
78
84
|
* `memberRoles` projection) — the audience for form submissions and
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -305,7 +311,13 @@ async function listStaffUids() {
|
|
|
305
311
|
console.error('staff notification failed', error);
|
|
306
312
|
}
|
|
307
313
|
}
|
|
308
|
-
/**
|
|
314
|
+
/**
|
|
315
|
+
* Notifies the org's owner + admins (billing, membership, org events).
|
|
316
|
+
*
|
|
317
|
+
* `skipEmail` is for a caller that emails the same notice to the same people
|
|
318
|
+
* itself — the usage alerts do (AGL-3431) — so an admin who switched the
|
|
319
|
+
* email channel on is not sent it twice.
|
|
320
|
+
*/ export async function notifyOrgAdmins(orgId, payload, options = {}) {
|
|
309
321
|
try {
|
|
310
322
|
const members = await listOrgMembers(orgId);
|
|
311
323
|
const admins = members.filter((member)=>member.role === 'owner' || member.role === 'admin');
|
|
@@ -317,9 +329,11 @@ async function listStaffUids() {
|
|
|
317
329
|
}
|
|
318
330
|
await notifyUsers(admins.map((member)=>member.$id), _extends({}, payload, {
|
|
319
331
|
orgId
|
|
320
|
-
}), {
|
|
332
|
+
}), _extends({
|
|
321
333
|
emails
|
|
322
|
-
}
|
|
334
|
+
}, options.skipEmail ? {
|
|
335
|
+
skipEmail: true
|
|
336
|
+
} : {}));
|
|
323
337
|
} catch (error) {
|
|
324
338
|
console.error('org admin notification failed', error);
|
|
325
339
|
}
|