@aglyn/plugins-commerce 1.0.0-beta.147 → 1.0.0-beta.150
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/server/media-publish-guard.d.ts +29 -0
- package/src/lib/server/media-publish-guard.js +60 -0
- package/src/lib/server/media-publish-guard.js.map +1 -0
- package/src/lib/server/membership-register.js +28 -12
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/newsletter.js +28 -10
- package/src/lib/server/newsletter.js.map +1 -1
- package/src/lib/server/paid-media-uses.d.ts +90 -0
- package/src/lib/server/paid-media-uses.js +119 -0
- package/src/lib/server/paid-media-uses.js.map +1 -0
- package/src/lib/server.js +6 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-record-system.d.ts +34 -0
- package/src/lib/testing/stand-in-record-system.js +51 -0
- package/src/lib/testing/stand-in-record-system.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-commerce",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.150",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.150",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.150",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.150",
|
|
31
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.150",
|
|
32
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.150",
|
|
33
|
+
"@aglyn/shared-util-email": "1.0.0-beta.150",
|
|
34
|
+
"@aglyn/shared-util-http": "1.0.0-beta.150",
|
|
35
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.150",
|
|
36
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.150",
|
|
37
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.150",
|
|
38
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.150",
|
|
39
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.150",
|
|
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,29 @@
|
|
|
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
|
+
* Commerce’s answer to the platform's media-publish question (AGL-3080).
|
|
19
|
+
*
|
|
20
|
+
* The media library used to hold this rule itself, which meant the console
|
|
21
|
+
* knew what a product is, which two of its fields hold paid media, and that a
|
|
22
|
+
* deleted one sells nothing. It asks now, and this is the answer.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ A ceiling that stopped the scan is a REFUSAL with no blocker to name,
|
|
25
|
+
* never a pass: "we could not check" and "nothing sells this" point opposite
|
|
26
|
+
* ways, and only one of them is safe to publish on. The contract says the
|
|
27
|
+
* same thing about a throw, which is why nothing is caught here.
|
|
28
|
+
*/
|
|
29
|
+
export declare function registerCommerceMediaPublishGuard(): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ // Its own subpath, never the plugin-manager barrel: what a published page
|
|
18
|
+
// does not need, it must not import.
|
|
19
|
+
import { registerPluginMediaPublishGuard } from "@aglyn/aglyn/plugin-manager/plugin-media-publish";
|
|
20
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
21
|
+
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
22
|
+
import { findPaidMediaUses, paidMediaPublishRefusal } from "./paid-media-uses.js";
|
|
23
|
+
/**
|
|
24
|
+
* Commerce’s answer to the platform's media-publish question (AGL-3080).
|
|
25
|
+
*
|
|
26
|
+
* The media library used to hold this rule itself, which meant the console
|
|
27
|
+
* knew what a product is, which two of its fields hold paid media, and that a
|
|
28
|
+
* deleted one sells nothing. It asks now, and this is the answer.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ A ceiling that stopped the scan is a REFUSAL with no blocker to name,
|
|
31
|
+
* never a pass: "we could not check" and "nothing sells this" point opposite
|
|
32
|
+
* ways, and only one of them is safe to publish on. The contract says the
|
|
33
|
+
* same thing about a throw, which is why nothing is caught here.
|
|
34
|
+
*/ export function registerCommerceMediaPublishGuard() {
|
|
35
|
+
registerPluginMediaPublishGuard({
|
|
36
|
+
check: async (request)=>{
|
|
37
|
+
const uses = await findPaidMediaUses(_extends({
|
|
38
|
+
firestore: firebaseAdmin.app().firestore(),
|
|
39
|
+
base: request.base,
|
|
40
|
+
mediaId: request.mediaId
|
|
41
|
+
}, request.bucket ? {
|
|
42
|
+
bucket: request.bucket
|
|
43
|
+
} : {}));
|
|
44
|
+
if (uses.complete && !uses.uses.length) return null;
|
|
45
|
+
return {
|
|
46
|
+
reason: paidMediaPublishRefusal(uses),
|
|
47
|
+
blockers: uses.uses.map((use)=>({
|
|
48
|
+
label: use.productName,
|
|
49
|
+
refId: use.productId,
|
|
50
|
+
hostId: use.hostId
|
|
51
|
+
})),
|
|
52
|
+
complete: uses.complete
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}, {
|
|
56
|
+
pluginId: BUNDLE_ID
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
//# sourceMappingURL=media-publish-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/media-publish-guard.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Its own subpath, never the plugin-manager barrel: what a published page\n// does not need, it must not import.\nimport { registerPluginMediaPublishGuard } from '@aglyn/aglyn/plugin-manager/plugin-media-publish'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n findPaidMediaUses,\n paidMediaPublishRefusal,\n type PaidMediaUsesFirestore,\n} from './paid-media-uses'\n\n/**\n * Commerce’s answer to the platform's media-publish question (AGL-3080).\n *\n * The media library used to hold this rule itself, which meant the console\n * knew what a product is, which two of its fields hold paid media, and that a\n * deleted one sells nothing. It asks now, and this is the answer.\n *\n * ⚠️ A ceiling that stopped the scan is a REFUSAL with no blocker to name,\n * never a pass: \"we could not check\" and \"nothing sells this\" point opposite\n * ways, and only one of them is safe to publish on. The contract says the\n * same thing about a throw, which is why nothing is caught here.\n */\nexport function registerCommerceMediaPublishGuard(): void {\n registerPluginMediaPublishGuard(\n {\n check: async (request) => {\n const uses = await findPaidMediaUses({\n firestore: firebaseAdmin.app().firestore() as PaidMediaUsesFirestore,\n base: request.base,\n mediaId: request.mediaId,\n ...(request.bucket ? { bucket: request.bucket } : {}),\n })\n if (uses.complete && !uses.uses.length) return null\n return {\n reason: paidMediaPublishRefusal(uses),\n blockers: uses.uses.map((use) => ({\n label: use.productName,\n refId: use.productId,\n hostId: use.hostId,\n })),\n complete: uses.complete,\n }\n },\n },\n { pluginId: BUNDLE_ID },\n )\n}\n\n"],"names":["registerPluginMediaPublishGuard","firebaseAdmin","BUNDLE_ID","findPaidMediaUses","paidMediaPublishRefusal","registerCommerceMediaPublishGuard","check","request","uses","firestore","app","base","mediaId","bucket","complete","length","reason","blockers","map","use","label","productName","refId","productId","hostId","pluginId"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,0EAA0E;AAC1E,qCAAqC;AACrC,SAASA,+BAA+B,QAAQ,mDAAkD;AAClG,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SACEC,iBAAiB,EACjBC,uBAAuB,QAElB,uBAAmB;AAE1B;;;;;;;;;;;CAWC,GACD,OAAO,SAASC;IACdL,gCACE;QACEM,OAAO,OAAOC;YACZ,MAAMC,OAAO,MAAML,kBAAkB;gBACnCM,WAAWR,cAAcS,GAAG,GAAGD,SAAS;gBACxCE,MAAMJ,QAAQI,IAAI;gBAClBC,SAASL,QAAQK,OAAO;eACpBL,QAAQM,MAAM,GAAG;gBAAEA,QAAQN,QAAQM,MAAM;YAAC,IAAI,CAAC;YAErD,IAAIL,KAAKM,QAAQ,IAAI,CAACN,KAAKA,IAAI,CAACO,MAAM,EAAE,OAAO;YAC/C,OAAO;gBACLC,QAAQZ,wBAAwBI;gBAChCS,UAAUT,KAAKA,IAAI,CAACU,GAAG,CAAC,CAACC,MAAS,CAAA;wBAChCC,OAAOD,IAAIE,WAAW;wBACtBC,OAAOH,IAAII,SAAS;wBACpBC,QAAQL,IAAIK,MAAM;oBACpB,CAAA;gBACAV,UAAUN,KAAKM,QAAQ;YACzB;QACF;IACF,GACA;QAAEW,UAAUvB;IAAU;AAE1B"}
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
import { memberNameSearchFields } from "./member-name-search.js";
|
|
18
18
|
import { checkVisitorRecordCeiling, HOST_TOKENS, marketingConsentFieldsForHost, personKey, SITE_MEMBER_CEILING_CODE, SITE_MEMBER_UNAVAILABLE_MESSAGE, SITE_MEMBERS_MAX_PER_HOST } from "@aglyn/aglyn/server";
|
|
19
19
|
import { addHostLead, firebaseAdmin, recordVisitorRecordCeilingTrip, resolveCampaignTouch } from "@aglyn/tenant-data-admin";
|
|
20
|
-
import {
|
|
20
|
+
import { emitHostEvent } from "@aglyn/tenant-runtime";
|
|
21
|
+
import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
|
|
21
22
|
import { hashMemberPassword, mintMemberSession, setMemberCookie } from "./membership.js";
|
|
22
23
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
23
24
|
/**
|
|
@@ -195,24 +196,39 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
195
196
|
}, campaignTouch ? {
|
|
196
197
|
touch: campaignTouch
|
|
197
198
|
} : {}));
|
|
198
|
-
|
|
199
|
-
|
|
199
|
+
/*
|
|
200
|
+
* Contacts ingestion (AGL-197), reported to whichever plugin keeps people
|
|
201
|
+
* rather than written by this one (AGL-3080). Commerce knows it just met
|
|
202
|
+
* somebody; what a person record is belongs to the plugin that models it,
|
|
203
|
+
* and a workspace that keeps none is a quiet, correct answer here.
|
|
204
|
+
*/ void recordCapturedContact(_extends({
|
|
205
|
+
/*
|
|
206
|
+
* Not resolved here. The record system keys a person on the SITE they
|
|
207
|
+
* were met on, so this costs the capture nothing — and resolving it
|
|
208
|
+
* would put a Firestore read in front of the writer, which is the one
|
|
209
|
+
* thing `recordCapturedContact` asks a door not to do.
|
|
210
|
+
*/ orgId: '',
|
|
200
211
|
hostId,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// the lead this sign-up also files is the sales record, and the stage
|
|
206
|
-
// on the contact says only that the person asked to be kept.
|
|
207
|
-
initialLifecycleStage: 'subscriber',
|
|
212
|
+
identity: {
|
|
213
|
+
email,
|
|
214
|
+
name: displayName || undefined
|
|
215
|
+
},
|
|
208
216
|
interaction: {
|
|
217
|
+
source: 'member',
|
|
209
218
|
refId: memberRef.id,
|
|
210
219
|
summary: 'Joined as a member'
|
|
211
|
-
}
|
|
220
|
+
},
|
|
221
|
+
// An account is a subscription to the site, not an enquiry (AGL-2612):
|
|
222
|
+
// the lead this sign-up also files is the sales record, and the stage
|
|
223
|
+
// on the contact says only that the person asked to be kept. A floor,
|
|
224
|
+
// so a customer who opens an account stays a customer.
|
|
225
|
+
lifecycleFloor: 'subscriber'
|
|
212
226
|
}, marketingConsent ? {
|
|
213
227
|
marketingConsent: true
|
|
214
228
|
} : {}, campaignTouch ? {
|
|
215
|
-
|
|
229
|
+
detail: {
|
|
230
|
+
campaignTouch
|
|
231
|
+
}
|
|
216
232
|
} : {}));
|
|
217
233
|
// Event triggers (AGL-128/148): sign-ups double as leads here too. The
|
|
218
234
|
// lead's id is the person key `addHostLead` filed it under, so a webhook
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-register.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { memberNameSearchFields } from './member-name-search'\nimport {\n checkVisitorRecordCeiling,\n HOST_TOKENS,\n marketingConsentFieldsForHost,\n personKey,\n SITE_MEMBER_CEILING_CODE,\n SITE_MEMBER_UNAVAILABLE_MESSAGE,\n SITE_MEMBERS_MAX_PER_HOST,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n recordVisitorRecordCeilingTrip,\n resolveCampaignTouch,\n} from '@aglyn/tenant-data-admin'\nimport { captureHostContact, emitHostEvent } from '@aglyn/tenant-runtime'\nimport {\n hashMemberPassword,\n mintMemberSession,\n setMemberCookie,\n} from './membership'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Site member sign-up (AGL-109): creates the member record (scrypt hash),\n * doubles as a lead, and signs the visitor in via the session cookie.\n */\nexport const membershipRegisterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n const password = String(req.body?.password ?? '')\n const displayName = String(req.body?.displayName ?? '')\n .trim()\n .slice(0, 80)\n // Explicit opt-in checkbox (AGL-2499) — never inferred from signing up.\n const marketingConsent = req.body?.marketingConsent === true\n if (!hostId) return res.status(400).json({ error: 'Missing host' })\n if (!EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // Member accounts are UNLIMITED on every plan (AGL-889) — no seat or\n // quota check belongs here. Audience monetization happens downstream:\n // contact bands meter the CRM projection (AGL-890) and paid\n // memberships carry the plan's digital transaction fee (AGL-892).\n //\n // `SITE_MEMBERS_MAX_PER_HOST` below is NOT that check and does not make\n // it one (AGL-1529). It is a flat PLATFORM ceiling — same number on every\n // plan, no `OrgEntitlements` key, nothing on the price list — and it\n // exists because this handler is reachable by an ANONYMOUS VISITOR, so\n // without it the collection was bounded only by a per-(host, IP) rate\n // limiter that fails soft and bounds the RATE rather than the TOTAL. An\n // abuse control is not something we sell; \"unlimited member accounts on\n // every plan\" stays literally true.\n const membersRef = hostRef.collection('siteMembers')\n const memberRef = membersRef.doc()\n /*\n * COUNT, DEDUPE AND CREATE IN ONE TRANSACTION (AGL-1529, the AGL-2231\n * treatment).\n *\n * Read-then-decide-then-`set()` is the create-time quota that laundered\n * everywhere else in this repo: N concurrent sign-ups each read the same\n * pre-count, each find room, and each land — and nothing re-counts\n * afterwards, so the extra accounts are permanent. The fix is WHEN the\n * count is evaluated, not the counting rule.\n * `Transaction.get(AggregateQuery)` serialises the count against a\n * concurrent create into this collection: the loser retries, re-reads the\n * higher count and is refused. The duplicate-email read moves inside for\n * free, which also closes the smaller race it always had — two\n * simultaneous sign-ups on one address both saw `empty` and both wrote.\n *\n * ALL READS BEFORE THE WRITE, which Firestore requires.\n *\n * The scrypt hash sits between the last read and the create ON PURPOSE.\n * It is ~100 ms of CPU and it is the most expensive thing in the request,\n * so a refused sign-up must never pay it — hashing before the transaction\n * would hand a flood a CPU amplifier on exactly the path the ceiling\n * exists to contain, and would also be a regression, since today a\n * duplicate-email refusal does not hash either. It is a pure computation,\n * not an effect, so it does not violate the \"nothing happens in a\n * transaction body\" rule the sibling routes state.\n *\n * A refusal is returned as DATA and rendered outside: a body that can run\n * several times must not be the place a response is built.\n */\n const refusal = await firestore.runTransaction(async (tx) => {\n const existing = await tx.get(membersRef.where('email', '==', email).limit(1))\n if (!existing.empty) return { duplicate: true, ceiling: 0 }\n const used = (await tx.get(membersRef.count())).data().count\n // Live documents only, so removing a member in the inbox frees the slot.\n const verdict = checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n if (verdict.exceeded) return { duplicate: false, ceiling: verdict.ceiling }\n tx.create(memberRef, {\n email,\n ...(displayName ? memberNameSearchFields(displayName) : {}),\n passwordScrypt: hashMemberPassword(password),\n /*\n * The checkbox is PERSISTED on the member, not only forwarded.\n *\n * It reached the lead and the contact from the two lines below and\n * was dropped for this document, so `hosts/{hostId}/siteMembers` had\n * no consent field of any kind and `audience: 'members'` had nothing\n * for the send-time join to read — the audience could not be filtered\n * even in principle (`docs/specs/email-overhaul.md` §1d/§3f). The\n * other two documents are not a substitute: a member is deduped in\n * this transaction while leads append every time, and contacts are\n * ORG-scoped where a member is the site's own.\n *\n * Written only when ticked. Signing up is not opting in — that is why\n * the checkbox exists and why it defaults unchecked — so the omitted\n * case stores nothing and reads back as an unrecorded basis rather\n * than as a refusal.\n *\n * Under this site's key even though `siteMembers` already lives\n * beneath it, so that one reader answers for every silo — see\n * `addHostLead`, which makes the same write for the same reason.\n */\n ...(marketingConsent\n ? marketingConsentFieldsForHost(hostId, Date.now())\n : {}),\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return null\n })\n if (refusal?.duplicate) {\n return res.status(409).json({ error: 'That email is already a member' })\n }\n if (refusal) {\n // Visible to the HOST — the rule that a control nobody can see in\n // the console did not ship. Durable counter + one notification.\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'siteMembers',\n ceiling: refusal.ceiling,\n })\n // Opaque to the VISITOR, who is a stranger to this site and not our\n // customer (AGL-1666's rules, restated in the message's own docblock).\n // The one genuinely useful thing to hand them is a door that still\n // opens, so the site's OWN published support address rides along when\n // it has one — read through the host-token registry, whose description\n // of this field is literally \"where visitors should write for help\".\n // Nothing else off the host document may leave the console.\n const contact = HOST_TOKENS['supportEmail']?.resolve(\n hostSnapshot.data() as any,\n )\n return res.status(429).json({\n error: SITE_MEMBER_UNAVAILABLE_MESSAGE,\n // Machine-readable, because the dispatcher's rate limiter answers 429\n // too and the status therefore discriminates nothing.\n code: SITE_MEMBER_CEILING_CODE,\n ...(contact ? { contact } : {}),\n })\n }\n // Sign-ups double as leads for the site owner (AGL-109), through the one\n // writer that enforces `LEADS_MAX_PER_HOST` (AGL-1529). A refused lead\n // never fails the sign-up: the visitor asked for an account, not for a\n // lead record, and the trip is recorded for the owner either way.\n /*\n * THE CAMPAIGN TOUCH, RESOLVED ONCE FOR THE WHOLE SIGN-UP.\n *\n * A sign-up is the identify moment for a visitor who has been anonymous\n * until now, and it writes two records that a campaign can be credited\n * with — the lead and the contact. One resolve, one keyed read, and the\n * two cannot end up naming different campaigns.\n *\n * The MEMBER record itself is not attributed. A member is an account the\n * visitor holds and the lead is the site's record of the same act, so\n * crediting both would count one sign-up twice under two names.\n */\n const signedUpAtMs = Date.now()\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: req.body?.campaignTouch,\n email,\n atMs: signedUpAtMs,\n })\n await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n // The name the person just typed (AGL-2303). `campaign-send` reads\n // `leads.name` for merge tags and NOTHING wrote it, so every campaign\n // to the leads audience addressed a blank — with the name sitting one\n // line above, already stored on the member document.\n ...(displayName ? { name: displayName } : {}),\n source: 'signup',\n ...(marketingConsent ? { marketingConsent: true } : {}),\n },\n ...(campaignTouch ? { touch: campaignTouch } : {}),\n })\n // Contacts ingestion (AGL-197).\n void captureHostContact({\n hostId,\n email,\n name: displayName || undefined,\n source: 'member',\n // An account is a subscription to the site, not an enquiry (AGL-2612):\n // the lead this sign-up also files is the sales record, and the stage\n // on the contact says only that the person asked to be kept.\n initialLifecycleStage: 'subscriber',\n interaction: { refId: memberRef.id, summary: 'Joined as a member' },\n ...(marketingConsent ? { marketingConsent: true } : {}),\n ...(campaignTouch ? { campaignTouch } : {}),\n })\n // Event triggers (AGL-128/148): sign-ups double as leads here too. The\n // lead's id is the person key `addHostLead` filed it under, so a webhook\n // can read the row back over `/v1/leads/{leadId}` (AGL-2627); empty for\n // an address that could not be keyed, as every optional payload key is.\n await emitHostEvent(hostId, 'memberSignUp', { email })\n await emitHostEvent(hostId, 'lead', {\n email,\n source: 'signup',\n leadId: personKey(email) ?? '',\n })\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberRef.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-up failed' })\n }\n}\n"],"names":["memberNameSearchFields","checkVisitorRecordCeiling","HOST_TOKENS","marketingConsentFieldsForHost","personKey","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","SITE_MEMBERS_MAX_PER_HOST","addHostLead","firebaseAdmin","recordVisitorRecordCeilingTrip","resolveCampaignTouch","captureHostContact","emitHostEvent","hashMemberPassword","mintMemberSession","setMemberCookie","EMAIL_PATTERN","membershipRegisterHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","displayName","slice","marketingConsent","test","length","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","membersRef","memberRef","refusal","runTransaction","tx","existing","where","limit","empty","duplicate","ceiling","used","count","data","verdict","exceeded","create","passwordScrypt","Date","now","createdAt","FieldValue","serverTimestamp","kind","contact","resolve","code","signedUpAtMs","campaignTouch","wire","atMs","lead","name","source","touch","undefined","initialLifecycleStage","interaction","refId","id","summary","leadId","ok","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-register.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { memberNameSearchFields } from './member-name-search'\nimport {\n checkVisitorRecordCeiling,\n HOST_TOKENS,\n marketingConsentFieldsForHost,\n personKey,\n SITE_MEMBER_CEILING_CODE,\n SITE_MEMBER_UNAVAILABLE_MESSAGE,\n SITE_MEMBERS_MAX_PER_HOST,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n recordVisitorRecordCeilingTrip,\n resolveCampaignTouch,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n hashMemberPassword,\n mintMemberSession,\n setMemberCookie,\n} from './membership'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Site member sign-up (AGL-109): creates the member record (scrypt hash),\n * doubles as a lead, and signs the visitor in via the session cookie.\n */\nexport const membershipRegisterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n const password = String(req.body?.password ?? '')\n const displayName = String(req.body?.displayName ?? '')\n .trim()\n .slice(0, 80)\n // Explicit opt-in checkbox (AGL-2499) — never inferred from signing up.\n const marketingConsent = req.body?.marketingConsent === true\n if (!hostId) return res.status(400).json({ error: 'Missing host' })\n if (!EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // Member accounts are UNLIMITED on every plan (AGL-889) — no seat or\n // quota check belongs here. Audience monetization happens downstream:\n // contact bands meter the CRM projection (AGL-890) and paid\n // memberships carry the plan's digital transaction fee (AGL-892).\n //\n // `SITE_MEMBERS_MAX_PER_HOST` below is NOT that check and does not make\n // it one (AGL-1529). It is a flat PLATFORM ceiling — same number on every\n // plan, no `OrgEntitlements` key, nothing on the price list — and it\n // exists because this handler is reachable by an ANONYMOUS VISITOR, so\n // without it the collection was bounded only by a per-(host, IP) rate\n // limiter that fails soft and bounds the RATE rather than the TOTAL. An\n // abuse control is not something we sell; \"unlimited member accounts on\n // every plan\" stays literally true.\n const membersRef = hostRef.collection('siteMembers')\n const memberRef = membersRef.doc()\n /*\n * COUNT, DEDUPE AND CREATE IN ONE TRANSACTION (AGL-1529, the AGL-2231\n * treatment).\n *\n * Read-then-decide-then-`set()` is the create-time quota that laundered\n * everywhere else in this repo: N concurrent sign-ups each read the same\n * pre-count, each find room, and each land — and nothing re-counts\n * afterwards, so the extra accounts are permanent. The fix is WHEN the\n * count is evaluated, not the counting rule.\n * `Transaction.get(AggregateQuery)` serialises the count against a\n * concurrent create into this collection: the loser retries, re-reads the\n * higher count and is refused. The duplicate-email read moves inside for\n * free, which also closes the smaller race it always had — two\n * simultaneous sign-ups on one address both saw `empty` and both wrote.\n *\n * ALL READS BEFORE THE WRITE, which Firestore requires.\n *\n * The scrypt hash sits between the last read and the create ON PURPOSE.\n * It is ~100 ms of CPU and it is the most expensive thing in the request,\n * so a refused sign-up must never pay it — hashing before the transaction\n * would hand a flood a CPU amplifier on exactly the path the ceiling\n * exists to contain, and would also be a regression, since today a\n * duplicate-email refusal does not hash either. It is a pure computation,\n * not an effect, so it does not violate the \"nothing happens in a\n * transaction body\" rule the sibling routes state.\n *\n * A refusal is returned as DATA and rendered outside: a body that can run\n * several times must not be the place a response is built.\n */\n const refusal = await firestore.runTransaction(async (tx) => {\n const existing = await tx.get(membersRef.where('email', '==', email).limit(1))\n if (!existing.empty) return { duplicate: true, ceiling: 0 }\n const used = (await tx.get(membersRef.count())).data().count\n // Live documents only, so removing a member in the inbox frees the slot.\n const verdict = checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n if (verdict.exceeded) return { duplicate: false, ceiling: verdict.ceiling }\n tx.create(memberRef, {\n email,\n ...(displayName ? memberNameSearchFields(displayName) : {}),\n passwordScrypt: hashMemberPassword(password),\n /*\n * The checkbox is PERSISTED on the member, not only forwarded.\n *\n * It reached the lead and the contact from the two lines below and\n * was dropped for this document, so `hosts/{hostId}/siteMembers` had\n * no consent field of any kind and `audience: 'members'` had nothing\n * for the send-time join to read — the audience could not be filtered\n * even in principle (`docs/specs/email-overhaul.md` §1d/§3f). The\n * other two documents are not a substitute: a member is deduped in\n * this transaction while leads append every time, and contacts are\n * ORG-scoped where a member is the site's own.\n *\n * Written only when ticked. Signing up is not opting in — that is why\n * the checkbox exists and why it defaults unchecked — so the omitted\n * case stores nothing and reads back as an unrecorded basis rather\n * than as a refusal.\n *\n * Under this site's key even though `siteMembers` already lives\n * beneath it, so that one reader answers for every silo — see\n * `addHostLead`, which makes the same write for the same reason.\n */\n ...(marketingConsent\n ? marketingConsentFieldsForHost(hostId, Date.now())\n : {}),\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return null\n })\n if (refusal?.duplicate) {\n return res.status(409).json({ error: 'That email is already a member' })\n }\n if (refusal) {\n // Visible to the HOST — the rule that a control nobody can see in\n // the console did not ship. Durable counter + one notification.\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'siteMembers',\n ceiling: refusal.ceiling,\n })\n // Opaque to the VISITOR, who is a stranger to this site and not our\n // customer (AGL-1666's rules, restated in the message's own docblock).\n // The one genuinely useful thing to hand them is a door that still\n // opens, so the site's OWN published support address rides along when\n // it has one — read through the host-token registry, whose description\n // of this field is literally \"where visitors should write for help\".\n // Nothing else off the host document may leave the console.\n const contact = HOST_TOKENS['supportEmail']?.resolve(\n hostSnapshot.data() as any,\n )\n return res.status(429).json({\n error: SITE_MEMBER_UNAVAILABLE_MESSAGE,\n // Machine-readable, because the dispatcher's rate limiter answers 429\n // too and the status therefore discriminates nothing.\n code: SITE_MEMBER_CEILING_CODE,\n ...(contact ? { contact } : {}),\n })\n }\n // Sign-ups double as leads for the site owner (AGL-109), through the one\n // writer that enforces `LEADS_MAX_PER_HOST` (AGL-1529). A refused lead\n // never fails the sign-up: the visitor asked for an account, not for a\n // lead record, and the trip is recorded for the owner either way.\n /*\n * THE CAMPAIGN TOUCH, RESOLVED ONCE FOR THE WHOLE SIGN-UP.\n *\n * A sign-up is the identify moment for a visitor who has been anonymous\n * until now, and it writes two records that a campaign can be credited\n * with — the lead and the contact. One resolve, one keyed read, and the\n * two cannot end up naming different campaigns.\n *\n * The MEMBER record itself is not attributed. A member is an account the\n * visitor holds and the lead is the site's record of the same act, so\n * crediting both would count one sign-up twice under two names.\n */\n const signedUpAtMs = Date.now()\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: req.body?.campaignTouch,\n email,\n atMs: signedUpAtMs,\n })\n await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n // The name the person just typed (AGL-2303). `campaign-send` reads\n // `leads.name` for merge tags and NOTHING wrote it, so every campaign\n // to the leads audience addressed a blank — with the name sitting one\n // line above, already stored on the member document.\n ...(displayName ? { name: displayName } : {}),\n source: 'signup',\n ...(marketingConsent ? { marketingConsent: true } : {}),\n },\n ...(campaignTouch ? { touch: campaignTouch } : {}),\n })\n /*\n * Contacts ingestion (AGL-197), reported to whichever plugin keeps people\n * rather than written by this one (AGL-3080). Commerce knows it just met\n * somebody; what a person record is belongs to the plugin that models it,\n * and a workspace that keeps none is a quiet, correct answer here.\n */\n void recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email, name: displayName || undefined },\n interaction: {\n source: 'member',\n refId: memberRef.id,\n summary: 'Joined as a member',\n },\n // An account is a subscription to the site, not an enquiry (AGL-2612):\n // the lead this sign-up also files is the sales record, and the stage\n // on the contact says only that the person asked to be kept. A floor,\n // so a customer who opens an account stays a customer.\n lifecycleFloor: 'subscriber',\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n // Event triggers (AGL-128/148): sign-ups double as leads here too. The\n // lead's id is the person key `addHostLead` filed it under, so a webhook\n // can read the row back over `/v1/leads/{leadId}` (AGL-2627); empty for\n // an address that could not be keyed, as every optional payload key is.\n await emitHostEvent(hostId, 'memberSignUp', { email })\n await emitHostEvent(hostId, 'lead', {\n email,\n source: 'signup',\n leadId: personKey(email) ?? '',\n })\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberRef.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-up failed' })\n }\n}\n"],"names":["memberNameSearchFields","checkVisitorRecordCeiling","HOST_TOKENS","marketingConsentFieldsForHost","personKey","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","SITE_MEMBERS_MAX_PER_HOST","addHostLead","firebaseAdmin","recordVisitorRecordCeilingTrip","resolveCampaignTouch","emitHostEvent","recordCapturedContact","hashMemberPassword","mintMemberSession","setMemberCookie","EMAIL_PATTERN","membershipRegisterHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","displayName","slice","marketingConsent","test","length","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","membersRef","memberRef","refusal","runTransaction","tx","existing","where","limit","empty","duplicate","ceiling","used","count","data","verdict","exceeded","create","passwordScrypt","Date","now","createdAt","FieldValue","serverTimestamp","kind","contact","resolve","code","signedUpAtMs","campaignTouch","wire","atMs","lead","name","source","touch","orgId","identity","undefined","interaction","refId","id","summary","lifecycleFloor","detail","leadId","ok","console"],"mappings":"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"}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { hostPublicOrigin } from "@aglyn/aglyn/app-utils/host-naming";
|
|
18
18
|
import { mergeEmailTopics, normalizeEmailTopic, topicRequiresDoubleOptIn, EMAIL_TOPICS_COLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
|
|
19
19
|
import { enrollListMember, firebaseAdmin, hostSendingIdentity, meterHostEmail, consentGroupForSite, orgDataCollectionForHost, recordPendingTopicConfirmation, resolveCampaignTouch, resolveOrgIdForHost, siteRequiresDoubleOptIn } from "@aglyn/tenant-data-admin";
|
|
20
|
-
import
|
|
20
|
+
import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
|
|
21
21
|
import { buildConfirmUrl } from "@aglyn/tenant-data-admin/server/email-unsubscribe-link";
|
|
22
22
|
import { sendEmail } from "@aglyn/shared-util-email";
|
|
23
23
|
import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
|
|
@@ -252,20 +252,38 @@ import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/r
|
|
|
252
252
|
email,
|
|
253
253
|
atMs: now
|
|
254
254
|
});
|
|
255
|
-
|
|
255
|
+
/*
|
|
256
|
+
* Reported to whichever plugin keeps people, not written by this one
|
|
257
|
+
* (AGL-3080). A newsletter signup is commerce meeting somebody; what a
|
|
258
|
+
* person record is, and whether this workspace keeps any, belongs to the
|
|
259
|
+
* plugin that models them.
|
|
260
|
+
*
|
|
261
|
+
* Still awaited, because the confirmation below is deliberately after it.
|
|
262
|
+
*/ await recordCapturedContact(_extends({
|
|
263
|
+
/*
|
|
264
|
+
* Not resolved here. The record system keys a person on the SITE they
|
|
265
|
+
* were met on, so this costs the capture nothing — and resolving it
|
|
266
|
+
* would put a Firestore read in front of the writer, which is the one
|
|
267
|
+
* thing `recordCapturedContact` asks a door not to do.
|
|
268
|
+
*/ orgId: '',
|
|
256
269
|
hostId,
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// from the site and nothing more yet.
|
|
261
|
-
initialLifecycleStage: 'subscriber',
|
|
262
|
-
marketingConsent: true,
|
|
270
|
+
identity: {
|
|
271
|
+
email
|
|
272
|
+
},
|
|
263
273
|
interaction: {
|
|
274
|
+
source: 'newsletter',
|
|
264
275
|
refId: `newsletter-${now}`,
|
|
265
276
|
summary: 'Subscribed to the newsletter'
|
|
266
|
-
}
|
|
277
|
+
},
|
|
278
|
+
// The stage the name is for (AGL-2612): somebody who asked to hear
|
|
279
|
+
// from the site and nothing more yet. A floor, so a customer who
|
|
280
|
+
// subscribes stays a customer.
|
|
281
|
+
lifecycleFloor: 'subscriber',
|
|
282
|
+
marketingConsent: true
|
|
267
283
|
}, campaignTouch ? {
|
|
268
|
-
|
|
284
|
+
detail: {
|
|
285
|
+
campaignTouch
|
|
286
|
+
}
|
|
269
287
|
} : {}));
|
|
270
288
|
/*
|
|
271
289
|
* The confirmation, when this site asks for one.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/newsletter.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 { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n mergeEmailTopics,\n normalizeEmailTopic,\n topicRequiresDoubleOptIn,\n EMAIL_TOPICS_COLLECTION,\n type EmailTopic,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n enrollListMember,\n firebaseAdmin,\n hostSendingIdentity,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n recordPendingTopicConfirmation,\n resolveCampaignTouch,\n resolveOrgIdForHost,\n siteRequiresDoubleOptIn,\n} from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\nimport { buildConfirmUrl } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * The stream a newsletter signup joins.\n *\n * The built-in id, not the campaign default: somebody typing their address\n * into a footer box is asking for the newsletter, and attributing it to\n * \"Promotions and offers\" would confirm them for a stream they did not name.\n */\nconst NEWSLETTER_TOPIC_ID = 'newsletter'\n\n/**\n * Enrolls one address into an org list's members (AGL-2499).\n *\n * The document id comes from `enrollListMember`, which is the only writer of\n * that collection — the workflow `enrollList` step reaches it too, and when\n * the two derived their own ids the same person subscribing by both routes\n * became two members of one list.\n *\n * Best-effort and silent on any problem: list enrollment rides along with\n * the newsletter signup, which must still succeed (and still upsert the\n * contact) even when the list id is stale, was mistyped into the besigner\n * prop, or belongs to an org this host cannot resolve.\n */\nasync function enrollInList(options: {\n hostId: string\n listId: string\n email: string\n name?: string\n /** The opt-in the signup itself carries — see the call site. */\n marketingConsent?: boolean\n}): Promise<void> {\n if (!isDocumentId(options.listId)) return\n try {\n const contactsRef = await orgDataCollectionForHost(options.hostId, 'contacts')\n const listRef = contactsRef.parent?.collection('lists').doc(options.listId)\n if (!listRef) return\n const listSnapshot = await listRef.get()\n // A stale/mistyped id must not silently CREATE a list — campaign-send's\n // `list` audience would then read a list nobody set up.\n if (!listSnapshot.exists) return\n await enrollListMember({\n listRef,\n group: await consentGroupForSite(options.hostId),\n email: options.email,\n ...(options.name ? { name: options.name } : {}),\n source: 'newsletter',\n ...(options.marketingConsent ? { marketingConsent: true } : {}),\n })\n } catch (error) {\n console.error('list enrollment failed', error)\n }\n}\n\n/**\n * DOUBLE OPT-IN, when this site asks for one\n * (`docs/specs/email-competitive-gaps.md` P8).\n *\n * A footer signup is where a confirmation belongs — ActiveCampaign's shape,\n * where forms are the thing that default to it — and it is the only capture\n * path in the product where the person is present, expecting a reply, and has\n * just typed the address themselves.\n *\n * ## The confirmation message is TRANSACTIONAL\n *\n * It carries no marketing context, so it passes no frequency cap and adds no\n * unsubscribe header: somebody who just asked to subscribe is owed the answer\n * to what they asked, and gating it behind a marketing ceiling would drop\n * exactly the message that lets them out of the quarantine. The suppression\n * lists are not consulted here either — `recordPendingTopicConfirmation`\n * refuses an address that left this stream before any message is composed,\n * which is the check that matters: a signup form must not become a way to\n * mail somebody who unsubscribed by asking them again.\n *\n * A hard-bounced address is deliberately NOT refused here, and the asymmetry\n * with the opt-out above is the point. An opt-out is a stated intention; a\n * bounce is a measurement of a mailbox at a moment, and refusing to send at\n * submission time would settle it on evidence that cannot be older than the\n * question being asked. The confirmation goes out, and if it is delivered and\n * clicked, `confirmTopicSubscription` lifts the platform bounce — the round\n * trip is what proves the record stale. A complaint or an unsubscribe is\n * never lifted by that path.\n *\n * ## Best-effort, like the enrollment beside it\n *\n * A failure to send leaves the address pending and unmailable, which is the\n * safe direction: they signed up, nothing reaches them, and signing up again\n * re-sends. The signup itself still succeeds — the contact is captured with\n * its consent record either way, and losing the capture because a confirmation\n * bounced would be a worse trade for everybody.\n *\n * @returns whether the address was put in the quarantine, so the caller can\n * decide whether to enroll them on a list yet.\n */\nasync function requestConfirmation(options: {\n hostId: string\n email: string\n topics: EmailTopic[]\n /** The host document the setting was read from — see {@link loadHostGate}. */\n host: { cname?: unknown; subdomain?: unknown }\n}): Promise<boolean> {\n const topic = options.topics.find(\n (candidate) => candidate.id === NEWSLETTER_TOPIC_ID,\n )\n const { result } = await recordPendingTopicConfirmation(\n options.hostId,\n options.email,\n NEWSLETTER_TOPIC_ID,\n )\n if (result !== 'pending') return false\n try {\n const siteBase =\n hostPublicOrigin({\n cname: String(options.host.cname ?? ''),\n subdomain: String(options.host.subdomain ?? ''),\n }) ?? ''\n const url = buildConfirmUrl({\n siteBase,\n hostId: options.hostId,\n email: options.email,\n topicId: NEWSLETTER_TOPIC_ID,\n })\n // No origin or no signing secret means no link, and a confirmation\n // message with nothing to click is worse than none: the person believes\n // they have subscribed and nothing ever arrives. Said out loud rather\n // than shipped quietly, exactly as the unsubscribe seam does.\n if (!url) {\n console.warn(\n '[newsletter] confirmation not sent — no public origin or no ' +\n 'EMAIL_UNSUBSCRIBE_SECRET',\n )\n return true\n }\n const stream = topic?.name ?? 'our newsletter'\n const result = await sendEmail({\n to: options.email,\n subject: `Confirm your subscription`,\n text:\n `Please confirm that you want to receive ${stream} at this ` +\n `address:\\n\\n${url}\\n\\nThe link works for three days. If you did ` +\n 'not sign up, ignore this message — nothing will be sent.',\n sendingIdentity: await hostSendingIdentity(options.hostId),\n audience: 'tenant',\n context: 'newsletter confirmation',\n })\n /*\n * Counted against the site, as TRANSACTIONAL.\n *\n * It is a message this site sent, so it costs what a message costs and\n * the meter has to see it. `'transactional'` is the honest class: the\n * person just typed their address into a form and is waiting for the\n * answer, which is why the message carries no marketing context and no\n * unsubscribe header either. Only a delivery is counted, so a send that\n * never left is not billed as one.\n */\n if (result.sent) await meterHostEmail(options.hostId)\n } catch (error) {\n console.error('confirmation send failed', error)\n }\n return true\n}\n\n/**\n * The site's topic catalog, for the one question the signup asks of it.\n *\n * Fails soft to the built-ins, like the preference page's read: a site whose\n * org cannot be resolved still gets the four defaults, so the decision is\n * made against a catalog rather than against nothing.\n */\nasync function loadTopics(hostId: string): Promise<EmailTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return mergeEmailTopics(null)\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n return mergeEmailTopics(\n (snapshot?.docs ?? [])\n .map((doc: any) => normalizeEmailTopic(doc.id, doc.data()))\n .filter((topic: EmailTopic | null): topic is EmailTopic => !!topic),\n )\n } catch (error) {\n console.error('[newsletter] topic catalog read failed', error)\n return mergeEmailTopics(null)\n }\n}\n\n/**\n * Everything the confirmation decision needs, in one round trip.\n *\n * Three reads — the host document, the org lookup and the topic catalog — and\n * the first is issued IN PARALLEL with the other two rather than after them,\n * because none of the three depends on another's answer. A signup is a person\n * waiting for a form to come back, and serialized reads on that path are\n * latency the person feels for a decision that is usually \"no\".\n *\n * The host document is returned rather than discarded: the confirmation link\n * needs the site's public origin, which is on that same document, and reading\n * it twice would be a read per signup to recover something already in hand.\n *\n * Fails soft on the site flag, which `siteRequiresDoubleOptIn` already does —\n * a default a failed read could switch ON would quarantine every new signup\n * on a site whose owner never asked for confirmations.\n */\nasync function loadHostGate(hostId: string): Promise<{\n topics: EmailTopic[]\n siteDefault: boolean\n host: { cname?: unknown; subdomain?: unknown }\n}> {\n const [topics, siteDefault, snapshot] = await Promise.all([\n loadTopics(hostId),\n siteRequiresDoubleOptIn(hostId),\n firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null),\n ])\n return {\n topics,\n siteDefault,\n host: {\n cname: snapshot?.get('cname'),\n subdomain: snapshot?.get('subdomain'),\n },\n }\n}\n\n// Best-effort per-instance flood damper.\nconst attemptsByIp = new Map<string, number[]>()\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n/**\n * Newsletter opt-in (AGL-301): footer signups and checkout opt-ins land\n * in the contacts CRM with an explicit consent timestamp, feeding the\n * email-campaign audiences.\n */\nexport const newsletterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const email = String(body.email ?? '')\n .trim()\n .toLowerCase()\n const listId = String(body.listId ?? '').trim()\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n // Keeps counting under the no-address bucket rather than being skipped: an\n // unauthenticated signup endpoint that stops counting is an open write into\n // the contacts CRM.\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 < 60_000,\n )\n attempts.push(now)\n attemptsByIp.set(ip, attempts)\n if (attempts.length > 10) {\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n // A newsletter signup is an identify moment: the visitor was anonymous\n // while they browsed and this request is the first thing that names them.\n // Both channels are asked once, here, and the later touch is credited.\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: body.campaignTouch,\n email,\n atMs: now,\n })\n await captureHostContact({\n hostId,\n email,\n source: 'newsletter',\n // The stage the name is for (AGL-2612): somebody who asked to hear\n // from the site and nothing more yet.\n initialLifecycleStage: 'subscriber',\n marketingConsent: true,\n interaction: {\n refId: `newsletter-${now}`,\n summary: 'Subscribed to the newsletter',\n },\n ...(campaignTouch ? { campaignTouch } : {}),\n })\n /*\n * The confirmation, when this site asks for one.\n *\n * AFTER the contact upsert and never instead of it. The person typed\n * their address and ticked a box, and that consent record is a fact\n * whether or not they go on to click a link — withholding the capture\n * until they do would lose the record of what they actually did. What the\n * confirmation gates is the SEND, on the person's own topic entry, which\n * is where `filterTopicSendable` reads it.\n */\n const { topics, siteDefault, host } = await loadHostGate(hostId)\n const confirming =\n topicRequiresDoubleOptIn(\n topics.find((topic) => topic.id === NEWSLETTER_TOPIC_ID),\n siteDefault,\n ) && (await requestConfirmation({ hostId, email, topics, host }))\n if (listId) {\n /*\n * The same basis the contact upsert records, on the membership too.\n *\n * A list membership had no consent field at all, so `audience: 'list'`\n * gave the send-time join nothing to read even for the one audience\n * whose members literally asked for a newsletter\n * (`docs/specs/email-overhaul.md` §1d). This is a DECLARED opt-in and\n * not an inference from an act: the request this handler serves is\n * \"subscribe me\", which is the checkbox.\n */\n await enrollInList({ hostId, listId, email, marketingConsent: true })\n }\n /*\n * The caller is told which of the two things happened, so the signup form\n * can say \"check your email\" rather than \"you're subscribed\".\n *\n * A form that reported success identically either way would be the site\n * telling somebody they are on the list while the send path refuses them,\n * and the only way they could find out is by noticing that nothing\n * arrives.\n */\n return res.status(200).json({ ok: true, confirmationRequired: confirming })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Signup failed' })\n }\n}\n"],"names":["hostPublicOrigin","mergeEmailTopics","normalizeEmailTopic","topicRequiresDoubleOptIn","EMAIL_TOPICS_COLLECTION","enrollListMember","firebaseAdmin","hostSendingIdentity","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","recordPendingTopicConfirmation","resolveCampaignTouch","resolveOrgIdForHost","siteRequiresDoubleOptIn","captureHostContact","buildConfirmUrl","sendEmail","isDocumentId","EMAIL_PATTERN","NEWSLETTER_TOPIC_ID","enrollInList","options","listId","contactsRef","hostId","listRef","parent","collection","doc","listSnapshot","get","exists","group","email","name","source","marketingConsent","error","console","requestConfirmation","topic","topics","find","candidate","id","result","siteBase","cname","String","host","subdomain","url","topicId","warn","stream","to","subject","text","sendingIdentity","audience","context","sent","loadTopics","orgId","snapshot","app","firestore","docs","map","data","filter","loadHostGate","siteDefault","Promise","all","catch","attemptsByIp","Map","NO_CLIENT_ADDRESS_BUCKET","readClientIp","newsletterHandler","req","res","body","method","status","json","JSON","parse","trim","toLowerCase","test","ip","headers","remoteAddress","socket","now","Date","attempts","at","push","set","length","campaignTouch","wire","atMs","initialLifecycleStage","interaction","refId","summary","confirming","ok","confirmationRequired"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/newsletter.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 { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n mergeEmailTopics,\n normalizeEmailTopic,\n topicRequiresDoubleOptIn,\n EMAIL_TOPICS_COLLECTION,\n type EmailTopic,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n enrollListMember,\n firebaseAdmin,\n hostSendingIdentity,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n recordPendingTopicConfirmation,\n resolveCampaignTouch,\n resolveOrgIdForHost,\n siteRequiresDoubleOptIn,\n} from '@aglyn/tenant-data-admin'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport { buildConfirmUrl } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * The stream a newsletter signup joins.\n *\n * The built-in id, not the campaign default: somebody typing their address\n * into a footer box is asking for the newsletter, and attributing it to\n * \"Promotions and offers\" would confirm them for a stream they did not name.\n */\nconst NEWSLETTER_TOPIC_ID = 'newsletter'\n\n/**\n * Enrolls one address into an org list's members (AGL-2499).\n *\n * The document id comes from `enrollListMember`, which is the only writer of\n * that collection — the workflow `enrollList` step reaches it too, and when\n * the two derived their own ids the same person subscribing by both routes\n * became two members of one list.\n *\n * Best-effort and silent on any problem: list enrollment rides along with\n * the newsletter signup, which must still succeed (and still upsert the\n * contact) even when the list id is stale, was mistyped into the besigner\n * prop, or belongs to an org this host cannot resolve.\n */\nasync function enrollInList(options: {\n hostId: string\n listId: string\n email: string\n name?: string\n /** The opt-in the signup itself carries — see the call site. */\n marketingConsent?: boolean\n}): Promise<void> {\n if (!isDocumentId(options.listId)) return\n try {\n const contactsRef = await orgDataCollectionForHost(options.hostId, 'contacts')\n const listRef = contactsRef.parent?.collection('lists').doc(options.listId)\n if (!listRef) return\n const listSnapshot = await listRef.get()\n // A stale/mistyped id must not silently CREATE a list — campaign-send's\n // `list` audience would then read a list nobody set up.\n if (!listSnapshot.exists) return\n await enrollListMember({\n listRef,\n group: await consentGroupForSite(options.hostId),\n email: options.email,\n ...(options.name ? { name: options.name } : {}),\n source: 'newsletter',\n ...(options.marketingConsent ? { marketingConsent: true } : {}),\n })\n } catch (error) {\n console.error('list enrollment failed', error)\n }\n}\n\n/**\n * DOUBLE OPT-IN, when this site asks for one\n * (`docs/specs/email-competitive-gaps.md` P8).\n *\n * A footer signup is where a confirmation belongs — ActiveCampaign's shape,\n * where forms are the thing that default to it — and it is the only capture\n * path in the product where the person is present, expecting a reply, and has\n * just typed the address themselves.\n *\n * ## The confirmation message is TRANSACTIONAL\n *\n * It carries no marketing context, so it passes no frequency cap and adds no\n * unsubscribe header: somebody who just asked to subscribe is owed the answer\n * to what they asked, and gating it behind a marketing ceiling would drop\n * exactly the message that lets them out of the quarantine. The suppression\n * lists are not consulted here either — `recordPendingTopicConfirmation`\n * refuses an address that left this stream before any message is composed,\n * which is the check that matters: a signup form must not become a way to\n * mail somebody who unsubscribed by asking them again.\n *\n * A hard-bounced address is deliberately NOT refused here, and the asymmetry\n * with the opt-out above is the point. An opt-out is a stated intention; a\n * bounce is a measurement of a mailbox at a moment, and refusing to send at\n * submission time would settle it on evidence that cannot be older than the\n * question being asked. The confirmation goes out, and if it is delivered and\n * clicked, `confirmTopicSubscription` lifts the platform bounce — the round\n * trip is what proves the record stale. A complaint or an unsubscribe is\n * never lifted by that path.\n *\n * ## Best-effort, like the enrollment beside it\n *\n * A failure to send leaves the address pending and unmailable, which is the\n * safe direction: they signed up, nothing reaches them, and signing up again\n * re-sends. The signup itself still succeeds — the contact is captured with\n * its consent record either way, and losing the capture because a confirmation\n * bounced would be a worse trade for everybody.\n *\n * @returns whether the address was put in the quarantine, so the caller can\n * decide whether to enroll them on a list yet.\n */\nasync function requestConfirmation(options: {\n hostId: string\n email: string\n topics: EmailTopic[]\n /** The host document the setting was read from — see {@link loadHostGate}. */\n host: { cname?: unknown; subdomain?: unknown }\n}): Promise<boolean> {\n const topic = options.topics.find(\n (candidate) => candidate.id === NEWSLETTER_TOPIC_ID,\n )\n const { result } = await recordPendingTopicConfirmation(\n options.hostId,\n options.email,\n NEWSLETTER_TOPIC_ID,\n )\n if (result !== 'pending') return false\n try {\n const siteBase =\n hostPublicOrigin({\n cname: String(options.host.cname ?? ''),\n subdomain: String(options.host.subdomain ?? ''),\n }) ?? ''\n const url = buildConfirmUrl({\n siteBase,\n hostId: options.hostId,\n email: options.email,\n topicId: NEWSLETTER_TOPIC_ID,\n })\n // No origin or no signing secret means no link, and a confirmation\n // message with nothing to click is worse than none: the person believes\n // they have subscribed and nothing ever arrives. Said out loud rather\n // than shipped quietly, exactly as the unsubscribe seam does.\n if (!url) {\n console.warn(\n '[newsletter] confirmation not sent — no public origin or no ' +\n 'EMAIL_UNSUBSCRIBE_SECRET',\n )\n return true\n }\n const stream = topic?.name ?? 'our newsletter'\n const result = await sendEmail({\n to: options.email,\n subject: `Confirm your subscription`,\n text:\n `Please confirm that you want to receive ${stream} at this ` +\n `address:\\n\\n${url}\\n\\nThe link works for three days. If you did ` +\n 'not sign up, ignore this message — nothing will be sent.',\n sendingIdentity: await hostSendingIdentity(options.hostId),\n audience: 'tenant',\n context: 'newsletter confirmation',\n })\n /*\n * Counted against the site, as TRANSACTIONAL.\n *\n * It is a message this site sent, so it costs what a message costs and\n * the meter has to see it. `'transactional'` is the honest class: the\n * person just typed their address into a form and is waiting for the\n * answer, which is why the message carries no marketing context and no\n * unsubscribe header either. Only a delivery is counted, so a send that\n * never left is not billed as one.\n */\n if (result.sent) await meterHostEmail(options.hostId)\n } catch (error) {\n console.error('confirmation send failed', error)\n }\n return true\n}\n\n/**\n * The site's topic catalog, for the one question the signup asks of it.\n *\n * Fails soft to the built-ins, like the preference page's read: a site whose\n * org cannot be resolved still gets the four defaults, so the decision is\n * made against a catalog rather than against nothing.\n */\nasync function loadTopics(hostId: string): Promise<EmailTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return mergeEmailTopics(null)\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n return mergeEmailTopics(\n (snapshot?.docs ?? [])\n .map((doc: any) => normalizeEmailTopic(doc.id, doc.data()))\n .filter((topic: EmailTopic | null): topic is EmailTopic => !!topic),\n )\n } catch (error) {\n console.error('[newsletter] topic catalog read failed', error)\n return mergeEmailTopics(null)\n }\n}\n\n/**\n * Everything the confirmation decision needs, in one round trip.\n *\n * Three reads — the host document, the org lookup and the topic catalog — and\n * the first is issued IN PARALLEL with the other two rather than after them,\n * because none of the three depends on another's answer. A signup is a person\n * waiting for a form to come back, and serialized reads on that path are\n * latency the person feels for a decision that is usually \"no\".\n *\n * The host document is returned rather than discarded: the confirmation link\n * needs the site's public origin, which is on that same document, and reading\n * it twice would be a read per signup to recover something already in hand.\n *\n * Fails soft on the site flag, which `siteRequiresDoubleOptIn` already does —\n * a default a failed read could switch ON would quarantine every new signup\n * on a site whose owner never asked for confirmations.\n */\nasync function loadHostGate(hostId: string): Promise<{\n topics: EmailTopic[]\n siteDefault: boolean\n host: { cname?: unknown; subdomain?: unknown }\n}> {\n const [topics, siteDefault, snapshot] = await Promise.all([\n loadTopics(hostId),\n siteRequiresDoubleOptIn(hostId),\n firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null),\n ])\n return {\n topics,\n siteDefault,\n host: {\n cname: snapshot?.get('cname'),\n subdomain: snapshot?.get('subdomain'),\n },\n }\n}\n\n// Best-effort per-instance flood damper.\nconst attemptsByIp = new Map<string, number[]>()\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n/**\n * Newsletter opt-in (AGL-301): footer signups and checkout opt-ins land\n * in the contacts CRM with an explicit consent timestamp, feeding the\n * email-campaign audiences.\n */\nexport const newsletterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const email = String(body.email ?? '')\n .trim()\n .toLowerCase()\n const listId = String(body.listId ?? '').trim()\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n // Keeps counting under the no-address bucket rather than being skipped: an\n // unauthenticated signup endpoint that stops counting is an open write into\n // the contacts CRM.\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 < 60_000,\n )\n attempts.push(now)\n attemptsByIp.set(ip, attempts)\n if (attempts.length > 10) {\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n // A newsletter signup is an identify moment: the visitor was anonymous\n // while they browsed and this request is the first thing that names them.\n // Both channels are asked once, here, and the later touch is credited.\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: body.campaignTouch,\n email,\n atMs: now,\n })\n /*\n * Reported to whichever plugin keeps people, not written by this one\n * (AGL-3080). A newsletter signup is commerce meeting somebody; what a\n * person record is, and whether this workspace keeps any, belongs to the\n * plugin that models them.\n *\n * Still awaited, because the confirmation below is deliberately after it.\n */\n await recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email },\n interaction: {\n source: 'newsletter',\n refId: `newsletter-${now}`,\n summary: 'Subscribed to the newsletter',\n },\n // The stage the name is for (AGL-2612): somebody who asked to hear\n // from the site and nothing more yet. A floor, so a customer who\n // subscribes stays a customer.\n lifecycleFloor: 'subscriber',\n marketingConsent: true,\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n /*\n * The confirmation, when this site asks for one.\n *\n * AFTER the contact upsert and never instead of it. The person typed\n * their address and ticked a box, and that consent record is a fact\n * whether or not they go on to click a link — withholding the capture\n * until they do would lose the record of what they actually did. What the\n * confirmation gates is the SEND, on the person's own topic entry, which\n * is where `filterTopicSendable` reads it.\n */\n const { topics, siteDefault, host } = await loadHostGate(hostId)\n const confirming =\n topicRequiresDoubleOptIn(\n topics.find((topic) => topic.id === NEWSLETTER_TOPIC_ID),\n siteDefault,\n ) && (await requestConfirmation({ hostId, email, topics, host }))\n if (listId) {\n /*\n * The same basis the contact upsert records, on the membership too.\n *\n * A list membership had no consent field at all, so `audience: 'list'`\n * gave the send-time join nothing to read even for the one audience\n * whose members literally asked for a newsletter\n * (`docs/specs/email-overhaul.md` §1d). This is a DECLARED opt-in and\n * not an inference from an act: the request this handler serves is\n * \"subscribe me\", which is the checkbox.\n */\n await enrollInList({ hostId, listId, email, marketingConsent: true })\n }\n /*\n * The caller is told which of the two things happened, so the signup form\n * can say \"check your email\" rather than \"you're subscribed\".\n *\n * A form that reported success identically either way would be the site\n * telling somebody they are on the list while the send path refuses them,\n * and the only way they could find out is by noticing that nothing\n * arrives.\n */\n return res.status(200).json({ ok: true, confirmationRequired: confirming })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Signup failed' })\n }\n}\n"],"names":["hostPublicOrigin","mergeEmailTopics","normalizeEmailTopic","topicRequiresDoubleOptIn","EMAIL_TOPICS_COLLECTION","enrollListMember","firebaseAdmin","hostSendingIdentity","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","recordPendingTopicConfirmation","resolveCampaignTouch","resolveOrgIdForHost","siteRequiresDoubleOptIn","recordCapturedContact","buildConfirmUrl","sendEmail","isDocumentId","EMAIL_PATTERN","NEWSLETTER_TOPIC_ID","enrollInList","options","listId","contactsRef","hostId","listRef","parent","collection","doc","listSnapshot","get","exists","group","email","name","source","marketingConsent","error","console","requestConfirmation","topic","topics","find","candidate","id","result","siteBase","cname","String","host","subdomain","url","topicId","warn","stream","to","subject","text","sendingIdentity","audience","context","sent","loadTopics","orgId","snapshot","app","firestore","docs","map","data","filter","loadHostGate","siteDefault","Promise","all","catch","attemptsByIp","Map","NO_CLIENT_ADDRESS_BUCKET","readClientIp","newsletterHandler","req","res","body","method","status","json","JSON","parse","trim","toLowerCase","test","ip","headers","remoteAddress","socket","now","Date","attempts","at","push","set","length","campaignTouch","wire","atMs","identity","interaction","refId","summary","lifecycleFloor","detail","confirming","ok","confirmationRequired"],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
* Which products still sell one library asset as paid media: a members video
|
|
19
|
+
* (AGL-2814) or a paid download (AGL-2847).
|
|
20
|
+
*
|
|
21
|
+
* Paid media is private, and the commerce routes refuse to deliver a file
|
|
22
|
+
* that is not. That leaves one way to put it back in public: "Publish file"
|
|
23
|
+
* in the media library, which gives the asset its permanent CDN URL again.
|
|
24
|
+
* That URL names the same asset as every signed link a buyer was ever handed,
|
|
25
|
+
* so stripping `exp` and `sig` off any of them would start working again. The
|
|
26
|
+
* media route asks this before it publishes, and refuses while the answer is
|
|
27
|
+
* not empty.
|
|
28
|
+
*
|
|
29
|
+
* It reads the products themselves rather than a marker stamped on the media
|
|
30
|
+
* document. A marker is one more thing every product writer has to keep true,
|
|
31
|
+
* and the products hub, the CSV import, duplication and a direct edit all
|
|
32
|
+
* write these lists. The products cannot disagree with themselves.
|
|
33
|
+
*/
|
|
34
|
+
/** The product fields that hold paid media, each a list of `{ url }`. */
|
|
35
|
+
export declare const PAID_MEDIA_LISTS: readonly ["gatedVideos", "digitalFiles"];
|
|
36
|
+
/** A product that sells an asset as paid media. */
|
|
37
|
+
export interface PaidMediaUse {
|
|
38
|
+
hostId: string;
|
|
39
|
+
productId: string;
|
|
40
|
+
productName: string;
|
|
41
|
+
}
|
|
42
|
+
/** What the scan found, and whether it read everything it needed to. */
|
|
43
|
+
export interface PaidMediaUses {
|
|
44
|
+
uses: PaidMediaUse[];
|
|
45
|
+
/**
|
|
46
|
+
* False when a ceiling stopped the scan. An incomplete "no product sells
|
|
47
|
+
* this" is not an answer anyone may publish on.
|
|
48
|
+
*/
|
|
49
|
+
complete: boolean;
|
|
50
|
+
}
|
|
51
|
+
/** Sites read for an org-library asset before the scan calls itself partial. */
|
|
52
|
+
export declare const PAID_MEDIA_USE_HOST_CEILING = 200;
|
|
53
|
+
/** Products read per site before the scan calls itself partial. */
|
|
54
|
+
export declare const PAID_MEDIA_USE_PRODUCT_CEILING = 5000;
|
|
55
|
+
interface QueryLike {
|
|
56
|
+
where(field: string, op: '==', value: unknown): QueryLike;
|
|
57
|
+
select(...fields: string[]): QueryLike;
|
|
58
|
+
limit(count: number): QueryLike;
|
|
59
|
+
get(): Promise<{
|
|
60
|
+
size: number;
|
|
61
|
+
docs: {
|
|
62
|
+
id: string;
|
|
63
|
+
get(field: string): unknown;
|
|
64
|
+
}[];
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
67
|
+
/** The part of Firestore the scan reads through. */
|
|
68
|
+
export interface PaidMediaUsesFirestore {
|
|
69
|
+
collection(name: string): QueryLike & {
|
|
70
|
+
doc(id: string): {
|
|
71
|
+
collection(name: string): QueryLike;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Every product selling the asset `mediaId` in the library at `base` —
|
|
77
|
+
* `hosts/{hostId}` or `orgs/{orgId}` — as paid media.
|
|
78
|
+
*
|
|
79
|
+
* `bucket` is the platform's media bucket, so a raw download URL from any
|
|
80
|
+
* other bucket is never mistaken for this asset.
|
|
81
|
+
*/
|
|
82
|
+
export declare function findPaidMediaUses(options: {
|
|
83
|
+
firestore: PaidMediaUsesFirestore;
|
|
84
|
+
base: string;
|
|
85
|
+
mediaId: string;
|
|
86
|
+
bucket?: string;
|
|
87
|
+
}): Promise<PaidMediaUses>;
|
|
88
|
+
/** What the media library tells an author whose publish was refused. */
|
|
89
|
+
export declare function paidMediaPublishRefusal(result: PaidMediaUses): string;
|
|
90
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
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 { paidMediaAssetOf, samePaidMediaLibrary } from "@aglyn/aglyn/server";
|
|
17
|
+
/**
|
|
18
|
+
* Which products still sell one library asset as paid media: a members video
|
|
19
|
+
* (AGL-2814) or a paid download (AGL-2847).
|
|
20
|
+
*
|
|
21
|
+
* Paid media is private, and the commerce routes refuse to deliver a file
|
|
22
|
+
* that is not. That leaves one way to put it back in public: "Publish file"
|
|
23
|
+
* in the media library, which gives the asset its permanent CDN URL again.
|
|
24
|
+
* That URL names the same asset as every signed link a buyer was ever handed,
|
|
25
|
+
* so stripping `exp` and `sig` off any of them would start working again. The
|
|
26
|
+
* media route asks this before it publishes, and refuses while the answer is
|
|
27
|
+
* not empty.
|
|
28
|
+
*
|
|
29
|
+
* It reads the products themselves rather than a marker stamped on the media
|
|
30
|
+
* document. A marker is one more thing every product writer has to keep true,
|
|
31
|
+
* and the products hub, the CSV import, duplication and a direct edit all
|
|
32
|
+
* write these lists. The products cannot disagree with themselves.
|
|
33
|
+
*/ /** The product fields that hold paid media, each a list of `{ url }`. */ export const PAID_MEDIA_LISTS = [
|
|
34
|
+
'gatedVideos',
|
|
35
|
+
'digitalFiles'
|
|
36
|
+
];
|
|
37
|
+
/** Sites read for an org-library asset before the scan calls itself partial. */ export const PAID_MEDIA_USE_HOST_CEILING = 200;
|
|
38
|
+
/** Products read per site before the scan calls itself partial. */ export const PAID_MEDIA_USE_PRODUCT_CEILING = 5000;
|
|
39
|
+
/** Sites whose catalogs are read at once. */ const HOST_CONCURRENCY = 8;
|
|
40
|
+
/**
|
|
41
|
+
* Every product selling the asset `mediaId` in the library at `base` —
|
|
42
|
+
* `hosts/{hostId}` or `orgs/{orgId}` — as paid media.
|
|
43
|
+
*
|
|
44
|
+
* `bucket` is the platform's media bucket, so a raw download URL from any
|
|
45
|
+
* other bucket is never mistaken for this asset.
|
|
46
|
+
*/ export async function findPaidMediaUses(options) {
|
|
47
|
+
var _options_base;
|
|
48
|
+
const { firestore, mediaId } = options;
|
|
49
|
+
const [root, scopeId] = String((_options_base = options.base) != null ? _options_base : '').split('/');
|
|
50
|
+
if (root !== 'hosts' && root !== 'orgs' || !scopeId || !mediaId) {
|
|
51
|
+
return {
|
|
52
|
+
uses: [],
|
|
53
|
+
complete: false
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const library = root === 'orgs' ? `org:${scopeId}` : scopeId;
|
|
57
|
+
let complete = true;
|
|
58
|
+
let hostIds;
|
|
59
|
+
if (root === 'orgs') {
|
|
60
|
+
const hosts = await firestore.collection('hosts').where('orgId', '==', scopeId).limit(PAID_MEDIA_USE_HOST_CEILING + 1).get();
|
|
61
|
+
if (hosts.size > PAID_MEDIA_USE_HOST_CEILING) complete = false;
|
|
62
|
+
hostIds = hosts.docs.slice(0, PAID_MEDIA_USE_HOST_CEILING).map((host)=>host.id);
|
|
63
|
+
} else {
|
|
64
|
+
// A site's own library can only be sold by that site.
|
|
65
|
+
hostIds = [
|
|
66
|
+
scopeId
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
const namesThisAsset = (entry)=>{
|
|
70
|
+
const asset = paidMediaAssetOf(entry == null ? void 0 : entry.url, {
|
|
71
|
+
bucket: options.bucket
|
|
72
|
+
});
|
|
73
|
+
return (asset == null ? void 0 : asset.mediaId) === mediaId && samePaidMediaLibrary(asset.scope, library);
|
|
74
|
+
};
|
|
75
|
+
const uses = [];
|
|
76
|
+
for(let index = 0; index < hostIds.length; index += HOST_CONCURRENCY){
|
|
77
|
+
const batch = hostIds.slice(index, index + HOST_CONCURRENCY);
|
|
78
|
+
const catalogs = await Promise.all(batch.map(async (hostId)=>({
|
|
79
|
+
hostId,
|
|
80
|
+
products: await firestore.collection('hosts').doc(hostId).collection('products').select('name', 'deletedAt', ...PAID_MEDIA_LISTS).limit(PAID_MEDIA_USE_PRODUCT_CEILING + 1).get()
|
|
81
|
+
})));
|
|
82
|
+
for (const { hostId, products } of catalogs){
|
|
83
|
+
if (products.size > PAID_MEDIA_USE_PRODUCT_CEILING) complete = false;
|
|
84
|
+
for (const product of products.docs.slice(0, PAID_MEDIA_USE_PRODUCT_CEILING)){
|
|
85
|
+
// A deleted product sells nothing. Restoring one brings back paid media
|
|
86
|
+
// the commerce routes refuse until the file is private again.
|
|
87
|
+
if (product.get('deletedAt')) continue;
|
|
88
|
+
const sells = PAID_MEDIA_LISTS.some((list)=>{
|
|
89
|
+
const entries = product.get(list);
|
|
90
|
+
return Array.isArray(entries) && entries.some(namesThisAsset);
|
|
91
|
+
});
|
|
92
|
+
if (sells) {
|
|
93
|
+
var _product_get;
|
|
94
|
+
uses.push({
|
|
95
|
+
hostId,
|
|
96
|
+
productId: product.id,
|
|
97
|
+
productName: String((_product_get = product.get('name')) != null ? _product_get : '') || product.id
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
uses,
|
|
105
|
+
complete
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** What the media library tells an author whose publish was refused. */ export function paidMediaPublishRefusal(result) {
|
|
109
|
+
const { uses } = result;
|
|
110
|
+
if (!uses.length) {
|
|
111
|
+
return 'We could not check every product that might sell this file, so it ' + 'stays private. Remove it from any product that uses it, then publish ' + 'it again.';
|
|
112
|
+
}
|
|
113
|
+
const names = uses.slice(0, 3).map((use)=>`“${use.productName}”`).join(', ');
|
|
114
|
+
const more = uses.length > 3 ? ` and ${uses.length - 3} more` : '';
|
|
115
|
+
const which = uses.length === 1 ? 'that product' : 'those products';
|
|
116
|
+
return `This file is sold on ${names}${more}. Remove it from ${which} before ` + 'publishing it: a public copy would let anyone have it without buying.';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
//# sourceMappingURL=paid-media-uses.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/paid-media-uses.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 { paidMediaAssetOf, samePaidMediaLibrary } from '@aglyn/aglyn/server'\n\n/**\n * Which products still sell one library asset as paid media: a members video\n * (AGL-2814) or a paid download (AGL-2847).\n *\n * Paid media is private, and the commerce routes refuse to deliver a file\n * that is not. That leaves one way to put it back in public: \"Publish file\"\n * in the media library, which gives the asset its permanent CDN URL again.\n * That URL names the same asset as every signed link a buyer was ever handed,\n * so stripping `exp` and `sig` off any of them would start working again. The\n * media route asks this before it publishes, and refuses while the answer is\n * not empty.\n *\n * It reads the products themselves rather than a marker stamped on the media\n * document. A marker is one more thing every product writer has to keep true,\n * and the products hub, the CSV import, duplication and a direct edit all\n * write these lists. The products cannot disagree with themselves.\n */\n\n/** The product fields that hold paid media, each a list of `{ url }`. */\nexport const PAID_MEDIA_LISTS = ['gatedVideos', 'digitalFiles'] as const\n\n/** A product that sells an asset as paid media. */\nexport interface PaidMediaUse {\n hostId: string\n productId: string\n productName: string\n}\n\n/** What the scan found, and whether it read everything it needed to. */\nexport interface PaidMediaUses {\n uses: PaidMediaUse[]\n /**\n * False when a ceiling stopped the scan. An incomplete \"no product sells\n * this\" is not an answer anyone may publish on.\n */\n complete: boolean\n}\n\n/** Sites read for an org-library asset before the scan calls itself partial. */\nexport const PAID_MEDIA_USE_HOST_CEILING = 200\n\n/** Products read per site before the scan calls itself partial. */\nexport const PAID_MEDIA_USE_PRODUCT_CEILING = 5000\n\n/** Sites whose catalogs are read at once. */\nconst HOST_CONCURRENCY = 8\n\ninterface QueryLike {\n where(field: string, op: '==', value: unknown): QueryLike\n select(...fields: string[]): QueryLike\n limit(count: number): QueryLike\n get(): Promise<{\n size: number\n docs: { id: string; get(field: string): unknown }[]\n }>\n}\n\n/** The part of Firestore the scan reads through. */\nexport interface PaidMediaUsesFirestore {\n collection(name: string): QueryLike & {\n doc(id: string): { collection(name: string): QueryLike }\n }\n}\n\n/**\n * Every product selling the asset `mediaId` in the library at `base` —\n * `hosts/{hostId}` or `orgs/{orgId}` — as paid media.\n *\n * `bucket` is the platform's media bucket, so a raw download URL from any\n * other bucket is never mistaken for this asset.\n */\nexport async function findPaidMediaUses(options: {\n firestore: PaidMediaUsesFirestore\n base: string\n mediaId: string\n bucket?: string\n}): Promise<PaidMediaUses> {\n const { firestore, mediaId } = options\n const [root, scopeId] = String(options.base ?? '').split('/')\n if ((root !== 'hosts' && root !== 'orgs') || !scopeId || !mediaId) {\n return { uses: [], complete: false }\n }\n const library = root === 'orgs' ? `org:${scopeId}` : scopeId\n let complete = true\n\n let hostIds: string[]\n if (root === 'orgs') {\n const hosts = await firestore\n .collection('hosts')\n .where('orgId', '==', scopeId)\n .limit(PAID_MEDIA_USE_HOST_CEILING + 1)\n .get()\n if (hosts.size > PAID_MEDIA_USE_HOST_CEILING) complete = false\n hostIds = hosts.docs\n .slice(0, PAID_MEDIA_USE_HOST_CEILING)\n .map((host) => host.id)\n } else {\n // A site's own library can only be sold by that site.\n hostIds = [scopeId]\n }\n\n const namesThisAsset = (entry: unknown) => {\n const asset = paidMediaAssetOf((entry as { url?: unknown } | null)?.url, {\n bucket: options.bucket,\n })\n return asset?.mediaId === mediaId && samePaidMediaLibrary(asset.scope, library)\n }\n\n const uses: PaidMediaUse[] = []\n for (let index = 0; index < hostIds.length; index += HOST_CONCURRENCY) {\n const batch = hostIds.slice(index, index + HOST_CONCURRENCY)\n const catalogs = await Promise.all(\n batch.map(async (hostId) => ({\n hostId,\n products: await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('products')\n .select('name', 'deletedAt', ...PAID_MEDIA_LISTS)\n .limit(PAID_MEDIA_USE_PRODUCT_CEILING + 1)\n .get(),\n })),\n )\n for (const { hostId, products } of catalogs) {\n if (products.size > PAID_MEDIA_USE_PRODUCT_CEILING) complete = false\n for (const product of products.docs.slice(0, PAID_MEDIA_USE_PRODUCT_CEILING)) {\n // A deleted product sells nothing. Restoring one brings back paid media\n // the commerce routes refuse until the file is private again.\n if (product.get('deletedAt')) continue\n const sells = PAID_MEDIA_LISTS.some((list) => {\n const entries = product.get(list)\n return Array.isArray(entries) && entries.some(namesThisAsset)\n })\n if (sells) {\n uses.push({\n hostId,\n productId: product.id,\n productName: String(product.get('name') ?? '') || product.id,\n })\n }\n }\n }\n }\n return { uses, complete }\n}\n\n/** What the media library tells an author whose publish was refused. */\nexport function paidMediaPublishRefusal(result: PaidMediaUses): string {\n const { uses } = result\n if (!uses.length) {\n return (\n 'We could not check every product that might sell this file, so it ' +\n 'stays private. Remove it from any product that uses it, then publish ' +\n 'it again.'\n )\n }\n const names = uses\n .slice(0, 3)\n .map((use) => `“${use.productName}”`)\n .join(', ')\n const more = uses.length > 3 ? ` and ${uses.length - 3} more` : ''\n const which = uses.length === 1 ? 'that product' : 'those products'\n return (\n `This file is sold on ${names}${more}. Remove it from ${which} before ` +\n 'publishing it: a public copy would let anyone have it without buying.'\n )\n}\n"],"names":["paidMediaAssetOf","samePaidMediaLibrary","PAID_MEDIA_LISTS","PAID_MEDIA_USE_HOST_CEILING","PAID_MEDIA_USE_PRODUCT_CEILING","HOST_CONCURRENCY","findPaidMediaUses","options","firestore","mediaId","root","scopeId","String","base","split","uses","complete","library","hostIds","hosts","collection","where","limit","get","size","docs","slice","map","host","id","namesThisAsset","entry","asset","url","bucket","scope","index","length","batch","catalogs","Promise","all","hostId","products","doc","select","product","sells","some","list","entries","Array","isArray","push","productId","productName","paidMediaPublishRefusal","result","names","use","join","more","which"],"mappings":"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"}
|
package/src/lib/server.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { isEmailConfigured } from "@aglyn/shared-util-email";
|
|
25
25
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
26
26
|
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
27
|
+
import { registerCommerceMediaPublishGuard } from "./server/media-publish-guard.js";
|
|
27
28
|
import { registerProductCardReader } from "./server/product-card.js";
|
|
28
29
|
import { registerCommerceTaxProfile } from "./server/tax-profile.js";
|
|
29
30
|
import { commerceBillingWebhookHandler } from "./server/billing-webhook.js";
|
|
@@ -254,6 +255,11 @@ registerPluginJob({
|
|
|
254
255
|
// What a product looks like to a surface that is not this plugin's — a
|
|
255
256
|
// campaign email that features one asks here rather than importing the model.
|
|
256
257
|
registerProductCardReader();
|
|
258
|
+
// …and why a file somebody is SELLING may not be made public (AGL-3080).
|
|
259
|
+
// The media library asks before it hands an asset its permanent CDN URL
|
|
260
|
+
// back; what a product is, and which of its fields hold paid media, is
|
|
261
|
+
// this plugin's to know.
|
|
262
|
+
registerCommerceMediaPublishGuard();
|
|
257
263
|
// …and the tax rule, for the webhook that confirms what they charged.
|
|
258
264
|
registerCommerceTaxProfile();
|
|
259
265
|
// Stripe webhook sections (AGL-418): orders/carts/drafts/reservations/
|
package/src/lib/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/commerce/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\n/**\n * Server half of the commerce plugin (AGL-396): the site-facing storefront\n * API handlers, registered with the plugin API registry and served by the\n * tenant dispatcher at their unchanged `/api/commerce/*` URLs. This module\n * pulls in firebase-admin + Stripe, so it is NOT re-exported from the client\n * barrel — apps import `@aglyn/plugins-commerce/server` only from their\n * (server-only) API dispatcher registration.\n */\n\nimport {\n registerBillingWebhookHandler,\n registerOrderFulfilmentService,\n registerPluginApiRoute,\n registerPluginJob,\n registerSitePageEnricher,\n registerSitePageResolver,\n registerPluginPermissions,\n registerPluginConfigSchema,\n} from '@aglyn/aglyn/server'\nimport { isEmailConfigured } from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerProductCardReader } from './server/product-card'\nimport { registerCommerceTaxProfile } from './server/tax-profile'\nimport { commerceBillingWebhookHandler } from './server/billing-webhook'\nimport { registerOrderFigureReaders } from './server/order-figures'\nimport { COMMERCE_PERMISSIONS } from './model/plugin-permissions'\nimport { COMMERCE_CONFIG_SCHEMA } from './plugin-config'\nimport { commerceSitePageEnricher } from './server/site-page-enricher'\nimport { commerceSitePageResolver } from './server/site-page-resolver'\nimport { cartCheckoutHandler } from './server/cart-checkout'\nimport { cartHandler } from './server/cart'\nimport { catalogHandler } from './server/catalog'\nimport { checkoutHandler } from './server/checkout'\nimport { downloadHandler } from './server/download'\nimport { feedHandler } from './server/feed'\nimport { newsletterHandler } from './server/newsletter'\nimport { notifyRestockHandler } from './server/notify-restock'\nimport { productHandler } from './server/product'\nimport { relatedHandler } from './server/related'\nimport { reservationAvailabilityHandler } from './server/reservation-availability'\nimport { gateHandler } from './server/gate'\nimport { memberFeedHandler } from './server/member-feed'\nimport { membershipAccountHandler } from './server/membership-account'\nimport { membershipAdminPasswordHandler } from './server/membership-admin-password'\nimport { membershipContentHandler } from './server/membership-content'\nimport { membershipLoginHandler } from './server/membership-login'\nimport { membershipLogoutHandler } from './server/membership-logout'\nimport { membershipRecoverHandler } from './server/membership-recover'\nimport { membershipRegisterHandler } from './server/membership-register'\nimport { membershipResetHandler } from './server/membership-reset'\nimport { membershipWishlistHandler } from './server/membership-wishlist'\nimport { reserveHandler } from './server/reserve'\nimport { streamHandler } from './server/stream'\nimport { subscriptionPortalHandler } from './server/subscription-portal'\nimport { reviewsHandler } from './server/reviews'\nimport { connectHandler } from './server/connect'\nimport { cancelOrderHandler } from './server/cancel-order'\nimport { draftOrderHandler } from './server/draft-order'\nimport {\n fulfillOrderHandler,\n recordOrderShipment,\n} from './server/fulfill-order'\nimport { giftCardsHandler } from './server/gift-cards'\nimport { memberPostHandler } from './server/member-post'\nimport { orderAnalyticsHandler } from './server/order-analytics'\nimport { posOrderHandler } from './server/pos-order'\nimport {\n processAbandonedHandler,\n scanAbandonedCheckouts,\n} from './server/process-abandoned'\nimport { processRestockHandler, scanRestockAlerts } from './server/process-restock'\nimport { scanStockDecrements } from './server/reconcile-stock'\nimport { refundHandler } from './server/refund'\nimport { scanSupplierDeliveries } from './server/supplier-outbox'\nimport { supplierUpdateHandler } from './server/supplier-update'\n\n/**\n * The two commerce beats (AGL-2227).\n *\n * `commerce/process-abandoned` and `commerce/process-restock` have existed\n * since AGL-323/326 as `x-cron-secret` HTTP doors, and `registerCommerceConsoleApi`\n * below has called them \"the scheduler-driven jobs\" in a comment that whole\n * time. **Nothing scheduled them.** Not `scheduled-crons.yml` (its table names\n * 11 paths; neither of these), not `vercel.json` (no `crons` key at all), not\n * `registerPluginJob` — the only two registrations in the repo were scheduled\n * publishing and the bookings hold-expiry.\n *\n * The cost was a Pro-tier entitlement (`abandonedCart`) that had never sent an\n * email, and a storefront \"notify me when it's back\" form writing into a queue\n * with no drain. AGL-1793 had already added the collection-group indexes these\n * two scans need, which is the clearest evidence they were always meant to run.\n *\n * Module scope, like the bookings job beside it: the runner route reaches jobs\n * through `ensureAll(['tenantApi'])`, and a registration inside a `register*`\n * function would depend on which entry point happened to be loaded.\n *\n * 15 minutes, not 1: `process-abandoned` will not remind a checkout younger\n * than an hour and `process-restock` only mails alerts whose product already\n * has stock, so a tighter beat buys nothing and costs two collection-group\n * scans a minute. Both are bounded (200 docs) and idempotent — each pass\n * stamps what it sent — so an overlapping or repeated beat cannot double-send.\n */\nconst RECOVERY_JOB_INTERVAL_MINUTES = 15\n\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'abandoned-checkout-recovery',\n intervalMinutes: RECOVERY_JOB_INTERVAL_MINUTES,\n description:\n 'Email one recovery reminder per stalled checkout (abandonedCart plans).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n // Quietly, not as an error: email is optional per deployment, and a beat\n // that logs every minute on a self-host without Resend buries everything\n // else in the log.\n if (!isEmailConfigured()) return\n const { sent } = await scanAbandonedCheckouts(gate)\n if (sent) console.info(`commerce: sent ${sent} abandoned-cart reminders`)\n },\n})\n\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'back-in-stock-alerts',\n intervalMinutes: RECOVERY_JOB_INTERVAL_MINUTES,\n description: 'Email shoppers whose requested product is in stock again.',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n if (!isEmailConfigured()) return\n const { sent } = await scanRestockAlerts(gate)\n if (sent) console.info(`commerce: sent ${sent} back-in-stock alerts`)\n },\n})\n\n/**\n * The missing-decrement detector (AGL-2358).\n *\n * HOURLY, not on the 15-minute recovery beat: it is a detector for a rare\n * process death, not a queue drain, and nothing it finds gets less true for\n * waiting an hour. The cost of the beat is what sets the interval — one\n * collection-group read of the platform's most recent orders, plus two\n * queries per host that has any — and an hour keeps that off the same tick as\n * the two scans above.\n *\n * NOT gated on `isEmailConfigured()`, unlike its two neighbours. Those send\n * mail; this writes a console notification and a log line, both of which work\n * on a self-host with no mail provider at all — and a stock count that is\n * silently wrong is exactly what a self-hoster least wants suppressed by a\n * setting about email.\n */\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'stock-decrement-reconciliation',\n intervalMinutes: 60,\n description:\n 'Report paid orders whose stock decrement never landed (AGL-2358).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n const scan = await scanStockDecrements(gate)\n if (scan.missingLines || scan.truncatedHosts) {\n console.warn(\n `commerce: ${scan.missingLines} order lines across ${scan.hosts} ` +\n `sites have no sale ledger row (${scan.reportedOrders} newly ` +\n `reported, ${scan.truncatedHosts} sites' ledger window truncated)`,\n )\n }\n },\n})\n\n/**\n * The dropship supplier outbox drain (AGL-2473).\n *\n * EVERY MINUTE, unlike the three above, and the interval is the point rather\n * than an oversight. This is the only one of the four that a BUYER is waiting\n * on: the row it drains is a paid order that has not been routed to whoever\n * ships it, and every minute it sits is a minute the parcel is not moving. The\n * first backoff step is also 60s, so a supplier that blipped is retried on the\n * next tick.\n *\n * The cost that set fifteen minutes for the recovery pair does not apply. Those\n * are collection-GROUP scans across every site on the platform; this is one\n * ordinary equality query against a top-level collection that is EMPTY in the\n * ordinary case — an empty result bills a single read, so the whole beat is\n * ~1,440 reads a day whether or not anyone is dropshipping.\n *\n * Not gated on `isEmailConfigured()`. The supplier notification is an HTTP POST\n * to the merchant's own supplier, and a self-host with no mail provider still\n * has dropship orders to route.\n */\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'supplier-webhook-delivery',\n intervalMinutes: 1,\n description:\n 'Deliver queued dropship supplier notifications, with backoff and a ' +\n 'dead letter that tells the merchant (AGL-2473).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n const scan = await scanSupplierDeliveries(gate)\n if (scan.deadLettered) {\n console.warn(\n `commerce: ${scan.deadLettered} dropship orders could not be routed ` +\n `to their supplier and have been reported on the order`,\n )\n }\n },\n})\n\n/** Registers the commerce plugin's storefront API routes. */\nexport function registerCommerceApi(): void {\n registerProductCardReader()\n // The merchant's tax rule, for every plugin that prices a charge here.\n registerCommerceTaxProfile()\n registerPluginPermissions(COMMERCE_PERMISSIONS)\n // Merchant-settable register discount ceiling (AGL-2161). Registered on\n // BOTH surfaces, like the permissions above: the POS route reads it\n // server-side, and the settings card renders it from the same schema.\n registerPluginConfigSchema(COMMERCE_CONFIG_SCHEMA)\n // PDP/PLP template pages (AGL-418): /products/* + /collections/*.\n registerSitePageResolver(commerceSitePageResolver)\n // Seeds product grids on ordinary screens (AGL-659) — the resolver above\n // only covers /products/* and /collections/*, so /products itself, and any\n // designed page with a grid on it, needed this to render server-side.\n registerSitePageEnricher(commerceSitePageEnricher)\n registerPluginApiRoute('commerce/cart-checkout', cartCheckoutHandler)\n registerPluginApiRoute('commerce/cart', cartHandler)\n registerPluginApiRoute('commerce/catalog', catalogHandler)\n registerPluginApiRoute('commerce/checkout', checkoutHandler)\n registerPluginApiRoute('commerce/download', downloadHandler)\n registerPluginApiRoute('commerce/feed', feedHandler)\n registerPluginApiRoute('commerce/newsletter', newsletterHandler)\n registerPluginApiRoute('commerce/notify-restock', notifyRestockHandler)\n // GA-safe order projection for the storefront `purchase` (AGL-1641).\n registerPluginApiRoute('commerce/order-analytics', orderAnalyticsHandler)\n registerPluginApiRoute('commerce/product', productHandler)\n registerPluginApiRoute('commerce/related', relatedHandler)\n registerPluginApiRoute('commerce/reservation-availability', reservationAvailabilityHandler)\n registerPluginApiRoute('commerce/reserve', reserveHandler)\n registerPluginApiRoute('commerce/gate', gateHandler)\n registerPluginApiRoute('commerce/member-feed', memberFeedHandler)\n registerPluginApiRoute('commerce/stream', streamHandler)\n registerPluginApiRoute('commerce/subscription-portal', subscriptionPortalHandler)\n registerPluginApiRoute('commerce/reviews', reviewsHandler)\n registerPluginApiRoute('membership/account', membershipAccountHandler)\n // Console-driven password help for a member (AGL-914). Console-auth, not\n // the visitor cookie the neighbouring routes take.\n registerPluginApiRoute(\n 'membership/admin-password',\n membershipAdminPasswordHandler,\n )\n registerPluginApiRoute('membership/content', membershipContentHandler)\n registerPluginApiRoute('membership/login', membershipLoginHandler)\n registerPluginApiRoute('membership/logout', membershipLogoutHandler)\n // Password recovery pair (AGL-552): request + complete.\n registerPluginApiRoute('membership/recover', membershipRecoverHandler)\n registerPluginApiRoute('membership/register', membershipRegisterHandler)\n registerPluginApiRoute('membership/reset', membershipResetHandler)\n registerPluginApiRoute('membership/wishlist', membershipWishlistHandler)\n}\n\n/**\n * Registers the commerce plugin's console-side API routes (AGL-396):\n * merchant/staff operations (Connect onboarding, refunds, draft & POS\n * orders, member posts), the supplier tracking callback, and the HTTP doors\n * for the abandoned-cart / restock passes.\n *\n * Those last two are NOT what schedules them — the `registerPluginJob` calls\n * above are (AGL-2227). This comment used to call them \"the scheduler-driven\n * jobs\", which is how they stayed dark for months: it asserted the wiring\n * instead of having it.\n */\nexport function registerCommerceConsoleApi(): void {\n // What a product looks like to a surface that is not this plugin's — a\n // campaign email that features one asks here rather than importing the model.\n registerProductCardReader()\n // …and the tax rule, for the webhook that confirms what they charged.\n registerCommerceTaxProfile()\n // Stripe webhook sections (AGL-418): orders/carts/drafts/reservations/\n // subscriptions ride the platform webhook via the hook registry.\n registerBillingWebhookHandler(commerceBillingWebhookHandler)\n // Cancel + stock release in one transaction (AGL-1808). Server-side because\n // the release depends on the transition rule, and a client write could not\n // re-ask it under the same lock that flips the status.\n registerPluginApiRoute('commerce/cancel-order', cancelOrderHandler)\n registerPluginApiRoute('commerce/connect', connectHandler)\n registerPluginApiRoute('commerce/draft-order', draftOrderHandler)\n // Fulfil + mark-delivered with the transition re-asked under the write\n // (AGL-1819) — the same stale-dialog hole cancel-order closes, minus the\n // stock release those two transitions never had.\n registerPluginApiRoute('commerce/fulfill-order', fulfillOrderHandler)\n // The same transaction, offered to the HOST APP as a capability (AGL-2461)\n // so `/v1` can record a shipment without importing this library — which\n // `eslint.config.mjs`'s `scope:app` boundary forbids, and which is why the\n // public REST API could take an order and never ship it.\n //\n // Registered HERE, in the consoleApi surface, beside the route it shares an\n // implementation with: `/v1` reaches it through the console's server plugin\n // loader, the same `ensureAll(['consoleApi'])` batch the plugin API\n // dispatcher runs. A registration at module scope would instead depend on\n // which entry point a process happened to import.\n //\n // NOTHING ABOUT AUTHORIZATION CROSSES THIS LINE. The capability is a\n // pre-authorized domain operation and `/v1` does its own gating (key scope,\n // plan entitlement, org-owns-host, plugin-enabled-for-site) before it calls.\n registerOrderFulfilmentService({\n pluginId: BUNDLE_ID,\n recordShipment: recordOrderShipment,\n })\n // Issue / void store credit (AGL-2226). Server-side because the host\n // catch-all in the Firestore rules would otherwise let a client write\n // its own `balanceCents`, which checkout applies as amount-off.\n registerPluginApiRoute('commerce/gift-cards', giftCardsHandler)\n registerPluginApiRoute('commerce/member-post', memberPostHandler)\n registerPluginApiRoute('commerce/pos-order', posOrderHandler)\n registerPluginApiRoute('commerce/process-abandoned', processAbandonedHandler)\n registerPluginApiRoute('commerce/process-restock', processRestockHandler)\n registerPluginApiRoute('commerce/refund', refundHandler)\n registerPluginApiRoute('commerce/supplier-update', supplierUpdateHandler)\n // The store's sales as figure tables (AGL-2915), for the AI plugin's\n // insights to read by id rather than by reading orders. The console runs\n // insight jobs, so the console surface registers them.\n registerOrderFigureReaders(() => firebaseAdmin.app().firestore())\n}\n\n// Shared with the (still app-side) membership/account route.\nexport { mintDownloadToken } from './server/download'\n\n// Site-member session primitives, shared with the (still app-side)\n// membership/* routes until those migrate too (AGL-396).\nexport * from './server/membership'\n"],"names":["registerBillingWebhookHandler","registerOrderFulfilmentService","registerPluginApiRoute","registerPluginJob","registerSitePageEnricher","registerSitePageResolver","registerPluginPermissions","registerPluginConfigSchema","isEmailConfigured","firebaseAdmin","BUNDLE_ID","registerProductCardReader","registerCommerceTaxProfile","commerceBillingWebhookHandler","registerOrderFigureReaders","COMMERCE_PERMISSIONS","COMMERCE_CONFIG_SCHEMA","commerceSitePageEnricher","commerceSitePageResolver","cartCheckoutHandler","cartHandler","catalogHandler","checkoutHandler","downloadHandler","feedHandler","newsletterHandler","notifyRestockHandler","productHandler","relatedHandler","reservationAvailabilityHandler","gateHandler","memberFeedHandler","membershipAccountHandler","membershipAdminPasswordHandler","membershipContentHandler","membershipLoginHandler","membershipLogoutHandler","membershipRecoverHandler","membershipRegisterHandler","membershipResetHandler","membershipWishlistHandler","reserveHandler","streamHandler","subscriptionPortalHandler","reviewsHandler","connectHandler","cancelOrderHandler","draftOrderHandler","fulfillOrderHandler","recordOrderShipment","giftCardsHandler","memberPostHandler","orderAnalyticsHandler","posOrderHandler","processAbandonedHandler","scanAbandonedCheckouts","processRestockHandler","scanRestockAlerts","scanStockDecrements","refundHandler","scanSupplierDeliveries","supplierUpdateHandler","RECOVERY_JOB_INTERVAL_MINUTES","pluginId","name","intervalMinutes","description","lockdown","scope","handler","gate","sent","console","info","scan","missingLines","truncatedHosts","warn","hosts","reportedOrders","deadLettered","registerCommerceApi","registerCommerceConsoleApi","recordShipment","app","firestore","mintDownloadToken"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/commerce/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\n/**\n * Server half of the commerce plugin (AGL-396): the site-facing storefront\n * API handlers, registered with the plugin API registry and served by the\n * tenant dispatcher at their unchanged `/api/commerce/*` URLs. This module\n * pulls in firebase-admin + Stripe, so it is NOT re-exported from the client\n * barrel — apps import `@aglyn/plugins-commerce/server` only from their\n * (server-only) API dispatcher registration.\n */\n\nimport {\n registerBillingWebhookHandler,\n registerOrderFulfilmentService,\n registerPluginApiRoute,\n registerPluginJob,\n registerSitePageEnricher,\n registerSitePageResolver,\n registerPluginPermissions,\n registerPluginConfigSchema,\n} from '@aglyn/aglyn/server'\nimport { isEmailConfigured } from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerCommerceMediaPublishGuard } from './server/media-publish-guard'\nimport { registerProductCardReader } from './server/product-card'\nimport { registerCommerceTaxProfile } from './server/tax-profile'\nimport { commerceBillingWebhookHandler } from './server/billing-webhook'\nimport { registerOrderFigureReaders } from './server/order-figures'\nimport { COMMERCE_PERMISSIONS } from './model/plugin-permissions'\nimport { COMMERCE_CONFIG_SCHEMA } from './plugin-config'\nimport { commerceSitePageEnricher } from './server/site-page-enricher'\nimport { commerceSitePageResolver } from './server/site-page-resolver'\nimport { cartCheckoutHandler } from './server/cart-checkout'\nimport { cartHandler } from './server/cart'\nimport { catalogHandler } from './server/catalog'\nimport { checkoutHandler } from './server/checkout'\nimport { downloadHandler } from './server/download'\nimport { feedHandler } from './server/feed'\nimport { newsletterHandler } from './server/newsletter'\nimport { notifyRestockHandler } from './server/notify-restock'\nimport { productHandler } from './server/product'\nimport { relatedHandler } from './server/related'\nimport { reservationAvailabilityHandler } from './server/reservation-availability'\nimport { gateHandler } from './server/gate'\nimport { memberFeedHandler } from './server/member-feed'\nimport { membershipAccountHandler } from './server/membership-account'\nimport { membershipAdminPasswordHandler } from './server/membership-admin-password'\nimport { membershipContentHandler } from './server/membership-content'\nimport { membershipLoginHandler } from './server/membership-login'\nimport { membershipLogoutHandler } from './server/membership-logout'\nimport { membershipRecoverHandler } from './server/membership-recover'\nimport { membershipRegisterHandler } from './server/membership-register'\nimport { membershipResetHandler } from './server/membership-reset'\nimport { membershipWishlistHandler } from './server/membership-wishlist'\nimport { reserveHandler } from './server/reserve'\nimport { streamHandler } from './server/stream'\nimport { subscriptionPortalHandler } from './server/subscription-portal'\nimport { reviewsHandler } from './server/reviews'\nimport { connectHandler } from './server/connect'\nimport { cancelOrderHandler } from './server/cancel-order'\nimport { draftOrderHandler } from './server/draft-order'\nimport {\n fulfillOrderHandler,\n recordOrderShipment,\n} from './server/fulfill-order'\nimport { giftCardsHandler } from './server/gift-cards'\nimport { memberPostHandler } from './server/member-post'\nimport { orderAnalyticsHandler } from './server/order-analytics'\nimport { posOrderHandler } from './server/pos-order'\nimport {\n processAbandonedHandler,\n scanAbandonedCheckouts,\n} from './server/process-abandoned'\nimport { processRestockHandler, scanRestockAlerts } from './server/process-restock'\nimport { scanStockDecrements } from './server/reconcile-stock'\nimport { refundHandler } from './server/refund'\nimport { scanSupplierDeliveries } from './server/supplier-outbox'\nimport { supplierUpdateHandler } from './server/supplier-update'\n\n/**\n * The two commerce beats (AGL-2227).\n *\n * `commerce/process-abandoned` and `commerce/process-restock` have existed\n * since AGL-323/326 as `x-cron-secret` HTTP doors, and `registerCommerceConsoleApi`\n * below has called them \"the scheduler-driven jobs\" in a comment that whole\n * time. **Nothing scheduled them.** Not `scheduled-crons.yml` (its table names\n * 11 paths; neither of these), not `vercel.json` (no `crons` key at all), not\n * `registerPluginJob` — the only two registrations in the repo were scheduled\n * publishing and the bookings hold-expiry.\n *\n * The cost was a Pro-tier entitlement (`abandonedCart`) that had never sent an\n * email, and a storefront \"notify me when it's back\" form writing into a queue\n * with no drain. AGL-1793 had already added the collection-group indexes these\n * two scans need, which is the clearest evidence they were always meant to run.\n *\n * Module scope, like the bookings job beside it: the runner route reaches jobs\n * through `ensureAll(['tenantApi'])`, and a registration inside a `register*`\n * function would depend on which entry point happened to be loaded.\n *\n * 15 minutes, not 1: `process-abandoned` will not remind a checkout younger\n * than an hour and `process-restock` only mails alerts whose product already\n * has stock, so a tighter beat buys nothing and costs two collection-group\n * scans a minute. Both are bounded (200 docs) and idempotent — each pass\n * stamps what it sent — so an overlapping or repeated beat cannot double-send.\n */\nconst RECOVERY_JOB_INTERVAL_MINUTES = 15\n\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'abandoned-checkout-recovery',\n intervalMinutes: RECOVERY_JOB_INTERVAL_MINUTES,\n description:\n 'Email one recovery reminder per stalled checkout (abandonedCart plans).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n // Quietly, not as an error: email is optional per deployment, and a beat\n // that logs every minute on a self-host without Resend buries everything\n // else in the log.\n if (!isEmailConfigured()) return\n const { sent } = await scanAbandonedCheckouts(gate)\n if (sent) console.info(`commerce: sent ${sent} abandoned-cart reminders`)\n },\n})\n\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'back-in-stock-alerts',\n intervalMinutes: RECOVERY_JOB_INTERVAL_MINUTES,\n description: 'Email shoppers whose requested product is in stock again.',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n if (!isEmailConfigured()) return\n const { sent } = await scanRestockAlerts(gate)\n if (sent) console.info(`commerce: sent ${sent} back-in-stock alerts`)\n },\n})\n\n/**\n * The missing-decrement detector (AGL-2358).\n *\n * HOURLY, not on the 15-minute recovery beat: it is a detector for a rare\n * process death, not a queue drain, and nothing it finds gets less true for\n * waiting an hour. The cost of the beat is what sets the interval — one\n * collection-group read of the platform's most recent orders, plus two\n * queries per host that has any — and an hour keeps that off the same tick as\n * the two scans above.\n *\n * NOT gated on `isEmailConfigured()`, unlike its two neighbours. Those send\n * mail; this writes a console notification and a log line, both of which work\n * on a self-host with no mail provider at all — and a stock count that is\n * silently wrong is exactly what a self-hoster least wants suppressed by a\n * setting about email.\n */\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'stock-decrement-reconciliation',\n intervalMinutes: 60,\n description:\n 'Report paid orders whose stock decrement never landed (AGL-2358).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n const scan = await scanStockDecrements(gate)\n if (scan.missingLines || scan.truncatedHosts) {\n console.warn(\n `commerce: ${scan.missingLines} order lines across ${scan.hosts} ` +\n `sites have no sale ledger row (${scan.reportedOrders} newly ` +\n `reported, ${scan.truncatedHosts} sites' ledger window truncated)`,\n )\n }\n },\n})\n\n/**\n * The dropship supplier outbox drain (AGL-2473).\n *\n * EVERY MINUTE, unlike the three above, and the interval is the point rather\n * than an oversight. This is the only one of the four that a BUYER is waiting\n * on: the row it drains is a paid order that has not been routed to whoever\n * ships it, and every minute it sits is a minute the parcel is not moving. The\n * first backoff step is also 60s, so a supplier that blipped is retried on the\n * next tick.\n *\n * The cost that set fifteen minutes for the recovery pair does not apply. Those\n * are collection-GROUP scans across every site on the platform; this is one\n * ordinary equality query against a top-level collection that is EMPTY in the\n * ordinary case — an empty result bills a single read, so the whole beat is\n * ~1,440 reads a day whether or not anyone is dropshipping.\n *\n * Not gated on `isEmailConfigured()`. The supplier notification is an HTTP POST\n * to the merchant's own supplier, and a self-host with no mail provider still\n * has dropship orders to route.\n */\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'supplier-webhook-delivery',\n intervalMinutes: 1,\n description:\n 'Deliver queued dropship supplier notifications, with backoff and a ' +\n 'dead letter that tells the merchant (AGL-2473).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n const scan = await scanSupplierDeliveries(gate)\n if (scan.deadLettered) {\n console.warn(\n `commerce: ${scan.deadLettered} dropship orders could not be routed ` +\n `to their supplier and have been reported on the order`,\n )\n }\n },\n})\n\n/** Registers the commerce plugin's storefront API routes. */\nexport function registerCommerceApi(): void {\n registerProductCardReader()\n // The merchant's tax rule, for every plugin that prices a charge here.\n registerCommerceTaxProfile()\n registerPluginPermissions(COMMERCE_PERMISSIONS)\n // Merchant-settable register discount ceiling (AGL-2161). Registered on\n // BOTH surfaces, like the permissions above: the POS route reads it\n // server-side, and the settings card renders it from the same schema.\n registerPluginConfigSchema(COMMERCE_CONFIG_SCHEMA)\n // PDP/PLP template pages (AGL-418): /products/* + /collections/*.\n registerSitePageResolver(commerceSitePageResolver)\n // Seeds product grids on ordinary screens (AGL-659) — the resolver above\n // only covers /products/* and /collections/*, so /products itself, and any\n // designed page with a grid on it, needed this to render server-side.\n registerSitePageEnricher(commerceSitePageEnricher)\n registerPluginApiRoute('commerce/cart-checkout', cartCheckoutHandler)\n registerPluginApiRoute('commerce/cart', cartHandler)\n registerPluginApiRoute('commerce/catalog', catalogHandler)\n registerPluginApiRoute('commerce/checkout', checkoutHandler)\n registerPluginApiRoute('commerce/download', downloadHandler)\n registerPluginApiRoute('commerce/feed', feedHandler)\n registerPluginApiRoute('commerce/newsletter', newsletterHandler)\n registerPluginApiRoute('commerce/notify-restock', notifyRestockHandler)\n // GA-safe order projection for the storefront `purchase` (AGL-1641).\n registerPluginApiRoute('commerce/order-analytics', orderAnalyticsHandler)\n registerPluginApiRoute('commerce/product', productHandler)\n registerPluginApiRoute('commerce/related', relatedHandler)\n registerPluginApiRoute('commerce/reservation-availability', reservationAvailabilityHandler)\n registerPluginApiRoute('commerce/reserve', reserveHandler)\n registerPluginApiRoute('commerce/gate', gateHandler)\n registerPluginApiRoute('commerce/member-feed', memberFeedHandler)\n registerPluginApiRoute('commerce/stream', streamHandler)\n registerPluginApiRoute('commerce/subscription-portal', subscriptionPortalHandler)\n registerPluginApiRoute('commerce/reviews', reviewsHandler)\n registerPluginApiRoute('membership/account', membershipAccountHandler)\n // Console-driven password help for a member (AGL-914). Console-auth, not\n // the visitor cookie the neighbouring routes take.\n registerPluginApiRoute(\n 'membership/admin-password',\n membershipAdminPasswordHandler,\n )\n registerPluginApiRoute('membership/content', membershipContentHandler)\n registerPluginApiRoute('membership/login', membershipLoginHandler)\n registerPluginApiRoute('membership/logout', membershipLogoutHandler)\n // Password recovery pair (AGL-552): request + complete.\n registerPluginApiRoute('membership/recover', membershipRecoverHandler)\n registerPluginApiRoute('membership/register', membershipRegisterHandler)\n registerPluginApiRoute('membership/reset', membershipResetHandler)\n registerPluginApiRoute('membership/wishlist', membershipWishlistHandler)\n}\n\n/**\n * Registers the commerce plugin's console-side API routes (AGL-396):\n * merchant/staff operations (Connect onboarding, refunds, draft & POS\n * orders, member posts), the supplier tracking callback, and the HTTP doors\n * for the abandoned-cart / restock passes.\n *\n * Those last two are NOT what schedules them — the `registerPluginJob` calls\n * above are (AGL-2227). This comment used to call them \"the scheduler-driven\n * jobs\", which is how they stayed dark for months: it asserted the wiring\n * instead of having it.\n */\nexport function registerCommerceConsoleApi(): void {\n // What a product looks like to a surface that is not this plugin's — a\n // campaign email that features one asks here rather than importing the model.\n registerProductCardReader()\n // …and why a file somebody is SELLING may not be made public (AGL-3080).\n // The media library asks before it hands an asset its permanent CDN URL\n // back; what a product is, and which of its fields hold paid media, is\n // this plugin's to know.\n registerCommerceMediaPublishGuard()\n // …and the tax rule, for the webhook that confirms what they charged.\n registerCommerceTaxProfile()\n // Stripe webhook sections (AGL-418): orders/carts/drafts/reservations/\n // subscriptions ride the platform webhook via the hook registry.\n registerBillingWebhookHandler(commerceBillingWebhookHandler)\n // Cancel + stock release in one transaction (AGL-1808). Server-side because\n // the release depends on the transition rule, and a client write could not\n // re-ask it under the same lock that flips the status.\n registerPluginApiRoute('commerce/cancel-order', cancelOrderHandler)\n registerPluginApiRoute('commerce/connect', connectHandler)\n registerPluginApiRoute('commerce/draft-order', draftOrderHandler)\n // Fulfil + mark-delivered with the transition re-asked under the write\n // (AGL-1819) — the same stale-dialog hole cancel-order closes, minus the\n // stock release those two transitions never had.\n registerPluginApiRoute('commerce/fulfill-order', fulfillOrderHandler)\n // The same transaction, offered to the HOST APP as a capability (AGL-2461)\n // so `/v1` can record a shipment without importing this library — which\n // `eslint.config.mjs`'s `scope:app` boundary forbids, and which is why the\n // public REST API could take an order and never ship it.\n //\n // Registered HERE, in the consoleApi surface, beside the route it shares an\n // implementation with: `/v1` reaches it through the console's server plugin\n // loader, the same `ensureAll(['consoleApi'])` batch the plugin API\n // dispatcher runs. A registration at module scope would instead depend on\n // which entry point a process happened to import.\n //\n // NOTHING ABOUT AUTHORIZATION CROSSES THIS LINE. The capability is a\n // pre-authorized domain operation and `/v1` does its own gating (key scope,\n // plan entitlement, org-owns-host, plugin-enabled-for-site) before it calls.\n registerOrderFulfilmentService({\n pluginId: BUNDLE_ID,\n recordShipment: recordOrderShipment,\n })\n // Issue / void store credit (AGL-2226). Server-side because the host\n // catch-all in the Firestore rules would otherwise let a client write\n // its own `balanceCents`, which checkout applies as amount-off.\n registerPluginApiRoute('commerce/gift-cards', giftCardsHandler)\n registerPluginApiRoute('commerce/member-post', memberPostHandler)\n registerPluginApiRoute('commerce/pos-order', posOrderHandler)\n registerPluginApiRoute('commerce/process-abandoned', processAbandonedHandler)\n registerPluginApiRoute('commerce/process-restock', processRestockHandler)\n registerPluginApiRoute('commerce/refund', refundHandler)\n registerPluginApiRoute('commerce/supplier-update', supplierUpdateHandler)\n // The store's sales as figure tables (AGL-2915), for the AI plugin's\n // insights to read by id rather than by reading orders. The console runs\n // insight jobs, so the console surface registers them.\n registerOrderFigureReaders(() => firebaseAdmin.app().firestore())\n}\n\n// Shared with the (still app-side) membership/account route.\nexport { mintDownloadToken } from './server/download'\n\n// Site-member session primitives, shared with the (still app-side)\n// membership/* routes until those migrate too (AGL-396).\nexport * from './server/membership'\n"],"names":["registerBillingWebhookHandler","registerOrderFulfilmentService","registerPluginApiRoute","registerPluginJob","registerSitePageEnricher","registerSitePageResolver","registerPluginPermissions","registerPluginConfigSchema","isEmailConfigured","firebaseAdmin","BUNDLE_ID","registerCommerceMediaPublishGuard","registerProductCardReader","registerCommerceTaxProfile","commerceBillingWebhookHandler","registerOrderFigureReaders","COMMERCE_PERMISSIONS","COMMERCE_CONFIG_SCHEMA","commerceSitePageEnricher","commerceSitePageResolver","cartCheckoutHandler","cartHandler","catalogHandler","checkoutHandler","downloadHandler","feedHandler","newsletterHandler","notifyRestockHandler","productHandler","relatedHandler","reservationAvailabilityHandler","gateHandler","memberFeedHandler","membershipAccountHandler","membershipAdminPasswordHandler","membershipContentHandler","membershipLoginHandler","membershipLogoutHandler","membershipRecoverHandler","membershipRegisterHandler","membershipResetHandler","membershipWishlistHandler","reserveHandler","streamHandler","subscriptionPortalHandler","reviewsHandler","connectHandler","cancelOrderHandler","draftOrderHandler","fulfillOrderHandler","recordOrderShipment","giftCardsHandler","memberPostHandler","orderAnalyticsHandler","posOrderHandler","processAbandonedHandler","scanAbandonedCheckouts","processRestockHandler","scanRestockAlerts","scanStockDecrements","refundHandler","scanSupplierDeliveries","supplierUpdateHandler","RECOVERY_JOB_INTERVAL_MINUTES","pluginId","name","intervalMinutes","description","lockdown","scope","handler","gate","sent","console","info","scan","missingLines","truncatedHosts","warn","hosts","reportedOrders","deadLettered","registerCommerceApi","registerCommerceConsoleApi","recordShipment","app","firestore","mintDownloadToken"],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { PluginContactCaptureRequest } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture';
|
|
18
|
+
/**
|
|
19
|
+
* A plugin that keeps people, standing in for the one that does (AGL-3080).
|
|
20
|
+
*
|
|
21
|
+
* Commerce reports the people it meets — a newsletter signup, a membership registration and an order — through the contact-capture
|
|
22
|
+
* contract and imports no record system, so its specs stand a writer up the
|
|
23
|
+
* way the loader would. One plugin may not import another, which is why this
|
|
24
|
+
* is here rather than borrowed from the CRM.
|
|
25
|
+
*
|
|
26
|
+
* What these specs certify is that the door REPORTS the right capture: the
|
|
27
|
+
* address, the door's own word for itself, the stage floor and the consent.
|
|
28
|
+
* That the real record system turns that into the same contact it always
|
|
29
|
+
* wrote is held where both plugins can be reached — the CRM's own adapter
|
|
30
|
+
* specs and the forms-door specs in `apps/tenant`.
|
|
31
|
+
*
|
|
32
|
+
* Returns the list the writer fills, in the order the doors reported them.
|
|
33
|
+
*/
|
|
34
|
+
export declare function standInRecordSystem(): PluginContactCaptureRequest[];
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { registerPluginContactCaptureWriter } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
|
|
17
|
+
import { resetPluginServicesForTests } from "@aglyn/aglyn/plugin-manager/plugin-services";
|
|
18
|
+
/**
|
|
19
|
+
* A plugin that keeps people, standing in for the one that does (AGL-3080).
|
|
20
|
+
*
|
|
21
|
+
* Commerce reports the people it meets — a newsletter signup, a membership registration and an order — through the contact-capture
|
|
22
|
+
* contract and imports no record system, so its specs stand a writer up the
|
|
23
|
+
* way the loader would. One plugin may not import another, which is why this
|
|
24
|
+
* is here rather than borrowed from the CRM.
|
|
25
|
+
*
|
|
26
|
+
* What these specs certify is that the door REPORTS the right capture: the
|
|
27
|
+
* address, the door's own word for itself, the stage floor and the consent.
|
|
28
|
+
* That the real record system turns that into the same contact it always
|
|
29
|
+
* wrote is held where both plugins can be reached — the CRM's own adapter
|
|
30
|
+
* specs and the forms-door specs in `apps/tenant`.
|
|
31
|
+
*
|
|
32
|
+
* Returns the list the writer fills, in the order the doors reported them.
|
|
33
|
+
*/ export function standInRecordSystem() {
|
|
34
|
+
resetPluginServicesForTests();
|
|
35
|
+
const captured = [];
|
|
36
|
+
registerPluginContactCaptureWriter({
|
|
37
|
+
capture: async (request)=>{
|
|
38
|
+
captured.push(request);
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
contactId: `contact-${captured.length}`,
|
|
42
|
+
created: true
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}, {
|
|
46
|
+
pluginId: 'record-system'
|
|
47
|
+
});
|
|
48
|
+
return captured;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=stand-in-record-system.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/testing/stand-in-record-system.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 { registerPluginContactCaptureWriter } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport type { PluginContactCaptureRequest } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport { resetPluginServicesForTests } from '@aglyn/aglyn/plugin-manager/plugin-services'\n\n/**\n * A plugin that keeps people, standing in for the one that does (AGL-3080).\n *\n * Commerce reports the people it meets — a newsletter signup, a membership registration and an order — through the contact-capture\n * contract and imports no record system, so its specs stand a writer up the\n * way the loader would. One plugin may not import another, which is why this\n * is here rather than borrowed from the CRM.\n *\n * What these specs certify is that the door REPORTS the right capture: the\n * address, the door's own word for itself, the stage floor and the consent.\n * That the real record system turns that into the same contact it always\n * wrote is held where both plugins can be reached — the CRM's own adapter\n * specs and the forms-door specs in `apps/tenant`.\n *\n * Returns the list the writer fills, in the order the doors reported them.\n */\nexport function standInRecordSystem(): PluginContactCaptureRequest[] {\n resetPluginServicesForTests()\n const captured: PluginContactCaptureRequest[] = []\n registerPluginContactCaptureWriter(\n {\n capture: async (request) => {\n captured.push(request)\n return { ok: true, contactId: `contact-${captured.length}`, created: true }\n },\n },\n { pluginId: 'record-system' },\n )\n return captured\n}\n"],"names":["registerPluginContactCaptureWriter","resetPluginServicesForTests","standInRecordSystem","captured","capture","request","push","ok","contactId","length","created","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kCAAkC,QAAQ,qDAAoD;AAEvG,SAASC,2BAA2B,QAAQ,8CAA6C;AAEzF;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC;IACdD;IACA,MAAME,WAA0C,EAAE;IAClDH,mCACE;QACEI,SAAS,OAAOC;YACdF,SAASG,IAAI,CAACD;YACd,OAAO;gBAAEE,IAAI;gBAAMC,WAAW,CAAC,QAAQ,EAAEL,SAASM,MAAM,EAAE;gBAAEC,SAAS;YAAK;QAC5E;IACF,GACA;QAAEC,UAAU;IAAgB;IAE9B,OAAOR;AACT"}
|