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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/console/gift-cards-card.component.js +34 -2
  3. package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
  4. package/src/lib/model/commerce-gift-cards.d.ts +30 -0
  5. package/src/lib/model/commerce-gift-cards.js +39 -1
  6. package/src/lib/model/commerce-gift-cards.js.map +1 -1
  7. package/src/lib/server/billing-webhook.js +180 -106
  8. package/src/lib/server/billing-webhook.js.map +1 -1
  9. package/src/lib/server/cart-checkout.js +46 -8
  10. package/src/lib/server/cart-checkout.js.map +1 -1
  11. package/src/lib/server/checkout.js +23 -5
  12. package/src/lib/server/checkout.js.map +1 -1
  13. package/src/lib/server/download.js +12 -1
  14. package/src/lib/server/download.js.map +1 -1
  15. package/src/lib/server/draft-order.js +5 -2
  16. package/src/lib/server/draft-order.js.map +1 -1
  17. package/src/lib/server/feed.js +12 -2
  18. package/src/lib/server/feed.js.map +1 -1
  19. package/src/lib/server/gift-card-risk.d.ts +112 -0
  20. package/src/lib/server/gift-card-risk.js +220 -0
  21. package/src/lib/server/gift-card-risk.js.map +1 -0
  22. package/src/lib/server/gift-cards.js +37 -2
  23. package/src/lib/server/gift-cards.js.map +1 -1
  24. package/src/lib/server/paid-media-delivery.d.ts +26 -4
  25. package/src/lib/server/paid-media-delivery.js +36 -9
  26. package/src/lib/server/paid-media-delivery.js.map +1 -1
  27. package/src/lib/server/recurring-charges.d.ts +44 -0
  28. package/src/lib/server/recurring-charges.js +65 -0
  29. package/src/lib/server/recurring-charges.js.map +1 -0
  30. package/src/lib/server/refund.js +24 -0
  31. package/src/lib/server/refund.js.map +1 -1
  32. package/src/lib/server/reserve.js +14 -5
  33. package/src/lib/server/reserve.js.map +1 -1
  34. package/src/lib/server/stream.js +12 -1
  35. package/src/lib/server/stream.js.map +1 -1
  36. package/src/lib/server/subscription-portal.js +13 -5
  37. package/src/lib/server/subscription-portal.js.map +1 -1
  38. package/src/lib/server/supplier-outbox.js +22 -7
  39. package/src/lib/server/supplier-outbox.js.map +1 -1
  40. package/src/lib/server.js +15 -3
  41. package/src/lib/server.js.map +1 -1
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * THE CARDS AN ORDER ISSUED, WHEN ITS PAYMENT GOES BAD (AGL-3363).
18
+ *
19
+ * See "GIFT CARDS ARE A CASH-OUT" in `model/commerce-gift-cards.ts` for the
20
+ * rules. This is the one place that applies them to the cards an order
21
+ * minted — found by `orderId`, which the cart's completion stamps on every
22
+ * card it issues (the order's id is its Checkout Session id) — so the
23
+ * webhook's fraud-signal and dispute branches and the refund route share it.
24
+ *
25
+ * Every write is per card, in its own transaction, and best-effort: a card
26
+ * that fails to freeze is logged and the rest carry on. The caller's money
27
+ * movement (a refund that already left, a dispute Stripe already opened) is
28
+ * never failed by this.
29
+ *=========================================*/ const CARDS_PER_ORDER_READ = 500;
30
+ /**
31
+ * Apply `action` to every card `orderId` issued on `hostRef`. Returns how
32
+ * many cards changed. Never throws.
33
+ */ export async function applyGiftCardRiskToOrder(input) {
34
+ var _input_nowMs;
35
+ const { firestore, hostRef, orderId, action } = input;
36
+ if (!orderId) return 0;
37
+ const nowMs = (_input_nowMs = input.nowMs) != null ? _input_nowMs : Date.now();
38
+ let cards;
39
+ try {
40
+ var _snapshot_docs;
41
+ const snapshot = await hostRef.collection('giftCards').where('orderId', '==', orderId).limit(CARDS_PER_ORDER_READ).get();
42
+ cards = (_snapshot_docs = snapshot.docs) != null ? _snapshot_docs : [];
43
+ } catch (error) {
44
+ console.error('[gift-card-risk] could not read the order’s cards', orderId, error);
45
+ return 0;
46
+ }
47
+ let changed = 0;
48
+ for (const card of cards){
49
+ try {
50
+ const moved = await firestore.runTransaction(async (transaction)=>{
51
+ var _fresh_data;
52
+ const fresh = await transaction.get(card.ref);
53
+ const data = (_fresh_data = fresh.data()) != null ? _fresh_data : {};
54
+ if (Number(data.voidedAtMs) > 0) return false;
55
+ if (action.kind === 'freeze') {
56
+ if (Number(data.frozenAtMs) > 0) return false;
57
+ transaction.set(card.ref, {
58
+ frozenAtMs: nowMs,
59
+ frozenReason: action.reason
60
+ }, {
61
+ merge: true
62
+ });
63
+ return true;
64
+ }
65
+ if (action.kind === 'release') {
66
+ if (!(Number(data.frozenAtMs) > 0)) return false;
67
+ if (action.reason && data.frozenReason !== action.reason) return false;
68
+ transaction.set(card.ref, {
69
+ frozenAtMs: null,
70
+ frozenReason: null,
71
+ releasedAtMs: nowMs
72
+ }, {
73
+ merge: true
74
+ });
75
+ return true;
76
+ }
77
+ if (action.productIds) {
78
+ if (!data.productId || !action.productIds.includes(data.productId)) {
79
+ return false;
80
+ }
81
+ }
82
+ const balance = Math.max(0, Math.round(Number(data.balanceCents) || 0));
83
+ transaction.set(card.ref, {
84
+ balanceCents: 0,
85
+ voidedAtMs: nowMs,
86
+ voidedReason: action.reason,
87
+ // What was taken back, so the merchant can see it and reissue by
88
+ // hand if the reversal is itself reversed.
89
+ voidedBalanceCents: balance
90
+ }, {
91
+ merge: true
92
+ });
93
+ return true;
94
+ });
95
+ if (moved) changed += 1;
96
+ } catch (error) {
97
+ console.error('[gift-card-risk] card update failed', card.id, error);
98
+ }
99
+ }
100
+ return changed;
101
+ }
102
+ /** What a freeze's reason means, in the merchant's words. */ const FREEZE_CAUSE = {
103
+ 'early-fraud-warning': 'the card issuer reported the payment as possibly fraudulent',
104
+ 'radar-review': 'the payment is being reviewed for fraud',
105
+ dispute: 'the payment is disputed',
106
+ 'refund-review': 'part of the payment was refunded outside your dashboard and the refund could not be matched to the gift cards'
107
+ };
108
+ /**
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.
113
+ */ export async function notifyGiftCardHold(input, notify) {
114
+ 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);
122
+ }
123
+ /**
124
+ * Apply a refund made OUTSIDE the console — in the Stripe Dashboard — to the
125
+ * gift cards the order bought, the way an in-console refund does (AGL-3363).
126
+ *
127
+ * - A charge refunded in full voids every card the order issued.
128
+ * - A partial refund is matched to lines by amount: when it equals exactly
129
+ * one line's total, or exactly the total of every gift-card line, the
130
+ * cards those lines bought are voided (a line that bought no card voids
131
+ * nothing, which is the refund of a mug).
132
+ * - Anything else cannot be told apart — two lines of the same price, a
133
+ * goodwill amount — so the order's cards are FROZEN for the merchant to
134
+ * review rather than voided or left spendable.
135
+ */ export async function applyExternalRefundToGiftCards(input) {
136
+ const { firestore, hostRef, orderId } = input;
137
+ let cardProducts;
138
+ try {
139
+ var _snapshot_docs;
140
+ const snapshot = await hostRef.collection('giftCards').where('orderId', '==', orderId).limit(CARDS_PER_ORDER_READ).get();
141
+ const live = ((_snapshot_docs = snapshot.docs) != null ? _snapshot_docs : []).filter((card)=>{
142
+ var _ref;
143
+ var _card_data;
144
+ return !(Number((_ref = card.get == null ? void 0 : card.get.call(card, 'voidedAtMs')) != null ? _ref : card.data == null ? void 0 : (_card_data = card.data.call(card)) == null ? void 0 : _card_data['voidedAtMs']) > 0);
145
+ });
146
+ if (live.length === 0) return {
147
+ kind: 'no-cards'
148
+ };
149
+ cardProducts = live.map((card)=>{
150
+ var _ref, _ref1;
151
+ var _card_data;
152
+ return String((_ref = (_ref1 = card.get == null ? void 0 : card.get.call(card, 'productId')) != null ? _ref1 : card.data == null ? void 0 : (_card_data = card.data.call(card)) == null ? void 0 : _card_data['productId']) != null ? _ref : '');
153
+ });
154
+ } catch (error) {
155
+ console.error('[gift-card-risk] could not read the order’s cards', orderId, error);
156
+ return {
157
+ kind: 'no-cards'
158
+ };
159
+ }
160
+ const apply = (action)=>applyGiftCardRiskToOrder({
161
+ firestore,
162
+ hostRef,
163
+ orderId,
164
+ action,
165
+ nowMs: input.nowMs
166
+ });
167
+ if (input.fullyRefunded) {
168
+ return {
169
+ kind: 'voided',
170
+ cards: await apply({
171
+ kind: 'void',
172
+ reason: 'refund'
173
+ })
174
+ };
175
+ }
176
+ const refundCents = Math.round(input.refundCents);
177
+ const single = input.lines.filter((line)=>line.totalCents === refundCents);
178
+ const singleProducts = new Set(single.map((line)=>line.productId));
179
+ if (single.length > 0 && singleProducts.size === 1) {
180
+ const [productId] = [
181
+ ...singleProducts
182
+ ];
183
+ if (!cardProducts.includes(productId)) return {
184
+ kind: 'matched-other-line'
185
+ };
186
+ return {
187
+ kind: 'voided',
188
+ cards: await apply({
189
+ kind: 'void',
190
+ reason: 'refund',
191
+ productIds: [
192
+ productId
193
+ ]
194
+ })
195
+ };
196
+ }
197
+ const giftLines = input.lines.filter((line)=>cardProducts.includes(line.productId));
198
+ const giftTotal = giftLines.reduce((sum, line)=>sum + line.totalCents, 0);
199
+ if (single.length === 0 && giftLines.length > 0 && giftTotal === refundCents) {
200
+ return {
201
+ kind: 'voided',
202
+ cards: await apply({
203
+ kind: 'void',
204
+ reason: 'refund',
205
+ productIds: [
206
+ ...new Set(giftLines.map((line)=>line.productId))
207
+ ]
208
+ })
209
+ };
210
+ }
211
+ return {
212
+ kind: 'frozen-for-review',
213
+ cards: await apply({
214
+ kind: 'freeze',
215
+ reason: 'refund-review'
216
+ })
217
+ };
218
+ }
219
+
220
+ //# sourceMappingURL=gift-card-risk.js.map
@@ -0,0 +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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"}
@@ -64,7 +64,7 @@ import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
64
64
  if (!hostId) return res.status(400).json({
65
65
  error: 'Missing hostId'
66
66
  });
67
- if (action !== 'issue' && action !== 'void') {
67
+ if (action !== 'issue' && action !== 'void' && action !== 'release') {
68
68
  return res.status(400).json({
69
69
  error: 'Unknown action'
70
70
  });
@@ -107,13 +107,48 @@ import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
107
107
  error: 'Gift cards are not included on this plan'
108
108
  });
109
109
  }
110
- if (action === 'void') {
110
+ // RELEASE A HELD CARD (AGL-3363). A fraud warning, review or dispute on
111
+ // the payment that bought a card freezes it; the merchant who has
112
+ // satisfied themselves the payment is genuine lifts that here. A voided
113
+ // card stays voided: release restores redemption, never a balance.
114
+ if (action === 'release') {
111
115
  var _body_code;
112
116
  const code = String((_body_code = body.code) != null ? _body_code : '').trim();
113
117
  if (!code) return res.status(400).json({
114
118
  error: 'Missing code'
115
119
  });
116
120
  const cardRef = hostRef.collection('giftCards').doc(code);
121
+ const existing = await cardRef.get();
122
+ if (!existing.exists) {
123
+ return res.status(404).json({
124
+ error: 'Unknown gift card'
125
+ });
126
+ }
127
+ if (!(Number(existing.get('frozenAtMs')) > 0)) {
128
+ return res.status(409).json({
129
+ error: 'This gift card is not on hold'
130
+ });
131
+ }
132
+ await cardRef.set({
133
+ frozenAtMs: null,
134
+ frozenReason: null,
135
+ releasedAtMs: Date.now(),
136
+ releasedBy: decoded.uid
137
+ }, {
138
+ merge: true
139
+ });
140
+ return res.status(200).json({
141
+ ok: true,
142
+ code
143
+ });
144
+ }
145
+ if (action === 'void') {
146
+ var _body_code1;
147
+ const code = String((_body_code1 = body.code) != null ? _body_code1 : '').trim();
148
+ if (!code) return res.status(400).json({
149
+ error: 'Missing code'
150
+ });
151
+ const cardRef = hostRef.collection('giftCards').doc(code);
117
152
  // Zero the balance rather than delete the document. AGL-1767 is the
118
153
  // reason: a deleted card that a checkout still had in flight was
119
154
  // RESURRECTED by the webhook's `increment(-cents)` into a NEGATIVE
@@ -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 { 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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"}
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' && action !== 'release') {\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 // RELEASE A HELD CARD (AGL-3363). A fraud warning, review or dispute on\n // the payment that bought a card freezes it; the merchant who has\n // satisfied themselves the payment is genuine lifts that here. A voided\n // card stays voided: release restores redemption, never a balance.\n if (action === 'release') {\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 const existing = await cardRef.get()\n if (!existing.exists) {\n return res.status(404).json({ error: 'Unknown gift card' })\n }\n if (!(Number(existing.get('frozenAtMs')) > 0)) {\n return res.status(409).json({ error: 'This gift card is not on hold' })\n }\n await cardRef.set(\n {\n frozenAtMs: null,\n frozenReason: null,\n releasedAtMs: Date.now(),\n releasedBy: decoded.uid,\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, code })\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","Number","set","frozenAtMs","frozenReason","releasedAtMs","Date","now","releasedBy","merge","ok","balanceCents","voidedAtMs","voidedBy","amountCents","Math","round","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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"}
@@ -46,7 +46,17 @@ import { type MediaDeliveryProvider } from '@aglyn/aglyn/plugin-manager/media-de
46
46
  * runs against the site actually selling it.
47
47
  * 4. **A hotlink to somebody else's server passes through unchanged.** No code
48
48
  * of ours runs there and nothing here can make its URL expire. That is the
49
- * author's hosting decision, and the docs say so.
49
+ * author's hosting decision, and the docs say so. EXCEPT a hotlink whose
50
+ * host wears a brand it is not (`lookalikeBrandForHost`, the phishing
51
+ * screen's rule): the redirect leaves from the merchant's own domain, so a
52
+ * lure behind it is one no screen of the page or the receipt ever saw. It
53
+ * is refused for every workspace, like a lookalike link in an email
54
+ * (AGL-3363), and the caller files it.
55
+ * 5. **A signed Storage read is a download when it is one.** A download, or
56
+ * an object whose type a browser would run (HTML, SVG, XML), is signed
57
+ * with `response-content-disposition: attachment`, so the file is saved
58
+ * rather than rendered (AGL-3363). The CDN already does this for its own
59
+ * deliveries; a V4 URL carried nothing.
50
60
  *
51
61
  * ## A video with a copy at the delivery provider (AGL-2824)
52
62
  *
@@ -68,7 +78,9 @@ export type PaidMediaRefusal =
68
78
  /** A Storage URL for a bucket this platform does not serve from. */
69
79
  | 'foreign-storage'
70
80
  /** Not a value any delivery can be built from. */
71
- | 'malformed';
81
+ | 'malformed'
82
+ /** A hotlink to a host that wears another brand's name (AGL-3363). */
83
+ | 'lookalike';
72
84
  export type PaidMediaDelivery = {
73
85
  ok: true;
74
86
  via: 'signed-cdn';
@@ -98,6 +110,8 @@ export type PaidMediaDelivery = {
98
110
  } | {
99
111
  ok: false;
100
112
  refusal: PaidMediaRefusal;
113
+ /** For a `lookalike`: the host it wears a brand in, for the caller to file. */
114
+ host?: string;
101
115
  };
102
116
  /** A document read, reduced to what this module asks of one. */
103
117
  export interface PaidMediaDocument {
@@ -111,9 +125,16 @@ export interface PaidMediaDeliveryIo {
111
125
  readMedia(collection: 'hosts' | 'orgs', scopeId: string, mediaId: string): Promise<PaidMediaDocument>;
112
126
  /** The org a site belongs to, or null for an unindexed site. */
113
127
  orgIdForHost(hostId: string): Promise<string | null>;
114
- /** A V4 signed read URL for one object in {@link bucketName}. */
115
- signStorageRead(objectPath: string, expiresAtMs: number): Promise<string>;
128
+ /**
129
+ * A V4 signed read URL for one object in {@link bucketName}. `attachment`
130
+ * signs `response-content-disposition: attachment` into it (AGL-3363).
131
+ */
132
+ signStorageRead(objectPath: string, expiresAtMs: number, options?: {
133
+ attachment?: boolean;
134
+ }): Promise<string>;
116
135
  }
136
+ /** Is this signed read a download, by the caller's ask or by what the object is? */
137
+ export declare function storageReadIsAttachment(objectPath: string, cdnParams: ReadonlyArray<readonly [string, string]> | undefined): boolean;
117
138
  /**
118
139
  * Resolves a stored product media URL to a redirect target that expires, or
119
140
  * says why there is none. See the module comment for the rules.
@@ -167,6 +188,7 @@ export declare function createPaidMediaDeliveryIo(options: {
167
188
  version: 'v4';
168
189
  action: 'read';
169
190
  expires: number;
191
+ responseDisposition?: string;
170
192
  }): Promise<[string] | string[]>;
171
193
  };
172
194
  };
@@ -16,10 +16,18 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { hostQualifiedScope, MEDIA_CDN_RENDITION_AUTO, MEDIA_CDN_RENDITION_PARAM, MEDIA_CDN_ROUTE, paidMediaAssetOfObject, parseMediaRenditions, parsePaidMediaSource } from "@aglyn/aglyn/server";
18
18
  import { mediaDeliveryProvider } from "@aglyn/aglyn/plugin-manager/media-delivery-provider";
19
+ import { lookalikeBrandForHost } from "@aglyn/shared-util-email/outbound-phishing-screen";
19
20
  import { mediaDeliveryRedirect } from "@aglyn/tenant-data-admin/server/media-delivery";
20
21
  import { assertMediaSignatureTtl, mediaSignatureQuery, mintMediaSignature } from "@aglyn/tenant-data-admin/server/media-signing";
21
22
  import { isMediaStoragePathInScope } from "@aglyn/tenant-data-admin/server/media-storage-path";
22
23
  import { mediaCdnScopeRefusal, parseMediaCdnScope, selectAutoRendition } from "@aglyn/tenant-data-admin/server/serve-media-cdn";
24
+ /** Object types a browser runs rather than shows: always saved, never rendered. */ const ACTIVE_OBJECT_EXTENSION = /\.(html?|xhtml|xht|svgz?|xml|xsl|xslt|mht|mhtml|js|mjs)$/i;
25
+ /** Is this signed read a download, by the caller's ask or by what the object is? */ export function storageReadIsAttachment(objectPath, cdnParams) {
26
+ if ((cdnParams != null ? cdnParams : []).some(([key, value])=>key === 'download' && value === '1')) {
27
+ return true;
28
+ }
29
+ return ACTIVE_OBJECT_EXTENSION.test(objectPath);
30
+ }
23
31
  /**
24
32
  * Resolves a stored product media URL to a redirect target that expires, or
25
33
  * says why there is none. See the module comment for the rules.
@@ -142,11 +150,26 @@ import { mediaCdnScopeRefusal, parseMediaCdnScope, selectAutoRendition } from "@
142
150
  case 'malformed':
143
151
  return refuse('malformed');
144
152
  case 'external':
145
- return {
146
- ok: true,
147
- via: 'external',
148
- location: source.url
149
- };
153
+ {
154
+ let externalHost;
155
+ try {
156
+ externalHost = new URL(source.url).hostname;
157
+ } catch (unused) {
158
+ return refuse('malformed');
159
+ }
160
+ if (lookalikeBrandForHost(externalHost)) {
161
+ return {
162
+ ok: false,
163
+ refusal: 'lookalike',
164
+ host: externalHost
165
+ };
166
+ }
167
+ return {
168
+ ok: true,
169
+ via: 'external',
170
+ location: source.url
171
+ };
172
+ }
150
173
  case 'asset':
151
174
  return deliverAsset(source.scope, source.mediaId);
152
175
  case 'storage-object':
@@ -177,7 +200,9 @@ import { mediaCdnScopeRefusal, parseMediaCdnScope, selectAutoRendition } from "@
177
200
  return {
178
201
  ok: true,
179
202
  via: 'signed-storage',
180
- location: await io.signStorageRead(objectPath, expiresAtMs),
203
+ location: await io.signStorageRead(objectPath, expiresAtMs, {
204
+ attachment: storageReadIsAttachment(objectPath, options.cdnParams)
205
+ }),
181
206
  expiresAtMs,
182
207
  objectPath
183
208
  };
@@ -198,12 +223,14 @@ import { mediaCdnScopeRefusal, parseMediaCdnScope, selectAutoRendition } from "@
198
223
  const orgId = index.exists ? index.get('orgId') : undefined;
199
224
  return typeof orgId === 'string' && orgId ? orgId : null;
200
225
  },
201
- signStorageRead: async (objectPath, expiresAtMs)=>{
202
- const [url] = await bucket.file(objectPath).getSignedUrl({
226
+ signStorageRead: async (objectPath, expiresAtMs, signOptions)=>{
227
+ const [url] = await bucket.file(objectPath).getSignedUrl(_extends({
203
228
  version: 'v4',
204
229
  action: 'read',
205
230
  expires: expiresAtMs
206
- });
231
+ }, (signOptions == null ? void 0 : signOptions.attachment) ? {
232
+ responseDisposition: 'attachment'
233
+ } : {}));
207
234
  return String(url);
208
235
  }
209
236
  };