@aglyn/plugins-commerce 1.0.0-beta.184 → 1.0.0-beta.186

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.184",
3
+ "version": "1.0.0-beta.186",
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.184",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.184",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.184",
31
- "@aglyn/shared-ui-next": "1.0.0-beta.184",
32
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.184",
33
- "@aglyn/shared-util-email": "1.0.0-beta.184",
34
- "@aglyn/shared-util-http": "1.0.0-beta.184",
35
- "@aglyn/shared-util-timestamp": "1.0.0-beta.184",
36
- "@aglyn/shared-util-tools": "1.0.0-beta.184",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.184",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.184",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.184",
28
+ "@aglyn/aglyn": "1.0.0-beta.186",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.186",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.186",
31
+ "@aglyn/shared-ui-next": "1.0.0-beta.186",
32
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.186",
33
+ "@aglyn/shared-util-email": "1.0.0-beta.186",
34
+ "@aglyn/shared-util-http": "1.0.0-beta.186",
35
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.186",
36
+ "@aglyn/shared-util-tools": "1.0.0-beta.186",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.186",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.186",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.186",
40
40
  "@stripe/react-stripe-js": "^3.10.0",
41
41
  "@stripe/stripe-js": "^5.10.0",
42
42
  "@swc/helpers": "0.5.23",
@@ -29,11 +29,12 @@
29
29
  * collection to every client, staff included, and only these server routes
30
30
  * read or write it.
31
31
  *
32
- * `passwordScrypt` on the PROFILE is the legacy copy. Sign-in still reads it
33
- * for a member with no credential document, which is every member until
34
- * `tools/scripts/backfill-site-member-credentials.mjs` moves the hashes; each
35
- * writer here deletes it as it writes the credential document, and the rules
36
- * refuse a client that tries to set it.
32
+ * A `passwordScrypt` on the PROFILE answers for nothing. That was where the
33
+ * hash lived before AGL-3308, and a one-shot migration moved every copy
34
+ * across (2026-09-24). No route reads one there now, so a copy written onto a
35
+ * profile, by staff or by a stale script, cannot open an account; each writer
36
+ * here still deletes the field as it writes the credential document, and the
37
+ * rules refuse a client that tries to set it.
37
38
  */
38
39
  /**
39
40
  * The fields that belong on the credential document, and that a writer
@@ -46,21 +47,20 @@ export declare const MEMBER_CREDENTIAL_FIELDS: readonly ["passwordScrypt", "pass
46
47
  /** The member's credential document. */
47
48
  export declare function memberCredentialRef(hostRef: FirebaseFirestore.DocumentReference, memberId: string): FirebaseFirestore.DocumentReference;
48
49
  /**
49
- * The hash a password is checked against: the credential document's, else the
50
- * legacy copy on the profile.
50
+ * The hash a password is checked against: the credential document's, or none.
51
51
  *
52
- * Once a credential document carries a hash the profile's copy is never read,
53
- * so a stale or planted legacy value cannot open an account that has moved.
54
52
  * Every flow that checks a password or binds a reset token to one asks this,
55
- * so the two can never disagree about which hash is current.
53
+ * so the two can never disagree about which hash is current. A member with no
54
+ * credential document has no password — sign-in refuses them, and a reset
55
+ * link lets them set one.
56
56
  */
57
- export declare function storedPasswordHash(credential: FirebaseFirestore.DocumentSnapshot | null | undefined, member: FirebaseFirestore.DocumentSnapshot | null | undefined): string | undefined;
57
+ export declare function storedPasswordHash(credential: FirebaseFirestore.DocumentSnapshot | null | undefined): string | undefined;
58
58
  /** {@link storedPasswordHash} for a member document already in hand. */
59
59
  export declare function readMemberPasswordHash(hostRef: FirebaseFirestore.DocumentReference, member: FirebaseFirestore.DocumentSnapshot): Promise<string | undefined>;
60
60
  /**
61
- * The profile patch that retires its legacy credential fields — spread into
62
- * the same write that sets the credential document, so no moment exists where
63
- * the profile still answers for a password the credential document replaced.
61
+ * The profile patch that deletes any credential field from it — spread into
62
+ * the same write that sets the credential document, so a hash never sits on
63
+ * the document every member of the site can read.
64
64
  */
65
65
  export declare function retiredCredentialFields(): Record<(typeof MEMBER_CREDENTIAL_FIELDS)[number], FirebaseFirestore.FieldValue>;
66
66
  /**
@@ -29,11 +29,12 @@
29
29
  * collection to every client, staff included, and only these server routes
30
30
  * read or write it.
31
31
  *
32
- * `passwordScrypt` on the PROFILE is the legacy copy. Sign-in still reads it
33
- * for a member with no credential document, which is every member until
34
- * `tools/scripts/backfill-site-member-credentials.mjs` moves the hashes; each
35
- * writer here deletes it as it writes the credential document, and the rules
36
- * refuse a client that tries to set it.
32
+ * A `passwordScrypt` on the PROFILE answers for nothing. That was where the
33
+ * hash lived before AGL-3308, and a one-shot migration moved every copy
34
+ * across (2026-09-24). No route reads one there now, so a copy written onto a
35
+ * profile, by staff or by a stale script, cannot open an account; each writer
36
+ * here still deletes the field as it writes the credential document, and the
37
+ * rules refuse a client that tries to set it.
37
38
  */ /**
38
39
  * The fields that belong on the credential document, and that a writer
39
40
  * deletes from the profile when it writes one.
@@ -53,25 +54,23 @@
53
54
  return typeof value === 'string' && value ? value : undefined;
54
55
  }
55
56
  /**
56
- * The hash a password is checked against: the credential document's, else the
57
- * legacy copy on the profile.
57
+ * The hash a password is checked against: the credential document's, or none.
58
58
  *
59
- * Once a credential document carries a hash the profile's copy is never read,
60
- * so a stale or planted legacy value cannot open an account that has moved.
61
59
  * Every flow that checks a password or binds a reset token to one asks this,
62
- * so the two can never disagree about which hash is current.
63
- */ export function storedPasswordHash(credential, member) {
64
- var _hashOn;
65
- return (_hashOn = hashOn(credential)) != null ? _hashOn : hashOn(member);
60
+ * so the two can never disagree about which hash is current. A member with no
61
+ * credential document has no password — sign-in refuses them, and a reset
62
+ * link lets them set one.
63
+ */ export function storedPasswordHash(credential) {
64
+ return hashOn(credential);
66
65
  }
67
66
  /** {@link storedPasswordHash} for a member document already in hand. */ export async function readMemberPasswordHash(hostRef, member) {
68
67
  const credential = await memberCredentialRef(hostRef, member.id).get();
69
- return storedPasswordHash(credential, member);
68
+ return storedPasswordHash(credential);
70
69
  }
71
70
  /**
72
- * The profile patch that retires its legacy credential fields — spread into
73
- * the same write that sets the credential document, so no moment exists where
74
- * the profile still answers for a password the credential document replaced.
71
+ * The profile patch that deletes any credential field from it — spread into
72
+ * the same write that sets the credential document, so a hash never sits on
73
+ * the document every member of the site can read.
75
74
  */ export function retiredCredentialFields() {
76
75
  const remove = firebaseAdmin.firestore.FieldValue.delete();
77
76
  return {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-credentials.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 { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * WHERE A SITE MEMBER'S PASSWORD LIVES (AGL-3308).\n *\n * `hosts/{hostId}/siteMemberCredentials/{memberId}`, keyed by the member's\n * own id, beside the profile at `hosts/{hostId}/siteMembers/{memberId}`.\n *\n * The profile is what the console lists — the Users page, the Inbox, the\n * dashboard's newest members — so every member of the site reads it, viewers\n * included. A `salt:scrypt` hash on it was readable by all of them, and the\n * host catch-all let any content writer replace it and sign in as the\n * member. Firestore rules cannot hide one field of a readable document, so\n * the hash moved to a document no client reaches at all: the rules deny this\n * collection to every client, staff included, and only these server routes\n * read or write it.\n *\n * `passwordScrypt` on the PROFILE is the legacy copy. Sign-in still reads it\n * for a member with no credential document, which is every member until\n * `tools/scripts/backfill-site-member-credentials.mjs` moves the hashes; each\n * writer here deletes it as it writes the credential document, and the rules\n * refuse a client that tries to set it.\n */\n\n/**\n * The fields that belong on the credential document, and that a writer\n * deletes from the profile when it writes one.\n *\n * `passwordResetAt` travels with the hash: it says when the password was last\n * reset, which is a fact about the credential and nothing the console shows.\n */\nexport const MEMBER_CREDENTIAL_FIELDS = ['passwordScrypt', 'passwordResetAt'] as const\n\n/** The member's credential document. */\nexport function memberCredentialRef(\n hostRef: FirebaseFirestore.DocumentReference,\n memberId: string,\n): FirebaseFirestore.DocumentReference {\n return hostRef.collection('siteMemberCredentials').doc(memberId)\n}\n\n/** A usable stored hash: a non-empty string, or nothing. */\nfunction hashOn(\n snapshot: FirebaseFirestore.DocumentSnapshot | null | undefined,\n): string | undefined {\n if (!snapshot || snapshot.exists === false) return undefined\n const value = snapshot.get('passwordScrypt')\n return typeof value === 'string' && value ? value : undefined\n}\n\n/**\n * The hash a password is checked against: the credential document's, else the\n * legacy copy on the profile.\n *\n * Once a credential document carries a hash the profile's copy is never read,\n * so a stale or planted legacy value cannot open an account that has moved.\n * Every flow that checks a password or binds a reset token to one asks this,\n * so the two can never disagree about which hash is current.\n */\nexport function storedPasswordHash(\n credential: FirebaseFirestore.DocumentSnapshot | null | undefined,\n member: FirebaseFirestore.DocumentSnapshot | null | undefined,\n): string | undefined {\n return hashOn(credential) ?? hashOn(member)\n}\n\n/** {@link storedPasswordHash} for a member document already in hand. */\nexport async function readMemberPasswordHash(\n hostRef: FirebaseFirestore.DocumentReference,\n member: FirebaseFirestore.DocumentSnapshot,\n): Promise<string | undefined> {\n const credential = await memberCredentialRef(hostRef, member.id).get()\n return storedPasswordHash(credential, member)\n}\n\n/**\n * The profile patch that retires its legacy credential fields — spread into\n * the same write that sets the credential document, so no moment exists where\n * the profile still answers for a password the credential document replaced.\n */\nexport function retiredCredentialFields(): Record<\n (typeof MEMBER_CREDENTIAL_FIELDS)[number],\n FirebaseFirestore.FieldValue\n> {\n const remove = firebaseAdmin.firestore.FieldValue.delete()\n return { passwordScrypt: remove, passwordResetAt: remove }\n}\n\n/**\n * Whether a client-supplied member id names ONE document.\n *\n * `CollectionReference.doc()` appends a slash-separated PATH, so an id like\n * `a/b/c` would address a document nested under another member — the cart\n * cookie lesson of AGL-1763. Console routes take the id from a request body.\n */\nexport function isMemberDocumentId(memberId: string): boolean {\n return (\n memberId.length > 0 &&\n memberId.length <= 1500 &&\n !memberId.includes('/') &&\n memberId !== '.' &&\n memberId !== '..'\n )\n}\n"],"names":["firebaseAdmin","MEMBER_CREDENTIAL_FIELDS","memberCredentialRef","hostRef","memberId","collection","doc","hashOn","snapshot","exists","undefined","value","get","storedPasswordHash","credential","member","readMemberPasswordHash","id","retiredCredentialFields","remove","firestore","FieldValue","delete","passwordScrypt","passwordResetAt","isMemberDocumentId","length","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,2BAA0B;AAExD;;;;;;;;;;;;;;;;;;;;CAoBC,GAED;;;;;;CAMC,GACD,OAAO,MAAMC,2BAA2B;IAAC;IAAkB;CAAkB,CAAS;AAEtF,sCAAsC,GACtC,OAAO,SAASC,oBACdC,OAA4C,EAC5CC,QAAgB;IAEhB,OAAOD,QAAQE,UAAU,CAAC,yBAAyBC,GAAG,CAACF;AACzD;AAEA,0DAA0D,GAC1D,SAASG,OACPC,QAA+D;IAE/D,IAAI,CAACA,YAAYA,SAASC,MAAM,KAAK,OAAO,OAAOC;IACnD,MAAMC,QAAQH,SAASI,GAAG,CAAC;IAC3B,OAAO,OAAOD,UAAU,YAAYA,QAAQA,QAAQD;AACtD;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASG,mBACdC,UAAiE,EACjEC,MAA6D;QAEtDR;IAAP,QAAOA,UAAAA,OAAOO,uBAAPP,UAAsBA,OAAOQ;AACtC;AAEA,sEAAsE,GACtE,OAAO,eAAeC,uBACpBb,OAA4C,EAC5CY,MAA0C;IAE1C,MAAMD,aAAa,MAAMZ,oBAAoBC,SAASY,OAAOE,EAAE,EAAEL,GAAG;IACpE,OAAOC,mBAAmBC,YAAYC;AACxC;AAEA;;;;CAIC,GACD,OAAO,SAASG;IAId,MAAMC,SAASnB,cAAcoB,SAAS,CAACC,UAAU,CAACC,MAAM;IACxD,OAAO;QAAEC,gBAAgBJ;QAAQK,iBAAiBL;IAAO;AAC3D;AAEA;;;;;;CAMC,GACD,OAAO,SAASM,mBAAmBrB,QAAgB;IACjD,OACEA,SAASsB,MAAM,GAAG,KAClBtB,SAASsB,MAAM,IAAI,QACnB,CAACtB,SAASuB,QAAQ,CAAC,QACnBvB,aAAa,OACbA,aAAa;AAEjB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-credentials.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 { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * WHERE A SITE MEMBER'S PASSWORD LIVES (AGL-3308).\n *\n * `hosts/{hostId}/siteMemberCredentials/{memberId}`, keyed by the member's\n * own id, beside the profile at `hosts/{hostId}/siteMembers/{memberId}`.\n *\n * The profile is what the console lists — the Users page, the Inbox, the\n * dashboard's newest members — so every member of the site reads it, viewers\n * included. A `salt:scrypt` hash on it was readable by all of them, and the\n * host catch-all let any content writer replace it and sign in as the\n * member. Firestore rules cannot hide one field of a readable document, so\n * the hash moved to a document no client reaches at all: the rules deny this\n * collection to every client, staff included, and only these server routes\n * read or write it.\n *\n * A `passwordScrypt` on the PROFILE answers for nothing. That was where the\n * hash lived before AGL-3308, and a one-shot migration moved every copy\n * across (2026-09-24). No route reads one there now, so a copy written onto a\n * profile, by staff or by a stale script, cannot open an account; each writer\n * here still deletes the field as it writes the credential document, and the\n * rules refuse a client that tries to set it.\n */\n\n/**\n * The fields that belong on the credential document, and that a writer\n * deletes from the profile when it writes one.\n *\n * `passwordResetAt` travels with the hash: it says when the password was last\n * reset, which is a fact about the credential and nothing the console shows.\n */\nexport const MEMBER_CREDENTIAL_FIELDS = ['passwordScrypt', 'passwordResetAt'] as const\n\n/** The member's credential document. */\nexport function memberCredentialRef(\n hostRef: FirebaseFirestore.DocumentReference,\n memberId: string,\n): FirebaseFirestore.DocumentReference {\n return hostRef.collection('siteMemberCredentials').doc(memberId)\n}\n\n/** A usable stored hash: a non-empty string, or nothing. */\nfunction hashOn(\n snapshot: FirebaseFirestore.DocumentSnapshot | null | undefined,\n): string | undefined {\n if (!snapshot || snapshot.exists === false) return undefined\n const value = snapshot.get('passwordScrypt')\n return typeof value === 'string' && value ? value : undefined\n}\n\n/**\n * The hash a password is checked against: the credential document's, or none.\n *\n * Every flow that checks a password or binds a reset token to one asks this,\n * so the two can never disagree about which hash is current. A member with no\n * credential document has no password — sign-in refuses them, and a reset\n * link lets them set one.\n */\nexport function storedPasswordHash(\n credential: FirebaseFirestore.DocumentSnapshot | null | undefined,\n): string | undefined {\n return hashOn(credential)\n}\n\n/** {@link storedPasswordHash} for a member document already in hand. */\nexport async function readMemberPasswordHash(\n hostRef: FirebaseFirestore.DocumentReference,\n member: FirebaseFirestore.DocumentSnapshot,\n): Promise<string | undefined> {\n const credential = await memberCredentialRef(hostRef, member.id).get()\n return storedPasswordHash(credential)\n}\n\n/**\n * The profile patch that deletes any credential field from it — spread into\n * the same write that sets the credential document, so a hash never sits on\n * the document every member of the site can read.\n */\nexport function retiredCredentialFields(): Record<\n (typeof MEMBER_CREDENTIAL_FIELDS)[number],\n FirebaseFirestore.FieldValue\n> {\n const remove = firebaseAdmin.firestore.FieldValue.delete()\n return { passwordScrypt: remove, passwordResetAt: remove }\n}\n\n/**\n * Whether a client-supplied member id names ONE document.\n *\n * `CollectionReference.doc()` appends a slash-separated PATH, so an id like\n * `a/b/c` would address a document nested under another member — the cart\n * cookie lesson of AGL-1763. Console routes take the id from a request body.\n */\nexport function isMemberDocumentId(memberId: string): boolean {\n return (\n memberId.length > 0 &&\n memberId.length <= 1500 &&\n !memberId.includes('/') &&\n memberId !== '.' &&\n memberId !== '..'\n )\n}\n"],"names":["firebaseAdmin","MEMBER_CREDENTIAL_FIELDS","memberCredentialRef","hostRef","memberId","collection","doc","hashOn","snapshot","exists","undefined","value","get","storedPasswordHash","credential","readMemberPasswordHash","member","id","retiredCredentialFields","remove","firestore","FieldValue","delete","passwordScrypt","passwordResetAt","isMemberDocumentId","length","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,2BAA0B;AAExD;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED;;;;;;CAMC,GACD,OAAO,MAAMC,2BAA2B;IAAC;IAAkB;CAAkB,CAAS;AAEtF,sCAAsC,GACtC,OAAO,SAASC,oBACdC,OAA4C,EAC5CC,QAAgB;IAEhB,OAAOD,QAAQE,UAAU,CAAC,yBAAyBC,GAAG,CAACF;AACzD;AAEA,0DAA0D,GAC1D,SAASG,OACPC,QAA+D;IAE/D,IAAI,CAACA,YAAYA,SAASC,MAAM,KAAK,OAAO,OAAOC;IACnD,MAAMC,QAAQH,SAASI,GAAG,CAAC;IAC3B,OAAO,OAAOD,UAAU,YAAYA,QAAQA,QAAQD;AACtD;AAEA;;;;;;;CAOC,GACD,OAAO,SAASG,mBACdC,UAAiE;IAEjE,OAAOP,OAAOO;AAChB;AAEA,sEAAsE,GACtE,OAAO,eAAeC,uBACpBZ,OAA4C,EAC5Ca,MAA0C;IAE1C,MAAMF,aAAa,MAAMZ,oBAAoBC,SAASa,OAAOC,EAAE,EAAEL,GAAG;IACpE,OAAOC,mBAAmBC;AAC5B;AAEA;;;;CAIC,GACD,OAAO,SAASI;IAId,MAAMC,SAASnB,cAAcoB,SAAS,CAACC,UAAU,CAACC,MAAM;IACxD,OAAO;QAAEC,gBAAgBJ;QAAQK,iBAAiBL;IAAO;AAC3D;AAEA;;;;;;CAMC,GACD,OAAO,SAASM,mBAAmBrB,QAAgB;IACjD,OACEA,SAASsB,MAAM,GAAG,KAClBtB,SAASsB,MAAM,IAAI,QACnB,CAACtB,SAASuB,QAAQ,CAAC,QACnBvB,aAAa,OACbA,aAAa;AAEjB"}
@@ -32,7 +32,7 @@ import type { PluginApiHandler } from '@aglyn/aglyn/server';
32
32
  * console route, so it is registered on the console's API surface.
33
33
  *
34
34
  * The hash is written to the member's credential document (AGL-3308), which
35
- * no client can read, and the legacy copy on the profile goes in the same
36
- * batch.
35
+ * no client can read, and any credential field on the profile is deleted in
36
+ * the same batch.
37
37
  */
38
38
  export declare const membershipAdminPasswordHandler: PluginApiHandler;
@@ -38,8 +38,8 @@ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retire
38
38
  * console route, so it is registered on the console's API surface.
39
39
  *
40
40
  * The hash is written to the member's credential document (AGL-3308), which
41
- * no client can read, and the legacy copy on the profile goes in the same
42
- * batch.
41
+ * no client can read, and any credential field on the profile is deleted in
42
+ * the same batch.
43
43
  */ export const membershipAdminPasswordHandler = async (req, res)=>{
44
44
  var _req_headers_authorization, _ref, _ref1, _ref2;
45
45
  var _req_body, _req_body1, _req_body2;
@@ -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 the legacy copy on the profile goes in the same\n * 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 })\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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"}
@@ -60,8 +60,7 @@ const MAX_ATTEMPTS = 10;
60
60
  const hostRef = firestore.collection('hosts').doc(hostId);
61
61
  const membersQuery = await hostRef.collection('siteMembers').where('email', '==', email).limit(1).get();
62
62
  const memberDoc = membersQuery.docs[0];
63
- // The credential document's hash, else the legacy copy on the profile
64
- // (AGL-3308) — see member-credentials.ts.
63
+ // The credential document's hash (AGL-3308) — see member-credentials.ts.
65
64
  const passwordScrypt = memberDoc ? await readMemberPasswordHash(hostRef, memberDoc) : undefined;
66
65
  if (!memberDoc || !verifyMemberPassword(password, passwordScrypt)) {
67
66
  return res.status(401).json({
@@ -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, else the legacy copy on the profile\n // (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(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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"}
@@ -174,8 +174,7 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
174
174
  ok: true
175
175
  });
176
176
  }
177
- // Bound to the hash sign-in checks: the credential document's, else the
178
- // legacy copy on the profile (AGL-3308).
177
+ // Bound to the hash sign-in checks, the credential document's (AGL-3308).
179
178
  const token = mintPasswordResetToken(hostId, memberDoc.id, await readMemberPasswordHash(hostRef, memberDoc));
180
179
  // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is
181
180
  // minted server-side and mailed out, so a wrong apex is not a display
@@ -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, else the\n // legacy copy on the profile (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 })\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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"}
@@ -68,7 +68,7 @@ import { isMemberDocumentId, memberCredentialRef, retiredCredentialFields, store
68
68
  const outcome = await firestore.runTransaction(async (tx)=>{
69
69
  const [memberDoc, credentialDoc] = await tx.getAll(memberRef, credentialRef);
70
70
  if (!memberDoc.exists) return 'invalid';
71
- if (!verifyPasswordResetToken(hostId, token, storedPasswordHash(credentialDoc, memberDoc))) {
71
+ if (!verifyPasswordResetToken(hostId, token, storedPasswordHash(credentialDoc))) {
72
72
  return 'invalid';
73
73
  }
74
74
  // Suspension gate (AGL-546/550): a token minted before the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-reset.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 {\n MEMBER_SUSPENDED_ERROR,\n hashMemberPassword,\n passwordResetTokenMemberId,\n verifyPasswordResetToken,\n} from './membership'\nimport {\n isMemberDocumentId,\n memberCredentialRef,\n retiredCredentialFields,\n storedPasswordHash,\n} from './member-credentials'\n\n/**\n * Password reset completion (AGL-552): verifies the recover token\n * (signature + expiry + binding to the member's CURRENT password hash —\n * so a completed reset invalidates every outstanding token) and writes the\n * new scrypt hash to the member's credential document (AGL-3308). No session\n * is minted here; the client signs in through the normal login route with the\n * new password. Token guessing is a non-issue (256-bit HMAC), so no extra\n * damper beyond login's.\n *\n * ONE TRANSACTION, from the token check to the write. The hash the token is\n * checked against is the hash the write replaces, so two submissions of one\n * link cannot both land — the second re-reads the new hash and fails the\n * binding — and a member removed mid-request is not recreated as a stub.\n */\nexport const membershipResetHandler: 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 token = String(req.body?.token ?? '')\n const password = String(req.body?.password ?? '')\n if (!hostId || !token) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n const invalid = () =>\n res.status(400).json({\n error: 'That reset link is invalid or has expired — request a new one.',\n })\n // Structural pre-check only (host + expiry); the signature binds to the\n // member's current hash, which we have to load first.\n const memberId = passwordResetTokenMemberId(hostId, token)\n if (!memberId || !isMemberDocumentId(memberId)) return invalid()\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const memberRef = hostRef.collection('siteMembers').doc(memberId)\n const credentialRef = memberCredentialRef(hostRef, memberId)\n // Hashed once, however often the body retries, and only for a link that\n // verified: ~100 ms of CPU is not spent on a stale or forged token.\n let newHash: string | undefined\n const outcome = await firestore.runTransaction(async (tx) => {\n const [memberDoc, credentialDoc] = await tx.getAll(memberRef, credentialRef)\n if (!memberDoc.exists) return 'invalid'\n if (\n !verifyPasswordResetToken(\n hostId,\n token,\n storedPasswordHash(credentialDoc, memberDoc),\n )\n ) {\n return 'invalid'\n }\n // Suspension gate (AGL-546/550): a token minted before the\n // suspension stays cryptographically valid for its hour, so reject\n // here — recovery must not rehabilitate a suspended account. Checked\n // AFTER the signature so only the mailed-link holder (who already\n // knows the account exists) can observe the suspension.\n if (memberDoc.get('suspended') === true) return 'suspended'\n newHash ??= hashMemberPassword(password)\n tx.set(\n credentialRef,\n {\n passwordScrypt: newHash,\n passwordResetAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n tx.update(memberRef, retiredCredentialFields())\n return 'reset'\n })\n if (outcome === 'invalid') return invalid()\n if (outcome === 'suspended') {\n return res.status(403).json({ error: MEMBER_SUSPENDED_ERROR })\n }\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Reset failed' })\n }\n}\n"],"names":["firebaseAdmin","MEMBER_SUSPENDED_ERROR","hashMemberPassword","passwordResetTokenMemberId","verifyPasswordResetToken","isMemberDocumentId","memberCredentialRef","retiredCredentialFields","storedPasswordHash","membershipResetHandler","req","res","method","status","json","error","hostId","String","body","token","password","length","invalid","memberId","firestore","app","hostRef","collection","doc","memberRef","credentialRef","newHash","outcome","runTransaction","tx","memberDoc","credentialDoc","getAll","exists","get","set","passwordScrypt","passwordResetAt","FieldValue","serverTimestamp","merge","update","ok","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-reset.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 {\n MEMBER_SUSPENDED_ERROR,\n hashMemberPassword,\n passwordResetTokenMemberId,\n verifyPasswordResetToken,\n} from './membership'\nimport {\n isMemberDocumentId,\n memberCredentialRef,\n retiredCredentialFields,\n storedPasswordHash,\n} from './member-credentials'\n\n/**\n * Password reset completion (AGL-552): verifies the recover token\n * (signature + expiry + binding to the member's CURRENT password hash —\n * so a completed reset invalidates every outstanding token) and writes the\n * new scrypt hash to the member's credential document (AGL-3308). No session\n * is minted here; the client signs in through the normal login route with the\n * new password. Token guessing is a non-issue (256-bit HMAC), so no extra\n * damper beyond login's.\n *\n * ONE TRANSACTION, from the token check to the write. The hash the token is\n * checked against is the hash the write replaces, so two submissions of one\n * link cannot both land — the second re-reads the new hash and fails the\n * binding — and a member removed mid-request is not recreated as a stub.\n */\nexport const membershipResetHandler: 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 token = String(req.body?.token ?? '')\n const password = String(req.body?.password ?? '')\n if (!hostId || !token) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n const invalid = () =>\n res.status(400).json({\n error: 'That reset link is invalid or has expired — request a new one.',\n })\n // Structural pre-check only (host + expiry); the signature binds to the\n // member's current hash, which we have to load first.\n const memberId = passwordResetTokenMemberId(hostId, token)\n if (!memberId || !isMemberDocumentId(memberId)) return invalid()\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const memberRef = hostRef.collection('siteMembers').doc(memberId)\n const credentialRef = memberCredentialRef(hostRef, memberId)\n // Hashed once, however often the body retries, and only for a link that\n // verified: ~100 ms of CPU is not spent on a stale or forged token.\n let newHash: string | undefined\n const outcome = await firestore.runTransaction(async (tx) => {\n const [memberDoc, credentialDoc] = await tx.getAll(memberRef, credentialRef)\n if (!memberDoc.exists) return 'invalid'\n if (\n !verifyPasswordResetToken(\n hostId,\n token,\n storedPasswordHash(credentialDoc),\n )\n ) {\n return 'invalid'\n }\n // Suspension gate (AGL-546/550): a token minted before the\n // suspension stays cryptographically valid for its hour, so reject\n // here — recovery must not rehabilitate a suspended account. Checked\n // AFTER the signature so only the mailed-link holder (who already\n // knows the account exists) can observe the suspension.\n if (memberDoc.get('suspended') === true) return 'suspended'\n newHash ??= hashMemberPassword(password)\n tx.set(\n credentialRef,\n {\n passwordScrypt: newHash,\n passwordResetAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n tx.update(memberRef, retiredCredentialFields())\n return 'reset'\n })\n if (outcome === 'invalid') return invalid()\n if (outcome === 'suspended') {\n return res.status(403).json({ error: MEMBER_SUSPENDED_ERROR })\n }\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Reset failed' })\n }\n}\n"],"names":["firebaseAdmin","MEMBER_SUSPENDED_ERROR","hashMemberPassword","passwordResetTokenMemberId","verifyPasswordResetToken","isMemberDocumentId","memberCredentialRef","retiredCredentialFields","storedPasswordHash","membershipResetHandler","req","res","method","status","json","error","hostId","String","body","token","password","length","invalid","memberId","firestore","app","hostRef","collection","doc","memberRef","credentialRef","newHash","outcome","runTransaction","tx","memberDoc","credentialDoc","getAll","exists","get","set","passwordScrypt","passwordResetAt","FieldValue","serverTimestamp","merge","update","ok","console"],"mappings":"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"}