@aglyn/plugins-commerce 1.0.0-beta.200 → 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/components/console/order-detail-dialog.component.js +12 -0
- package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
- package/src/lib/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/model/commerce-orders.d.ts +7 -0
- package/src/lib/model/commerce-orders.js.map +1 -1
- package/src/lib/server/billing-webhook.js +132 -14
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cart-checkout.js +49 -10
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/catalog.js +18 -3
- package/src/lib/server/catalog.js.map +1 -1
- package/src/lib/server/checkout.js +26 -6
- 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 +7 -3
- 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 +41 -3
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/membership-admin-password.js +8 -2
- package/src/lib/server/membership-admin-password.js.map +1 -1
- package/src/lib/server/membership-recover.js +4 -1
- package/src/lib/server/membership-recover.js.map +1 -1
- package/src/lib/server/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/pos-order.js +3 -1
- package/src/lib/server/pos-order.js.map +1 -1
- package/src/lib/server/process-abandoned.js +6 -2
- package/src/lib/server/process-abandoned.js.map +1 -1
- package/src/lib/server/process-restock.js +6 -2
- package/src/lib/server/process-restock.js.map +1 -1
- package/src/lib/server/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 +17 -6
- 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
|
|
@@ -189,7 +224,10 @@ import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
|
|
|
189
224
|
fromName: Aglyn.resolveBrandingProfile(owner == null ? void 0 : owner.org).fromName,
|
|
190
225
|
sendingIdentity: await hostSendingIdentity(hostId),
|
|
191
226
|
audience: 'tenant',
|
|
192
|
-
context: 'gift card'
|
|
227
|
+
context: 'gift card',
|
|
228
|
+
// Owed to the recipient by their own order: the phishing
|
|
229
|
+
// screen's soft rules never hold it (AGL-3356).
|
|
230
|
+
owedFor: 'order'
|
|
193
231
|
})).then(()=>true).catch(()=>false);
|
|
194
232
|
if (sent) {
|
|
195
233
|
// Cost meter (AGL-1438). Transactional: the card has already been
|
|
@@ -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 })\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","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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"}
|
|
@@ -153,7 +153,10 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
|
|
|
153
153
|
fromName: branding.fromName,
|
|
154
154
|
sendingIdentity: await hostSendingIdentity(hostId),
|
|
155
155
|
audience: 'tenant',
|
|
156
|
-
context: 'membership admin recovery'
|
|
156
|
+
context: 'membership admin recovery',
|
|
157
|
+
// Owed to the recipient by their own account: the phishing
|
|
158
|
+
// screen's soft rules never hold it (AGL-3356).
|
|
159
|
+
owedFor: 'account'
|
|
157
160
|
});
|
|
158
161
|
if (!result.sent) {
|
|
159
162
|
return res.status(502).json({
|
|
@@ -203,7 +206,10 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
|
|
|
203
206
|
fromName: branding.fromName,
|
|
204
207
|
sendingIdentity: await hostSendingIdentity(hostId),
|
|
205
208
|
audience: 'tenant',
|
|
206
|
-
context: 'membership admin password change'
|
|
209
|
+
context: 'membership admin password change',
|
|
210
|
+
// Owed to the recipient by their own account: the phishing
|
|
211
|
+
// screen's soft rules never hold it (AGL-3356).
|
|
212
|
+
owedFor: 'account'
|
|
207
213
|
});
|
|
208
214
|
notified = result.sent;
|
|
209
215
|
// Cost meter (AGL-1438). Transactional: a security notice.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-admin-password.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile, validateNewPassword } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n consumePasswordResetSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isImpersonationSession,\n meterHostEmail,\n passwordResetThrottleMessage,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n hashMemberPassword,\n MEMBER_SESSIONS_VALID_FROM_FIELD,\n mintPasswordResetToken,\n} from './membership'\nimport {\n isMemberDocumentId,\n memberCredentialRef,\n readMemberPasswordHash,\n retiredCredentialFields,\n} from './member-credentials'\n\n/**\n * Console-side password help for a SITE MEMBER (AGL-914) — a visitor account\n * on a published site, not an Aglyn console user.\n *\n * Sibling to `membership-recover`, and deliberately its opposite in tone.\n * That endpoint is public, so it answers `{ok:true}` no matter what to avoid\n * confirming whether an address has an account. This one is driven by a site\n * admin looking straight at the member's record — they already know the\n * account exists, so hiding failures would only leave them guessing whether\n * their click did anything. Every outcome here is reported honestly.\n *\n * Auth is the console id-token check `hosts/members` uses (site admin via\n * the `memberRoles` projection, or an org role carrying manageMembers) — NOT\n * the visitor session cookie the rest of the membership endpoints take. A\n * console route, so it is registered on the console's API surface.\n *\n * The hash is written to the member's credential document (AGL-3308), which\n * no client can read, and any credential field on the profile is deleted in\n * the same batch.\n */\nexport const membershipAdminPasswordHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers['authorization'] ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n const hostId = String(req.body?.hostId ?? '')\n const memberId = String(req.body?.memberId ?? '')\n const action = String(req.body?.action ?? '')\n if (!hostId || !memberId) {\n return res.status(400).json({ error: 'Missing hostId or memberId' })\n }\n if (!isMemberDocumentId(memberId)) {\n return res.status(400).json({ error: 'Unknown member' })\n }\n if (action !== 'sendPasswordReset' && action !== 'setPassword') {\n return res.status(400).json({ error: 'Unknown action' })\n }\n\n try {\n const app = firebaseAdmin.app()\n const decoded = await app.auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n // Same shape as emailUnverifiedResponse (AGL-479), written by hand\n // because that helper returns a fetch `Response` and this is a\n // (req, res) plugin handler.\n return res.status(403).json({\n error: 'Verify your email to continue',\n reason: 'email-unverified',\n })\n }\n const firestore = app.firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n const host = hostSnapshot.data()\n const memberRole = (host?.['memberRoles'] ?? {})[decoded.uid]\n const orgPermissions = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !host ||\n (memberRole !== 'admin' && !orgPermissions.permissions.manageMembers)\n ) {\n return res.status(403).json({ error: 'Not a site admin' })\n }\n\n const memberRef = hostRef.collection('siteMembers').doc(memberId)\n const memberSnapshot = await memberRef.get()\n if (!memberSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown member' })\n }\n const email = String(memberSnapshot.get('email') ?? '')\n if (!email) {\n return res.status(400).json({ error: 'This member has no email address' })\n }\n\n // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is\n // minted server-side and mailed out, so a wrong apex is not a display\n // bug — it is a live link on somebody else's domain.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'the site',\n )\n // White-label sender identity (White-Label Phase 3): the store's brand via\n // the one shared resolver; the copy already uses the site name.\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n\n if (action === 'sendPasswordReset') {\n // Throttled per recipient and per actor (AGL-920). Same caps as the\n // console surfaces — a site member's inbox is no less worth protecting\n // than a console user's.\n const throttle = await consumePasswordResetSend({\n actorKey: decoded.uid,\n recipientKey: email,\n })\n if (!throttle.allowed) {\n res.setHeader('Retry-After', String(throttle.retryAfterSeconds))\n return res\n .status(429)\n .json({ error: passwordResetThrottleMessage(throttle) })\n }\n if (!isEmailConfigured()) {\n return res.status(502).json({\n error: 'Email is not configured, so no reset link could be sent',\n })\n }\n // Same single-use, hash-bound token the self-serve flow mints\n // (AGL-552) — completing the reset rewrites `passwordScrypt`, which\n // retires the token without any server-side storage.\n const token = mintPasswordResetToken(\n hostId,\n memberId,\n await readMemberPasswordHash(hostRef, memberSnapshot),\n )\n const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const result = await sendEmail({\n to: email,\n subject: `Reset your ${siteName} password`,\n text:\n `An administrator of ${siteName} started a password reset for ` +\n 'your account. Set a new password here:\\n\\n' +\n `${resetUrl}\\n\\n` +\n 'The link works once and expires in 1 hour. Your current ' +\n 'password keeps working until you use it.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin recovery',\n })\n if (!result.sent) {\n return res.status(502).json({\n error: 'The reset email could not be delivered',\n })\n }\n // Cost meter (AGL-1438). Transactional: a reset a quota refused leaves\n // the member locked out of a site they pay for.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n }\n\n const validated = validateNewPassword(req.body?.password)\n if (validated.error) {\n return res.status(400).json({ error: validated.error })\n }\n // Rewriting the hash alone would NOT sign the member out: their session\n // is an HMAC cookie that never encodes the password (unlike a reset\n // token, which binds to it on purpose). The cut-off field is what\n // actually revokes the old sessions — see readActiveMemberSession. It\n // stays on the profile, which every session check already reads.\n //\n // One batch: the profile `update` refuses a member removed since the\n // read above, and that refusal takes the credential write with it.\n const batch = firestore.batch()\n batch.set(\n memberCredentialRef(hostRef, memberId),\n { passwordScrypt: hashMemberPassword(validated.password) },\n { merge: true },\n )\n batch.update(memberRef, {\n ...retiredCredentialFields(),\n [MEMBER_SESSIONS_VALID_FROM_FIELD]: Date.now(),\n })\n await batch.commit()\n // Best-effort: the password has already changed, so a mail failure must\n // not fail the request and send the admin round again. Report it so\n // they know to reach the member another way.\n let notified = false\n if (isEmailConfigured()) {\n const result = await sendEmail({\n to: email,\n subject: `Your ${siteName} password was changed`,\n text:\n `An administrator of ${siteName} set a new password on your ` +\n 'account. You have been signed out on every device and will need ' +\n `the new password to sign back in at ${siteBase}.\\n\\n` +\n 'If you did not expect this, contact the site owner.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin password change',\n })\n notified = result.sent\n // Cost meter (AGL-1438). Transactional: a security notice.\n if (result.sent) await meterHostEmail(hostId)\n }\n return res.status(200).json({ ok: true, notified })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Password operation failed' })\n }\n}\n"],"names":["resolveBrandingProfile","validateNewPassword","hostPublicOrigin","isEmailConfigured","sendEmail","consumePasswordResetSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isImpersonationSession","meterHostEmail","passwordResetThrottleMessage","resolveOrgPermissions","hashMemberPassword","MEMBER_SESSIONS_VALID_FROM_FIELD","mintPasswordResetToken","isMemberDocumentId","memberCredentialRef","readMemberPasswordHash","retiredCredentialFields","membershipAdminPasswordHandler","req","res","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","hostId","body","memberId","action","memberSnapshot","hostSnapshot","app","decoded","auth","verifyIdToken","email_verified","reason","firestore","hostRef","collection","doc","get","host","data","memberRole","uid","orgPermissions","permissions","manageMembers","memberRef","exists","email","siteBase","cname","subdomain","siteName","branding","catch","org","throttle","actorKey","recipientKey","allowed","setHeader","retryAfterSeconds","token","resetUrl","encodeURIComponent","result","to","subject","text","fromName","sendingIdentity","audience","context","sent","ok","validated","password","batch","set","passwordScrypt","merge","update","Date","now","commit","notified","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-admin-password.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile, validateNewPassword } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n consumePasswordResetSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isImpersonationSession,\n meterHostEmail,\n passwordResetThrottleMessage,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n hashMemberPassword,\n MEMBER_SESSIONS_VALID_FROM_FIELD,\n mintPasswordResetToken,\n} from './membership'\nimport {\n isMemberDocumentId,\n memberCredentialRef,\n readMemberPasswordHash,\n retiredCredentialFields,\n} from './member-credentials'\n\n/**\n * Console-side password help for a SITE MEMBER (AGL-914) — a visitor account\n * on a published site, not an Aglyn console user.\n *\n * Sibling to `membership-recover`, and deliberately its opposite in tone.\n * That endpoint is public, so it answers `{ok:true}` no matter what to avoid\n * confirming whether an address has an account. This one is driven by a site\n * admin looking straight at the member's record — they already know the\n * account exists, so hiding failures would only leave them guessing whether\n * their click did anything. Every outcome here is reported honestly.\n *\n * Auth is the console id-token check `hosts/members` uses (site admin via\n * the `memberRoles` projection, or an org role carrying manageMembers) — NOT\n * the visitor session cookie the rest of the membership endpoints take. A\n * console route, so it is registered on the console's API surface.\n *\n * The hash is written to the member's credential document (AGL-3308), which\n * no client can read, and any credential field on the profile is deleted in\n * the same batch.\n */\nexport const membershipAdminPasswordHandler: PluginApiHandler = async (\n req,\n res,\n) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers['authorization'] ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n const hostId = String(req.body?.hostId ?? '')\n const memberId = String(req.body?.memberId ?? '')\n const action = String(req.body?.action ?? '')\n if (!hostId || !memberId) {\n return res.status(400).json({ error: 'Missing hostId or memberId' })\n }\n if (!isMemberDocumentId(memberId)) {\n return res.status(400).json({ error: 'Unknown member' })\n }\n if (action !== 'sendPasswordReset' && action !== 'setPassword') {\n return res.status(400).json({ error: 'Unknown action' })\n }\n\n try {\n const app = firebaseAdmin.app()\n const decoded = await app.auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n // Same shape as emailUnverifiedResponse (AGL-479), written by hand\n // because that helper returns a fetch `Response` and this is a\n // (req, res) plugin handler.\n return res.status(403).json({\n error: 'Verify your email to continue',\n reason: 'email-unverified',\n })\n }\n const firestore = app.firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n const host = hostSnapshot.data()\n const memberRole = (host?.['memberRoles'] ?? {})[decoded.uid]\n const orgPermissions = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !host ||\n (memberRole !== 'admin' && !orgPermissions.permissions.manageMembers)\n ) {\n return res.status(403).json({ error: 'Not a site admin' })\n }\n\n const memberRef = hostRef.collection('siteMembers').doc(memberId)\n const memberSnapshot = await memberRef.get()\n if (!memberSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown member' })\n }\n const email = String(memberSnapshot.get('email') ?? '')\n if (!email) {\n return res.status(400).json({ error: 'This member has no email address' })\n }\n\n // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is\n // minted server-side and mailed out, so a wrong apex is not a display\n // bug — it is a live link on somebody else's domain.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'the site',\n )\n // White-label sender identity (White-Label Phase 3): the store's brand via\n // the one shared resolver; the copy already uses the site name.\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n\n if (action === 'sendPasswordReset') {\n // Throttled per recipient and per actor (AGL-920). Same caps as the\n // console surfaces — a site member's inbox is no less worth protecting\n // than a console user's.\n const throttle = await consumePasswordResetSend({\n actorKey: decoded.uid,\n recipientKey: email,\n })\n if (!throttle.allowed) {\n res.setHeader('Retry-After', String(throttle.retryAfterSeconds))\n return res\n .status(429)\n .json({ error: passwordResetThrottleMessage(throttle) })\n }\n if (!isEmailConfigured()) {\n return res.status(502).json({\n error: 'Email is not configured, so no reset link could be sent',\n })\n }\n // Same single-use, hash-bound token the self-serve flow mints\n // (AGL-552) — completing the reset rewrites `passwordScrypt`, which\n // retires the token without any server-side storage.\n const token = mintPasswordResetToken(\n hostId,\n memberId,\n await readMemberPasswordHash(hostRef, memberSnapshot),\n )\n const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const result = await sendEmail({\n to: email,\n subject: `Reset your ${siteName} password`,\n text:\n `An administrator of ${siteName} started a password reset for ` +\n 'your account. Set a new password here:\\n\\n' +\n `${resetUrl}\\n\\n` +\n 'The link works once and expires in 1 hour. Your current ' +\n 'password keeps working until you use it.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin recovery',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n if (!result.sent) {\n return res.status(502).json({\n error: 'The reset email could not be delivered',\n })\n }\n // Cost meter (AGL-1438). Transactional: a reset a quota refused leaves\n // the member locked out of a site they pay for.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n }\n\n const validated = validateNewPassword(req.body?.password)\n if (validated.error) {\n return res.status(400).json({ error: validated.error })\n }\n // Rewriting the hash alone would NOT sign the member out: their session\n // is an HMAC cookie that never encodes the password (unlike a reset\n // token, which binds to it on purpose). The cut-off field is what\n // actually revokes the old sessions — see readActiveMemberSession. It\n // stays on the profile, which every session check already reads.\n //\n // One batch: the profile `update` refuses a member removed since the\n // read above, and that refusal takes the credential write with it.\n const batch = firestore.batch()\n batch.set(\n memberCredentialRef(hostRef, memberId),\n { passwordScrypt: hashMemberPassword(validated.password) },\n { merge: true },\n )\n batch.update(memberRef, {\n ...retiredCredentialFields(),\n [MEMBER_SESSIONS_VALID_FROM_FIELD]: Date.now(),\n })\n await batch.commit()\n // Best-effort: the password has already changed, so a mail failure must\n // not fail the request and send the admin round again. Report it so\n // they know to reach the member another way.\n let notified = false\n if (isEmailConfigured()) {\n const result = await sendEmail({\n to: email,\n subject: `Your ${siteName} password was changed`,\n text:\n `An administrator of ${siteName} set a new password on your ` +\n 'account. You have been signed out on every device and will need ' +\n `the new password to sign back in at ${siteBase}.\\n\\n` +\n 'If you did not expect this, contact the site owner.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership admin password change',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n notified = result.sent\n // Cost meter (AGL-1438). Transactional: a security notice.\n if (result.sent) await meterHostEmail(hostId)\n }\n return res.status(200).json({ ok: true, notified })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Password operation failed' })\n }\n}\n"],"names":["resolveBrandingProfile","validateNewPassword","hostPublicOrigin","isEmailConfigured","sendEmail","consumePasswordResetSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isImpersonationSession","meterHostEmail","passwordResetThrottleMessage","resolveOrgPermissions","hashMemberPassword","MEMBER_SESSIONS_VALID_FROM_FIELD","mintPasswordResetToken","isMemberDocumentId","memberCredentialRef","readMemberPasswordHash","retiredCredentialFields","membershipAdminPasswordHandler","req","res","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","hostId","body","memberId","action","memberSnapshot","hostSnapshot","app","decoded","auth","verifyIdToken","email_verified","reason","firestore","hostRef","collection","doc","get","host","data","memberRole","uid","orgPermissions","permissions","manageMembers","memberRef","exists","email","siteBase","cname","subdomain","siteName","branding","catch","org","throttle","actorKey","recipientKey","allowed","setHeader","retryAfterSeconds","token","resetUrl","encodeURIComponent","result","to","subject","text","fromName","sendingIdentity","audience","context","owedFor","sent","ok","validated","password","batch","set","passwordScrypt","merge","update","Date","now","commit","notified","console"],"mappings":"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"}
|
|
@@ -223,7 +223,10 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
223
223
|
fromName: branding.fromName,
|
|
224
224
|
sendingIdentity: await hostSendingIdentity(hostId),
|
|
225
225
|
audience: 'tenant',
|
|
226
|
-
context: 'membership recovery'
|
|
226
|
+
context: 'membership recovery',
|
|
227
|
+
// Owed to the recipient by their own account: the phishing
|
|
228
|
+
// screen's soft rules never hold it (AGL-3356).
|
|
229
|
+
owedFor: 'account'
|
|
227
230
|
});
|
|
228
231
|
// Cost meter (AGL-1438). Transactional, and the clearest case for why a
|
|
229
232
|
// quota may not refuse one: this email is how the member gets back into
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-recover.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport {\n consumeMembershipRecoverAttempt,\n consumeMembershipRecoverSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n RECOVER_MIN_MEMBER_AGE_MS,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { mintPasswordResetToken } from './membership'\nimport { readMemberPasswordHash } from './member-credentials'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Password-recovery request (AGL-552). ALWAYS answers `200 {ok:true}` once\n * the input validates and the caller is inside its rate budget — whether or\n * not the email belongs to a member — so the endpoint can't be used to probe\n * account existence. When a member matches, a single-use one-hour token\n * (bound to the current password hash, see membership.ts) is mailed via\n * Resend with a link to the site's `/recover` route.\n *\n * ## Abuse controls (AGL-1966)\n *\n * This is an unauthenticated endpoint that makes us send mail from\n * `noreply@aglyn.com` to an address the caller chose, and it composes with\n * `membership/register` — which admits arbitrary addresses because member\n * accounts are unlimited on every plan (AGL-889) — into a mail relay. The\n * bounces from that land on the sending reputation shared by every customer's\n * transactional mail.\n *\n * Four controls, in the order they run, and the ORDER is part of each:\n *\n * 1. **Attempt caps** — per recipient and per IP, durable\n * (`membership-recover-throttle.ts`). Consumed BEFORE the member lookup so\n * a member and a non-member spend the identical budget and receive the\n * identical answer; a visible 429 from here therefore leaks nothing. The\n * per-instance `Map` this replaced reset on every cold start and was kept\n * per instance, which on Vercel is close to no limit at all (AGL-794).\n * 2. **The young-member guard** — a member row created inside\n * `RECOVER_MIN_MEMBER_AGE_MS` is not worth a reset mail. Register-then-\n * recover is the whole attack and nobody forgets a password ten minutes\n * after choosing it. Silent-success exit.\n * 3. **The platform suppression list** — see the note on the call below.\n * Silent-success exit.\n * 4. **The per-site daily send ceiling** — consumed only when a message is\n * actually about to go out. Because that branch is reachable only for real\n * members, its refusal MUST be the silent-success exit rather than an\n * error, or the ceiling becomes the oracle everything else avoids.\n *\n * What remains unbounded is stated rather than papered over: a genuine botnet\n * still gets `RECOVER_ATTEMPTS_PER_IP` per source, and an attacker who can\n * create many sites gets the per-site ceiling once per site. Host creation is\n * itself rate-limited, and bounding the botnet case needs an edge WAF, not an\n * application limiter — the same conclusion `plugin-api-rate-limit.ts` reaches.\n */\nexport const membershipRecoverHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n // Null when nothing readable names the caller; the throttle then skips its\n // per-source cap and the per-recipient one carries the control alone.\n const ip = readClientIp(req.headers, {\n remoteAddress: req.socket?.remoteAddress,\n })\n // Control 1. Before the lookup, so both branches spend the same budget.\n // A store failure degrades to the in-process cap rather than refusing\n // (`consumeRateLimit` fails soft on an unreachable store, closed on\n // contention) — a Firestore blip must not lock every site's members out of\n // their own password reset.\n const attempt = await consumeMembershipRecoverAttempt({ email, ip })\n if (!attempt.allowed) {\n res.setHeader('Retry-After', String(attempt.retryAfterSeconds))\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [hostSnapshot, membersQuery] = await Promise.all([\n hostRef.get(),\n hostRef\n .collection('siteMembers')\n .where('email', '==', email)\n .limit(1)\n .get(),\n ])\n const memberDoc = membersQuery.docs[0]\n // Unknown site, unknown email, suspended account (AGL-546): all take\n // the same silent-success exit — the response never distinguishes.\n if (\n !hostSnapshot.exists ||\n !memberDoc ||\n memberDoc.get('suspended') === true\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 2 — the register-then-recover composition (AGL-1966).\n //\n // `createdAt` is a server timestamp written by `membership-register.ts`.\n // A row that has none is a legacy member, not a fresh one: rows predating\n // the field must NOT be refused, so an unreadable or absent value reads as\n // \"old enough\". This guard exists to refuse a member created seconds ago,\n // which is a case where the field is always present by construction.\n const createdAtMs = (() => {\n const raw = memberDoc.get('createdAt') as\n | { toMillis?: () => number }\n | undefined\n try {\n return typeof raw?.toMillis === 'function' ? raw.toMillis() : null\n } catch {\n return null\n }\n })()\n if (\n createdAtMs !== null &&\n Date.now() - createdAtMs < RECOVER_MIN_MEMBER_AGE_MS\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 3 — the platform suppression list (AGL-1918 / AGL-2407).\n //\n // `email-suppression.ts` argues that TRANSACTIONAL mail deliberately does\n // not consult this list, because refusing a password reset over an\n // unrelated old bounce locks a real customer out of their own account.\n // That argument is about mail a signed-in human just asked us for. It does\n // not reach here, and this is the exception it does not cover:\n //\n // - The address is chosen by an ANONYMOUS caller, not by the account\n // holder. \"The human just did something\" is exactly the premise that is\n // missing on an unauthenticated endpoint.\n // - The list holds PERMANENT bounces and spam complaints only — transient\n // bounces are excluded at the webhook. A permanent bounce means the\n // mailbox does not exist, so the send cannot reach anyone; all it can do\n // is tell a provider that `aglyn.com` re-mails dead addresses. There is\n // no locked-out customer on the other side of it to protect.\n // - The reputational cost is shared by every customer's transactional\n // mail, which is precisely what AGL-1966 is about.\n //\n // The HOST list (`hosts/{hostId}/suppressions`) is deliberately NOT\n // consulted: it also holds marketing unsubscribes, and unsubscribing from\n // a store's newsletter must never stop that store's password resets.\n //\n // `isEmailSuppressed` fails CLOSED — a read that throws answers \"treat as\n // suppressed\". That adds no new outage mode here: the member lookup above\n // is a Firestore read on the same database, so a Firestore outage has\n // already taken this handler to its silent-success catch.\n if (await isEmailSuppressed(email)) {\n return res.status(200).json({ ok: true })\n }\n if (!isEmailConfigured()) {\n // Config gaps must not become an existence oracle either; log for\n // the operator and keep the visitor-facing contract.\n console.error('membership/recover: email is not configured')\n return res.status(200).json({ ok: true })\n }\n // Bound to the hash sign-in checks, the credential document's (AGL-3308).\n const token = mintPasswordResetToken(\n hostId,\n memberDoc.id,\n await readMemberPasswordHash(hostRef, memberDoc),\n )\n // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is\n // minted server-side and mailed out, so a wrong apex is not a display\n // bug — it is a live link on somebody else's domain.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'your site',\n )\n // White-label sender identity (White-Label Phase 3): the store's brand via\n // the one shared resolver (the copy already uses the site name).\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n // Control 4 — the per-site daily ceiling on recovery mail (AGL-1966).\n //\n // Consumed here, as late as possible, because it counts MESSAGES and not\n // requests: everything above this line can refuse without having decided\n // to send, and a counter that ticked for those would exhaust a site's\n // budget on traffic that never cost a message.\n //\n // The refusal is the silent-success exit, NOT a 429. This branch is\n // reachable only for an address that really is a member, so a visible\n // error here would be the existence oracle the whole handler is built to\n // avoid — the one place where the cheap answer is the wrong one.\n //\n // The accepted cost: during an attack on a site, a genuine member can get\n // no mail and no error. The ceiling is sized more than an order of\n // magnitude above any real site's daily resets so that reaching it is\n // evidence of the attack rather than of a busy Tuesday.\n const sendBudget = await consumeMembershipRecoverSend({ hostId })\n if (!sendBudget.allowed) {\n console.error('membership/recover: daily send ceiling reached', {\n hostId,\n retryAfterSeconds: sendBudget.retryAfterSeconds,\n })\n return res.status(200).json({ ok: true })\n }\n await sendEmail({\n to: email,\n subject: `Reset your ${siteName} password`,\n text:\n `Someone asked to reset the password for your ${siteName} ` +\n 'account. If that was you, set a new password here:\\n\\n' +\n `${resetUrl}\\n\\n` +\n 'The link works once and expires in 1 hour. If you did not ' +\n 'ask for this, you can safely ignore this email — your ' +\n 'password is unchanged.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership recovery',\n })\n // Cost meter (AGL-1438). Transactional, and the clearest case for why a\n // quota may not refuse one: this email is how the member gets back into\n // their account.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n // Even a backend failure stays silent-success: the enumeration\n // contract beats the visitor's error visibility here, and the send is\n // best-effort by design.\n return res.status(200).json({ ok: true })\n }\n}\n"],"names":["resolveBrandingProfile","hostPublicOrigin","consumeMembershipRecoverAttempt","consumeMembershipRecoverSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","RECOVER_MIN_MEMBER_AGE_MS","isEmailConfigured","sendEmail","mintPasswordResetToken","readMemberPasswordHash","readClientIp","EMAIL_PATTERN","membershipRecoverHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","test","ip","headers","remoteAddress","socket","attempt","allowed","setHeader","retryAfterSeconds","hostSnapshot","firestore","app","hostRef","collection","doc","membersQuery","Promise","all","get","where","limit","memberDoc","docs","exists","ok","createdAtMs","raw","toMillis","Date","now","console","token","id","siteBase","cname","subdomain","resetUrl","encodeURIComponent","siteName","branding","catch","org","sendBudget","to","subject","text","fromName","sendingIdentity","audience","context"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-recover.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport {\n consumeMembershipRecoverAttempt,\n consumeMembershipRecoverSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n RECOVER_MIN_MEMBER_AGE_MS,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { mintPasswordResetToken } from './membership'\nimport { readMemberPasswordHash } from './member-credentials'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Password-recovery request (AGL-552). ALWAYS answers `200 {ok:true}` once\n * the input validates and the caller is inside its rate budget — whether or\n * not the email belongs to a member — so the endpoint can't be used to probe\n * account existence. When a member matches, a single-use one-hour token\n * (bound to the current password hash, see membership.ts) is mailed via\n * Resend with a link to the site's `/recover` route.\n *\n * ## Abuse controls (AGL-1966)\n *\n * This is an unauthenticated endpoint that makes us send mail from\n * `noreply@aglyn.com` to an address the caller chose, and it composes with\n * `membership/register` — which admits arbitrary addresses because member\n * accounts are unlimited on every plan (AGL-889) — into a mail relay. The\n * bounces from that land on the sending reputation shared by every customer's\n * transactional mail.\n *\n * Four controls, in the order they run, and the ORDER is part of each:\n *\n * 1. **Attempt caps** — per recipient and per IP, durable\n * (`membership-recover-throttle.ts`). Consumed BEFORE the member lookup so\n * a member and a non-member spend the identical budget and receive the\n * identical answer; a visible 429 from here therefore leaks nothing. The\n * per-instance `Map` this replaced reset on every cold start and was kept\n * per instance, which on Vercel is close to no limit at all (AGL-794).\n * 2. **The young-member guard** — a member row created inside\n * `RECOVER_MIN_MEMBER_AGE_MS` is not worth a reset mail. Register-then-\n * recover is the whole attack and nobody forgets a password ten minutes\n * after choosing it. Silent-success exit.\n * 3. **The platform suppression list** — see the note on the call below.\n * Silent-success exit.\n * 4. **The per-site daily send ceiling** — consumed only when a message is\n * actually about to go out. Because that branch is reachable only for real\n * members, its refusal MUST be the silent-success exit rather than an\n * error, or the ceiling becomes the oracle everything else avoids.\n *\n * What remains unbounded is stated rather than papered over: a genuine botnet\n * still gets `RECOVER_ATTEMPTS_PER_IP` per source, and an attacker who can\n * create many sites gets the per-site ceiling once per site. Host creation is\n * itself rate-limited, and bounding the botnet case needs an edge WAF, not an\n * application limiter — the same conclusion `plugin-api-rate-limit.ts` reaches.\n */\nexport const membershipRecoverHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n // Null when nothing readable names the caller; the throttle then skips its\n // per-source cap and the per-recipient one carries the control alone.\n const ip = readClientIp(req.headers, {\n remoteAddress: req.socket?.remoteAddress,\n })\n // Control 1. Before the lookup, so both branches spend the same budget.\n // A store failure degrades to the in-process cap rather than refusing\n // (`consumeRateLimit` fails soft on an unreachable store, closed on\n // contention) — a Firestore blip must not lock every site's members out of\n // their own password reset.\n const attempt = await consumeMembershipRecoverAttempt({ email, ip })\n if (!attempt.allowed) {\n res.setHeader('Retry-After', String(attempt.retryAfterSeconds))\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [hostSnapshot, membersQuery] = await Promise.all([\n hostRef.get(),\n hostRef\n .collection('siteMembers')\n .where('email', '==', email)\n .limit(1)\n .get(),\n ])\n const memberDoc = membersQuery.docs[0]\n // Unknown site, unknown email, suspended account (AGL-546): all take\n // the same silent-success exit — the response never distinguishes.\n if (\n !hostSnapshot.exists ||\n !memberDoc ||\n memberDoc.get('suspended') === true\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 2 — the register-then-recover composition (AGL-1966).\n //\n // `createdAt` is a server timestamp written by `membership-register.ts`.\n // A row that has none is a legacy member, not a fresh one: rows predating\n // the field must NOT be refused, so an unreadable or absent value reads as\n // \"old enough\". This guard exists to refuse a member created seconds ago,\n // which is a case where the field is always present by construction.\n const createdAtMs = (() => {\n const raw = memberDoc.get('createdAt') as\n | { toMillis?: () => number }\n | undefined\n try {\n return typeof raw?.toMillis === 'function' ? raw.toMillis() : null\n } catch {\n return null\n }\n })()\n if (\n createdAtMs !== null &&\n Date.now() - createdAtMs < RECOVER_MIN_MEMBER_AGE_MS\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 3 — the platform suppression list (AGL-1918 / AGL-2407).\n //\n // `email-suppression.ts` argues that TRANSACTIONAL mail deliberately does\n // not consult this list, because refusing a password reset over an\n // unrelated old bounce locks a real customer out of their own account.\n // That argument is about mail a signed-in human just asked us for. It does\n // not reach here, and this is the exception it does not cover:\n //\n // - The address is chosen by an ANONYMOUS caller, not by the account\n // holder. \"The human just did something\" is exactly the premise that is\n // missing on an unauthenticated endpoint.\n // - The list holds PERMANENT bounces and spam complaints only — transient\n // bounces are excluded at the webhook. A permanent bounce means the\n // mailbox does not exist, so the send cannot reach anyone; all it can do\n // is tell a provider that `aglyn.com` re-mails dead addresses. There is\n // no locked-out customer on the other side of it to protect.\n // - The reputational cost is shared by every customer's transactional\n // mail, which is precisely what AGL-1966 is about.\n //\n // The HOST list (`hosts/{hostId}/suppressions`) is deliberately NOT\n // consulted: it also holds marketing unsubscribes, and unsubscribing from\n // a store's newsletter must never stop that store's password resets.\n //\n // `isEmailSuppressed` fails CLOSED — a read that throws answers \"treat as\n // suppressed\". That adds no new outage mode here: the member lookup above\n // is a Firestore read on the same database, so a Firestore outage has\n // already taken this handler to its silent-success catch.\n if (await isEmailSuppressed(email)) {\n return res.status(200).json({ ok: true })\n }\n if (!isEmailConfigured()) {\n // Config gaps must not become an existence oracle either; log for\n // the operator and keep the visitor-facing contract.\n console.error('membership/recover: email is not configured')\n return res.status(200).json({ ok: true })\n }\n // Bound to the hash sign-in checks, the credential document's (AGL-3308).\n const token = mintPasswordResetToken(\n hostId,\n memberDoc.id,\n await readMemberPasswordHash(hostRef, memberDoc),\n )\n // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is\n // minted server-side and mailed out, so a wrong apex is not a display\n // bug — it is a live link on somebody else's domain.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'your site',\n )\n // White-label sender identity (White-Label Phase 3): the store's brand via\n // the one shared resolver (the copy already uses the site name).\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n // Control 4 — the per-site daily ceiling on recovery mail (AGL-1966).\n //\n // Consumed here, as late as possible, because it counts MESSAGES and not\n // requests: everything above this line can refuse without having decided\n // to send, and a counter that ticked for those would exhaust a site's\n // budget on traffic that never cost a message.\n //\n // The refusal is the silent-success exit, NOT a 429. This branch is\n // reachable only for an address that really is a member, so a visible\n // error here would be the existence oracle the whole handler is built to\n // avoid — the one place where the cheap answer is the wrong one.\n //\n // The accepted cost: during an attack on a site, a genuine member can get\n // no mail and no error. The ceiling is sized more than an order of\n // magnitude above any real site's daily resets so that reaching it is\n // evidence of the attack rather than of a busy Tuesday.\n const sendBudget = await consumeMembershipRecoverSend({ hostId })\n if (!sendBudget.allowed) {\n console.error('membership/recover: daily send ceiling reached', {\n hostId,\n retryAfterSeconds: sendBudget.retryAfterSeconds,\n })\n return res.status(200).json({ ok: true })\n }\n await sendEmail({\n to: email,\n subject: `Reset your ${siteName} password`,\n text:\n `Someone asked to reset the password for your ${siteName} ` +\n 'account. If that was you, set a new password here:\\n\\n' +\n `${resetUrl}\\n\\n` +\n 'The link works once and expires in 1 hour. If you did not ' +\n 'ask for this, you can safely ignore this email — your ' +\n 'password is unchanged.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership recovery',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n // Cost meter (AGL-1438). Transactional, and the clearest case for why a\n // quota may not refuse one: this email is how the member gets back into\n // their account.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n // Even a backend failure stays silent-success: the enumeration\n // contract beats the visitor's error visibility here, and the send is\n // best-effort by design.\n return res.status(200).json({ ok: true })\n }\n}\n"],"names":["resolveBrandingProfile","hostPublicOrigin","consumeMembershipRecoverAttempt","consumeMembershipRecoverSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","RECOVER_MIN_MEMBER_AGE_MS","isEmailConfigured","sendEmail","mintPasswordResetToken","readMemberPasswordHash","readClientIp","EMAIL_PATTERN","membershipRecoverHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","test","ip","headers","remoteAddress","socket","attempt","allowed","setHeader","retryAfterSeconds","hostSnapshot","firestore","app","hostRef","collection","doc","membersQuery","Promise","all","get","where","limit","memberDoc","docs","exists","ok","createdAtMs","raw","toMillis","Date","now","console","token","id","siteBase","cname","subdomain","resetUrl","encodeURIComponent","siteName","branding","catch","org","sendBudget","to","subject","text","fromName","sendingIdentity","audience","context","owedFor"],"mappings":"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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
|
};
|