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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-v1-description.d.ts +112 -0
  3. package/src/lib/server/api-v1-description.js +126 -0
  4. package/src/lib/server/api-v1-description.js.map +1 -0
  5. package/src/lib/server/api-v1-kit.d.ts +156 -0
  6. package/src/lib/server/api-v1-kit.js +191 -0
  7. package/src/lib/server/api-v1-kit.js.map +1 -0
  8. package/src/lib/server/api-v1-resources.d.ts +144 -0
  9. package/src/lib/server/api-v1-resources.js +126 -0
  10. package/src/lib/server/api-v1-resources.js.map +1 -0
  11. package/src/lib/server/consent-group-change.d.ts +2 -1
  12. package/src/lib/server/consent-group-change.js +41 -16
  13. package/src/lib/server/consent-group-change.js.map +1 -1
  14. package/src/lib/server/contact-email-index.d.ts +22 -0
  15. package/src/lib/server/contact-email-index.js +108 -0
  16. package/src/lib/server/contact-email-index.js.map +1 -1
  17. package/src/lib/server/crm-email-activity.d.ts +1 -1
  18. package/src/lib/server/crm-email-activity.js +1 -1
  19. package/src/lib/server/crm-email-activity.js.map +1 -1
  20. package/src/lib/server/crm-inbound-email.d.ts +23 -0
  21. package/src/lib/server/crm-inbound-email.js +1 -16
  22. package/src/lib/server/crm-inbound-email.js.map +1 -1
  23. package/src/lib/server/crm-records.d.ts +9 -1
  24. package/src/lib/server/crm-records.js +19 -2
  25. package/src/lib/server/crm-records.js.map +1 -1
  26. package/src/lib/server/cron-auth.d.ts +44 -0
  27. package/src/lib/server/cron-auth.js +76 -0
  28. package/src/lib/server/cron-auth.js.map +1 -0
  29. package/src/lib/server/dataset-live-pages.js +6 -6
  30. package/src/lib/server/dataset-live-pages.js.map +1 -1
  31. package/src/lib/server/duplicate-resource.d.ts +26 -0
  32. package/src/lib/server/duplicate-resource.js +47 -25
  33. package/src/lib/server/duplicate-resource.js.map +1 -1
  34. package/src/lib/server/dynamic-list-materialize.js +4 -1
  35. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  36. package/src/lib/server/email-suppression.d.ts +11 -0
  37. package/src/lib/server/email-suppression.js +36 -1
  38. package/src/lib/server/email-suppression.js.map +1 -1
  39. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  40. package/src/lib/server/email-unsubscribe-link.js +7 -7
  41. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  42. package/src/lib/server/erase.d.ts +19 -33
  43. package/src/lib/server/erase.js +37 -162
  44. package/src/lib/server/erase.js.map +1 -1
  45. package/src/lib/server/host-email-tokens.js +1 -1
  46. package/src/lib/server/host-email-tokens.js.map +1 -1
  47. package/src/lib/server/host-memberships.js +3 -3
  48. package/src/lib/server/host-memberships.js.map +1 -1
  49. package/src/lib/server/member-email-aliases.d.ts +1 -1
  50. package/src/lib/server/member-email-aliases.js +1 -1
  51. package/src/lib/server/member-email-aliases.js.map +1 -1
  52. package/src/lib/server/new-account-announcement.d.ts +62 -0
  53. package/src/lib/server/new-account-announcement.js +77 -0
  54. package/src/lib/server/new-account-announcement.js.map +1 -0
  55. package/src/lib/server/notifications.d.ts +8 -2
  56. package/src/lib/server/notifications.js +11 -3
  57. package/src/lib/server/notifications.js.map +1 -1
  58. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  59. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
  60. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  61. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  62. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  63. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  64. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  65. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  66. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  67. package/src/lib/server/payment-provider.d.ts +173 -0
  68. package/src/lib/server/payment-provider.js +70 -0
  69. package/src/lib/server/payment-provider.js.map +1 -0
  70. package/src/lib/server/personal-data-export.js +1 -1
  71. package/src/lib/server/personal-data-export.js.map +1 -1
  72. package/src/lib/server/rate-limit-store.js +13 -3
  73. package/src/lib/server/rate-limit-store.js.map +1 -1
  74. package/src/lib/server/realm-plugins.js +8 -3
  75. package/src/lib/server/realm-plugins.js.map +1 -1
  76. package/src/lib/server/retained-refusals.d.ts +142 -0
  77. package/src/lib/server/retained-refusals.js +325 -0
  78. package/src/lib/server/retained-refusals.js.map +1 -0
  79. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  80. package/src/lib/server/test-firestore-queries.js +70 -27
  81. package/src/lib/server/test-firestore-queries.js.map +1 -1
  82. package/src/lib/server/upsert-contact.d.ts +46 -1
  83. package/src/lib/server/upsert-contact.js +85 -18
  84. package/src/lib/server/upsert-contact.js.map +1 -1
  85. package/src/tenancy.d.ts +4 -5
  86. package/src/tenancy.js +11 -6
  87. package/src/tenancy.js.map +1 -1
  88. package/src/lib/server/connect-account-status.js.map +0 -1
  89. package/src/lib/server/connect-payout-failure.js.map +0 -1
  90. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  91. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  92. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  93. package/src/lib/server/contact-merge.d.ts +0 -55
  94. package/src/lib/server/contact-merge.js +0 -288
  95. package/src/lib/server/contact-merge.js.map +0 -1
  96. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  97. package/src/lib/server/form-dataset-binding-token.js +0 -107
  98. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  99. package/src/lib/server/stripe-account-mode.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/marketplace-theme'\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport { buildSiteEmailChrome } from '@aglyn/aglyn/app-utils/site-email-chrome'\nimport {\n buildEmailPalette,\n emailPaletteBaseForHost,\n getTenantEmail,\n hostEmailOrigin,\n loadHostEmail,\n renderFramedTextEmail,\n renderHostEmail,\n renderLoadedHostEmail,\n type EmailChrome,\n type EmailTheme,\n type LoadedHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n const host = await readHostTokenSource(firestore, hostId)\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n //\n // The component graft joins on the same argument (AGL-3287): a header or\n // footer the site owner placed is a reference the shared renderer cannot\n // resolve, so every sender behind this function mails it expanded without\n // having to know it exists. Style overrides do not survive into mail —\n // they land in `sx`, which `renderEmailHtml` never reads — but property\n // values and attribute overrides do.\n //\n // And the site's header and footer (AGL-3370), which the built-in copy is\n // drawn inside; a design the owner published goes out as they built it.\n {\n ...options,\n sanitize: sanitizeAuthorHtml,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n },\n )\n}\n\n/**\n * The site's host document as the token and chrome readers see it, or null.\n *\n * A failed read must not stop the send. Every token then resolves to its\n * empty behaviour, which is the same outcome as a site that set nothing —\n * an email with a gap where the address would be, rather than no email.\n */\nasync function readHostTokenSource(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<SiteEmailHost | null> {\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n return snapshot.exists ? (snapshot.data() as SiteEmailHost) : null\n } catch {\n return null\n }\n}\n\n/** What a site email reads off the host document: its tokens and its theme. */\ntype SiteEmailHost = HostTokenSource & {\n theme?: {\n colorSchemes?: { light?: Record<string, unknown> | null }\n shape?: { borderRadius?: unknown }\n } | null\n themeOverride?: unknown\n}\n\n/**\n * The site's theme as its email draws it (AGL-3370): the palette the\n * published site renders with, light scheme, since an inbox has one, and its\n * corner radius for buttons. A site with no theme of its own resolves the\n * same base the site itself does.\n */\nfunction siteThemeFor(host: SiteEmailHost | null): EmailTheme {\n // Resolved, not read off `theme`: a site's edits are an override on the\n // theme it picked (AGL-3404), so the stored theme alone is the design\n // before anything the site changed.\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n const radius = theme?.shape?.borderRadius\n return {\n palette: buildEmailPalette({\n base: emailPaletteBaseForHost(host),\n colors: (theme?.colorSchemes?.light ?? null) as Record<string, unknown> | null,\n }),\n ...(typeof radius === 'number' ? { buttonRadius: radius } : {}),\n }\n}\n\n/** The site's chrome for one kind of email, with that kind's footer reason. */\nfunction siteChromeFor(\n host: HostTokenSource | null,\n templateKey: string,\n): EmailChrome {\n return buildSiteEmailChrome({\n host,\n reason: getTenantEmail(templateKey)?.footerReason,\n })\n}\n\n/** A site email loaded once for a batch, with the site's tokens beside it. */\nexport interface LoadedHostEmailWithTokens {\n loaded: LoadedHostEmail\n hostMerge: Record<string, string>\n}\n\n/**\n * {@link loadHostEmail} for a batch (AGL-3370): the template, or the built-in\n * copy in the site's header and footer, read ONCE, with the site's `host.*`\n * tokens beside it for {@link renderLoadedHostEmailWithTokens}. `null` only\n * for a key with neither, so the batch keeps its own copy.\n */\nexport async function loadHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\n): Promise<LoadedHostEmailWithTokens | null> {\n const host = await readHostTokenSource(firestore, hostId)\n const loaded = await loadHostEmail(firestore as never, hostId, templateKey, {\n ...options,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n })\n return loaded ? { loaded, hostMerge: hostTokenMerge(host) } : null\n}\n\n/** One recipient's copy of a batch-loaded site email; no reads. */\nexport function renderLoadedHostEmailWithTokens(\n bundle: LoadedHostEmailWithTokens,\n merge: Record<string, string> = {},\n): RenderedHostEmail | null {\n return renderLoadedHostEmail(\n bundle.loaded,\n { ...bundle.hostMerge, ...merge },\n sanitizeAuthorHtml,\n )\n}\n\n/**\n * The site's header and footer for one kind of email, its theme, and the\n * origin and host a stored logo reference resolves against (AGL-3370): for a sender\n * that renders per recipient itself, such as a typed campaign.\n */\nexport async function siteEmailFrame(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n): Promise<{\n chrome: EmailChrome\n theme: EmailTheme\n mediaOrigin?: string\n mediaHostId: string\n}> {\n const host = await readHostTokenSource(firestore, hostId)\n const origin = host\n ? hostEmailOrigin({ cname: host.cname, subdomain: host.subdomain })\n : undefined\n return {\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n ...(origin ? { mediaOrigin: origin } : {}),\n mediaHostId: hostId,\n }\n}\n\n/**\n * A text-only site email in the site's header and footer (AGL-3370): a\n * workflow's email step, or a message a site sends in words nobody designed.\n * The text arrives resolved; the footer's reason is the kind's, by key.\n */\nexport async function renderSiteTextEmail(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n message: { subject: string; text: string; preheader?: string },\n): Promise<{ subject: string; html: string; text: string }> {\n const frame = await siteEmailFrame(firestore, hostId, templateKey)\n const rendered = renderFramedTextEmail({ ...message, ...frame })\n return { subject: message.subject, html: rendered.html, text: rendered.text }\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["resolveSiteTheme","sanitizeAuthorHtml","hostTokenMerge","composeHostComponentNodes","buildSiteEmailChrome","buildEmailPalette","emailPaletteBaseForHost","getTenantEmail","hostEmailOrigin","loadHostEmail","renderFramedTextEmail","renderHostEmail","renderLoadedHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","readHostTokenSource","sanitize","compose","chrome","siteChromeFor","theme","siteThemeFor","snapshot","collection","doc","get","exists","data","radius","shape","borderRadius","palette","base","colors","colorSchemes","light","buttonRadius","reason","footerReason","loadHostEmailWithTokens","loaded","hostMerge","renderLoadedHostEmailWithTokens","bundle","siteEmailFrame","origin","cname","subdomain","undefined","mediaOrigin","mediaHostId","renderSiteTextEmail","message","frame","rendered","subject","html","text"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport { buildSiteEmailChrome } from '@aglyn/aglyn/app-utils/site-email-chrome'\nimport {\n buildEmailPalette,\n emailPaletteBaseForHost,\n getTenantEmail,\n hostEmailOrigin,\n loadHostEmail,\n renderFramedTextEmail,\n renderHostEmail,\n renderLoadedHostEmail,\n type EmailChrome,\n type EmailTheme,\n type LoadedHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n const host = await readHostTokenSource(firestore, hostId)\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n //\n // The component graft joins on the same argument (AGL-3287): a header or\n // footer the site owner placed is a reference the shared renderer cannot\n // resolve, so every sender behind this function mails it expanded without\n // having to know it exists. Style overrides do not survive into mail —\n // they land in `sx`, which `renderEmailHtml` never reads — but property\n // values and attribute overrides do.\n //\n // And the site's header and footer (AGL-3370), which the built-in copy is\n // drawn inside; a design the owner published goes out as they built it.\n {\n ...options,\n sanitize: sanitizeAuthorHtml,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n },\n )\n}\n\n/**\n * The site's host document as the token and chrome readers see it, or null.\n *\n * A failed read must not stop the send. Every token then resolves to its\n * empty behaviour, which is the same outcome as a site that set nothing —\n * an email with a gap where the address would be, rather than no email.\n */\nasync function readHostTokenSource(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<SiteEmailHost | null> {\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n return snapshot.exists ? (snapshot.data() as SiteEmailHost) : null\n } catch {\n return null\n }\n}\n\n/** What a site email reads off the host document: its tokens and its theme. */\ntype SiteEmailHost = HostTokenSource & {\n theme?: {\n colorSchemes?: { light?: Record<string, unknown> | null }\n shape?: { borderRadius?: unknown }\n } | null\n themeOverride?: unknown\n}\n\n/**\n * The site's theme as its email draws it (AGL-3370): the palette the\n * published site renders with, light scheme, since an inbox has one, and its\n * corner radius for buttons. A site with no theme of its own resolves the\n * same base the site itself does.\n */\nfunction siteThemeFor(host: SiteEmailHost | null): EmailTheme {\n // Resolved, not read off `theme`: a site's edits are an override on the\n // theme it picked (AGL-3404), so the stored theme alone is the design\n // before anything the site changed.\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n const radius = theme?.shape?.borderRadius\n return {\n palette: buildEmailPalette({\n base: emailPaletteBaseForHost(host),\n colors: (theme?.colorSchemes?.light ?? null) as Record<string, unknown> | null,\n }),\n ...(typeof radius === 'number' ? { buttonRadius: radius } : {}),\n }\n}\n\n/** The site's chrome for one kind of email, with that kind's footer reason. */\nfunction siteChromeFor(\n host: HostTokenSource | null,\n templateKey: string,\n): EmailChrome {\n return buildSiteEmailChrome({\n host,\n reason: getTenantEmail(templateKey)?.footerReason,\n })\n}\n\n/** A site email loaded once for a batch, with the site's tokens beside it. */\nexport interface LoadedHostEmailWithTokens {\n loaded: LoadedHostEmail\n hostMerge: Record<string, string>\n}\n\n/**\n * {@link loadHostEmail} for a batch (AGL-3370): the template, or the built-in\n * copy in the site's header and footer, read ONCE, with the site's `host.*`\n * tokens beside it for {@link renderLoadedHostEmailWithTokens}. `null` only\n * for a key with neither, so the batch keeps its own copy.\n */\nexport async function loadHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\n): Promise<LoadedHostEmailWithTokens | null> {\n const host = await readHostTokenSource(firestore, hostId)\n const loaded = await loadHostEmail(firestore as never, hostId, templateKey, {\n ...options,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n })\n return loaded ? { loaded, hostMerge: hostTokenMerge(host) } : null\n}\n\n/** One recipient's copy of a batch-loaded site email; no reads. */\nexport function renderLoadedHostEmailWithTokens(\n bundle: LoadedHostEmailWithTokens,\n merge: Record<string, string> = {},\n): RenderedHostEmail | null {\n return renderLoadedHostEmail(\n bundle.loaded,\n { ...bundle.hostMerge, ...merge },\n sanitizeAuthorHtml,\n )\n}\n\n/**\n * The site's header and footer for one kind of email, its theme, and the\n * origin and host a stored logo reference resolves against (AGL-3370): for a sender\n * that renders per recipient itself, such as a typed campaign.\n */\nexport async function siteEmailFrame(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n): Promise<{\n chrome: EmailChrome\n theme: EmailTheme\n mediaOrigin?: string\n mediaHostId: string\n}> {\n const host = await readHostTokenSource(firestore, hostId)\n const origin = host\n ? hostEmailOrigin({ cname: host.cname, subdomain: host.subdomain })\n : undefined\n return {\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n ...(origin ? { mediaOrigin: origin } : {}),\n mediaHostId: hostId,\n }\n}\n\n/**\n * A text-only site email in the site's header and footer (AGL-3370): a\n * workflow's email step, or a message a site sends in words nobody designed.\n * The text arrives resolved; the footer's reason is the kind's, by key.\n */\nexport async function renderSiteTextEmail(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n message: { subject: string; text: string; preheader?: string },\n): Promise<{ subject: string; html: string; text: string }> {\n const frame = await siteEmailFrame(firestore, hostId, templateKey)\n const rendered = renderFramedTextEmail({ ...message, ...frame })\n return { subject: message.subject, html: rendered.html, text: rendered.text }\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["resolveSiteTheme","sanitizeAuthorHtml","hostTokenMerge","composeHostComponentNodes","buildSiteEmailChrome","buildEmailPalette","emailPaletteBaseForHost","getTenantEmail","hostEmailOrigin","loadHostEmail","renderFramedTextEmail","renderHostEmail","renderLoadedHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","readHostTokenSource","sanitize","compose","chrome","siteChromeFor","theme","siteThemeFor","snapshot","collection","doc","get","exists","data","radius","shape","borderRadius","palette","base","colors","colorSchemes","light","buttonRadius","reason","footerReason","loadHostEmailWithTokens","loaded","hostMerge","renderLoadedHostEmailWithTokens","bundle","siteEmailFrame","origin","cname","subdomain","undefined","mediaOrigin","mediaHostId","renderSiteTextEmail","message","frame","rendered","subject","html","text"],"mappings":"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"}
@@ -86,7 +86,7 @@ const readHostMeta = async (db, hostIds)=>{
86
86
  ];
87
87
  }));
88
88
  };
89
- const listMembers = async (db, orgId)=>{
89
+ const readOrgMembers = async (db, orgId)=>{
90
90
  const snap = await db.collection('orgs').doc(orgId).collection('members').get();
91
91
  return snap.docs.map((d)=>_extends({
92
92
  $id: d.id
@@ -269,7 +269,7 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
269
269
  */ export async function syncHostProjectionForMembers(orgId, hostId) {
270
270
  const db = firestore();
271
271
  const [members, meta] = await Promise.all([
272
- listMembers(db, orgId),
272
+ readOrgMembers(db, orgId),
273
273
  readHostMeta(db, [
274
274
  hostId
275
275
  ])
@@ -301,7 +301,7 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
301
301
  }
302
302
  /** Delete every member's projection row for one host (host erased). */ export async function deleteHostProjectionForAllMembers(orgId, hostId) {
303
303
  const db = firestore();
304
- const members = await listMembers(db, orgId);
304
+ const members = await readOrgMembers(db, orgId);
305
305
  await commitChunked(db, members.map((member)=>(batch)=>{
306
306
  batch.delete(membershipRef(db, member.$id, hostId));
307
307
  }));
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.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 * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n /** Whether the site document was there to read. */\n exists?: boolean\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n exists: snap.exists,\n },\n ]),\n )\n}\n\nconst listMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/**\n * The keys the staff Sites list (`/admin/sites`) queries on the SITE document\n * itself: the same name key, search tokens and custom-domain flag the\n * membership rows carry, so staff find a site by its name, subdomain or\n * domain across every organization without walking anyone's projection.\n *\n * Stamped by `syncHostProjectionForMembers` — the funnel every create,\n * rename, domain attach and domain release already calls — and by\n * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before\n * it. `hasCustomDomain` is stored as `false`, never left out, for the reason\n * the membership row gives: a query cannot find a document by a field it\n * lacks.\n */\nexport const hostSearchFields = (\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n) => ({\n nameLower: nameSearchKey(meta?.displayName ?? ''),\n searchTokens: hostMembershipSearchTokens(meta),\n hasCustomDomain: hasCustomDomain(meta),\n})\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n listMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n // The staff Sites list's keys, on the site itself. `update`, never a\n // merge-set: a site erased since the read must not be minted back as a\n // phantom carrying only these three keys (the AGL-1765 shape). Outside the\n // batch so a site gone missing cannot fail its members' rows.\n if (hostMeta?.exists) {\n await db\n .collection('hosts')\n .doc(hostId)\n .update(hostSearchFields(hostMeta))\n .catch(() => undefined)\n }\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await listMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","exists","listMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","hostSearchFields","nameLower","searchTokens","isTimestamp","toMillis","membershipRow","role","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","update","catch","undefined","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.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 * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n /** Whether the site document was there to read. */\n exists?: boolean\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n exists: snap.exists,\n },\n ]),\n )\n}\n\nconst readOrgMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/**\n * The keys the staff Sites list (`/admin/sites`) queries on the SITE document\n * itself: the same name key, search tokens and custom-domain flag the\n * membership rows carry, so staff find a site by its name, subdomain or\n * domain across every organization without walking anyone's projection.\n *\n * Stamped by `syncHostProjectionForMembers` — the funnel every create,\n * rename, domain attach and domain release already calls — and by\n * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before\n * it. `hasCustomDomain` is stored as `false`, never left out, for the reason\n * the membership row gives: a query cannot find a document by a field it\n * lacks.\n */\nexport const hostSearchFields = (\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n) => ({\n nameLower: nameSearchKey(meta?.displayName ?? ''),\n searchTokens: hostMembershipSearchTokens(meta),\n hasCustomDomain: hasCustomDomain(meta),\n})\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n readOrgMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n // The staff Sites list's keys, on the site itself. `update`, never a\n // merge-set: a site erased since the read must not be minted back as a\n // phantom carrying only these three keys (the AGL-1765 shape). Outside the\n // batch so a site gone missing cannot fail its members' rows.\n if (hostMeta?.exists) {\n await db\n .collection('hosts')\n .doc(hostId)\n .update(hostSearchFields(hostMeta))\n .catch(() => undefined)\n }\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await readOrgMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","exists","readOrgMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","hostSearchFields","nameLower","searchTokens","isTimestamp","toMillis","membershipRow","role","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","update","catch","undefined","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}
@@ -32,7 +32,7 @@ export interface MemberEmailAliasRefused {
32
32
  message: string;
33
33
  }
34
34
  /** The addresses one member has added in one workspace, confirmed or not. */
35
- export declare function listMemberEmailAliases(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string): Promise<MemberEmailAlias[]>;
35
+ export declare function loadMemberEmailAliases(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string): Promise<MemberEmailAlias[]>;
36
36
  export type MemberEmailAliasAdded = {
37
37
  ok: true;
38
38
  alias: MemberEmailAlias;
@@ -84,7 +84,7 @@ function aliasesRef(firestore, orgId, uid) {
84
84
  function memberRef(firestore, orgId, uid) {
85
85
  return firestore.collection('orgs').doc(orgId).collection('members').doc(uid);
86
86
  }
87
- /** The addresses one member has added in one workspace, confirmed or not. */ export async function listMemberEmailAliases(firestore, orgId, uid) {
87
+ /** The addresses one member has added in one workspace, confirmed or not. */ export async function loadMemberEmailAliases(firestore, orgId, uid) {
88
88
  const snapshot = await aliasesRef(firestore, orgId, uid).get();
89
89
  return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null);
90
90
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/member-email-aliases.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n evaluateMemberEmailAliasAdd,\n isVerifiedMemberEmailAlias,\n MEMBER_EMAIL_ALIAS_CONFIRM_PARAM,\n MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n MEMBER_EMAIL_ALIASES_COLLECTION,\n type MemberEmailAlias,\n type MemberEmailAliasAddRefusal,\n normalizeMemberEmailAlias,\n readMemberEmailAliases,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { createHmac } from 'crypto'\nimport { tokenSigningSecret } from './media-signing'\nimport { renderSystemEmailContent, systemEmailBrand } from './render-system-email'\nimport { safeEqual } from './safe-equal'\n\n/**\n * A MEMBER'S OWN ADDRESSES (AGL-2975): the half with Firestore.\n *\n * The pure rules — what an address is, which are confirmed, whose an\n * address is — are `@aglyn/aglyn`'s `member-email-aliases.ts`. This module\n * is the store under `orgs/{orgId}/memberEmailAliases/{uid}`, the signed\n * confirmation link, and the email that carries it.\n *\n * ## Every write is here\n *\n * The rules deny the collection to every client, reads included, so\n * `verifiedAtMs` is only ever set here, by one of two writers, each running\n * behind the member's own verified session:\n * {@link confirmMemberEmailAlias}, for a member who opened the link mailed\n * to the address, and {@link confirmMemberEmailAliasesByProvider}, for a\n * member whose connected mail account lists the address as one its provider\n * has already verified they may send as. A client that could write the\n * document could mark any address it liked as its own, and the capture\n * webhook would then file a stranger's mail as the member's.\n *\n * ## The confirmation link\n *\n * `{ org, uid, address, addedAtMs, exp }`, HMAC-signed with the shared,\n * fail-closed `TOKEN_SIGNING_SECRET` under its own `member-email-alias:`\n * prefix, so it can never be replayed as a media, edit-bar or form token.\n * Nothing is stored for it: the entry's `addedAtMs` is what binds a link to\n * one request, so removing an address and adding it again retires every\n * link sent before, and a link for an address already confirmed confirms\n * nothing new.\n *\n * Confirming takes the session of the member who added the address — not\n * merely the link. Opening the mailbox proves the address receives mail;\n * it does not prove the mailbox is the member's. A prospect whose address\n * was pasted into the wrong field receives the link too, and a link that\n * worked for whoever opened it would let that prospect turn their own\n * address into the member's, after which every reply they sent would be\n * filed as the member writing to nobody.\n *\n * Firestore is a parameter, as in `crm-inbound-email.ts`, so a spec needs a\n * store and nothing else; the rate limiter and the email meter are loaded\n * only when a message is actually sent.\n */\n\n/** The version tag at the head of a token, so a later claim change is refused by shape. */\nconst TOKEN_VERSION = 'mea1'\n\n/** Domain separation from every other token the shared secret signs. */\nconst TOKEN_CONTEXT = 'member-email-alias:v1'\n\n/** Far above any real token, so an oversized value is refused unparsed. */\nconst TOKEN_MAX_CHARS = 2048\n\n/** Confirmation emails one member may cause per hour, across addresses. */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR = 10\n\n/**\n * Confirmation emails one address may receive per hour, from anybody.\n * Per member alone would let many members aim at one mailbox; per address\n * alone would let one member walk a list — the two limits the account\n * address route applies, for the same mailbomb shape.\n */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR = 3\n\n/** Every refusal this module answers with, as a stable code. */\nexport type MemberEmailAliasRefusal =\n | MemberEmailAliasAddRefusal\n | 'not-a-member'\n | 'unknown-address'\n | 'token-invalid'\n | 'token-expired'\n | 'wrong-member'\n | 'link-retired'\n\nexport interface MemberEmailAliasRefused {\n ok: false\n refusal: MemberEmailAliasRefusal\n message: string\n}\n\nconst refused = (refusal: MemberEmailAliasRefusal, message: string): MemberEmailAliasRefused => ({\n ok: false,\n refusal,\n message,\n})\n\nconst NOT_A_MEMBER = 'Only a member of this workspace can add addresses to it.'\n\nfunction aliasesRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(MEMBER_EMAIL_ALIASES_COLLECTION)\n .doc(uid)\n}\n\nfunction memberRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore.collection('orgs').doc(orgId).collection('members').doc(uid)\n}\n\n/** The addresses one member has added in one workspace, confirmed or not. */\nexport async function listMemberEmailAliases(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): Promise<MemberEmailAlias[]> {\n const snapshot = await aliasesRef(firestore, orgId, uid).get()\n return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null)\n}\n\nexport type MemberEmailAliasAdded =\n | {\n ok: true\n alias: MemberEmailAlias\n /** False when the address was already waiting for confirmation. */\n created: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Adds an address, unconfirmed, for a member of the workspace — or answers\n * the entry already waiting for it, so the caller sends another link. One\n * transaction over the roster row and the member's document, so the\n * ceiling is counted from what is stored at the moment of the write and an\n * address is never added for somebody who is no longer a member.\n */\nexport async function addMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n uid: string\n address: unknown\n /** The address the member signs in with, which is theirs already. */\n signInEmail?: string | null\n /** Domains the deployment receives mail at — the capture domain. */\n reservedDomains?: readonly string[]\n nowMs?: number\n },\n): Promise<MemberEmailAliasAdded> {\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasAdded>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const verdict = evaluateMemberEmailAliasAdd({\n address: input.address,\n signInEmail: input.signInEmail ?? null,\n aliases,\n reservedDomains: input.reservedDomains ?? [],\n })\n if (verdict.ok === false) return refused(verdict.refusal, verdict.message)\n if (verdict.existing) return { ok: true, alias: verdict.existing, created: false }\n const alias: MemberEmailAlias = { address: verdict.address, addedAtMs: nowMs }\n tx.set(ref, { uid: input.uid, aliases: [...aliases, alias], updatedAtMs: nowMs })\n return { ok: true, alias, created: true }\n })\n}\n\n/**\n * Removes one address, confirmed or not; the document goes with its last\n * address. Removal retires every link sent for the address, because a link\n * names the entry's `addedAtMs` and there is no longer an entry to match.\n */\nexport type MemberEmailAliasRemoved =\n | { ok: true; removed: MemberEmailAlias }\n | MemberEmailAliasRefused\n\nexport async function removeMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; address: unknown; nowMs?: number },\n): Promise<MemberEmailAliasRemoved> {\n const address = normalizeMemberEmailAlias(input.address)\n if (!address) return refused('invalid-address', 'Enter a valid email address.')\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasRemoved>(async (tx) => {\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const removed = aliases.find((alias) => alias.address === address)\n if (!removed) return refused('unknown-address', 'That address is not on your list.')\n const remaining = aliases.filter((alias) => alias.address !== address)\n if (remaining.length) {\n tx.set(ref, { uid: input.uid, aliases: remaining, updatedAtMs: nowMs })\n } else {\n tx.delete(ref)\n }\n return { ok: true, removed }\n })\n}\n\n/** What a confirmation link asserts. */\nexport interface MemberEmailAliasClaims {\n orgId: string\n uid: string\n address: string\n /** The `addedAtMs` of the entry the link was sent for. */\n addedAtMs: number\n /** Expiry, epoch ms. */\n exp: number\n}\n\ninterface WireClaims {\n o: string\n u: string\n a: string\n t: number\n e: number\n}\n\nconst signature = (payload: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${TOKEN_CONTEXT}:${payload}`).digest('base64url')\n\n/**\n * The token a confirmation link carries for one entry. Throws when\n * `TOKEN_SIGNING_SECRET` is not configured, like every token that secret\n * signs; the route answers that as a failure to send.\n */\nexport function mintMemberEmailAliasToken(\n claims: Omit<MemberEmailAliasClaims, 'exp'>,\n nowMs: number = Date.now(),\n): string {\n const wire: WireClaims = {\n o: claims.orgId,\n u: claims.uid,\n a: claims.address,\n t: claims.addedAtMs,\n e: nowMs + MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return `${TOKEN_VERSION}.${payload}.${signature(payload)}`\n}\n\n/**\n * The claims a token carries, or why it carries none: `token-expired` for a\n * genuine link past its day, `token-invalid` for anything else — tampered,\n * truncated, another version, or a deployment with no signing secret, whose\n * only safe reading of a link is that it proves nothing.\n */\nexport function readMemberEmailAliasToken(\n token: unknown,\n nowMs: number = Date.now(),\n): { ok: true; claims: MemberEmailAliasClaims } | MemberEmailAliasRefused {\n const invalid = refused('token-invalid', 'That confirmation link is not valid.')\n if (typeof token !== 'string' || !token || token.length > TOKEN_MAX_CHARS) return invalid\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return invalid\n const [, payload, signed] = parts\n let expected: string\n try {\n expected = signature(payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signed, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const address = normalizeMemberEmailAlias(wire?.a)\n const orgId = typeof wire?.o === 'string' ? wire.o : ''\n const uid = typeof wire?.u === 'string' ? wire.u : ''\n const addedAtMs = Number(wire?.t)\n const exp = Number(wire?.e)\n if (!orgId || !uid || !address || !Number.isFinite(addedAtMs) || !Number.isFinite(exp)) {\n return invalid\n }\n if (exp <= nowMs) {\n return refused('token-expired', 'That confirmation link has expired. Send a new one from the list.')\n }\n return { ok: true, claims: { orgId, uid, address, addedAtMs, exp } }\n}\n\nexport type MemberEmailAliasConfirmed =\n | {\n ok: true\n orgId: string\n address: string\n /** True when the address had been confirmed before this call. */\n alreadyConfirmed: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms the address a link was sent for, for the member signed in — who\n * must be the member who added it (see the module comment). A link for an\n * entry that has since been removed or added again is refused as retired,\n * and opening a link twice is not an error.\n */\nexport async function confirmMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { token: unknown; callerUid: string; nowMs?: number },\n): Promise<MemberEmailAliasConfirmed> {\n const nowMs = input.nowMs ?? Date.now()\n const read = readMemberEmailAliasToken(input.token, nowMs)\n if (read.ok === false) return read\n const { claims } = read\n if (!input.callerUid || claims.uid !== input.callerUid) {\n return refused(\n 'wrong-member',\n 'This link confirms an address for another member. Sign in as the member who added it, then open the link again.',\n )\n }\n const ref = aliasesRef(firestore, claims.orgId, claims.uid)\n const roster = memberRef(firestore, claims.orgId, claims.uid)\n return firestore.runTransaction<MemberEmailAliasConfirmed>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', 'You are no longer a member of that workspace.')\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const entry = aliases.find((alias) => alias.address === claims.address)\n if (!entry || entry.addedAtMs !== claims.addedAtMs) {\n return refused(\n 'link-retired',\n 'This link is for an address that has since been removed or added again. Send a new link from the list.',\n )\n }\n if (isVerifiedMemberEmailAlias(entry)) {\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: true }\n }\n const next = aliases.map((alias) =>\n alias.address === entry.address ? { ...alias, verifiedAtMs: nowMs } : alias,\n )\n tx.set(ref, { uid: claims.uid, aliases: next, updatedAtMs: nowMs })\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: false }\n })\n}\n\nexport type MemberEmailAliasesConfirmedByProvider =\n | {\n ok: true\n /** The addresses this call confirmed, normalized, in list order. */\n confirmed: string[]\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms a member's PENDING addresses that their connected mail account\n * already proves are theirs (AGL-2978).\n *\n * The confirmation link proves an address receives mail and asks the member\n * to open it. A provider's verified send-as list proves more: the provider\n * confirmed the account may send as the address, and the member proved they\n * hold that account by connecting it under their own session. So an address\n * the member added and has not yet confirmed counts as confirmed once it\n * appears in that list.\n *\n * Only addresses the member ALREADY added are touched — this never adds one,\n * because an address the member did not ask for is not a claim they made —\n * and an address already confirmed keeps its original `verifiedAtMs`. The\n * caller passes only the provider's VERIFIED addresses; which of a\n * provider's listings count as verified is the caller's rule, not this\n * store's. Nothing is written when nothing changes.\n */\nexport async function confirmMemberEmailAliasesByProvider(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; addresses: readonly unknown[]; nowMs?: number },\n): Promise<MemberEmailAliasesConfirmedByProvider> {\n const nowMs = input.nowMs ?? Date.now()\n const proven = new Set(\n input.addresses\n .map((address) => normalizeMemberEmailAlias(address))\n .filter((address): address is string => Boolean(address)),\n )\n if (!input.orgId || !input.uid) return refused('not-a-member', NOT_A_MEMBER)\n if (!proven.size) return { ok: true, confirmed: [] }\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasesConfirmedByProvider>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const confirmed: string[] = []\n const next = aliases.map((alias) => {\n if (isVerifiedMemberEmailAlias(alias) || !proven.has(alias.address)) return alias\n confirmed.push(alias.address)\n return { ...alias, verifiedAtMs: nowMs }\n })\n if (confirmed.length) {\n tx.set(ref, { uid: input.uid, aliases: next, updatedAtMs: nowMs })\n }\n return { ok: true, confirmed }\n })\n}\n\n/**\n * A console path a link may land on: absolute, on the console's own\n * origin, with no query or fragment of its own. Anything else lands on\n * `/`, which still signs the member in.\n */\nconst RETURN_PATH = /^\\/(?![/\\\\])[^\\s?#\\\\]{0,511}$/\n\n/**\n * The link a confirmation email carries: the console origin the caller\n * resolved from server configuration — never from a request header — the\n * page the member added the address on, and the token.\n */\nexport function memberEmailAliasConfirmUrl(input: {\n origin: string\n returnPath?: unknown\n token: string\n}): string {\n const origin = String(input.origin ?? '').replace(/\\/+$/, '')\n const raw = typeof input.returnPath === 'string' ? input.returnPath : ''\n const path = RETURN_PATH.test(raw) ? raw : '/'\n return `${origin}${path}?${MEMBER_EMAIL_ALIAS_CONFIRM_PARAM}=${encodeURIComponent(input.token)}`\n}\n\nexport type MemberEmailAliasConfirmationSent =\n | { sent: true }\n | { sent: false; status: 429 | 501 | 502; error: string }\n\n/**\n * Emails the confirmation link to the address, in the workspace's brand.\n *\n * Rate-limited per member and per destination address before anything is\n * sent (see the two ceilings above), and metered to the workspace, whose\n * member asked for it.\n */\nexport async function sendMemberEmailAliasConfirmation(input: {\n orgId: string\n /** The org document, for its name and its brand. */\n org: Record<string, unknown> | null\n uid: string\n /** The member's display name, when the roster row carries one. */\n memberName?: string | null\n address: string\n confirmUrl: string\n}): Promise<MemberEmailAliasConfirmationSent> {\n if (!isEmailConfigured()) {\n return { sent: false, status: 501, error: 'Email is not configured on this deployment.' }\n }\n const { consumeRateLimit } = await import('./rate-limit-store')\n const perMember = await consumeRateLimit(`member-email-alias-confirm:${input.uid}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perMember.allowed) {\n return { sent: false, status: 429, error: 'Too many confirmation emails. Try again in an hour.' }\n }\n const perAddress = await consumeRateLimit(`member-email-alias-target:${input.address}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perAddress.allowed) {\n return {\n sent: false,\n status: 429,\n error: 'Too many confirmation emails for that address. Try again later.',\n }\n }\n\n const branding = resolveBrandingProfile((input.org ?? null) as never)\n const workspace = String(input.org?.['name'] ?? '').trim() || 'your workspace'\n const member = String(input.memberName ?? '').trim()\n const text = [\n `${member || 'A member'} asked to add ${input.address} as one of their own email ` +\n `addresses in the ${workspace} workspace on ${branding.productName}.`,\n '',\n `To confirm it, open this link while signed in as ${member || 'that member'}:`,\n '',\n input.confirmUrl,\n '',\n `Once it is confirmed, email sent from ${input.address} with the workspace's capture ` +\n 'address in BCC is filed under the person it was written to. The link works for 24 hours.',\n '',\n 'If you did not expect this email, ignore it. Nothing changes unless the member who asked opens the link.',\n ].join('\\n')\n // The `member-email-alias-confirmation` system email (AGL-3367), in the\n // workspace's brand; `text` above is its last resort.\n const content = await renderSystemEmailContent(\n 'member-email-alias-confirmation',\n {\n 'member.name': member || 'A member',\n address: input.address,\n 'org.name': workspace,\n confirmUrl: input.confirmUrl,\n },\n systemEmailBrand((input.org ?? null) as Record<string, unknown> | null),\n { subject: `Confirm your address for ${workspace}`, text },\n )\n const result = await sendEmail({\n to: input.address,\n ...content,\n fromName: branding.fromName,\n context: 'member-email-alias-confirmation',\n })\n if (!result.sent) {\n return { sent: false, status: 502, error: 'The confirmation email could not be sent.' }\n }\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n return { sent: true }\n}\n"],"names":["evaluateMemberEmailAliasAdd","isVerifiedMemberEmailAlias","MEMBER_EMAIL_ALIAS_CONFIRM_PARAM","MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS","MEMBER_EMAIL_ALIASES_COLLECTION","normalizeMemberEmailAlias","readMemberEmailAliases","resolveBrandingProfile","isEmailConfigured","sendEmail","createHmac","tokenSigningSecret","renderSystemEmailContent","systemEmailBrand","safeEqual","TOKEN_VERSION","TOKEN_CONTEXT","TOKEN_MAX_CHARS","MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR","MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR","refused","refusal","message","ok","NOT_A_MEMBER","aliasesRef","firestore","orgId","uid","collection","doc","memberRef","listMemberEmailAliases","snapshot","get","exists","data","addMemberEmailAlias","input","nowMs","Date","now","ref","roster","runTransaction","tx","member","stored","aliases","verdict","address","signInEmail","reservedDomains","existing","alias","created","addedAtMs","set","updatedAtMs","removeMemberEmailAlias","removed","find","remaining","filter","length","delete","signature","payload","update","digest","mintMemberEmailAliasToken","claims","wire","o","u","a","t","e","Buffer","from","JSON","stringify","toString","readMemberEmailAliasToken","token","invalid","parts","split","signed","expected","parse","Number","exp","isFinite","confirmMemberEmailAlias","read","callerUid","entry","alreadyConfirmed","next","map","verifiedAtMs","confirmMemberEmailAliasesByProvider","proven","Set","addresses","Boolean","size","confirmed","has","push","RETURN_PATH","memberEmailAliasConfirmUrl","origin","String","replace","raw","returnPath","path","test","encodeURIComponent","sendMemberEmailAliasConfirmation","sent","status","error","consumeRateLimit","perMember","limit","windowMs","allowed","perAddress","branding","org","workspace","trim","memberName","text","productName","confirmUrl","join","content","subject","result","to","fromName","context","meterOrgEmail","catch","undefined"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/member-email-aliases.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n evaluateMemberEmailAliasAdd,\n isVerifiedMemberEmailAlias,\n MEMBER_EMAIL_ALIAS_CONFIRM_PARAM,\n MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n MEMBER_EMAIL_ALIASES_COLLECTION,\n type MemberEmailAlias,\n type MemberEmailAliasAddRefusal,\n normalizeMemberEmailAlias,\n readMemberEmailAliases,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { createHmac } from 'crypto'\nimport { tokenSigningSecret } from './media-signing'\nimport { renderSystemEmailContent, systemEmailBrand } from './render-system-email'\nimport { safeEqual } from './safe-equal'\n\n/**\n * A MEMBER'S OWN ADDRESSES (AGL-2975): the half with Firestore.\n *\n * The pure rules — what an address is, which are confirmed, whose an\n * address is — are `@aglyn/aglyn`'s `member-email-aliases.ts`. This module\n * is the store under `orgs/{orgId}/memberEmailAliases/{uid}`, the signed\n * confirmation link, and the email that carries it.\n *\n * ## Every write is here\n *\n * The rules deny the collection to every client, reads included, so\n * `verifiedAtMs` is only ever set here, by one of two writers, each running\n * behind the member's own verified session:\n * {@link confirmMemberEmailAlias}, for a member who opened the link mailed\n * to the address, and {@link confirmMemberEmailAliasesByProvider}, for a\n * member whose connected mail account lists the address as one its provider\n * has already verified they may send as. A client that could write the\n * document could mark any address it liked as its own, and the capture\n * webhook would then file a stranger's mail as the member's.\n *\n * ## The confirmation link\n *\n * `{ org, uid, address, addedAtMs, exp }`, HMAC-signed with the shared,\n * fail-closed `TOKEN_SIGNING_SECRET` under its own `member-email-alias:`\n * prefix, so it can never be replayed as a media, edit-bar or form token.\n * Nothing is stored for it: the entry's `addedAtMs` is what binds a link to\n * one request, so removing an address and adding it again retires every\n * link sent before, and a link for an address already confirmed confirms\n * nothing new.\n *\n * Confirming takes the session of the member who added the address — not\n * merely the link. Opening the mailbox proves the address receives mail;\n * it does not prove the mailbox is the member's. A prospect whose address\n * was pasted into the wrong field receives the link too, and a link that\n * worked for whoever opened it would let that prospect turn their own\n * address into the member's, after which every reply they sent would be\n * filed as the member writing to nobody.\n *\n * Firestore is a parameter, as in `crm-inbound-email.ts`, so a spec needs a\n * store and nothing else; the rate limiter and the email meter are loaded\n * only when a message is actually sent.\n */\n\n/** The version tag at the head of a token, so a later claim change is refused by shape. */\nconst TOKEN_VERSION = 'mea1'\n\n/** Domain separation from every other token the shared secret signs. */\nconst TOKEN_CONTEXT = 'member-email-alias:v1'\n\n/** Far above any real token, so an oversized value is refused unparsed. */\nconst TOKEN_MAX_CHARS = 2048\n\n/** Confirmation emails one member may cause per hour, across addresses. */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR = 10\n\n/**\n * Confirmation emails one address may receive per hour, from anybody.\n * Per member alone would let many members aim at one mailbox; per address\n * alone would let one member walk a list — the two limits the account\n * address route applies, for the same mailbomb shape.\n */\nexport const MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR = 3\n\n/** Every refusal this module answers with, as a stable code. */\nexport type MemberEmailAliasRefusal =\n | MemberEmailAliasAddRefusal\n | 'not-a-member'\n | 'unknown-address'\n | 'token-invalid'\n | 'token-expired'\n | 'wrong-member'\n | 'link-retired'\n\nexport interface MemberEmailAliasRefused {\n ok: false\n refusal: MemberEmailAliasRefusal\n message: string\n}\n\nconst refused = (refusal: MemberEmailAliasRefusal, message: string): MemberEmailAliasRefused => ({\n ok: false,\n refusal,\n message,\n})\n\nconst NOT_A_MEMBER = 'Only a member of this workspace can add addresses to it.'\n\nfunction aliasesRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(MEMBER_EMAIL_ALIASES_COLLECTION)\n .doc(uid)\n}\n\nfunction memberRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string) {\n return firestore.collection('orgs').doc(orgId).collection('members').doc(uid)\n}\n\n/** The addresses one member has added in one workspace, confirmed or not. */\nexport async function loadMemberEmailAliases(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): Promise<MemberEmailAlias[]> {\n const snapshot = await aliasesRef(firestore, orgId, uid).get()\n return readMemberEmailAliases(snapshot.exists ? snapshot.data() : null)\n}\n\nexport type MemberEmailAliasAdded =\n | {\n ok: true\n alias: MemberEmailAlias\n /** False when the address was already waiting for confirmation. */\n created: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Adds an address, unconfirmed, for a member of the workspace — or answers\n * the entry already waiting for it, so the caller sends another link. One\n * transaction over the roster row and the member's document, so the\n * ceiling is counted from what is stored at the moment of the write and an\n * address is never added for somebody who is no longer a member.\n */\nexport async function addMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n uid: string\n address: unknown\n /** The address the member signs in with, which is theirs already. */\n signInEmail?: string | null\n /** Domains the deployment receives mail at — the capture domain. */\n reservedDomains?: readonly string[]\n nowMs?: number\n },\n): Promise<MemberEmailAliasAdded> {\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasAdded>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const verdict = evaluateMemberEmailAliasAdd({\n address: input.address,\n signInEmail: input.signInEmail ?? null,\n aliases,\n reservedDomains: input.reservedDomains ?? [],\n })\n if (verdict.ok === false) return refused(verdict.refusal, verdict.message)\n if (verdict.existing) return { ok: true, alias: verdict.existing, created: false }\n const alias: MemberEmailAlias = { address: verdict.address, addedAtMs: nowMs }\n tx.set(ref, { uid: input.uid, aliases: [...aliases, alias], updatedAtMs: nowMs })\n return { ok: true, alias, created: true }\n })\n}\n\n/**\n * Removes one address, confirmed or not; the document goes with its last\n * address. Removal retires every link sent for the address, because a link\n * names the entry's `addedAtMs` and there is no longer an entry to match.\n */\nexport type MemberEmailAliasRemoved =\n | { ok: true; removed: MemberEmailAlias }\n | MemberEmailAliasRefused\n\nexport async function removeMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; address: unknown; nowMs?: number },\n): Promise<MemberEmailAliasRemoved> {\n const address = normalizeMemberEmailAlias(input.address)\n if (!address) return refused('invalid-address', 'Enter a valid email address.')\n const nowMs = input.nowMs ?? Date.now()\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasRemoved>(async (tx) => {\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const removed = aliases.find((alias) => alias.address === address)\n if (!removed) return refused('unknown-address', 'That address is not on your list.')\n const remaining = aliases.filter((alias) => alias.address !== address)\n if (remaining.length) {\n tx.set(ref, { uid: input.uid, aliases: remaining, updatedAtMs: nowMs })\n } else {\n tx.delete(ref)\n }\n return { ok: true, removed }\n })\n}\n\n/** What a confirmation link asserts. */\nexport interface MemberEmailAliasClaims {\n orgId: string\n uid: string\n address: string\n /** The `addedAtMs` of the entry the link was sent for. */\n addedAtMs: number\n /** Expiry, epoch ms. */\n exp: number\n}\n\ninterface WireClaims {\n o: string\n u: string\n a: string\n t: number\n e: number\n}\n\nconst signature = (payload: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${TOKEN_CONTEXT}:${payload}`).digest('base64url')\n\n/**\n * The token a confirmation link carries for one entry. Throws when\n * `TOKEN_SIGNING_SECRET` is not configured, like every token that secret\n * signs; the route answers that as a failure to send.\n */\nexport function mintMemberEmailAliasToken(\n claims: Omit<MemberEmailAliasClaims, 'exp'>,\n nowMs: number = Date.now(),\n): string {\n const wire: WireClaims = {\n o: claims.orgId,\n u: claims.uid,\n a: claims.address,\n t: claims.addedAtMs,\n e: nowMs + MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS,\n }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return `${TOKEN_VERSION}.${payload}.${signature(payload)}`\n}\n\n/**\n * The claims a token carries, or why it carries none: `token-expired` for a\n * genuine link past its day, `token-invalid` for anything else — tampered,\n * truncated, another version, or a deployment with no signing secret, whose\n * only safe reading of a link is that it proves nothing.\n */\nexport function readMemberEmailAliasToken(\n token: unknown,\n nowMs: number = Date.now(),\n): { ok: true; claims: MemberEmailAliasClaims } | MemberEmailAliasRefused {\n const invalid = refused('token-invalid', 'That confirmation link is not valid.')\n if (typeof token !== 'string' || !token || token.length > TOKEN_MAX_CHARS) return invalid\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) return invalid\n const [, payload, signed] = parts\n let expected: string\n try {\n expected = signature(payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signed, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const address = normalizeMemberEmailAlias(wire?.a)\n const orgId = typeof wire?.o === 'string' ? wire.o : ''\n const uid = typeof wire?.u === 'string' ? wire.u : ''\n const addedAtMs = Number(wire?.t)\n const exp = Number(wire?.e)\n if (!orgId || !uid || !address || !Number.isFinite(addedAtMs) || !Number.isFinite(exp)) {\n return invalid\n }\n if (exp <= nowMs) {\n return refused('token-expired', 'That confirmation link has expired. Send a new one from the list.')\n }\n return { ok: true, claims: { orgId, uid, address, addedAtMs, exp } }\n}\n\nexport type MemberEmailAliasConfirmed =\n | {\n ok: true\n orgId: string\n address: string\n /** True when the address had been confirmed before this call. */\n alreadyConfirmed: boolean\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms the address a link was sent for, for the member signed in — who\n * must be the member who added it (see the module comment). A link for an\n * entry that has since been removed or added again is refused as retired,\n * and opening a link twice is not an error.\n */\nexport async function confirmMemberEmailAlias(\n firestore: FirebaseFirestore.Firestore,\n input: { token: unknown; callerUid: string; nowMs?: number },\n): Promise<MemberEmailAliasConfirmed> {\n const nowMs = input.nowMs ?? Date.now()\n const read = readMemberEmailAliasToken(input.token, nowMs)\n if (read.ok === false) return read\n const { claims } = read\n if (!input.callerUid || claims.uid !== input.callerUid) {\n return refused(\n 'wrong-member',\n 'This link confirms an address for another member. Sign in as the member who added it, then open the link again.',\n )\n }\n const ref = aliasesRef(firestore, claims.orgId, claims.uid)\n const roster = memberRef(firestore, claims.orgId, claims.uid)\n return firestore.runTransaction<MemberEmailAliasConfirmed>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', 'You are no longer a member of that workspace.')\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const entry = aliases.find((alias) => alias.address === claims.address)\n if (!entry || entry.addedAtMs !== claims.addedAtMs) {\n return refused(\n 'link-retired',\n 'This link is for an address that has since been removed or added again. Send a new link from the list.',\n )\n }\n if (isVerifiedMemberEmailAlias(entry)) {\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: true }\n }\n const next = aliases.map((alias) =>\n alias.address === entry.address ? { ...alias, verifiedAtMs: nowMs } : alias,\n )\n tx.set(ref, { uid: claims.uid, aliases: next, updatedAtMs: nowMs })\n return { ok: true, orgId: claims.orgId, address: entry.address, alreadyConfirmed: false }\n })\n}\n\nexport type MemberEmailAliasesConfirmedByProvider =\n | {\n ok: true\n /** The addresses this call confirmed, normalized, in list order. */\n confirmed: string[]\n }\n | MemberEmailAliasRefused\n\n/**\n * Confirms a member's PENDING addresses that their connected mail account\n * already proves are theirs (AGL-2978).\n *\n * The confirmation link proves an address receives mail and asks the member\n * to open it. A provider's verified send-as list proves more: the provider\n * confirmed the account may send as the address, and the member proved they\n * hold that account by connecting it under their own session. So an address\n * the member added and has not yet confirmed counts as confirmed once it\n * appears in that list.\n *\n * Only addresses the member ALREADY added are touched — this never adds one,\n * because an address the member did not ask for is not a claim they made —\n * and an address already confirmed keeps its original `verifiedAtMs`. The\n * caller passes only the provider's VERIFIED addresses; which of a\n * provider's listings count as verified is the caller's rule, not this\n * store's. Nothing is written when nothing changes.\n */\nexport async function confirmMemberEmailAliasesByProvider(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; uid: string; addresses: readonly unknown[]; nowMs?: number },\n): Promise<MemberEmailAliasesConfirmedByProvider> {\n const nowMs = input.nowMs ?? Date.now()\n const proven = new Set(\n input.addresses\n .map((address) => normalizeMemberEmailAlias(address))\n .filter((address): address is string => Boolean(address)),\n )\n if (!input.orgId || !input.uid) return refused('not-a-member', NOT_A_MEMBER)\n if (!proven.size) return { ok: true, confirmed: [] }\n const ref = aliasesRef(firestore, input.orgId, input.uid)\n const roster = memberRef(firestore, input.orgId, input.uid)\n return firestore.runTransaction<MemberEmailAliasesConfirmedByProvider>(async (tx) => {\n const member = await tx.get(roster)\n if (!member.exists) return refused('not-a-member', NOT_A_MEMBER)\n const stored = await tx.get(ref)\n const aliases = readMemberEmailAliases(stored.exists ? stored.data() : null)\n const confirmed: string[] = []\n const next = aliases.map((alias) => {\n if (isVerifiedMemberEmailAlias(alias) || !proven.has(alias.address)) return alias\n confirmed.push(alias.address)\n return { ...alias, verifiedAtMs: nowMs }\n })\n if (confirmed.length) {\n tx.set(ref, { uid: input.uid, aliases: next, updatedAtMs: nowMs })\n }\n return { ok: true, confirmed }\n })\n}\n\n/**\n * A console path a link may land on: absolute, on the console's own\n * origin, with no query or fragment of its own. Anything else lands on\n * `/`, which still signs the member in.\n */\nconst RETURN_PATH = /^\\/(?![/\\\\])[^\\s?#\\\\]{0,511}$/\n\n/**\n * The link a confirmation email carries: the console origin the caller\n * resolved from server configuration — never from a request header — the\n * page the member added the address on, and the token.\n */\nexport function memberEmailAliasConfirmUrl(input: {\n origin: string\n returnPath?: unknown\n token: string\n}): string {\n const origin = String(input.origin ?? '').replace(/\\/+$/, '')\n const raw = typeof input.returnPath === 'string' ? input.returnPath : ''\n const path = RETURN_PATH.test(raw) ? raw : '/'\n return `${origin}${path}?${MEMBER_EMAIL_ALIAS_CONFIRM_PARAM}=${encodeURIComponent(input.token)}`\n}\n\nexport type MemberEmailAliasConfirmationSent =\n | { sent: true }\n | { sent: false; status: 429 | 501 | 502; error: string }\n\n/**\n * Emails the confirmation link to the address, in the workspace's brand.\n *\n * Rate-limited per member and per destination address before anything is\n * sent (see the two ceilings above), and metered to the workspace, whose\n * member asked for it.\n */\nexport async function sendMemberEmailAliasConfirmation(input: {\n orgId: string\n /** The org document, for its name and its brand. */\n org: Record<string, unknown> | null\n uid: string\n /** The member's display name, when the roster row carries one. */\n memberName?: string | null\n address: string\n confirmUrl: string\n}): Promise<MemberEmailAliasConfirmationSent> {\n if (!isEmailConfigured()) {\n return { sent: false, status: 501, error: 'Email is not configured on this deployment.' }\n }\n const { consumeRateLimit } = await import('./rate-limit-store')\n const perMember = await consumeRateLimit(`member-email-alias-confirm:${input.uid}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perMember.allowed) {\n return { sent: false, status: 429, error: 'Too many confirmation emails. Try again in an hour.' }\n }\n const perAddress = await consumeRateLimit(`member-email-alias-target:${input.address}`, {\n limit: MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!perAddress.allowed) {\n return {\n sent: false,\n status: 429,\n error: 'Too many confirmation emails for that address. Try again later.',\n }\n }\n\n const branding = resolveBrandingProfile((input.org ?? null) as never)\n const workspace = String(input.org?.['name'] ?? '').trim() || 'your workspace'\n const member = String(input.memberName ?? '').trim()\n const text = [\n `${member || 'A member'} asked to add ${input.address} as one of their own email ` +\n `addresses in the ${workspace} workspace on ${branding.productName}.`,\n '',\n `To confirm it, open this link while signed in as ${member || 'that member'}:`,\n '',\n input.confirmUrl,\n '',\n `Once it is confirmed, email sent from ${input.address} with the workspace's capture ` +\n 'address in BCC is filed under the person it was written to. The link works for 24 hours.',\n '',\n 'If you did not expect this email, ignore it. Nothing changes unless the member who asked opens the link.',\n ].join('\\n')\n // The `member-email-alias-confirmation` system email (AGL-3367), in the\n // workspace's brand; `text` above is its last resort.\n const content = await renderSystemEmailContent(\n 'member-email-alias-confirmation',\n {\n 'member.name': member || 'A member',\n address: input.address,\n 'org.name': workspace,\n confirmUrl: input.confirmUrl,\n },\n systemEmailBrand((input.org ?? null) as Record<string, unknown> | null),\n { subject: `Confirm your address for ${workspace}`, text },\n )\n const result = await sendEmail({\n to: input.address,\n ...content,\n fromName: branding.fromName,\n context: 'member-email-alias-confirmation',\n })\n if (!result.sent) {\n return { sent: false, status: 502, error: 'The confirmation email could not be sent.' }\n }\n const { meterOrgEmail } = await import('./email-metering')\n await meterOrgEmail(input.orgId).catch(() => undefined)\n return { sent: true }\n}\n"],"names":["evaluateMemberEmailAliasAdd","isVerifiedMemberEmailAlias","MEMBER_EMAIL_ALIAS_CONFIRM_PARAM","MEMBER_EMAIL_ALIAS_CONFIRM_TTL_MS","MEMBER_EMAIL_ALIASES_COLLECTION","normalizeMemberEmailAlias","readMemberEmailAliases","resolveBrandingProfile","isEmailConfigured","sendEmail","createHmac","tokenSigningSecret","renderSystemEmailContent","systemEmailBrand","safeEqual","TOKEN_VERSION","TOKEN_CONTEXT","TOKEN_MAX_CHARS","MEMBER_EMAIL_ALIAS_SENDS_PER_MEMBER_PER_HOUR","MEMBER_EMAIL_ALIAS_SENDS_PER_ADDRESS_PER_HOUR","refused","refusal","message","ok","NOT_A_MEMBER","aliasesRef","firestore","orgId","uid","collection","doc","memberRef","loadMemberEmailAliases","snapshot","get","exists","data","addMemberEmailAlias","input","nowMs","Date","now","ref","roster","runTransaction","tx","member","stored","aliases","verdict","address","signInEmail","reservedDomains","existing","alias","created","addedAtMs","set","updatedAtMs","removeMemberEmailAlias","removed","find","remaining","filter","length","delete","signature","payload","update","digest","mintMemberEmailAliasToken","claims","wire","o","u","a","t","e","Buffer","from","JSON","stringify","toString","readMemberEmailAliasToken","token","invalid","parts","split","signed","expected","parse","Number","exp","isFinite","confirmMemberEmailAlias","read","callerUid","entry","alreadyConfirmed","next","map","verifiedAtMs","confirmMemberEmailAliasesByProvider","proven","Set","addresses","Boolean","size","confirmed","has","push","RETURN_PATH","memberEmailAliasConfirmUrl","origin","String","replace","raw","returnPath","path","test","encodeURIComponent","sendMemberEmailAliasConfirmation","sent","status","error","consumeRateLimit","perMember","limit","windowMs","allowed","perAddress","branding","org","workspace","trim","memberName","text","productName","confirmUrl","join","content","subject","result","to","fromName","context","meterOrgEmail","catch","undefined"],"mappings":"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"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * The one "New account" staff notice per account (AGL-3225).
19
+ *
20
+ * Whether an account is NEW is read from its auth record's creation time,
21
+ * never from whether `users/{uid}` happens to exist yet. That document has
22
+ * several writers that can reach it first — the sign-up page's own writes
23
+ * (profile, plan intent, campaign, consent), the acquisition record, the
24
+ * /signin consent bounce's acquisition record, the SSO JIT seed — so "the
25
+ * session mint's seed created it" was true only for whichever sign-up won a
26
+ * race, and never for a password account, whose first mint waits on email
27
+ * verification while the page writes the document on the spot.
28
+ *
29
+ * Exactly once is the marker's job: `accountAnnouncements/{uid}` is created
30
+ * with `create()`, which Firestore refuses when the document exists, so two
31
+ * mints racing each other — a second tab, a retry — cannot both win. It is a
32
+ * collection of its own rather than a field on `users/{uid}` because the
33
+ * owner may delete that document, and a marker its subject can erase is a
34
+ * notice its subject can replay. No rule matches the collection, so every
35
+ * client is denied it and the Admin SDK is the only writer.
36
+ */
37
+ /** The server-only collection that remembers which accounts were announced. */
38
+ export declare const NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION = "accountAnnouncements";
39
+ /**
40
+ * How long after its creation an account's first session may still announce
41
+ * it. A password account mints its first session only once the address is
42
+ * verified, and a Google account bounced from /signin for consent only when
43
+ * the person comes back to /signup — both can be days. Seven matches how long
44
+ * the sign-up workspace hold waits for the same first session. Every account
45
+ * older than this predates the marker, so without the window each one's next
46
+ * sign-in would be announced as new.
47
+ */
48
+ export declare const NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS: number;
49
+ export interface ClaimNewAccountAnnouncementInput {
50
+ uid: string;
51
+ /** The auth record's creation time; null or unparseable claims nothing. */
52
+ accountCreatedAtMs: number | null;
53
+ nowMs?: number;
54
+ firestore?: FirebaseFirestore.Firestore;
55
+ }
56
+ /**
57
+ * True exactly once per account, and only while the account is new: the
58
+ * caller that gets `true` sends the notice. Throws when the marker cannot be
59
+ * written for any reason but "already announced", so a caller's catch — not a
60
+ * silent `false` — is what a Firestore outage reaches.
61
+ */
62
+ export declare function claimNewAccountAnnouncement(input: ClaimNewAccountAnnouncementInput): Promise<boolean>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { FieldValue } from "firebase-admin/firestore";
17
+ import firebaseAdmin from "./firebase-admin.js";
18
+ /**
19
+ * The one "New account" staff notice per account (AGL-3225).
20
+ *
21
+ * Whether an account is NEW is read from its auth record's creation time,
22
+ * never from whether `users/{uid}` happens to exist yet. That document has
23
+ * several writers that can reach it first — the sign-up page's own writes
24
+ * (profile, plan intent, campaign, consent), the acquisition record, the
25
+ * /signin consent bounce's acquisition record, the SSO JIT seed — so "the
26
+ * session mint's seed created it" was true only for whichever sign-up won a
27
+ * race, and never for a password account, whose first mint waits on email
28
+ * verification while the page writes the document on the spot.
29
+ *
30
+ * Exactly once is the marker's job: `accountAnnouncements/{uid}` is created
31
+ * with `create()`, which Firestore refuses when the document exists, so two
32
+ * mints racing each other — a second tab, a retry — cannot both win. It is a
33
+ * collection of its own rather than a field on `users/{uid}` because the
34
+ * owner may delete that document, and a marker its subject can erase is a
35
+ * notice its subject can replay. No rule matches the collection, so every
36
+ * client is denied it and the Admin SDK is the only writer.
37
+ */ /** The server-only collection that remembers which accounts were announced. */ export const NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION = 'accountAnnouncements';
38
+ /**
39
+ * How long after its creation an account's first session may still announce
40
+ * it. A password account mints its first session only once the address is
41
+ * verified, and a Google account bounced from /signin for consent only when
42
+ * the person comes back to /signup — both can be days. Seven matches how long
43
+ * the sign-up workspace hold waits for the same first session. Every account
44
+ * older than this predates the marker, so without the window each one's next
45
+ * sign-in would be announced as new.
46
+ */ export const NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
47
+ /**
48
+ * True exactly once per account, and only while the account is new: the
49
+ * caller that gets `true` sends the notice. Throws when the marker cannot be
50
+ * written for any reason but "already announced", so a caller's catch — not a
51
+ * silent `false` — is what a Firestore outage reaches.
52
+ */ export async function claimNewAccountAnnouncement(input) {
53
+ var _input_nowMs, _input_firestore;
54
+ const createdAtMs = input.accountCreatedAtMs;
55
+ if (!input.uid || createdAtMs === null || !Number.isFinite(createdAtMs)) {
56
+ return false;
57
+ }
58
+ const nowMs = (_input_nowMs = input.nowMs) != null ? _input_nowMs : Date.now();
59
+ if (nowMs - createdAtMs > NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS) return false;
60
+ const firestore = (_input_firestore = input.firestore) != null ? _input_firestore : firebaseAdmin.app().firestore();
61
+ try {
62
+ await firestore.collection(NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION).doc(input.uid).create({
63
+ announcedAt: FieldValue.serverTimestamp(),
64
+ accountCreatedAtMs: createdAtMs
65
+ });
66
+ return true;
67
+ } catch (error) {
68
+ if (isAlreadyExists(error)) return false;
69
+ throw error;
70
+ }
71
+ }
72
+ /** Firestore's ALREADY_EXISTS, as the Admin SDK reports it (gRPC code 6). */ function isAlreadyExists(error) {
73
+ const code = error == null ? void 0 : error.code;
74
+ return code === 6 || code === 'already-exists' || code === 'ALREADY_EXISTS';
75
+ }
76
+
77
+ //# sourceMappingURL=new-account-announcement.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/new-account-announcement.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * The one \"New account\" staff notice per account (AGL-3225).\n *\n * Whether an account is NEW is read from its auth record's creation time,\n * never from whether `users/{uid}` happens to exist yet. That document has\n * several writers that can reach it first — the sign-up page's own writes\n * (profile, plan intent, campaign, consent), the acquisition record, the\n * /signin consent bounce's acquisition record, the SSO JIT seed — so \"the\n * session mint's seed created it\" was true only for whichever sign-up won a\n * race, and never for a password account, whose first mint waits on email\n * verification while the page writes the document on the spot.\n *\n * Exactly once is the marker's job: `accountAnnouncements/{uid}` is created\n * with `create()`, which Firestore refuses when the document exists, so two\n * mints racing each other — a second tab, a retry — cannot both win. It is a\n * collection of its own rather than a field on `users/{uid}` because the\n * owner may delete that document, and a marker its subject can erase is a\n * notice its subject can replay. No rule matches the collection, so every\n * client is denied it and the Admin SDK is the only writer.\n */\n\n/** The server-only collection that remembers which accounts were announced. */\nexport const NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION = 'accountAnnouncements'\n\n/**\n * How long after its creation an account's first session may still announce\n * it. A password account mints its first session only once the address is\n * verified, and a Google account bounced from /signin for consent only when\n * the person comes back to /signup — both can be days. Seven matches how long\n * the sign-up workspace hold waits for the same first session. Every account\n * older than this predates the marker, so without the window each one's next\n * sign-in would be announced as new.\n */\nexport const NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000\n\nexport interface ClaimNewAccountAnnouncementInput {\n uid: string\n /** The auth record's creation time; null or unparseable claims nothing. */\n accountCreatedAtMs: number | null\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}\n\n/**\n * True exactly once per account, and only while the account is new: the\n * caller that gets `true` sends the notice. Throws when the marker cannot be\n * written for any reason but \"already announced\", so a caller's catch — not a\n * silent `false` — is what a Firestore outage reaches.\n */\nexport async function claimNewAccountAnnouncement(\n input: ClaimNewAccountAnnouncementInput,\n): Promise<boolean> {\n const createdAtMs = input.accountCreatedAtMs\n if (!input.uid || createdAtMs === null || !Number.isFinite(createdAtMs)) {\n return false\n }\n const nowMs = input.nowMs ?? Date.now()\n if (nowMs - createdAtMs > NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS) return false\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n try {\n await firestore\n .collection(NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION)\n .doc(input.uid)\n .create({\n announcedAt: FieldValue.serverTimestamp(),\n accountCreatedAtMs: createdAtMs,\n })\n return true\n } catch (error) {\n if (isAlreadyExists(error)) return false\n throw error\n }\n}\n\n/** Firestore's ALREADY_EXISTS, as the Admin SDK reports it (gRPC code 6). */\nfunction isAlreadyExists(error: unknown): boolean {\n const code = (error as { code?: unknown } | null)?.code\n return code === 6 || code === 'already-exists' || code === 'ALREADY_EXISTS'\n}\n"],"names":["FieldValue","firebaseAdmin","NEW_ACCOUNT_ANNOUNCEMENTS_COLLECTION","NEW_ACCOUNT_ANNOUNCEMENT_WINDOW_MS","claimNewAccountAnnouncement","input","createdAtMs","accountCreatedAtMs","uid","Number","isFinite","nowMs","Date","now","firestore","app","collection","doc","create","announcedAt","serverTimestamp","error","isAlreadyExists","code"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AACrD,OAAOC,mBAAmB,sBAAkB;AAE5C;;;;;;;;;;;;;;;;;;;CAmBC,GAED,6EAA6E,GAC7E,OAAO,MAAMC,uCAAuC,uBAAsB;AAE1E;;;;;;;;CAQC,GACD,OAAO,MAAMC,qCAAqC,IAAI,KAAK,KAAK,KAAK,KAAI;AAUzE;;;;;CAKC,GACD,OAAO,eAAeC,4BACpBC,KAAuC;QAMzBA,cAEIA;IANlB,MAAMC,cAAcD,MAAME,kBAAkB;IAC5C,IAAI,CAACF,MAAMG,GAAG,IAAIF,gBAAgB,QAAQ,CAACG,OAAOC,QAAQ,CAACJ,cAAc;QACvE,OAAO;IACT;IACA,MAAMK,SAAQN,eAAAA,MAAMM,KAAK,YAAXN,eAAeO,KAAKC,GAAG;IACrC,IAAIF,QAAQL,cAAcH,oCAAoC,OAAO;IACrE,MAAMW,aAAYT,mBAAAA,MAAMS,SAAS,YAAfT,mBAAmBJ,cAAcc,GAAG,GAAGD,SAAS;IAClE,IAAI;QACF,MAAMA,UACHE,UAAU,CAACd,sCACXe,GAAG,CAACZ,MAAMG,GAAG,EACbU,MAAM,CAAC;YACNC,aAAanB,WAAWoB,eAAe;YACvCb,oBAAoBD;QACtB;QACF,OAAO;IACT,EAAE,OAAOe,OAAO;QACd,IAAIC,gBAAgBD,QAAQ,OAAO;QACnC,MAAMA;IACR;AACF;AAEA,2EAA2E,GAC3E,SAASC,gBAAgBD,KAAc;IACrC,MAAME,OAAQF,yBAAD,AAACA,MAAqCE,IAAI;IACvD,OAAOA,SAAS,KAAKA,SAAS,oBAAoBA,SAAS;AAC7D"}
@@ -71,8 +71,14 @@ export declare function notifyStaff(payload: NotificationPayload): Promise<void>
71
71
  export declare function notifyStaffConsole(payload: NotificationPayload, options?: {
72
72
  skipEmail?: boolean;
73
73
  }): Promise<void>;
74
- /** Notifies the org's owner + admins (billing, membership, org events). */
75
- export declare function notifyOrgAdmins(orgId: string, payload: NotificationPayload): Promise<void>;
74
+ /**
75
+ * Notifies the org's owner + admins (billing, membership, org events).
76
+ *
77
+ * `skipEmail` is for a caller that emails the same notice to the same people
78
+ * itself — the usage alerts do (AGL-3431) — so an admin who switched the
79
+ * email channel on is not sent it twice.
80
+ */
81
+ export declare function notifyOrgAdmins(orgId: string, payload: NotificationPayload, options?: Pick<NotifyUsersOptions, 'skipEmail'>): Promise<void>;
76
82
  /**
77
83
  * Notifies everyone who manages a host (admin/editor in the host doc's
78
84
  * `memberRoles` projection) — the audience for form submissions and
@@ -311,7 +311,13 @@ async function listStaffUids() {
311
311
  console.error('staff notification failed', error);
312
312
  }
313
313
  }
314
- /** Notifies the org's owner + admins (billing, membership, org events). */ export async function notifyOrgAdmins(orgId, payload) {
314
+ /**
315
+ * Notifies the org's owner + admins (billing, membership, org events).
316
+ *
317
+ * `skipEmail` is for a caller that emails the same notice to the same people
318
+ * itself — the usage alerts do (AGL-3431) — so an admin who switched the
319
+ * email channel on is not sent it twice.
320
+ */ export async function notifyOrgAdmins(orgId, payload, options = {}) {
315
321
  try {
316
322
  const members = await listOrgMembers(orgId);
317
323
  const admins = members.filter((member)=>member.role === 'owner' || member.role === 'admin');
@@ -323,9 +329,11 @@ async function listStaffUids() {
323
329
  }
324
330
  await notifyUsers(admins.map((member)=>member.$id), _extends({}, payload, {
325
331
  orgId
326
- }), {
332
+ }), _extends({
327
333
  emails
328
- });
334
+ }, options.skipEmail ? {
335
+ skipEmail: true
336
+ } : {}));
329
337
  } catch (error) {
330
338
  console.error('org admin notification failed', error);
331
339
  }