@aglyn/plugins-commerce 1.0.0-beta.204 → 1.0.0-beta.205
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-commerce",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.205",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.205",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.205",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.205",
|
|
31
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.205",
|
|
32
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.205",
|
|
33
|
+
"@aglyn/shared-util-email": "1.0.0-beta.205",
|
|
34
|
+
"@aglyn/shared-util-http": "1.0.0-beta.205",
|
|
35
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.205",
|
|
36
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.205",
|
|
37
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.205",
|
|
38
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.205",
|
|
39
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.205",
|
|
40
40
|
"@stripe/react-stripe-js": "^3.10.0",
|
|
41
41
|
"@stripe/stripe-js": "^5.10.0",
|
|
42
42
|
"@swc/helpers": "0.5.23",
|
|
@@ -78,6 +78,11 @@ const MAX_ATTEMPTS = 10;
|
|
|
78
78
|
// Event trigger (AGL-128/148).
|
|
79
79
|
await emitHostEvent(hostId, 'memberSignIn', {
|
|
80
80
|
email
|
|
81
|
+
}, {
|
|
82
|
+
actor: {
|
|
83
|
+
kind: 'visitor',
|
|
84
|
+
email
|
|
85
|
+
}
|
|
81
86
|
});
|
|
82
87
|
// Cart linkage (AGL-294): stamp the guest cart with the member so
|
|
83
88
|
// abandoned-cart and analytics can attribute it.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-login.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 { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { readCartId } from './cart-cookie'\nimport {\n MEMBER_SUSPENDED_ERROR,\n mintMemberSession,\n setMemberCookie,\n verifyMemberPassword,\n} from './membership'\nimport { readMemberPasswordHash } from './member-credentials'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n// Best-effort per-instance brute-force damper (mirrors AGL-87 unlock).\nconst attemptsByIp = new Map<string, number[]>()\nconst WINDOW_MS = 60_000\nconst MAX_ATTEMPTS = 10\n\n/** Site member sign-in (AGL-109); sets the session cookie on success. */\nexport const membershipLoginHandler: 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 const password = String(req.body?.password ?? '')\n if (!hostId || !email || !password) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n // Keeps counting under the no-address bucket rather than being skipped: this\n // damper guards a PASSWORD, and one that stops counting is a password anyone\n // can grind. On a deployment that cannot name its callers the whole site\n // shares one attempt budget, which is the direction to be wrong in here.\n const ip =\n readClientIp(req.headers, { remoteAddress: req.socket?.remoteAddress }) ??\n NO_CLIENT_ADDRESS_BUCKET\n const now = Date.now()\n const attempts = (attemptsByIp.get(ip) ?? []).filter(\n (at) => now - at < WINDOW_MS,\n )\n attempts.push(now)\n attemptsByIp.set(ip, attempts)\n if (attempts.length > MAX_ATTEMPTS) {\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 membersQuery = await hostRef\n .collection('siteMembers')\n .where('email', '==', email)\n .limit(1)\n .get()\n const memberDoc = membersQuery.docs[0]\n // The credential document's hash (AGL-3308) — see member-credentials.ts.\n const passwordScrypt = memberDoc\n ? await readMemberPasswordHash(hostRef, memberDoc)\n : undefined\n if (!memberDoc || !verifyMemberPassword(password, passwordScrypt)) {\n return res.status(401).json({ error: 'Wrong email or password' })\n }\n // Suspension gate (AGL-546): console-suspended members cannot sign in.\n // Checked after the password so the message never leaks account\n // existence to guessers who don't hold the credentials.\n if (memberDoc.get('suspended') === true) {\n return res.status(401).json({ error: MEMBER_SUSPENDED_ERROR })\n }\n // Event trigger (AGL-128/148).\n await emitHostEvent(hostId, 'memberSignIn', { email })\n // Cart linkage (AGL-294): stamp the guest cart with the member so\n // abandoned-cart and analytics can attribute it.\n //\n // AGL-1763: `update()`, never `set(..., { merge: true })`. `cartId` is the\n // `aglyn_cart_{hostId}` cookie verbatim — the one value on this request the\n // client chooses — and a merge-set against a missing path CREATES the\n // document. So a member could mint `carts/{anything}` at will, one document\n // per sign-in, each holding a `customerId` and nothing else: no `lines`, no\n // `createdAtMs`, no `updatedAtMs`. Inert to read — `cart.ts` treats a\n // missing `lines` as `[]` — but real storage on the merchant's Firestore\n // and real weight in the per-host document count, growing without bound and\n // belonging to no basket anyone ever filled.\n //\n // Nor is the cookie confined to one document. `CollectionReference.doc()`\n // appends a SLASH-SEPARATED path and refuses it only when the component\n // count comes out odd, so a cookie of `a/b/c` wrote to `carts/a/b/c` — a\n // document at a nesting of the caller's choosing.\n //\n // Refusing loses nothing, which is why this is a plain refusal rather than\n // AGL-1760's refuse-and-record: a cart that does not exist holds no lines,\n // so there is no basket to attribute and no work to strand. `cart.ts` is\n // what creates a cart — on the first POST that puts something in it, with\n // the whole document written. This only ever stamps one.\n //\n // AGL-1769 moved the cookie's name and its validity rule into\n // `cart-cookie.ts`, so a slash-bearing value never reaches `.doc()` from\n // here at all. `update()` stays: the rule bounds the value to ONE document\n // id, and whether THAT document exists is a separate question only the\n // write can answer.\n const cartId = readCartId(req.cookies, hostId)\n if (cartId) {\n try {\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('carts')\n .doc(cartId)\n .update({ customerId: memberDoc.id })\n } catch (error) {\n // Absent cart, malformed cookie, or a Firestore failure — none of them\n // may fail a sign-in that has already succeeded, so this stays\n // swallowed as it was. It is no longer SILENT, though: the old\n // `.catch(() => undefined)` could not tell a missing cart from an\n // outage. And the guard is a `try` rather than a `.catch()` because\n // `.doc()` throws SYNCHRONOUSLY on an odd-component path, outside the\n // promise the old handler covered — a mangled cookie used to 500 the\n // member's own sign-in.\n console.error('Cart linkage failed', hostId, error)\n }\n }\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberDoc.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-in failed' })\n }\n}\n"],"names":["firebaseAdmin","emitHostEvent","readCartId","MEMBER_SUSPENDED_ERROR","mintMemberSession","setMemberCookie","verifyMemberPassword","readMemberPasswordHash","NO_CLIENT_ADDRESS_BUCKET","readClientIp","attemptsByIp","Map","WINDOW_MS","MAX_ATTEMPTS","membershipLoginHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","ip","headers","remoteAddress","socket","now","Date","attempts","get","filter","at","push","set","length","firestore","app","hostRef","collection","doc","membersQuery","where","limit","memberDoc","docs","passwordScrypt","undefined","cartId","cookies","update","customerId","id","console","ok"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-login.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 { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { readCartId } from './cart-cookie'\nimport {\n MEMBER_SUSPENDED_ERROR,\n mintMemberSession,\n setMemberCookie,\n verifyMemberPassword,\n} from './membership'\nimport { readMemberPasswordHash } from './member-credentials'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n// Best-effort per-instance brute-force damper (mirrors AGL-87 unlock).\nconst attemptsByIp = new Map<string, number[]>()\nconst WINDOW_MS = 60_000\nconst MAX_ATTEMPTS = 10\n\n/** Site member sign-in (AGL-109); sets the session cookie on success. */\nexport const membershipLoginHandler: 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 const password = String(req.body?.password ?? '')\n if (!hostId || !email || !password) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n // Keeps counting under the no-address bucket rather than being skipped: this\n // damper guards a PASSWORD, and one that stops counting is a password anyone\n // can grind. On a deployment that cannot name its callers the whole site\n // shares one attempt budget, which is the direction to be wrong in here.\n const ip =\n readClientIp(req.headers, { remoteAddress: req.socket?.remoteAddress }) ??\n NO_CLIENT_ADDRESS_BUCKET\n const now = Date.now()\n const attempts = (attemptsByIp.get(ip) ?? []).filter(\n (at) => now - at < WINDOW_MS,\n )\n attempts.push(now)\n attemptsByIp.set(ip, attempts)\n if (attempts.length > MAX_ATTEMPTS) {\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 membersQuery = await hostRef\n .collection('siteMembers')\n .where('email', '==', email)\n .limit(1)\n .get()\n const memberDoc = membersQuery.docs[0]\n // The credential document's hash (AGL-3308) — see member-credentials.ts.\n const passwordScrypt = memberDoc\n ? await readMemberPasswordHash(hostRef, memberDoc)\n : undefined\n if (!memberDoc || !verifyMemberPassword(password, passwordScrypt)) {\n return res.status(401).json({ error: 'Wrong email or password' })\n }\n // Suspension gate (AGL-546): console-suspended members cannot sign in.\n // Checked after the password so the message never leaks account\n // existence to guessers who don't hold the credentials.\n if (memberDoc.get('suspended') === true) {\n return res.status(401).json({ error: MEMBER_SUSPENDED_ERROR })\n }\n // Event trigger (AGL-128/148).\n await emitHostEvent(\n hostId,\n 'memberSignIn',\n { email },\n { actor: { kind: 'visitor', email } },\n )\n // Cart linkage (AGL-294): stamp the guest cart with the member so\n // abandoned-cart and analytics can attribute it.\n //\n // AGL-1763: `update()`, never `set(..., { merge: true })`. `cartId` is the\n // `aglyn_cart_{hostId}` cookie verbatim — the one value on this request the\n // client chooses — and a merge-set against a missing path CREATES the\n // document. So a member could mint `carts/{anything}` at will, one document\n // per sign-in, each holding a `customerId` and nothing else: no `lines`, no\n // `createdAtMs`, no `updatedAtMs`. Inert to read — `cart.ts` treats a\n // missing `lines` as `[]` — but real storage on the merchant's Firestore\n // and real weight in the per-host document count, growing without bound and\n // belonging to no basket anyone ever filled.\n //\n // Nor is the cookie confined to one document. `CollectionReference.doc()`\n // appends a SLASH-SEPARATED path and refuses it only when the component\n // count comes out odd, so a cookie of `a/b/c` wrote to `carts/a/b/c` — a\n // document at a nesting of the caller's choosing.\n //\n // Refusing loses nothing, which is why this is a plain refusal rather than\n // AGL-1760's refuse-and-record: a cart that does not exist holds no lines,\n // so there is no basket to attribute and no work to strand. `cart.ts` is\n // what creates a cart — on the first POST that puts something in it, with\n // the whole document written. This only ever stamps one.\n //\n // AGL-1769 moved the cookie's name and its validity rule into\n // `cart-cookie.ts`, so a slash-bearing value never reaches `.doc()` from\n // here at all. `update()` stays: the rule bounds the value to ONE document\n // id, and whether THAT document exists is a separate question only the\n // write can answer.\n const cartId = readCartId(req.cookies, hostId)\n if (cartId) {\n try {\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('carts')\n .doc(cartId)\n .update({ customerId: memberDoc.id })\n } catch (error) {\n // Absent cart, malformed cookie, or a Firestore failure — none of them\n // may fail a sign-in that has already succeeded, so this stays\n // swallowed as it was. It is no longer SILENT, though: the old\n // `.catch(() => undefined)` could not tell a missing cart from an\n // outage. And the guard is a `try` rather than a `.catch()` because\n // `.doc()` throws SYNCHRONOUSLY on an odd-component path, outside the\n // promise the old handler covered — a mangled cookie used to 500 the\n // member's own sign-in.\n console.error('Cart linkage failed', hostId, error)\n }\n }\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberDoc.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-in failed' })\n }\n}\n"],"names":["firebaseAdmin","emitHostEvent","readCartId","MEMBER_SUSPENDED_ERROR","mintMemberSession","setMemberCookie","verifyMemberPassword","readMemberPasswordHash","NO_CLIENT_ADDRESS_BUCKET","readClientIp","attemptsByIp","Map","WINDOW_MS","MAX_ATTEMPTS","membershipLoginHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","ip","headers","remoteAddress","socket","now","Date","attempts","get","filter","at","push","set","length","firestore","app","hostRef","collection","doc","membersQuery","where","limit","memberDoc","docs","passwordScrypt","undefined","actor","kind","cartId","cookies","update","customerId","id","console","ok"],"mappings":"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"}
|
|
@@ -239,6 +239,11 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
239
239
|
// so the `lead` event is the lead surfaces' to emit.
|
|
240
240
|
await emitHostEvent(hostId, 'memberSignUp', {
|
|
241
241
|
email
|
|
242
|
+
}, {
|
|
243
|
+
actor: {
|
|
244
|
+
kind: 'visitor',
|
|
245
|
+
email
|
|
246
|
+
}
|
|
242
247
|
});
|
|
243
248
|
setMemberCookie(res, hostId, mintMemberSession(hostId, memberRef.id));
|
|
244
249
|
return res.status(200).json({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-register.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { memberNameSearchFields, memberSearchTokens } from './member-name-search'\nimport {\n checkVisitorRecordCeiling,\n HOST_TOKENS,\n marketingConsentFieldsForHost,\n SITE_MEMBER_CEILING_CODE,\n SITE_MEMBER_UNAVAILABLE_MESSAGE,\n SITE_MEMBERS_MAX_PER_HOST,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n recordVisitorRecordCeilingTrip,\n resolveCampaignTouch,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n hashMemberPassword,\n mintMemberSession,\n setMemberCookie,\n} from './membership'\nimport { memberCredentialRef } from './member-credentials'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Site member sign-up (AGL-109): creates the member record and its credential\n * document (the scrypt hash, AGL-3308), doubles as a lead, and signs the\n * visitor in via the session cookie.\n */\nexport const membershipRegisterHandler: 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 const password = String(req.body?.password ?? '')\n const displayName = String(req.body?.displayName ?? '')\n .trim()\n .slice(0, 80)\n // Explicit opt-in checkbox (AGL-2499) — never inferred from signing up.\n const marketingConsent = req.body?.marketingConsent === true\n if (!hostId) return res.status(400).json({ error: 'Missing host' })\n if (!EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n try {\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 // Member accounts are UNLIMITED on every plan (AGL-889) — no seat or\n // quota check belongs here. Audience monetization happens downstream:\n // contact bands meter the CRM projection (AGL-890) and paid\n // memberships carry the plan's digital transaction fee (AGL-892).\n //\n // `SITE_MEMBERS_MAX_PER_HOST` below is NOT that check and does not make\n // it one (AGL-1529). It is a flat PLATFORM ceiling — same number on every\n // plan, no `OrgEntitlements` key, nothing on the price list — and it\n // exists because this handler is reachable by an ANONYMOUS VISITOR, so\n // without it the collection was bounded only by a per-(host, IP) rate\n // limiter that fails soft and bounds the RATE rather than the TOTAL. An\n // abuse control is not something we sell; \"unlimited member accounts on\n // every plan\" stays literally true.\n const membersRef = hostRef.collection('siteMembers')\n const memberRef = membersRef.doc()\n /*\n * COUNT, DEDUPE AND CREATE IN ONE TRANSACTION (AGL-1529, the AGL-2231\n * treatment).\n *\n * Read-then-decide-then-`set()` is the create-time quota that laundered\n * everywhere else in this repo: N concurrent sign-ups each read the same\n * pre-count, each find room, and each land — and nothing re-counts\n * afterwards, so the extra accounts are permanent. The fix is WHEN the\n * count is evaluated, not the counting rule.\n * `Transaction.get(AggregateQuery)` serialises the count against a\n * concurrent create into this collection: the loser retries, re-reads the\n * higher count and is refused. The duplicate-email read moves inside for\n * free, which also closes the smaller race it always had — two\n * simultaneous sign-ups on one address both saw `empty` and both wrote.\n *\n * ALL READS BEFORE THE WRITE, which Firestore requires.\n *\n * The scrypt hash sits between the last read and the create ON PURPOSE.\n * It is ~100 ms of CPU and it is the most expensive thing in the request,\n * so a refused sign-up must never pay it — hashing before the transaction\n * would hand a flood a CPU amplifier on exactly the path the ceiling\n * exists to contain, and would also be a regression, since today a\n * duplicate-email refusal does not hash either. It is a pure computation,\n * not an effect, so it does not violate the \"nothing happens in a\n * transaction body\" rule the sibling routes state.\n *\n * A refusal is returned as DATA and rendered outside: a body that can run\n * several times must not be the place a response is built.\n */\n const refusal = await firestore.runTransaction(async (tx) => {\n const existing = await tx.get(membersRef.where('email', '==', email).limit(1))\n if (!existing.empty) return { duplicate: true, ceiling: 0 }\n const used = (await tx.get(membersRef.count())).data().count\n // Live documents only, so removing a member in the inbox frees the slot.\n const verdict = checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n if (verdict.exceeded) return { duplicate: false, ceiling: verdict.ceiling }\n // The hash goes to the credential document, which no client can read\n // (AGL-3308), and in this transaction, so a member never exists without\n // the password they chose. The profile below is what the console lists.\n tx.set(memberCredentialRef(hostRef, memberRef.id), {\n passwordScrypt: hashMemberPassword(password),\n })\n tx.create(memberRef, {\n email,\n ...(displayName ? memberNameSearchFields(displayName) : {}),\n // The Site users list's search: the name and the address (AGL-3321).\n searchTokens: memberSearchTokens(displayName, email),\n /*\n * Stored `false`, not left out: the console's Status filter asks\n * `suspended == false` for the active members, and a query cannot\n * find a document that lacks the field. The drawer's Suspend and\n * Reactivate write `true` and `false` over it.\n */\n suspended: false,\n /*\n * The checkbox is PERSISTED on the member, not only forwarded.\n *\n * It reached the lead and the contact from the two lines below and\n * was dropped for this document, so `hosts/{hostId}/siteMembers` had\n * no consent field of any kind and `audience: 'members'` had nothing\n * for the send-time join to read — the audience could not be filtered\n * even in principle (`docs/specs/email-overhaul.md` §1d/§3f). The\n * other two documents are not a substitute: a member is deduped in\n * this transaction while leads append every time, and contacts are\n * ORG-scoped where a member is the site's own.\n *\n * Written only when ticked. Signing up is not opting in — that is why\n * the checkbox exists and why it defaults unchecked — so the omitted\n * case stores nothing and reads back as an unrecorded basis rather\n * than as a refusal.\n *\n * Under this site's key even though `siteMembers` already lives\n * beneath it, so that one reader answers for every silo — see\n * `addHostLead`, which makes the same write for the same reason.\n */\n ...(marketingConsent\n ? marketingConsentFieldsForHost(hostId, Date.now())\n : {}),\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return null\n })\n if (refusal?.duplicate) {\n return res.status(409).json({ error: 'That email is already a member' })\n }\n if (refusal) {\n // Visible to the HOST — the rule that a control nobody can see in\n // the console did not ship. Durable counter + one notification.\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'siteMembers',\n ceiling: refusal.ceiling,\n })\n // Opaque to the VISITOR, who is a stranger to this site and not our\n // customer (AGL-1666's rules, restated in the message's own docblock).\n // The one genuinely useful thing to hand them is a door that still\n // opens, so the site's OWN published support address rides along when\n // it has one — read through the host-token registry, whose description\n // of this field is literally \"where visitors should write for help\".\n // Nothing else off the host document may leave the console.\n const contact = HOST_TOKENS['supportEmail']?.resolve(\n hostSnapshot.data() as any,\n )\n return res.status(429).json({\n error: SITE_MEMBER_UNAVAILABLE_MESSAGE,\n // Machine-readable, because the dispatcher's rate limiter answers 429\n // too and the status therefore discriminates nothing.\n code: SITE_MEMBER_CEILING_CODE,\n ...(contact ? { contact } : {}),\n })\n }\n // Sign-ups double as leads for the site owner (AGL-109), through the one\n // writer that enforces `LEADS_MAX_PER_HOST` (AGL-1529). A refused lead\n // never fails the sign-up: the visitor asked for an account, not for a\n // lead record, and the trip is recorded for the owner either way.\n /*\n * THE CAMPAIGN TOUCH, RESOLVED ONCE FOR THE WHOLE SIGN-UP.\n *\n * A sign-up is the identify moment for a visitor who has been anonymous\n * until now, and it writes two records that a campaign can be credited\n * with — the lead and the contact. One resolve, one keyed read, and the\n * two cannot end up naming different campaigns.\n *\n * The MEMBER record itself is not attributed. A member is an account the\n * visitor holds and the lead is the site's record of the same act, so\n * crediting both would count one sign-up twice under two names.\n */\n const signedUpAtMs = Date.now()\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: req.body?.campaignTouch,\n email,\n atMs: signedUpAtMs,\n })\n /*\n * Contacts ingestion (AGL-197), reported to whichever plugin keeps people\n * rather than written by this one (AGL-3080). Commerce knows it just met\n * somebody; what a person record is belongs to the plugin that models it,\n * and a workspace that keeps none is a quiet, correct answer here.\n */\n /*\n * A member account is a RELATIONSHIP (AGL-3232): the person is a\n * contact from this moment, and an open lead the site held for the\n * address is closed as converted onto that contact by the record\n * system — a sign-up files no lead of its own any more, the way a\n * portal user is a contact and not a lead in Salesforce.\n */\n void recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email, name: displayName || undefined },\n interaction: {\n source: 'member',\n refId: memberRef.id,\n summary: 'Joined as a member',\n },\n surface: 'relationship',\n // An account is a subscription to the site, not an enquiry (AGL-2612):\n // the stage on the contact says only that the person asked to be\n // kept. A floor, so a customer who opens an account stays a customer.\n lifecycleFloor: 'subscriber',\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n // Event trigger (AGL-128/148). A sign-up is no longer a lead (AGL-3232),\n // so the `lead` event is the lead surfaces' to emit.\n await emitHostEvent(hostId, 'memberSignUp', { email })\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberRef.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-up failed' })\n }\n}\n"],"names":["memberNameSearchFields","memberSearchTokens","checkVisitorRecordCeiling","HOST_TOKENS","marketingConsentFieldsForHost","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","SITE_MEMBERS_MAX_PER_HOST","firebaseAdmin","recordVisitorRecordCeilingTrip","resolveCampaignTouch","emitHostEvent","recordCapturedContact","hashMemberPassword","mintMemberSession","setMemberCookie","memberCredentialRef","EMAIL_PATTERN","membershipRegisterHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","displayName","slice","marketingConsent","test","length","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","membersRef","memberRef","refusal","runTransaction","tx","existing","where","limit","empty","duplicate","ceiling","used","count","data","verdict","exceeded","set","id","passwordScrypt","create","searchTokens","suspended","Date","now","createdAt","FieldValue","serverTimestamp","kind","contact","resolve","code","signedUpAtMs","campaignTouch","wire","atMs","orgId","identity","name","undefined","interaction","source","refId","summary","surface","lifecycleFloor","detail","ok","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-register.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { memberNameSearchFields, memberSearchTokens } from './member-name-search'\nimport {\n checkVisitorRecordCeiling,\n HOST_TOKENS,\n marketingConsentFieldsForHost,\n SITE_MEMBER_CEILING_CODE,\n SITE_MEMBER_UNAVAILABLE_MESSAGE,\n SITE_MEMBERS_MAX_PER_HOST,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n recordVisitorRecordCeilingTrip,\n resolveCampaignTouch,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n hashMemberPassword,\n mintMemberSession,\n setMemberCookie,\n} from './membership'\nimport { memberCredentialRef } from './member-credentials'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Site member sign-up (AGL-109): creates the member record and its credential\n * document (the scrypt hash, AGL-3308), doubles as a lead, and signs the\n * visitor in via the session cookie.\n */\nexport const membershipRegisterHandler: 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 const password = String(req.body?.password ?? '')\n const displayName = String(req.body?.displayName ?? '')\n .trim()\n .slice(0, 80)\n // Explicit opt-in checkbox (AGL-2499) — never inferred from signing up.\n const marketingConsent = req.body?.marketingConsent === true\n if (!hostId) return res.status(400).json({ error: 'Missing host' })\n if (!EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n try {\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 // Member accounts are UNLIMITED on every plan (AGL-889) — no seat or\n // quota check belongs here. Audience monetization happens downstream:\n // contact bands meter the CRM projection (AGL-890) and paid\n // memberships carry the plan's digital transaction fee (AGL-892).\n //\n // `SITE_MEMBERS_MAX_PER_HOST` below is NOT that check and does not make\n // it one (AGL-1529). It is a flat PLATFORM ceiling — same number on every\n // plan, no `OrgEntitlements` key, nothing on the price list — and it\n // exists because this handler is reachable by an ANONYMOUS VISITOR, so\n // without it the collection was bounded only by a per-(host, IP) rate\n // limiter that fails soft and bounds the RATE rather than the TOTAL. An\n // abuse control is not something we sell; \"unlimited member accounts on\n // every plan\" stays literally true.\n const membersRef = hostRef.collection('siteMembers')\n const memberRef = membersRef.doc()\n /*\n * COUNT, DEDUPE AND CREATE IN ONE TRANSACTION (AGL-1529, the AGL-2231\n * treatment).\n *\n * Read-then-decide-then-`set()` is the create-time quota that laundered\n * everywhere else in this repo: N concurrent sign-ups each read the same\n * pre-count, each find room, and each land — and nothing re-counts\n * afterwards, so the extra accounts are permanent. The fix is WHEN the\n * count is evaluated, not the counting rule.\n * `Transaction.get(AggregateQuery)` serialises the count against a\n * concurrent create into this collection: the loser retries, re-reads the\n * higher count and is refused. The duplicate-email read moves inside for\n * free, which also closes the smaller race it always had — two\n * simultaneous sign-ups on one address both saw `empty` and both wrote.\n *\n * ALL READS BEFORE THE WRITE, which Firestore requires.\n *\n * The scrypt hash sits between the last read and the create ON PURPOSE.\n * It is ~100 ms of CPU and it is the most expensive thing in the request,\n * so a refused sign-up must never pay it — hashing before the transaction\n * would hand a flood a CPU amplifier on exactly the path the ceiling\n * exists to contain, and would also be a regression, since today a\n * duplicate-email refusal does not hash either. It is a pure computation,\n * not an effect, so it does not violate the \"nothing happens in a\n * transaction body\" rule the sibling routes state.\n *\n * A refusal is returned as DATA and rendered outside: a body that can run\n * several times must not be the place a response is built.\n */\n const refusal = await firestore.runTransaction(async (tx) => {\n const existing = await tx.get(membersRef.where('email', '==', email).limit(1))\n if (!existing.empty) return { duplicate: true, ceiling: 0 }\n const used = (await tx.get(membersRef.count())).data().count\n // Live documents only, so removing a member in the inbox frees the slot.\n const verdict = checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n if (verdict.exceeded) return { duplicate: false, ceiling: verdict.ceiling }\n // The hash goes to the credential document, which no client can read\n // (AGL-3308), and in this transaction, so a member never exists without\n // the password they chose. The profile below is what the console lists.\n tx.set(memberCredentialRef(hostRef, memberRef.id), {\n passwordScrypt: hashMemberPassword(password),\n })\n tx.create(memberRef, {\n email,\n ...(displayName ? memberNameSearchFields(displayName) : {}),\n // The Site users list's search: the name and the address (AGL-3321).\n searchTokens: memberSearchTokens(displayName, email),\n /*\n * Stored `false`, not left out: the console's Status filter asks\n * `suspended == false` for the active members, and a query cannot\n * find a document that lacks the field. The drawer's Suspend and\n * Reactivate write `true` and `false` over it.\n */\n suspended: false,\n /*\n * The checkbox is PERSISTED on the member, not only forwarded.\n *\n * It reached the lead and the contact from the two lines below and\n * was dropped for this document, so `hosts/{hostId}/siteMembers` had\n * no consent field of any kind and `audience: 'members'` had nothing\n * for the send-time join to read — the audience could not be filtered\n * even in principle (`docs/specs/email-overhaul.md` §1d/§3f). The\n * other two documents are not a substitute: a member is deduped in\n * this transaction while leads append every time, and contacts are\n * ORG-scoped where a member is the site's own.\n *\n * Written only when ticked. Signing up is not opting in — that is why\n * the checkbox exists and why it defaults unchecked — so the omitted\n * case stores nothing and reads back as an unrecorded basis rather\n * than as a refusal.\n *\n * Under this site's key even though `siteMembers` already lives\n * beneath it, so that one reader answers for every silo — see\n * `addHostLead`, which makes the same write for the same reason.\n */\n ...(marketingConsent\n ? marketingConsentFieldsForHost(hostId, Date.now())\n : {}),\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return null\n })\n if (refusal?.duplicate) {\n return res.status(409).json({ error: 'That email is already a member' })\n }\n if (refusal) {\n // Visible to the HOST — the rule that a control nobody can see in\n // the console did not ship. Durable counter + one notification.\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'siteMembers',\n ceiling: refusal.ceiling,\n })\n // Opaque to the VISITOR, who is a stranger to this site and not our\n // customer (AGL-1666's rules, restated in the message's own docblock).\n // The one genuinely useful thing to hand them is a door that still\n // opens, so the site's OWN published support address rides along when\n // it has one — read through the host-token registry, whose description\n // of this field is literally \"where visitors should write for help\".\n // Nothing else off the host document may leave the console.\n const contact = HOST_TOKENS['supportEmail']?.resolve(\n hostSnapshot.data() as any,\n )\n return res.status(429).json({\n error: SITE_MEMBER_UNAVAILABLE_MESSAGE,\n // Machine-readable, because the dispatcher's rate limiter answers 429\n // too and the status therefore discriminates nothing.\n code: SITE_MEMBER_CEILING_CODE,\n ...(contact ? { contact } : {}),\n })\n }\n // Sign-ups double as leads for the site owner (AGL-109), through the one\n // writer that enforces `LEADS_MAX_PER_HOST` (AGL-1529). A refused lead\n // never fails the sign-up: the visitor asked for an account, not for a\n // lead record, and the trip is recorded for the owner either way.\n /*\n * THE CAMPAIGN TOUCH, RESOLVED ONCE FOR THE WHOLE SIGN-UP.\n *\n * A sign-up is the identify moment for a visitor who has been anonymous\n * until now, and it writes two records that a campaign can be credited\n * with — the lead and the contact. One resolve, one keyed read, and the\n * two cannot end up naming different campaigns.\n *\n * The MEMBER record itself is not attributed. A member is an account the\n * visitor holds and the lead is the site's record of the same act, so\n * crediting both would count one sign-up twice under two names.\n */\n const signedUpAtMs = Date.now()\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: req.body?.campaignTouch,\n email,\n atMs: signedUpAtMs,\n })\n /*\n * Contacts ingestion (AGL-197), reported to whichever plugin keeps people\n * rather than written by this one (AGL-3080). Commerce knows it just met\n * somebody; what a person record is belongs to the plugin that models it,\n * and a workspace that keeps none is a quiet, correct answer here.\n */\n /*\n * A member account is a RELATIONSHIP (AGL-3232): the person is a\n * contact from this moment, and an open lead the site held for the\n * address is closed as converted onto that contact by the record\n * system — a sign-up files no lead of its own any more, the way a\n * portal user is a contact and not a lead in Salesforce.\n */\n void recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email, name: displayName || undefined },\n interaction: {\n source: 'member',\n refId: memberRef.id,\n summary: 'Joined as a member',\n },\n surface: 'relationship',\n // An account is a subscription to the site, not an enquiry (AGL-2612):\n // the stage on the contact says only that the person asked to be\n // kept. A floor, so a customer who opens an account stays a customer.\n lifecycleFloor: 'subscriber',\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n // Event trigger (AGL-128/148). A sign-up is no longer a lead (AGL-3232),\n // so the `lead` event is the lead surfaces' to emit.\n await emitHostEvent(\n hostId,\n 'memberSignUp',\n { email },\n { actor: { kind: 'visitor', email } },\n )\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberRef.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-up failed' })\n }\n}\n"],"names":["memberNameSearchFields","memberSearchTokens","checkVisitorRecordCeiling","HOST_TOKENS","marketingConsentFieldsForHost","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","SITE_MEMBERS_MAX_PER_HOST","firebaseAdmin","recordVisitorRecordCeilingTrip","resolveCampaignTouch","emitHostEvent","recordCapturedContact","hashMemberPassword","mintMemberSession","setMemberCookie","memberCredentialRef","EMAIL_PATTERN","membershipRegisterHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","displayName","slice","marketingConsent","test","length","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","membersRef","memberRef","refusal","runTransaction","tx","existing","where","limit","empty","duplicate","ceiling","used","count","data","verdict","exceeded","set","id","passwordScrypt","create","searchTokens","suspended","Date","now","createdAt","FieldValue","serverTimestamp","kind","contact","resolve","code","signedUpAtMs","campaignTouch","wire","atMs","orgId","identity","name","undefined","interaction","source","refId","summary","surface","lifecycleFloor","detail","actor","ok","console"],"mappings":"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"}
|