@aglyn/plugins-commerce 1.0.0-beta.199 → 1.0.0-beta.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/console/catalog-organization-card.component.js +48 -3
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
- package/src/lib/components/console/gift-cards-card.component.d.ts +9 -0
- package/src/lib/components/console/gift-cards-card.component.js +53 -28
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.d.ts +26 -0
- package/src/lib/components/console/host-orders-card.component.js +264 -194
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/order-detail-dialog.component.js +12 -0
- package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
- package/src/lib/components/console/pos-page.component.d.ts +14 -0
- package/src/lib/components/console/pos-page.component.js +86 -45
- package/src/lib/components/console/pos-page.component.js.map +1 -1
- package/src/lib/components/console/product-editor-dialog.component.js +30 -6
- package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
- package/src/lib/components/console/product-slugs.d.ts +43 -0
- package/src/lib/components/console/product-slugs.js +60 -0
- package/src/lib/components/console/product-slugs.js.map +1 -0
- package/src/lib/components/console/products-hub-card.component.d.ts +2 -2
- package/src/lib/components/console/products-hub-card.component.js +229 -282
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/components/console/products-hub-zone.component.d.ts +10 -5
- package/src/lib/components/console/products-hub-zone.component.js +32 -9
- package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
- package/src/lib/components/console/recovery-queue-card.component.d.ts +37 -5
- package/src/lib/components/console/recovery-queue-card.component.js +107 -86
- package/src/lib/components/console/recovery-queue-card.component.js.map +1 -1
- package/src/lib/components/console/reservations-card.component.js +15 -3
- package/src/lib/components/console/reservations-card.component.js.map +1 -1
- package/src/lib/components/console/smart-collections.d.ts +32 -0
- package/src/lib/components/console/smart-collections.js +57 -0
- package/src/lib/components/console/smart-collections.js.map +1 -0
- package/src/lib/components/console/stock-movements-card.component.d.ts +13 -4
- package/src/lib/components/console/stock-movements-card.component.js +142 -180
- package/src/lib/components/console/stock-movements-card.component.js.map +1 -1
- package/src/lib/components/product-grid.d.ts +5 -0
- package/src/lib/components/product-grid.js +95 -34
- package/src/lib/components/product-grid.js.map +1 -1
- package/src/lib/constants/orders-list-query.d.ts +40 -0
- package/src/lib/constants/orders-list-query.js +205 -0
- package/src/lib/constants/orders-list-query.js.map +1 -0
- package/src/lib/constants/product-list-query.d.ts +42 -0
- package/src/lib/constants/product-list-query.js +217 -0
- package/src/lib/constants/product-list-query.js.map +1 -0
- package/src/lib/constants/stock-movement-query.d.ts +27 -0
- package/src/lib/constants/stock-movement-query.js +110 -0
- package/src/lib/constants/stock-movement-query.js.map +1 -0
- package/src/lib/constants/storefront-catalog-query.d.ts +103 -0
- package/src/lib/constants/storefront-catalog-query.js +312 -0
- package/src/lib/constants/storefront-catalog-query.js.map +1 -0
- package/src/lib/model/checkout-recovery.d.ts +37 -0
- package/src/lib/model/checkout-recovery.js +44 -0
- package/src/lib/model/checkout-recovery.js.map +1 -0
- package/src/lib/model/commerce-orders.d.ts +7 -0
- package/src/lib/model/commerce-orders.js.map +1 -1
- package/src/lib/model/commerce.d.ts +80 -0
- package/src/lib/model/commerce.js +67 -6
- package/src/lib/model/commerce.js.map +1 -1
- package/src/lib/model/gift-card-search.d.ts +24 -0
- package/src/lib/model/gift-card-search.js +48 -0
- package/src/lib/model/gift-card-search.js.map +1 -0
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/order-list-fields.d.ts +80 -0
- package/src/lib/model/order-list-fields.js +112 -0
- package/src/lib/model/order-list-fields.js.map +1 -0
- package/src/lib/server/billing-webhook.js +180 -99
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cancel-order.js +6 -6
- package/src/lib/server/cancel-order.js.map +1 -1
- package/src/lib/server/cart-checkout.js +9 -2
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/catalog.d.ts +46 -30
- package/src/lib/server/catalog.js +507 -170
- package/src/lib/server/catalog.js.map +1 -1
- package/src/lib/server/checkout.js +3 -1
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/collection-membership.d.ts +35 -0
- package/src/lib/server/collection-membership.js +172 -0
- package/src/lib/server/collection-membership.js.map +1 -0
- package/src/lib/server/draft-order.js +5 -3
- package/src/lib/server/draft-order.js.map +1 -1
- package/src/lib/server/gift-cards.js +7 -1
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/member-name-search.d.ts +17 -0
- package/src/lib/server/member-name-search.js +23 -1
- package/src/lib/server/member-name-search.js.map +1 -1
- package/src/lib/server/membership-account.js +7 -3
- package/src/lib/server/membership-account.js.map +1 -1
- package/src/lib/server/membership-admin-password.js +8 -2
- package/src/lib/server/membership-admin-password.js.map +1 -1
- package/src/lib/server/membership-recover.js +4 -1
- package/src/lib/server/membership-recover.js.map +1 -1
- package/src/lib/server/membership-register.js +3 -1
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/pos-order.js +9 -5
- package/src/lib/server/pos-order.js.map +1 -1
- package/src/lib/server/process-abandoned.js +20 -7
- package/src/lib/server/process-abandoned.js.map +1 -1
- package/src/lib/server/process-restock.js +6 -2
- package/src/lib/server/process-restock.js.map +1 -1
- package/src/lib/server/reserve-stock.js +3 -4
- package/src/lib/server/reserve-stock.js.map +1 -1
- package/src/lib/server/reserve.js +3 -1
- package/src/lib/server/reserve.js.map +1 -1
- package/src/lib/server/site-page-enricher.js +3 -3
- package/src/lib/server/site-page-enricher.js.map +1 -1
- package/src/lib/server.js +4 -0
- package/src/lib/server.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/gift-cards.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 * as Aglyn from '@aglyn/aglyn/server'\nimport { randomBytes } from 'crypto'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n renderHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n\n/** A merchant may not mint more than this in one card. */\nconst MAX_ISSUE_CENTS = 100_000\n\n/** `GC-` + 12 uppercase hex, the shape `billing-webhook.ts` already mints. */\nfunction mintCode(): string {\n return `GC-${randomBytes(6).toString('hex').toUpperCase()}`\n}\n\n/**\n * Gift-card issue / void (AGL-2226).\n *\n * The `giftCards` subcollection has existed since AGL-322 — minted by\n * `billing-webhook.ts` when a gift-card product is bought, decremented by\n * `cart-checkout.ts` at redemption. Neither end had a console: grepping for\n * that collection returned exactly those two server files, so a\n * merchant on Business (the tier that carries `giftCards`) could sell store\n * credit and then not see one cent of the liability they had taken on, help\n * a customer who lost a code, or void a leaked one.\n *\n * WRITES ARE SERVER-SIDE, and that is not a style preference. The Firestore\n * rules' host catch-all would happily grant an admin `create`/`update` on\n * `giftCards` — it is not in any exclusion list. A client-writable\n * `balanceCents` is a self-service discount machine: set it to any number,\n * redeem it at checkout, and `cart-checkout.ts` applies it as amount-off\n * against a Stripe coupon. The reads stay client-side (the catch-all's\n * `allow read` already grants them, and the card wants live updates), so\n * only the two operations that move money come through here.\n *\n * Re-checks the entitlement server-side for the same reason\n * `billing-webhook.ts:2285` does: a card issued while the plan was live is\n * honoured, but a lapsed plan must not mint new ones.\n */\nexport const giftCardsHandler: 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\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const action = String(body.action ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n if (action !== 'issue' && action !== 'void') {\n return res.status(400).json({ error: 'Unknown action' })\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 if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // AN ALLOWLIST (AGL-2372), not the `!role || role === 'viewer'` denylist\n // this route used to carry — the same correction AGL-2262 made to\n // `pos-order.ts` and `draft-order.ts` and did not reach here.\n //\n // A denylist silently WIDENS every time the role union grows. It did:\n // `HostAccessRole` is `admin | editor | author | viewer` since AGL-2334,\n // and `author` is grantable on any host through `/api/hosts/members`. So\n // \"not literally viewer\" started admitting authors — a role that exists to\n // edit content — to a route that mints and destroys store credit.\n //\n // `admin | editor` is the bar this route always meant (the comment here\n // said \"owner/admin/editor, not viewer\"); the difference is that a role\n // added tomorrow is now refused until someone decides otherwise, rather\n // than admitted by silence. Absent stays refused: `undefined` is not in\n // the set, which matters with `strictNullChecks` off.\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 owner = await getOrgForHost(hostId).catch(() => null)\n if (!Aglyn.checkEntitlement(owner?.org as never, 'giftCards')) {\n return res\n .status(402)\n .json({ error: 'Gift cards are not included on this plan' })\n }\n\n if (action === 'void') {\n const code = String(body.code ?? '').trim()\n if (!code) return res.status(400).json({ error: 'Missing code' })\n const cardRef = hostRef.collection('giftCards').doc(code)\n // Zero the balance rather than delete the document. AGL-1767 is the\n // reason: a deleted card that a checkout still had in flight was\n // RESURRECTED by the webhook's `increment(-cents)` into a NEGATIVE\n // balance, which then read as a card with money on it and understated\n // the outstanding total. A zeroed card decrements to a negative number\n // that no redemption path will ever accept, and the row stays\n // auditable.\n const existing = await cardRef.get()\n if (!existing.exists) {\n return res.status(404).json({ error: 'Unknown gift card' })\n }\n await cardRef.set(\n {\n balanceCents: 0,\n voidedAtMs: Date.now(),\n voidedBy: decoded.uid,\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, code })\n }\n\n const amountCents = Math.round(Number(body.amountCents ?? 0))\n if (!Number.isFinite(amountCents) || amountCents <= 0) {\n return res.status(400).json({ error: 'Amount must be above zero' })\n }\n if (amountCents > MAX_ISSUE_CENTS) {\n return res\n .status(400)\n .json({ error: `Amount may not exceed $${MAX_ISSUE_CENTS / 100}` })\n }\n const recipientEmail = String(body.recipientEmail ?? '').trim().slice(0, 200)\n const note = String(body.note ?? '').trim().slice(0, 200)\n const code = mintCode()\n await hostRef.collection('giftCards').doc(code).set({\n initialCents: amountCents,\n balanceCents: amountCents,\n recipientEmail: recipientEmail || null,\n // Null rather than absent: the field is what tells a reader whether a\n // card came from a sale or from the console, and `undefined` would\n // make an issued-by-hand card indistinguishable from a legacy row.\n orderId: null,\n issuedBy: decoded.uid,\n ...(note ? { note } : {}),\n createdAtMs: Date.now(),\n })\n\n let emailed = false\n if (recipientEmail && isEmailConfigured()) {\n const value = `$${(amountCents / 100).toFixed(2)}`\n // The same site-designed template the purchase path uses (AGL-771), so\n // a hand-issued card arrives looking like a bought one.\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'gift-card',\n { 'giftcard.code': code, 'giftcard.value': value },\n )\n const sent = await sendEmail({\n to: recipientEmail,\n subject: designed?.subject ?? 'Your gift card',\n text:\n designed?.text ||\n `Gift card code: ${code}\\nValue: ${value}\\n\\n` +\n 'Enter it at checkout to apply the balance.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: Aglyn.resolveBrandingProfile(owner?.org as never).fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'gift card',\n })\n .then(() => true)\n .catch(() => false)\n if (sent) {\n // Cost meter (AGL-1438). Transactional: the card has already been\n // minted and the recipient is owed the code, so a quota may count\n // this send but never refuse it. Metered on the SUCCESS path only,\n // matching every other sender — a send the provider rejected costs\n // nothing, and a meter that counted it would inflate the COGS figure\n // exactly when mail was broken.\n await meterHostEmail(hostId)\n }\n // The truth, not the intent: the console shows this back to whoever\n // issued the card, and reporting a delivered email for a send that\n // threw is how a customer ends up waiting for a code that is not\n // coming.\n emailed = sent\n }\n\n return res.status(200).json({ ok: true, code, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Gift card operation failed' })\n }\n}\n"],"names":["Aglyn","randomBytes","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","renderHostEmailWithTokens","isEmailConfigured","sendEmail","MAX_ISSUE_CENTS","mintCode","toString","toUpperCase","giftCardsHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","action","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","exists","memberRole","uid","owner","catch","checkEntitlement","org","code","trim","cardRef","existing","set","balanceCents","voidedAtMs","Date","now","voidedBy","merge","ok","amountCents","Math","round","Number","isFinite","recipientEmail","note","initialCents","orderId","issuedBy","createdAtMs","emailed","value","toFixed","designed","sent","to","subject","text","html","fromName","resolveBrandingProfile","sendingIdentity","audience","context","then","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/gift-cards.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 * as Aglyn from '@aglyn/aglyn/server'\nimport { randomBytes } from 'crypto'\nimport { giftCardSearchTokens } from '../model/gift-card-search'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n renderHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n\n/** A merchant may not mint more than this in one card. */\nconst MAX_ISSUE_CENTS = 100_000\n\n/** `GC-` + 12 uppercase hex, the shape `billing-webhook.ts` already mints. */\nfunction mintCode(): string {\n return `GC-${randomBytes(6).toString('hex').toUpperCase()}`\n}\n\n/**\n * Gift-card issue / void (AGL-2226).\n *\n * The `giftCards` subcollection has existed since AGL-322 — minted by\n * `billing-webhook.ts` when a gift-card product is bought, decremented by\n * `cart-checkout.ts` at redemption. Neither end had a console: grepping for\n * that collection returned exactly those two server files, so a\n * merchant on Business (the tier that carries `giftCards`) could sell store\n * credit and then not see one cent of the liability they had taken on, help\n * a customer who lost a code, or void a leaked one.\n *\n * WRITES ARE SERVER-SIDE, and that is not a style preference. The Firestore\n * rules' host catch-all would happily grant an admin `create`/`update` on\n * `giftCards` — it is not in any exclusion list. A client-writable\n * `balanceCents` is a self-service discount machine: set it to any number,\n * redeem it at checkout, and `cart-checkout.ts` applies it as amount-off\n * against a Stripe coupon. The reads stay client-side (the catch-all's\n * `allow read` already grants them, and the card wants live updates), so\n * only the two operations that move money come through here.\n *\n * Re-checks the entitlement server-side for the same reason\n * `billing-webhook.ts:2285` does: a card issued while the plan was live is\n * honoured, but a lapsed plan must not mint new ones.\n */\nexport const giftCardsHandler: 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\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const action = String(body.action ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n if (action !== 'issue' && action !== 'void') {\n return res.status(400).json({ error: 'Unknown action' })\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 if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // AN ALLOWLIST (AGL-2372), not the `!role || role === 'viewer'` denylist\n // this route used to carry — the same correction AGL-2262 made to\n // `pos-order.ts` and `draft-order.ts` and did not reach here.\n //\n // A denylist silently WIDENS every time the role union grows. It did:\n // `HostAccessRole` is `admin | editor | author | viewer` since AGL-2334,\n // and `author` is grantable on any host through `/api/hosts/members`. So\n // \"not literally viewer\" started admitting authors — a role that exists to\n // edit content — to a route that mints and destroys store credit.\n //\n // `admin | editor` is the bar this route always meant (the comment here\n // said \"owner/admin/editor, not viewer\"); the difference is that a role\n // added tomorrow is now refused until someone decides otherwise, rather\n // than admitted by silence. Absent stays refused: `undefined` is not in\n // the set, which matters with `strictNullChecks` off.\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 owner = await getOrgForHost(hostId).catch(() => null)\n if (!Aglyn.checkEntitlement(owner?.org as never, 'giftCards')) {\n return res\n .status(402)\n .json({ error: 'Gift cards are not included on this plan' })\n }\n\n if (action === 'void') {\n const code = String(body.code ?? '').trim()\n if (!code) return res.status(400).json({ error: 'Missing code' })\n const cardRef = hostRef.collection('giftCards').doc(code)\n // Zero the balance rather than delete the document. AGL-1767 is the\n // reason: a deleted card that a checkout still had in flight was\n // RESURRECTED by the webhook's `increment(-cents)` into a NEGATIVE\n // balance, which then read as a card with money on it and understated\n // the outstanding total. A zeroed card decrements to a negative number\n // that no redemption path will ever accept, and the row stays\n // auditable.\n const existing = await cardRef.get()\n if (!existing.exists) {\n return res.status(404).json({ error: 'Unknown gift card' })\n }\n await cardRef.set(\n {\n balanceCents: 0,\n voidedAtMs: Date.now(),\n voidedBy: decoded.uid,\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, code })\n }\n\n const amountCents = Math.round(Number(body.amountCents ?? 0))\n if (!Number.isFinite(amountCents) || amountCents <= 0) {\n return res.status(400).json({ error: 'Amount must be above zero' })\n }\n if (amountCents > MAX_ISSUE_CENTS) {\n return res\n .status(400)\n .json({ error: `Amount may not exceed $${MAX_ISSUE_CENTS / 100}` })\n }\n const recipientEmail = String(body.recipientEmail ?? '').trim().slice(0, 200)\n const note = String(body.note ?? '').trim().slice(0, 200)\n const code = mintCode()\n await hostRef.collection('giftCards').doc(code).set({\n initialCents: amountCents,\n balanceCents: amountCents,\n recipientEmail: recipientEmail || null,\n // Null rather than absent: the field is what tells a reader whether a\n // card came from a sale or from the console, and `undefined` would\n // make an issued-by-hand card indistinguishable from a legacy row.\n orderId: null,\n issuedBy: decoded.uid,\n ...(note ? { note } : {}),\n // What the console's Gift cards search asks (AGL-3321).\n searchTokens: giftCardSearchTokens(code, recipientEmail || null),\n createdAtMs: Date.now(),\n })\n\n let emailed = false\n if (recipientEmail && isEmailConfigured()) {\n const value = `$${(amountCents / 100).toFixed(2)}`\n // The same site-designed template the purchase path uses (AGL-771), so\n // a hand-issued card arrives looking like a bought one.\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'gift-card',\n { 'giftcard.code': code, 'giftcard.value': value },\n )\n const sent = await sendEmail({\n to: recipientEmail,\n subject: designed?.subject ?? 'Your gift card',\n text:\n designed?.text ||\n `Gift card code: ${code}\\nValue: ${value}\\n\\n` +\n 'Enter it at checkout to apply the balance.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: Aglyn.resolveBrandingProfile(owner?.org as never).fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'gift card',\n // Owed to the recipient by their own order: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'order',\n })\n .then(() => true)\n .catch(() => false)\n if (sent) {\n // Cost meter (AGL-1438). Transactional: the card has already been\n // minted and the recipient is owed the code, so a quota may count\n // this send but never refuse it. Metered on the SUCCESS path only,\n // matching every other sender — a send the provider rejected costs\n // nothing, and a meter that counted it would inflate the COGS figure\n // exactly when mail was broken.\n await meterHostEmail(hostId)\n }\n // The truth, not the intent: the console shows this back to whoever\n // issued the card, and reporting a delivered email for a send that\n // threw is how a customer ends up waiting for a code that is not\n // coming.\n emailed = sent\n }\n\n return res.status(200).json({ ok: true, code, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Gift card operation failed' })\n }\n}\n"],"names":["Aglyn","randomBytes","giftCardSearchTokens","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","renderHostEmailWithTokens","isEmailConfigured","sendEmail","MAX_ISSUE_CENTS","mintCode","toString","toUpperCase","giftCardsHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","action","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","exists","memberRole","uid","owner","catch","checkEntitlement","org","code","trim","cardRef","existing","set","balanceCents","voidedAtMs","Date","now","voidedBy","merge","ok","amountCents","Math","round","Number","isFinite","recipientEmail","note","initialCents","orderId","issuedBy","searchTokens","createdAtMs","emailed","value","toFixed","designed","sent","to","subject","text","html","fromName","resolveBrandingProfile","sendingIdentity","audience","context","owedFor","then","console"],"mappings":"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"}
|
|
@@ -35,3 +35,20 @@ export declare function memberNameSearchFields(displayName: string): {
|
|
|
35
35
|
displayNameLower: string;
|
|
36
36
|
displayNameTokens: string[];
|
|
37
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* What the Site users list's search box finds a member by (AGL-3321): the
|
|
40
|
+
* start of any word of their display name, or of their address — the whole
|
|
41
|
+
* address, its local part, its domain, or any run of letters and digits in
|
|
42
|
+
* it, read the way the activity logs read an actor's (`addressSearchWords`).
|
|
43
|
+
* So `ada`, `lovelace` and `example.com` all find Ada Lovelace
|
|
44
|
+
* <ada@example.com>, and a member who never gave a name is still found.
|
|
45
|
+
*
|
|
46
|
+
* Stamped as `searchTokens` at sign-up (the only writer of `email`, which
|
|
47
|
+
* never changes afterwards) and wherever the display name is written, with
|
|
48
|
+
* the address the document already holds. `Name contains` keeps reading
|
|
49
|
+
* `displayNameTokens`, so it filters the name alone.
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ EVERY writer of `displayName` or `email` must stamp it, or the member
|
|
52
|
+
* lists normally and the search cannot find them.
|
|
53
|
+
*/
|
|
54
|
+
export declare function memberSearchTokens(displayName: string | null | undefined, email: string | null | undefined): string[];
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
16
|
+
*/ import { addressSearchWords } from "@aglyn/aglyn/app-utils/activity-search";
|
|
17
|
+
import { nameSearchKey, nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
|
|
17
18
|
/**
|
|
18
19
|
* The search fields that travel with a site member's display name (AGL-2501).
|
|
19
20
|
*
|
|
@@ -38,5 +39,26 @@
|
|
|
38
39
|
displayNameTokens: nameSearchTokens(displayName)
|
|
39
40
|
};
|
|
40
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* What the Site users list's search box finds a member by (AGL-3321): the
|
|
44
|
+
* start of any word of their display name, or of their address — the whole
|
|
45
|
+
* address, its local part, its domain, or any run of letters and digits in
|
|
46
|
+
* it, read the way the activity logs read an actor's (`addressSearchWords`).
|
|
47
|
+
* So `ada`, `lovelace` and `example.com` all find Ada Lovelace
|
|
48
|
+
* <ada@example.com>, and a member who never gave a name is still found.
|
|
49
|
+
*
|
|
50
|
+
* Stamped as `searchTokens` at sign-up (the only writer of `email`, which
|
|
51
|
+
* never changes afterwards) and wherever the display name is written, with
|
|
52
|
+
* the address the document already holds. `Name contains` keeps reading
|
|
53
|
+
* `displayNameTokens`, so it filters the name alone.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ EVERY writer of `displayName` or `email` must stamp it, or the member
|
|
56
|
+
* lists normally and the search cannot find them.
|
|
57
|
+
*/ export function memberSearchTokens(displayName, email) {
|
|
58
|
+
return nameSearchTokens([
|
|
59
|
+
...addressSearchWords(email),
|
|
60
|
+
displayName != null ? displayName : ''
|
|
61
|
+
].join(' '));
|
|
62
|
+
}
|
|
41
63
|
|
|
42
64
|
//# sourceMappingURL=member-name-search.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-name-search.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 nameSearchKey,\n nameSearchTokens,\n} from '@aglyn/aglyn/app-utils/name-search'\n\n/**\n * The search fields that travel with a site member's display name (AGL-2501).\n *\n * The console's Site users card is paged, so a name search that compares the\n * rows already fetched answers \"no such member\" for everyone past the first\n * page. Searching the whole collection needs a query, and a query over a raw\n * `displayName` is case-sensitive — it would miss rather than fail, which is\n * the worse of the two.\n *\n * `email` needs no twin: the register path already lower-cases it before\n * storing, so the stored value IS its own normalized key.\n *\n * ⚠️ EVERY writer of `displayName` must call this. A member updated through a\n * path that skips it keeps a stale key and becomes unfindable by their new\n * name while still listing normally — the quiet half of a search bug.\n */\nexport function memberNameSearchFields(displayName: string): {\n displayName: string\n displayNameLower: string\n displayNameTokens: string[]\n} {\n return {\n displayName,\n displayNameLower: nameSearchKey(displayName),\n // Every prefix of every WORD, so a reader finds \"Ada Lovelace\" by typing\n // \"lovelace\" and not only by typing \"ada\".\n displayNameTokens: nameSearchTokens(displayName),\n }\n}\n"],"names":["nameSearchKey","nameSearchTokens","memberNameSearchFields","displayName","displayNameLower","displayNameTokens"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-name-search.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 { addressSearchWords } from '@aglyn/aglyn/app-utils/activity-search'\nimport {\n nameSearchKey,\n nameSearchTokens,\n} from '@aglyn/aglyn/app-utils/name-search'\n\n/**\n * The search fields that travel with a site member's display name (AGL-2501).\n *\n * The console's Site users card is paged, so a name search that compares the\n * rows already fetched answers \"no such member\" for everyone past the first\n * page. Searching the whole collection needs a query, and a query over a raw\n * `displayName` is case-sensitive — it would miss rather than fail, which is\n * the worse of the two.\n *\n * `email` needs no twin: the register path already lower-cases it before\n * storing, so the stored value IS its own normalized key.\n *\n * ⚠️ EVERY writer of `displayName` must call this. A member updated through a\n * path that skips it keeps a stale key and becomes unfindable by their new\n * name while still listing normally — the quiet half of a search bug.\n */\nexport function memberNameSearchFields(displayName: string): {\n displayName: string\n displayNameLower: string\n displayNameTokens: string[]\n} {\n return {\n displayName,\n displayNameLower: nameSearchKey(displayName),\n // Every prefix of every WORD, so a reader finds \"Ada Lovelace\" by typing\n // \"lovelace\" and not only by typing \"ada\".\n displayNameTokens: nameSearchTokens(displayName),\n }\n}\n\n/**\n * What the Site users list's search box finds a member by (AGL-3321): the\n * start of any word of their display name, or of their address — the whole\n * address, its local part, its domain, or any run of letters and digits in\n * it, read the way the activity logs read an actor's (`addressSearchWords`).\n * So `ada`, `lovelace` and `example.com` all find Ada Lovelace\n * <ada@example.com>, and a member who never gave a name is still found.\n *\n * Stamped as `searchTokens` at sign-up (the only writer of `email`, which\n * never changes afterwards) and wherever the display name is written, with\n * the address the document already holds. `Name contains` keeps reading\n * `displayNameTokens`, so it filters the name alone.\n *\n * ⚠️ EVERY writer of `displayName` or `email` must stamp it, or the member\n * lists normally and the search cannot find them.\n */\nexport function memberSearchTokens(\n displayName: string | null | undefined,\n email: string | null | undefined,\n): string[] {\n return nameSearchTokens(\n [...addressSearchWords(email), displayName ?? ''].join(' '),\n )\n}\n"],"names":["addressSearchWords","nameSearchKey","nameSearchTokens","memberNameSearchFields","displayName","displayNameLower","displayNameTokens","memberSearchTokens","email","join"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,yCAAwC;AAC3E,SACEC,aAAa,EACbC,gBAAgB,QACX,qCAAoC;AAE3C;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,uBAAuBC,WAAmB;IAKxD,OAAO;QACLA;QACAC,kBAAkBJ,cAAcG;QAChC,yEAAyE;QACzE,2CAA2C;QAC3CE,mBAAmBJ,iBAAiBE;IACtC;AACF;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASG,mBACdH,WAAsC,EACtCI,KAAgC;IAEhC,OAAON,iBACL;WAAIF,mBAAmBQ;QAAQJ,sBAAAA,cAAe;KAAG,CAACK,IAAI,CAAC;AAE3D"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
-
import { memberNameSearchFields } from "./member-name-search.js";
|
|
17
|
+
import { memberNameSearchFields, memberSearchTokens } from "./member-name-search.js";
|
|
18
18
|
import * as CommerceModel from "../model/index.js";
|
|
19
19
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { mintDownloadToken } from "./download.js";
|
|
@@ -44,7 +44,7 @@ import { requireActiveMember } from "./membership.js";
|
|
|
44
44
|
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
45
45
|
const memberRef = hostRef.collection('siteMembers').doc(memberId);
|
|
46
46
|
if (req.method === 'POST') {
|
|
47
|
-
var _ref1;
|
|
47
|
+
var _ref1, _memberSnapshot_get3;
|
|
48
48
|
var _req_body1, _req_body2;
|
|
49
49
|
const displayName = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.displayName) != null ? _ref1 : '').slice(0, 80);
|
|
50
50
|
const addresses = Array.isArray((_req_body2 = req.body) == null ? void 0 : _req_body2.addresses) ? req.body.addresses.slice(0, 5).map((address)=>{
|
|
@@ -59,7 +59,11 @@ import { requireActiveMember } from "./membership.js";
|
|
|
59
59
|
country: String((_address_country = address.country) != null ? _address_country : '').slice(0, 2).toUpperCase()
|
|
60
60
|
};
|
|
61
61
|
}) : undefined;
|
|
62
|
-
await memberRef.set(_extends({}, displayName ? memberNameSearchFields(displayName)
|
|
62
|
+
await memberRef.set(_extends({}, displayName ? _extends({}, memberNameSearchFields(displayName), {
|
|
63
|
+
// The Site users list's search reads the new name beside
|
|
64
|
+
// the address the member signed up with (AGL-3321).
|
|
65
|
+
searchTokens: memberSearchTokens(displayName, String((_memberSnapshot_get3 = memberSnapshot.get('email')) != null ? _memberSnapshot_get3 : ''))
|
|
66
|
+
}) : {}, addresses ? {
|
|
63
67
|
addresses
|
|
64
68
|
} : {}), {
|
|
65
69
|
merge: true
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-account.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 } from './member-name-search'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { mintDownloadToken } from './download'\nimport { requireActiveMember } from './membership'\n\nexport interface AccountOrderView {\n id: string\n number: string\n status: CommerceModel.OrderStatus\n totalCents: number\n createdAtMs: number\n itemsSummary: string\n tracking?: { carrier?: string; trackingNumber?: string }\n}\n\n/**\n * Member account (AGL-294): profile + address book + order history for\n * the signed-in site member (AGL-109 session cookie). Orders match by\n * the member's email — guest purchases made with the same address show\n * up too, which is what buyers expect.\n */\nexport const membershipAccountHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(\n (req.method === 'GET' ? req.query.hostId : req.body?.hostId) ?? '',\n )\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n try {\n // Suspension gate (AGL-546/550): the shared guard 401s stale\n // sessions and 403s suspended members with the cookie cleared, so\n // the site stops presenting a signed-in shell; the account block\n // treats any non-OK response as signed-out, the graceful path here.\n const auth = await requireActiveMember(req, res, hostId)\n if (!auth) return\n const { memberId, member: memberSnapshot } = auth\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const memberRef = hostRef.collection('siteMembers').doc(memberId)\n\n if (req.method === 'POST') {\n const displayName = String(req.body?.displayName ?? '').slice(0, 80)\n const addresses = Array.isArray(req.body?.addresses)\n ? (req.body.addresses as CommerceModel.OrderAddress[]).slice(0, 5).map(\n (address) => ({\n name: String(address.name ?? '').slice(0, 80),\n line1: String(address.line1 ?? '').slice(0, 120),\n line2: String(address.line2 ?? '').slice(0, 120),\n city: String(address.city ?? '').slice(0, 80),\n state: String(address.state ?? '').slice(0, 40),\n postalCode: String(address.postalCode ?? '').slice(0, 20),\n country: String(address.country ?? '').slice(0, 2).toUpperCase(),\n }),\n )\n : undefined\n await memberRef.set(\n {\n ...(displayName ? memberNameSearchFields(displayName) : {}),\n ...(addresses ? { addresses } : {}),\n },\n { merge: true },\n )\n }\n\n const email = String(memberSnapshot.get('email') ?? '')\n const ordersSnapshot = await hostRef\n .collection('orders')\n .where('customerEmail', '==', email)\n .limit(25)\n .get()\n const orders: AccountOrderView[] = ordersSnapshot.docs\n .map((docSnapshot) => {\n const order = CommerceModel.liftLegacyOrder(docSnapshot.data() as any)\n const latestFulfillment = (order.fulfillments ?? []).slice(-1)[0]\n return {\n id: docSnapshot.id,\n number: CommerceModel.formatOrderNumber(order, docSnapshot.id),\n status: order.status,\n totalCents:\n order.totals?.totalCents ?? Number(order.amountCents ?? 0),\n createdAtMs:\n order.createdAtMs ??\n ((docSnapshot.get('createdAt')?.seconds ?? 0) * 1000 || 0),\n itemsSummary: (order.lineItems ?? [])\n .map((line) => `${line.quantity}× ${line.name}`)\n .join(', ')\n .slice(0, 140),\n ...(latestFulfillment?.trackingNumber\n ? {\n tracking: {\n carrier: latestFulfillment.carrier,\n trackingNumber: latestFulfillment.trackingNumber,\n },\n }\n : {}),\n }\n })\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n\n // Digital downloads (AGL-302): tokened links for digital lines on\n // paid orders; files resolve to the product's current list.\n const downloads: Array<{\n orderId: string\n productId: string\n productName: string\n url: string\n licenseKeys?: string[]\n }> = []\n for (const docSnapshot of ordersSnapshot.docs) {\n const order = CommerceModel.liftLegacyOrder(docSnapshot.data() as any)\n if (!CommerceModel.orderEntitlesProduct(order, 'any')) continue\n const orderKeys = (docSnapshot.get('licenseKeys') ?? {}) as Record<\n string,\n string[]\n >\n for (const line of order.lineItems ?? []) {\n if (line.productType !== 'digital') continue\n // PER LINE (AGL-2454). The status test above only ever moved on a FULL\n // refund, so this page went on listing the download link AND the\n // licence key for a line the merchant had refunded. The key string is\n // the one thing here that cannot be taken back once mailed — which is\n // exactly why it is retired from the pool rather than reissued — so\n // removing it from the account page is the only withdrawal available.\n if (!CommerceModel.orderEntitlesProduct(order, line.productId)) continue\n downloads.push({\n orderId: docSnapshot.id,\n productId: line.productId,\n productName: line.name,\n ...(orderKeys[line.productId]?.length\n ? { licenseKeys: orderKeys[line.productId] }\n : {}),\n url:\n `/api/commerce/download?hostId=${encodeURIComponent(hostId)}` +\n `&orderId=${encodeURIComponent(docSnapshot.id)}` +\n `&productId=${encodeURIComponent(line.productId)}` +\n `&token=${mintDownloadToken(hostId, docSnapshot.id)}`,\n })\n }\n }\n\n // Storefront subscriptions (AGL-303) with product names resolved.\n const subscriptionsSnapshot = await hostRef\n .collection('subscriptions')\n .where('customerEmail', '==', email)\n .limit(10)\n .get()\n const subscriptions = await Promise.all(\n subscriptionsSnapshot.docs.map(async (docSnapshot) => {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(String(docSnapshot.get('productId') ?? ''))\n .get()\n return {\n id: docSnapshot.id,\n productName: String(productSnapshot.get('name') ?? 'Subscription'),\n status: String(docSnapshot.get('status') ?? 'active'),\n currentPeriodEndMs: docSnapshot.get('currentPeriodEndMs') ?? null,\n }\n }),\n )\n\n return res.status(200).json({\n member: {\n email,\n displayName: memberSnapshot.get('displayName') ?? '',\n addresses: memberSnapshot.get('addresses') ?? [],\n },\n orders,\n downloads,\n subscriptions,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Account unavailable' })\n }\n}\n"],"names":["memberNameSearchFields","CommerceModel","firebaseAdmin","mintDownloadToken","requireActiveMember","membershipAccountHandler","req","res","hostId","String","method","query","body","status","json","error","memberSnapshot","auth","memberId","member","firestore","app","hostRef","collection","doc","memberRef","displayName","slice","addresses","Array","isArray","map","address","name","line1","line2","city","state","postalCode","country","toUpperCase","undefined","set","merge","email","get","ordersSnapshot","where","limit","orders","docs","docSnapshot","order","liftLegacyOrder","data","latestFulfillment","fulfillments","id","number","formatOrderNumber","totalCents","totals","Number","amountCents","createdAtMs","seconds","itemsSummary","lineItems","line","quantity","join","trackingNumber","tracking","carrier","sort","a","b","downloads","orderEntitlesProduct","orderKeys","productType","productId","push","orderId","productName","length","licenseKeys","url","encodeURIComponent","subscriptionsSnapshot","subscriptions","Promise","all","productSnapshot","currentPeriodEndMs","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-account.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 * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { mintDownloadToken } from './download'\nimport { requireActiveMember } from './membership'\n\nexport interface AccountOrderView {\n id: string\n number: string\n status: CommerceModel.OrderStatus\n totalCents: number\n createdAtMs: number\n itemsSummary: string\n tracking?: { carrier?: string; trackingNumber?: string }\n}\n\n/**\n * Member account (AGL-294): profile + address book + order history for\n * the signed-in site member (AGL-109 session cookie). Orders match by\n * the member's email — guest purchases made with the same address show\n * up too, which is what buyers expect.\n */\nexport const membershipAccountHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(\n (req.method === 'GET' ? req.query.hostId : req.body?.hostId) ?? '',\n )\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n try {\n // Suspension gate (AGL-546/550): the shared guard 401s stale\n // sessions and 403s suspended members with the cookie cleared, so\n // the site stops presenting a signed-in shell; the account block\n // treats any non-OK response as signed-out, the graceful path here.\n const auth = await requireActiveMember(req, res, hostId)\n if (!auth) return\n const { memberId, member: memberSnapshot } = auth\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const memberRef = hostRef.collection('siteMembers').doc(memberId)\n\n if (req.method === 'POST') {\n const displayName = String(req.body?.displayName ?? '').slice(0, 80)\n const addresses = Array.isArray(req.body?.addresses)\n ? (req.body.addresses as CommerceModel.OrderAddress[]).slice(0, 5).map(\n (address) => ({\n name: String(address.name ?? '').slice(0, 80),\n line1: String(address.line1 ?? '').slice(0, 120),\n line2: String(address.line2 ?? '').slice(0, 120),\n city: String(address.city ?? '').slice(0, 80),\n state: String(address.state ?? '').slice(0, 40),\n postalCode: String(address.postalCode ?? '').slice(0, 20),\n country: String(address.country ?? '').slice(0, 2).toUpperCase(),\n }),\n )\n : undefined\n await memberRef.set(\n {\n ...(displayName\n ? {\n ...memberNameSearchFields(displayName),\n // The Site users list's search reads the new name beside\n // the address the member signed up with (AGL-3321).\n searchTokens: memberSearchTokens(\n displayName,\n String(memberSnapshot.get('email') ?? ''),\n ),\n }\n : {}),\n ...(addresses ? { addresses } : {}),\n },\n { merge: true },\n )\n }\n\n const email = String(memberSnapshot.get('email') ?? '')\n const ordersSnapshot = await hostRef\n .collection('orders')\n .where('customerEmail', '==', email)\n .limit(25)\n .get()\n const orders: AccountOrderView[] = ordersSnapshot.docs\n .map((docSnapshot) => {\n const order = CommerceModel.liftLegacyOrder(docSnapshot.data() as any)\n const latestFulfillment = (order.fulfillments ?? []).slice(-1)[0]\n return {\n id: docSnapshot.id,\n number: CommerceModel.formatOrderNumber(order, docSnapshot.id),\n status: order.status,\n totalCents:\n order.totals?.totalCents ?? Number(order.amountCents ?? 0),\n createdAtMs:\n order.createdAtMs ??\n ((docSnapshot.get('createdAt')?.seconds ?? 0) * 1000 || 0),\n itemsSummary: (order.lineItems ?? [])\n .map((line) => `${line.quantity}× ${line.name}`)\n .join(', ')\n .slice(0, 140),\n ...(latestFulfillment?.trackingNumber\n ? {\n tracking: {\n carrier: latestFulfillment.carrier,\n trackingNumber: latestFulfillment.trackingNumber,\n },\n }\n : {}),\n }\n })\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n\n // Digital downloads (AGL-302): tokened links for digital lines on\n // paid orders; files resolve to the product's current list.\n const downloads: Array<{\n orderId: string\n productId: string\n productName: string\n url: string\n licenseKeys?: string[]\n }> = []\n for (const docSnapshot of ordersSnapshot.docs) {\n const order = CommerceModel.liftLegacyOrder(docSnapshot.data() as any)\n if (!CommerceModel.orderEntitlesProduct(order, 'any')) continue\n const orderKeys = (docSnapshot.get('licenseKeys') ?? {}) as Record<\n string,\n string[]\n >\n for (const line of order.lineItems ?? []) {\n if (line.productType !== 'digital') continue\n // PER LINE (AGL-2454). The status test above only ever moved on a FULL\n // refund, so this page went on listing the download link AND the\n // licence key for a line the merchant had refunded. The key string is\n // the one thing here that cannot be taken back once mailed — which is\n // exactly why it is retired from the pool rather than reissued — so\n // removing it from the account page is the only withdrawal available.\n if (!CommerceModel.orderEntitlesProduct(order, line.productId)) continue\n downloads.push({\n orderId: docSnapshot.id,\n productId: line.productId,\n productName: line.name,\n ...(orderKeys[line.productId]?.length\n ? { licenseKeys: orderKeys[line.productId] }\n : {}),\n url:\n `/api/commerce/download?hostId=${encodeURIComponent(hostId)}` +\n `&orderId=${encodeURIComponent(docSnapshot.id)}` +\n `&productId=${encodeURIComponent(line.productId)}` +\n `&token=${mintDownloadToken(hostId, docSnapshot.id)}`,\n })\n }\n }\n\n // Storefront subscriptions (AGL-303) with product names resolved.\n const subscriptionsSnapshot = await hostRef\n .collection('subscriptions')\n .where('customerEmail', '==', email)\n .limit(10)\n .get()\n const subscriptions = await Promise.all(\n subscriptionsSnapshot.docs.map(async (docSnapshot) => {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(String(docSnapshot.get('productId') ?? ''))\n .get()\n return {\n id: docSnapshot.id,\n productName: String(productSnapshot.get('name') ?? 'Subscription'),\n status: String(docSnapshot.get('status') ?? 'active'),\n currentPeriodEndMs: docSnapshot.get('currentPeriodEndMs') ?? null,\n }\n }),\n )\n\n return res.status(200).json({\n member: {\n email,\n displayName: memberSnapshot.get('displayName') ?? '',\n addresses: memberSnapshot.get('addresses') ?? [],\n },\n orders,\n downloads,\n subscriptions,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Account unavailable' })\n }\n}\n"],"names":["memberNameSearchFields","memberSearchTokens","CommerceModel","firebaseAdmin","mintDownloadToken","requireActiveMember","membershipAccountHandler","req","res","hostId","String","method","query","body","status","json","error","memberSnapshot","auth","memberId","member","firestore","app","hostRef","collection","doc","memberRef","displayName","slice","addresses","Array","isArray","map","address","name","line1","line2","city","state","postalCode","country","toUpperCase","undefined","set","searchTokens","get","merge","email","ordersSnapshot","where","limit","orders","docs","docSnapshot","order","liftLegacyOrder","data","latestFulfillment","fulfillments","id","number","formatOrderNumber","totalCents","totals","Number","amountCents","createdAtMs","seconds","itemsSummary","lineItems","line","quantity","join","trackingNumber","tracking","carrier","sort","a","b","downloads","orderEntitlesProduct","orderKeys","productType","productId","push","orderId","productName","length","licenseKeys","url","encodeURIComponent","subscriptionsSnapshot","subscriptions","Promise","all","productSnapshot","currentPeriodEndMs","console"],"mappings":"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"}
|
|
@@ -153,7 +153,10 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
|
|
|
153
153
|
fromName: branding.fromName,
|
|
154
154
|
sendingIdentity: await hostSendingIdentity(hostId),
|
|
155
155
|
audience: 'tenant',
|
|
156
|
-
context: 'membership admin recovery'
|
|
156
|
+
context: 'membership admin recovery',
|
|
157
|
+
// Owed to the recipient by their own account: the phishing
|
|
158
|
+
// screen's soft rules never hold it (AGL-3356).
|
|
159
|
+
owedFor: 'account'
|
|
157
160
|
});
|
|
158
161
|
if (!result.sent) {
|
|
159
162
|
return res.status(502).json({
|
|
@@ -203,7 +206,10 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
|
|
|
203
206
|
fromName: branding.fromName,
|
|
204
207
|
sendingIdentity: await hostSendingIdentity(hostId),
|
|
205
208
|
audience: 'tenant',
|
|
206
|
-
context: 'membership admin password change'
|
|
209
|
+
context: 'membership admin password change',
|
|
210
|
+
// Owed to the recipient by their own account: the phishing
|
|
211
|
+
// screen's soft rules never hold it (AGL-3356).
|
|
212
|
+
owedFor: 'account'
|
|
207
213
|
});
|
|
208
214
|
notified = result.sent;
|
|
209
215
|
// Cost meter (AGL-1438). Transactional: a security notice.
|
|
@@ -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 {\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 result = await sendEmail({\n to: email,\n subject: `Reset your ${siteName} password`,\n text:\n `An administrator of ${siteName} started a password reset for ` +\n 'your account. Set a new password here:\\n\\n' +\n `${resetUrl}\\n\\n` +\n 'The link works once and expires in 1 hour. Your current ' +\n 'password keeps working until you use it.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin recovery',\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 const result = await sendEmail({\n to: email,\n subject: `Your ${siteName} password was changed`,\n 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 fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin password change',\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","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","result","to","subject","text","fromName","sendingIdentity","audience","context","sent","ok","validated","password","batch","set","passwordScrypt","merge","update","Date","now","commit","notified","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 {\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 result = await sendEmail({\n to: email,\n subject: `Reset your ${siteName} password`,\n text:\n `An administrator of ${siteName} started a password reset for ` +\n 'your account. Set a new password here:\\n\\n' +\n `${resetUrl}\\n\\n` +\n 'The link works once and expires in 1 hour. Your current ' +\n 'password keeps working until you use it.',\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 const result = await sendEmail({\n to: email,\n subject: `Your ${siteName} password was changed`,\n 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 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","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","result","to","subject","text","fromName","sendingIdentity","audience","context","owedFor","sent","ok","validated","password","batch","set","passwordScrypt","merge","update","Date","now","commit","notified","console"],"mappings":"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"}
|
|
@@ -223,7 +223,10 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
223
223
|
fromName: branding.fromName,
|
|
224
224
|
sendingIdentity: await hostSendingIdentity(hostId),
|
|
225
225
|
audience: 'tenant',
|
|
226
|
-
context: 'membership recovery'
|
|
226
|
+
context: 'membership recovery',
|
|
227
|
+
// Owed to the recipient by their own account: the phishing
|
|
228
|
+
// screen's soft rules never hold it (AGL-3356).
|
|
229
|
+
owedFor: 'account'
|
|
227
230
|
});
|
|
228
231
|
// Cost meter (AGL-1438). Transactional, and the clearest case for why a
|
|
229
232
|
// quota may not refuse one: this email is how the member gets back into
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-recover.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 } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport {\n consumeMembershipRecoverAttempt,\n consumeMembershipRecoverSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n RECOVER_MIN_MEMBER_AGE_MS,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { mintPasswordResetToken } from './membership'\nimport { readMemberPasswordHash } from './member-credentials'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Password-recovery request (AGL-552). ALWAYS answers `200 {ok:true}` once\n * the input validates and the caller is inside its rate budget — whether or\n * not the email belongs to a member — so the endpoint can't be used to probe\n * account existence. When a member matches, a single-use one-hour token\n * (bound to the current password hash, see membership.ts) is mailed via\n * Resend with a link to the site's `/recover` route.\n *\n * ## Abuse controls (AGL-1966)\n *\n * This is an unauthenticated endpoint that makes us send mail from\n * `noreply@aglyn.com` to an address the caller chose, and it composes with\n * `membership/register` — which admits arbitrary addresses because member\n * accounts are unlimited on every plan (AGL-889) — into a mail relay. The\n * bounces from that land on the sending reputation shared by every customer's\n * transactional mail.\n *\n * Four controls, in the order they run, and the ORDER is part of each:\n *\n * 1. **Attempt caps** — per recipient and per IP, durable\n * (`membership-recover-throttle.ts`). Consumed BEFORE the member lookup so\n * a member and a non-member spend the identical budget and receive the\n * identical answer; a visible 429 from here therefore leaks nothing. The\n * per-instance `Map` this replaced reset on every cold start and was kept\n * per instance, which on Vercel is close to no limit at all (AGL-794).\n * 2. **The young-member guard** — a member row created inside\n * `RECOVER_MIN_MEMBER_AGE_MS` is not worth a reset mail. Register-then-\n * recover is the whole attack and nobody forgets a password ten minutes\n * after choosing it. Silent-success exit.\n * 3. **The platform suppression list** — see the note on the call below.\n * Silent-success exit.\n * 4. **The per-site daily send ceiling** — consumed only when a message is\n * actually about to go out. Because that branch is reachable only for real\n * members, its refusal MUST be the silent-success exit rather than an\n * error, or the ceiling becomes the oracle everything else avoids.\n *\n * What remains unbounded is stated rather than papered over: a genuine botnet\n * still gets `RECOVER_ATTEMPTS_PER_IP` per source, and an attacker who can\n * create many sites gets the per-site ceiling once per site. Host creation is\n * itself rate-limited, and bounding the botnet case needs an edge WAF, not an\n * application limiter — the same conclusion `plugin-api-rate-limit.ts` reaches.\n */\nexport const membershipRecoverHandler: 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 if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n // Null when nothing readable names the caller; the throttle then skips its\n // per-source cap and the per-recipient one carries the control alone.\n const ip = readClientIp(req.headers, {\n remoteAddress: req.socket?.remoteAddress,\n })\n // Control 1. Before the lookup, so both branches spend the same budget.\n // A store failure degrades to the in-process cap rather than refusing\n // (`consumeRateLimit` fails soft on an unreachable store, closed on\n // contention) — a Firestore blip must not lock every site's members out of\n // their own password reset.\n const attempt = await consumeMembershipRecoverAttempt({ email, ip })\n if (!attempt.allowed) {\n res.setHeader('Retry-After', String(attempt.retryAfterSeconds))\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [hostSnapshot, membersQuery] = await Promise.all([\n hostRef.get(),\n hostRef\n .collection('siteMembers')\n .where('email', '==', email)\n .limit(1)\n .get(),\n ])\n const memberDoc = membersQuery.docs[0]\n // Unknown site, unknown email, suspended account (AGL-546): all take\n // the same silent-success exit — the response never distinguishes.\n if (\n !hostSnapshot.exists ||\n !memberDoc ||\n memberDoc.get('suspended') === true\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 2 — the register-then-recover composition (AGL-1966).\n //\n // `createdAt` is a server timestamp written by `membership-register.ts`.\n // A row that has none is a legacy member, not a fresh one: rows predating\n // the field must NOT be refused, so an unreadable or absent value reads as\n // \"old enough\". This guard exists to refuse a member created seconds ago,\n // which is a case where the field is always present by construction.\n const createdAtMs = (() => {\n const raw = memberDoc.get('createdAt') as\n | { toMillis?: () => number }\n | undefined\n try {\n return typeof raw?.toMillis === 'function' ? raw.toMillis() : null\n } catch {\n return null\n }\n })()\n if (\n createdAtMs !== null &&\n Date.now() - createdAtMs < RECOVER_MIN_MEMBER_AGE_MS\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 3 — the platform suppression list (AGL-1918 / AGL-2407).\n //\n // `email-suppression.ts` argues that TRANSACTIONAL mail deliberately does\n // not consult this list, because refusing a password reset over an\n // unrelated old bounce locks a real customer out of their own account.\n // That argument is about mail a signed-in human just asked us for. It does\n // not reach here, and this is the exception it does not cover:\n //\n // - The address is chosen by an ANONYMOUS caller, not by the account\n // holder. \"The human just did something\" is exactly the premise that is\n // missing on an unauthenticated endpoint.\n // - The list holds PERMANENT bounces and spam complaints only — transient\n // bounces are excluded at the webhook. A permanent bounce means the\n // mailbox does not exist, so the send cannot reach anyone; all it can do\n // is tell a provider that `aglyn.com` re-mails dead addresses. There is\n // no locked-out customer on the other side of it to protect.\n // - The reputational cost is shared by every customer's transactional\n // mail, which is precisely what AGL-1966 is about.\n //\n // The HOST list (`hosts/{hostId}/suppressions`) is deliberately NOT\n // consulted: it also holds marketing unsubscribes, and unsubscribing from\n // a store's newsletter must never stop that store's password resets.\n //\n // `isEmailSuppressed` fails CLOSED — a read that throws answers \"treat as\n // suppressed\". That adds no new outage mode here: the member lookup above\n // is a Firestore read on the same database, so a Firestore outage has\n // already taken this handler to its silent-success catch.\n if (await isEmailSuppressed(email)) {\n return res.status(200).json({ ok: true })\n }\n if (!isEmailConfigured()) {\n // Config gaps must not become an existence oracle either; log for\n // the operator and keep the visitor-facing contract.\n console.error('membership/recover: email is not configured')\n return res.status(200).json({ ok: true })\n }\n // Bound to the hash sign-in checks, the credential document's (AGL-3308).\n const token = mintPasswordResetToken(\n hostId,\n memberDoc.id,\n await readMemberPasswordHash(hostRef, memberDoc),\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 resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'your 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 // Control 4 — the per-site daily ceiling on recovery mail (AGL-1966).\n //\n // Consumed here, as late as possible, because it counts MESSAGES and not\n // requests: everything above this line can refuse without having decided\n // to send, and a counter that ticked for those would exhaust a site's\n // budget on traffic that never cost a message.\n //\n // The refusal is the silent-success exit, NOT a 429. This branch is\n // reachable only for an address that really is a member, so a visible\n // error here would be the existence oracle the whole handler is built to\n // avoid — the one place where the cheap answer is the wrong one.\n //\n // The accepted cost: during an attack on a site, a genuine member can get\n // no mail and no error. The ceiling is sized more than an order of\n // magnitude above any real site's daily resets so that reaching it is\n // evidence of the attack rather than of a busy Tuesday.\n const sendBudget = await consumeMembershipRecoverSend({ hostId })\n if (!sendBudget.allowed) {\n console.error('membership/recover: daily send ceiling reached', {\n hostId,\n retryAfterSeconds: sendBudget.retryAfterSeconds,\n })\n return res.status(200).json({ ok: true })\n }\n await sendEmail({\n to: email,\n subject: `Reset your ${siteName} password`,\n text:\n `Someone asked to reset the password for your ${siteName} ` +\n 'account. If that was you, set a new password here:\\n\\n' +\n `${resetUrl}\\n\\n` +\n 'The link works once and expires in 1 hour. If you did not ' +\n 'ask for this, you can safely ignore this email — your ' +\n 'password is unchanged.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership recovery',\n })\n // Cost meter (AGL-1438). Transactional, and the clearest case for why a\n // quota may not refuse one: this email is how the member gets back into\n // their account.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n // Even a backend failure stays silent-success: the enumeration\n // contract beats the visitor's error visibility here, and the send is\n // best-effort by design.\n return res.status(200).json({ ok: true })\n }\n}\n"],"names":["resolveBrandingProfile","hostPublicOrigin","consumeMembershipRecoverAttempt","consumeMembershipRecoverSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","RECOVER_MIN_MEMBER_AGE_MS","isEmailConfigured","sendEmail","mintPasswordResetToken","readMemberPasswordHash","readClientIp","EMAIL_PATTERN","membershipRecoverHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","test","ip","headers","remoteAddress","socket","attempt","allowed","setHeader","retryAfterSeconds","hostSnapshot","firestore","app","hostRef","collection","doc","membersQuery","Promise","all","get","where","limit","memberDoc","docs","exists","ok","createdAtMs","raw","toMillis","Date","now","console","token","id","siteBase","cname","subdomain","resetUrl","encodeURIComponent","siteName","branding","catch","org","sendBudget","to","subject","text","fromName","sendingIdentity","audience","context"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-recover.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 } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport {\n consumeMembershipRecoverAttempt,\n consumeMembershipRecoverSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n RECOVER_MIN_MEMBER_AGE_MS,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { mintPasswordResetToken } from './membership'\nimport { readMemberPasswordHash } from './member-credentials'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Password-recovery request (AGL-552). ALWAYS answers `200 {ok:true}` once\n * the input validates and the caller is inside its rate budget — whether or\n * not the email belongs to a member — so the endpoint can't be used to probe\n * account existence. When a member matches, a single-use one-hour token\n * (bound to the current password hash, see membership.ts) is mailed via\n * Resend with a link to the site's `/recover` route.\n *\n * ## Abuse controls (AGL-1966)\n *\n * This is an unauthenticated endpoint that makes us send mail from\n * `noreply@aglyn.com` to an address the caller chose, and it composes with\n * `membership/register` — which admits arbitrary addresses because member\n * accounts are unlimited on every plan (AGL-889) — into a mail relay. The\n * bounces from that land on the sending reputation shared by every customer's\n * transactional mail.\n *\n * Four controls, in the order they run, and the ORDER is part of each:\n *\n * 1. **Attempt caps** — per recipient and per IP, durable\n * (`membership-recover-throttle.ts`). Consumed BEFORE the member lookup so\n * a member and a non-member spend the identical budget and receive the\n * identical answer; a visible 429 from here therefore leaks nothing. The\n * per-instance `Map` this replaced reset on every cold start and was kept\n * per instance, which on Vercel is close to no limit at all (AGL-794).\n * 2. **The young-member guard** — a member row created inside\n * `RECOVER_MIN_MEMBER_AGE_MS` is not worth a reset mail. Register-then-\n * recover is the whole attack and nobody forgets a password ten minutes\n * after choosing it. Silent-success exit.\n * 3. **The platform suppression list** — see the note on the call below.\n * Silent-success exit.\n * 4. **The per-site daily send ceiling** — consumed only when a message is\n * actually about to go out. Because that branch is reachable only for real\n * members, its refusal MUST be the silent-success exit rather than an\n * error, or the ceiling becomes the oracle everything else avoids.\n *\n * What remains unbounded is stated rather than papered over: a genuine botnet\n * still gets `RECOVER_ATTEMPTS_PER_IP` per source, and an attacker who can\n * create many sites gets the per-site ceiling once per site. Host creation is\n * itself rate-limited, and bounding the botnet case needs an edge WAF, not an\n * application limiter — the same conclusion `plugin-api-rate-limit.ts` reaches.\n */\nexport const membershipRecoverHandler: 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 if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n // Null when nothing readable names the caller; the throttle then skips its\n // per-source cap and the per-recipient one carries the control alone.\n const ip = readClientIp(req.headers, {\n remoteAddress: req.socket?.remoteAddress,\n })\n // Control 1. Before the lookup, so both branches spend the same budget.\n // A store failure degrades to the in-process cap rather than refusing\n // (`consumeRateLimit` fails soft on an unreachable store, closed on\n // contention) — a Firestore blip must not lock every site's members out of\n // their own password reset.\n const attempt = await consumeMembershipRecoverAttempt({ email, ip })\n if (!attempt.allowed) {\n res.setHeader('Retry-After', String(attempt.retryAfterSeconds))\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [hostSnapshot, membersQuery] = await Promise.all([\n hostRef.get(),\n hostRef\n .collection('siteMembers')\n .where('email', '==', email)\n .limit(1)\n .get(),\n ])\n const memberDoc = membersQuery.docs[0]\n // Unknown site, unknown email, suspended account (AGL-546): all take\n // the same silent-success exit — the response never distinguishes.\n if (\n !hostSnapshot.exists ||\n !memberDoc ||\n memberDoc.get('suspended') === true\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 2 — the register-then-recover composition (AGL-1966).\n //\n // `createdAt` is a server timestamp written by `membership-register.ts`.\n // A row that has none is a legacy member, not a fresh one: rows predating\n // the field must NOT be refused, so an unreadable or absent value reads as\n // \"old enough\". This guard exists to refuse a member created seconds ago,\n // which is a case where the field is always present by construction.\n const createdAtMs = (() => {\n const raw = memberDoc.get('createdAt') as\n | { toMillis?: () => number }\n | undefined\n try {\n return typeof raw?.toMillis === 'function' ? raw.toMillis() : null\n } catch {\n return null\n }\n })()\n if (\n createdAtMs !== null &&\n Date.now() - createdAtMs < RECOVER_MIN_MEMBER_AGE_MS\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 3 — the platform suppression list (AGL-1918 / AGL-2407).\n //\n // `email-suppression.ts` argues that TRANSACTIONAL mail deliberately does\n // not consult this list, because refusing a password reset over an\n // unrelated old bounce locks a real customer out of their own account.\n // That argument is about mail a signed-in human just asked us for. It does\n // not reach here, and this is the exception it does not cover:\n //\n // - The address is chosen by an ANONYMOUS caller, not by the account\n // holder. \"The human just did something\" is exactly the premise that is\n // missing on an unauthenticated endpoint.\n // - The list holds PERMANENT bounces and spam complaints only — transient\n // bounces are excluded at the webhook. A permanent bounce means the\n // mailbox does not exist, so the send cannot reach anyone; all it can do\n // is tell a provider that `aglyn.com` re-mails dead addresses. There is\n // no locked-out customer on the other side of it to protect.\n // - The reputational cost is shared by every customer's transactional\n // mail, which is precisely what AGL-1966 is about.\n //\n // The HOST list (`hosts/{hostId}/suppressions`) is deliberately NOT\n // consulted: it also holds marketing unsubscribes, and unsubscribing from\n // a store's newsletter must never stop that store's password resets.\n //\n // `isEmailSuppressed` fails CLOSED — a read that throws answers \"treat as\n // suppressed\". That adds no new outage mode here: the member lookup above\n // is a Firestore read on the same database, so a Firestore outage has\n // already taken this handler to its silent-success catch.\n if (await isEmailSuppressed(email)) {\n return res.status(200).json({ ok: true })\n }\n if (!isEmailConfigured()) {\n // Config gaps must not become an existence oracle either; log for\n // the operator and keep the visitor-facing contract.\n console.error('membership/recover: email is not configured')\n return res.status(200).json({ ok: true })\n }\n // Bound to the hash sign-in checks, the credential document's (AGL-3308).\n const token = mintPasswordResetToken(\n hostId,\n memberDoc.id,\n await readMemberPasswordHash(hostRef, memberDoc),\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 resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'your 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 // Control 4 — the per-site daily ceiling on recovery mail (AGL-1966).\n //\n // Consumed here, as late as possible, because it counts MESSAGES and not\n // requests: everything above this line can refuse without having decided\n // to send, and a counter that ticked for those would exhaust a site's\n // budget on traffic that never cost a message.\n //\n // The refusal is the silent-success exit, NOT a 429. This branch is\n // reachable only for an address that really is a member, so a visible\n // error here would be the existence oracle the whole handler is built to\n // avoid — the one place where the cheap answer is the wrong one.\n //\n // The accepted cost: during an attack on a site, a genuine member can get\n // no mail and no error. The ceiling is sized more than an order of\n // magnitude above any real site's daily resets so that reaching it is\n // evidence of the attack rather than of a busy Tuesday.\n const sendBudget = await consumeMembershipRecoverSend({ hostId })\n if (!sendBudget.allowed) {\n console.error('membership/recover: daily send ceiling reached', {\n hostId,\n retryAfterSeconds: sendBudget.retryAfterSeconds,\n })\n return res.status(200).json({ ok: true })\n }\n await sendEmail({\n to: email,\n subject: `Reset your ${siteName} password`,\n text:\n `Someone asked to reset the password for your ${siteName} ` +\n 'account. If that was you, set a new password here:\\n\\n' +\n `${resetUrl}\\n\\n` +\n 'The link works once and expires in 1 hour. If you did not ' +\n 'ask for this, you can safely ignore this email — your ' +\n 'password is unchanged.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership 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 // Cost meter (AGL-1438). Transactional, and the clearest case for why a\n // quota may not refuse one: this email is how the member gets back into\n // their account.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n // Even a backend failure stays silent-success: the enumeration\n // contract beats the visitor's error visibility here, and the send is\n // best-effort by design.\n return res.status(200).json({ ok: true })\n }\n}\n"],"names":["resolveBrandingProfile","hostPublicOrigin","consumeMembershipRecoverAttempt","consumeMembershipRecoverSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","RECOVER_MIN_MEMBER_AGE_MS","isEmailConfigured","sendEmail","mintPasswordResetToken","readMemberPasswordHash","readClientIp","EMAIL_PATTERN","membershipRecoverHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","test","ip","headers","remoteAddress","socket","attempt","allowed","setHeader","retryAfterSeconds","hostSnapshot","firestore","app","hostRef","collection","doc","membersQuery","Promise","all","get","where","limit","memberDoc","docs","exists","ok","createdAtMs","raw","toMillis","Date","now","console","token","id","siteBase","cname","subdomain","resetUrl","encodeURIComponent","siteName","branding","catch","org","sendBudget","to","subject","text","fromName","sendingIdentity","audience","context","owedFor"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SAASA,sBAAsB,QAAQ,sBAAqB;AAC5D,SAASC,gBAAgB,QAAQ,sBAAqB;AACtD,SACEC,+BAA+B,EAC/BC,4BAA4B,EAC5BC,aAAa,EACbC,aAAa,EACbC,mBAAmB,EACnBC,iBAAiB,EACjBC,cAAc,EACdC,yBAAyB,QACpB,2BAA0B;AACjC,SAASC,iBAAiB,EAAEC,SAAS,QAAQ,2BAA0B;AACvE,SAASC,sBAAsB,QAAQ,kBAAc;AACrD,SAASC,sBAAsB,QAAQ,0BAAsB;AAC7D,SAASC,YAAY,QAAQ,oCAAmC;AAEhE,MAAMC,gBAAgB;AAEtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCC,GACD,OAAO,MAAMC,2BAA6C,OAAOC,KAAKC;;QAI9CD,WACDA,YASJA;IAbjB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,SAASC,gBAAOP,YAAAA,IAAIQ,IAAI,qBAARR,UAAUM,MAAM,mBAAI;IAC1C,MAAMG,QAAQF,iBAAOP,aAAAA,IAAIQ,IAAI,qBAARR,WAAUS,KAAK,oBAAI,IACrCC,IAAI,GACJC,WAAW;IACd,IAAI,CAACL,UAAU,CAACR,cAAcc,IAAI,CAACH,QAAQ;QACzC,OAAOR,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAkB;IACzD;IACA,2EAA2E;IAC3E,sEAAsE;IACtE,MAAMQ,KAAKhB,aAAaG,IAAIc,OAAO,EAAE;QACnCC,aAAa,GAAEf,cAAAA,IAAIgB,MAAM,qBAAVhB,YAAYe,aAAa;IAC1C;IACA,wEAAwE;IACxE,sEAAsE;IACtE,oEAAoE;IACpE,2EAA2E;IAC3E,4BAA4B;IAC5B,MAAME,UAAU,MAAMhC,gCAAgC;QAAEwB;QAAOI;IAAG;IAClE,IAAI,CAACI,QAAQC,OAAO,EAAE;QACpBjB,IAAIkB,SAAS,CAAC,eAAeZ,OAAOU,QAAQG,iBAAiB;QAC7D,OAAOnB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAoB;IAC3D;IACA,IAAI;YA0FArB,mBAMAqC,OAAAA;YAKC;QApGH,MAAMC,YAAYnC,cAAcoC,GAAG,GAAGD,SAAS;QAC/C,MAAME,UAAUF,UAAUG,UAAU,CAAC,SAASC,GAAG,CAACpB;QAClD,MAAM,CAACe,cAAcM,aAAa,GAAG,MAAMC,QAAQC,GAAG,CAAC;YACrDL,QAAQM,GAAG;YACXN,QACGC,UAAU,CAAC,eACXM,KAAK,CAAC,SAAS,MAAMtB,OACrBuB,KAAK,CAAC,GACNF,GAAG;SACP;QACD,MAAMG,YAAYN,aAAaO,IAAI,CAAC,EAAE;QACtC,qEAAqE;QACrE,mEAAmE;QACnE,IACE,CAACb,aAAac,MAAM,IACpB,CAACF,aACDA,UAAUH,GAAG,CAAC,iBAAiB,MAC/B;YACA,OAAO7B,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEgC,IAAI;YAAK;QACzC;QACA,gEAAgE;QAChE,EAAE;QACF,yEAAyE;QACzE,0EAA0E;QAC1E,2EAA2E;QAC3E,0EAA0E;QAC1E,qEAAqE;QACrE,MAAMC,cAAc,AAAC,CAAA;YACnB,MAAMC,MAAML,UAAUH,GAAG,CAAC;YAG1B,IAAI;gBACF,OAAO,QAAOQ,uBAAAA,IAAKC,QAAQ,MAAK,aAAaD,IAAIC,QAAQ,KAAK;YAChE,EAAE,eAAM;gBACN,OAAO;YACT;QACF,CAAA;QACA,IACEF,gBAAgB,QAChBG,KAAKC,GAAG,KAAKJ,cAAc7C,2BAC3B;YACA,OAAOS,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEgC,IAAI;YAAK;QACzC;QACA,mEAAmE;QACnE,EAAE;QACF,0EAA0E;QAC1E,mEAAmE;QACnE,uEAAuE;QACvE,2EAA2E;QAC3E,+DAA+D;QAC/D,EAAE;QACF,sEAAsE;QACtE,2EAA2E;QAC3E,6CAA6C;QAC7C,2EAA2E;QAC3E,uEAAuE;QACvE,4EAA4E;QAC5E,2EAA2E;QAC3E,gEAAgE;QAChE,uEAAuE;QACvE,sDAAsD;QACtD,EAAE;QACF,oEAAoE;QACpE,0EAA0E;QAC1E,qEAAqE;QACrE,EAAE;QACF,0EAA0E;QAC1E,0EAA0E;QAC1E,sEAAsE;QACtE,0DAA0D;QAC1D,IAAI,MAAM9C,kBAAkBmB,QAAQ;YAClC,OAAOR,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEgC,IAAI;YAAK;QACzC;QACA,IAAI,CAAC3C,qBAAqB;YACxB,kEAAkE;YAClE,qDAAqD;YACrDiD,QAAQrC,KAAK,CAAC;YACd,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEgC,IAAI;YAAK;QACzC;QACA,0EAA0E;QAC1E,MAAMO,QAAQhD,uBACZW,QACA2B,UAAUW,EAAE,EACZ,MAAMhD,uBAAuB4B,SAASS;QAExC,qEAAqE;QACrE,sEAAsE;QACtE,qDAAqD;QACrD,MAAMY,YACJ7D,oBAAAA,iBAAiB;YACf8D,OAAOzB,aAAaS,GAAG,CAAC;YACxBiB,WAAW1B,aAAaS,GAAG,CAAC;QAC9B,cAHA9C,oBAGM;QACR,MAAMgE,WAAW,GAAGH,SAAS,eAAe,EAAEI,mBAAmBN,QAAQ;QACzE,MAAMO,WAAW3C,QACfc,SAAAA,oBAAAA,aAAaS,GAAG,CAAC,0BAAjBT,oBAAmCA,aAAaS,GAAG,CAAC,wBAApDT,QAAoE;QAEtE,2EAA2E;QAC3E,iEAAiE;QACjE,MAAM8B,WAAWpE,wBACd,QAAA,MAAMK,cAAckB,QAAQ8C,KAAK,CAAC,IAAM,0BAAzC,AAAC,MAAgDC,GAAG;QAEtD,sEAAsE;QACtE,EAAE;QACF,yEAAyE;QACzE,yEAAyE;QACzE,sEAAsE;QACtE,+CAA+C;QAC/C,EAAE;QACF,oEAAoE;QACpE,sEAAsE;QACtE,yEAAyE;QACzE,iEAAiE;QACjE,EAAE;QACF,0EAA0E;QAC1E,mEAAmE;QACnE,sEAAsE;QACtE,wDAAwD;QACxD,MAAMC,aAAa,MAAMpE,6BAA6B;YAAEoB;QAAO;QAC/D,IAAI,CAACgD,WAAWpC,OAAO,EAAE;YACvBwB,QAAQrC,KAAK,CAAC,kDAAkD;gBAC9DC;gBACAc,mBAAmBkC,WAAWlC,iBAAiB;YACjD;YACA,OAAOnB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEgC,IAAI;YAAK;QACzC;QACA,MAAM1C,UAAU;YACd6D,IAAI9C;YACJ+C,SAAS,CAAC,WAAW,EAAEN,SAAS,SAAS,CAAC;YAC1CO,MACE,CAAC,6CAA6C,EAAEP,SAAS,CAAC,CAAC,GAC3D,2DACA,GAAGF,SAAS,IAAI,CAAC,GACjB,+DACA,2DACA;YACFU,UAAUP,SAASO,QAAQ;YAC3BC,iBAAiB,MAAMtE,oBAAoBiB;YAC3CsD,UAAU;YACVC,SAAS;YACT,2DAA2D;YAC3D,gDAAgD;YAChDC,SAAS;QACX;QACA,wEAAwE;QACxE,wEAAwE;QACxE,iBAAiB;QACjB,MAAMvE,eAAee;QACrB,OAAOL,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEgC,IAAI;QAAK;IACzC,EAAE,OAAO/B,OAAO;QACdqC,QAAQrC,KAAK,CAACA;QACd,+DAA+D;QAC/D,sEAAsE;QACtE,yBAAyB;QACzB,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEgC,IAAI;QAAK;IACzC;AACF,EAAC"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
-
import { memberNameSearchFields } from "./member-name-search.js";
|
|
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
19
|
import { firebaseAdmin, recordVisitorRecordCeilingTrip, resolveCampaignTouch } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { emitHostEvent } from "@aglyn/tenant-runtime";
|
|
@@ -128,6 +128,8 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
128
128
|
tx.create(memberRef, _extends({
|
|
129
129
|
email
|
|
130
130
|
}, displayName ? memberNameSearchFields(displayName) : {}, {
|
|
131
|
+
// The Site users list's search: the name and the address (AGL-3321).
|
|
132
|
+
searchTokens: memberSearchTokens(displayName, email),
|
|
131
133
|
/*
|
|
132
134
|
* Stored `false`, not left out: the console's Status filter asks
|
|
133
135
|
* `suspended == false` for the active members, and a query cannot
|