@aglyn/plugins-commerce 1.0.0-beta.219 → 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 (80) hide show
  1. package/README.md +5 -0
  2. package/package.json +14 -14
  3. package/src/lib/components/console/catalog-organization-card.component.js +626 -446
  4. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
  5. package/src/lib/components/console/payments-settings-card.component.js +1 -1
  6. package/src/lib/components/console/payments-settings-card.component.js.map +1 -1
  7. package/src/lib/components/console/product-editor-dialog.component.js +1127 -846
  8. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  9. package/src/lib/components/console/site-member-purchases.component.d.ts +24 -0
  10. package/src/lib/components/console/site-member-purchases.component.js +339 -0
  11. package/src/lib/components/console/site-member-purchases.component.js.map +1 -0
  12. package/src/lib/components/member-signup.js +2 -2
  13. package/src/lib/components/member-signup.js.map +1 -1
  14. package/src/lib/components/newsletter-signup.js +2 -2
  15. package/src/lib/components/newsletter-signup.js.map +1 -1
  16. package/src/lib/constants/operator-alerts.js +5 -3
  17. package/src/lib/constants/operator-alerts.js.map +1 -1
  18. package/src/lib/model/commerce-io.js +1 -1
  19. package/src/lib/model/commerce-io.js.map +1 -1
  20. package/src/lib/model/commerce-record-lists.d.ts +31 -0
  21. package/src/lib/model/commerce-record-lists.js +45 -0
  22. package/src/lib/model/commerce-record-lists.js.map +1 -0
  23. package/src/lib/model/commerce-record-routes.d.ts +18 -0
  24. package/src/lib/model/commerce-record-routes.js +1 -1
  25. package/src/lib/model/commerce-record-routes.js.map +1 -1
  26. package/src/lib/model/commerce-search-sources.js +4 -2
  27. package/src/lib/model/commerce-search-sources.js.map +1 -1
  28. package/src/lib/model/commerce-topics.d.ts +20 -0
  29. package/src/lib/model/commerce-topics.js +33 -0
  30. package/src/lib/model/commerce-topics.js.map +1 -0
  31. package/src/lib/model/index.d.ts +1 -0
  32. package/src/lib/model/index.js +1 -0
  33. package/src/lib/model/index.js.map +1 -1
  34. package/src/lib/model/product-record.d.ts +32 -0
  35. package/src/lib/model/product-record.js +82 -0
  36. package/src/lib/model/product-record.js.map +1 -0
  37. package/src/lib/model/site-member-purchases.d.ts +48 -0
  38. package/src/lib/model/site-member-purchases.js +50 -0
  39. package/src/lib/model/site-member-purchases.js.map +1 -0
  40. package/src/lib/plugin.js +12 -0
  41. package/src/lib/plugin.js.map +1 -1
  42. package/src/lib/server/billing-webhook.js +342 -170
  43. package/src/lib/server/billing-webhook.js.map +1 -1
  44. package/src/lib/server/cart-checkout.js +1 -1
  45. package/src/lib/server/cart-checkout.js.map +1 -1
  46. package/src/lib/server/gift-cards.js +7 -3
  47. package/src/lib/server/gift-cards.js.map +1 -1
  48. package/src/lib/server/low-stock.js +3 -1
  49. package/src/lib/server/low-stock.js.map +1 -1
  50. package/src/lib/server/member-post.js +2 -2
  51. package/src/lib/server/member-post.js.map +1 -1
  52. package/src/lib/server/membership-admin-password.js +4 -1
  53. package/src/lib/server/membership-admin-password.js.map +1 -1
  54. package/src/lib/server/membership-register.js +13 -2
  55. package/src/lib/server/membership-register.js.map +1 -1
  56. package/src/lib/server/newsletter.js +32 -19
  57. package/src/lib/server/newsletter.js.map +1 -1
  58. package/src/lib/server/pos-order.js +1 -1
  59. package/src/lib/server/pos-order.js.map +1 -1
  60. package/src/lib/server/process-abandoned.js +4 -2
  61. package/src/lib/server/process-abandoned.js.map +1 -1
  62. package/src/lib/server/process-restock.js +7 -5
  63. package/src/lib/server/process-restock.js.map +1 -1
  64. package/src/lib/server/product-record-index.js +5 -37
  65. package/src/lib/server/product-record-index.js.map +1 -1
  66. package/src/lib/server/reconcile-stock.js +1 -1
  67. package/src/lib/server/reconcile-stock.js.map +1 -1
  68. package/src/lib/server/reserve-stock.js +6 -1
  69. package/src/lib/server/reserve-stock.js.map +1 -1
  70. package/src/lib/server/revenue-source.js +3 -3
  71. package/src/lib/server/revenue-source.js.map +1 -1
  72. package/src/lib/server/supplier-outbox.js +16 -1
  73. package/src/lib/server/supplier-outbox.js.map +1 -1
  74. package/src/lib/server/supplier-update.js +19 -9
  75. package/src/lib/server/supplier-update.js.map +1 -1
  76. package/src/lib/starter-templates.d.ts +19 -0
  77. package/src/lib/starter-templates.js +157 -0
  78. package/src/lib/starter-templates.js.map +1 -0
  79. package/src/lib/tenant-emails.js +54 -14
  80. package/src/lib/tenant-emails.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/low-stock.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 { notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * The AGL-281 low-stock crossing alert, extracted so every stock decrement\n * fires it (AGL-1826). It used to live inline on the buy-now branch only, so\n * whether a merchant was told they were running out depended on which door the\n * sale came through: the single-product storefront button alerted, while the\n * cart, a merchant-sent draft link and the register crossed the same threshold\n * silently.\n *\n * SEMANTICS ARE THE BUY-NOW BRANCH'S, UNCHANGED:\n *\n * - The threshold is the product's own `lowStockThreshold`, compared against\n * the flat total across TRACKED variants (`isLowStock` /\n * `productInventory`). No threshold configured means no alert, ever.\n * - It fires on the CROSSING adjustment only — low after, not low before — so\n * managers get one nudge per threshold breach, not one per order after it.\n * - Redelivery safety is the CALLER's replay guard, not re-checked here:\n * every call site sits behind its branch's own gate (the cart and buy-now\n * `created` transactions, the draft branch's pending-to-paid flip, the\n * POS handler's `claimAttempt`), so a redelivered event never recomputes the\n * pair this compares.\n *\n * Takes the PRE- and POST-decrement product states the caller just computed —\n * never a re-read, which could see its own write (no crossing observed) or a\n * concurrent one (somebody else's crossing attributed here). Callers looping\n * over lines must hand in the compounded pair (AGL-1828/AGL-1825): each line's\n * `lifted` is the previous line's `updated`, so two lines of one product cross\n * exactly once, on the line that actually breaches.\n *\n * Fire-and-forget like the sibling notifications on every branch —\n * `notifyHostManagers` never throws, and an alert must never fail a webhook.\n */\nexport function alertLowStockCrossing(\n hostId: string,\n lifted: Pick<\n CommerceModel.HostProduct,\n 'name' | 'variants' | 'lowStockThreshold'\n >,\n updated: Pick<\n CommerceModel.HostProduct,\n 'name' | 'variants' | 'lowStockThreshold'\n >,\n): void {\n if (\n CommerceModel.isLowStock(updated) &&\n !CommerceModel.isLowStock(lifted)\n ) {\n void notifyHostManagers(hostId, {\n type: 'content.lowStock',\n title: `Low stock — ${updated.name}`,\n body: `${CommerceModel.productInventory(updated) ?? 0} left across tracked variants`,\n link: `/${hostId}/products`,\n })\n }\n}\n"],"names":["notifyHostManagers","CommerceModel","alertLowStockCrossing","hostId","lifted","updated","isLowStock","type","title","name","body","productInventory","link"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,2BAA0B;AAC7D,YAAYC,mBAAmB,oBAAU;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,sBACdC,MAAc,EACdC,MAGC,EACDC,OAGC;IAED,IACEJ,cAAcK,UAAU,CAACD,YACzB,CAACJ,cAAcK,UAAU,CAACF,SAC1B;YAIWH;QAHX,KAAKD,mBAAmBG,QAAQ;YAC9BI,MAAM;YACNC,OAAO,CAAC,YAAY,EAAEH,QAAQI,IAAI,EAAE;YACpCC,MAAM,IAAGT,kCAAAA,cAAcU,gBAAgB,CAACN,oBAA/BJ,kCAA2C,EAAE,6BAA6B,CAAC;YACpFW,MAAM,CAAC,CAAC,EAAET,OAAO,SAAS,CAAC;QAC7B;IACF;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/low-stock.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 { notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * The AGL-281 low-stock crossing alert, extracted so every stock decrement\n * fires it (AGL-1826). It used to live inline on the buy-now branch only, so\n * whether a merchant was told they were running out depended on which door the\n * sale came through: the single-product storefront button alerted, while the\n * cart, a merchant-sent draft link and the register crossed the same threshold\n * silently.\n *\n * SEMANTICS ARE THE BUY-NOW BRANCH'S, UNCHANGED:\n *\n * - The threshold is the product's own `lowStockThreshold`, compared against\n * the flat total across TRACKED variants (`isLowStock` /\n * `productInventory`). No threshold configured means no alert, ever.\n * - It fires on the CROSSING adjustment only — low after, not low before — so\n * managers get one nudge per threshold breach, not one per order after it.\n * - Redelivery safety is the CALLER's replay guard, not re-checked here:\n * every call site sits behind its branch's own gate (the cart and buy-now\n * `created` transactions, the draft branch's pending-to-paid flip, the\n * POS handler's `claimAttempt`), so a redelivered event never recomputes the\n * pair this compares.\n *\n * Takes the PRE- and POST-decrement product states the caller just computed —\n * never a re-read, which could see its own write (no crossing observed) or a\n * concurrent one (somebody else's crossing attributed here). Callers looping\n * over lines must hand in the compounded pair (AGL-1828/AGL-1825): each line's\n * `lifted` is the previous line's `updated`, so two lines of one product cross\n * exactly once, on the line that actually breaches.\n *\n * Fire-and-forget like the sibling notifications on every branch —\n * `notifyHostManagers` never throws, and an alert must never fail a webhook.\n */\nexport function alertLowStockCrossing(\n hostId: string,\n lifted: Pick<\n CommerceModel.HostProduct,\n 'name' | 'variants' | 'lowStockThreshold'\n >,\n updated: Pick<\n CommerceModel.HostProduct,\n 'name' | 'variants' | 'lowStockThreshold'\n >,\n): void {\n if (\n CommerceModel.isLowStock(updated) &&\n !CommerceModel.isLowStock(lifted)\n ) {\n // The product, the count, the threshold and the site, in the body\n // (AGL-3432), which an email reader sees without the title.\n void notifyHostManagers(hostId, {\n type: 'content.lowStock',\n title: `Low stock — ${updated.name}`,\n body:\n `${updated.name} on {site} is down to ` +\n `${CommerceModel.productInventory(updated) ?? 0} in stock across its ` +\n 'tracked variants, at or below its low-stock threshold of ' +\n `${updated.lowStockThreshold}.`,\n link: `/${hostId}/products`,\n })\n }\n}\n"],"names":["notifyHostManagers","CommerceModel","alertLowStockCrossing","hostId","lifted","updated","isLowStock","type","title","name","body","productInventory","lowStockThreshold","link"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,2BAA0B;AAC7D,YAAYC,mBAAmB,oBAAU;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASC,sBACdC,MAAc,EACdC,MAGC,EACDC,OAGC;IAED,IACEJ,cAAcK,UAAU,CAACD,YACzB,CAACJ,cAAcK,UAAU,CAACF,SAC1B;YAQOH;QAPP,kEAAkE;QAClE,4DAA4D;QAC5D,KAAKD,mBAAmBG,QAAQ;YAC9BI,MAAM;YACNC,OAAO,CAAC,YAAY,EAAEH,QAAQI,IAAI,EAAE;YACpCC,MACE,GAAGL,QAAQI,IAAI,CAAC,sBAAsB,CAAC,GACvC,IAAGR,kCAAAA,cAAcU,gBAAgB,CAACN,oBAA/BJ,kCAA2C,EAAE,qBAAqB,CAAC,GACtE,8DACA,GAAGI,QAAQO,iBAAiB,CAAC,CAAC,CAAC;YACjCC,MAAM,CAAC,CAAC,EAAEV,OAAO,SAAS,CAAC;QAC7B;IACF;AACF"}
@@ -18,7 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
19
19
  import * as CommerceModel from "../model/index.js";
20
20
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
21
- import { consentGroupForHost, EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveBrandingProfile } from "@aglyn/aglyn/server";
21
+ import { consentGroupForHost, hostPublicOrigin, resolveBrandingProfile } from "@aglyn/aglyn/server";
22
22
  // Which member subscribers are live enough to email (AGL-316). The list itself
23
23
  // now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a
24
24
  // private copy here and a second private copy in `gate.ts`, which is the drift
@@ -153,7 +153,7 @@ import { consentGroupForHost, EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveB
153
153
  */ marketing: {
154
154
  hostId,
155
155
  siteBase,
156
- topicId: EMAIL_TOPIC_NEWSLETTER,
156
+ topicId: CommerceModel.NEWSLETTER_TOPIC_ID,
157
157
  consentHostIds: consentGroup.hostIds,
158
158
  consentAwaitsConfirmation: consentGroup.awaitsConfirmation
159
159
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-post.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 firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport * as CommerceModel from '../model'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n consentGroupForHost,\n EMAIL_TOPIC_NEWSLETTER,\n hostPublicOrigin,\n type PluginApiHandler,\n resolveBrandingProfile,\n} from '@aglyn/aglyn/server'\n\n// Which member subscribers are live enough to email (AGL-316). The list itself\n// now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a\n// private copy here and a second private copy in `gate.ts`, which is the drift\n// AGL-1715 guards against on the org side.\n\n/**\n * Member post publish (AGL-316): manager-gated; writes the post and\n * optionally emails the entitled subscribers (product-scoped or all\n * live subscribers) through the env-gated Resend pipeline.\n */\nexport const memberPostHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const title = String(body.title ?? '').trim().slice(0, 160)\n const postBody = String(body.body ?? '').trim().slice(0, 5000)\n const productId = String(body.productId ?? '').trim()\n const emailSubscribers = Boolean(body.emailSubscribers)\n if (!hostId || !title) {\n return res.status(400).json({ error: 'Missing hostId or title' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n // AN ALLOWLIST (AGL-2372), matching `gift-cards.ts` and the AGL-2262\n // correction to the register. The old `!role || role === 'viewer'`\n // denylist admitted every string that was not literally `viewer` — and\n // since AGL-2334 that includes `author`, a real grantable role, on a route\n // that publishes to paying subscribers and sends them mail on the\n // merchant's brand. A denylist that widens whenever the role union grows\n // is the defect; the set is the fix. Absent is refused, not permitted.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n const postRef = await hostRef.collection('memberPosts').add({\n title,\n body: postBody,\n ...(productId ? { productId } : {}),\n createdAtMs: Date.now(),\n createdBy: decoded.uid,\n })\n\n let emailed = 0\n if (emailSubscribers && isEmailConfigured()) {\n /*\n * ORDERED (D1). This was `limit(500)` with no `orderBy`, and Firestore\n * answers an unordered limit in DOCUMENT-ID order — so a site past the\n * ceiling mailed whichever five hundred subscriptions happened to hash\n * low, and the same five hundred every time. `__name__` is the order it\n * was already getting; stating it makes the window a decision rather\n * than an accident, and makes it the same window on every publish.\n */\n const subscriptions = await hostRef\n .collection('subscriptions')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(500)\n .get()\n const recipients = [\n ...new Set(\n subscriptions.docs\n .filter(\n (docSnapshot) =>\n CommerceModel.isTenantSubscriptionLive(\n docSnapshot.get('status'),\n ) &&\n (!productId ||\n docSnapshot.get('productId') === productId) &&\n docSnapshot.get('customerEmail'),\n )\n .map((docSnapshot) => String(docSnapshot.get('customerEmail'))),\n ),\n ].slice(0, 200)\n // White-label sender identity (White-Label Phase 3): the store's brand\n // via the one shared resolver, from the owning org doc.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const branding = resolveBrandingProfile(owner?.org as never)\n // The site's consent group, off the same org read: a member who left\n // any site the org declared one sender with this one is not mailed.\n const consentGroup = consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n )\n // The site's own origin, for the unsubscribe link the gate mints. Read\n // from the host document already in hand rather than assembled from an\n // apex: a post is mailed and read later, and a wrong origin sends a\n // subscriber's opt-out to a domain the merchant does not control.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n /*\n * ONE site, up to two hundred recipients — so the identity is resolved\n * once and reused, not read per message. Hoisted rather than cached\n * inside the helper for the reason its docblock gives: verification can\n * change under a long-lived process, so how long a resolution may be\n * trusted is the caller's declaration.\n */\n const identity = await hostSendingIdentity(hostId)\n // The `member-post` site email (AGL-3370): the site's design, or the\n // built-in copy in its header and footer. Read once for the batch, and\n // the same message for everyone, so rendered once too.\n const designed = await loadHostEmailWithTokens(\n firestore,\n hostId,\n 'member-post',\n siteBase ? { origin: siteBase } : {},\n )\n .then((bundle) =>\n bundle\n ? renderLoadedHostEmailWithTokens(bundle, {\n 'post.title': title,\n 'post.body': postBody,\n })\n : null,\n )\n .catch(() => null)\n for (const to of recipients) {\n /*\n * MARKETING. A member post is a merchant mailing their audience, so\n * it carries the unsubscribe header pair and a visible link, it is\n * checked against both suppression lists, and it counts against how\n * much mail one person receives from this site.\n *\n * Priority stays transactional — this handler answers a click and has\n * nowhere to put a message the hourly governor deferred, and the rule\n * on `'bulk'` is that only a resumable sweep may use it.\n */\n const result = await sendEmail({\n to,\n subject: designed?.subject || title,\n text: designed?.text || postBody || title,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'member post',\n /*\n * A post to a site's members is the \"Newsletter\" stream — regular\n * news and stories from the site. Declared so a member who left\n * that stream, while staying subscribed to everything else, is not\n * mailed it; the gate asks the topic only for callers that name\n * one.\n */\n marketing: {\n hostId,\n siteBase,\n topicId: EMAIL_TOPIC_NEWSLETTER,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n // The cost meter counts messages that LEFT. A suppressed or capped\n // recipient produced no message and therefore no cost, and counting\n // one would bill a merchant for the control working.\n if (result.sent) emailed += 1\n }\n // Cost meter (AGL-1438), once for the batch rather than per recipient —\n // same number, one write. Transactional: a member post is content the\n // subscriber is paying for, not a discretionary campaign, so it counts\n // toward cost and is never refused by the campaign cap.\n await meterHostEmail(hostId, emailed)\n }\n return res.status(200).json({ postId: postRef.id, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","CommerceModel","isEmailConfigured","sendEmail","consentGroupForHost","EMAIL_TOPIC_NEWSLETTER","hostPublicOrigin","resolveBrandingProfile","memberPostHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","title","trim","postBody","productId","emailSubscribers","Boolean","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","memberRole","uid","postRef","add","createdAtMs","Date","now","createdBy","emailed","subscriptions","orderBy","FieldPath","documentId","limit","recipients","Set","docs","filter","docSnapshot","isTenantSubscriptionLive","map","owner","catch","branding","org","consentGroup","siteBase","cname","subdomain","identity","designed","origin","then","bundle","to","result","subject","text","html","fromName","sendingIdentity","audience","context","marketing","topicId","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","sent","postId","id","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-post.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 firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport * as CommerceModel from '../model'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n consentGroupForHost,\n hostPublicOrigin,\n type PluginApiHandler,\n resolveBrandingProfile,\n} from '@aglyn/aglyn/server'\n\n// Which member subscribers are live enough to email (AGL-316). The list itself\n// now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a\n// private copy here and a second private copy in `gate.ts`, which is the drift\n// AGL-1715 guards against on the org side.\n\n/**\n * Member post publish (AGL-316): manager-gated; writes the post and\n * optionally emails the entitled subscribers (product-scoped or all\n * live subscribers) through the env-gated Resend pipeline.\n */\nexport const memberPostHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const title = String(body.title ?? '').trim().slice(0, 160)\n const postBody = String(body.body ?? '').trim().slice(0, 5000)\n const productId = String(body.productId ?? '').trim()\n const emailSubscribers = Boolean(body.emailSubscribers)\n if (!hostId || !title) {\n return res.status(400).json({ error: 'Missing hostId or title' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n // AN ALLOWLIST (AGL-2372), matching `gift-cards.ts` and the AGL-2262\n // correction to the register. The old `!role || role === 'viewer'`\n // denylist admitted every string that was not literally `viewer` — and\n // since AGL-2334 that includes `author`, a real grantable role, on a route\n // that publishes to paying subscribers and sends them mail on the\n // merchant's brand. A denylist that widens whenever the role union grows\n // is the defect; the set is the fix. Absent is refused, not permitted.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n const postRef = await hostRef.collection('memberPosts').add({\n title,\n body: postBody,\n ...(productId ? { productId } : {}),\n createdAtMs: Date.now(),\n createdBy: decoded.uid,\n })\n\n let emailed = 0\n if (emailSubscribers && isEmailConfigured()) {\n /*\n * ORDERED (D1). This was `limit(500)` with no `orderBy`, and Firestore\n * answers an unordered limit in DOCUMENT-ID order — so a site past the\n * ceiling mailed whichever five hundred subscriptions happened to hash\n * low, and the same five hundred every time. `__name__` is the order it\n * was already getting; stating it makes the window a decision rather\n * than an accident, and makes it the same window on every publish.\n */\n const subscriptions = await hostRef\n .collection('subscriptions')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(500)\n .get()\n const recipients = [\n ...new Set(\n subscriptions.docs\n .filter(\n (docSnapshot) =>\n CommerceModel.isTenantSubscriptionLive(\n docSnapshot.get('status'),\n ) &&\n (!productId ||\n docSnapshot.get('productId') === productId) &&\n docSnapshot.get('customerEmail'),\n )\n .map((docSnapshot) => String(docSnapshot.get('customerEmail'))),\n ),\n ].slice(0, 200)\n // White-label sender identity (White-Label Phase 3): the store's brand\n // via the one shared resolver, from the owning org doc.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const branding = resolveBrandingProfile(owner?.org as never)\n // The site's consent group, off the same org read: a member who left\n // any site the org declared one sender with this one is not mailed.\n const consentGroup = consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n )\n // The site's own origin, for the unsubscribe link the gate mints. Read\n // from the host document already in hand rather than assembled from an\n // apex: a post is mailed and read later, and a wrong origin sends a\n // subscriber's opt-out to a domain the merchant does not control.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n /*\n * ONE site, up to two hundred recipients — so the identity is resolved\n * once and reused, not read per message. Hoisted rather than cached\n * inside the helper for the reason its docblock gives: verification can\n * change under a long-lived process, so how long a resolution may be\n * trusted is the caller's declaration.\n */\n const identity = await hostSendingIdentity(hostId)\n // The `member-post` site email (AGL-3370): the site's design, or the\n // built-in copy in its header and footer. Read once for the batch, and\n // the same message for everyone, so rendered once too.\n const designed = await loadHostEmailWithTokens(\n firestore,\n hostId,\n 'member-post',\n siteBase ? { origin: siteBase } : {},\n )\n .then((bundle) =>\n bundle\n ? renderLoadedHostEmailWithTokens(bundle, {\n 'post.title': title,\n 'post.body': postBody,\n })\n : null,\n )\n .catch(() => null)\n for (const to of recipients) {\n /*\n * MARKETING. A member post is a merchant mailing their audience, so\n * it carries the unsubscribe header pair and a visible link, it is\n * checked against both suppression lists, and it counts against how\n * much mail one person receives from this site.\n *\n * Priority stays transactional — this handler answers a click and has\n * nowhere to put a message the hourly governor deferred, and the rule\n * on `'bulk'` is that only a resumable sweep may use it.\n */\n const result = await sendEmail({\n to,\n subject: designed?.subject || title,\n text: designed?.text || postBody || title,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'member post',\n /*\n * A post to a site's members is the \"Newsletter\" stream — regular\n * news and stories from the site. Declared so a member who left\n * that stream, while staying subscribed to everything else, is not\n * mailed it; the gate asks the topic only for callers that name\n * one.\n */\n marketing: {\n hostId,\n siteBase,\n topicId: CommerceModel.NEWSLETTER_TOPIC_ID,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n // The cost meter counts messages that LEFT. A suppressed or capped\n // recipient produced no message and therefore no cost, and counting\n // one would bill a merchant for the control working.\n if (result.sent) emailed += 1\n }\n // Cost meter (AGL-1438), once for the batch rather than per recipient —\n // same number, one write. Transactional: a member post is content the\n // subscriber is paying for, not a discretionary campaign, so it counts\n // toward cost and is never refused by the campaign cap.\n await meterHostEmail(hostId, emailed)\n }\n return res.status(200).json({ postId: postRef.id, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","CommerceModel","isEmailConfigured","sendEmail","consentGroupForHost","hostPublicOrigin","resolveBrandingProfile","memberPostHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","title","trim","postBody","productId","emailSubscribers","Boolean","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","memberRole","uid","postRef","add","createdAtMs","Date","now","createdBy","emailed","subscriptions","orderBy","FieldPath","documentId","limit","recipients","Set","docs","filter","docSnapshot","isTenantSubscriptionLive","map","owner","catch","branding","org","consentGroup","siteBase","cname","subdomain","identity","designed","origin","then","bundle","to","result","subject","text","html","fromName","sendingIdentity","audience","context","marketing","topicId","NEWSLETTER_TOPIC_ID","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","sent","postId","id","console"],"mappings":"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"}
@@ -222,10 +222,13 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
222
222
  }, siteBase ? {
223
223
  origin: siteBase
224
224
  } : {}).catch(()=>null);
225
+ // How to get back in (AGL-3432): the member does not know the password
226
+ // the administrator chose, so the email says to ask for it or to set
227
+ // their own through "Forgot password?".
225
228
  const result = await sendEmail(_extends({
226
229
  to: email,
227
230
  subject: (designed == null ? void 0 : designed.subject) || `Your ${siteName} password was changed`,
228
- text: (designed == null ? void 0 : designed.text) || `An administrator of ${siteName} set a new password on your ` + 'account. You have been signed out on every device and will need ' + `the new password to sign back in at ${siteBase}.\n\n` + 'If you did not expect this, contact the site owner.'
231
+ text: (designed == null ? void 0 : designed.text) || `An administrator of ${siteName} set a new password on your ` + 'account and signed you out on every device. To sign back in at ' + `${siteBase}, get the new password from them, or choose ` + '"Forgot password?" on the sign-in page to set your own.\n\n' + 'If you did not expect this, contact the site owner.'
229
232
  }, (designed == null ? void 0 : designed.html) ? {
230
233
  html: designed.html
231
234
  } : {}, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-admin-password.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 type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile, validateNewPassword } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { renderHostEmailWithTokens } from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport {\n consumePasswordResetSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isImpersonationSession,\n meterHostEmail,\n passwordResetThrottleMessage,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n hashMemberPassword,\n MEMBER_SESSIONS_VALID_FROM_FIELD,\n mintPasswordResetToken,\n} from './membership'\nimport {\n isMemberDocumentId,\n memberCredentialRef,\n readMemberPasswordHash,\n retiredCredentialFields,\n} from './member-credentials'\n\n/**\n * Console-side password help for a SITE MEMBER (AGL-914) — a visitor account\n * on a published site, not an Aglyn console user.\n *\n * Sibling to `membership-recover`, and deliberately its opposite in tone.\n * That endpoint is public, so it answers `{ok:true}` no matter what to avoid\n * confirming whether an address has an account. This one is driven by a site\n * admin looking straight at the member's record — they already know the\n * account exists, so hiding failures would only leave them guessing whether\n * their click did anything. Every outcome here is reported honestly.\n *\n * Auth is the console id-token check `hosts/members` uses (site admin via\n * the `memberRoles` projection, or an org role carrying manageMembers) — NOT\n * the visitor session cookie the rest of the membership endpoints take. A\n * console route, so it is registered on the console's API surface.\n *\n * The hash is written to the member's credential document (AGL-3308), which\n * no client can read, and any credential field on the profile is deleted in\n * the same batch.\n */\nexport const membershipAdminPasswordHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers['authorization'] ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n const hostId = String(req.body?.hostId ?? '')\n const memberId = String(req.body?.memberId ?? '')\n const action = String(req.body?.action ?? '')\n if (!hostId || !memberId) {\n return res.status(400).json({ error: 'Missing hostId or memberId' })\n }\n if (!isMemberDocumentId(memberId)) {\n return res.status(400).json({ error: 'Unknown member' })\n }\n if (action !== 'sendPasswordReset' && action !== 'setPassword') {\n return res.status(400).json({ error: 'Unknown action' })\n }\n\n try {\n const app = firebaseAdmin.app()\n const decoded = await app.auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n // Same shape as emailUnverifiedResponse (AGL-479), written by hand\n // because that helper returns a fetch `Response` and this is a\n // (req, res) plugin handler.\n return res.status(403).json({\n error: 'Verify your email to continue',\n reason: 'email-unverified',\n })\n }\n const firestore = app.firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n const host = hostSnapshot.data()\n const memberRole = (host?.['memberRoles'] ?? {})[decoded.uid]\n const orgPermissions = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !host ||\n (memberRole !== 'admin' && !orgPermissions.permissions.manageMembers)\n ) {\n return res.status(403).json({ error: 'Not a site admin' })\n }\n\n const memberRef = hostRef.collection('siteMembers').doc(memberId)\n const memberSnapshot = await memberRef.get()\n if (!memberSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown member' })\n }\n const email = String(memberSnapshot.get('email') ?? '')\n if (!email) {\n return res.status(400).json({ error: 'This member has no email address' })\n }\n\n // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is\n // minted server-side and mailed out, so a wrong apex is not a display\n // bug — it is a live link on somebody else's domain.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'the site',\n )\n // White-label sender identity (White-Label Phase 3): the store's brand via\n // the one shared resolver; the copy already uses the site name.\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n\n if (action === 'sendPasswordReset') {\n // Throttled per recipient and per actor (AGL-920). Same caps as the\n // console surfaces — a site member's inbox is no less worth protecting\n // than a console user's.\n const throttle = await consumePasswordResetSend({\n actorKey: decoded.uid,\n recipientKey: email,\n })\n if (!throttle.allowed) {\n res.setHeader('Retry-After', String(throttle.retryAfterSeconds))\n return res\n .status(429)\n .json({ error: passwordResetThrottleMessage(throttle) })\n }\n if (!isEmailConfigured()) {\n return res.status(502).json({\n error: 'Email is not configured, so no reset link could be sent',\n })\n }\n // Same single-use, hash-bound token the self-serve flow mints\n // (AGL-552) — completing the reset rewrites `passwordScrypt`, which\n // retires the token without any server-side storage.\n const token = mintPasswordResetToken(\n hostId,\n memberId,\n await readMemberPasswordHash(hostRef, memberSnapshot),\n )\n const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const intro = `An administrator of ${siteName} started a password reset for your account.`\n const note =\n 'The link works once and expires in 1 hour. Your current ' +\n 'password keeps working until you use it.'\n // The `member-password-reset` site email (AGL-3370), as the member's\n // own request sends it, with the words for who started it.\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'member-password-reset',\n { 'site.name': siteName, 'reset.intro': intro, resetUrl, 'reset.note': note },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n const result = await sendEmail({\n to: email,\n subject: designed?.subject || `Reset your ${siteName} password`,\n text:\n designed?.text ||\n `${intro.replace(/\\.$/, '')}. Set a new password here:\\n\\n` +\n `${resetUrl}\\n\\n${note}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin recovery',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n if (!result.sent) {\n return res.status(502).json({\n error: 'The reset email could not be delivered',\n })\n }\n // Cost meter (AGL-1438). Transactional: a reset a quota refused leaves\n // the member locked out of a site they pay for.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n }\n\n const validated = validateNewPassword(req.body?.password)\n if (validated.error) {\n return res.status(400).json({ error: validated.error })\n }\n // Rewriting the hash alone would NOT sign the member out: their session\n // is an HMAC cookie that never encodes the password (unlike a reset\n // token, which binds to it on purpose). The cut-off field is what\n // actually revokes the old sessions — see readActiveMemberSession. It\n // stays on the profile, which every session check already reads.\n //\n // One batch: the profile `update` refuses a member removed since the\n // read above, and that refusal takes the credential write with it.\n const batch = firestore.batch()\n batch.set(\n memberCredentialRef(hostRef, memberId),\n { passwordScrypt: hashMemberPassword(validated.password) },\n { merge: true },\n )\n batch.update(memberRef, {\n ...retiredCredentialFields(),\n [MEMBER_SESSIONS_VALID_FROM_FIELD]: Date.now(),\n })\n await batch.commit()\n // Best-effort: the password has already changed, so a mail failure must\n // not fail the request and send the admin round again. Report it so\n // they know to reach the member another way.\n let notified = false\n if (isEmailConfigured()) {\n // The `member-password-changed` site email (AGL-3370).\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'member-password-changed',\n { 'site.name': siteName, signInUrl: siteBase },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n const result = await sendEmail({\n to: email,\n subject: designed?.subject || `Your ${siteName} password was changed`,\n text:\n designed?.text ||\n `An administrator of ${siteName} set a new password on your ` +\n 'account. You have been signed out on every device and will need ' +\n `the new password to sign back in at ${siteBase}.\\n\\n` +\n 'If you did not expect this, contact the site owner.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin password change',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n notified = result.sent\n // Cost meter (AGL-1438). Transactional: a security notice.\n if (result.sent) await meterHostEmail(hostId)\n }\n return res.status(200).json({ ok: true, notified })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Password operation failed' })\n }\n}\n"],"names":["resolveBrandingProfile","validateNewPassword","hostPublicOrigin","isEmailConfigured","sendEmail","renderHostEmailWithTokens","consumePasswordResetSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isImpersonationSession","meterHostEmail","passwordResetThrottleMessage","resolveOrgPermissions","hashMemberPassword","MEMBER_SESSIONS_VALID_FROM_FIELD","mintPasswordResetToken","isMemberDocumentId","memberCredentialRef","readMemberPasswordHash","retiredCredentialFields","membershipAdminPasswordHandler","req","res","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","hostId","body","memberId","action","memberSnapshot","hostSnapshot","app","decoded","auth","verifyIdToken","email_verified","reason","firestore","hostRef","collection","doc","get","host","data","memberRole","uid","orgPermissions","permissions","manageMembers","memberRef","exists","email","siteBase","cname","subdomain","siteName","branding","catch","org","throttle","actorKey","recipientKey","allowed","setHeader","retryAfterSeconds","token","resetUrl","encodeURIComponent","intro","note","designed","origin","result","to","subject","text","replace","html","fromName","sendingIdentity","audience","context","owedFor","sent","ok","validated","password","batch","set","passwordScrypt","merge","update","Date","now","commit","notified","signInUrl","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-admin-password.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 type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile, validateNewPassword } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { renderHostEmailWithTokens } from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport {\n consumePasswordResetSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isImpersonationSession,\n meterHostEmail,\n passwordResetThrottleMessage,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n hashMemberPassword,\n MEMBER_SESSIONS_VALID_FROM_FIELD,\n mintPasswordResetToken,\n} from './membership'\nimport {\n isMemberDocumentId,\n memberCredentialRef,\n readMemberPasswordHash,\n retiredCredentialFields,\n} from './member-credentials'\n\n/**\n * Console-side password help for a SITE MEMBER (AGL-914) — a visitor account\n * on a published site, not an Aglyn console user.\n *\n * Sibling to `membership-recover`, and deliberately its opposite in tone.\n * That endpoint is public, so it answers `{ok:true}` no matter what to avoid\n * confirming whether an address has an account. This one is driven by a site\n * admin looking straight at the member's record — they already know the\n * account exists, so hiding failures would only leave them guessing whether\n * their click did anything. Every outcome here is reported honestly.\n *\n * Auth is the console id-token check `hosts/members` uses (site admin via\n * the `memberRoles` projection, or an org role carrying manageMembers) — NOT\n * the visitor session cookie the rest of the membership endpoints take. A\n * console route, so it is registered on the console's API surface.\n *\n * The hash is written to the member's credential document (AGL-3308), which\n * no client can read, and any credential field on the profile is deleted in\n * the same batch.\n */\nexport const membershipAdminPasswordHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers['authorization'] ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n const hostId = String(req.body?.hostId ?? '')\n const memberId = String(req.body?.memberId ?? '')\n const action = String(req.body?.action ?? '')\n if (!hostId || !memberId) {\n return res.status(400).json({ error: 'Missing hostId or memberId' })\n }\n if (!isMemberDocumentId(memberId)) {\n return res.status(400).json({ error: 'Unknown member' })\n }\n if (action !== 'sendPasswordReset' && action !== 'setPassword') {\n return res.status(400).json({ error: 'Unknown action' })\n }\n\n try {\n const app = firebaseAdmin.app()\n const decoded = await app.auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n // Same shape as emailUnverifiedResponse (AGL-479), written by hand\n // because that helper returns a fetch `Response` and this is a\n // (req, res) plugin handler.\n return res.status(403).json({\n error: 'Verify your email to continue',\n reason: 'email-unverified',\n })\n }\n const firestore = app.firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n const host = hostSnapshot.data()\n const memberRole = (host?.['memberRoles'] ?? {})[decoded.uid]\n const orgPermissions = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !host ||\n (memberRole !== 'admin' && !orgPermissions.permissions.manageMembers)\n ) {\n return res.status(403).json({ error: 'Not a site admin' })\n }\n\n const memberRef = hostRef.collection('siteMembers').doc(memberId)\n const memberSnapshot = await memberRef.get()\n if (!memberSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown member' })\n }\n const email = String(memberSnapshot.get('email') ?? '')\n if (!email) {\n return res.status(400).json({ error: 'This member has no email address' })\n }\n\n // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is\n // minted server-side and mailed out, so a wrong apex is not a display\n // bug — it is a live link on somebody else's domain.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'the site',\n )\n // White-label sender identity (White-Label Phase 3): the store's brand via\n // the one shared resolver; the copy already uses the site name.\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n\n if (action === 'sendPasswordReset') {\n // Throttled per recipient and per actor (AGL-920). Same caps as the\n // console surfaces — a site member's inbox is no less worth protecting\n // than a console user's.\n const throttle = await consumePasswordResetSend({\n actorKey: decoded.uid,\n recipientKey: email,\n })\n if (!throttle.allowed) {\n res.setHeader('Retry-After', String(throttle.retryAfterSeconds))\n return res\n .status(429)\n .json({ error: passwordResetThrottleMessage(throttle) })\n }\n if (!isEmailConfigured()) {\n return res.status(502).json({\n error: 'Email is not configured, so no reset link could be sent',\n })\n }\n // Same single-use, hash-bound token the self-serve flow mints\n // (AGL-552) — completing the reset rewrites `passwordScrypt`, which\n // retires the token without any server-side storage.\n const token = mintPasswordResetToken(\n hostId,\n memberId,\n await readMemberPasswordHash(hostRef, memberSnapshot),\n )\n const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const intro = `An administrator of ${siteName} started a password reset for your account.`\n const note =\n 'The link works once and expires in 1 hour. Your current ' +\n 'password keeps working until you use it.'\n // The `member-password-reset` site email (AGL-3370), as the member's\n // own request sends it, with the words for who started it.\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'member-password-reset',\n { 'site.name': siteName, 'reset.intro': intro, resetUrl, 'reset.note': note },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n const result = await sendEmail({\n to: email,\n subject: designed?.subject || `Reset your ${siteName} password`,\n text:\n designed?.text ||\n `${intro.replace(/\\.$/, '')}. Set a new password here:\\n\\n` +\n `${resetUrl}\\n\\n${note}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin recovery',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n if (!result.sent) {\n return res.status(502).json({\n error: 'The reset email could not be delivered',\n })\n }\n // Cost meter (AGL-1438). Transactional: a reset a quota refused leaves\n // the member locked out of a site they pay for.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n }\n\n const validated = validateNewPassword(req.body?.password)\n if (validated.error) {\n return res.status(400).json({ error: validated.error })\n }\n // Rewriting the hash alone would NOT sign the member out: their session\n // is an HMAC cookie that never encodes the password (unlike a reset\n // token, which binds to it on purpose). The cut-off field is what\n // actually revokes the old sessions — see readActiveMemberSession. It\n // stays on the profile, which every session check already reads.\n //\n // One batch: the profile `update` refuses a member removed since the\n // read above, and that refusal takes the credential write with it.\n const batch = firestore.batch()\n batch.set(\n memberCredentialRef(hostRef, memberId),\n { passwordScrypt: hashMemberPassword(validated.password) },\n { merge: true },\n )\n batch.update(memberRef, {\n ...retiredCredentialFields(),\n [MEMBER_SESSIONS_VALID_FROM_FIELD]: Date.now(),\n })\n await batch.commit()\n // Best-effort: the password has already changed, so a mail failure must\n // not fail the request and send the admin round again. Report it so\n // they know to reach the member another way.\n let notified = false\n if (isEmailConfigured()) {\n // The `member-password-changed` site email (AGL-3370).\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'member-password-changed',\n { 'site.name': siteName, signInUrl: siteBase },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n // How to get back in (AGL-3432): the member does not know the password\n // the administrator chose, so the email says to ask for it or to set\n // their own through \"Forgot password?\".\n const result = await sendEmail({\n to: email,\n subject: designed?.subject || `Your ${siteName} password was changed`,\n text:\n designed?.text ||\n `An administrator of ${siteName} set a new password on your ` +\n 'account and signed you out on every device. To sign back in at ' +\n `${siteBase}, get the new password from them, or choose ` +\n '\"Forgot password?\" on the sign-in page to set your own.\\n\\n' +\n 'If you did not expect this, contact the site owner.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin password change',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n notified = result.sent\n // Cost meter (AGL-1438). Transactional: a security notice.\n if (result.sent) await meterHostEmail(hostId)\n }\n return res.status(200).json({ ok: true, notified })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Password operation failed' })\n }\n}\n"],"names":["resolveBrandingProfile","validateNewPassword","hostPublicOrigin","isEmailConfigured","sendEmail","renderHostEmailWithTokens","consumePasswordResetSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isImpersonationSession","meterHostEmail","passwordResetThrottleMessage","resolveOrgPermissions","hashMemberPassword","MEMBER_SESSIONS_VALID_FROM_FIELD","mintPasswordResetToken","isMemberDocumentId","memberCredentialRef","readMemberPasswordHash","retiredCredentialFields","membershipAdminPasswordHandler","req","res","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","hostId","body","memberId","action","memberSnapshot","hostSnapshot","app","decoded","auth","verifyIdToken","email_verified","reason","firestore","hostRef","collection","doc","get","host","data","memberRole","uid","orgPermissions","permissions","manageMembers","memberRef","exists","email","siteBase","cname","subdomain","siteName","branding","catch","org","throttle","actorKey","recipientKey","allowed","setHeader","retryAfterSeconds","token","resetUrl","encodeURIComponent","intro","note","designed","origin","result","to","subject","text","replace","html","fromName","sendingIdentity","audience","context","owedFor","sent","ok","validated","password","batch","set","passwordScrypt","merge","update","Date","now","commit","notified","signInUrl","console"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  import { memberNameSearchFields, memberSearchTokens } from "./member-name-search.js";
18
18
  import { checkVisitorRecordCeiling, HOST_TOKENS, marketingConsentFieldsForHost, SITE_MEMBER_CEILING_CODE, SITE_MEMBER_UNAVAILABLE_MESSAGE, SITE_MEMBERS_MAX_PER_HOST } from "@aglyn/aglyn/server";
19
- import { firebaseAdmin, recordVisitorRecordCeilingTrip, resolveCampaignTouch } from "@aglyn/tenant-data-admin";
19
+ import { firebaseAdmin, rearmVisitorRecordCeilingNotice, recordVisitorRecordCeilingTrip, resolveCampaignTouch } from "@aglyn/tenant-data-admin";
20
20
  import { emitHostEvent } from "@aglyn/tenant-runtime";
21
21
  import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
22
22
  import { hashMemberPassword, mintMemberSession, setMemberCookie } from "./membership.js";
@@ -106,13 +106,16 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
106
106
  *
107
107
  * A refusal is returned as DATA and rendered outside: a body that can run
108
108
  * several times must not be the place a response is built.
109
- */ const refusal = await firestore.runTransaction(async (tx)=>{
109
+ */ // The count the new account was judged against, for the notice re-arm.
110
+ let usedBeforeCreate = 0;
111
+ const refusal = await firestore.runTransaction(async (tx)=>{
110
112
  const existing = await tx.get(membersRef.where('email', '==', email).limit(1));
111
113
  if (!existing.empty) return {
112
114
  duplicate: true,
113
115
  ceiling: 0
114
116
  };
115
117
  const used = (await tx.get(membersRef.count())).data().count;
118
+ usedBeforeCreate = used;
116
119
  // Live documents only, so removing a member in the inbox frees the slot.
117
120
  const verdict = checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST);
118
121
  if (verdict.exceeded) return {
@@ -173,6 +176,14 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
173
176
  contact
174
177
  } : {}));
175
178
  }
179
+ // The account took a slot below the ceiling. When it took the last one,
180
+ // the next refusal is a new crossing and is announced again (AGL-3442).
181
+ await rearmVisitorRecordCeilingNotice({
182
+ hostRef,
183
+ kind: 'siteMembers',
184
+ used: usedBeforeCreate,
185
+ ceiling: SITE_MEMBERS_MAX_PER_HOST
186
+ });
176
187
  // Sign-ups double as leads for the site owner (AGL-109), through the one
177
188
  // writer that enforces `LEADS_MAX_PER_HOST` (AGL-1529). A refused lead
178
189
  // never fails the sign-up: the visitor asked for an account, not for a
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-register.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 type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { memberNameSearchFields, memberSearchTokens } from './member-name-search'\nimport {\n checkVisitorRecordCeiling,\n HOST_TOKENS,\n marketingConsentFieldsForHost,\n SITE_MEMBER_CEILING_CODE,\n SITE_MEMBER_UNAVAILABLE_MESSAGE,\n SITE_MEMBERS_MAX_PER_HOST,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n recordVisitorRecordCeilingTrip,\n resolveCampaignTouch,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n hashMemberPassword,\n mintMemberSession,\n setMemberCookie,\n} from './membership'\nimport { memberCredentialRef } from './member-credentials'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Site member sign-up (AGL-109): creates the member record and its credential\n * document (the scrypt hash, AGL-3308), doubles as a lead, and signs the\n * visitor in via the session cookie.\n */\nexport const membershipRegisterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n const password = String(req.body?.password ?? '')\n const displayName = String(req.body?.displayName ?? '')\n .trim()\n .slice(0, 80)\n // Explicit opt-in checkbox (AGL-2499) — never inferred from signing up.\n const marketingConsent = req.body?.marketingConsent === true\n if (!hostId) return res.status(400).json({ error: 'Missing host' })\n if (!EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // Member accounts are UNLIMITED on every plan (AGL-889) — no seat or\n // quota check belongs here. Audience monetization happens downstream:\n // contact bands meter the CRM projection (AGL-890) and paid\n // memberships carry the plan's digital transaction fee (AGL-892).\n //\n // `SITE_MEMBERS_MAX_PER_HOST` below is NOT that check and does not make\n // it one (AGL-1529). It is a flat PLATFORM ceiling — same number on every\n // plan, no `OrgEntitlements` key, nothing on the price list — and it\n // exists because this handler is reachable by an ANONYMOUS VISITOR, so\n // without it the collection was bounded only by a per-(host, IP) rate\n // limiter that fails soft and bounds the RATE rather than the TOTAL. An\n // abuse control is not something we sell; \"unlimited member accounts on\n // every plan\" stays literally true.\n const membersRef = hostRef.collection('siteMembers')\n const memberRef = membersRef.doc()\n /*\n * COUNT, DEDUPE AND CREATE IN ONE TRANSACTION (AGL-1529, the AGL-2231\n * treatment).\n *\n * Read-then-decide-then-`set()` is the create-time quota that laundered\n * everywhere else in this repo: N concurrent sign-ups each read the same\n * pre-count, each find room, and each land — and nothing re-counts\n * afterwards, so the extra accounts are permanent. The fix is WHEN the\n * count is evaluated, not the counting rule.\n * `Transaction.get(AggregateQuery)` serialises the count against a\n * concurrent create into this collection: the loser retries, re-reads the\n * higher count and is refused. The duplicate-email read moves inside for\n * free, which also closes the smaller race it always had — two\n * simultaneous sign-ups on one address both saw `empty` and both wrote.\n *\n * ALL READS BEFORE THE WRITE, which Firestore requires.\n *\n * The scrypt hash sits between the last read and the create ON PURPOSE.\n * It is ~100 ms of CPU and it is the most expensive thing in the request,\n * so a refused sign-up must never pay it — hashing before the transaction\n * would hand a flood a CPU amplifier on exactly the path the ceiling\n * exists to contain, and would also be a regression, since today a\n * duplicate-email refusal does not hash either. It is a pure computation,\n * not an effect, so it does not violate the \"nothing happens in a\n * transaction body\" rule the sibling routes state.\n *\n * A refusal is returned as DATA and rendered outside: a body that can run\n * several times must not be the place a response is built.\n */\n const refusal = await firestore.runTransaction(async (tx) => {\n const existing = await tx.get(membersRef.where('email', '==', email).limit(1))\n if (!existing.empty) return { duplicate: true, ceiling: 0 }\n const used = (await tx.get(membersRef.count())).data().count\n // Live documents only, so removing a member in the inbox frees the slot.\n const verdict = checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n if (verdict.exceeded) return { duplicate: false, ceiling: verdict.ceiling }\n // The hash goes to the credential document, which no client can read\n // (AGL-3308), and in this transaction, so a member never exists without\n // the password they chose. The profile below is what the console lists.\n tx.set(memberCredentialRef(hostRef, memberRef.id), {\n passwordScrypt: hashMemberPassword(password),\n })\n tx.create(memberRef, {\n email,\n ...(displayName ? memberNameSearchFields(displayName) : {}),\n // The Site users list's search: the name and the address (AGL-3321).\n searchTokens: memberSearchTokens(displayName, email),\n /*\n * Stored `false`, not left out: the console's Status filter asks\n * `suspended == false` for the active members, and a query cannot\n * find a document that lacks the field. The drawer's Suspend and\n * Reactivate write `true` and `false` over it.\n */\n suspended: false,\n /*\n * The checkbox is PERSISTED on the member, not only forwarded.\n *\n * It reached the lead and the contact from the two lines below and\n * was dropped for this document, so `hosts/{hostId}/siteMembers` had\n * no consent field of any kind and `audience: 'members'` had nothing\n * for the send-time join to read — the audience could not be filtered\n * even in principle (`docs/specs/email-overhaul.md` §1d/§3f). The\n * other two documents are not a substitute: a member is deduped in\n * this transaction while leads append every time, and contacts are\n * ORG-scoped where a member is the site's own.\n *\n * Written only when ticked. Signing up is not opting in — that is why\n * the checkbox exists and why it defaults unchecked — so the omitted\n * case stores nothing and reads back as an unrecorded basis rather\n * than as a refusal.\n *\n * Under this site's key even though `siteMembers` already lives\n * beneath it, so that one reader answers for every silo — see\n * `addHostLead`, which makes the same write for the same reason.\n */\n ...(marketingConsent\n ? marketingConsentFieldsForHost(hostId, Date.now())\n : {}),\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return null\n })\n if (refusal?.duplicate) {\n return res.status(409).json({ error: 'That email is already a member' })\n }\n if (refusal) {\n // Visible to the HOST — the rule that a control nobody can see in\n // the console did not ship. Durable counter + one notification.\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'siteMembers',\n ceiling: refusal.ceiling,\n })\n // Opaque to the VISITOR, who is a stranger to this site and not our\n // customer (AGL-1666's rules, restated in the message's own docblock).\n // The one genuinely useful thing to hand them is a door that still\n // opens, so the site's OWN published support address rides along when\n // it has one — read through the host-token registry, whose description\n // of this field is literally \"where visitors should write for help\".\n // Nothing else off the host document may leave the console.\n const contact = HOST_TOKENS['supportEmail']?.resolve(\n hostSnapshot.data() as any,\n )\n return res.status(429).json({\n error: SITE_MEMBER_UNAVAILABLE_MESSAGE,\n // Machine-readable, because the dispatcher's rate limiter answers 429\n // too and the status therefore discriminates nothing.\n code: SITE_MEMBER_CEILING_CODE,\n ...(contact ? { contact } : {}),\n })\n }\n // Sign-ups double as leads for the site owner (AGL-109), through the one\n // writer that enforces `LEADS_MAX_PER_HOST` (AGL-1529). A refused lead\n // never fails the sign-up: the visitor asked for an account, not for a\n // lead record, and the trip is recorded for the owner either way.\n /*\n * THE CAMPAIGN TOUCH, RESOLVED ONCE FOR THE WHOLE SIGN-UP.\n *\n * A sign-up is the identify moment for a visitor who has been anonymous\n * until now, and it writes two records that a campaign can be credited\n * with — the lead and the contact. One resolve, one keyed read, and the\n * two cannot end up naming different campaigns.\n *\n * The MEMBER record itself is not attributed. A member is an account the\n * visitor holds and the lead is the site's record of the same act, so\n * crediting both would count one sign-up twice under two names.\n */\n const signedUpAtMs = Date.now()\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: req.body?.campaignTouch,\n email,\n atMs: signedUpAtMs,\n })\n /*\n * Contacts ingestion (AGL-197), reported to whichever plugin keeps people\n * rather than written by this one (AGL-3080). Commerce knows it just met\n * somebody; what a person record is belongs to the plugin that models it,\n * and a workspace that keeps none is a quiet, correct answer here.\n */\n /*\n * A member account is a RELATIONSHIP (AGL-3232): the person is a\n * contact from this moment, and an open lead the site held for the\n * address is closed as converted onto that contact by the record\n * system — a sign-up files no lead of its own any more, the way a\n * portal user is a contact and not a lead in Salesforce.\n */\n void recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email, name: displayName || undefined },\n interaction: {\n source: 'member',\n refId: memberRef.id,\n summary: 'Joined as a member',\n },\n surface: 'relationship',\n // An account is a subscription to the site, not an enquiry (AGL-2612):\n // the stage on the contact says only that the person asked to be\n // kept. A floor, so a customer who opens an account stays a customer.\n lifecycleFloor: 'subscriber',\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n // Event trigger (AGL-128/148). A sign-up is no longer a lead (AGL-3232),\n // so the `lead` event is the lead surfaces' to emit.\n await emitHostEvent(\n hostId,\n 'memberSignUp',\n { email },\n { actor: { kind: 'visitor', email } },\n )\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberRef.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-up failed' })\n }\n}\n"],"names":["memberNameSearchFields","memberSearchTokens","checkVisitorRecordCeiling","HOST_TOKENS","marketingConsentFieldsForHost","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","SITE_MEMBERS_MAX_PER_HOST","firebaseAdmin","recordVisitorRecordCeilingTrip","resolveCampaignTouch","emitHostEvent","recordCapturedContact","hashMemberPassword","mintMemberSession","setMemberCookie","memberCredentialRef","EMAIL_PATTERN","membershipRegisterHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","displayName","slice","marketingConsent","test","length","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","membersRef","memberRef","refusal","runTransaction","tx","existing","where","limit","empty","duplicate","ceiling","used","count","data","verdict","exceeded","set","id","passwordScrypt","create","searchTokens","suspended","Date","now","createdAt","FieldValue","serverTimestamp","kind","contact","resolve","code","signedUpAtMs","campaignTouch","wire","atMs","orgId","identity","name","undefined","interaction","source","refId","summary","surface","lifecycleFloor","detail","actor","ok","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-register.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 type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { memberNameSearchFields, memberSearchTokens } from './member-name-search'\nimport {\n checkVisitorRecordCeiling,\n HOST_TOKENS,\n marketingConsentFieldsForHost,\n SITE_MEMBER_CEILING_CODE,\n SITE_MEMBER_UNAVAILABLE_MESSAGE,\n SITE_MEMBERS_MAX_PER_HOST,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n rearmVisitorRecordCeilingNotice,\n recordVisitorRecordCeilingTrip,\n resolveCampaignTouch,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n hashMemberPassword,\n mintMemberSession,\n setMemberCookie,\n} from './membership'\nimport { memberCredentialRef } from './member-credentials'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Site member sign-up (AGL-109): creates the member record and its credential\n * document (the scrypt hash, AGL-3308), doubles as a lead, and signs the\n * visitor in via the session cookie.\n */\nexport const membershipRegisterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n const password = String(req.body?.password ?? '')\n const displayName = String(req.body?.displayName ?? '')\n .trim()\n .slice(0, 80)\n // Explicit opt-in checkbox (AGL-2499) — never inferred from signing up.\n const marketingConsent = req.body?.marketingConsent === true\n if (!hostId) return res.status(400).json({ error: 'Missing host' })\n if (!EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // Member accounts are UNLIMITED on every plan (AGL-889) — no seat or\n // quota check belongs here. Audience monetization happens downstream:\n // contact bands meter the CRM projection (AGL-890) and paid\n // memberships carry the plan's digital transaction fee (AGL-892).\n //\n // `SITE_MEMBERS_MAX_PER_HOST` below is NOT that check and does not make\n // it one (AGL-1529). It is a flat PLATFORM ceiling — same number on every\n // plan, no `OrgEntitlements` key, nothing on the price list — and it\n // exists because this handler is reachable by an ANONYMOUS VISITOR, so\n // without it the collection was bounded only by a per-(host, IP) rate\n // limiter that fails soft and bounds the RATE rather than the TOTAL. An\n // abuse control is not something we sell; \"unlimited member accounts on\n // every plan\" stays literally true.\n const membersRef = hostRef.collection('siteMembers')\n const memberRef = membersRef.doc()\n /*\n * COUNT, DEDUPE AND CREATE IN ONE TRANSACTION (AGL-1529, the AGL-2231\n * treatment).\n *\n * Read-then-decide-then-`set()` is the create-time quota that laundered\n * everywhere else in this repo: N concurrent sign-ups each read the same\n * pre-count, each find room, and each land — and nothing re-counts\n * afterwards, so the extra accounts are permanent. The fix is WHEN the\n * count is evaluated, not the counting rule.\n * `Transaction.get(AggregateQuery)` serialises the count against a\n * concurrent create into this collection: the loser retries, re-reads the\n * higher count and is refused. The duplicate-email read moves inside for\n * free, which also closes the smaller race it always had — two\n * simultaneous sign-ups on one address both saw `empty` and both wrote.\n *\n * ALL READS BEFORE THE WRITE, which Firestore requires.\n *\n * The scrypt hash sits between the last read and the create ON PURPOSE.\n * It is ~100 ms of CPU and it is the most expensive thing in the request,\n * so a refused sign-up must never pay it — hashing before the transaction\n * would hand a flood a CPU amplifier on exactly the path the ceiling\n * exists to contain, and would also be a regression, since today a\n * duplicate-email refusal does not hash either. It is a pure computation,\n * not an effect, so it does not violate the \"nothing happens in a\n * transaction body\" rule the sibling routes state.\n *\n * A refusal is returned as DATA and rendered outside: a body that can run\n * several times must not be the place a response is built.\n */\n // The count the new account was judged against, for the notice re-arm.\n let usedBeforeCreate = 0\n const refusal = await firestore.runTransaction(async (tx) => {\n const existing = await tx.get(membersRef.where('email', '==', email).limit(1))\n if (!existing.empty) return { duplicate: true, ceiling: 0 }\n const used = (await tx.get(membersRef.count())).data().count\n usedBeforeCreate = used\n // Live documents only, so removing a member in the inbox frees the slot.\n const verdict = checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n if (verdict.exceeded) return { duplicate: false, ceiling: verdict.ceiling }\n // The hash goes to the credential document, which no client can read\n // (AGL-3308), and in this transaction, so a member never exists without\n // the password they chose. The profile below is what the console lists.\n tx.set(memberCredentialRef(hostRef, memberRef.id), {\n passwordScrypt: hashMemberPassword(password),\n })\n tx.create(memberRef, {\n email,\n ...(displayName ? memberNameSearchFields(displayName) : {}),\n // The Site users list's search: the name and the address (AGL-3321).\n searchTokens: memberSearchTokens(displayName, email),\n /*\n * Stored `false`, not left out: the console's Status filter asks\n * `suspended == false` for the active members, and a query cannot\n * find a document that lacks the field. The drawer's Suspend and\n * Reactivate write `true` and `false` over it.\n */\n suspended: false,\n /*\n * The checkbox is PERSISTED on the member, not only forwarded.\n *\n * It reached the lead and the contact from the two lines below and\n * was dropped for this document, so `hosts/{hostId}/siteMembers` had\n * no consent field of any kind and `audience: 'members'` had nothing\n * for the send-time join to read — the audience could not be filtered\n * even in principle (`docs/specs/email-overhaul.md` §1d/§3f). The\n * other two documents are not a substitute: a member is deduped in\n * this transaction while leads append every time, and contacts are\n * ORG-scoped where a member is the site's own.\n *\n * Written only when ticked. Signing up is not opting in — that is why\n * the checkbox exists and why it defaults unchecked — so the omitted\n * case stores nothing and reads back as an unrecorded basis rather\n * than as a refusal.\n *\n * Under this site's key even though `siteMembers` already lives\n * beneath it, so that one reader answers for every silo — see\n * `addHostLead`, which makes the same write for the same reason.\n */\n ...(marketingConsent\n ? marketingConsentFieldsForHost(hostId, Date.now())\n : {}),\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return null\n })\n if (refusal?.duplicate) {\n return res.status(409).json({ error: 'That email is already a member' })\n }\n if (refusal) {\n // Visible to the HOST — the rule that a control nobody can see in\n // the console did not ship. Durable counter + one notification.\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'siteMembers',\n ceiling: refusal.ceiling,\n })\n // Opaque to the VISITOR, who is a stranger to this site and not our\n // customer (AGL-1666's rules, restated in the message's own docblock).\n // The one genuinely useful thing to hand them is a door that still\n // opens, so the site's OWN published support address rides along when\n // it has one — read through the host-token registry, whose description\n // of this field is literally \"where visitors should write for help\".\n // Nothing else off the host document may leave the console.\n const contact = HOST_TOKENS['supportEmail']?.resolve(\n hostSnapshot.data() as any,\n )\n return res.status(429).json({\n error: SITE_MEMBER_UNAVAILABLE_MESSAGE,\n // Machine-readable, because the dispatcher's rate limiter answers 429\n // too and the status therefore discriminates nothing.\n code: SITE_MEMBER_CEILING_CODE,\n ...(contact ? { contact } : {}),\n })\n }\n // The account took a slot below the ceiling. When it took the last one,\n // the next refusal is a new crossing and is announced again (AGL-3442).\n await rearmVisitorRecordCeilingNotice({\n hostRef,\n kind: 'siteMembers',\n used: usedBeforeCreate,\n ceiling: SITE_MEMBERS_MAX_PER_HOST,\n })\n // Sign-ups double as leads for the site owner (AGL-109), through the one\n // writer that enforces `LEADS_MAX_PER_HOST` (AGL-1529). A refused lead\n // never fails the sign-up: the visitor asked for an account, not for a\n // lead record, and the trip is recorded for the owner either way.\n /*\n * THE CAMPAIGN TOUCH, RESOLVED ONCE FOR THE WHOLE SIGN-UP.\n *\n * A sign-up is the identify moment for a visitor who has been anonymous\n * until now, and it writes two records that a campaign can be credited\n * with — the lead and the contact. One resolve, one keyed read, and the\n * two cannot end up naming different campaigns.\n *\n * The MEMBER record itself is not attributed. A member is an account the\n * visitor holds and the lead is the site's record of the same act, so\n * crediting both would count one sign-up twice under two names.\n */\n const signedUpAtMs = Date.now()\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: req.body?.campaignTouch,\n email,\n atMs: signedUpAtMs,\n })\n /*\n * Contacts ingestion (AGL-197), reported to whichever plugin keeps people\n * rather than written by this one (AGL-3080). Commerce knows it just met\n * somebody; what a person record is belongs to the plugin that models it,\n * and a workspace that keeps none is a quiet, correct answer here.\n */\n /*\n * A member account is a RELATIONSHIP (AGL-3232): the person is a\n * contact from this moment, and an open lead the site held for the\n * address is closed as converted onto that contact by the record\n * system — a sign-up files no lead of its own any more, the way a\n * portal user is a contact and not a lead in Salesforce.\n */\n void recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email, name: displayName || undefined },\n interaction: {\n source: 'member',\n refId: memberRef.id,\n summary: 'Joined as a member',\n },\n surface: 'relationship',\n // An account is a subscription to the site, not an enquiry (AGL-2612):\n // the stage on the contact says only that the person asked to be\n // kept. A floor, so a customer who opens an account stays a customer.\n lifecycleFloor: 'subscriber',\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n // Event trigger (AGL-128/148). A sign-up is no longer a lead (AGL-3232),\n // so the `lead` event is the lead surfaces' to emit.\n await emitHostEvent(\n hostId,\n 'memberSignUp',\n { email },\n { actor: { kind: 'visitor', email } },\n )\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberRef.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-up failed' })\n }\n}\n"],"names":["memberNameSearchFields","memberSearchTokens","checkVisitorRecordCeiling","HOST_TOKENS","marketingConsentFieldsForHost","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","SITE_MEMBERS_MAX_PER_HOST","firebaseAdmin","rearmVisitorRecordCeilingNotice","recordVisitorRecordCeilingTrip","resolveCampaignTouch","emitHostEvent","recordCapturedContact","hashMemberPassword","mintMemberSession","setMemberCookie","memberCredentialRef","EMAIL_PATTERN","membershipRegisterHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","displayName","slice","marketingConsent","test","length","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","membersRef","memberRef","usedBeforeCreate","refusal","runTransaction","tx","existing","where","limit","empty","duplicate","ceiling","used","count","data","verdict","exceeded","set","id","passwordScrypt","create","searchTokens","suspended","Date","now","createdAt","FieldValue","serverTimestamp","kind","contact","resolve","code","signedUpAtMs","campaignTouch","wire","atMs","orgId","identity","name","undefined","interaction","source","refId","summary","surface","lifecycleFloor","detail","actor","ok","console"],"mappings":"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"}
@@ -15,7 +15,9 @@
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  import { hostPublicOrigin } from "@aglyn/aglyn/app-utils/host-naming";
18
- import { mergeEmailTopics, normalizeEmailTopic, topicRequiresDoubleOptIn, EMAIL_TOPICS_COLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
18
+ import { mergeSubscriptionTopics, normalizeSubscriptionTopic, topicRequiresDoubleOptIn } from "@aglyn/aglyn/app-utils/subscription-topics";
19
+ import { pluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
20
+ import { NEWSLETTER_TOPIC_ID } from "../model/commerce-topics.js";
19
21
  import { enrollListMember, firebaseAdmin, hostSendingIdentity, meterHostEmail, consentGroupForSite, orgDataCollectionForHost, recordPendingTopicConfirmation, resolveCampaignTouch, resolveOrgIdForHost, siteRequiresDoubleOptIn } from "@aglyn/tenant-data-admin";
20
22
  import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
21
23
  import { renderHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
@@ -23,13 +25,17 @@ import { buildConfirmUrl } from "@aglyn/tenant-data-admin/server/email-unsubscri
23
25
  import { sendEmail } from "@aglyn/shared-util-email";
24
26
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
25
27
  const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
26
- /**
27
- * The stream a newsletter signup joins.
28
- *
29
- * The built-in id, not the campaign default: somebody typing their address
30
- * into a footer box is asking for the newsletter, and attributing it to
31
- * "Promotions and offers" would confirm them for a stream they did not name.
32
- */ const NEWSLETTER_TOPIC_ID = 'newsletter';
28
+ /*
29
+ * The stream a newsletter signup joins is `NEWSLETTER_TOPIC_ID`, the built-in
30
+ * id and not the default stream: somebody typing their address into a footer
31
+ * box is asking for the newsletter, and attributing it to "Promotions and
32
+ * offers" would confirm them for a stream they did not name.
33
+ */ /**
34
+ * The record kind an org's topic catalog is read as: its keeper publishes it
35
+ * through the core's record-index seam, facts `{ description, archived,
36
+ * doubleOptIn? }`.
37
+ */ const SUBSCRIPTION_TOPIC_KIND = 'subscriptionTopic';
38
+ /** How many of an org's topics the signup reads; the catalog is a handful. */ const TOPIC_CATALOG_SCAN = 100;
33
39
  /**
34
40
  * Enrolls one address into an org list's members (AGL-2499).
35
41
  *
@@ -133,7 +139,7 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
133
139
  console.warn('[newsletter] confirmation not sent — no public origin or no ' + 'EMAIL_UNSUBSCRIBE_SECRET');
134
140
  return true;
135
141
  }
136
- const stream = (_ref = topic == null ? void 0 : topic.name) != null ? _ref : 'our newsletter';
142
+ const stream = (_ref = topic == null ? void 0 : topic.name) != null ? _ref : 'Newsletter';
137
143
  // The `newsletter-confirmation` site email (AGL-3370): the site's design,
138
144
  // or the built-in copy in its header and footer.
139
145
  const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), options.hostId, 'newsletter-confirmation', {
@@ -145,7 +151,7 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
145
151
  const result = await sendEmail(_extends({
146
152
  to: options.email,
147
153
  subject: (designed == null ? void 0 : designed.subject) || `Confirm your subscription`,
148
- text: (designed == null ? void 0 : designed.text) || `Please confirm that you want to receive ${stream} at this ` + `address:\n\n${url}\n\nThe link works for three days. If you did ` + 'not sign up, ignore this message — nothing will be sent.'
154
+ text: (designed == null ? void 0 : designed.text) || `Please confirm that you want to get ${stream} emails at this ` + `address:\n\n${url}\n\nThe link works for three days. If you did ` + 'not sign up, ignore this message — nothing will be sent.'
149
155
  }, (designed == null ? void 0 : designed.html) ? {
150
156
  html: designed.html
151
157
  } : {}, {
@@ -171,19 +177,26 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
171
177
  /**
172
178
  * The site's topic catalog, for the one question the signup asks of it.
173
179
  *
174
- * Fails soft to the built-ins, like the preference page's read: a site whose
175
- * org cannot be resolved still gets the four defaults, so the decision is
176
- * made against a catalog rather than against nothing.
180
+ * Asked of the plugin that keeps the catalog, through its record index, and
181
+ * never read from its collection. Fails soft to the declared built-ins, like
182
+ * the preference page's read: a site whose org cannot be resolved, or a
183
+ * process where nothing keeps the catalog, still gets the built-in streams,
184
+ * so the decision is made against a catalog rather than against nothing.
177
185
  */ async function loadTopics(hostId) {
178
186
  try {
179
- var _ref;
180
- const orgId = await resolveOrgIdForHost(hostId);
181
- if (!orgId) return mergeEmailTopics(null);
182
- const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection(EMAIL_TOPICS_COLLECTION).get();
183
- return mergeEmailTopics(((_ref = snapshot == null ? void 0 : snapshot.docs) != null ? _ref : []).map((doc)=>normalizeEmailTopic(doc.id, doc.data())).filter((topic)=>!!topic));
187
+ const keeper = pluginRecordIndex(SUBSCRIPTION_TOPIC_KIND);
188
+ const orgId = keeper ? await resolveOrgIdForHost(hostId) : null;
189
+ if (!keeper || !orgId) return mergeSubscriptionTopics(null);
190
+ const { records } = await keeper.index.list({
191
+ orgId,
192
+ limit: TOPIC_CATALOG_SCAN
193
+ });
194
+ return records.map((record)=>normalizeSubscriptionTopic(record.id, _extends({
195
+ name: record.name
196
+ }, record.facts))).filter((topic)=>!!topic);
184
197
  } catch (error) {
185
198
  console.error('[newsletter] topic catalog read failed', error);
186
- return mergeEmailTopics(null);
199
+ return mergeSubscriptionTopics(null);
187
200
  }
188
201
  }
189
202
  /**