@aglyn/tenant-data-admin 1.0.0-beta.201 → 1.0.0-beta.203

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.
Files changed (35) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/card-payment-velocity.d.ts +85 -0
  3. package/src/lib/server/card-payment-velocity.js +202 -0
  4. package/src/lib/server/card-payment-velocity.js.map +1 -0
  5. package/src/lib/server/configured-url-fetch.d.ts +42 -0
  6. package/src/lib/server/configured-url-fetch.js +118 -0
  7. package/src/lib/server/configured-url-fetch.js.map +1 -0
  8. package/src/lib/server/hosted-page-review.d.ts +22 -2
  9. package/src/lib/server/hosted-page-review.js +43 -7
  10. package/src/lib/server/hosted-page-review.js.map +1 -1
  11. package/src/lib/server/member-email-aliases.js +18 -7
  12. package/src/lib/server/member-email-aliases.js.map +1 -1
  13. package/src/lib/server/notifications.d.ts +8 -0
  14. package/src/lib/server/notifications.js +92 -17
  15. package/src/lib/server/notifications.js.map +1 -1
  16. package/src/lib/server/org-member-notice.js +20 -5
  17. package/src/lib/server/org-member-notice.js.map +1 -1
  18. package/src/lib/server/organizations.d.ts +10 -1
  19. package/src/lib/server/organizations.js +12 -4
  20. package/src/lib/server/organizations.js.map +1 -1
  21. package/src/lib/server/outbound-send-review.d.ts +4 -2
  22. package/src/lib/server/outbound-send-review.js +1 -1
  23. package/src/lib/server/outbound-send-review.js.map +1 -1
  24. package/src/lib/server/render-system-email.d.ts +176 -0
  25. package/src/lib/server/render-system-email.js +315 -0
  26. package/src/lib/server/render-system-email.js.map +1 -0
  27. package/src/lib/server/staff-alert-email.d.ts +2 -1
  28. package/src/lib/server/staff-alert-email.js +16 -5
  29. package/src/lib/server/staff-alert-email.js.map +1 -1
  30. package/src/lib/server/stripe-transfer-reversal.d.ts +63 -0
  31. package/src/lib/server/stripe-transfer-reversal.js +177 -0
  32. package/src/lib/server/stripe-transfer-reversal.js.map +1 -0
  33. package/src/tenancy.d.ts +2 -0
  34. package/src/tenancy.js +2 -0
  35. package/src/tenancy.js.map +1 -1
@@ -74,7 +74,7 @@ import { screenHostedPage } from "@aglyn/shared-util-email/hosted-page-screen";
74
74
  import { lookalikeBrandForHost, phishingScreenBrandLabel, signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
75
75
  import { FieldValue } from "firebase-admin/firestore";
76
76
  import firebaseAdmin from "./firebase-admin.js";
77
- import { notifyStaff } from "./notifications.js";
77
+ import { notifyHostManagers, notifyStaff } from "./notifications.js";
78
78
  import { orgAgeDays } from "./org-age.js";
79
79
  import { getHostDocAdmin, getOrgForHost } from "./organizations.js";
80
80
  import { canonicalJson, fileOutboundHold, flaggedHostOf, heldOutboundReference, heldOutboundReviewId, outboundContentHash, workspaceScreenIdentity } from "./outbound-send-review.js";
@@ -266,7 +266,11 @@ function pageReviewRef(hostId, screenId) {
266
266
  *
267
267
  * - `site` — a custom domain a site is served on (the attach route);
268
268
  * - `sending` — a custom domain a workspace sends email as, when it is
269
- * added and again when it verifies (`sending-domains.ts`).
269
+ * added and again when it verifies (`sending-domains.ts`);
270
+ * - `link` — a link a merchant configured that a buyer or a server would
271
+ * follow: a product's paid download or video hotlink, a supplier's order
272
+ * webhook (AGL-3363). The link itself is refused where it is followed; this
273
+ * files it, and tells the site's managers once ({@link notifyLookalikeLink}).
270
274
  *
271
275
  * Not refused, because the domain is the merchant's own registration and
272
276
  * the platform cannot tell a brand's own agency attaching `paypal-promo.com`
@@ -286,10 +290,11 @@ function pageReviewRef(hostId, screenId) {
286
290
  if (!brand) return 'clean';
287
291
  const label = phishingScreenBrandLabel(brand.id);
288
292
  const sending = input.kind === 'sending';
293
+ const link = input.kind === 'link';
289
294
  try {
290
- var _input_orgId, _input_hostId;
295
+ var _input_orgId, _input_hostId, _input_hostId1, _input_where;
291
296
  const firestore = firebaseAdmin.app().firestore();
292
- const owner = sending ? `orgs/${(_input_orgId = input.orgId) != null ? _input_orgId : ''}/sendingDomains` : `hosts/${(_input_hostId = input.hostId) != null ? _input_hostId : ''}/cname`;
297
+ const owner = sending ? `orgs/${(_input_orgId = input.orgId) != null ? _input_orgId : ''}/sendingDomains` : link ? `hosts/${(_input_hostId = input.hostId) != null ? _input_hostId : ''}/links` : `hosts/${(_input_hostId1 = input.hostId) != null ? _input_hostId1 : ''}/cname`;
293
298
  const reviewId = heldOutboundReviewId(owner, domain);
294
299
  const reference = heldOutboundReference(reviewId);
295
300
  const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
@@ -304,9 +309,9 @@ function pageReviewRef(hostId, screenId) {
304
309
  hostId: input.hostId,
305
310
  orgId: input.orgId,
306
311
  details: [
307
- sending ? `A workspace added ${domain} as a domain to send email from.` : `A site attached the custom domain ${domain}.`,
312
+ sending ? `A workspace added ${domain} as a domain to send email from.` : link ? `A site configured a link to ${domain} (${(_input_where = input.where) != null ? _input_where : 'a commerce link'}).` : `A site attached the custom domain ${domain}.`,
308
313
  `${domain} wears the ${label} name but is not ${label}'s domain.`,
309
- sending ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' + 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.' : 'Its pages are still screened as they are served. Mark this actioned ' + 'and lock the site if it is impersonation; dismiss it if the brand is theirs.'
314
+ sending ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' + 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.' : link ? 'Nobody is sent to it: the link is refused wherever it is followed. Mark this ' + 'actioned and lock the site if it is impersonation.' : 'Its pages are still screened as they are served. Mark this actioned ' + 'and lock the site if it is impersonation; dismiss it if the brand is theirs.'
310
315
  ].join('\n'),
311
316
  reporterEmail: null,
312
317
  reporterName: null,
@@ -319,7 +324,16 @@ function pageReviewRef(hostId, screenId) {
319
324
  } : {}), {
320
325
  merge: true
321
326
  });
322
- if (first) {
327
+ if (first && link) {
328
+ var _input_where1;
329
+ await notifyLookalikeLink({
330
+ hostId: input.hostId,
331
+ domain,
332
+ label,
333
+ where: (_input_where1 = input.where) != null ? _input_where1 : 'a commerce link',
334
+ reference
335
+ });
336
+ } else if (first) {
323
337
  await notifyStaff({
324
338
  type: 'system.abuseReportUrgent',
325
339
  title: sending ? 'Sending domain flagged — possible brand impersonation' : 'Custom domain flagged — possible brand impersonation',
@@ -333,6 +347,28 @@ function pageReviewRef(hostId, screenId) {
333
347
  return 'failed';
334
348
  }
335
349
  }
350
+ /**
351
+ * Tell staff and the site's managers, once per (site, domain), that a link
352
+ * the merchant configured was refused because it wears another brand's name
353
+ * (AGL-3363). The one notifier for this kind of signal, so a shared
354
+ * risk-notice seam can take it over whole. The merchant is told what was
355
+ * refused and how to fix it; never how the check works. Never throws.
356
+ */ export async function notifyLookalikeLink(input) {
357
+ await Promise.all([
358
+ notifyStaff({
359
+ type: 'system.abuseReportUrgent',
360
+ title: 'Commerce link flagged — possible brand impersonation',
361
+ body: `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` + `It is refused wherever it is followed. Reference ${input.reference}.`,
362
+ link: '/admin/abuse-reports'
363
+ }).catch(()=>undefined),
364
+ input.hostId ? notifyHostManagers(input.hostId, {
365
+ type: 'content.order',
366
+ title: 'A link on your store was blocked',
367
+ body: `${input.where[0].toUpperCase()}${input.where.slice(1)} points to ${input.domain}, ` + `which looks like another company's website, so nobody is sent there. ` + 'Replace it with a file from your media library or a link on your own domain. ' + `If you think this is a mistake, contact support with reference ${input.reference}.`,
368
+ link: `/${input.hostId}/products`
369
+ }).catch(()=>undefined) : Promise.resolve()
370
+ ]);
371
+ }
336
372
  /** A site's custom domain, through {@link flagLookalikeDomain}. */ export function flagLookalikeCustomDomain(input) {
337
373
  return flagLookalikeDomain(_extends({
338
374
  kind: 'site'
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/hosted-page-review.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 * HELD FOR REVIEW: a published page (AGL-3362).\n *\n * ## Why the check sits where a page is COMPOSED, not where it is published\n *\n * A publish is a browser write. The besigner toolbar, the versions panel,\n * the screen details page, page-from-template, the layout and component\n * besigners and the form besigner each move a pointer (`versionId`, the\n * host's `screens` map, a component's own `nodes`) straight from the\n * client SDK; the scheduled-publish beat moves it from the server; an\n * experiment serves a version no pointer names; and an author may rewrite\n * the LIVE version document itself without moving any pointer at all. A\n * check on any one of those writes is a check the next one walks around.\n *\n * They all meet in one place: `composeScreenNodes` (`@aglyn/tenant-runtime`),\n * which every published render, the password unlock, the collection and\n * product templates and every experiment variant go through, and which\n * returns the page exactly as a visitor will receive it — the screen, its\n * layout chrome, its grafted components and its forms. So the screen is read\n * there, and a held page is one that is never composed for a visitor. For a\n * visitor, \"publish\" is the first render after the pointer moved; this is\n * that moment.\n *\n * ## What a hold does\n *\n * - The version is not served. The last version this page served clean\n * ({@link servedPageVersion}) is composed instead — screened again, since\n * a version document can be edited in place — and if there is none, or it\n * holds too, the page is not found. A first publish that holds therefore\n * serves nothing.\n * - One review row per (page, signal set) lands in the abuse queue at\n * `/admin/abuse-reports`, the same `phishing` row a held email files\n * (`fileOutboundHold`), with `heldSend.kind: 'page'`, the page's URL and\n * the held version. Staff dismiss it to RELEASE (the admin route drops the\n * site's cache and the next render serves it) or mark it actioned to\n * REJECT (it stays unserved; any later version carrying the same signals\n * is refused without a new row).\n * - Keyed on the SIGNALS, as the send seam's rows are: a composed page\n * carries per-render values (dates, collection rows) that no content hash\n * would survive, while what makes it phishing does not change between\n * renders.\n *\n * ## Tiers\n *\n * `signalsThatHold`: a lookalike link or embed and an author-defined\n * credential field hold for EVERY workspace; a brand's call to action only\n * for a workspace in its first fortnight. The workspace's documents are read\n * only when the pure screen has found something, so a clean page costs no\n * read for this.\n *\n * Fails CLOSED, unlike the send path. Composition itself reads the store, so\n * an outage here is an outage on the render already, and the only cost of\n * not serving a page the screen flagged is a false positive's wait; serving\n * it is the phishing page this exists to stop.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport { screenHostedPage } from '@aglyn/shared-util-email/hosted-page-screen'\nimport {\n lookalikeBrandForHost,\n phishingScreenBrandLabel,\n type PhishingScreenSignal,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyStaff } from './notifications'\nimport { orgAgeDays } from './org-age'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\nimport {\n canonicalJson,\n fileOutboundHold,\n flaggedHostOf,\n heldOutboundReference,\n heldOutboundReviewId,\n outboundContentHash,\n type HeldOutboundSendState,\n workspaceScreenIdentity,\n} from './outbound-send-review'\n\n/** Where the last version a page served clean is noted: a hint, re-screened before use. */\nexport const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews'\n\nexport interface HostedPageReviewRequest {\n hostId: string\n screenId: string\n versionId: string\n /** The page as composed for a visitor. */\n nodes: unknown\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n}\n\nexport type HostedPageReviewOutcome =\n | { outcome: 'serve' }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string }\n\n/** The review row's id for a page and the signals that held. */\nexport function pageReviewId(\n hostId: string,\n screenId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/screens/${screenId}`, outboundContentHash([key]))\n}\n\n/** How long this process trusts a page row's state — the send seam's minute. */\nconst PAGE_MEMO_TTL_MS = 60_000\nconst pageMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n/** The version each page last served clean, as this process last wrote it. */\nconst servedMemo = new Map<string, string>()\n\n/** Test seam: forget what this process learned. */\nexport function resetHostedPageReviewMemoForTests(): void {\n pageMemo.clear()\n servedMemo.clear()\n}\n\n/** The page's public URL, for the review row. Best effort; the row stands without it. */\nfunction pageUrl(host: Record<string, unknown> | null, screenId: string): string | null {\n const subdomain = typeof host?.['subdomain'] === 'string' ? (host['subdomain'] as string) : ''\n const cname = typeof host?.['cname'] === 'string' ? (host['cname'] as string) : ''\n const origin = cname ? `https://${cname}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : ''\n if (!origin) return null\n const screens = (host?.['screens'] ?? {}) as Record<string, unknown>\n const path = typeof screens[screenId] === 'string' ? (screens[screenId] as string) : ''\n const clean = path.replace(/^\\/+|\\/+$/g, '')\n return `${origin}/${clean === 'index' ? '' : clean}`\n}\n\n/**\n * Screen one page as a visitor would receive it, and say whether it may be\n * served. See the module header.\n */\nexport async function reviewHostedPage(\n request: HostedPageReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenHostedPage({ nodes: request.nodes })\n if (!found.signals.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n let reviewId = ''\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n // Again, now knowing whose page it is: a brand in the workspace's own\n // name or domain is its own, and a link to its own site is not away.\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenHostedPage({ nodes: request.nodes, ...identity })\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n reviewId = pageReviewId(request.hostId, request.screenId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n const url = pageUrl(host, request.screenId)\n const flagged = flaggedHostOf(signals)\n const pageName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/screens/${request.screenId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: (url ?? `screen ${request.screenId}`).slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n extra: { heldPage: { screenId: request.screenId, versionId: request.versionId, url } },\n headline:\n `Held a published page of ${pageName}${url ? ` (${url})` : ''}, version ` +\n `${request.versionId}, from a workspace ${ageDays ?? '?'} day(s) old. The page ` +\n 'serves its last clean version, or nothing, until this is decided.',\n alertTitle: 'Published page held for review — possible phishing',\n alertBody: `A page ${url ? `at ${url} ` : ''}was held before it went live.`,\n // The page itself is what was reported; the flagged host, when there\n // is one, is named in the details and on `reportedHostname`.\n url,\n reportedHostname: flagged,\n })\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Closed only for what holds whoever the workspace is: a soft signal\n // needs the workspace's age to hold at all, and that is what failed.\n if (!signalsThatHold(found.signals, { ageDays: null }).length) {\n console.error('[page-review] a page could not be reviewed — serving it', error)\n return { outcome: 'serve' }\n }\n console.error('[page-review] a flagged page could not be reviewed — not serving it', error)\n return {\n outcome: 'held',\n reviewId,\n reference: reviewId ? heldOutboundReference(reviewId) : '',\n }\n }\n}\n\nfunction pageReviewRef(hostId: string, screenId: string) {\n return firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .doc(screenId)\n}\n\n/**\n * The version this page last served clean, or null. A HINT, not a\n * clearance: the caller composes and screens it again before serving it,\n * because a version document can be edited after it was served.\n */\nexport async function servedPageVersion(hostId: string, screenId: string): Promise<string | null> {\n try {\n const snapshot = await pageReviewRef(hostId, screenId).get()\n const value = snapshot.get('servedVersionId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n}\n\n/**\n * Note that this page served `versionId` clean. One read the first time a\n * process renders a page, a write only when the version moved; nothing\n * after that, however often the page is rendered.\n */\nexport async function recordServedPageVersion(\n hostId: string,\n screenId: string,\n versionId: string,\n): Promise<void> {\n const key = `${hostId}/${screenId}`\n if (!versionId || servedMemo.get(key) === versionId) return\n try {\n const ref = pageReviewRef(hostId, screenId)\n const snapshot = await ref.get()\n if (snapshot.get('servedVersionId') !== versionId) {\n await ref.set({ servedVersionId: versionId, servedAtMs: Date.now() }, { merge: true })\n }\n servedMemo.set(key, versionId)\n } catch (error) {\n console.warn('[page-review] the served version could not be noted', error)\n }\n}\n\n/**\n * A domain a workspace attached that wears a brand (AGL-3356, AGL-3362):\n * filed in the abuse queue as an urgent `phishing` row for staff, not\n * refused. Two kinds, one rule:\n *\n * - `site` — a custom domain a site is served on (the attach route);\n * - `sending` — a custom domain a workspace sends email as, when it is\n * added and again when it verifies (`sending-domains.ts`).\n *\n * Not refused, because the domain is the merchant's own registration and\n * the platform cannot tell a brand's own agency attaching `paypal-promo.com`\n * from a kit — but a brand's lookalike on a site or in a `From:` line is\n * exactly what staff look at first. The site's pages are still screened as\n * they are served, and mail FROM a lookalike domain is held at the send seam\n * for every workspace. The test is the phishing screen's own lookalike rule\n * (`lookalikeBrandForHost`), the one that holds a link in an email. One row\n * per (owner, kind, domain), so a re-attach or a re-verify counts rather than\n * re-alerts.\n *\n * Never throws: the write it rides on has already happened.\n */\nexport async function flagLookalikeDomain(input: {\n kind: 'site' | 'sending'\n hostId: string | null\n orgId: string | null\n domain: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n const domain = String(input.domain ?? '').trim().toLowerCase()\n const brand = lookalikeBrandForHost(domain)\n if (!brand) return 'clean'\n const label = phishingScreenBrandLabel(brand.id)\n const sending = input.kind === 'sending'\n try {\n const firestore = firebaseAdmin.app().firestore()\n const owner = sending ? `orgs/${input.orgId ?? ''}/sendingDomains` : `hosts/${input.hostId ?? ''}/cname`\n const reviewId = heldOutboundReviewId(owner, domain)\n const reference = heldOutboundReference(reviewId)\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url: `https://${domain}/`,\n reportedHostname: domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n sending\n ? `A workspace added ${domain} as a domain to send email from.`\n : `A site attached the custom domain ${domain}.`,\n `${domain} wears the ${label} name but is not ${label}'s domain.`,\n sending\n ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' +\n 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.'\n : 'Its pages are still screened as they are served. Mark this actioned ' +\n 'and lock the site if it is impersonation; dismiss it if the brand is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifyStaff({\n type: 'system.abuseReportUrgent',\n title: sending\n ? 'Sending domain flagged — possible brand impersonation'\n : 'Custom domain flagged — possible brand impersonation',\n body:\n `A ${sending ? 'workspace added the sending domain' : 'site attached'} ${domain}, ` +\n `which looks like ${label}. Reference ${reference}.`,\n link: '/admin/abuse-reports',\n })\n }\n return 'flagged'\n } catch (error) {\n console.error('[page-review] a lookalike domain could not be flagged', error)\n return 'failed'\n }\n}\n\n/** A site's custom domain, through {@link flagLookalikeDomain}. */\nexport function flagLookalikeCustomDomain(input: {\n hostId: string\n orgId: string | null\n domain: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n return flagLookalikeDomain({ kind: 'site', ...input })\n}\n"],"names":["ABUSE_REPORT_COLLECTION","TENANT_APEX","screenHostedPage","lookalikeBrandForHost","phishingScreenBrandLabel","signalsThatHold","FieldValue","firebaseAdmin","notifyStaff","orgAgeDays","getHostDocAdmin","getOrgForHost","canonicalJson","fileOutboundHold","flaggedHostOf","heldOutboundReference","heldOutboundReviewId","outboundContentHash","workspaceScreenIdentity","PAGE_REVIEW_SUBCOLLECTION","pageReviewId","hostId","screenId","signals","key","map","signal","sort","join","PAGE_MEMO_TTL_MS","pageMemo","Map","servedMemo","resetHostedPageReviewMemoForTests","clear","pageUrl","host","subdomain","cname","origin","screens","path","clean","replace","reviewHostedPage","request","found","nodes","length","outcome","nowMs","Date","now","reviewId","owner","Promise","all","undefined","org","orgId","identity","verdict","ageDays","createdAt","reference","memo","get","atMs","state","url","flagged","pageName","heldSend","kind","contentHash","subject","slice","fromName","heldAtMs","extra","heldPage","versionId","headline","alertTitle","alertBody","reportedHostname","set","error","console","pageReviewRef","app","firestore","collection","doc","servedPageVersion","snapshot","value","recordServedPageVersion","ref","servedVersionId","servedAtMs","merge","warn","flagLookalikeDomain","input","domain","String","trim","toLowerCase","brand","label","id","sending","first","exists","category","severity","source","details","reporterEmail","reporterName","dmca","reportCount","increment","updatedAt","serverTimestamp","status","type","title","body","link","flagLookalikeCustomDomain"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/hosted-page-review.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 * HELD FOR REVIEW: a published page (AGL-3362).\n *\n * ## Why the check sits where a page is COMPOSED, not where it is published\n *\n * A publish is a browser write. The besigner toolbar, the versions panel,\n * the screen details page, page-from-template, the layout and component\n * besigners and the form besigner each move a pointer (`versionId`, the\n * host's `screens` map, a component's own `nodes`) straight from the\n * client SDK; the scheduled-publish beat moves it from the server; an\n * experiment serves a version no pointer names; and an author may rewrite\n * the LIVE version document itself without moving any pointer at all. A\n * check on any one of those writes is a check the next one walks around.\n *\n * They all meet in one place: `composeScreenNodes` (`@aglyn/tenant-runtime`),\n * which every published render, the password unlock, the collection and\n * product templates and every experiment variant go through, and which\n * returns the page exactly as a visitor will receive it — the screen, its\n * layout chrome, its grafted components and its forms. So the screen is read\n * there, and a held page is one that is never composed for a visitor. For a\n * visitor, \"publish\" is the first render after the pointer moved; this is\n * that moment.\n *\n * ## What a hold does\n *\n * - The version is not served. The last version this page served clean\n * ({@link servedPageVersion}) is composed instead — screened again, since\n * a version document can be edited in place — and if there is none, or it\n * holds too, the page is not found. A first publish that holds therefore\n * serves nothing.\n * - One review row per (page, signal set) lands in the abuse queue at\n * `/admin/abuse-reports`, the same `phishing` row a held email files\n * (`fileOutboundHold`), with `heldSend.kind: 'page'`, the page's URL and\n * the held version. Staff dismiss it to RELEASE (the admin route drops the\n * site's cache and the next render serves it) or mark it actioned to\n * REJECT (it stays unserved; any later version carrying the same signals\n * is refused without a new row).\n * - Keyed on the SIGNALS, as the send seam's rows are: a composed page\n * carries per-render values (dates, collection rows) that no content hash\n * would survive, while what makes it phishing does not change between\n * renders.\n *\n * ## Tiers\n *\n * `signalsThatHold`: a lookalike link or embed and an author-defined\n * credential field hold for EVERY workspace; a brand's call to action only\n * for a workspace in its first fortnight. The workspace's documents are read\n * only when the pure screen has found something, so a clean page costs no\n * read for this.\n *\n * Fails CLOSED, unlike the send path. Composition itself reads the store, so\n * an outage here is an outage on the render already, and the only cost of\n * not serving a page the screen flagged is a false positive's wait; serving\n * it is the phishing page this exists to stop.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/host-naming'\nimport { screenHostedPage } from '@aglyn/shared-util-email/hosted-page-screen'\nimport {\n lookalikeBrandForHost,\n phishingScreenBrandLabel,\n type PhishingScreenSignal,\n signalsThatHold,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyHostManagers, notifyStaff } from './notifications'\nimport { orgAgeDays } from './org-age'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\nimport {\n canonicalJson,\n fileOutboundHold,\n flaggedHostOf,\n heldOutboundReference,\n heldOutboundReviewId,\n outboundContentHash,\n type HeldOutboundSendState,\n workspaceScreenIdentity,\n} from './outbound-send-review'\n\n/** Where the last version a page served clean is noted: a hint, re-screened before use. */\nexport const PAGE_REVIEW_SUBCOLLECTION = 'pageReviews'\n\nexport interface HostedPageReviewRequest {\n hostId: string\n screenId: string\n versionId: string\n /** The page as composed for a visitor. */\n nodes: unknown\n /** The host and org documents, when the caller already holds them. */\n host?: Record<string, unknown> | null\n org?: Record<string, unknown> | null\n orgId?: string | null\n nowMs?: number\n}\n\nexport type HostedPageReviewOutcome =\n | { outcome: 'serve' }\n | { outcome: 'held' | 'rejected'; reviewId: string; reference: string }\n\n/** The review row's id for a page and the signals that held. */\nexport function pageReviewId(\n hostId: string,\n screenId: string,\n signals: readonly PhishingScreenSignal[],\n): string {\n const key = signals\n .map((signal) => canonicalJson(signal))\n .sort()\n .join('\\n')\n return heldOutboundReviewId(`hosts/${hostId}/screens/${screenId}`, outboundContentHash([key]))\n}\n\n/** How long this process trusts a page row's state — the send seam's minute. */\nconst PAGE_MEMO_TTL_MS = 60_000\nconst pageMemo = new Map<string, { state: HeldOutboundSendState; atMs: number }>()\n/** The version each page last served clean, as this process last wrote it. */\nconst servedMemo = new Map<string, string>()\n\n/** Test seam: forget what this process learned. */\nexport function resetHostedPageReviewMemoForTests(): void {\n pageMemo.clear()\n servedMemo.clear()\n}\n\n/** The page's public URL, for the review row. Best effort; the row stands without it. */\nfunction pageUrl(host: Record<string, unknown> | null, screenId: string): string | null {\n const subdomain = typeof host?.['subdomain'] === 'string' ? (host['subdomain'] as string) : ''\n const cname = typeof host?.['cname'] === 'string' ? (host['cname'] as string) : ''\n const origin = cname ? `https://${cname}` : subdomain ? `https://${subdomain}.${TENANT_APEX}` : ''\n if (!origin) return null\n const screens = (host?.['screens'] ?? {}) as Record<string, unknown>\n const path = typeof screens[screenId] === 'string' ? (screens[screenId] as string) : ''\n const clean = path.replace(/^\\/+|\\/+$/g, '')\n return `${origin}/${clean === 'index' ? '' : clean}`\n}\n\n/**\n * Screen one page as a visitor would receive it, and say whether it may be\n * served. See the module header.\n */\nexport async function reviewHostedPage(\n request: HostedPageReviewRequest,\n): Promise<HostedPageReviewOutcome> {\n const found = screenHostedPage({ nodes: request.nodes })\n if (!found.signals.length) return { outcome: 'serve' }\n\n const nowMs = request.nowMs ?? Date.now()\n let reviewId = ''\n try {\n const [host, owner] = await Promise.all([\n request.host !== undefined ? request.host : getHostDocAdmin(request.hostId),\n request.org !== undefined\n ? { orgId: request.orgId ?? null, org: request.org }\n : getOrgForHost(request.hostId),\n ])\n const org = (owner?.org as Record<string, unknown> | null | undefined) ?? null\n const orgId = owner?.orgId ?? null\n // Again, now knowing whose page it is: a brand in the workspace's own\n // name or domain is its own, and a link to its own site is not away.\n const identity = workspaceScreenIdentity({ org, host })\n const verdict = screenHostedPage({ nodes: request.nodes, ...identity })\n const ageDays = orgAgeDays((org as { createdAt?: unknown } | null)?.createdAt, nowMs)\n const signals = signalsThatHold(verdict.signals, { ageDays })\n if (!signals.length) return { outcome: 'serve' }\n\n reviewId = pageReviewId(request.hostId, request.screenId, signals)\n const reference = heldOutboundReference(reviewId)\n const memo = pageMemo.get(reviewId)\n if (memo && nowMs - memo.atMs < PAGE_MEMO_TTL_MS) {\n return memo.state === 'released'\n ? { outcome: 'serve' }\n : { outcome: memo.state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n }\n const url = pageUrl(host, request.screenId)\n const flagged = flaggedHostOf(signals)\n const pageName =\n typeof host?.['name'] === 'string' && host['name'] ? `\"${host['name']}\"` : 'a site'\n const state = await fileOutboundHold({\n reviewId,\n heldSend: {\n kind: 'page',\n path: `hosts/${request.hostId}/screens/${request.screenId}`,\n hostId: request.hostId,\n orgId,\n contentHash: outboundContentHash([signals]),\n subject: (url ?? `screen ${request.screenId}`).slice(0, 300),\n fromName: null,\n signals,\n state: 'held',\n heldAtMs: nowMs,\n ageDays,\n },\n extra: { heldPage: { screenId: request.screenId, versionId: request.versionId, url } },\n headline:\n `Held a published page of ${pageName}${url ? ` (${url})` : ''}, version ` +\n `${request.versionId}, from a workspace ${ageDays ?? '?'} day(s) old. The page ` +\n 'serves its last clean version, or nothing, until this is decided.',\n alertTitle: 'Published page held for review — possible phishing',\n alertBody: `A page ${url ? `at ${url} ` : ''}was held before it went live.`,\n // The page itself is what was reported; the flagged host, when there\n // is one, is named in the details and on `reportedHostname`.\n url,\n reportedHostname: flagged,\n })\n pageMemo.set(reviewId, { state, atMs: nowMs })\n return state === 'released'\n ? { outcome: 'serve' }\n : { outcome: state === 'rejected' ? 'rejected' : 'held', reviewId, reference }\n } catch (error) {\n // Closed only for what holds whoever the workspace is: a soft signal\n // needs the workspace's age to hold at all, and that is what failed.\n if (!signalsThatHold(found.signals, { ageDays: null }).length) {\n console.error('[page-review] a page could not be reviewed — serving it', error)\n return { outcome: 'serve' }\n }\n console.error('[page-review] a flagged page could not be reviewed — not serving it', error)\n return {\n outcome: 'held',\n reviewId,\n reference: reviewId ? heldOutboundReference(reviewId) : '',\n }\n }\n}\n\nfunction pageReviewRef(hostId: string, screenId: string) {\n return firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .doc(screenId)\n}\n\n/**\n * The version this page last served clean, or null. A HINT, not a\n * clearance: the caller composes and screens it again before serving it,\n * because a version document can be edited after it was served.\n */\nexport async function servedPageVersion(hostId: string, screenId: string): Promise<string | null> {\n try {\n const snapshot = await pageReviewRef(hostId, screenId).get()\n const value = snapshot.get('servedVersionId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n}\n\n/**\n * Note that this page served `versionId` clean. One read the first time a\n * process renders a page, a write only when the version moved; nothing\n * after that, however often the page is rendered.\n */\nexport async function recordServedPageVersion(\n hostId: string,\n screenId: string,\n versionId: string,\n): Promise<void> {\n const key = `${hostId}/${screenId}`\n if (!versionId || servedMemo.get(key) === versionId) return\n try {\n const ref = pageReviewRef(hostId, screenId)\n const snapshot = await ref.get()\n if (snapshot.get('servedVersionId') !== versionId) {\n await ref.set({ servedVersionId: versionId, servedAtMs: Date.now() }, { merge: true })\n }\n servedMemo.set(key, versionId)\n } catch (error) {\n console.warn('[page-review] the served version could not be noted', error)\n }\n}\n\n/**\n * A domain a workspace attached that wears a brand (AGL-3356, AGL-3362):\n * filed in the abuse queue as an urgent `phishing` row for staff, not\n * refused. Two kinds, one rule:\n *\n * - `site` — a custom domain a site is served on (the attach route);\n * - `sending` — a custom domain a workspace sends email as, when it is\n * added and again when it verifies (`sending-domains.ts`);\n * - `link` — a link a merchant configured that a buyer or a server would\n * follow: a product's paid download or video hotlink, a supplier's order\n * webhook (AGL-3363). The link itself is refused where it is followed; this\n * files it, and tells the site's managers once ({@link notifyLookalikeLink}).\n *\n * Not refused, because the domain is the merchant's own registration and\n * the platform cannot tell a brand's own agency attaching `paypal-promo.com`\n * from a kit — but a brand's lookalike on a site or in a `From:` line is\n * exactly what staff look at first. The site's pages are still screened as\n * they are served, and mail FROM a lookalike domain is held at the send seam\n * for every workspace. The test is the phishing screen's own lookalike rule\n * (`lookalikeBrandForHost`), the one that holds a link in an email. One row\n * per (owner, kind, domain), so a re-attach or a re-verify counts rather than\n * re-alerts.\n *\n * Never throws: the write it rides on has already happened.\n */\nexport async function flagLookalikeDomain(input: {\n kind: 'site' | 'sending' | 'link'\n hostId: string | null\n orgId: string | null\n domain: string\n /** For a `link`: where it was configured, as staff read it. */\n where?: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n const domain = String(input.domain ?? '').trim().toLowerCase()\n const brand = lookalikeBrandForHost(domain)\n if (!brand) return 'clean'\n const label = phishingScreenBrandLabel(brand.id)\n const sending = input.kind === 'sending'\n const link = input.kind === 'link'\n try {\n const firestore = firebaseAdmin.app().firestore()\n const owner = sending\n ? `orgs/${input.orgId ?? ''}/sendingDomains`\n : link\n ? `hosts/${input.hostId ?? ''}/links`\n : `hosts/${input.hostId ?? ''}/cname`\n const reviewId = heldOutboundReviewId(owner, domain)\n const reference = heldOutboundReference(reviewId)\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'outbound-screen',\n url: `https://${domain}/`,\n reportedHostname: domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: [\n sending\n ? `A workspace added ${domain} as a domain to send email from.`\n : link\n ? `A site configured a link to ${domain} (${input.where ?? 'a commerce link'}).`\n : `A site attached the custom domain ${domain}.`,\n `${domain} wears the ${label} name but is not ${label}'s domain.`,\n sending\n ? 'Email from it is held at the send seam for every workspace. Mark this actioned ' +\n 'and lock the workspace if it is impersonation; dismiss it if the brand is theirs.'\n : link\n ? 'Nobody is sent to it: the link is refused wherever it is followed. Mark this ' +\n 'actioned and lock the site if it is impersonation.'\n : 'Its pages are still screened as they are served. Mark this actioned ' +\n 'and lock the site if it is impersonation; dismiss it if the brand is theirs.',\n ].join('\\n'),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: FieldValue.increment(1),\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n if (first && link) {\n await notifyLookalikeLink({\n hostId: input.hostId,\n domain,\n label,\n where: input.where ?? 'a commerce link',\n reference,\n })\n } else if (first) {\n await notifyStaff({\n type: 'system.abuseReportUrgent',\n title: sending\n ? 'Sending domain flagged — possible brand impersonation'\n : 'Custom domain flagged — possible brand impersonation',\n body:\n `A ${sending ? 'workspace added the sending domain' : 'site attached'} ${domain}, ` +\n `which looks like ${label}. Reference ${reference}.`,\n link: '/admin/abuse-reports',\n })\n }\n return 'flagged'\n } catch (error) {\n console.error('[page-review] a lookalike domain could not be flagged', error)\n return 'failed'\n }\n}\n\n/**\n * Tell staff and the site's managers, once per (site, domain), that a link\n * the merchant configured was refused because it wears another brand's name\n * (AGL-3363). The one notifier for this kind of signal, so a shared\n * risk-notice seam can take it over whole. The merchant is told what was\n * refused and how to fix it; never how the check works. Never throws.\n */\nexport async function notifyLookalikeLink(input: {\n hostId: string | null\n domain: string\n label: string\n where: string\n reference: string\n}): Promise<void> {\n await Promise.all([\n notifyStaff({\n type: 'system.abuseReportUrgent',\n title: 'Commerce link flagged — possible brand impersonation',\n body:\n `A site configured ${input.where} to ${input.domain}, which looks like ${input.label}. ` +\n `It is refused wherever it is followed. Reference ${input.reference}.`,\n link: '/admin/abuse-reports',\n }).catch(() => undefined),\n input.hostId\n ? notifyHostManagers(input.hostId, {\n type: 'content.order',\n title: 'A link on your store was blocked',\n body:\n `${input.where[0].toUpperCase()}${input.where.slice(1)} points to ${input.domain}, ` +\n `which looks like another company's website, so nobody is sent there. ` +\n 'Replace it with a file from your media library or a link on your own domain. ' +\n `If you think this is a mistake, contact support with reference ${input.reference}.`,\n link: `/${input.hostId}/products`,\n }).catch(() => undefined)\n : Promise.resolve(),\n ])\n}\n\n/** A site's custom domain, through {@link flagLookalikeDomain}. */\nexport function flagLookalikeCustomDomain(input: {\n hostId: string\n orgId: string | null\n domain: string\n}): Promise<'flagged' | 'clean' | 'failed'> {\n return flagLookalikeDomain({ kind: 'site', ...input })\n}\n"],"names":["ABUSE_REPORT_COLLECTION","TENANT_APEX","screenHostedPage","lookalikeBrandForHost","phishingScreenBrandLabel","signalsThatHold","FieldValue","firebaseAdmin","notifyHostManagers","notifyStaff","orgAgeDays","getHostDocAdmin","getOrgForHost","canonicalJson","fileOutboundHold","flaggedHostOf","heldOutboundReference","heldOutboundReviewId","outboundContentHash","workspaceScreenIdentity","PAGE_REVIEW_SUBCOLLECTION","pageReviewId","hostId","screenId","signals","key","map","signal","sort","join","PAGE_MEMO_TTL_MS","pageMemo","Map","servedMemo","resetHostedPageReviewMemoForTests","clear","pageUrl","host","subdomain","cname","origin","screens","path","clean","replace","reviewHostedPage","request","found","nodes","length","outcome","nowMs","Date","now","reviewId","owner","Promise","all","undefined","org","orgId","identity","verdict","ageDays","createdAt","reference","memo","get","atMs","state","url","flagged","pageName","heldSend","kind","contentHash","subject","slice","fromName","heldAtMs","extra","heldPage","versionId","headline","alertTitle","alertBody","reportedHostname","set","error","console","pageReviewRef","app","firestore","collection","doc","servedPageVersion","snapshot","value","recordServedPageVersion","ref","servedVersionId","servedAtMs","merge","warn","flagLookalikeDomain","input","domain","String","trim","toLowerCase","brand","label","id","sending","link","first","exists","category","severity","source","details","where","reporterEmail","reporterName","dmca","reportCount","increment","updatedAt","serverTimestamp","status","notifyLookalikeLink","type","title","body","catch","toUpperCase","resolve","flagLookalikeCustomDomain"],"mappings":"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"}
@@ -19,6 +19,7 @@ import { resolveBrandingProfile } from "@aglyn/aglyn/server";
19
19
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
20
20
  import { createHmac } from "crypto";
21
21
  import { tokenSigningSecret } from "./media-signing.js";
22
+ import { renderSystemEmailContent, systemEmailBrand } from "./render-system-email.js";
22
23
  import { safeEqual } from "./safe-equal.js";
23
24
  /**
24
25
  * A MEMBER'S OWN ADDRESSES (AGL-2975): the half with Firestore.
@@ -350,8 +351,8 @@ const signature = (payload)=>createHmac('sha256', tokenSigningSecret()).update(`
350
351
  * sent (see the two ceilings above), and metered to the workspace, whose
351
352
  * member asked for it.
352
353
  */ export async function sendMemberEmailAliasConfirmation(input) {
353
- var _input_org, _ref, _input_memberName;
354
- var _input_org1;
354
+ var _input_org, _ref, _input_memberName, _input_org1;
355
+ var _input_org2;
355
356
  if (!isEmailConfigured()) {
356
357
  return {
357
358
  sent: false,
@@ -383,7 +384,7 @@ const signature = (payload)=>createHmac('sha256', tokenSigningSecret()).update(`
383
384
  };
384
385
  }
385
386
  const branding = resolveBrandingProfile((_input_org = input.org) != null ? _input_org : null);
386
- const workspace = String((_ref = (_input_org1 = input.org) == null ? void 0 : _input_org1['name']) != null ? _ref : '').trim() || 'your workspace';
387
+ const workspace = String((_ref = (_input_org2 = input.org) == null ? void 0 : _input_org2['name']) != null ? _ref : '').trim() || 'your workspace';
387
388
  const member = String((_input_memberName = input.memberName) != null ? _input_memberName : '').trim();
388
389
  const text = [
389
390
  `${member || 'A member'} asked to add ${input.address} as one of their own email ` + `addresses in the ${workspace} workspace on ${branding.productName}.`,
@@ -396,13 +397,23 @@ const signature = (payload)=>createHmac('sha256', tokenSigningSecret()).update(`
396
397
  '',
397
398
  'If you did not expect this email, ignore it. Nothing changes unless the member who asked opens the link.'
398
399
  ].join('\n');
399
- const result = await sendEmail({
400
- to: input.address,
400
+ // The `member-email-alias-confirmation` system email (AGL-3367), in the
401
+ // workspace's brand; `text` above is its last resort.
402
+ const content = await renderSystemEmailContent('member-email-alias-confirmation', {
403
+ 'member.name': member || 'A member',
404
+ address: input.address,
405
+ 'org.name': workspace,
406
+ confirmUrl: input.confirmUrl
407
+ }, systemEmailBrand((_input_org1 = input.org) != null ? _input_org1 : null), {
401
408
  subject: `Confirm your address for ${workspace}`,
402
- text,
409
+ text
410
+ });
411
+ const result = await sendEmail(_extends({
412
+ to: input.address
413
+ }, content, {
403
414
  fromName: branding.fromName,
404
415
  context: 'member-email-alias-confirmation'
405
- });
416
+ }));
406
417
  if (!result.sent) {
407
418
  return {
408
419
  sent: false,
@@ -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 { 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 const result = await sendEmail({\n to: input.address,\n subject: `Confirm your address for ${workspace}`,\n text,\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","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","result","to","subject","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 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":";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"}
@@ -27,6 +27,14 @@ export interface NotifyUsersOptions {
27
27
  * map is fine and passing none is correct.
28
28
  */
29
29
  emails?: Readonly<Record<string, string | null | undefined>>;
30
+ /**
31
+ * Who the fan-out is for (AGL-3367). `staff` is the platform talking to its
32
+ * own people, so the email wears the platform's brand and is the platform's
33
+ * cost even when the notification is ABOUT an org, which a canceled
34
+ * subscription or a new ticket always is. Anything else speaks in the org's
35
+ * brand when the notification names one.
36
+ */
37
+ audience?: 'staff';
30
38
  }
31
39
  /**
32
40
  * Notification fan-out (AGL-259): batch-writes one doc per recipient at