@aglyn/plugins-commerce 1.0.0-beta.201 → 1.0.0-beta.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/console/gift-cards-card.component.js +34 -2
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
- package/src/lib/model/commerce-gift-cards.d.ts +28 -0
- package/src/lib/model/commerce-gift-cards.js +39 -1
- package/src/lib/model/commerce-gift-cards.js.map +1 -1
- package/src/lib/server/billing-webhook.js +58 -2
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cart-checkout.js +46 -8
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/checkout.js +23 -5
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/download.js +12 -1
- package/src/lib/server/download.js.map +1 -1
- package/src/lib/server/draft-order.js +5 -2
- package/src/lib/server/draft-order.js.map +1 -1
- package/src/lib/server/feed.js +12 -2
- package/src/lib/server/feed.js.map +1 -1
- package/src/lib/server/gift-card-risk.d.ts +70 -0
- package/src/lib/server/gift-card-risk.js +123 -0
- package/src/lib/server/gift-card-risk.js.map +1 -0
- package/src/lib/server/gift-cards.js +37 -2
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/paid-media-delivery.d.ts +26 -4
- package/src/lib/server/paid-media-delivery.js +36 -9
- package/src/lib/server/paid-media-delivery.js.map +1 -1
- package/src/lib/server/recurring-charges.d.ts +44 -0
- package/src/lib/server/recurring-charges.js +65 -0
- package/src/lib/server/recurring-charges.js.map +1 -0
- package/src/lib/server/refund.js +24 -0
- package/src/lib/server/refund.js.map +1 -1
- package/src/lib/server/reserve.js +14 -5
- package/src/lib/server/reserve.js.map +1 -1
- package/src/lib/server/stream.js +12 -1
- package/src/lib/server/stream.js.map +1 -1
- package/src/lib/server/subscription-portal.js +13 -5
- package/src/lib/server/subscription-portal.js.map +1 -1
- package/src/lib/server/supplier-outbox.js +22 -7
- package/src/lib/server/supplier-outbox.js.map +1 -1
- package/src/lib/server.js +15 -3
- package/src/lib/server.js.map +1 -1
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
115
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/paid-media-delivery.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 hostQualifiedScope,\n MEDIA_CDN_RENDITION_AUTO,\n MEDIA_CDN_RENDITION_PARAM,\n MEDIA_CDN_ROUTE,\n paidMediaAssetOfObject,\n parseMediaRenditions,\n parsePaidMediaSource,\n} from '@aglyn/aglyn/server'\nimport {\n type MediaDeliveryProvider,\n mediaDeliveryProvider,\n} from '@aglyn/aglyn/plugin-manager/media-delivery-provider'\nimport { mediaDeliveryRedirect } from '@aglyn/tenant-data-admin/server/media-delivery'\nimport {\n assertMediaSignatureTtl,\n mediaSignatureQuery,\n mintMediaSignature,\n} from '@aglyn/tenant-data-admin/server/media-signing'\nimport { isMediaStoragePathInScope } from '@aglyn/tenant-data-admin/server/media-storage-path'\nimport {\n mediaCdnScopeRefusal,\n parseMediaCdnScope,\n selectAutoRendition,\n} from '@aglyn/tenant-data-admin/server/serve-media-cdn'\n\n/**\n * Turning a URL stored on a product into a link a buyer may follow (AGL-2814).\n *\n * A product's paid media — its members videos — is stored as whatever the\n * author's picker wrote: a media reference, a CDN path, a raw Storage download\n * URL, or a hotlink somebody typed. `parsePaidMediaSource` says which of those\n * a value is. The commerce routes verify who is entitled and then redirect;\n * this module decides WHERE they may redirect, and its whole job is that the\n * answer is never a URL that outlives the purchase.\n *\n * ## The rules, in the order they are applied\n *\n * 1. **A library asset is delivered only when it is PRIVATE**, as a CDN URL\n * signed with {@link mintMediaSignature}. The CDN refuses a private asset on\n * every path without an unexpired signature, which is what makes the link\n * die. A public asset is refused here instead of redirected to: its URL\n * works for anyone forever, so handing it to a buyer is the leak itself.\n * 2. **A Storage download URL is never redirected to.** Its token lives as\n * long as the object. When the object belongs to a library asset, rule 1\n * applies to that asset. When no asset owns it (a legacy object, a derived\n * rendition file), it gets a V4 signed read URL with the same bounded\n * lifetime. When it is not in this platform's bucket, or not inside the\n * site's own library or its org's, it is refused.\n * 3. **Only the requesting site's own libraries are signed.** A product is\n * author data. Signing whatever scope it names would let one site's author\n * mint working links to another org's private files, so a host scope must\n * be this site and an org scope must be this site's org, and the signed\n * scope is always qualified with this site so the CDN's `visibleTo` check\n * runs against the site actually selling it.\n * 4. **A hotlink to somebody else's server passes through unchanged.** No code\n * of ours runs there and nothing here can make its URL expire. That is the\n * author's hosting decision, and the docs say so.\n *\n * ## A video with a copy at the delivery provider (AGL-2824)\n *\n * A caller that asks for it with `delivery` gets, for a video that passes\n * rule 1, the delivery provider's own short-lived URL instead of the signed CDN\n * URL, whenever `mediaDeliveryRedirect` finds a current copy and the release\n * flag is on for the owning org. It is the URL the CDN would redirect the\n * signed request to anyway, minted one hop earlier and bounded by the same\n * lifetime. Anything else takes the signed CDN URL exactly as before.\n */\n\n/** Why a stored URL was not turned into a link. */\nexport type PaidMediaRefusal =\n /** A library asset the public can still fetch without a signature. */\n | 'not-private'\n /** No such asset, a deleted one, or one not shared with this site. */\n | 'not-found'\n /** Another site's library, another org's, or outside the media prefixes. */\n | 'out-of-scope'\n /** A Storage URL for a bucket this platform does not serve from. */\n | 'foreign-storage'\n /** Not a value any delivery can be built from. */\n | 'malformed'\n\nexport type PaidMediaDelivery =\n | {\n ok: true\n via: 'signed-cdn'\n location: string\n expiresAtMs: number\n /** The scope segment the signature covers. */\n scope: string\n mediaId: string\n }\n | {\n ok: true\n via: 'signed-storage'\n location: string\n expiresAtMs: number\n objectPath: string\n }\n | { ok: true; via: 'external'; location: string }\n | {\n ok: true\n via: 'delivery'\n location: string\n expiresAtMs: number\n /** The scope segment the delivery was minted for. */\n scope: string\n mediaId: string\n }\n | { ok: false; refusal: PaidMediaRefusal }\n\n/** A document read, reduced to what this module asks of one. */\nexport interface PaidMediaDocument {\n exists: boolean\n get(field: string): unknown\n}\n\n/** Every read and signature this module needs, injectable for tests. */\nexport interface PaidMediaDeliveryIo {\n /** The platform's media bucket; empty when unconfigured. */\n bucketName: string\n readMedia(\n collection: 'hosts' | 'orgs',\n scopeId: string,\n mediaId: string,\n ): Promise<PaidMediaDocument>\n /** The org a site belongs to, or null for an unindexed site. */\n orgIdForHost(hostId: string): Promise<string | null>\n /** A V4 signed read URL for one object in {@link bucketName}. */\n signStorageRead(objectPath: string, expiresAtMs: number): Promise<string>\n}\n\n/**\n * Resolves a stored product media URL to a redirect target that expires, or\n * says why there is none. See the module comment for the rules.\n *\n * `cdnParams` ride on a signed CDN URL ahead of the signature, which covers\n * `(scope, mediaId, exp)` and never the parameters, so they select a\n * representation of the one asset the signature names and nothing else.\n */\nexport async function resolvePaidMediaDelivery(options: {\n stored: unknown\n /** The site selling the media; the only libraries it may sign from. */\n hostId: string\n /** The lifetime of the link; bounded by `assertMediaSignatureTtl`. */\n ttlMs: number\n cdnParams?: ReadonlyArray<readonly [string, string]>\n /**\n * Hand a video with a current copy at the delivery provider its URL there\n * (AGL-2824). `accept` is the request's `Accept`, which chooses the\n * rendition when `cdnParams` asks for `?r=auto`. The provider and the flag\n * verdict default to the registered provider and the release flag.\n */\n delivery?: {\n accept?: unknown\n provider?: MediaDeliveryProvider | null\n enabled?: (orgId: string | null) => Promise<boolean>\n }\n nowMs?: number\n io: PaidMediaDeliveryIo\n}): Promise<PaidMediaDelivery> {\n const { hostId, ttlMs, io } = options\n const nowMs = options.nowMs ?? Date.now()\n const refuse = (refusal: PaidMediaRefusal): PaidMediaDelivery => ({\n ok: false,\n refusal,\n })\n if (!hostId) return refuse('out-of-scope')\n\n // One lookup per call, and only for the forms that need to know the org.\n let orgId: Promise<string | null> | undefined\n const siteOrgId = () => (orgId ??= io.orgIdForHost(hostId))\n\n /**\n * The delivery provider's URL for a private library video, or null for\n * the signed CDN URL. The representation is the one the CDN would pick for\n * the same `?r=` and `Accept`, so the buyer gets the copy the signed URL\n * would have been redirected to.\n */\n const deliverFromProvider = async (\n delivery: NonNullable<typeof options.delivery>,\n asset: {\n collection: 'hosts' | 'orgs'\n scopeId: string\n orgScope: boolean\n deliveryScope: string\n mediaId: string\n document: PaidMediaDocument\n },\n ): Promise<PaidMediaDelivery | null> => {\n const provider =\n delivery.provider === undefined ? mediaDeliveryProvider('deliver') : delivery.provider\n if (!provider) return null\n const requested = (options.cdnParams ?? []).find(\n ([key]) => key === MEDIA_CDN_RENDITION_PARAM,\n )?.[1]\n const renditions = parseMediaRenditions(asset.document.get('videoRenditions'))\n const rendition = !requested\n ? undefined\n : requested === MEDIA_CDN_RENDITION_AUTO\n ? selectAutoRendition(renditions, delivery.accept)\n : renditions.find((entry) => entry.key === requested)\n const redirect = await mediaDeliveryRedirect({\n provider,\n asset: {\n collection: asset.collection,\n scopeId: asset.scopeId,\n mediaId: asset.mediaId,\n },\n document: asset.document,\n rendition,\n servedType: rendition ? rendition.contentType : asset.document.get('contentType'),\n claims: { scope: asset.deliveryScope, mediaId: asset.mediaId, hostId },\n orgId: async () => (asset.orgScope ? asset.scopeId : await siteOrgId()),\n ...(delivery.enabled ? { deliveryEnabled: delivery.enabled } : {}),\n ttlMs,\n nowMs,\n }).catch(() => null)\n if (!redirect) return null\n return {\n ok: true,\n via: 'delivery',\n location: redirect.location,\n expiresAtMs: redirect.expiresAtMs,\n scope: asset.deliveryScope,\n mediaId: asset.mediaId,\n }\n }\n\n const deliverAsset = async (\n scope: string,\n mediaId: string,\n ): Promise<PaidMediaDelivery> => {\n const parsed = parseMediaCdnScope(scope)\n if (!parsed) return refuse('malformed')\n let collection: 'hosts' | 'orgs'\n let deliveryScope: string\n if (parsed.isOrg) {\n const owner = await siteOrgId()\n if (!owner || parsed.scopeId !== owner) return refuse('out-of-scope')\n collection = 'orgs'\n deliveryScope = hostQualifiedScope(scope, hostId)\n } else {\n if (parsed.scopeId !== hostId) return refuse('out-of-scope')\n collection = 'hosts'\n deliveryScope = hostId\n }\n const deliveryParsed = parseMediaCdnScope(deliveryScope)\n if (!deliveryParsed) return refuse('malformed')\n const document = await io.readMedia(collection, parsed.scopeId, mediaId)\n if (!document?.exists || document.get('deletedAt')) {\n return refuse('not-found')\n }\n // The CDN would 404 an org asset this site is not allowed to use, so a\n // link to one is a link that cannot play.\n if (mediaCdnScopeRefusal(deliveryParsed, document.get('visibleTo'))) {\n return refuse('not-found')\n }\n if (document.get('private') !== true) return refuse('not-private')\n assertMediaSignatureTtl(ttlMs)\n const delivered = options.delivery\n ? await deliverFromProvider(options.delivery, {\n collection,\n scopeId: parsed.scopeId,\n orgScope: parsed.isOrg,\n deliveryScope,\n mediaId,\n document,\n })\n : null\n if (delivered) return delivered\n const signature = mintMediaSignature(deliveryScope, mediaId, nowMs, ttlMs)\n const params = new URLSearchParams()\n for (const [key, value] of options.cdnParams ?? []) {\n if (key !== 'exp' && key !== 'sig') params.set(key, value)\n }\n const prefix = params.toString()\n return {\n ok: true,\n via: 'signed-cdn',\n location:\n `${MEDIA_CDN_ROUTE}/${deliveryScope}/${mediaId}?` +\n `${prefix ? `${prefix}&` : ''}${mediaSignatureQuery(signature)}`,\n expiresAtMs: signature.exp,\n scope: deliveryScope,\n mediaId,\n }\n }\n\n const source = parsePaidMediaSource(options.stored)\n switch (source.kind) {\n case 'malformed':\n return refuse('malformed')\n case 'external':\n return { ok: true, via: 'external', location: source.url }\n case 'asset':\n return deliverAsset(source.scope, source.mediaId)\n case 'storage-object': {\n if (!io.bucketName || source.bucket !== io.bucketName) {\n return refuse('foreign-storage')\n }\n const { objectPath } = source\n const segments = objectPath.split('/')\n let base: string | null = null\n if (segments[0] === 'hosts' && segments[1] === hostId) {\n base = `hosts/${hostId}`\n } else if (segments[0] === 'orgs' && segments[1]) {\n const owner = await siteOrgId()\n if (owner && segments[1] === owner) base = `orgs/${owner}`\n }\n // The same prefix test the CDN applies to a document's `storagePath`,\n // which also refuses traversal and empty segments.\n if (!base || !isMediaStoragePathInScope(objectPath, base)) {\n return refuse('out-of-scope')\n }\n // An object keyed like an asset IS that asset, and the asset's own rules\n // decide — including refusing it when the asset is still public.\n const asset = paidMediaAssetOfObject(objectPath)\n if (asset) return deliverAsset(asset.scope, asset.mediaId)\n assertMediaSignatureTtl(ttlMs)\n const expiresAtMs = nowMs + ttlMs\n return {\n ok: true,\n via: 'signed-storage',\n location: await io.signStorageRead(objectPath, expiresAtMs),\n expiresAtMs,\n objectPath,\n }\n }\n }\n}\n\n/**\n * The production reads behind {@link resolvePaidMediaDelivery}, built on the\n * caller's Firestore and bucket handles so a route's own mocks reach them.\n */\nexport function createPaidMediaDeliveryIo(options: {\n firestore: {\n collection(name: string): {\n doc(id: string): {\n get(): Promise<PaidMediaDocument>\n collection(name: string): {\n doc(id: string): { get(): Promise<PaidMediaDocument> }\n }\n }\n }\n }\n bucket: {\n name?: string\n file(path: string): {\n getSignedUrl(config: {\n version: 'v4'\n action: 'read'\n expires: number\n }): Promise<[string] | string[]>\n }\n }\n}): PaidMediaDeliveryIo {\n const { firestore, bucket } = options\n return {\n bucketName: String(bucket?.name ?? ''),\n readMedia: (collection, scopeId, mediaId) =>\n firestore\n .collection(collection)\n .doc(scopeId)\n .collection('media')\n .doc(mediaId)\n .get(),\n orgIdForHost: async (hostId) => {\n const index = await firestore.collection('hostIndex').doc(hostId).get()\n const orgId = index.exists ? index.get('orgId') : undefined\n return typeof orgId === 'string' && orgId ? orgId : null\n },\n signStorageRead: async (objectPath, expiresAtMs) => {\n const [url] = await bucket.file(objectPath).getSignedUrl({\n version: 'v4',\n action: 'read',\n expires: expiresAtMs,\n })\n return String(url)\n },\n }\n}\n"],"names":["hostQualifiedScope","MEDIA_CDN_RENDITION_AUTO","MEDIA_CDN_RENDITION_PARAM","MEDIA_CDN_ROUTE","paidMediaAssetOfObject","parseMediaRenditions","parsePaidMediaSource","mediaDeliveryProvider","mediaDeliveryRedirect","assertMediaSignatureTtl","mediaSignatureQuery","mintMediaSignature","isMediaStoragePathInScope","mediaCdnScopeRefusal","parseMediaCdnScope","selectAutoRendition","resolvePaidMediaDelivery","options","hostId","ttlMs","io","nowMs","Date","now","refuse","refusal","ok","orgId","siteOrgId","orgIdForHost","deliverFromProvider","delivery","asset","provider","undefined","requested","cdnParams","find","key","renditions","document","get","rendition","accept","entry","redirect","collection","scopeId","mediaId","servedType","contentType","claims","scope","deliveryScope","orgScope","enabled","deliveryEnabled","catch","via","location","expiresAtMs","deliverAsset","parsed","isOrg","owner","deliveryParsed","readMedia","exists","delivered","signature","params","URLSearchParams","value","set","prefix","toString","exp","source","stored","kind","url","bucketName","bucket","objectPath","segments","split","base","signStorageRead","createPaidMediaDeliveryIo","firestore","String","name","doc","index","file","getSignedUrl","version","action","expires"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/paid-media-delivery.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 hostQualifiedScope,\n MEDIA_CDN_RENDITION_AUTO,\n MEDIA_CDN_RENDITION_PARAM,\n MEDIA_CDN_ROUTE,\n paidMediaAssetOfObject,\n parseMediaRenditions,\n parsePaidMediaSource,\n} from '@aglyn/aglyn/server'\nimport {\n type MediaDeliveryProvider,\n mediaDeliveryProvider,\n} from '@aglyn/aglyn/plugin-manager/media-delivery-provider'\nimport { lookalikeBrandForHost } from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { mediaDeliveryRedirect } from '@aglyn/tenant-data-admin/server/media-delivery'\nimport {\n assertMediaSignatureTtl,\n mediaSignatureQuery,\n mintMediaSignature,\n} from '@aglyn/tenant-data-admin/server/media-signing'\nimport { isMediaStoragePathInScope } from '@aglyn/tenant-data-admin/server/media-storage-path'\nimport {\n mediaCdnScopeRefusal,\n parseMediaCdnScope,\n selectAutoRendition,\n} from '@aglyn/tenant-data-admin/server/serve-media-cdn'\n\n/**\n * Turning a URL stored on a product into a link a buyer may follow (AGL-2814).\n *\n * A product's paid media — its members videos — is stored as whatever the\n * author's picker wrote: a media reference, a CDN path, a raw Storage download\n * URL, or a hotlink somebody typed. `parsePaidMediaSource` says which of those\n * a value is. The commerce routes verify who is entitled and then redirect;\n * this module decides WHERE they may redirect, and its whole job is that the\n * answer is never a URL that outlives the purchase.\n *\n * ## The rules, in the order they are applied\n *\n * 1. **A library asset is delivered only when it is PRIVATE**, as a CDN URL\n * signed with {@link mintMediaSignature}. The CDN refuses a private asset on\n * every path without an unexpired signature, which is what makes the link\n * die. A public asset is refused here instead of redirected to: its URL\n * works for anyone forever, so handing it to a buyer is the leak itself.\n * 2. **A Storage download URL is never redirected to.** Its token lives as\n * long as the object. When the object belongs to a library asset, rule 1\n * applies to that asset. When no asset owns it (a legacy object, a derived\n * rendition file), it gets a V4 signed read URL with the same bounded\n * lifetime. When it is not in this platform's bucket, or not inside the\n * site's own library or its org's, it is refused.\n * 3. **Only the requesting site's own libraries are signed.** A product is\n * author data. Signing whatever scope it names would let one site's author\n * mint working links to another org's private files, so a host scope must\n * be this site and an org scope must be this site's org, and the signed\n * scope is always qualified with this site so the CDN's `visibleTo` check\n * runs against the site actually selling it.\n * 4. **A hotlink to somebody else's server passes through unchanged.** No code\n * of ours runs there and nothing here can make its URL expire. That is the\n * author's hosting decision, and the docs say so. EXCEPT a hotlink whose\n * host wears a brand it is not (`lookalikeBrandForHost`, the phishing\n * screen's rule): the redirect leaves from the merchant's own domain, so a\n * lure behind it is one no screen of the page or the receipt ever saw. It\n * is refused for every workspace, like a lookalike link in an email\n * (AGL-3363), and the caller files it.\n * 5. **A signed Storage read is a download when it is one.** A download, or\n * an object whose type a browser would run (HTML, SVG, XML), is signed\n * with `response-content-disposition: attachment`, so the file is saved\n * rather than rendered (AGL-3363). The CDN already does this for its own\n * deliveries; a V4 URL carried nothing.\n *\n * ## A video with a copy at the delivery provider (AGL-2824)\n *\n * A caller that asks for it with `delivery` gets, for a video that passes\n * rule 1, the delivery provider's own short-lived URL instead of the signed CDN\n * URL, whenever `mediaDeliveryRedirect` finds a current copy and the release\n * flag is on for the owning org. It is the URL the CDN would redirect the\n * signed request to anyway, minted one hop earlier and bounded by the same\n * lifetime. Anything else takes the signed CDN URL exactly as before.\n */\n\n/** Why a stored URL was not turned into a link. */\nexport type PaidMediaRefusal =\n /** A library asset the public can still fetch without a signature. */\n | 'not-private'\n /** No such asset, a deleted one, or one not shared with this site. */\n | 'not-found'\n /** Another site's library, another org's, or outside the media prefixes. */\n | 'out-of-scope'\n /** A Storage URL for a bucket this platform does not serve from. */\n | 'foreign-storage'\n /** Not a value any delivery can be built from. */\n | 'malformed'\n /** A hotlink to a host that wears another brand's name (AGL-3363). */\n | 'lookalike'\n\nexport type PaidMediaDelivery =\n | {\n ok: true\n via: 'signed-cdn'\n location: string\n expiresAtMs: number\n /** The scope segment the signature covers. */\n scope: string\n mediaId: string\n }\n | {\n ok: true\n via: 'signed-storage'\n location: string\n expiresAtMs: number\n objectPath: string\n }\n | { ok: true; via: 'external'; location: string }\n | {\n ok: true\n via: 'delivery'\n location: string\n expiresAtMs: number\n /** The scope segment the delivery was minted for. */\n scope: string\n mediaId: string\n }\n | {\n ok: false\n refusal: PaidMediaRefusal\n /** For a `lookalike`: the host it wears a brand in, for the caller to file. */\n host?: string\n }\n\n/** A document read, reduced to what this module asks of one. */\nexport interface PaidMediaDocument {\n exists: boolean\n get(field: string): unknown\n}\n\n/** Every read and signature this module needs, injectable for tests. */\nexport interface PaidMediaDeliveryIo {\n /** The platform's media bucket; empty when unconfigured. */\n bucketName: string\n readMedia(\n collection: 'hosts' | 'orgs',\n scopeId: string,\n mediaId: string,\n ): Promise<PaidMediaDocument>\n /** The org a site belongs to, or null for an unindexed site. */\n orgIdForHost(hostId: string): Promise<string | null>\n /**\n * A V4 signed read URL for one object in {@link bucketName}. `attachment`\n * signs `response-content-disposition: attachment` into it (AGL-3363).\n */\n signStorageRead(\n objectPath: string,\n expiresAtMs: number,\n options?: { attachment?: boolean },\n ): Promise<string>\n}\n\n/** Object types a browser runs rather than shows: always saved, never rendered. */\nconst ACTIVE_OBJECT_EXTENSION = /\\.(html?|xhtml|xht|svgz?|xml|xsl|xslt|mht|mhtml|js|mjs)$/i\n\n/** Is this signed read a download, by the caller's ask or by what the object is? */\nexport function storageReadIsAttachment(\n objectPath: string,\n cdnParams: ReadonlyArray<readonly [string, string]> | undefined,\n): boolean {\n if ((cdnParams ?? []).some(([key, value]) => key === 'download' && value === '1')) {\n return true\n }\n return ACTIVE_OBJECT_EXTENSION.test(objectPath)\n}\n\n/**\n * Resolves a stored product media URL to a redirect target that expires, or\n * says why there is none. See the module comment for the rules.\n *\n * `cdnParams` ride on a signed CDN URL ahead of the signature, which covers\n * `(scope, mediaId, exp)` and never the parameters, so they select a\n * representation of the one asset the signature names and nothing else.\n */\nexport async function resolvePaidMediaDelivery(options: {\n stored: unknown\n /** The site selling the media; the only libraries it may sign from. */\n hostId: string\n /** The lifetime of the link; bounded by `assertMediaSignatureTtl`. */\n ttlMs: number\n cdnParams?: ReadonlyArray<readonly [string, string]>\n /**\n * Hand a video with a current copy at the delivery provider its URL there\n * (AGL-2824). `accept` is the request's `Accept`, which chooses the\n * rendition when `cdnParams` asks for `?r=auto`. The provider and the flag\n * verdict default to the registered provider and the release flag.\n */\n delivery?: {\n accept?: unknown\n provider?: MediaDeliveryProvider | null\n enabled?: (orgId: string | null) => Promise<boolean>\n }\n nowMs?: number\n io: PaidMediaDeliveryIo\n}): Promise<PaidMediaDelivery> {\n const { hostId, ttlMs, io } = options\n const nowMs = options.nowMs ?? Date.now()\n const refuse = (refusal: PaidMediaRefusal): PaidMediaDelivery => ({\n ok: false,\n refusal,\n })\n if (!hostId) return refuse('out-of-scope')\n\n // One lookup per call, and only for the forms that need to know the org.\n let orgId: Promise<string | null> | undefined\n const siteOrgId = () => (orgId ??= io.orgIdForHost(hostId))\n\n /**\n * The delivery provider's URL for a private library video, or null for\n * the signed CDN URL. The representation is the one the CDN would pick for\n * the same `?r=` and `Accept`, so the buyer gets the copy the signed URL\n * would have been redirected to.\n */\n const deliverFromProvider = async (\n delivery: NonNullable<typeof options.delivery>,\n asset: {\n collection: 'hosts' | 'orgs'\n scopeId: string\n orgScope: boolean\n deliveryScope: string\n mediaId: string\n document: PaidMediaDocument\n },\n ): Promise<PaidMediaDelivery | null> => {\n const provider =\n delivery.provider === undefined ? mediaDeliveryProvider('deliver') : delivery.provider\n if (!provider) return null\n const requested = (options.cdnParams ?? []).find(\n ([key]) => key === MEDIA_CDN_RENDITION_PARAM,\n )?.[1]\n const renditions = parseMediaRenditions(asset.document.get('videoRenditions'))\n const rendition = !requested\n ? undefined\n : requested === MEDIA_CDN_RENDITION_AUTO\n ? selectAutoRendition(renditions, delivery.accept)\n : renditions.find((entry) => entry.key === requested)\n const redirect = await mediaDeliveryRedirect({\n provider,\n asset: {\n collection: asset.collection,\n scopeId: asset.scopeId,\n mediaId: asset.mediaId,\n },\n document: asset.document,\n rendition,\n servedType: rendition ? rendition.contentType : asset.document.get('contentType'),\n claims: { scope: asset.deliveryScope, mediaId: asset.mediaId, hostId },\n orgId: async () => (asset.orgScope ? asset.scopeId : await siteOrgId()),\n ...(delivery.enabled ? { deliveryEnabled: delivery.enabled } : {}),\n ttlMs,\n nowMs,\n }).catch(() => null)\n if (!redirect) return null\n return {\n ok: true,\n via: 'delivery',\n location: redirect.location,\n expiresAtMs: redirect.expiresAtMs,\n scope: asset.deliveryScope,\n mediaId: asset.mediaId,\n }\n }\n\n const deliverAsset = async (\n scope: string,\n mediaId: string,\n ): Promise<PaidMediaDelivery> => {\n const parsed = parseMediaCdnScope(scope)\n if (!parsed) return refuse('malformed')\n let collection: 'hosts' | 'orgs'\n let deliveryScope: string\n if (parsed.isOrg) {\n const owner = await siteOrgId()\n if (!owner || parsed.scopeId !== owner) return refuse('out-of-scope')\n collection = 'orgs'\n deliveryScope = hostQualifiedScope(scope, hostId)\n } else {\n if (parsed.scopeId !== hostId) return refuse('out-of-scope')\n collection = 'hosts'\n deliveryScope = hostId\n }\n const deliveryParsed = parseMediaCdnScope(deliveryScope)\n if (!deliveryParsed) return refuse('malformed')\n const document = await io.readMedia(collection, parsed.scopeId, mediaId)\n if (!document?.exists || document.get('deletedAt')) {\n return refuse('not-found')\n }\n // The CDN would 404 an org asset this site is not allowed to use, so a\n // link to one is a link that cannot play.\n if (mediaCdnScopeRefusal(deliveryParsed, document.get('visibleTo'))) {\n return refuse('not-found')\n }\n if (document.get('private') !== true) return refuse('not-private')\n assertMediaSignatureTtl(ttlMs)\n const delivered = options.delivery\n ? await deliverFromProvider(options.delivery, {\n collection,\n scopeId: parsed.scopeId,\n orgScope: parsed.isOrg,\n deliveryScope,\n mediaId,\n document,\n })\n : null\n if (delivered) return delivered\n const signature = mintMediaSignature(deliveryScope, mediaId, nowMs, ttlMs)\n const params = new URLSearchParams()\n for (const [key, value] of options.cdnParams ?? []) {\n if (key !== 'exp' && key !== 'sig') params.set(key, value)\n }\n const prefix = params.toString()\n return {\n ok: true,\n via: 'signed-cdn',\n location:\n `${MEDIA_CDN_ROUTE}/${deliveryScope}/${mediaId}?` +\n `${prefix ? `${prefix}&` : ''}${mediaSignatureQuery(signature)}`,\n expiresAtMs: signature.exp,\n scope: deliveryScope,\n mediaId,\n }\n }\n\n const source = parsePaidMediaSource(options.stored)\n switch (source.kind) {\n case 'malformed':\n return refuse('malformed')\n case 'external': {\n let externalHost: string\n try {\n externalHost = new URL(source.url).hostname\n } catch {\n return refuse('malformed')\n }\n if (lookalikeBrandForHost(externalHost)) {\n return { ok: false, refusal: 'lookalike', host: externalHost }\n }\n return { ok: true, via: 'external', location: source.url }\n }\n case 'asset':\n return deliverAsset(source.scope, source.mediaId)\n case 'storage-object': {\n if (!io.bucketName || source.bucket !== io.bucketName) {\n return refuse('foreign-storage')\n }\n const { objectPath } = source\n const segments = objectPath.split('/')\n let base: string | null = null\n if (segments[0] === 'hosts' && segments[1] === hostId) {\n base = `hosts/${hostId}`\n } else if (segments[0] === 'orgs' && segments[1]) {\n const owner = await siteOrgId()\n if (owner && segments[1] === owner) base = `orgs/${owner}`\n }\n // The same prefix test the CDN applies to a document's `storagePath`,\n // which also refuses traversal and empty segments.\n if (!base || !isMediaStoragePathInScope(objectPath, base)) {\n return refuse('out-of-scope')\n }\n // An object keyed like an asset IS that asset, and the asset's own rules\n // decide — including refusing it when the asset is still public.\n const asset = paidMediaAssetOfObject(objectPath)\n if (asset) return deliverAsset(asset.scope, asset.mediaId)\n assertMediaSignatureTtl(ttlMs)\n const expiresAtMs = nowMs + ttlMs\n return {\n ok: true,\n via: 'signed-storage',\n location: await io.signStorageRead(objectPath, expiresAtMs, {\n attachment: storageReadIsAttachment(objectPath, options.cdnParams),\n }),\n expiresAtMs,\n objectPath,\n }\n }\n }\n}\n\n/**\n * The production reads behind {@link resolvePaidMediaDelivery}, built on the\n * caller's Firestore and bucket handles so a route's own mocks reach them.\n */\nexport function createPaidMediaDeliveryIo(options: {\n firestore: {\n collection(name: string): {\n doc(id: string): {\n get(): Promise<PaidMediaDocument>\n collection(name: string): {\n doc(id: string): { get(): Promise<PaidMediaDocument> }\n }\n }\n }\n }\n bucket: {\n name?: string\n file(path: string): {\n getSignedUrl(config: {\n version: 'v4'\n action: 'read'\n expires: number\n responseDisposition?: string\n }): Promise<[string] | string[]>\n }\n }\n}): PaidMediaDeliveryIo {\n const { firestore, bucket } = options\n return {\n bucketName: String(bucket?.name ?? ''),\n readMedia: (collection, scopeId, mediaId) =>\n firestore\n .collection(collection)\n .doc(scopeId)\n .collection('media')\n .doc(mediaId)\n .get(),\n orgIdForHost: async (hostId) => {\n const index = await firestore.collection('hostIndex').doc(hostId).get()\n const orgId = index.exists ? index.get('orgId') : undefined\n return typeof orgId === 'string' && orgId ? orgId : null\n },\n signStorageRead: async (objectPath, expiresAtMs, signOptions) => {\n const [url] = await bucket.file(objectPath).getSignedUrl({\n version: 'v4',\n action: 'read',\n expires: expiresAtMs,\n ...(signOptions?.attachment ? { responseDisposition: 'attachment' } : {}),\n })\n return String(url)\n },\n }\n}\n"],"names":["hostQualifiedScope","MEDIA_CDN_RENDITION_AUTO","MEDIA_CDN_RENDITION_PARAM","MEDIA_CDN_ROUTE","paidMediaAssetOfObject","parseMediaRenditions","parsePaidMediaSource","mediaDeliveryProvider","lookalikeBrandForHost","mediaDeliveryRedirect","assertMediaSignatureTtl","mediaSignatureQuery","mintMediaSignature","isMediaStoragePathInScope","mediaCdnScopeRefusal","parseMediaCdnScope","selectAutoRendition","ACTIVE_OBJECT_EXTENSION","storageReadIsAttachment","objectPath","cdnParams","some","key","value","test","resolvePaidMediaDelivery","options","hostId","ttlMs","io","nowMs","Date","now","refuse","refusal","ok","orgId","siteOrgId","orgIdForHost","deliverFromProvider","delivery","asset","provider","undefined","requested","find","renditions","document","get","rendition","accept","entry","redirect","collection","scopeId","mediaId","servedType","contentType","claims","scope","deliveryScope","orgScope","enabled","deliveryEnabled","catch","via","location","expiresAtMs","deliverAsset","parsed","isOrg","owner","deliveryParsed","readMedia","exists","delivered","signature","params","URLSearchParams","set","prefix","toString","exp","source","stored","kind","externalHost","URL","url","hostname","host","bucketName","bucket","segments","split","base","signStorageRead","attachment","createPaidMediaDeliveryIo","firestore","String","name","doc","index","signOptions","file","getSignedUrl","version","action","expires","responseDisposition"],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
import { type RecurringChargeRecord } from '@aglyn/aglyn/plugin-manager/plugin-recurring-charges';
|
|
18
|
+
/**
|
|
19
|
+
* Stripe statuses that can still produce a charge. `canceled` and
|
|
20
|
+
* `incomplete_expired` never bill again; `paused` (a trial that ended with
|
|
21
|
+
* no payment method) does not bill until a card is added, which the lock's
|
|
22
|
+
* pause would then catch on the next lock anyway.
|
|
23
|
+
*/
|
|
24
|
+
export declare const LIVE_SUBSCRIPTION_STATUSES: readonly ["active", "trialing", "past_due", "unpaid", "incomplete"];
|
|
25
|
+
/** A Firestore handle's shape, as far as this reader uses it. */
|
|
26
|
+
export interface RecurringChargesFirestore {
|
|
27
|
+
collection(name: string): {
|
|
28
|
+
doc(id: string): {
|
|
29
|
+
collection(name: string): {
|
|
30
|
+
where(field: string, op: 'in', value: readonly string[]): {
|
|
31
|
+
get(): Promise<{
|
|
32
|
+
docs: Array<{
|
|
33
|
+
id: string;
|
|
34
|
+
}>;
|
|
35
|
+
}>;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Every live subscription the given sites sell. Throws if a read throws. */
|
|
42
|
+
export declare function listCommerceLiveSubscriptions(firestore: RecurringChargesFirestore, hostIds: readonly string[]): Promise<RecurringChargeRecord[]>;
|
|
43
|
+
/** Declares the storefront's subscriptions to the lockdown. */
|
|
44
|
+
export declare function registerCommerceRecurringCharges(): void;
|
|
@@ -0,0 +1,65 @@
|
|
|
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 MEMBERSHIPS A SITE SELLS, FOR A SECURITY LOCKDOWN (AGL-3364).
|
|
18
|
+
*
|
|
19
|
+
* A storefront subscription (a membership, a recurring product) is
|
|
20
|
+
* recorded at `hosts/{hostId}/subscriptions/{stripeSubscriptionId}` when
|
|
21
|
+
* the checkout completes, and its `status` follows Stripe's
|
|
22
|
+
* `customer.subscription.*` events (see `billing-webhook.ts`). That record
|
|
23
|
+
* is the answer to "what does this site bill on a schedule": the lockdown
|
|
24
|
+
* asks here and pauses exactly these, rather than searching Stripe.
|
|
25
|
+
*=========================================*/ import { registerRecurringChargeSource } from "@aglyn/aglyn/plugin-manager/plugin-recurring-charges";
|
|
26
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
27
|
+
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
28
|
+
/**
|
|
29
|
+
* Stripe statuses that can still produce a charge. `canceled` and
|
|
30
|
+
* `incomplete_expired` never bill again; `paused` (a trial that ended with
|
|
31
|
+
* no payment method) does not bill until a card is added, which the lock's
|
|
32
|
+
* pause would then catch on the next lock anyway.
|
|
33
|
+
*/ // AGL-1715-EXEMPT: "can this still charge", a superset of the live triple
|
|
34
|
+
// that adds `unpaid` and `incomplete` — a lock must pause every schedule
|
|
35
|
+
// Stripe could still bill, not only the ones that grant access.
|
|
36
|
+
export const LIVE_SUBSCRIPTION_STATUSES = [
|
|
37
|
+
'active',
|
|
38
|
+
'trialing',
|
|
39
|
+
'past_due',
|
|
40
|
+
'unpaid',
|
|
41
|
+
'incomplete'
|
|
42
|
+
];
|
|
43
|
+
/** Every live subscription the given sites sell. Throws if a read throws. */ export async function listCommerceLiveSubscriptions(firestore, hostIds) {
|
|
44
|
+
const found = [];
|
|
45
|
+
for (const hostId of hostIds){
|
|
46
|
+
if (!hostId) continue;
|
|
47
|
+
const snapshot = await firestore.collection('hosts').doc(hostId).collection('subscriptions').where('status', 'in', LIVE_SUBSCRIPTION_STATUSES).get();
|
|
48
|
+
for (const doc of snapshot.docs){
|
|
49
|
+
found.push({
|
|
50
|
+
subscriptionId: doc.id,
|
|
51
|
+
hostId
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
/** Declares the storefront's subscriptions to the lockdown. */ export function registerCommerceRecurringCharges() {
|
|
58
|
+
registerRecurringChargeSource({
|
|
59
|
+
listLiveSubscriptions: ({ hostIds })=>listCommerceLiveSubscriptions(firebaseAdmin.app().firestore(), hostIds)
|
|
60
|
+
}, {
|
|
61
|
+
pluginId: BUNDLE_ID
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
//# sourceMappingURL=recurring-charges.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/recurring-charges.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 MEMBERSHIPS A SITE SELLS, FOR A SECURITY LOCKDOWN (AGL-3364).\n *\n * A storefront subscription (a membership, a recurring product) is\n * recorded at `hosts/{hostId}/subscriptions/{stripeSubscriptionId}` when\n * the checkout completes, and its `status` follows Stripe's\n * `customer.subscription.*` events (see `billing-webhook.ts`). That record\n * is the answer to \"what does this site bill on a schedule\": the lockdown\n * asks here and pauses exactly these, rather than searching Stripe.\n *=========================================*/\n\nimport {\n type RecurringChargeRecord,\n registerRecurringChargeSource,\n} from '@aglyn/aglyn/plugin-manager/plugin-recurring-charges'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * Stripe statuses that can still produce a charge. `canceled` and\n * `incomplete_expired` never bill again; `paused` (a trial that ended with\n * no payment method) does not bill until a card is added, which the lock's\n * pause would then catch on the next lock anyway.\n */\n// AGL-1715-EXEMPT: \"can this still charge\", a superset of the live triple\n// that adds `unpaid` and `incomplete` — a lock must pause every schedule\n// Stripe could still bill, not only the ones that grant access.\nexport const LIVE_SUBSCRIPTION_STATUSES = [\n 'active',\n 'trialing',\n 'past_due',\n 'unpaid',\n 'incomplete',\n] as const\n\n/** A Firestore handle's shape, as far as this reader uses it. */\nexport interface RecurringChargesFirestore {\n collection(name: string): {\n doc(id: string): {\n collection(name: string): {\n where(field: string, op: 'in', value: readonly string[]): {\n get(): Promise<{ docs: Array<{ id: string }> }>\n }\n }\n }\n }\n}\n\n/** Every live subscription the given sites sell. Throws if a read throws. */\nexport async function listCommerceLiveSubscriptions(\n firestore: RecurringChargesFirestore,\n hostIds: readonly string[],\n): Promise<RecurringChargeRecord[]> {\n const found: RecurringChargeRecord[] = []\n for (const hostId of hostIds) {\n if (!hostId) continue\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('subscriptions')\n .where('status', 'in', LIVE_SUBSCRIPTION_STATUSES)\n .get()\n for (const doc of snapshot.docs) {\n found.push({ subscriptionId: doc.id, hostId })\n }\n }\n return found\n}\n\n/** Declares the storefront's subscriptions to the lockdown. */\nexport function registerCommerceRecurringCharges(): void {\n registerRecurringChargeSource(\n {\n listLiveSubscriptions: ({ hostIds }) =>\n listCommerceLiveSubscriptions(\n firebaseAdmin.app().firestore() as unknown as RecurringChargesFirestore,\n hostIds,\n ),\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["registerRecurringChargeSource","firebaseAdmin","BUNDLE_ID","LIVE_SUBSCRIPTION_STATUSES","listCommerceLiveSubscriptions","firestore","hostIds","found","hostId","snapshot","collection","doc","where","get","docs","push","subscriptionId","id","registerCommerceRecurringCharges","listLiveSubscriptions","app","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;2CAS2C,GAE3C,SAEEA,6BAA6B,QACxB,uDAAsD;AAC7D,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,SAAS,QAAQ,gCAA4B;AAEtD;;;;;CAKC,GACD,0EAA0E;AAC1E,yEAAyE;AACzE,gEAAgE;AAChE,OAAO,MAAMC,6BAA6B;IACxC;IACA;IACA;IACA;IACA;CACD,CAAS;AAeV,2EAA2E,GAC3E,OAAO,eAAeC,8BACpBC,SAAoC,EACpCC,OAA0B;IAE1B,MAAMC,QAAiC,EAAE;IACzC,KAAK,MAAMC,UAAUF,QAAS;QAC5B,IAAI,CAACE,QAAQ;QACb,MAAMC,WAAW,MAAMJ,UACpBK,UAAU,CAAC,SACXC,GAAG,CAACH,QACJE,UAAU,CAAC,iBACXE,KAAK,CAAC,UAAU,MAAMT,4BACtBU,GAAG;QACN,KAAK,MAAMF,OAAOF,SAASK,IAAI,CAAE;YAC/BP,MAAMQ,IAAI,CAAC;gBAAEC,gBAAgBL,IAAIM,EAAE;gBAAET;YAAO;QAC9C;IACF;IACA,OAAOD;AACT;AAEA,6DAA6D,GAC7D,OAAO,SAASW;IACdlB,8BACE;QACEmB,uBAAuB,CAAC,EAAEb,OAAO,EAAE,GACjCF,8BACEH,cAAcmB,GAAG,GAAGf,SAAS,IAC7BC;IAEN,GACA;QAAEe,UAAUnB;IAAU;AAE1B"}
|
package/src/lib/server/refund.js
CHANGED
|
@@ -20,6 +20,7 @@ import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
|
20
20
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
21
21
|
import { createHash } from "crypto";
|
|
22
22
|
import { recordContactRefund } from "./contact-refund.js";
|
|
23
|
+
import { applyGiftCardRiskToOrder } from "./gift-card-risk.js";
|
|
23
24
|
import { flagOrderRestock } from "./restock-flag.js";
|
|
24
25
|
// Leaf import, not the barrel, for the reason `contact-refund.ts` states about
|
|
25
26
|
// `updateExisting`: the specs in this library mock `@aglyn/tenant-data-admin`
|
|
@@ -528,6 +529,29 @@ import { reverseEmailAttributedRevenue } from "@aglyn/tenant-data-admin/server/e
|
|
|
528
529
|
// restock ledgers below: the money has moved and nothing here may fail a
|
|
529
530
|
// refund that already left the merchant's account.
|
|
530
531
|
await retireLicenseKeys(hostRef, orderRef, orderId);
|
|
532
|
+
// GIFT CARDS THE REFUND TOOK BACK ARE VOIDED (AGL-3363). The money went
|
|
533
|
+
// back to the card that paid, so what is left on the cards that money
|
|
534
|
+
// bought goes too — every card on a full refund, and on a partial one
|
|
535
|
+
// only the cards bought by the lines it withdrew. A refund by amount with
|
|
536
|
+
// no lines named withdraws nothing, so it voids nothing. Same best-effort
|
|
537
|
+
// placement as the key retirement above: the refund has already moved.
|
|
538
|
+
if (fullyRefunded || requestedLineIds.length > 0) {
|
|
539
|
+
await applyGiftCardRiskToOrder({
|
|
540
|
+
firestore,
|
|
541
|
+
hostRef,
|
|
542
|
+
orderId,
|
|
543
|
+
action: _extends({
|
|
544
|
+
kind: 'void',
|
|
545
|
+
reason: 'refund'
|
|
546
|
+
}, fullyRefunded ? {} : {
|
|
547
|
+
productIds: requestedLineIds.map((index)=>{
|
|
548
|
+
var _ref;
|
|
549
|
+
var _order_lineItems_index, _order_lineItems;
|
|
550
|
+
return String((_ref = (_order_lineItems = order.lineItems) == null ? void 0 : (_order_lineItems_index = _order_lineItems[index]) == null ? void 0 : _order_lineItems_index.productId) != null ? _ref : '');
|
|
551
|
+
}).filter(Boolean)
|
|
552
|
+
})
|
|
553
|
+
});
|
|
554
|
+
}
|
|
531
555
|
// The customer's side of the ledger (AGL-1754). Everything above records
|
|
532
556
|
// the money on the ORDER; without this the buyer's `ltvCents` still counts
|
|
533
557
|
// a sale they returned, and only ever rises.
|