@aglyn/plugins-commerce 1.0.0-beta.183 → 1.0.0-beta.185

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/README.md CHANGED
@@ -48,7 +48,7 @@ All console pages and cards are code-split.
48
48
  `@aglyn/plugins-commerce/server` pulls in `firebase-admin` and Stripe and is kept out of the client entry point.
49
49
 
50
50
  - `registerCommerceApi()` (the `tenantApi` surface) registers the storefront routes under `commerce/` (catalog, product, related, reviews, cart, cart checkout, checkout, download, feed, newsletter, restock notifications, order analytics, reservations, gate, member feed, stream, subscription portal) and the account routes under `membership/` (register, login, logout, recover, reset, account, content, wishlist). It also registers a site page resolver and enricher, the commerce tax profile and a product card reader on core seams.
51
- - `registerCommerceConsoleApi()` (the `consoleApi` surface) registers the merchant routes: cancel, fulfill and refund an order, draft orders, POS orders, gift cards, member posts, Stripe Connect, supplier updates, and the abandoned-checkout and restock processors. It also adds a handler on the platform billing webhook and order figure readers.
51
+ - `registerCommerceConsoleApi()` (the `consoleApi` surface) registers the merchant routes: cancel, fulfill and refund an order, draft orders, POS orders, gift cards, member posts, a site member's password help and removal (`membership/admin-password`, `membership/admin-remove`), Stripe Connect, supplier updates, and the abandoned-checkout and restock processors. It also adds a handler on the platform billing webhook and order figure readers.
52
52
  - Loading the module registers scheduled plugin jobs: `abandoned-checkout-recovery`, `back-in-stock-alerts`, `stock-decrement-reconciliation` and `supplier-webhook-delivery`. This is why `package.json` lists `./src/lib/server.*` under `sideEffects`.
53
53
 
54
54
  Routes are served by the host app's API dispatcher under `/api/`, for example `/api/commerce/catalog`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-commerce",
3
- "version": "1.0.0-beta.183",
3
+ "version": "1.0.0-beta.185",
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.183",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.183",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.183",
31
- "@aglyn/shared-ui-next": "1.0.0-beta.183",
32
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.183",
33
- "@aglyn/shared-util-email": "1.0.0-beta.183",
34
- "@aglyn/shared-util-http": "1.0.0-beta.183",
35
- "@aglyn/shared-util-timestamp": "1.0.0-beta.183",
36
- "@aglyn/shared-util-tools": "1.0.0-beta.183",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.183",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.183",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.183",
28
+ "@aglyn/aglyn": "1.0.0-beta.185",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.185",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.185",
31
+ "@aglyn/shared-ui-next": "1.0.0-beta.185",
32
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.185",
33
+ "@aglyn/shared-util-email": "1.0.0-beta.185",
34
+ "@aglyn/shared-util-http": "1.0.0-beta.185",
35
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.185",
36
+ "@aglyn/shared-util-tools": "1.0.0-beta.185",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.185",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.185",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.185",
40
40
  "@stripe/react-stripe-js": "^3.10.0",
41
41
  "@stripe/stripe-js": "^5.10.0",
42
42
  "@swc/helpers": "0.5.23",
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * WHERE A SITE MEMBER'S PASSWORD LIVES (AGL-3308).
19
+ *
20
+ * `hosts/{hostId}/siteMemberCredentials/{memberId}`, keyed by the member's
21
+ * own id, beside the profile at `hosts/{hostId}/siteMembers/{memberId}`.
22
+ *
23
+ * The profile is what the console lists — the Users page, the Inbox, the
24
+ * dashboard's newest members — so every member of the site reads it, viewers
25
+ * included. A `salt:scrypt` hash on it was readable by all of them, and the
26
+ * host catch-all let any content writer replace it and sign in as the
27
+ * member. Firestore rules cannot hide one field of a readable document, so
28
+ * the hash moved to a document no client reaches at all: the rules deny this
29
+ * collection to every client, staff included, and only these server routes
30
+ * read or write it.
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.
37
+ */
38
+ /**
39
+ * The fields that belong on the credential document, and that a writer
40
+ * deletes from the profile when it writes one.
41
+ *
42
+ * `passwordResetAt` travels with the hash: it says when the password was last
43
+ * reset, which is a fact about the credential and nothing the console shows.
44
+ */
45
+ export declare const MEMBER_CREDENTIAL_FIELDS: readonly ["passwordScrypt", "passwordResetAt"];
46
+ /** The member's credential document. */
47
+ export declare function memberCredentialRef(hostRef: FirebaseFirestore.DocumentReference, memberId: string): FirebaseFirestore.DocumentReference;
48
+ /**
49
+ * The hash a password is checked against: the credential document's, else the
50
+ * legacy copy on the profile.
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
+ * 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.
56
+ */
57
+ export declare function storedPasswordHash(credential: FirebaseFirestore.DocumentSnapshot | null | undefined, member: FirebaseFirestore.DocumentSnapshot | null | undefined): string | undefined;
58
+ /** {@link storedPasswordHash} for a member document already in hand. */
59
+ export declare function readMemberPasswordHash(hostRef: FirebaseFirestore.DocumentReference, member: FirebaseFirestore.DocumentSnapshot): Promise<string | undefined>;
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.
64
+ */
65
+ export declare function retiredCredentialFields(): Record<(typeof MEMBER_CREDENTIAL_FIELDS)[number], FirebaseFirestore.FieldValue>;
66
+ /**
67
+ * Whether a client-supplied member id names ONE document.
68
+ *
69
+ * `CollectionReference.doc()` appends a slash-separated PATH, so an id like
70
+ * `a/b/c` would address a document nested under another member — the cart
71
+ * cookie lesson of AGL-1763. Console routes take the id from a request body.
72
+ */
73
+ export declare function isMemberDocumentId(memberId: string): boolean;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
17
+ /**
18
+ * WHERE A SITE MEMBER'S PASSWORD LIVES (AGL-3308).
19
+ *
20
+ * `hosts/{hostId}/siteMemberCredentials/{memberId}`, keyed by the member's
21
+ * own id, beside the profile at `hosts/{hostId}/siteMembers/{memberId}`.
22
+ *
23
+ * The profile is what the console lists — the Users page, the Inbox, the
24
+ * dashboard's newest members — so every member of the site reads it, viewers
25
+ * included. A `salt:scrypt` hash on it was readable by all of them, and the
26
+ * host catch-all let any content writer replace it and sign in as the
27
+ * member. Firestore rules cannot hide one field of a readable document, so
28
+ * the hash moved to a document no client reaches at all: the rules deny this
29
+ * collection to every client, staff included, and only these server routes
30
+ * read or write it.
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.
37
+ */ /**
38
+ * The fields that belong on the credential document, and that a writer
39
+ * deletes from the profile when it writes one.
40
+ *
41
+ * `passwordResetAt` travels with the hash: it says when the password was last
42
+ * reset, which is a fact about the credential and nothing the console shows.
43
+ */ export const MEMBER_CREDENTIAL_FIELDS = [
44
+ 'passwordScrypt',
45
+ 'passwordResetAt'
46
+ ];
47
+ /** The member's credential document. */ export function memberCredentialRef(hostRef, memberId) {
48
+ return hostRef.collection('siteMemberCredentials').doc(memberId);
49
+ }
50
+ /** A usable stored hash: a non-empty string, or nothing. */ function hashOn(snapshot) {
51
+ if (!snapshot || snapshot.exists === false) return undefined;
52
+ const value = snapshot.get('passwordScrypt');
53
+ return typeof value === 'string' && value ? value : undefined;
54
+ }
55
+ /**
56
+ * The hash a password is checked against: the credential document's, else the
57
+ * legacy copy on the profile.
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
+ * 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);
66
+ }
67
+ /** {@link storedPasswordHash} for a member document already in hand. */ export async function readMemberPasswordHash(hostRef, member) {
68
+ const credential = await memberCredentialRef(hostRef, member.id).get();
69
+ return storedPasswordHash(credential, member);
70
+ }
71
+ /**
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.
75
+ */ export function retiredCredentialFields() {
76
+ const remove = firebaseAdmin.firestore.FieldValue.delete();
77
+ return {
78
+ passwordScrypt: remove,
79
+ passwordResetAt: remove
80
+ };
81
+ }
82
+ /**
83
+ * Whether a client-supplied member id names ONE document.
84
+ *
85
+ * `CollectionReference.doc()` appends a slash-separated PATH, so an id like
86
+ * `a/b/c` would address a document nested under another member — the cart
87
+ * cookie lesson of AGL-1763. Console routes take the id from a request body.
88
+ */ export function isMemberDocumentId(memberId) {
89
+ return memberId.length > 0 && memberId.length <= 1500 && !memberId.includes('/') && memberId !== '.' && memberId !== '..';
90
+ }
91
+
92
+ //# sourceMappingURL=member-credentials.js.map
@@ -0,0 +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"}
@@ -17,7 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  */ import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail } from "@aglyn/tenant-data-admin";
18
18
  import * as CommerceModel from "../model/index.js";
19
19
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
20
- import { EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveBrandingProfile } from "@aglyn/aglyn/server";
20
+ import { consentGroupForHost, EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveBrandingProfile } from "@aglyn/aglyn/server";
21
21
  // Which member subscribers are live enough to email (AGL-316). The list itself
22
22
  // now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a
23
23
  // private copy here and a second private copy in `gate.ts`, which is the drift
@@ -79,8 +79,7 @@ import { EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveBrandingProfile } from
79
79
  }));
80
80
  let emailed = 0;
81
81
  if (emailSubscribers && isEmailConfigured()) {
82
- var _hostPublicOrigin;
83
- var _this;
82
+ var _ref, _hostPublicOrigin;
84
83
  /*
85
84
  * ORDERED (D1). This was `limit(500)` with no `orderBy`, and Firestore
86
85
  * answers an unordered limit in DOCUMENT-ID order — so a site past the
@@ -94,7 +93,11 @@ import { EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveBrandingProfile } from
94
93
  ].slice(0, 200);
95
94
  // White-label sender identity (White-Label Phase 3): the store's brand
96
95
  // via the one shared resolver, from the owning org doc.
97
- const branding = resolveBrandingProfile((_this = await getOrgForHost(hostId).catch(()=>null)) == null ? void 0 : _this.org);
96
+ const owner = await getOrgForHost(hostId).catch(()=>null);
97
+ const branding = resolveBrandingProfile(owner == null ? void 0 : owner.org);
98
+ // The site's consent group, off the same org read: a member who left
99
+ // any site the org declared one sender with this one is not mailed.
100
+ const consentHostIds = consentGroupForHost((_ref = owner == null ? void 0 : owner.org) != null ? _ref : null, hostId).hostIds;
98
101
  // The site's own origin, for the unsubscribe link the gate mints. Read
99
102
  // from the host document already in hand rather than assembled from an
100
103
  // apex: a post is mailed and read later, and a wrong origin sends a
@@ -137,7 +140,8 @@ import { EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveBrandingProfile } from
137
140
  */ marketing: {
138
141
  hostId,
139
142
  siteBase,
140
- topicId: EMAIL_TOPIC_NEWSLETTER
143
+ topicId: EMAIL_TOPIC_NEWSLETTER,
144
+ consentHostIds
141
145
  }
142
146
  });
143
147
  // The cost meter counts messages that LEFT. A suppressed or capped
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-post.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n EMAIL_TOPIC_NEWSLETTER,\n hostPublicOrigin,\n type PluginApiHandler,\n resolveBrandingProfile,\n} from '@aglyn/aglyn/server'\n\n// Which member subscribers are live enough to email (AGL-316). The list itself\n// now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a\n// private copy here and a second private copy in `gate.ts`, which is the drift\n// AGL-1715 guards against on the org side.\n\n/**\n * Member post publish (AGL-316): manager-gated; writes the post and\n * optionally emails the entitled subscribers (product-scoped or all\n * live subscribers) through the env-gated Resend pipeline.\n */\nexport const memberPostHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const title = String(body.title ?? '').trim().slice(0, 160)\n const postBody = String(body.body ?? '').trim().slice(0, 5000)\n const productId = String(body.productId ?? '').trim()\n const emailSubscribers = Boolean(body.emailSubscribers)\n if (!hostId || !title) {\n return res.status(400).json({ error: 'Missing hostId or title' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n // AN ALLOWLIST (AGL-2372), matching `gift-cards.ts` and the AGL-2262\n // correction to the register. The old `!role || role === 'viewer'`\n // denylist admitted every string that was not literally `viewer` — and\n // since AGL-2334 that includes `author`, a real grantable role, on a route\n // that publishes to paying subscribers and sends them mail on the\n // merchant's brand. A denylist that widens whenever the role union grows\n // is the defect; the set is the fix. Absent is refused, not permitted.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n const postRef = await hostRef.collection('memberPosts').add({\n title,\n body: postBody,\n ...(productId ? { productId } : {}),\n createdAtMs: Date.now(),\n createdBy: decoded.uid,\n })\n\n let emailed = 0\n if (emailSubscribers && isEmailConfigured()) {\n /*\n * ORDERED (D1). This was `limit(500)` with no `orderBy`, and Firestore\n * answers an unordered limit in DOCUMENT-ID order — so a site past the\n * ceiling mailed whichever five hundred subscriptions happened to hash\n * low, and the same five hundred every time. `__name__` is the order it\n * was already getting; stating it makes the window a decision rather\n * than an accident, and makes it the same window on every publish.\n */\n const subscriptions = await hostRef\n .collection('subscriptions')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(500)\n .get()\n const recipients = [\n ...new Set(\n subscriptions.docs\n .filter(\n (docSnapshot) =>\n CommerceModel.isTenantSubscriptionLive(\n docSnapshot.get('status'),\n ) &&\n (!productId ||\n docSnapshot.get('productId') === productId) &&\n docSnapshot.get('customerEmail'),\n )\n .map((docSnapshot) => String(docSnapshot.get('customerEmail'))),\n ),\n ].slice(0, 200)\n // White-label sender identity (White-Label Phase 3): the store's brand\n // via the one shared resolver, from the owning org doc.\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n // The site's own origin, for the unsubscribe link the gate mints. Read\n // from the host document already in hand rather than assembled from an\n // apex: a post is mailed and read later, and a wrong origin sends a\n // subscriber's opt-out to a domain the merchant does not control.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n /*\n * ONE site, up to two hundred recipients — so the identity is resolved\n * once and reused, not read per message. Hoisted rather than cached\n * inside the helper for the reason its docblock gives: verification can\n * change under a long-lived process, so how long a resolution may be\n * trusted is the caller's declaration.\n */\n const identity = await hostSendingIdentity(hostId)\n for (const to of recipients) {\n /*\n * MARKETING. A member post is a merchant mailing their audience, so\n * it carries the unsubscribe header pair and a visible link, it is\n * checked against both suppression lists, and it counts against how\n * much mail one person receives from this site.\n *\n * Priority stays transactional — this handler answers a click and has\n * nowhere to put a message the hourly governor deferred, and the rule\n * on `'bulk'` is that only a resumable sweep may use it.\n */\n const result = await sendEmail({\n to,\n subject: title,\n text: postBody || title,\n fromName: branding.fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'member post',\n /*\n * A post to a site's members is the \"Newsletter\" stream — regular\n * news and stories from the site. Declared so a member who left\n * that stream, while staying subscribed to everything else, is not\n * mailed it; the gate asks the topic only for callers that name\n * one.\n */\n marketing: {\n hostId,\n siteBase,\n topicId: EMAIL_TOPIC_NEWSLETTER,\n },\n })\n // The cost meter counts messages that LEFT. A suppressed or capped\n // recipient produced no message and therefore no cost, and counting\n // one would bill a merchant for the control working.\n if (result.sent) emailed += 1\n }\n // Cost meter (AGL-1438), once for the batch rather than per recipient —\n // same number, one write. Transactional: a member post is content the\n // subscriber is paying for, not a discretionary campaign, so it counts\n // toward cost and is never refused by the campaign cap.\n await meterHostEmail(hostId, emailed)\n }\n return res.status(200).json({ postId: postRef.id, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","CommerceModel","isEmailConfigured","sendEmail","EMAIL_TOPIC_NEWSLETTER","hostPublicOrigin","resolveBrandingProfile","memberPostHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","title","trim","postBody","productId","emailSubscribers","Boolean","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","memberRole","uid","postRef","add","createdAtMs","Date","now","createdBy","emailed","subscriptions","orderBy","FieldPath","documentId","limit","recipients","Set","docs","filter","docSnapshot","isTenantSubscriptionLive","map","branding","catch","org","siteBase","cname","subdomain","identity","to","result","subject","text","fromName","sendingIdentity","audience","context","marketing","topicId","sent","postId","id","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-post.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n consentGroupForHost,\n EMAIL_TOPIC_NEWSLETTER,\n hostPublicOrigin,\n type PluginApiHandler,\n resolveBrandingProfile,\n} from '@aglyn/aglyn/server'\n\n// Which member subscribers are live enough to email (AGL-316). The list itself\n// now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a\n// private copy here and a second private copy in `gate.ts`, which is the drift\n// AGL-1715 guards against on the org side.\n\n/**\n * Member post publish (AGL-316): manager-gated; writes the post and\n * optionally emails the entitled subscribers (product-scoped or all\n * live subscribers) through the env-gated Resend pipeline.\n */\nexport const memberPostHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const title = String(body.title ?? '').trim().slice(0, 160)\n const postBody = String(body.body ?? '').trim().slice(0, 5000)\n const productId = String(body.productId ?? '').trim()\n const emailSubscribers = Boolean(body.emailSubscribers)\n if (!hostId || !title) {\n return res.status(400).json({ error: 'Missing hostId or title' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n // AN ALLOWLIST (AGL-2372), matching `gift-cards.ts` and the AGL-2262\n // correction to the register. The old `!role || role === 'viewer'`\n // denylist admitted every string that was not literally `viewer` — and\n // since AGL-2334 that includes `author`, a real grantable role, on a route\n // that publishes to paying subscribers and sends them mail on the\n // merchant's brand. A denylist that widens whenever the role union grows\n // is the defect; the set is the fix. Absent is refused, not permitted.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n const postRef = await hostRef.collection('memberPosts').add({\n title,\n body: postBody,\n ...(productId ? { productId } : {}),\n createdAtMs: Date.now(),\n createdBy: decoded.uid,\n })\n\n let emailed = 0\n if (emailSubscribers && isEmailConfigured()) {\n /*\n * ORDERED (D1). This was `limit(500)` with no `orderBy`, and Firestore\n * answers an unordered limit in DOCUMENT-ID order — so a site past the\n * ceiling mailed whichever five hundred subscriptions happened to hash\n * low, and the same five hundred every time. `__name__` is the order it\n * was already getting; stating it makes the window a decision rather\n * than an accident, and makes it the same window on every publish.\n */\n const subscriptions = await hostRef\n .collection('subscriptions')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(500)\n .get()\n const recipients = [\n ...new Set(\n subscriptions.docs\n .filter(\n (docSnapshot) =>\n CommerceModel.isTenantSubscriptionLive(\n docSnapshot.get('status'),\n ) &&\n (!productId ||\n docSnapshot.get('productId') === productId) &&\n docSnapshot.get('customerEmail'),\n )\n .map((docSnapshot) => String(docSnapshot.get('customerEmail'))),\n ),\n ].slice(0, 200)\n // White-label sender identity (White-Label Phase 3): the store's brand\n // via the one shared resolver, from the owning org doc.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const branding = resolveBrandingProfile(owner?.org as never)\n // The site's consent group, off the same org read: a member who left\n // any site the org declared one sender with this one is not mailed.\n const consentHostIds = consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n ).hostIds\n // The site's own origin, for the unsubscribe link the gate mints. Read\n // from the host document already in hand rather than assembled from an\n // apex: a post is mailed and read later, and a wrong origin sends a\n // subscriber's opt-out to a domain the merchant does not control.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n /*\n * ONE site, up to two hundred recipients — so the identity is resolved\n * once and reused, not read per message. Hoisted rather than cached\n * inside the helper for the reason its docblock gives: verification can\n * change under a long-lived process, so how long a resolution may be\n * trusted is the caller's declaration.\n */\n const identity = await hostSendingIdentity(hostId)\n for (const to of recipients) {\n /*\n * MARKETING. A member post is a merchant mailing their audience, so\n * it carries the unsubscribe header pair and a visible link, it is\n * checked against both suppression lists, and it counts against how\n * much mail one person receives from this site.\n *\n * Priority stays transactional — this handler answers a click and has\n * nowhere to put a message the hourly governor deferred, and the rule\n * on `'bulk'` is that only a resumable sweep may use it.\n */\n const result = await sendEmail({\n to,\n subject: title,\n text: postBody || title,\n fromName: branding.fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'member post',\n /*\n * A post to a site's members is the \"Newsletter\" stream — regular\n * news and stories from the site. Declared so a member who left\n * that stream, while staying subscribed to everything else, is not\n * mailed it; the gate asks the topic only for callers that name\n * one.\n */\n marketing: {\n hostId,\n siteBase,\n topicId: EMAIL_TOPIC_NEWSLETTER,\n consentHostIds,\n },\n })\n // The cost meter counts messages that LEFT. A suppressed or capped\n // recipient produced no message and therefore no cost, and counting\n // one would bill a merchant for the control working.\n if (result.sent) emailed += 1\n }\n // Cost meter (AGL-1438), once for the batch rather than per recipient —\n // same number, one write. Transactional: a member post is content the\n // subscriber is paying for, not a discretionary campaign, so it counts\n // toward cost and is never refused by the campaign cap.\n await meterHostEmail(hostId, emailed)\n }\n return res.status(200).json({ postId: postRef.id, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","CommerceModel","isEmailConfigured","sendEmail","consentGroupForHost","EMAIL_TOPIC_NEWSLETTER","hostPublicOrigin","resolveBrandingProfile","memberPostHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","title","trim","postBody","productId","emailSubscribers","Boolean","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","memberRole","uid","postRef","add","createdAtMs","Date","now","createdBy","emailed","subscriptions","orderBy","FieldPath","documentId","limit","recipients","Set","docs","filter","docSnapshot","isTenantSubscriptionLive","map","owner","catch","branding","org","consentHostIds","hostIds","siteBase","cname","subdomain","identity","to","result","subject","text","fromName","sendingIdentity","audience","context","marketing","topicId","sent","postId","id","console"],"mappings":"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"}
@@ -28,6 +28,11 @@ import type { PluginApiHandler } from '@aglyn/aglyn/server';
28
28
  *
29
29
  * Auth is the console id-token check `hosts/members` uses (site admin via
30
30
  * the `memberRoles` projection, or an org role carrying manageMembers) — NOT
31
- * the visitor session cookie the rest of the membership endpoints take.
31
+ * the visitor session cookie the rest of the membership endpoints take. A
32
+ * console route, so it is registered on the console's API surface.
33
+ *
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.
32
37
  */
33
38
  export declare const membershipAdminPasswordHandler: PluginApiHandler;
@@ -13,12 +13,14 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { resolveBrandingProfile, validateNewPassword } from "@aglyn/aglyn/server";
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { resolveBrandingProfile, validateNewPassword } from "@aglyn/aglyn/server";
17
18
  import { hostPublicOrigin } from "@aglyn/aglyn/server";
18
19
  import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
19
20
  import { consumePasswordResetSend, firebaseAdmin, getOrgForHost, hostSendingIdentity, isImpersonationSession, meterHostEmail, passwordResetThrottleMessage } from "@aglyn/tenant-data-admin";
20
21
  import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
21
22
  import { hashMemberPassword, MEMBER_SESSIONS_VALID_FROM_FIELD, mintPasswordResetToken } from "./membership.js";
23
+ import { isMemberDocumentId, memberCredentialRef, readMemberPasswordHash, retiredCredentialFields } from "./member-credentials.js";
22
24
  /**
23
25
  * Console-side password help for a SITE MEMBER (AGL-914) — a visitor account
24
26
  * on a published site, not an Aglyn console user.
@@ -32,7 +34,12 @@ import { hashMemberPassword, MEMBER_SESSIONS_VALID_FROM_FIELD, mintPasswordReset
32
34
  *
33
35
  * Auth is the console id-token check `hosts/members` uses (site admin via
34
36
  * the `memberRoles` projection, or an org role carrying manageMembers) — NOT
35
- * the visitor session cookie the rest of the membership endpoints take.
37
+ * the visitor session cookie the rest of the membership endpoints take. A
38
+ * console route, so it is registered on the console's API surface.
39
+ *
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.
36
43
  */ export const membershipAdminPasswordHandler = async (req, res)=>{
37
44
  var _req_headers_authorization, _ref, _ref1, _ref2;
38
45
  var _req_body, _req_body1, _req_body2;
@@ -54,6 +61,11 @@ import { hashMemberPassword, MEMBER_SESSIONS_VALID_FROM_FIELD, mintPasswordReset
54
61
  error: 'Missing hostId or memberId'
55
62
  });
56
63
  }
64
+ if (!isMemberDocumentId(memberId)) {
65
+ return res.status(400).json({
66
+ error: 'Unknown member'
67
+ });
68
+ }
57
69
  if (action !== 'sendPasswordReset' && action !== 'setPassword') {
58
70
  return res.status(400).json({
59
71
  error: 'Unknown action'
@@ -132,7 +144,7 @@ import { hashMemberPassword, MEMBER_SESSIONS_VALID_FROM_FIELD, mintPasswordReset
132
144
  // Same single-use, hash-bound token the self-serve flow mints
133
145
  // (AGL-552) — completing the reset rewrites `passwordScrypt`, which
134
146
  // retires the token without any server-side storage.
135
- const token = mintPasswordResetToken(hostId, memberId, memberSnapshot.get('passwordScrypt'));
147
+ const token = mintPasswordResetToken(hostId, memberId, await readMemberPasswordHash(hostRef, memberSnapshot));
136
148
  const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`;
137
149
  const result = await sendEmail({
138
150
  to: email,
@@ -164,11 +176,21 @@ import { hashMemberPassword, MEMBER_SESSIONS_VALID_FROM_FIELD, mintPasswordReset
164
176
  // Rewriting the hash alone would NOT sign the member out: their session
165
177
  // is an HMAC cookie that never encodes the password (unlike a reset
166
178
  // token, which binds to it on purpose). The cut-off field is what
167
- // actually revokes the old sessions — see readActiveMemberSession.
168
- await memberRef.update({
169
- passwordScrypt: hashMemberPassword(validated.password),
170
- [MEMBER_SESSIONS_VALID_FROM_FIELD]: Date.now()
179
+ // actually revokes the old sessions — see readActiveMemberSession. It
180
+ // stays on the profile, which every session check already reads.
181
+ //
182
+ // One batch: the profile `update` refuses a member removed since the
183
+ // read above, and that refusal takes the credential write with it.
184
+ const batch = firestore.batch();
185
+ batch.set(memberCredentialRef(hostRef, memberId), {
186
+ passwordScrypt: hashMemberPassword(validated.password)
187
+ }, {
188
+ merge: true
171
189
  });
190
+ batch.update(memberRef, _extends({}, retiredCredentialFields(), {
191
+ [MEMBER_SESSIONS_VALID_FROM_FIELD]: Date.now()
192
+ }));
193
+ await batch.commit();
172
194
  // Best-effort: the password has already changed, so a mail failure must
173
195
  // not fail the request and send the admin round again. Report it so
174
196
  // they know to reach the member another way.
@@ -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'\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.\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 (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 memberSnapshot.get('passwordScrypt'),\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.\n await memberRef.update({\n passwordScrypt: hashMemberPassword(validated.password),\n [MEMBER_SESSIONS_VALID_FROM_FIELD]: Date.now(),\n })\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","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","update","passwordScrypt","Date","now","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 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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"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * Remove a SITE MEMBER from the console (AGL-3308): the profile and its
20
+ * credential document, in one batch.
21
+ *
22
+ * The password hash lives in a collection no client can reach, so a client
23
+ * delete of the profile would leave it behind — a credential for an account
24
+ * the site owner removed. The rules therefore refuse a client delete of any
25
+ * member that has a credential document, and this route is the removal.
26
+ *
27
+ * Who may call it is who the rules let delete a profile before it: a site
28
+ * role that writes content (`admin`, `editor` or `author` in the host's
29
+ * `memberRoles`). A frozen site is refused by the dispatcher's lockdown gate
30
+ * before this runs, the half the rules spelled `hostWritesFrozen`.
31
+ *
32
+ * Removing the profile also ends every session the member holds:
33
+ * `readActiveMemberSession` reads a missing profile as signed out.
34
+ *
35
+ * Idempotent. A member already gone answers 200, and a credential document
36
+ * left without its profile is cleared on the way.
37
+ */
38
+ export declare const membershipAdminRemoveHandler: PluginApiHandler;