@aglyn/plugins-commerce 1.0.0-beta.203 → 1.0.0-beta.204

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.
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import type { RiskEventInput } from '@aglyn/tenant-data-admin/server/risk-notice';
17
18
  import type * as CommerceModel from '../model';
18
19
  /** What happened to the payment that bought the cards. */
19
20
  export type GiftCardRiskAction = {
@@ -47,23 +48,23 @@ export declare function applyGiftCardRiskToOrder(input: {
47
48
  action: GiftCardRiskAction;
48
49
  nowMs?: number;
49
50
  }): Promise<number>;
50
- /** The notifier's shape: `notifyHostManagers` from the admin barrel. */
51
- export type GiftCardHoldNotifier = (hostId: string, payload: {
52
- type: 'content.order';
53
- title: string;
54
- body: string;
55
- link: string;
56
- }) => Promise<void>;
51
+ /** The notifier's shape: `notifyRiskEvent` from the admin barrel (AGL-3368). */
52
+ export type GiftCardHoldNotifier = (input: RiskEventInput) => Promise<unknown>;
57
53
  /**
58
- * Tell the site's managers that an order's gift cards were frozen
59
- * (AGL-3363). The one notifier for this kind of signal, so a shared
60
- * risk-notice seam can take it over whole. Says what happened and what to
61
- * do, never a rule or a number. Never throws.
54
+ * Tell the site's managers, the workspace's owners and staff that an
55
+ * order's gift cards were frozen (AGL-3363), through the risk notice seam
56
+ * (AGL-3368). The owners read the catalog's `gift-card-hold` words — what
57
+ * happened, and the two ways out: release the cards from Gift cards, or
58
+ * refund the order — never a rule or a number. Never throws.
62
59
  */
63
60
  export declare function notifyGiftCardHold(input: {
64
61
  hostId: string;
65
- /** The order as the merchant knows it: `Order 1042`. */
62
+ /** The order as the merchant knows it: `order 1042`. */
66
63
  orderLabel: string;
64
+ /** The order's own dialog, where Refund lives. */
65
+ orderPath?: string | null;
66
+ /** The order's id, so a redelivered signal notifies once. */
67
+ orderId?: string | null;
67
68
  cards: number;
68
69
  reason: CommerceModel.GiftCardFreezeReason;
69
70
  }, notify: GiftCardHoldNotifier): Promise<void>;
@@ -26,7 +26,8 @@
26
26
  * that fails to freeze is logged and the rest carry on. The caller's money
27
27
  * movement (a refund that already left, a dispute Stripe already opened) is
28
28
  * never failed by this.
29
- *=========================================*/ const CARDS_PER_ORDER_READ = 500;
29
+ *=========================================*/ import { _ as _extends } from "@swc/helpers/_/_extends";
30
+ const CARDS_PER_ORDER_READ = 500;
30
31
  /**
31
32
  * Apply `action` to every card `orderId` issued on `hostRef`. Returns how
32
33
  * many cards changed. Never throws.
@@ -106,19 +107,27 @@
106
107
  'refund-review': 'part of the payment was refunded outside your dashboard and the refund could not be matched to the gift cards'
107
108
  };
108
109
  /**
109
- * Tell the site's managers that an order's gift cards were frozen
110
- * (AGL-3363). The one notifier for this kind of signal, so a shared
111
- * risk-notice seam can take it over whole. Says what happened and what to
112
- * do, never a rule or a number. Never throws.
110
+ * Tell the site's managers, the workspace's owners and staff that an
111
+ * order's gift cards were frozen (AGL-3363), through the risk notice seam
112
+ * (AGL-3368). The owners read the catalog's `gift-card-hold` words — what
113
+ * happened, and the two ways out: release the cards from Gift cards, or
114
+ * refund the order — never a rule or a number. Never throws.
113
115
  */ export async function notifyGiftCardHold(input, notify) {
116
+ var _input_orderPath;
114
117
  if (!input.hostId || input.cards <= 0) return;
115
- const plural = input.cards === 1 ? 'gift card' : 'gift cards';
116
- await notify(input.hostId, {
117
- type: 'content.order',
118
- title: `${input.cards === 1 ? 'A gift card is' : 'Gift cards are'} on hold`,
119
- body: `The ${plural} bought with ${input.orderLabel} cannot be redeemed for now, ` + `because ${FREEZE_CAUSE[input.reason]}. The balance is kept. ` + (input.reason === 'refund-review' ? 'If the customer ' : 'If you are ') + (input.reason === 'refund-review' ? 'kept the gift cards, release them from Gift cards; if the refund was ' + 'for them, void them there.' : 'satisfied the payment is genuine, release it from Gift cards. If the ' + 'payment is refunded or the dispute is lost, the balance is voided for you.'),
120
- link: `/${input.hostId}/products`
121
- }).catch(()=>undefined);
118
+ await notify(_extends({
119
+ kind: 'gift-card-hold',
120
+ orgId: null,
121
+ hostId: input.hostId,
122
+ item: {
123
+ label: input.orderLabel,
124
+ path: (_input_orderPath = input.orderPath) != null ? _input_orderPath : `/${input.hostId}/products/orders`
125
+ }
126
+ }, input.orderId ? {
127
+ dedupeKey: `gift-card-hold:${input.hostId}:${input.orderId}:${input.reason}`
128
+ } : {}, {
129
+ staffEvidence: `${input.cards} gift card${input.cards === 1 ? '' : 's'} frozen: ${FREEZE_CAUSE[input.reason]}.`
130
+ })).catch(()=>undefined);
122
131
  }
123
132
  /**
124
133
  * Apply a refund made OUTSIDE the console — in the Stripe Dashboard — to the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/gift-card-risk.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE CARDS AN ORDER ISSUED, WHEN ITS PAYMENT GOES BAD (AGL-3363).\n *\n * See \"GIFT CARDS ARE A CASH-OUT\" in `model/commerce-gift-cards.ts` for the\n * rules. This is the one place that applies them to the cards an order\n * minted — found by `orderId`, which the cart's completion stamps on every\n * card it issues (the order's id is its Checkout Session id) — so the\n * webhook's fraud-signal and dispute branches and the refund route share it.\n *\n * Every write is per card, in its own transaction, and best-effort: a card\n * that fails to freeze is logged and the rest carry on. The caller's money\n * movement (a refund that already left, a dispute Stripe already opened) is\n * never failed by this.\n *=========================================*/\n\nimport type * as CommerceModel from '../model'\n\n/** What happened to the payment that bought the cards. */\nexport type GiftCardRiskAction =\n | { kind: 'freeze'; reason: CommerceModel.GiftCardFreezeReason }\n /** A dispute won, or a merchant's release: lifts a freeze for `reason` only. */\n | { kind: 'release'; reason?: CommerceModel.GiftCardFreezeReason }\n | {\n kind: 'void'\n reason: CommerceModel.GiftCardVoidReason\n /**\n * Only the cards bought as these products, for a partial refund that\n * withdrew some lines. Absent voids every card the order issued. A card\n * issued before `productId` was stamped is voided only by a full void.\n */\n productIds?: readonly string[]\n }\n\n/** A host document reference, as far as this module reads it. */\ntype HostRef = FirebaseFirestore.DocumentReference\n\nconst CARDS_PER_ORDER_READ = 500\n\n/**\n * Apply `action` to every card `orderId` issued on `hostRef`. Returns how\n * many cards changed. Never throws.\n */\nexport async function applyGiftCardRiskToOrder(input: {\n firestore: FirebaseFirestore.Firestore\n hostRef: HostRef\n orderId: string\n action: GiftCardRiskAction\n nowMs?: number\n}): Promise<number> {\n const { firestore, hostRef, orderId, action } = input\n if (!orderId) return 0\n const nowMs = input.nowMs ?? Date.now()\n let cards: FirebaseFirestore.QueryDocumentSnapshot[]\n try {\n const snapshot = await hostRef\n .collection('giftCards')\n .where('orderId', '==', orderId)\n .limit(CARDS_PER_ORDER_READ)\n .get()\n cards = snapshot.docs ?? []\n } catch (error) {\n console.error('[gift-card-risk] could not read the order’s cards', orderId, error)\n return 0\n }\n let changed = 0\n for (const card of cards) {\n try {\n const moved = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(card.ref)\n const data = (fresh.data() ?? {}) as CommerceModel.HostGiftCard & {\n productId?: string\n }\n if (Number(data.voidedAtMs) > 0) return false\n if (action.kind === 'freeze') {\n if (Number(data.frozenAtMs) > 0) return false\n transaction.set(\n card.ref,\n { frozenAtMs: nowMs, frozenReason: action.reason },\n { merge: true },\n )\n return true\n }\n if (action.kind === 'release') {\n if (!(Number(data.frozenAtMs) > 0)) return false\n if (action.reason && data.frozenReason !== action.reason) return false\n transaction.set(\n card.ref,\n { frozenAtMs: null, frozenReason: null, releasedAtMs: nowMs },\n { merge: true },\n )\n return true\n }\n if (action.productIds) {\n if (!data.productId || !action.productIds.includes(data.productId)) {\n return false\n }\n }\n const balance = Math.max(0, Math.round(Number(data.balanceCents) || 0))\n transaction.set(\n card.ref,\n {\n balanceCents: 0,\n voidedAtMs: nowMs,\n voidedReason: action.reason,\n // What was taken back, so the merchant can see it and reissue by\n // hand if the reversal is itself reversed.\n voidedBalanceCents: balance,\n },\n { merge: true },\n )\n return true\n })\n if (moved) changed += 1\n } catch (error) {\n console.error('[gift-card-risk] card update failed', card.id, error)\n }\n }\n return changed\n}\n\n/** What a freeze's reason means, in the merchant's words. */\nconst FREEZE_CAUSE: Record<CommerceModel.GiftCardFreezeReason, string> = {\n 'early-fraud-warning': 'the card issuer reported the payment as possibly fraudulent',\n 'radar-review': 'the payment is being reviewed for fraud',\n dispute: 'the payment is disputed',\n 'refund-review':\n 'part of the payment was refunded outside your dashboard and the refund could not be matched to the gift cards',\n}\n\n/** The notifier's shape: `notifyHostManagers` from the admin barrel. */\nexport type GiftCardHoldNotifier = (\n hostId: string,\n payload: { type: 'content.order'; title: string; body: string; link: string },\n) => Promise<void>\n\n/**\n * Tell the site's managers that an order's gift cards were frozen\n * (AGL-3363). The one notifier for this kind of signal, so a shared\n * risk-notice seam can take it over whole. Says what happened and what to\n * do, never a rule or a number. Never throws.\n */\nexport async function notifyGiftCardHold(\n input: {\n hostId: string\n /** The order as the merchant knows it: `Order 1042`. */\n orderLabel: string\n cards: number\n reason: CommerceModel.GiftCardFreezeReason\n },\n notify: GiftCardHoldNotifier,\n): Promise<void> {\n if (!input.hostId || input.cards <= 0) return\n const plural = input.cards === 1 ? 'gift card' : 'gift cards'\n await notify(input.hostId, {\n type: 'content.order',\n title: `${input.cards === 1 ? 'A gift card is' : 'Gift cards are'} on hold`,\n body:\n `The ${plural} bought with ${input.orderLabel} cannot be redeemed for now, ` +\n `because ${FREEZE_CAUSE[input.reason]}. The balance is kept. ` +\n (input.reason === 'refund-review' ? 'If the customer ' : 'If you are ') +\n (input.reason === 'refund-review'\n ? 'kept the gift cards, release them from Gift cards; if the refund was ' +\n 'for them, void them there.'\n : 'satisfied the payment is genuine, release it from Gift cards. If the ' +\n 'payment is refunded or the dispute is lost, the balance is voided for you.'),\n link: `/${input.hostId}/products`,\n }).catch(() => undefined)\n}\n\n/** One line of the order, as far as matching a refund to it goes. */\nexport interface GiftCardRefundLine {\n productId: string\n /** What the line cost in total: unit price × quantity. */\n totalCents: number\n}\n\n/** What a refund made outside the console did to the order's cards. */\nexport type ExternalRefundGiftCardOutcome =\n | { kind: 'no-cards' }\n | { kind: 'voided'; cards: number }\n /** Matched to a line that bought no gift card: nothing to take back. */\n | { kind: 'matched-other-line' }\n | { kind: 'frozen-for-review'; cards: number }\n\n/**\n * Apply a refund made OUTSIDE the console — in the Stripe Dashboard — to the\n * gift cards the order bought, the way an in-console refund does (AGL-3363).\n *\n * - A charge refunded in full voids every card the order issued.\n * - A partial refund is matched to lines by amount: when it equals exactly\n * one line's total, or exactly the total of every gift-card line, the\n * cards those lines bought are voided (a line that bought no card voids\n * nothing, which is the refund of a mug).\n * - Anything else cannot be told apart — two lines of the same price, a\n * goodwill amount — so the order's cards are FROZEN for the merchant to\n * review rather than voided or left spendable.\n */\nexport async function applyExternalRefundToGiftCards(input: {\n firestore: FirebaseFirestore.Firestore\n hostRef: FirebaseFirestore.DocumentReference\n orderId: string\n lines: readonly GiftCardRefundLine[]\n refundCents: number\n fullyRefunded: boolean\n nowMs?: number\n}): Promise<ExternalRefundGiftCardOutcome> {\n const { firestore, hostRef, orderId } = input\n let cardProducts: string[]\n try {\n const snapshot = await hostRef\n .collection('giftCards')\n .where('orderId', '==', orderId)\n .limit(CARDS_PER_ORDER_READ)\n .get()\n const live = (snapshot.docs ?? []).filter(\n (card) => !(Number(card.get?.('voidedAtMs') ?? card.data?.()?.['voidedAtMs']) > 0),\n )\n if (live.length === 0) return { kind: 'no-cards' }\n cardProducts = live.map((card) =>\n String(card.get?.('productId') ?? card.data?.()?.['productId'] ?? ''),\n )\n } catch (error) {\n console.error('[gift-card-risk] could not read the order’s cards', orderId, error)\n return { kind: 'no-cards' }\n }\n const apply = (action: GiftCardRiskAction) =>\n applyGiftCardRiskToOrder({ firestore, hostRef, orderId, action, nowMs: input.nowMs })\n\n if (input.fullyRefunded) {\n return { kind: 'voided', cards: await apply({ kind: 'void', reason: 'refund' }) }\n }\n const refundCents = Math.round(input.refundCents)\n const single = input.lines.filter((line) => line.totalCents === refundCents)\n const singleProducts = new Set(single.map((line) => line.productId))\n if (single.length > 0 && singleProducts.size === 1) {\n const [productId] = [...singleProducts]\n if (!cardProducts.includes(productId)) return { kind: 'matched-other-line' }\n return {\n kind: 'voided',\n cards: await apply({ kind: 'void', reason: 'refund', productIds: [productId] }),\n }\n }\n const giftLines = input.lines.filter((line) => cardProducts.includes(line.productId))\n const giftTotal = giftLines.reduce((sum, line) => sum + line.totalCents, 0)\n if (single.length === 0 && giftLines.length > 0 && giftTotal === refundCents) {\n return {\n kind: 'voided',\n cards: await apply({\n kind: 'void',\n reason: 'refund',\n productIds: [...new Set(giftLines.map((line) => line.productId))],\n }),\n }\n }\n return {\n kind: 'frozen-for-review',\n cards: await apply({ kind: 'freeze', reason: 'refund-review' }),\n }\n}\n"],"names":["CARDS_PER_ORDER_READ","applyGiftCardRiskToOrder","input","firestore","hostRef","orderId","action","nowMs","Date","now","cards","snapshot","collection","where","limit","get","docs","error","console","changed","card","moved","runTransaction","transaction","fresh","ref","data","Number","voidedAtMs","kind","frozenAtMs","set","frozenReason","reason","merge","releasedAtMs","productIds","productId","includes","balance","Math","max","round","balanceCents","voidedReason","voidedBalanceCents","id","FREEZE_CAUSE","dispute","notifyGiftCardHold","notify","hostId","plural","type","title","body","orderLabel","link","catch","undefined","applyExternalRefundToGiftCards","cardProducts","live","filter","length","map","String","apply","fullyRefunded","refundCents","single","lines","line","totalCents","singleProducts","Set","size","giftLines","giftTotal","reduce","sum"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/gift-card-risk.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE CARDS AN ORDER ISSUED, WHEN ITS PAYMENT GOES BAD (AGL-3363).\n *\n * See \"GIFT CARDS ARE A CASH-OUT\" in `model/commerce-gift-cards.ts` for the\n * rules. This is the one place that applies them to the cards an order\n * minted — found by `orderId`, which the cart's completion stamps on every\n * card it issues (the order's id is its Checkout Session id) — so the\n * webhook's fraud-signal and dispute branches and the refund route share it.\n *\n * Every write is per card, in its own transaction, and best-effort: a card\n * that fails to freeze is logged and the rest carry on. The caller's money\n * movement (a refund that already left, a dispute Stripe already opened) is\n * never failed by this.\n *=========================================*/\n\nimport type { RiskEventInput } from '@aglyn/tenant-data-admin/server/risk-notice'\nimport type * as CommerceModel from '../model'\n\n/** What happened to the payment that bought the cards. */\nexport type GiftCardRiskAction =\n | { kind: 'freeze'; reason: CommerceModel.GiftCardFreezeReason }\n /** A dispute won, or a merchant's release: lifts a freeze for `reason` only. */\n | { kind: 'release'; reason?: CommerceModel.GiftCardFreezeReason }\n | {\n kind: 'void'\n reason: CommerceModel.GiftCardVoidReason\n /**\n * Only the cards bought as these products, for a partial refund that\n * withdrew some lines. Absent voids every card the order issued. A card\n * issued before `productId` was stamped is voided only by a full void.\n */\n productIds?: readonly string[]\n }\n\n/** A host document reference, as far as this module reads it. */\ntype HostRef = FirebaseFirestore.DocumentReference\n\nconst CARDS_PER_ORDER_READ = 500\n\n/**\n * Apply `action` to every card `orderId` issued on `hostRef`. Returns how\n * many cards changed. Never throws.\n */\nexport async function applyGiftCardRiskToOrder(input: {\n firestore: FirebaseFirestore.Firestore\n hostRef: HostRef\n orderId: string\n action: GiftCardRiskAction\n nowMs?: number\n}): Promise<number> {\n const { firestore, hostRef, orderId, action } = input\n if (!orderId) return 0\n const nowMs = input.nowMs ?? Date.now()\n let cards: FirebaseFirestore.QueryDocumentSnapshot[]\n try {\n const snapshot = await hostRef\n .collection('giftCards')\n .where('orderId', '==', orderId)\n .limit(CARDS_PER_ORDER_READ)\n .get()\n cards = snapshot.docs ?? []\n } catch (error) {\n console.error('[gift-card-risk] could not read the order’s cards', orderId, error)\n return 0\n }\n let changed = 0\n for (const card of cards) {\n try {\n const moved = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(card.ref)\n const data = (fresh.data() ?? {}) as CommerceModel.HostGiftCard & {\n productId?: string\n }\n if (Number(data.voidedAtMs) > 0) return false\n if (action.kind === 'freeze') {\n if (Number(data.frozenAtMs) > 0) return false\n transaction.set(\n card.ref,\n { frozenAtMs: nowMs, frozenReason: action.reason },\n { merge: true },\n )\n return true\n }\n if (action.kind === 'release') {\n if (!(Number(data.frozenAtMs) > 0)) return false\n if (action.reason && data.frozenReason !== action.reason) return false\n transaction.set(\n card.ref,\n { frozenAtMs: null, frozenReason: null, releasedAtMs: nowMs },\n { merge: true },\n )\n return true\n }\n if (action.productIds) {\n if (!data.productId || !action.productIds.includes(data.productId)) {\n return false\n }\n }\n const balance = Math.max(0, Math.round(Number(data.balanceCents) || 0))\n transaction.set(\n card.ref,\n {\n balanceCents: 0,\n voidedAtMs: nowMs,\n voidedReason: action.reason,\n // What was taken back, so the merchant can see it and reissue by\n // hand if the reversal is itself reversed.\n voidedBalanceCents: balance,\n },\n { merge: true },\n )\n return true\n })\n if (moved) changed += 1\n } catch (error) {\n console.error('[gift-card-risk] card update failed', card.id, error)\n }\n }\n return changed\n}\n\n/** What a freeze's reason means, in the merchant's words. */\nconst FREEZE_CAUSE: Record<CommerceModel.GiftCardFreezeReason, string> = {\n 'early-fraud-warning': 'the card issuer reported the payment as possibly fraudulent',\n 'radar-review': 'the payment is being reviewed for fraud',\n dispute: 'the payment is disputed',\n 'refund-review':\n 'part of the payment was refunded outside your dashboard and the refund could not be matched to the gift cards',\n}\n\n/** The notifier's shape: `notifyRiskEvent` from the admin barrel (AGL-3368). */\nexport type GiftCardHoldNotifier = (input: RiskEventInput) => Promise<unknown>\n\n/**\n * Tell the site's managers, the workspace's owners and staff that an\n * order's gift cards were frozen (AGL-3363), through the risk notice seam\n * (AGL-3368). The owners read the catalog's `gift-card-hold` words — what\n * happened, and the two ways out: release the cards from Gift cards, or\n * refund the order — never a rule or a number. Never throws.\n */\nexport async function notifyGiftCardHold(\n input: {\n hostId: string\n /** The order as the merchant knows it: `order 1042`. */\n orderLabel: string\n /** The order's own dialog, where Refund lives. */\n orderPath?: string | null\n /** The order's id, so a redelivered signal notifies once. */\n orderId?: string | null\n cards: number\n reason: CommerceModel.GiftCardFreezeReason\n },\n notify: GiftCardHoldNotifier,\n): Promise<void> {\n if (!input.hostId || input.cards <= 0) return\n await notify({\n kind: 'gift-card-hold',\n orgId: null,\n hostId: input.hostId,\n item: { label: input.orderLabel, path: input.orderPath ?? `/${input.hostId}/products/orders` },\n ...(input.orderId ? { dedupeKey: `gift-card-hold:${input.hostId}:${input.orderId}:${input.reason}` } : {}),\n staffEvidence:\n `${input.cards} gift card${input.cards === 1 ? '' : 's'} frozen: ${FREEZE_CAUSE[input.reason]}.`,\n }).catch(() => undefined)\n}\n\n/** One line of the order, as far as matching a refund to it goes. */\nexport interface GiftCardRefundLine {\n productId: string\n /** What the line cost in total: unit price × quantity. */\n totalCents: number\n}\n\n/** What a refund made outside the console did to the order's cards. */\nexport type ExternalRefundGiftCardOutcome =\n | { kind: 'no-cards' }\n | { kind: 'voided'; cards: number }\n /** Matched to a line that bought no gift card: nothing to take back. */\n | { kind: 'matched-other-line' }\n | { kind: 'frozen-for-review'; cards: number }\n\n/**\n * Apply a refund made OUTSIDE the console — in the Stripe Dashboard — to the\n * gift cards the order bought, the way an in-console refund does (AGL-3363).\n *\n * - A charge refunded in full voids every card the order issued.\n * - A partial refund is matched to lines by amount: when it equals exactly\n * one line's total, or exactly the total of every gift-card line, the\n * cards those lines bought are voided (a line that bought no card voids\n * nothing, which is the refund of a mug).\n * - Anything else cannot be told apart — two lines of the same price, a\n * goodwill amount — so the order's cards are FROZEN for the merchant to\n * review rather than voided or left spendable.\n */\nexport async function applyExternalRefundToGiftCards(input: {\n firestore: FirebaseFirestore.Firestore\n hostRef: FirebaseFirestore.DocumentReference\n orderId: string\n lines: readonly GiftCardRefundLine[]\n refundCents: number\n fullyRefunded: boolean\n nowMs?: number\n}): Promise<ExternalRefundGiftCardOutcome> {\n const { firestore, hostRef, orderId } = input\n let cardProducts: string[]\n try {\n const snapshot = await hostRef\n .collection('giftCards')\n .where('orderId', '==', orderId)\n .limit(CARDS_PER_ORDER_READ)\n .get()\n const live = (snapshot.docs ?? []).filter(\n (card) => !(Number(card.get?.('voidedAtMs') ?? card.data?.()?.['voidedAtMs']) > 0),\n )\n if (live.length === 0) return { kind: 'no-cards' }\n cardProducts = live.map((card) =>\n String(card.get?.('productId') ?? card.data?.()?.['productId'] ?? ''),\n )\n } catch (error) {\n console.error('[gift-card-risk] could not read the order’s cards', orderId, error)\n return { kind: 'no-cards' }\n }\n const apply = (action: GiftCardRiskAction) =>\n applyGiftCardRiskToOrder({ firestore, hostRef, orderId, action, nowMs: input.nowMs })\n\n if (input.fullyRefunded) {\n return { kind: 'voided', cards: await apply({ kind: 'void', reason: 'refund' }) }\n }\n const refundCents = Math.round(input.refundCents)\n const single = input.lines.filter((line) => line.totalCents === refundCents)\n const singleProducts = new Set(single.map((line) => line.productId))\n if (single.length > 0 && singleProducts.size === 1) {\n const [productId] = [...singleProducts]\n if (!cardProducts.includes(productId)) return { kind: 'matched-other-line' }\n return {\n kind: 'voided',\n cards: await apply({ kind: 'void', reason: 'refund', productIds: [productId] }),\n }\n }\n const giftLines = input.lines.filter((line) => cardProducts.includes(line.productId))\n const giftTotal = giftLines.reduce((sum, line) => sum + line.totalCents, 0)\n if (single.length === 0 && giftLines.length > 0 && giftTotal === refundCents) {\n return {\n kind: 'voided',\n cards: await apply({\n kind: 'void',\n reason: 'refund',\n productIds: [...new Set(giftLines.map((line) => line.productId))],\n }),\n }\n }\n return {\n kind: 'frozen-for-review',\n cards: await apply({ kind: 'freeze', reason: 'refund-review' }),\n }\n}\n"],"names":["CARDS_PER_ORDER_READ","applyGiftCardRiskToOrder","input","firestore","hostRef","orderId","action","nowMs","Date","now","cards","snapshot","collection","where","limit","get","docs","error","console","changed","card","moved","runTransaction","transaction","fresh","ref","data","Number","voidedAtMs","kind","frozenAtMs","set","frozenReason","reason","merge","releasedAtMs","productIds","productId","includes","balance","Math","max","round","balanceCents","voidedReason","voidedBalanceCents","id","FREEZE_CAUSE","dispute","notifyGiftCardHold","notify","hostId","orgId","item","label","orderLabel","path","orderPath","dedupeKey","staffEvidence","catch","undefined","applyExternalRefundToGiftCards","cardProducts","live","filter","length","map","String","apply","fullyRefunded","refundCents","single","lines","line","totalCents","singleProducts","Set","size","giftLines","giftTotal","reduce","sum"],"mappings":"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"}
@@ -15,6 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail } from "@aglyn/tenant-data-admin";
18
+ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
18
19
  import * as CommerceModel from "../model/index.js";
19
20
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
20
21
  import { consentGroupForHost, EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveBrandingProfile } from "@aglyn/aglyn/server";
@@ -113,6 +114,15 @@ import { consentGroupForHost, EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveB
113
114
  * change under a long-lived process, so how long a resolution may be
114
115
  * trusted is the caller's declaration.
115
116
  */ const identity = await hostSendingIdentity(hostId);
117
+ // The `member-post` site email (AGL-3370): the site's design, or the
118
+ // built-in copy in its header and footer. Read once for the batch, and
119
+ // the same message for everyone, so rendered once too.
120
+ const designed = await loadHostEmailWithTokens(firestore, hostId, 'member-post', siteBase ? {
121
+ origin: siteBase
122
+ } : {}).then((bundle)=>bundle ? renderLoadedHostEmailWithTokens(bundle, {
123
+ 'post.title': title,
124
+ 'post.body': postBody
125
+ }) : null).catch(()=>null);
116
126
  for (const to of recipients){
117
127
  /*
118
128
  * MARKETING. A member post is a merchant mailing their audience, so
@@ -123,10 +133,13 @@ import { consentGroupForHost, EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveB
123
133
  * Priority stays transactional — this handler answers a click and has
124
134
  * nowhere to put a message the hourly governor deferred, and the rule
125
135
  * on `'bulk'` is that only a resumable sweep may use it.
126
- */ const result = await sendEmail({
136
+ */ const result = await sendEmail(_extends({
127
137
  to,
128
- subject: title,
129
- text: postBody || title,
138
+ subject: (designed == null ? void 0 : designed.subject) || title,
139
+ text: (designed == null ? void 0 : designed.text) || postBody || title
140
+ }, (designed == null ? void 0 : designed.html) ? {
141
+ html: designed.html
142
+ } : {}, {
130
143
  fromName: branding.fromName,
131
144
  sendingIdentity: identity,
132
145
  audience: 'tenant',
@@ -144,7 +157,7 @@ import { consentGroupForHost, EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveB
144
157
  consentHostIds: consentGroup.hostIds,
145
158
  consentAwaitsConfirmation: consentGroup.awaitsConfirmation
146
159
  }
147
- });
160
+ }));
148
161
  // The cost meter counts messages that LEFT. A suppressed or capped
149
162
  // recipient produced no message and therefore no cost, and counting
150
163
  // one would bill a merchant for the control working.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-post.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n consentGroupForHost,\n EMAIL_TOPIC_NEWSLETTER,\n hostPublicOrigin,\n type PluginApiHandler,\n resolveBrandingProfile,\n} from '@aglyn/aglyn/server'\n\n// Which member subscribers are live enough to email (AGL-316). The list itself\n// now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a\n// private copy here and a second private copy in `gate.ts`, which is the drift\n// AGL-1715 guards against on the org side.\n\n/**\n * Member post publish (AGL-316): manager-gated; writes the post and\n * optionally emails the entitled subscribers (product-scoped or all\n * live subscribers) through the env-gated Resend pipeline.\n */\nexport const memberPostHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const title = String(body.title ?? '').trim().slice(0, 160)\n const postBody = String(body.body ?? '').trim().slice(0, 5000)\n const productId = String(body.productId ?? '').trim()\n const emailSubscribers = Boolean(body.emailSubscribers)\n if (!hostId || !title) {\n return res.status(400).json({ error: 'Missing hostId or title' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n // AN ALLOWLIST (AGL-2372), matching `gift-cards.ts` and the AGL-2262\n // correction to the register. The old `!role || role === 'viewer'`\n // denylist admitted every string that was not literally `viewer` — and\n // since AGL-2334 that includes `author`, a real grantable role, on a route\n // that publishes to paying subscribers and sends them mail on the\n // merchant's brand. A denylist that widens whenever the role union grows\n // is the defect; the set is the fix. Absent is refused, not permitted.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n const postRef = await hostRef.collection('memberPosts').add({\n title,\n body: postBody,\n ...(productId ? { productId } : {}),\n createdAtMs: Date.now(),\n createdBy: decoded.uid,\n })\n\n let emailed = 0\n if (emailSubscribers && isEmailConfigured()) {\n /*\n * ORDERED (D1). This was `limit(500)` with no `orderBy`, and Firestore\n * answers an unordered limit in DOCUMENT-ID order — so a site past the\n * ceiling mailed whichever five hundred subscriptions happened to hash\n * low, and the same five hundred every time. `__name__` is the order it\n * was already getting; stating it makes the window a decision rather\n * than an accident, and makes it the same window on every publish.\n */\n const subscriptions = await hostRef\n .collection('subscriptions')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(500)\n .get()\n const recipients = [\n ...new Set(\n subscriptions.docs\n .filter(\n (docSnapshot) =>\n CommerceModel.isTenantSubscriptionLive(\n docSnapshot.get('status'),\n ) &&\n (!productId ||\n docSnapshot.get('productId') === productId) &&\n docSnapshot.get('customerEmail'),\n )\n .map((docSnapshot) => String(docSnapshot.get('customerEmail'))),\n ),\n ].slice(0, 200)\n // White-label sender identity (White-Label Phase 3): the store's brand\n // via the one shared resolver, from the owning org doc.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const branding = resolveBrandingProfile(owner?.org as never)\n // The site's consent group, off the same org read: a member who left\n // any site the org declared one sender with this one is not mailed.\n const consentGroup = consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n )\n // The site's own origin, for the unsubscribe link the gate mints. Read\n // from the host document already in hand rather than assembled from an\n // apex: a post is mailed and read later, and a wrong origin sends a\n // subscriber's opt-out to a domain the merchant does not control.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n /*\n * ONE site, up to two hundred recipients — so the identity is resolved\n * once and reused, not read per message. Hoisted rather than cached\n * inside the helper for the reason its docblock gives: verification can\n * change under a long-lived process, so how long a resolution may be\n * trusted is the caller's declaration.\n */\n const identity = await hostSendingIdentity(hostId)\n for (const to of recipients) {\n /*\n * MARKETING. A member post is a merchant mailing their audience, so\n * it carries the unsubscribe header pair and a visible link, it is\n * checked against both suppression lists, and it counts against how\n * much mail one person receives from this site.\n *\n * Priority stays transactional — this handler answers a click and has\n * nowhere to put a message the hourly governor deferred, and the rule\n * on `'bulk'` is that only a resumable sweep may use it.\n */\n const result = await sendEmail({\n to,\n subject: title,\n text: postBody || title,\n fromName: branding.fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'member post',\n /*\n * A post to a site's members is the \"Newsletter\" stream — regular\n * news and stories from the site. Declared so a member who left\n * that stream, while staying subscribed to everything else, is not\n * mailed it; the gate asks the topic only for callers that name\n * one.\n */\n marketing: {\n hostId,\n siteBase,\n topicId: EMAIL_TOPIC_NEWSLETTER,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n // The cost meter counts messages that LEFT. A suppressed or capped\n // recipient produced no message and therefore no cost, and counting\n // one would bill a merchant for the control working.\n if (result.sent) emailed += 1\n }\n // Cost meter (AGL-1438), once for the batch rather than per recipient —\n // same number, one write. Transactional: a member post is content the\n // subscriber is paying for, not a discretionary campaign, so it counts\n // toward cost and is never refused by the campaign cap.\n await meterHostEmail(hostId, emailed)\n }\n return res.status(200).json({ postId: postRef.id, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","CommerceModel","isEmailConfigured","sendEmail","consentGroupForHost","EMAIL_TOPIC_NEWSLETTER","hostPublicOrigin","resolveBrandingProfile","memberPostHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","title","trim","postBody","productId","emailSubscribers","Boolean","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","memberRole","uid","postRef","add","createdAtMs","Date","now","createdBy","emailed","subscriptions","orderBy","FieldPath","documentId","limit","recipients","Set","docs","filter","docSnapshot","isTenantSubscriptionLive","map","owner","catch","branding","org","consentGroup","siteBase","cname","subdomain","identity","to","result","subject","text","fromName","sendingIdentity","audience","context","marketing","topicId","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","sent","postId","id","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-post.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport * as CommerceModel from '../model'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n consentGroupForHost,\n EMAIL_TOPIC_NEWSLETTER,\n hostPublicOrigin,\n type PluginApiHandler,\n resolveBrandingProfile,\n} from '@aglyn/aglyn/server'\n\n// Which member subscribers are live enough to email (AGL-316). The list itself\n// now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a\n// private copy here and a second private copy in `gate.ts`, which is the drift\n// AGL-1715 guards against on the org side.\n\n/**\n * Member post publish (AGL-316): manager-gated; writes the post and\n * optionally emails the entitled subscribers (product-scoped or all\n * live subscribers) through the env-gated Resend pipeline.\n */\nexport const memberPostHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const title = String(body.title ?? '').trim().slice(0, 160)\n const postBody = String(body.body ?? '').trim().slice(0, 5000)\n const productId = String(body.productId ?? '').trim()\n const emailSubscribers = Boolean(body.emailSubscribers)\n if (!hostId || !title) {\n return res.status(400).json({ error: 'Missing hostId or title' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n // AN ALLOWLIST (AGL-2372), matching `gift-cards.ts` and the AGL-2262\n // correction to the register. The old `!role || role === 'viewer'`\n // denylist admitted every string that was not literally `viewer` — and\n // since AGL-2334 that includes `author`, a real grantable role, on a route\n // that publishes to paying subscribers and sends them mail on the\n // merchant's brand. A denylist that widens whenever the role union grows\n // is the defect; the set is the fix. Absent is refused, not permitted.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n const postRef = await hostRef.collection('memberPosts').add({\n title,\n body: postBody,\n ...(productId ? { productId } : {}),\n createdAtMs: Date.now(),\n createdBy: decoded.uid,\n })\n\n let emailed = 0\n if (emailSubscribers && isEmailConfigured()) {\n /*\n * ORDERED (D1). This was `limit(500)` with no `orderBy`, and Firestore\n * answers an unordered limit in DOCUMENT-ID order — so a site past the\n * ceiling mailed whichever five hundred subscriptions happened to hash\n * low, and the same five hundred every time. `__name__` is the order it\n * was already getting; stating it makes the window a decision rather\n * than an accident, and makes it the same window on every publish.\n */\n const subscriptions = await hostRef\n .collection('subscriptions')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(500)\n .get()\n const recipients = [\n ...new Set(\n subscriptions.docs\n .filter(\n (docSnapshot) =>\n CommerceModel.isTenantSubscriptionLive(\n docSnapshot.get('status'),\n ) &&\n (!productId ||\n docSnapshot.get('productId') === productId) &&\n docSnapshot.get('customerEmail'),\n )\n .map((docSnapshot) => String(docSnapshot.get('customerEmail'))),\n ),\n ].slice(0, 200)\n // White-label sender identity (White-Label Phase 3): the store's brand\n // via the one shared resolver, from the owning org doc.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const branding = resolveBrandingProfile(owner?.org as never)\n // The site's consent group, off the same org read: a member who left\n // any site the org declared one sender with this one is not mailed.\n const consentGroup = consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n )\n // The site's own origin, for the unsubscribe link the gate mints. Read\n // from the host document already in hand rather than assembled from an\n // apex: a post is mailed and read later, and a wrong origin sends a\n // subscriber's opt-out to a domain the merchant does not control.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n /*\n * ONE site, up to two hundred recipients — so the identity is resolved\n * once and reused, not read per message. Hoisted rather than cached\n * inside the helper for the reason its docblock gives: verification can\n * change under a long-lived process, so how long a resolution may be\n * trusted is the caller's declaration.\n */\n const identity = await hostSendingIdentity(hostId)\n // The `member-post` site email (AGL-3370): the site's design, or the\n // built-in copy in its header and footer. Read once for the batch, and\n // the same message for everyone, so rendered once too.\n const designed = await loadHostEmailWithTokens(\n firestore,\n hostId,\n 'member-post',\n siteBase ? { origin: siteBase } : {},\n )\n .then((bundle) =>\n bundle\n ? renderLoadedHostEmailWithTokens(bundle, {\n 'post.title': title,\n 'post.body': postBody,\n })\n : null,\n )\n .catch(() => null)\n for (const to of recipients) {\n /*\n * MARKETING. A member post is a merchant mailing their audience, so\n * it carries the unsubscribe header pair and a visible link, it is\n * checked against both suppression lists, and it counts against how\n * much mail one person receives from this site.\n *\n * Priority stays transactional — this handler answers a click and has\n * nowhere to put a message the hourly governor deferred, and the rule\n * on `'bulk'` is that only a resumable sweep may use it.\n */\n const result = await sendEmail({\n to,\n subject: designed?.subject || title,\n text: designed?.text || postBody || title,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'member post',\n /*\n * A post to a site's members is the \"Newsletter\" stream — regular\n * news and stories from the site. Declared so a member who left\n * that stream, while staying subscribed to everything else, is not\n * mailed it; the gate asks the topic only for callers that name\n * one.\n */\n marketing: {\n hostId,\n siteBase,\n topicId: EMAIL_TOPIC_NEWSLETTER,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n // The cost meter counts messages that LEFT. A suppressed or capped\n // recipient produced no message and therefore no cost, and counting\n // one would bill a merchant for the control working.\n if (result.sent) emailed += 1\n }\n // Cost meter (AGL-1438), once for the batch rather than per recipient —\n // same number, one write. Transactional: a member post is content the\n // subscriber is paying for, not a discretionary campaign, so it counts\n // toward cost and is never refused by the campaign cap.\n await meterHostEmail(hostId, emailed)\n }\n return res.status(200).json({ postId: postRef.id, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","CommerceModel","isEmailConfigured","sendEmail","consentGroupForHost","EMAIL_TOPIC_NEWSLETTER","hostPublicOrigin","resolveBrandingProfile","memberPostHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","title","trim","postBody","productId","emailSubscribers","Boolean","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","memberRole","uid","postRef","add","createdAtMs","Date","now","createdBy","emailed","subscriptions","orderBy","FieldPath","documentId","limit","recipients","Set","docs","filter","docSnapshot","isTenantSubscriptionLive","map","owner","catch","branding","org","consentGroup","siteBase","cname","subdomain","identity","designed","origin","then","bundle","to","result","subject","text","html","fromName","sendingIdentity","audience","context","marketing","topicId","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","sent","postId","id","console"],"mappings":"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"}
@@ -17,6 +17,7 @@
17
17
  import { resolveBrandingProfile, validateNewPassword } from "@aglyn/aglyn/server";
18
18
  import { hostPublicOrigin } from "@aglyn/aglyn/server";
19
19
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
20
+ import { renderHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
20
21
  import { consumePasswordResetSend, firebaseAdmin, getOrgForHost, hostSendingIdentity, isImpersonationSession, meterHostEmail, passwordResetThrottleMessage } from "@aglyn/tenant-data-admin";
21
22
  import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
22
23
  import { hashMemberPassword, MEMBER_SESSIONS_VALID_FROM_FIELD, mintPasswordResetToken } from "./membership.js";
@@ -146,10 +147,25 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
146
147
  // retires the token without any server-side storage.
147
148
  const token = mintPasswordResetToken(hostId, memberId, await readMemberPasswordHash(hostRef, memberSnapshot));
148
149
  const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`;
149
- const result = await sendEmail({
150
+ const intro = `An administrator of ${siteName} started a password reset for your account.`;
151
+ const note = 'The link works once and expires in 1 hour. Your current ' + 'password keeps working until you use it.';
152
+ // The `member-password-reset` site email (AGL-3370), as the member's
153
+ // own request sends it, with the words for who started it.
154
+ const designed = await renderHostEmailWithTokens(firestore, hostId, 'member-password-reset', {
155
+ 'site.name': siteName,
156
+ 'reset.intro': intro,
157
+ resetUrl,
158
+ 'reset.note': note
159
+ }, siteBase ? {
160
+ origin: siteBase
161
+ } : {}).catch(()=>null);
162
+ const result = await sendEmail(_extends({
150
163
  to: email,
151
- subject: `Reset your ${siteName} password`,
152
- text: `An administrator of ${siteName} started a password reset for ` + 'your account. Set a new password here:\n\n' + `${resetUrl}\n\n` + 'The link works once and expires in 1 hour. Your current ' + 'password keeps working until you use it.',
164
+ subject: (designed == null ? void 0 : designed.subject) || `Reset your ${siteName} password`,
165
+ text: (designed == null ? void 0 : designed.text) || `${intro.replace(/\.$/, '')}. Set a new password here:\n\n` + `${resetUrl}\n\n${note}`
166
+ }, (designed == null ? void 0 : designed.html) ? {
167
+ html: designed.html
168
+ } : {}, {
153
169
  fromName: branding.fromName,
154
170
  sendingIdentity: await hostSendingIdentity(hostId),
155
171
  audience: 'tenant',
@@ -157,7 +173,7 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
157
173
  // Owed to the recipient by their own account: the phishing
158
174
  // screen's soft rules never hold it (AGL-3356).
159
175
  owedFor: 'account'
160
- });
176
+ }));
161
177
  if (!result.sent) {
162
178
  return res.status(502).json({
163
179
  error: 'The reset email could not be delivered'
@@ -199,10 +215,20 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
199
215
  // they know to reach the member another way.
200
216
  let notified = false;
201
217
  if (isEmailConfigured()) {
202
- const result = await sendEmail({
218
+ // The `member-password-changed` site email (AGL-3370).
219
+ const designed = await renderHostEmailWithTokens(firestore, hostId, 'member-password-changed', {
220
+ 'site.name': siteName,
221
+ signInUrl: siteBase
222
+ }, siteBase ? {
223
+ origin: siteBase
224
+ } : {}).catch(()=>null);
225
+ const result = await sendEmail(_extends({
203
226
  to: email,
204
- subject: `Your ${siteName} password was changed`,
205
- text: `An administrator of ${siteName} set a new password on your ` + 'account. You have been signed out on every device and will need ' + `the new password to sign back in at ${siteBase}.\n\n` + 'If you did not expect this, contact the site owner.',
227
+ subject: (designed == null ? void 0 : designed.subject) || `Your ${siteName} password was changed`,
228
+ text: (designed == null ? void 0 : designed.text) || `An administrator of ${siteName} set a new password on your ` + 'account. You have been signed out on every device and will need ' + `the new password to sign back in at ${siteBase}.\n\n` + 'If you did not expect this, contact the site owner.'
229
+ }, (designed == null ? void 0 : designed.html) ? {
230
+ html: designed.html
231
+ } : {}, {
206
232
  fromName: branding.fromName,
207
233
  sendingIdentity: await hostSendingIdentity(hostId),
208
234
  audience: 'tenant',
@@ -210,7 +236,7 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
210
236
  // Owed to the recipient by their own account: the phishing
211
237
  // screen's soft rules never hold it (AGL-3356).
212
238
  owedFor: 'account'
213
- });
239
+ }));
214
240
  notified = result.sent;
215
241
  // Cost meter (AGL-1438). Transactional: a security notice.
216
242
  if (result.sent) await meterHostEmail(hostId);
@@ -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 // 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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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-admin-password.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile, validateNewPassword } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { renderHostEmailWithTokens } from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport {\n consumePasswordResetSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isImpersonationSession,\n meterHostEmail,\n passwordResetThrottleMessage,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n hashMemberPassword,\n MEMBER_SESSIONS_VALID_FROM_FIELD,\n mintPasswordResetToken,\n} from './membership'\nimport {\n isMemberDocumentId,\n memberCredentialRef,\n readMemberPasswordHash,\n retiredCredentialFields,\n} from './member-credentials'\n\n/**\n * Console-side password help for a SITE MEMBER (AGL-914) — a visitor account\n * on a published site, not an Aglyn console user.\n *\n * Sibling to `membership-recover`, and deliberately its opposite in tone.\n * That endpoint is public, so it answers `{ok:true}` no matter what to avoid\n * confirming whether an address has an account. This one is driven by a site\n * admin looking straight at the member's record — they already know the\n * account exists, so hiding failures would only leave them guessing whether\n * their click did anything. Every outcome here is reported honestly.\n *\n * Auth is the console id-token check `hosts/members` uses (site admin via\n * the `memberRoles` projection, or an org role carrying manageMembers) — NOT\n * the visitor session cookie the rest of the membership endpoints take. A\n * console route, so it is registered on the console's API surface.\n *\n * The hash is written to the member's credential document (AGL-3308), which\n * no client can read, and any credential field on the profile is deleted in\n * the same batch.\n */\nexport const membershipAdminPasswordHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers['authorization'] ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n const hostId = String(req.body?.hostId ?? '')\n const memberId = String(req.body?.memberId ?? '')\n const action = String(req.body?.action ?? '')\n if (!hostId || !memberId) {\n return res.status(400).json({ error: 'Missing hostId or memberId' })\n }\n if (!isMemberDocumentId(memberId)) {\n return res.status(400).json({ error: 'Unknown member' })\n }\n if (action !== 'sendPasswordReset' && action !== 'setPassword') {\n return res.status(400).json({ error: 'Unknown action' })\n }\n\n try {\n const app = firebaseAdmin.app()\n const decoded = await app.auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n // Same shape as emailUnverifiedResponse (AGL-479), written by hand\n // because that helper returns a fetch `Response` and this is a\n // (req, res) plugin handler.\n return res.status(403).json({\n error: 'Verify your email to continue',\n reason: 'email-unverified',\n })\n }\n const firestore = app.firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n const host = hostSnapshot.data()\n const memberRole = (host?.['memberRoles'] ?? {})[decoded.uid]\n const orgPermissions = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !host ||\n (memberRole !== 'admin' && !orgPermissions.permissions.manageMembers)\n ) {\n return res.status(403).json({ error: 'Not a site admin' })\n }\n\n const memberRef = hostRef.collection('siteMembers').doc(memberId)\n const memberSnapshot = await memberRef.get()\n if (!memberSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown member' })\n }\n const email = String(memberSnapshot.get('email') ?? '')\n if (!email) {\n return res.status(400).json({ error: 'This member has no email address' })\n }\n\n // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is\n // minted server-side and mailed out, so a wrong apex is not a display\n // bug — it is a live link on somebody else's domain.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'the site',\n )\n // White-label sender identity (White-Label Phase 3): the store's brand via\n // the one shared resolver; the copy already uses the site name.\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n\n if (action === 'sendPasswordReset') {\n // Throttled per recipient and per actor (AGL-920). Same caps as the\n // console surfaces — a site member's inbox is no less worth protecting\n // than a console user's.\n const throttle = await consumePasswordResetSend({\n actorKey: decoded.uid,\n recipientKey: email,\n })\n if (!throttle.allowed) {\n res.setHeader('Retry-After', String(throttle.retryAfterSeconds))\n return res\n .status(429)\n .json({ error: passwordResetThrottleMessage(throttle) })\n }\n if (!isEmailConfigured()) {\n return res.status(502).json({\n error: 'Email is not configured, so no reset link could be sent',\n })\n }\n // Same single-use, hash-bound token the self-serve flow mints\n // (AGL-552) — completing the reset rewrites `passwordScrypt`, which\n // retires the token without any server-side storage.\n const token = mintPasswordResetToken(\n hostId,\n memberId,\n await readMemberPasswordHash(hostRef, memberSnapshot),\n )\n const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const intro = `An administrator of ${siteName} started a password reset for your account.`\n const note =\n 'The link works once and expires in 1 hour. Your current ' +\n 'password keeps working until you use it.'\n // The `member-password-reset` site email (AGL-3370), as the member's\n // own request sends it, with the words for who started it.\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'member-password-reset',\n { 'site.name': siteName, 'reset.intro': intro, resetUrl, 'reset.note': note },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n const result = await sendEmail({\n to: email,\n subject: designed?.subject || `Reset your ${siteName} password`,\n text:\n designed?.text ||\n `${intro.replace(/\\.$/, '')}. Set a new password here:\\n\\n` +\n `${resetUrl}\\n\\n${note}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin recovery',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n if (!result.sent) {\n return res.status(502).json({\n error: 'The reset email could not be delivered',\n })\n }\n // Cost meter (AGL-1438). Transactional: a reset a quota refused leaves\n // the member locked out of a site they pay for.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n }\n\n const validated = validateNewPassword(req.body?.password)\n if (validated.error) {\n return res.status(400).json({ error: validated.error })\n }\n // Rewriting the hash alone would NOT sign the member out: their session\n // is an HMAC cookie that never encodes the password (unlike a reset\n // token, which binds to it on purpose). The cut-off field is what\n // actually revokes the old sessions — see readActiveMemberSession. It\n // stays on the profile, which every session check already reads.\n //\n // One batch: the profile `update` refuses a member removed since the\n // read above, and that refusal takes the credential write with it.\n const batch = firestore.batch()\n batch.set(\n memberCredentialRef(hostRef, memberId),\n { passwordScrypt: hashMemberPassword(validated.password) },\n { merge: true },\n )\n batch.update(memberRef, {\n ...retiredCredentialFields(),\n [MEMBER_SESSIONS_VALID_FROM_FIELD]: Date.now(),\n })\n await batch.commit()\n // Best-effort: the password has already changed, so a mail failure must\n // not fail the request and send the admin round again. Report it so\n // they know to reach the member another way.\n let notified = false\n if (isEmailConfigured()) {\n // The `member-password-changed` site email (AGL-3370).\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'member-password-changed',\n { 'site.name': siteName, signInUrl: siteBase },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n const result = await sendEmail({\n to: email,\n subject: designed?.subject || `Your ${siteName} password was changed`,\n text:\n designed?.text ||\n `An administrator of ${siteName} set a new password on your ` +\n 'account. You have been signed out on every device and will need ' +\n `the new password to sign back in at ${siteBase}.\\n\\n` +\n 'If you did not expect this, contact the site owner.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin password change',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n notified = result.sent\n // Cost meter (AGL-1438). Transactional: a security notice.\n if (result.sent) await meterHostEmail(hostId)\n }\n return res.status(200).json({ ok: true, notified })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Password operation failed' })\n }\n}\n"],"names":["resolveBrandingProfile","validateNewPassword","hostPublicOrigin","isEmailConfigured","sendEmail","renderHostEmailWithTokens","consumePasswordResetSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isImpersonationSession","meterHostEmail","passwordResetThrottleMessage","resolveOrgPermissions","hashMemberPassword","MEMBER_SESSIONS_VALID_FROM_FIELD","mintPasswordResetToken","isMemberDocumentId","memberCredentialRef","readMemberPasswordHash","retiredCredentialFields","membershipAdminPasswordHandler","req","res","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","hostId","body","memberId","action","memberSnapshot","hostSnapshot","app","decoded","auth","verifyIdToken","email_verified","reason","firestore","hostRef","collection","doc","get","host","data","memberRole","uid","orgPermissions","permissions","manageMembers","memberRef","exists","email","siteBase","cname","subdomain","siteName","branding","catch","org","throttle","actorKey","recipientKey","allowed","setHeader","retryAfterSeconds","token","resetUrl","encodeURIComponent","intro","note","designed","origin","result","to","subject","text","replace","html","fromName","sendingIdentity","audience","context","owedFor","sent","ok","validated","password","batch","set","passwordScrypt","merge","update","Date","now","commit","notified","signInUrl","console"],"mappings":"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"}
@@ -13,10 +13,12 @@
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 { resolveBrandingProfile } from "@aglyn/aglyn/server";
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { resolveBrandingProfile } from "@aglyn/aglyn/server";
17
18
  import { hostPublicOrigin } from "@aglyn/aglyn/server";
18
19
  import { consumeMembershipRecoverAttempt, consumeMembershipRecoverSend, firebaseAdmin, getOrgForHost, hostSendingIdentity, isEmailSuppressed, meterHostEmail, RECOVER_MIN_MEMBER_AGE_MS } from "@aglyn/tenant-data-admin";
19
20
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
21
+ import { renderHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
20
22
  import { mintPasswordResetToken } from "./membership.js";
21
23
  import { readMemberPasswordHash } from "./member-credentials.js";
22
24
  import { readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
@@ -216,10 +218,25 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
216
218
  ok: true
217
219
  });
218
220
  }
219
- await sendEmail({
221
+ const intro = `Someone asked to reset the password for your ${siteName} account.`;
222
+ const note = 'The link works once and expires in 1 hour. If you did not ' + 'ask for this, you can safely ignore this email — your ' + 'password is unchanged.';
223
+ // The `member-password-reset` site email (AGL-3370): the site's design,
224
+ // or the built-in copy in its header and footer.
225
+ const designed = await renderHostEmailWithTokens(firestore, hostId, 'member-password-reset', {
226
+ 'site.name': siteName,
227
+ 'reset.intro': intro,
228
+ resetUrl,
229
+ 'reset.note': note
230
+ }, siteBase ? {
231
+ origin: siteBase
232
+ } : {}).catch(()=>null);
233
+ await sendEmail(_extends({
220
234
  to: email,
221
- subject: `Reset your ${siteName} password`,
222
- text: `Someone asked to reset the password for your ${siteName} ` + 'account. If that was you, set a new password here:\n\n' + `${resetUrl}\n\n` + 'The link works once and expires in 1 hour. If you did not ' + 'ask for this, you can safely ignore this email — your ' + 'password is unchanged.',
235
+ subject: (designed == null ? void 0 : designed.subject) || `Reset your ${siteName} password`,
236
+ text: (designed == null ? void 0 : designed.text) || `${intro.replace(/\.$/, '')}. If that was you, set a new password here:\n\n` + `${resetUrl}\n\n${note}`
237
+ }, (designed == null ? void 0 : designed.html) ? {
238
+ html: designed.html
239
+ } : {}, {
223
240
  fromName: branding.fromName,
224
241
  sendingIdentity: await hostSendingIdentity(hostId),
225
242
  audience: 'tenant',
@@ -227,7 +244,7 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
227
244
  // Owed to the recipient by their own account: the phishing
228
245
  // screen's soft rules never hold it (AGL-3356).
229
246
  owedFor: 'account'
230
- });
247
+ }));
231
248
  // Cost meter (AGL-1438). Transactional, and the clearest case for why a
232
249
  // quota may not refuse one: this email is how the member gets back into
233
250
  // their account.