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

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 (156) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-description.d.ts +112 -0
  5. package/src/lib/server/api-v1-description.js +126 -0
  6. package/src/lib/server/api-v1-description.js.map +1 -0
  7. package/src/lib/server/api-v1-kit.d.ts +163 -0
  8. package/src/lib/server/api-v1-kit.js +191 -0
  9. package/src/lib/server/api-v1-kit.js.map +1 -0
  10. package/src/lib/server/api-v1-resources.d.ts +143 -0
  11. package/src/lib/server/api-v1-resources.js +125 -0
  12. package/src/lib/server/api-v1-resources.js.map +1 -0
  13. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  14. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  15. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  16. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  17. package/src/lib/server/card-payment-velocity.js +28 -2
  18. package/src/lib/server/card-payment-velocity.js.map +1 -1
  19. package/src/lib/server/consent-group-carry.js +1 -1
  20. package/src/lib/server/consent-group-carry.js.map +1 -1
  21. package/src/lib/server/consent-group-change.d.ts +2 -1
  22. package/src/lib/server/consent-group-change.js +43 -18
  23. package/src/lib/server/consent-group-change.js.map +1 -1
  24. package/src/lib/server/contact-email-index.d.ts +22 -0
  25. package/src/lib/server/contact-email-index.js +108 -0
  26. package/src/lib/server/contact-email-index.js.map +1 -1
  27. package/src/lib/server/crm-email-activity.d.ts +1 -1
  28. package/src/lib/server/crm-email-activity.js +1 -1
  29. package/src/lib/server/crm-email-activity.js.map +1 -1
  30. package/src/lib/server/crm-records.d.ts +9 -1
  31. package/src/lib/server/crm-records.js +19 -2
  32. package/src/lib/server/crm-records.js.map +1 -1
  33. package/src/lib/server/cron-auth.d.ts +44 -0
  34. package/src/lib/server/cron-auth.js +76 -0
  35. package/src/lib/server/cron-auth.js.map +1 -0
  36. package/src/lib/server/dataset-live-pages.js +6 -6
  37. package/src/lib/server/dataset-live-pages.js.map +1 -1
  38. package/src/lib/server/duplicate-resource.d.ts +26 -0
  39. package/src/lib/server/duplicate-resource.js +47 -25
  40. package/src/lib/server/duplicate-resource.js.map +1 -1
  41. package/src/lib/server/dynamic-list-materialize.js +6 -3
  42. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  43. package/src/lib/server/email-flow-gate.js +2 -2
  44. package/src/lib/server/email-flow-gate.js.map +1 -1
  45. package/src/lib/server/email-suppression.d.ts +11 -0
  46. package/src/lib/server/email-suppression.js +37 -2
  47. package/src/lib/server/email-suppression.js.map +1 -1
  48. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  49. package/src/lib/server/email-unsubscribe-link.js +7 -7
  50. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  51. package/src/lib/server/erase.d.ts +19 -33
  52. package/src/lib/server/erase.js +37 -162
  53. package/src/lib/server/erase.js.map +1 -1
  54. package/src/lib/server/host-email-tokens.js +1 -1
  55. package/src/lib/server/host-email-tokens.js.map +1 -1
  56. package/src/lib/server/host-memberships.js +3 -3
  57. package/src/lib/server/host-memberships.js.map +1 -1
  58. package/src/lib/server/host-visitor-records.d.ts +32 -4
  59. package/src/lib/server/host-visitor-records.js +73 -14
  60. package/src/lib/server/host-visitor-records.js.map +1 -1
  61. package/src/lib/server/hosted-page-review.d.ts +27 -0
  62. package/src/lib/server/hosted-page-review.js +149 -12
  63. package/src/lib/server/hosted-page-review.js.map +1 -1
  64. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  65. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  66. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  67. package/src/lib/server/member-email-aliases.d.ts +10 -2
  68. package/src/lib/server/member-email-aliases.js +26 -2
  69. package/src/lib/server/member-email-aliases.js.map +1 -1
  70. package/src/lib/server/new-account-announcement.d.ts +62 -0
  71. package/src/lib/server/new-account-announcement.js +77 -0
  72. package/src/lib/server/new-account-announcement.js.map +1 -0
  73. package/src/lib/server/notifications.d.ts +22 -2
  74. package/src/lib/server/notifications.js +40 -3
  75. package/src/lib/server/notifications.js.map +1 -1
  76. package/src/lib/server/operator-alerts.d.ts +7 -1
  77. package/src/lib/server/operator-alerts.js +5 -3
  78. package/src/lib/server/operator-alerts.js.map +1 -1
  79. package/src/lib/server/org-containers.d.ts +66 -0
  80. package/src/lib/server/org-containers.js +95 -0
  81. package/src/lib/server/org-containers.js.map +1 -0
  82. package/src/lib/server/org-member-notice.js +3 -2
  83. package/src/lib/server/org-member-notice.js.map +1 -1
  84. package/src/lib/server/payment-fraud-signal.js +5 -1
  85. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  86. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  87. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
  88. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  89. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  90. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  91. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  92. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  93. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  94. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  95. package/src/lib/server/payment-provider.d.ts +173 -0
  96. package/src/lib/server/payment-provider.js +70 -0
  97. package/src/lib/server/payment-provider.js.map +1 -0
  98. package/src/lib/server/payment-risk-record.d.ts +1 -1
  99. package/src/lib/server/payment-risk-record.js +3 -1
  100. package/src/lib/server/payment-risk-record.js.map +1 -1
  101. package/src/lib/server/personal-data-export.js +1 -1
  102. package/src/lib/server/personal-data-export.js.map +1 -1
  103. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  104. package/src/lib/server/platform-marketing-consent.js +1 -1
  105. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  106. package/src/lib/server/rate-limit-store.js +13 -3
  107. package/src/lib/server/rate-limit-store.js.map +1 -1
  108. package/src/lib/server/realm-plugins.js +8 -3
  109. package/src/lib/server/realm-plugins.js.map +1 -1
  110. package/src/lib/server/retained-refusals.d.ts +142 -0
  111. package/src/lib/server/retained-refusals.js +325 -0
  112. package/src/lib/server/retained-refusals.js.map +1 -0
  113. package/src/lib/server/risk-notice.d.ts +32 -1
  114. package/src/lib/server/risk-notice.js +90 -28
  115. package/src/lib/server/risk-notice.js.map +1 -1
  116. package/src/lib/server/sending-domains.d.ts +17 -0
  117. package/src/lib/server/sending-domains.js +28 -4
  118. package/src/lib/server/sending-domains.js.map +1 -1
  119. package/src/lib/server/sso-enforcement.js +7 -2
  120. package/src/lib/server/sso-enforcement.js.map +1 -1
  121. package/src/lib/server/staff-alert-email.js +4 -2
  122. package/src/lib/server/staff-alert-email.js.map +1 -1
  123. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  124. package/src/lib/server/test-firestore-queries.js +70 -27
  125. package/src/lib/server/test-firestore-queries.js.map +1 -1
  126. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  127. package/src/lib/server/topic-subscriptions.js.map +1 -0
  128. package/src/lib/server/upsert-contact.d.ts +46 -1
  129. package/src/lib/server/upsert-contact.js +87 -20
  130. package/src/lib/server/upsert-contact.js.map +1 -1
  131. package/src/tenancy.d.ts +5 -8
  132. package/src/tenancy.js +12 -13
  133. package/src/tenancy.js.map +1 -1
  134. package/src/lib/server/connect-account-status.js.map +0 -1
  135. package/src/lib/server/connect-payout-failure.js.map +0 -1
  136. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  137. package/src/lib/server/contact-email-engagement.js +0 -145
  138. package/src/lib/server/contact-email-engagement.js.map +0 -1
  139. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  140. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  141. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  142. package/src/lib/server/contact-merge.d.ts +0 -55
  143. package/src/lib/server/contact-merge.js +0 -288
  144. package/src/lib/server/contact-merge.js.map +0 -1
  145. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  146. package/src/lib/server/crm-booking-activity.js +0 -257
  147. package/src/lib/server/crm-booking-activity.js.map +0 -1
  148. package/src/lib/server/crm-inbound-email.d.ts +0 -145
  149. package/src/lib/server/crm-inbound-email.js +0 -297
  150. package/src/lib/server/crm-inbound-email.js.map +0 -1
  151. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  152. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  153. package/src/lib/server/form-dataset-binding-token.js +0 -107
  154. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  155. package/src/lib/server/stripe-account-mode.js.map +0 -1
  156. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
@@ -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":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,gBAAgB,QAEX,oCAAmC;AAC1C,SAASC,kBAAkB,QAAQ,qCAAoC;AACvE,SACEC,cAAc,QAET,qCAAoC;AAC3C,SAASC,yBAAyB,QAAQ,oDAAmD;AAC7F,SAASC,oBAAoB,QAAQ,2CAA0C;AAC/E,SACEC,iBAAiB,EACjBC,uBAAuB,EACvBC,cAAc,EACdC,eAAe,EACfC,aAAa,EACbC,qBAAqB,EACrBC,eAAe,EACfC,qBAAqB,QAKhB,2BAA0B;AAGjC;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,eAAeC,0BACpBC,SAAsC,EACtCC,MAAc,EACdC,WAAmB,EACnBC,QAAgC,CAAC,CAAC,EAClCC,UAA+B,CAAC,CAAC;IAEjC,MAAMC,OAAO,MAAMC,oBAAoBN,WAAWC;IAClD,OAAOJ,gBACLG,WACAC,QACAC,aACA,qEAAqE;IACrE,2EAA2E;IAC3E,2EAA2E;IAC3E,MAAM;IACN,aAAKd,eAAeiB,OAAUF,QAC9B,yEAAyE;IACzE,wEAAwE;IACxE,wEAAwE;IACxE,sEAAsE;IACtE,wEAAwE;IACxE,iBAAiB;IACjB,EAAE;IACF,yEAAyE;IACzE,yEAAyE;IACzE,0EAA0E;IAC1E,uEAAuE;IACvE,wEAAwE;IACxE,qCAAqC;IACrC,EAAE;IACF,0EAA0E;IAC1E,wEAAwE;IACxE,aACKC;QACHG,UAAUpB;QACVqB,SAASnB;QACToB,QAAQC,cAAcL,MAAMH;QAC5BS,OAAOC,aAAaP;;AAG1B;AAEA;;;;;;CAMC,GACD,eAAeC,oBACbN,SAAsC,EACtCC,MAAc;IAEd,IAAI;QACF,MAAMY,WAAW,MAAMb,UAAUc,UAAU,CAAC,SAASC,GAAG,CAACd,QAAQe,GAAG;QACpE,OAAOH,SAASI,MAAM,GAAIJ,SAASK,IAAI,KAAuB;IAChE,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAWA;;;;;CAKC,GACD,SAASN,aAAaP,IAA0B;;QAK/BM,cAIFA;IARb,wEAAwE;IACxE,sEAAsE;IACtE,oCAAoC;IACpC,MAAMA,QAAQzB,iBAAiBmB;IAC/B,MAAMc,SAASR,0BAAAA,eAAAA,MAAOS,KAAK,qBAAZT,aAAcU,YAAY;IACzC,OAAO;QACLC,SAAS/B,kBAAkB;YACzBgC,MAAM/B,wBAAwBa;YAC9BmB,MAAM,UAAGb,0BAAAA,sBAAAA,MAAOc,YAAY,qBAAnBd,oBAAqBe,KAAK,mBAAI;QACzC;OACI,OAAOP,WAAW,WAAW;QAAEQ,cAAcR;IAAO,IAAI,CAAC;AAEjE;AAEA,6EAA6E,GAC7E,SAAST,cACPL,IAA4B,EAC5BH,WAAmB;QAITT;IAFV,OAAOH,qBAAqB;QAC1Be;QACAuB,MAAM,GAAEnC,kBAAAA,eAAeS,iCAAfT,gBAA6BoC,YAAY;IACnD;AACF;AAQA;;;;;CAKC,GACD,OAAO,eAAeC,wBACpB9B,SAAsC,EACtCC,MAAc,EACdC,WAAmB,EACnBE,UAA+B,CAAC,CAAC;IAEjC,MAAMC,OAAO,MAAMC,oBAAoBN,WAAWC;IAClD,MAAM8B,SAAS,MAAMpC,cAAcK,WAAoBC,QAAQC,aAAa,aACvEE;QACHI,SAASnB;QACToB,QAAQC,cAAcL,MAAMH;QAC5BS,OAAOC,aAAaP;;IAEtB,OAAO0B,SAAS;QAAEA;QAAQC,WAAW5C,eAAeiB;IAAM,IAAI;AAChE;AAEA,iEAAiE,GACjE,OAAO,SAAS4B,gCACdC,MAAiC,EACjC/B,QAAgC,CAAC,CAAC;IAElC,OAAOL,sBACLoC,OAAOH,MAAM,EACb,aAAKG,OAAOF,SAAS,EAAK7B,QAC1BhB;AAEJ;AAEA;;;;CAIC,GACD,OAAO,eAAegD,eACpBnC,SAAsC,EACtCC,MAAc,EACdC,WAAmB;IAOnB,MAAMG,OAAO,MAAMC,oBAAoBN,WAAWC;IAClD,MAAMmC,SAAS/B,OACXX,gBAAgB;QAAE2C,OAAOhC,KAAKgC,KAAK;QAAEC,WAAWjC,KAAKiC,SAAS;IAAC,KAC/DC;IACJ,OAAO;QACL9B,QAAQC,cAAcL,MAAMH;QAC5BS,OAAOC,aAAaP;OAChB+B,SAAS;QAAEI,aAAaJ;IAAO,IAAI,CAAC;QACxCK,aAAaxC;;AAEjB;AAEA;;;;CAIC,GACD,OAAO,eAAeyC,oBACpB1C,SAAsC,EACtCC,MAAc,EACdC,WAAmB,EACnByC,OAA8D;IAE9D,MAAMC,QAAQ,MAAMT,eAAenC,WAAWC,QAAQC;IACtD,MAAM2C,WAAWjD,sBAAsB,aAAK+C,SAAYC;IACxD,OAAO;QAAEE,SAASH,QAAQG,OAAO;QAAEC,MAAMF,SAASE,IAAI;QAAEC,MAAMH,SAASG,IAAI;IAAC;AAC9E;AAEA,eAAejD,0BAAyB"}
@@ -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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"}
@@ -150,13 +150,19 @@ export declare function leadForWrite(hostId: string, key: string): Promise<{
150
150
  * so the record cannot be edited away by the site it describes, and host
151
151
  * admins can already READ it — which is what lets the inbox console page
152
152
  * render it with no rules change.
153
- * - One in-app notification to the site's managers, on the FIRST refusal of
154
- * the month only. A notification per refused bot request would be the
155
- * flood again, delivered.
153
+ * - One notification to the site's managers per CROSSING (AGL-3442): on the
154
+ * first refusal after the site reaches the ceiling, and not again while it
155
+ * stays there. A notification per refused bot request would be the flood
156
+ * again, delivered, and one a month would repeat an alarm about a state a
157
+ * site can sit in for months. {@link rearmVisitorRecordCeilingNotice}
158
+ * re-arms it once the site is seen below the ceiling, so reaching it again
159
+ * is announced again.
156
160
  *
157
161
  * The counter is month-keyed even though the CEILING is a total, because the
158
162
  * two facts are different: the ceiling is "how many records exist" and this is
159
- * "how many were turned away recently". Only the second is worth a notice.
163
+ * "how many were turned away recently". The inbox reports the second; the
164
+ * notice announces the first, which is why it follows the crossing and not
165
+ * the month.
160
166
  *
161
167
  * Best-effort throughout: bookkeeping that failed must never turn a contained
162
168
  * refusal into a 500, because a 500 is an invitation to retry.
@@ -168,6 +174,28 @@ export declare function recordVisitorRecordCeilingTrip(options: {
168
174
  ceiling: number;
169
175
  monthKey?: string;
170
176
  }): Promise<void>;
177
+ /**
178
+ * Re-arm the ceiling notice when an accepted record shows the site was below
179
+ * the ceiling (AGL-3442).
180
+ *
181
+ * Called by every writer that judges a new record against the ceiling, with
182
+ * the count it judged. It does nothing unless the record it just accepted took
183
+ * the site's LAST free slot: a site that dropped below the ceiling can reach
184
+ * it again only through that accept, so it is where the re-arm is observed,
185
+ * and every other accept pays nothing for it. That accept reads the refusal
186
+ * counter, and clears the notice marker only when the previous crossing was
187
+ * announced, so a site that never reached the ceiling gains no document.
188
+ *
189
+ * Best-effort and never throws, for the reason the trip's bookkeeping is: the
190
+ * record is already stored, and a failure here only costs a notice.
191
+ */
192
+ export declare function rearmVisitorRecordCeilingNotice(options: {
193
+ hostRef: FirebaseFirestore.DocumentReference;
194
+ kind: VisitorRecordKind;
195
+ /** The live count the accept was judged against, before its own record. */
196
+ used: number;
197
+ ceiling: number;
198
+ }): Promise<void>;
171
199
  /** What a caller wants stored on the lead. `createdAt` is stamped here. */
172
200
  export interface HostLeadInput {
173
201
  email: string;
@@ -22,7 +22,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
22
22
  * The policy is pure and lives in `@aglyn/aglyn/server`
23
23
  * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same
24
24
  * split as `visitorWriteRateLimitRefusal`, and for the same reason.
25
- */ import { CAPTURED_BY_HOST_FIELD, checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, marketingConsentFieldsForGroup, personKey, readMarketingBasis, submissionMonthKey, visitorRecordRefusedCounterId } from "@aglyn/aglyn/server";
25
+ */ import { CAPTURED_BY_HOST_FIELD, checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, marketingConsentFieldsForGroup, personKey, readMarketingBasis, submissionMonthKey, VISITOR_RECORD_NOTICE_SENT_FIELD, visitorRecordAcceptFillsCeiling, visitorRecordCeilingAnnounced, visitorRecordRefusedCounterId } from "@aglyn/aglyn/server";
26
26
  import { FieldValue } from "firebase-admin/firestore";
27
27
  import { attributeCampaignConversion } from "./campaign-conversion-attribution.js";
28
28
  import { notifyHostManagers } from "./notifications.js";
@@ -167,13 +167,19 @@ const firestore = ()=>firebaseAdmin.app().firestore();
167
167
  * so the record cannot be edited away by the site it describes, and host
168
168
  * admins can already READ it — which is what lets the inbox console page
169
169
  * render it with no rules change.
170
- * - One in-app notification to the site's managers, on the FIRST refusal of
171
- * the month only. A notification per refused bot request would be the
172
- * flood again, delivered.
170
+ * - One notification to the site's managers per CROSSING (AGL-3442): on the
171
+ * first refusal after the site reaches the ceiling, and not again while it
172
+ * stays there. A notification per refused bot request would be the flood
173
+ * again, delivered, and one a month would repeat an alarm about a state a
174
+ * site can sit in for months. {@link rearmVisitorRecordCeilingNotice}
175
+ * re-arms it once the site is seen below the ceiling, so reaching it again
176
+ * is announced again.
173
177
  *
174
178
  * The counter is month-keyed even though the CEILING is a total, because the
175
179
  * two facts are different: the ceiling is "how many records exist" and this is
176
- * "how many were turned away recently". Only the second is worth a notice.
180
+ * "how many were turned away recently". The inbox reports the second; the
181
+ * notice announces the first, which is why it follows the crossing and not
182
+ * the month.
177
183
  *
178
184
  * Best-effort throughout: bookkeeping that failed must never turn a contained
179
185
  * refusal into a 500, because a 500 is an invitation to retry.
@@ -182,24 +188,35 @@ const firestore = ()=>firebaseAdmin.app().firestore();
182
188
  const { hostRef, hostId, kind, ceiling } = options;
183
189
  const monthKey = (_options_monthKey = options.monthKey) != null ? _options_monthKey : submissionMonthKey();
184
190
  try {
185
- var _refusedSnapshot_get;
186
191
  const refusedRef = hostRef.collection('counters').doc(visitorRecordRefusedCounterId(kind));
187
192
  const refusedSnapshot = await refusedRef.get();
188
- const alreadyRefused = Number((_refusedSnapshot_get = refusedSnapshot.get(monthKey)) != null ? _refusedSnapshot_get : 0);
189
- await refusedRef.set({
193
+ // Announced already for this crossing, by this writer or by the
194
+ // month-scoped one before it — see `visitorRecordCeilingAnnounced`.
195
+ const announced = visitorRecordCeilingAnnounced(refusedSnapshot.data());
196
+ const now = Date.now();
197
+ await refusedRef.set(_extends({
190
198
  [monthKey]: FieldValue.increment(1),
191
199
  // Explicit values only — Firestore rejects `undefined`.
192
200
  ceiling,
193
- lastRefusedAtMs: Date.now()
194
- }, {
201
+ lastRefusedAtMs: now
202
+ }, announced ? {} : {
203
+ [VISITOR_RECORD_NOTICE_SENT_FIELD]: now
204
+ }), {
195
205
  merge: true
196
206
  });
197
- if (alreadyRefused === 0) {
207
+ if (!announced) {
198
208
  const leads = kind === 'leads';
199
- await notifyHostManagers(hostId, {
209
+ /*
210
+ * What is refused, said exactly (AGL-3432). A lead door — a form
211
+ * routed to leads, a booking — stores its own record BEFORE it files
212
+ * the lead, so at the ceiling the visitor's submission or booking is
213
+ * still kept and only the new lead is not. A member sign-up is refused
214
+ * whole: no account is created. `{site}` is the site's name, filled by
215
+ * `notifyHostManagers` from the host doc it already reads.
216
+ */ await notifyHostManagers(hostId, {
200
217
  type: 'system.visitorRecordsPaused',
201
- title: leads ? 'Lead capture paused — this site is at the platform limit' : 'Sign-ups paused — this site is at the platform limit',
202
- body: `This site holds ${ceiling.toLocaleString()} ` + `${leads ? 'leads' : 'member accounts'}, which is the platform ` + `safety limit, so further ${leads ? 'leads' : 'sign-ups'} are ` + 'being refused. This is not part of your plan — every plan ' + `includes unlimited ${leads ? 'leads' : 'member accounts'}. ` + `Remove some ${leads ? 'leads' : 'members'}, or contact support ` + 'if this is real traffic.',
218
+ title: leads ? 'Lead capture paused on {site} — platform limit reached' : 'Sign-ups paused on {site} — platform limit reached',
219
+ body: leads ? `{site} holds ${ceiling.toLocaleString()} leads, the platform ` + 'safety limit, so new people are no longer added as leads. What ' + 'they send, such as a form submission or a booking, is still ' + 'saved; only the new lead is not. This limit is not part of your ' + 'plan — every plan includes unlimited leads — and nothing is ' + 'charged. Remove some leads, or contact support if this is real ' + 'traffic.' : `{site} has ${ceiling.toLocaleString()} member accounts, the ` + 'platform safety limit, so new sign-ups are refused and no ' + 'account is created. This limit is not part of your plan — every ' + 'plan includes unlimited member accounts — and nothing is ' + 'charged. Remove some members, or contact support if this is ' + 'real traffic.',
203
220
  link: `/${hostId}/inbox`
204
221
  });
205
222
  }
@@ -207,6 +224,36 @@ const firestore = ()=>firebaseAdmin.app().firestore();
207
224
  console.error('visitor record ceiling bookkeeping failed', error);
208
225
  }
209
226
  }
227
+ /**
228
+ * Re-arm the ceiling notice when an accepted record shows the site was below
229
+ * the ceiling (AGL-3442).
230
+ *
231
+ * Called by every writer that judges a new record against the ceiling, with
232
+ * the count it judged. It does nothing unless the record it just accepted took
233
+ * the site's LAST free slot: a site that dropped below the ceiling can reach
234
+ * it again only through that accept, so it is where the re-arm is observed,
235
+ * and every other accept pays nothing for it. That accept reads the refusal
236
+ * counter, and clears the notice marker only when the previous crossing was
237
+ * announced, so a site that never reached the ceiling gains no document.
238
+ *
239
+ * Best-effort and never throws, for the reason the trip's bookkeeping is: the
240
+ * record is already stored, and a failure here only costs a notice.
241
+ */ export async function rearmVisitorRecordCeilingNotice(options) {
242
+ const { hostRef, kind, used, ceiling } = options;
243
+ if (!visitorRecordAcceptFillsCeiling(used, ceiling)) return;
244
+ try {
245
+ const refusedRef = hostRef.collection('counters').doc(visitorRecordRefusedCounterId(kind));
246
+ const refusedSnapshot = await refusedRef.get();
247
+ if (!visitorRecordCeilingAnnounced(refusedSnapshot.data())) return;
248
+ await refusedRef.set({
249
+ [VISITOR_RECORD_NOTICE_SENT_FIELD]: null
250
+ }, {
251
+ merge: true
252
+ });
253
+ } catch (error) {
254
+ console.error('visitor record ceiling re-arm failed', error);
255
+ }
256
+ }
210
257
  /**
211
258
  * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by
212
259
  * `LEADS_MAX_PER_HOST` (AGL-1529).
@@ -302,6 +349,8 @@ const firestore = ()=>firebaseAdmin.app().firestore();
302
349
  let created = false;
303
350
  let sourceAdded = false;
304
351
  let predicted = false;
352
+ // The count a new person was judged against, for the notice re-arm.
353
+ let usedBeforeCreate = null;
305
354
  const refused = await firestore.runTransaction(async (tx)=>{
306
355
  var _existing_data, _existing_data1, _existing_data2, _existing_data3;
307
356
  // Reset per attempt: a contended transaction re-runs its body, and a
@@ -309,6 +358,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
309
358
  // with a person who turned out to exist already.
310
359
  created = false;
311
360
  sourceAdded = false;
361
+ usedBeforeCreate = null;
312
362
  // ALL READS BEFORE THE WRITE, which Firestore requires.
313
363
  const existing = await tx.get(leadRef);
314
364
  // Whether the lead already reads "Would bounce", for the check below.
@@ -341,6 +391,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
341
391
  * both, which is the honest answer: each of them holds it.
342
392
  */ const used = (await tx.get(scopedToHost(leadsRef, hostId).count())).data().count;
343
393
  if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true;
394
+ usedBeforeCreate = used;
344
395
  }
345
396
  /*
346
397
  * Consent is carried forward and never cleared.
@@ -450,6 +501,14 @@ const firestore = ()=>firebaseAdmin.app().firestore();
450
501
  });
451
502
  return NOT_STORED;
452
503
  }
504
+ if (created && usedBeforeCreate !== null) {
505
+ await rearmVisitorRecordCeilingNotice({
506
+ hostRef,
507
+ kind: 'leads',
508
+ used: usedBeforeCreate,
509
+ ceiling: maxPerHost
510
+ });
511
+ }
453
512
  /*
454
513
  * ATTRIBUTED ON CREATION ONLY.
455
514
  *