@aglyn/plugins-commerce 1.0.0-beta.149 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-commerce",
3
- "version": "1.0.0-beta.149",
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.149",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.149",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.149",
31
- "@aglyn/shared-ui-next": "1.0.0-beta.149",
32
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.149",
33
- "@aglyn/shared-util-email": "1.0.0-beta.149",
34
- "@aglyn/shared-util-http": "1.0.0-beta.149",
35
- "@aglyn/shared-util-timestamp": "1.0.0-beta.149",
36
- "@aglyn/shared-util-tools": "1.0.0-beta.149",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.149",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.149",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.149",
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"}
@@ -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/
@@ -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":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD;;;;;;;CAOC,GAED,SACEA,6BAA6B,EAC7BC,8BAA8B,EAC9BC,sBAAsB,EACtBC,iBAAiB,EACjBC,wBAAwB,EACxBC,wBAAwB,EACxBC,yBAAyB,EACzBC,0BAA0B,QACrB,sBAAqB;AAC5B,SAASC,iBAAiB,QAAQ,2BAA0B;AAC5D,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,yBAAyB,QAAQ,2BAAuB;AACjE,SAASC,0BAA0B,QAAQ,0BAAsB;AACjE,SAASC,6BAA6B,QAAQ,8BAA0B;AACxE,SAASC,0BAA0B,QAAQ,4BAAwB;AACnE,SAASC,oBAAoB,QAAQ,gCAA4B;AACjE,SAASC,sBAAsB,QAAQ,qBAAiB;AACxD,SAASC,wBAAwB,QAAQ,iCAA6B;AACtE,SAASC,wBAAwB,QAAQ,iCAA6B;AACtE,SAASC,mBAAmB,QAAQ,4BAAwB;AAC5D,SAASC,WAAW,QAAQ,mBAAe;AAC3C,SAASC,cAAc,QAAQ,sBAAkB;AACjD,SAASC,eAAe,QAAQ,uBAAmB;AACnD,SAASC,eAAe,QAAQ,uBAAmB;AACnD,SAASC,WAAW,QAAQ,mBAAe;AAC3C,SAASC,iBAAiB,QAAQ,yBAAqB;AACvD,SAASC,oBAAoB,QAAQ,6BAAyB;AAC9D,SAASC,cAAc,QAAQ,sBAAkB;AACjD,SAASC,cAAc,QAAQ,sBAAkB;AACjD,SAASC,8BAA8B,QAAQ,uCAAmC;AAClF,SAASC,WAAW,QAAQ,mBAAe;AAC3C,SAASC,iBAAiB,QAAQ,0BAAsB;AACxD,SAASC,wBAAwB,QAAQ,iCAA6B;AACtE,SAASC,8BAA8B,QAAQ,wCAAoC;AACnF,SAASC,wBAAwB,QAAQ,iCAA6B;AACtE,SAASC,sBAAsB,QAAQ,+BAA2B;AAClE,SAASC,uBAAuB,QAAQ,gCAA4B;AACpE,SAASC,wBAAwB,QAAQ,iCAA6B;AACtE,SAASC,yBAAyB,QAAQ,kCAA8B;AACxE,SAASC,sBAAsB,QAAQ,+BAA2B;AAClE,SAASC,yBAAyB,QAAQ,kCAA8B;AACxE,SAASC,cAAc,QAAQ,sBAAkB;AACjD,SAASC,aAAa,QAAQ,qBAAiB;AAC/C,SAASC,yBAAyB,QAAQ,kCAA8B;AACxE,SAASC,cAAc,QAAQ,sBAAkB;AACjD,SAASC,cAAc,QAAQ,sBAAkB;AACjD,SAASC,kBAAkB,QAAQ,2BAAuB;AAC1D,SAASC,iBAAiB,QAAQ,0BAAsB;AACxD,SACEC,mBAAmB,EACnBC,mBAAmB,QACd,4BAAwB;AAC/B,SAASC,gBAAgB,QAAQ,yBAAqB;AACtD,SAASC,iBAAiB,QAAQ,0BAAsB;AACxD,SAASC,qBAAqB,QAAQ,8BAA0B;AAChE,SAASC,eAAe,QAAQ,wBAAoB;AACpD,SACEC,uBAAuB,EACvBC,sBAAsB,QACjB,gCAA4B;AACnC,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,8BAA0B;AACnF,SAASC,mBAAmB,QAAQ,8BAA0B;AAC9D,SAASC,aAAa,QAAQ,qBAAiB;AAC/C,SAASC,sBAAsB,QAAQ,8BAA0B;AACjE,SAASC,qBAAqB,QAAQ,8BAA0B;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,MAAMC,gCAAgC;AAEtC3D,kBAAkB;IAChB4D,UAAUrD;IACVsD,MAAM;IACNC,iBAAiBH;IACjBI,aACE;IACFC,UAAU;QAAEC,OAAO;IAAW;IAC9BC,SAAS,OAAOC;QACd,yEAAyE;QACzE,yEAAyE;QACzE,mBAAmB;QACnB,IAAI,CAAC9D,qBAAqB;QAC1B,MAAM,EAAE+D,IAAI,EAAE,GAAG,MAAMhB,uBAAuBe;QAC9C,IAAIC,MAAMC,QAAQC,IAAI,CAAC,CAAC,eAAe,EAAEF,KAAK,yBAAyB,CAAC;IAC1E;AACF;AAEApE,kBAAkB;IAChB4D,UAAUrD;IACVsD,MAAM;IACNC,iBAAiBH;IACjBI,aAAa;IACbC,UAAU;QAAEC,OAAO;IAAW;IAC9BC,SAAS,OAAOC;QACd,IAAI,CAAC9D,qBAAqB;QAC1B,MAAM,EAAE+D,IAAI,EAAE,GAAG,MAAMd,kBAAkBa;QACzC,IAAIC,MAAMC,QAAQC,IAAI,CAAC,CAAC,eAAe,EAAEF,KAAK,qBAAqB,CAAC;IACtE;AACF;AAEA;;;;;;;;;;;;;;;CAeC,GACDpE,kBAAkB;IAChB4D,UAAUrD;IACVsD,MAAM;IACNC,iBAAiB;IACjBC,aACE;IACFC,UAAU;QAAEC,OAAO;IAAW;IAC9BC,SAAS,OAAOC;QACd,MAAMI,OAAO,MAAMhB,oBAAoBY;QACvC,IAAII,KAAKC,YAAY,IAAID,KAAKE,cAAc,EAAE;YAC5CJ,QAAQK,IAAI,CACV,CAAC,UAAU,EAAEH,KAAKC,YAAY,CAAC,oBAAoB,EAAED,KAAKI,KAAK,CAAC,CAAC,CAAC,GAChE,CAAC,+BAA+B,EAAEJ,KAAKK,cAAc,CAAC,OAAO,CAAC,GAC9D,CAAC,UAAU,EAAEL,KAAKE,cAAc,CAAC,gCAAgC,CAAC;QAExE;IACF;AACF;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACDzE,kBAAkB;IAChB4D,UAAUrD;IACVsD,MAAM;IACNC,iBAAiB;IACjBC,aACE,wEACA;IACFC,UAAU;QAAEC,OAAO;IAAW;IAC9BC,SAAS,OAAOC;QACd,MAAMI,OAAO,MAAMd,uBAAuBU;QAC1C,IAAII,KAAKM,YAAY,EAAE;YACrBR,QAAQK,IAAI,CACV,CAAC,UAAU,EAAEH,KAAKM,YAAY,CAAC,qCAAqC,CAAC,GACnE,CAAC,qDAAqD,CAAC;QAE7D;IACF;AACF;AAEA,2DAA2D,GAC3D,OAAO,SAASC;IACdtE;IACA,uEAAuE;IACvEC;IACAN,0BAA0BS;IAC1B,wEAAwE;IACxE,oEAAoE;IACpE,sEAAsE;IACtER,2BAA2BS;IAC3B,kEAAkE;IAClEX,yBAAyBa;IACzB,yEAAyE;IACzE,2EAA2E;IAC3E,sEAAsE;IACtEd,yBAAyBa;IACzBf,uBAAuB,0BAA0BiB;IACjDjB,uBAAuB,iBAAiBkB;IACxClB,uBAAuB,oBAAoBmB;IAC3CnB,uBAAuB,qBAAqBoB;IAC5CpB,uBAAuB,qBAAqBqB;IAC5CrB,uBAAuB,iBAAiBsB;IACxCtB,uBAAuB,uBAAuBuB;IAC9CvB,uBAAuB,2BAA2BwB;IAClD,qEAAqE;IACrExB,uBAAuB,4BAA4BkD;IACnDlD,uBAAuB,oBAAoByB;IAC3CzB,uBAAuB,oBAAoB0B;IAC3C1B,uBAAuB,qCAAqC2B;IAC5D3B,uBAAuB,oBAAoBuC;IAC3CvC,uBAAuB,iBAAiB4B;IACxC5B,uBAAuB,wBAAwB6B;IAC/C7B,uBAAuB,mBAAmBwC;IAC1CxC,uBAAuB,gCAAgCyC;IACvDzC,uBAAuB,oBAAoB0C;IAC3C1C,uBAAuB,sBAAsB8B;IAC7C,yEAAyE;IACzE,mDAAmD;IACnD9B,uBACE,6BACA+B;IAEF/B,uBAAuB,sBAAsBgC;IAC7ChC,uBAAuB,oBAAoBiC;IAC3CjC,uBAAuB,qBAAqBkC;IAC5C,wDAAwD;IACxDlC,uBAAuB,sBAAsBmC;IAC7CnC,uBAAuB,uBAAuBoC;IAC9CpC,uBAAuB,oBAAoBqC;IAC3CrC,uBAAuB,uBAAuBsC;AAChD;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAAS0C;IACd,uEAAuE;IACvE,8EAA8E;IAC9EvE;IACA,sEAAsE;IACtEC;IACA,uEAAuE;IACvE,iEAAiE;IACjEZ,8BAA8Ba;IAC9B,4EAA4E;IAC5E,2EAA2E;IAC3E,uDAAuD;IACvDX,uBAAuB,yBAAyB4C;IAChD5C,uBAAuB,oBAAoB2C;IAC3C3C,uBAAuB,wBAAwB6C;IAC/C,uEAAuE;IACvE,yEAAyE;IACzE,iDAAiD;IACjD7C,uBAAuB,0BAA0B8C;IACjD,2EAA2E;IAC3E,wEAAwE;IACxE,2EAA2E;IAC3E,yDAAyD;IACzD,EAAE;IACF,4EAA4E;IAC5E,4EAA4E;IAC5E,oEAAoE;IACpE,0EAA0E;IAC1E,kDAAkD;IAClD,EAAE;IACF,qEAAqE;IACrE,4EAA4E;IAC5E,6EAA6E;IAC7E/C,+BAA+B;QAC7B8D,UAAUrD;QACVyE,gBAAgBlC;IAClB;IACA,qEAAqE;IACrE,sEAAsE;IACtE,gEAAgE;IAChE/C,uBAAuB,uBAAuBgD;IAC9ChD,uBAAuB,wBAAwBiD;IAC/CjD,uBAAuB,sBAAsBmD;IAC7CnD,uBAAuB,8BAA8BoD;IACrDpD,uBAAuB,4BAA4BsD;IACnDtD,uBAAuB,mBAAmByD;IAC1CzD,uBAAuB,4BAA4B2D;IACnD,qEAAqE;IACrE,yEAAyE;IACzE,uDAAuD;IACvD/C,2BAA2B,IAAML,cAAc2E,GAAG,GAAGC,SAAS;AAChE;AAEA,6DAA6D;AAC7D,SAASC,iBAAiB,QAAQ,uBAAmB;AAErD,mEAAmE;AACnE,yDAAyD;AACzD,cAAc,yBAAqB"}
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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"}