@aglyn/plugins-marketplace 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +51 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/artifact-update-dialog.component.d.ts +27 -0
- package/src/lib/components/artifact-update-dialog.component.js +244 -0
- package/src/lib/components/artifact-update-dialog.component.js.map +1 -0
- package/src/lib/components/host-plugins-card.component.d.ts +23 -0
- package/src/lib/components/host-plugins-card.component.js +566 -0
- package/src/lib/components/host-plugins-card.component.js.map +1 -0
- package/src/lib/components/listing-content.component.d.ts +57 -0
- package/src/lib/components/listing-content.component.js +1880 -0
- package/src/lib/components/listing-content.component.js.map +1 -0
- package/src/lib/components/listing-image.component.d.ts +46 -0
- package/src/lib/components/listing-image.component.js +38 -0
- package/src/lib/components/listing-image.component.js.map +1 -0
- package/src/lib/components/listing-reviews.component.d.ts +22 -0
- package/src/lib/components/listing-reviews.component.js +419 -0
- package/src/lib/components/listing-reviews.component.js.map +1 -0
- package/src/lib/components/marketplace-browse.component.d.ts +38 -0
- package/src/lib/components/marketplace-browse.component.js +802 -0
- package/src/lib/components/marketplace-browse.component.js.map +1 -0
- package/src/lib/components/plugin-site-set-panel.component.d.ts +24 -0
- package/src/lib/components/plugin-site-set-panel.component.js +137 -0
- package/src/lib/components/plugin-site-set-panel.component.js.map +1 -0
- package/src/lib/components/plugin-site-set.component.d.ts +43 -0
- package/src/lib/components/plugin-site-set.component.js +425 -0
- package/src/lib/components/plugin-site-set.component.js.map +1 -0
- package/src/lib/components/rating-input.component.d.ts +3 -0
- package/src/lib/components/rating-input.component.js +47 -0
- package/src/lib/components/rating-input.component.js.map +1 -0
- package/src/lib/components/report-target.component.d.ts +36 -0
- package/src/lib/components/report-target.component.js +181 -0
- package/src/lib/components/report-target.component.js.map +1 -0
- package/src/lib/components/uninstall-impact-dialog.component.d.ts +40 -0
- package/src/lib/components/uninstall-impact-dialog.component.js +244 -0
- package/src/lib/components/uninstall-impact-dialog.component.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/hooks/use-artifact-update.d.ts +41 -0
- package/src/lib/hooks/use-artifact-update.js +169 -0
- package/src/lib/hooks/use-artifact-update.js.map +1 -0
- package/src/lib/hooks/use-marketplace-actions.d.ts +37 -0
- package/src/lib/hooks/use-marketplace-actions.js +523 -0
- package/src/lib/hooks/use-marketplace-actions.js.map +1 -0
- package/src/lib/model/artifact-merge.d.ts +154 -0
- package/src/lib/model/artifact-merge.js +228 -0
- package/src/lib/model/artifact-merge.js.map +1 -0
- package/src/lib/model/index.d.ts +43 -0
- package/src/lib/model/index.js +41 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/marketplace-props.d.ts +57 -0
- package/src/lib/model/marketplace-props.js +441 -0
- package/src/lib/model/marketplace-props.js.map +1 -0
- package/src/lib/model/marketplace.d.ts +833 -0
- package/src/lib/model/marketplace.js +929 -0
- package/src/lib/model/marketplace.js.map +1 -0
- package/src/lib/model/rating-field.d.ts +24 -0
- package/src/lib/model/rating-field.js +33 -0
- package/src/lib/model/rating-field.js.map +1 -0
- package/src/lib/plugin.d.ts +33 -0
- package/src/lib/plugin.js +86 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server/billing-webhook.d.ts +25 -0
- package/src/lib/server/billing-webhook.js +1062 -0
- package/src/lib/server/billing-webhook.js.map +1 -0
- package/src/lib/server/checkout.d.ts +76 -0
- package/src/lib/server/checkout.js +477 -0
- package/src/lib/server/checkout.js.map +1 -0
- package/src/lib/server/connect.d.ts +24 -0
- package/src/lib/server/connect.js +205 -0
- package/src/lib/server/connect.js.map +1 -0
- package/src/lib/server/install-dataset-schema.d.ts +32 -0
- package/src/lib/server/install-dataset-schema.js +255 -0
- package/src/lib/server/install-dataset-schema.js.map +1 -0
- package/src/lib/server/install-email-starter.d.ts +65 -0
- package/src/lib/server/install-email-starter.js +279 -0
- package/src/lib/server/install-email-starter.js.map +1 -0
- package/src/lib/server/install-email-template.d.ts +34 -0
- package/src/lib/server/install-email-template.js +252 -0
- package/src/lib/server/install-email-template.js.map +1 -0
- package/src/lib/server/install-layout.d.ts +29 -0
- package/src/lib/server/install-layout.js +323 -0
- package/src/lib/server/install-layout.js.map +1 -0
- package/src/lib/server/install-pin-counts.d.ts +173 -0
- package/src/lib/server/install-pin-counts.js +248 -0
- package/src/lib/server/install-pin-counts.js.map +1 -0
- package/src/lib/server/install-plugin.d.ts +18 -0
- package/src/lib/server/install-plugin.js +405 -0
- package/src/lib/server/install-plugin.js.map +1 -0
- package/src/lib/server/install-template.d.ts +37 -0
- package/src/lib/server/install-template.js +348 -0
- package/src/lib/server/install-template.js.map +1 -0
- package/src/lib/server/install-theme.d.ts +42 -0
- package/src/lib/server/install-theme.js +313 -0
- package/src/lib/server/install-theme.js.map +1 -0
- package/src/lib/server/install.d.ts +27 -0
- package/src/lib/server/install.js +276 -0
- package/src/lib/server/install.js.map +1 -0
- package/src/lib/server/listing-versions.d.ts +26 -0
- package/src/lib/server/listing-versions.js +378 -0
- package/src/lib/server/listing-versions.js.map +1 -0
- package/src/lib/server/preview-image.d.ts +37 -0
- package/src/lib/server/preview-image.js +216 -0
- package/src/lib/server/preview-image.js.map +1 -0
- package/src/lib/server/provenance.d.ts +100 -0
- package/src/lib/server/provenance.js +118 -0
- package/src/lib/server/provenance.js.map +1 -0
- package/src/lib/server/publish-dataset-schema.d.ts +31 -0
- package/src/lib/server/publish-dataset-schema.js +202 -0
- package/src/lib/server/publish-dataset-schema.js.map +1 -0
- package/src/lib/server/publish-email-starter.d.ts +44 -0
- package/src/lib/server/publish-email-starter.js +231 -0
- package/src/lib/server/publish-email-starter.js.map +1 -0
- package/src/lib/server/publish-email-template.d.ts +33 -0
- package/src/lib/server/publish-email-template.js +211 -0
- package/src/lib/server/publish-email-template.js.map +1 -0
- package/src/lib/server/publish-layout.d.ts +33 -0
- package/src/lib/server/publish-layout.js +243 -0
- package/src/lib/server/publish-layout.js.map +1 -0
- package/src/lib/server/publish-plugin.d.ts +18 -0
- package/src/lib/server/publish-plugin.js +485 -0
- package/src/lib/server/publish-plugin.js.map +1 -0
- package/src/lib/server/publish-preconditions.d.ts +86 -0
- package/src/lib/server/publish-preconditions.js +160 -0
- package/src/lib/server/publish-preconditions.js.map +1 -0
- package/src/lib/server/publish-template.d.ts +27 -0
- package/src/lib/server/publish-template.js +215 -0
- package/src/lib/server/publish-template.js.map +1 -0
- package/src/lib/server/publish-theme.d.ts +38 -0
- package/src/lib/server/publish-theme.js +186 -0
- package/src/lib/server/publish-theme.js.map +1 -0
- package/src/lib/server/publish.d.ts +26 -0
- package/src/lib/server/publish.js +204 -0
- package/src/lib/server/publish.js.map +1 -0
- package/src/lib/server/publisher-profile-save.d.ts +30 -0
- package/src/lib/server/publisher-profile-save.js +226 -0
- package/src/lib/server/publisher-profile-save.js.map +1 -0
- package/src/lib/server/publisher-profile.d.ts +86 -0
- package/src/lib/server/publisher-profile.js +116 -0
- package/src/lib/server/publisher-profile.js.map +1 -0
- package/src/lib/server/purchase-entitlement.d.ts +83 -0
- package/src/lib/server/purchase-entitlement.js +118 -0
- package/src/lib/server/purchase-entitlement.js.map +1 -0
- package/src/lib/server/report.d.ts +32 -0
- package/src/lib/server/report.js +101 -0
- package/src/lib/server/report.js.map +1 -0
- package/src/lib/server/reviews.d.ts +41 -0
- package/src/lib/server/reviews.js +240 -0
- package/src/lib/server/reviews.js.map +1 -0
- package/src/lib/server/update-artifact.d.ts +19 -0
- package/src/lib/server/update-artifact.js +569 -0
- package/src/lib/server/update-artifact.js.map +1 -0
- package/src/lib/server/verification-request.d.ts +31 -0
- package/src/lib/server/verification-request.js +173 -0
- package/src/lib/server/verification-request.js.map +1 -0
- package/src/lib/server/version-stats.d.ts +202 -0
- package/src/lib/server/version-stats.js +305 -0
- package/src/lib/server/version-stats.js.map +1 -0
- package/src/lib/server.d.ts +30 -0
- package/src/lib/server.js +95 -0
- package/src/lib/server.js.map +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
* The paid-install entitlement predicate (AGL-46/1546/1699).
|
|
18
|
+
*
|
|
19
|
+
* ONE function, deliberately, because there are eight ways into paid content —
|
|
20
|
+
* seven install routes plus `update-artifact` — and every one of them used to
|
|
21
|
+
* carry its own copy of this query. AGL-1546 taught the copy in `install.ts`
|
|
22
|
+
* that a refunded purchase no longer entitles and could not reach the other
|
|
23
|
+
* seven, so a refunded buyer kept installing plugins, themes, templates,
|
|
24
|
+
* layouts, dataset schemas and email templates for as long as the listing
|
|
25
|
+
* existed. A predicate that decides who has paid us belongs in one place; the
|
|
26
|
+
* next thing that changes it (chargebacks, AGL-1554) then changes it
|
|
27
|
+
* everywhere by construction.
|
|
28
|
+
*/ /**
|
|
29
|
+
* How many of a buyer's purchase docs for one listing to examine.
|
|
30
|
+
*
|
|
31
|
+
* Filtering in the query is not available to us: the honest predicate is
|
|
32
|
+
* "there exists a purchase without `refundedAt`", and Firestore's
|
|
33
|
+
* `where('refundedAt', '==', null)` does not match documents that are MISSING
|
|
34
|
+
* the field — which is every purchase written before AGL-1546. A `!=` filter
|
|
35
|
+
* has the same blind spot from the other side. So the docs come back unfiltered
|
|
36
|
+
* and the liveness test happens here.
|
|
37
|
+
*
|
|
38
|
+
* Ten is a bound on the read, not on the semantics: it takes ten refunded
|
|
39
|
+
* purchases of the SAME listing by the SAME buyer before a live eleventh could
|
|
40
|
+
* be missed, and the failure mode of that is a 402 on a listing the buyer owns
|
|
41
|
+
* — recoverable and visible — rather than free paid content.
|
|
42
|
+
*/ import { hasOrgLicenceOf } from "../model/marketplace.js";
|
|
43
|
+
const PURCHASE_SCAN_LIMIT = 10;
|
|
44
|
+
/**
|
|
45
|
+
* True when this ORGANIZATION holds a live licence for this listing
|
|
46
|
+
* (AGL-2331); see `purchaseEntitlesOrg` in the model for the licensing model
|
|
47
|
+
* and the legacy grant, which is where the reasoning lives.
|
|
48
|
+
*
|
|
49
|
+
* A FULLY refunded purchase (`refundedAt`, stamped by the `charge.refunded`
|
|
50
|
+
* branch of the marketplace billing webhook) reads as absent. Re-buying after a
|
|
51
|
+
* refund writes a fresh session-keyed doc, so a legitimate second purchase
|
|
52
|
+
* still installs and a refunded one sitting beside it does not veto it.
|
|
53
|
+
*
|
|
54
|
+
* TWO queries, not one, and not an `in` filter over both fields — Firestore
|
|
55
|
+
* has no disjunction across different fields that also keeps the `listingId`
|
|
56
|
+
* equality, and the honest question genuinely is a union of two populations:
|
|
57
|
+
* the org's licences, and this person's pre-AGL-2331 purchases. Both are
|
|
58
|
+
* equality-only conjunctions, so both are served by the automatic single-field
|
|
59
|
+
* indexes exactly as the original query was — no composite index, nothing to
|
|
60
|
+
* deploy.
|
|
61
|
+
*
|
|
62
|
+
* The org query is skipped entirely when there is no org, and the legacy query
|
|
63
|
+
* when there is no uid, so the common path (an org-stamped licence for a
|
|
64
|
+
* signed-in member) still costs the two reads it always did plus one for the
|
|
65
|
+
* grandfather — and the grandfather query is against a set that can only
|
|
66
|
+
* shrink.
|
|
67
|
+
*
|
|
68
|
+
* Callers gate on this only when `priceUsd > 0` and the caller is not the
|
|
69
|
+
* publisher — a free listing and a publisher installing their own work never
|
|
70
|
+
* had a purchase doc to find.
|
|
71
|
+
*/ export async function hasLivePurchase({ firestore, buyerUid, buyerOrgId, listingId }) {
|
|
72
|
+
var _ref, _ref1;
|
|
73
|
+
if (!listingId) return false;
|
|
74
|
+
if (!buyerUid && !buyerOrgId) return false;
|
|
75
|
+
const purchases = firestore.collection('marketplacePurchases');
|
|
76
|
+
const [byOrg, byUid] = await Promise.all([
|
|
77
|
+
buyerOrgId ? purchases.where('buyerOrgId', '==', buyerOrgId).where('listingId', '==', listingId).limit(PURCHASE_SCAN_LIMIT).get() : null,
|
|
78
|
+
// The legacy grant (AGL-2331). Kept as its own read rather than folded
|
|
79
|
+
// into the org query, because the documents it exists to find are exactly
|
|
80
|
+
// the ones with no `buyerOrgId` to match on — an org-keyed query cannot
|
|
81
|
+
// see them at all, which is precisely how a naive cutover would have
|
|
82
|
+
// revoked every purchase made before it.
|
|
83
|
+
buyerUid ? purchases.where('buyerUid', '==', buyerUid).where('listingId', '==', listingId).limit(PURCHASE_SCAN_LIMIT).get() : null
|
|
84
|
+
]);
|
|
85
|
+
// `hasOrgLicenceOf` re-tests the listing id the queries already filtered on,
|
|
86
|
+
// deliberately: it is the same shared predicate the listing page and the
|
|
87
|
+
// checkout guard call, so the one place that decides who owns what has one
|
|
88
|
+
// implementation rather than a server-shaped variant of it.
|
|
89
|
+
return hasOrgLicenceOf([
|
|
90
|
+
...(_ref = byOrg == null ? void 0 : byOrg.docs) != null ? _ref : [],
|
|
91
|
+
...(_ref1 = byUid == null ? void 0 : byUid.docs) != null ? _ref1 : []
|
|
92
|
+
].map((purchase)=>purchase.data()), listingId, {
|
|
93
|
+
orgId: buyerOrgId,
|
|
94
|
+
uid: buyerUid
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The whole gate: resolves to `null` when the caller may proceed, or to the
|
|
99
|
+
* 402 payload to return when they may not.
|
|
100
|
+
*
|
|
101
|
+
* Returning the payload rather than writing the response keeps this usable
|
|
102
|
+
* from `update-artifact`, whose preview and apply branches answer differently,
|
|
103
|
+
* while still giving the seven install routes a single-line gate.
|
|
104
|
+
*/ export async function requirePurchase({ firestore, buyerUid, buyerOrgId, listingId, priceUsd, ownsListing }) {
|
|
105
|
+
if (!(priceUsd > 0) || ownsListing) return null;
|
|
106
|
+
const live = await hasLivePurchase({
|
|
107
|
+
firestore,
|
|
108
|
+
buyerUid,
|
|
109
|
+
buyerOrgId,
|
|
110
|
+
listingId
|
|
111
|
+
});
|
|
112
|
+
return live ? null : {
|
|
113
|
+
error: 'Purchase required',
|
|
114
|
+
priceUsd
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=purchase-entitlement.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/purchase-entitlement.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 * The paid-install entitlement predicate (AGL-46/1546/1699).\n *\n * ONE function, deliberately, because there are eight ways into paid content —\n * seven install routes plus `update-artifact` — and every one of them used to\n * carry its own copy of this query. AGL-1546 taught the copy in `install.ts`\n * that a refunded purchase no longer entitles and could not reach the other\n * seven, so a refunded buyer kept installing plugins, themes, templates,\n * layouts, dataset schemas and email templates for as long as the listing\n * existed. A predicate that decides who has paid us belongs in one place; the\n * next thing that changes it (chargebacks, AGL-1554) then changes it\n * everywhere by construction.\n */\n\n/**\n * How many of a buyer's purchase docs for one listing to examine.\n *\n * Filtering in the query is not available to us: the honest predicate is\n * \"there exists a purchase without `refundedAt`\", and Firestore's\n * `where('refundedAt', '==', null)` does not match documents that are MISSING\n * the field — which is every purchase written before AGL-1546. A `!=` filter\n * has the same blind spot from the other side. So the docs come back unfiltered\n * and the liveness test happens here.\n *\n * Ten is a bound on the read, not on the semantics: it takes ten refunded\n * purchases of the SAME listing by the SAME buyer before a live eleventh could\n * be missed, and the failure mode of that is a 402 on a listing the buyer owns\n * — recoverable and visible — rather than free paid content.\n */\n\nimport {\n hasOrgLicenceOf,\n type PurchaseLiveness,\n} from '../model/marketplace'\n\nconst PURCHASE_SCAN_LIMIT = 10\n\n// The liveness test itself lives in the model (AGL-2158), not here: the\n// listing page asks the same question and cannot import a server module, and\n// its own copy had drifted to no refund test at all — showing a refunded\n// buyer as an owner while these routes 402'd them.\n\n\ninterface LivePurchaseQuery {\n firestore: FirebaseFirestore.Firestore\n /** The authenticated caller's uid — never a value from the request body. */\n buyerUid: string\n /**\n * THE ORGANIZATION BEING INSTALLED INTO (AGL-2331) — required, because a\n * licence belongs to an org and asking this question without one can only be\n * answered by the legacy grant.\n *\n * Every caller resolves it SERVER-SIDE from an authenticated membership\n * (`resolveOrgPermissions`), never from a request body field. It is\n * deliberately not optional: the gate that decides who gets paid content\n * must not be satisfiable by forgetting an argument, and a required property\n * makes every one of the nine doors a compile error until it is wired.\n *\n * The empty string is legal and means \"this site has no owning org\" — see\n * `purchaseEntitlesOrg`, which never matches a document against it.\n */\n buyerOrgId: string\n listingId: string\n}\n\n/**\n * True when this ORGANIZATION holds a live licence for this listing\n * (AGL-2331); see `purchaseEntitlesOrg` in the model for the licensing model\n * and the legacy grant, which is where the reasoning lives.\n *\n * A FULLY refunded purchase (`refundedAt`, stamped by the `charge.refunded`\n * branch of the marketplace billing webhook) reads as absent. Re-buying after a\n * refund writes a fresh session-keyed doc, so a legitimate second purchase\n * still installs and a refunded one sitting beside it does not veto it.\n *\n * TWO queries, not one, and not an `in` filter over both fields — Firestore\n * has no disjunction across different fields that also keeps the `listingId`\n * equality, and the honest question genuinely is a union of two populations:\n * the org's licences, and this person's pre-AGL-2331 purchases. Both are\n * equality-only conjunctions, so both are served by the automatic single-field\n * indexes exactly as the original query was — no composite index, nothing to\n * deploy.\n *\n * The org query is skipped entirely when there is no org, and the legacy query\n * when there is no uid, so the common path (an org-stamped licence for a\n * signed-in member) still costs the two reads it always did plus one for the\n * grandfather — and the grandfather query is against a set that can only\n * shrink.\n *\n * Callers gate on this only when `priceUsd > 0` and the caller is not the\n * publisher — a free listing and a publisher installing their own work never\n * had a purchase doc to find.\n */\nexport async function hasLivePurchase({\n firestore,\n buyerUid,\n buyerOrgId,\n listingId,\n}: LivePurchaseQuery): Promise<boolean> {\n if (!listingId) return false\n if (!buyerUid && !buyerOrgId) return false\n const purchases = firestore.collection('marketplacePurchases')\n const [byOrg, byUid] = await Promise.all([\n buyerOrgId\n ? purchases\n .where('buyerOrgId', '==', buyerOrgId)\n .where('listingId', '==', listingId)\n .limit(PURCHASE_SCAN_LIMIT)\n .get()\n : null,\n // The legacy grant (AGL-2331). Kept as its own read rather than folded\n // into the org query, because the documents it exists to find are exactly\n // the ones with no `buyerOrgId` to match on — an org-keyed query cannot\n // see them at all, which is precisely how a naive cutover would have\n // revoked every purchase made before it.\n buyerUid\n ? purchases\n .where('buyerUid', '==', buyerUid)\n .where('listingId', '==', listingId)\n .limit(PURCHASE_SCAN_LIMIT)\n .get()\n : null,\n ])\n // `hasOrgLicenceOf` re-tests the listing id the queries already filtered on,\n // deliberately: it is the same shared predicate the listing page and the\n // checkout guard call, so the one place that decides who owns what has one\n // implementation rather than a server-shaped variant of it.\n return hasOrgLicenceOf(\n [...(byOrg?.docs ?? []), ...(byUid?.docs ?? [])].map(\n (purchase) => purchase.data() as PurchaseLiveness,\n ),\n listingId,\n { orgId: buyerOrgId, uid: buyerUid },\n )\n}\n\n/**\n * The whole gate: resolves to `null` when the caller may proceed, or to the\n * 402 payload to return when they may not.\n *\n * Returning the payload rather than writing the response keeps this usable\n * from `update-artifact`, whose preview and apply branches answer differently,\n * while still giving the seven install routes a single-line gate.\n */\nexport async function requirePurchase({\n firestore,\n buyerUid,\n buyerOrgId,\n listingId,\n priceUsd,\n ownsListing,\n}: LivePurchaseQuery & {\n priceUsd: number\n /** The publisher installs their own listing for free (AGL-652). */\n ownsListing: boolean\n}): Promise<{ error: string; priceUsd: number } | null> {\n if (!(priceUsd > 0) || ownsListing) return null\n const live = await hasLivePurchase({\n firestore,\n buyerUid,\n buyerOrgId,\n listingId,\n })\n return live ? null : { error: 'Purchase required', priceUsd }\n}\n"],"names":["hasOrgLicenceOf","PURCHASE_SCAN_LIMIT","hasLivePurchase","firestore","buyerUid","buyerOrgId","listingId","purchases","collection","byOrg","byUid","Promise","all","where","limit","get","docs","map","purchase","data","orgId","uid","requirePurchase","priceUsd","ownsListing","live","error"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED;;;;;;;;;;;;;;CAcC,GAED,SACEA,eAAe,QAEV,0BAAsB;AAE7B,MAAMC,sBAAsB;AA8B5B;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GACD,OAAO,eAAeC,gBAAgB,EACpCC,SAAS,EACTC,QAAQ,EACRC,UAAU,EACVC,SAAS,EACS;;IAClB,IAAI,CAACA,WAAW,OAAO;IACvB,IAAI,CAACF,YAAY,CAACC,YAAY,OAAO;IACrC,MAAME,YAAYJ,UAAUK,UAAU,CAAC;IACvC,MAAM,CAACC,OAAOC,MAAM,GAAG,MAAMC,QAAQC,GAAG,CAAC;QACvCP,aACIE,UACGM,KAAK,CAAC,cAAc,MAAMR,YAC1BQ,KAAK,CAAC,aAAa,MAAMP,WACzBQ,KAAK,CAACb,qBACNc,GAAG,KACN;QACJ,uEAAuE;QACvE,0EAA0E;QAC1E,wEAAwE;QACxE,qEAAqE;QACrE,yCAAyC;QACzCX,WACIG,UACGM,KAAK,CAAC,YAAY,MAAMT,UACxBS,KAAK,CAAC,aAAa,MAAMP,WACzBQ,KAAK,CAACb,qBACNc,GAAG,KACN;KACL;IACD,6EAA6E;IAC7E,yEAAyE;IACzE,2EAA2E;IAC3E,4DAA4D;IAC5D,OAAOf,gBACL;mBAAKS,yBAAAA,MAAOO,IAAI,mBAAI,EAAE;oBAAON,yBAAAA,MAAOM,IAAI,oBAAI,EAAE;KAAE,CAACC,GAAG,CAClD,CAACC,WAAaA,SAASC,IAAI,KAE7Bb,WACA;QAAEc,OAAOf;QAAYgB,KAAKjB;IAAS;AAEvC;AAEA;;;;;;;CAOC,GACD,OAAO,eAAekB,gBAAgB,EACpCnB,SAAS,EACTC,QAAQ,EACRC,UAAU,EACVC,SAAS,EACTiB,QAAQ,EACRC,WAAW,EAKZ;IACC,IAAI,CAAED,CAAAA,WAAW,CAAA,KAAMC,aAAa,OAAO;IAC3C,MAAMC,OAAO,MAAMvB,gBAAgB;QACjCC;QACAC;QACAC;QACAC;IACF;IACA,OAAOmB,OAAO,OAAO;QAAEC,OAAO;QAAqBH;IAAS;AAC9D"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
18
|
+
/**
|
|
19
|
+
* Report a listing or a review for staff attention (AGL-658).
|
|
20
|
+
*
|
|
21
|
+
* Pre-publication review is plugin-only and stays that way — plugins
|
|
22
|
+
* execute code, so they earn the wait, while a component or template is
|
|
23
|
+
* inert until someone installs it. But "inert" is not "harmless": a
|
|
24
|
+
* template carries a whole screen tree into someone else's org. So
|
|
25
|
+
* everything is reportable after the fact, which is the posture the
|
|
26
|
+
* catalogue can actually sustain as it grows.
|
|
27
|
+
*
|
|
28
|
+
* One report per reporter per target: the doc id is derived, so reporting
|
|
29
|
+
* twice updates rather than letting one account inflate a queue.
|
|
30
|
+
*/
|
|
31
|
+
export declare const reportHandler: PluginApiHandler;
|
|
32
|
+
export default reportHandler;
|
|
@@ -0,0 +1,101 @@
|
|
|
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 { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { createHash } from "crypto";
|
|
18
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
19
|
+
const MAX_REASON_LENGTH = 1000;
|
|
20
|
+
/**
|
|
21
|
+
* Report a listing or a review for staff attention (AGL-658).
|
|
22
|
+
*
|
|
23
|
+
* Pre-publication review is plugin-only and stays that way — plugins
|
|
24
|
+
* execute code, so they earn the wait, while a component or template is
|
|
25
|
+
* inert until someone installs it. But "inert" is not "harmless": a
|
|
26
|
+
* template carries a whole screen tree into someone else's org. So
|
|
27
|
+
* everything is reportable after the fact, which is the posture the
|
|
28
|
+
* catalogue can actually sustain as it grows.
|
|
29
|
+
*
|
|
30
|
+
* One report per reporter per target: the doc id is derived, so reporting
|
|
31
|
+
* twice updates rather than letting one account inflate a queue.
|
|
32
|
+
*/ export const reportHandler = async (req, res)=>{
|
|
33
|
+
var _ref, _ref1, _ref2, _req_headers_authorization;
|
|
34
|
+
var _req_body, _req_body1, _req_body2;
|
|
35
|
+
if (req.method !== 'POST') {
|
|
36
|
+
return res.status(405).json({
|
|
37
|
+
error: 'Method not allowed'
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const listingId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.listingId) != null ? _ref : '');
|
|
41
|
+
const reviewUid = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.reviewUid) != null ? _ref1 : '');
|
|
42
|
+
const reason = String((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.reason) != null ? _ref2 : '').trim().slice(0, MAX_REASON_LENGTH);
|
|
43
|
+
if (!listingId) return res.status(400).json({
|
|
44
|
+
error: 'Missing listingId'
|
|
45
|
+
});
|
|
46
|
+
if (!reason) return res.status(400).json({
|
|
47
|
+
error: 'Add a reason'
|
|
48
|
+
});
|
|
49
|
+
const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
|
|
50
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
51
|
+
if (!idToken) return res.status(401).json({
|
|
52
|
+
error: 'Unauthenticated'
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
var _listingSnapshot_get, _listingSnapshot_get1;
|
|
56
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
57
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
58
|
+
const listingSnapshot = await firestore.collection('marketplaceListings').doc(listingId).get();
|
|
59
|
+
if (!listingSnapshot.exists) {
|
|
60
|
+
return res.status(404).json({
|
|
61
|
+
error: 'Unknown listing'
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const targetType = reviewUid ? 'review' : 'listing';
|
|
65
|
+
// Deterministic id, so a second report from the same account on the
|
|
66
|
+
// same target UPDATES rather than stacking — one person must not be
|
|
67
|
+
// able to make something look widely reported. Hashed because the
|
|
68
|
+
// parts can contain characters Firestore rejects in a doc id.
|
|
69
|
+
const reportId = createHash('sha256').update(`${decoded.uid}:${listingId}:${reviewUid || 'listing'}`).digest('hex').slice(0, 40);
|
|
70
|
+
const now = firebaseAdmin.firestore.FieldValue.serverTimestamp();
|
|
71
|
+
await firestore.collection('marketplaceReports').doc(reportId).set(_extends({
|
|
72
|
+
targetType,
|
|
73
|
+
listingId
|
|
74
|
+
}, reviewUid ? {
|
|
75
|
+
reviewUid
|
|
76
|
+
} : {}, {
|
|
77
|
+
// The verified token, never a client-supplied uid.
|
|
78
|
+
reporterUid: decoded.uid,
|
|
79
|
+
reason,
|
|
80
|
+
status: 'open',
|
|
81
|
+
listingName: (_listingSnapshot_get = listingSnapshot.get('displayName')) != null ? _listingSnapshot_get : '',
|
|
82
|
+
publisherOrgId: (_listingSnapshot_get1 = listingSnapshot.get('profileId')) != null ? _listingSnapshot_get1 : null,
|
|
83
|
+
updatedAt: now,
|
|
84
|
+
createdAt: now
|
|
85
|
+
}), {
|
|
86
|
+
merge: true
|
|
87
|
+
});
|
|
88
|
+
return res.status(200).json({
|
|
89
|
+
ok: true,
|
|
90
|
+
targetType
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(error);
|
|
94
|
+
return res.status(500).json({
|
|
95
|
+
error: 'Report failed'
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
export default reportHandler;
|
|
100
|
+
|
|
101
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/report.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 { createHash } from 'crypto'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\nconst MAX_REASON_LENGTH = 1000\n\n/**\n * Report a listing or a review for staff attention (AGL-658).\n *\n * Pre-publication review is plugin-only and stays that way — plugins\n * execute code, so they earn the wait, while a component or template is\n * inert until someone installs it. But \"inert\" is not \"harmless\": a\n * template carries a whole screen tree into someone else's org. So\n * everything is reportable after the fact, which is the posture the\n * catalogue can actually sustain as it grows.\n *\n * One report per reporter per target: the doc id is derived, so reporting\n * twice updates rather than letting one account inflate a queue.\n */\nexport const reportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const listingId = String(req.body?.listingId ?? '')\n const reviewUid = String(req.body?.reviewUid ?? '')\n const reason = String(req.body?.reason ?? '')\n .trim()\n .slice(0, MAX_REASON_LENGTH)\n if (!listingId) return res.status(400).json({ error: 'Missing listingId' })\n if (!reason) return res.status(400).json({ error: 'Add a reason' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const listingSnapshot = await firestore\n .collection('marketplaceListings')\n .doc(listingId)\n .get()\n if (!listingSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n\n const targetType = reviewUid ? 'review' : 'listing'\n // Deterministic id, so a second report from the same account on the\n // same target UPDATES rather than stacking — one person must not be\n // able to make something look widely reported. Hashed because the\n // parts can contain characters Firestore rejects in a doc id.\n const reportId = createHash('sha256')\n .update(`${decoded.uid}:${listingId}:${reviewUid || 'listing'}`)\n .digest('hex')\n .slice(0, 40)\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n await firestore\n .collection('marketplaceReports')\n .doc(reportId)\n .set(\n {\n targetType,\n listingId,\n ...(reviewUid ? { reviewUid } : {}),\n // The verified token, never a client-supplied uid.\n reporterUid: decoded.uid,\n reason,\n status: 'open',\n listingName: listingSnapshot.get('displayName') ?? '',\n publisherOrgId: listingSnapshot.get('profileId') ?? null,\n updatedAt: now,\n createdAt: now,\n },\n { merge: true },\n )\n\n return res.status(200).json({ ok: true, targetType })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Report failed' })\n }\n}\n\nexport default reportHandler\n"],"names":["createHash","firebaseAdmin","MAX_REASON_LENGTH","reportHandler","req","res","method","status","json","error","listingId","String","body","reviewUid","reason","trim","slice","authorization","headers","idToken","startsWith","length","undefined","listingSnapshot","decoded","app","auth","verifyIdToken","firestore","collection","doc","get","exists","targetType","reportId","update","uid","digest","now","FieldValue","serverTimestamp","set","reporterUid","listingName","publisherOrgId","updatedAt","createdAt","merge","ok","console"],"mappings":"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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
18
|
+
export declare function isVerifiedInstaller(firestore: FirebaseFirestore.Firestore, uid: string, listingId: string): Promise<{
|
|
19
|
+
verified: boolean;
|
|
20
|
+
orgId?: string;
|
|
21
|
+
}>;
|
|
22
|
+
/**
|
|
23
|
+
* Ratings and comments on a marketplace listing (AGL-655).
|
|
24
|
+
*
|
|
25
|
+
* One review per account, keyed by uid, so re-submitting edits rather than
|
|
26
|
+
* stacking. Two rules shape everything else:
|
|
27
|
+
*
|
|
28
|
+
* - **Rating requires a verified email AND having installed it (AGL-865).**
|
|
29
|
+
* A star rating is the signal that drives ranking, so it is reserved for
|
|
30
|
+
* accounts that verified their email and actually used the thing.
|
|
31
|
+
* Commenting stays open to any signed-in account — questions before
|
|
32
|
+
* installing are useful, and a comment moves no numbers.
|
|
33
|
+
* - **The publisher cannot review their own listing.** Org-scoped, so a
|
|
34
|
+
* second account in the same org does not slip through.
|
|
35
|
+
*
|
|
36
|
+
* Aggregates are adjusted in the same transaction as the review, and are
|
|
37
|
+
* frozen from client writes in rules. Kept as a running `ratingSum` +
|
|
38
|
+
* `ratingCount` so a submission costs one read, not a scan of every review.
|
|
39
|
+
*/
|
|
40
|
+
export declare const reviewsHandler: PluginApiHandler;
|
|
41
|
+
export default reviewsHandler;
|
|
@@ -0,0 +1,240 @@
|
|
|
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 { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
|
|
18
|
+
import { canActAsPublisher } from "./publisher-profile.js";
|
|
19
|
+
const MAX_COMMENT_LENGTH = 2000;
|
|
20
|
+
/** Orgs scanned when looking for an install pin. */ const MAX_ORGS_CHECKED = 25;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the author has actually used this listing (AGL-655).
|
|
23
|
+
*
|
|
24
|
+
* An install pin lives at `orgs/{orgId}/installs/{listingId}` (org-wide) or
|
|
25
|
+
* `hosts/{hostId}/installs/{listingId}` (one site), and **it is one or the
|
|
26
|
+
* other** — `install-plugin` reassigns its target ref to the org path for an
|
|
27
|
+
* org-scoped install rather than writing both.
|
|
28
|
+
*
|
|
29
|
+
* This used to read the org pin alone, on the stated reasoning that "a host
|
|
30
|
+
* pin implies the org installed it somewhere, and the org mirror is what the
|
|
31
|
+
* install routes write" (AGL-1006). There is no org mirror, so a per-site
|
|
32
|
+
* install left nothing to find and the workspace was refused a rating — and
|
|
33
|
+
* it refused exactly the careful installers, the ones who scoped a plugin to
|
|
34
|
+
* one site before rolling it out. Ratings drive marketplace ranking, so that
|
|
35
|
+
* skewed the sample toward org-wide installers.
|
|
36
|
+
*
|
|
37
|
+
* Host pins are found through the org doc's own `hosts` map (written by
|
|
38
|
+
* `registerOrgHost`), then read in ONE batched `getAll` alongside the org
|
|
39
|
+
* pin. Two round trips per org, bounded and index-free — deliberately not a
|
|
40
|
+
* collection-group query on `installs`, which would need a new index and
|
|
41
|
+
* would match every tenant's pins for a listing, unbounded, to answer a
|
|
42
|
+
* question about one org.
|
|
43
|
+
*/ const MAX_HOSTS_CHECKED = 50;
|
|
44
|
+
// Exported for the AGL-1006 regression test: it takes its firestore, so the
|
|
45
|
+
// rule "a host pin counts" is checkable without standing up the whole
|
|
46
|
+
// handler (auth, transactions, aggregate writes).
|
|
47
|
+
export async function isVerifiedInstaller(firestore, uid, listingId) {
|
|
48
|
+
const memberships = await firestore.collection('users').doc(uid).collection('orgs').limit(MAX_ORGS_CHECKED).get();
|
|
49
|
+
for (const membership of memberships.docs){
|
|
50
|
+
var _org_get;
|
|
51
|
+
const orgId = membership.id;
|
|
52
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
53
|
+
const org = await orgRef.get();
|
|
54
|
+
const hostIds = Object.keys((_org_get = org.get('hosts')) != null ? _org_get : {}).slice(0, MAX_HOSTS_CHECKED);
|
|
55
|
+
const refs = [
|
|
56
|
+
orgRef.collection('installs').doc(listingId),
|
|
57
|
+
...hostIds.map((hostId)=>firestore.collection('hosts').doc(hostId).collection('installs').doc(listingId))
|
|
58
|
+
];
|
|
59
|
+
const pins = await firestore.getAll(...refs);
|
|
60
|
+
if (pins.some((pin)=>pin.exists)) return {
|
|
61
|
+
verified: true,
|
|
62
|
+
orgId
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
verified: false
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Ratings and comments on a marketplace listing (AGL-655).
|
|
71
|
+
*
|
|
72
|
+
* One review per account, keyed by uid, so re-submitting edits rather than
|
|
73
|
+
* stacking. Two rules shape everything else:
|
|
74
|
+
*
|
|
75
|
+
* - **Rating requires a verified email AND having installed it (AGL-865).**
|
|
76
|
+
* A star rating is the signal that drives ranking, so it is reserved for
|
|
77
|
+
* accounts that verified their email and actually used the thing.
|
|
78
|
+
* Commenting stays open to any signed-in account — questions before
|
|
79
|
+
* installing are useful, and a comment moves no numbers.
|
|
80
|
+
* - **The publisher cannot review their own listing.** Org-scoped, so a
|
|
81
|
+
* second account in the same org does not slip through.
|
|
82
|
+
*
|
|
83
|
+
* Aggregates are adjusted in the same transaction as the review, and are
|
|
84
|
+
* frozen from client writes in rules. Kept as a running `ratingSum` +
|
|
85
|
+
* `ratingCount` so a submission costs one read, not a scan of every review.
|
|
86
|
+
*/ export const reviewsHandler = async (req, res)=>{
|
|
87
|
+
var _ref, _req_headers_authorization;
|
|
88
|
+
var _req_body;
|
|
89
|
+
const method = req.method === 'DELETE' ? 'DELETE' : 'POST';
|
|
90
|
+
if (req.method !== 'POST' && req.method !== 'DELETE') {
|
|
91
|
+
return res.status(405).json({
|
|
92
|
+
error: 'Method not allowed'
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const listingId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.listingId) != null ? _ref : '');
|
|
96
|
+
if (!listingId) return res.status(400).json({
|
|
97
|
+
error: 'Missing listingId'
|
|
98
|
+
});
|
|
99
|
+
const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
|
|
100
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
101
|
+
if (!idToken) return res.status(401).json({
|
|
102
|
+
error: 'Unauthenticated'
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
var _ref1;
|
|
106
|
+
var _req_body1, _req_body2;
|
|
107
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
108
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
109
|
+
const listingRef = firestore.collection('marketplaceListings').doc(listingId);
|
|
110
|
+
const listingSnapshot = await listingRef.get();
|
|
111
|
+
const listing = listingSnapshot.data();
|
|
112
|
+
if (!listing || listing.deletedAt) {
|
|
113
|
+
return res.status(404).json({
|
|
114
|
+
error: 'Unknown listing'
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const reviewRef = listingRef.collection('reviews').doc(decoded.uid);
|
|
118
|
+
if (method === 'DELETE') {
|
|
119
|
+
await firestore.runTransaction(async (tx)=>{
|
|
120
|
+
var _existing_get;
|
|
121
|
+
const [existing, listingNow] = await Promise.all([
|
|
122
|
+
tx.get(reviewRef),
|
|
123
|
+
tx.get(listingRef)
|
|
124
|
+
]);
|
|
125
|
+
if (!existing.exists) return;
|
|
126
|
+
const previousRating = Number((_existing_get = existing.get('rating')) != null ? _existing_get : 0) || 0;
|
|
127
|
+
const hadComment = !!existing.get('comment');
|
|
128
|
+
tx.delete(reviewRef);
|
|
129
|
+
tx.update(listingRef, nextAggregates(listingNow, -previousRating, previousRating ? -1 : 0, hadComment ? -1 : 0));
|
|
130
|
+
});
|
|
131
|
+
return res.status(200).json({
|
|
132
|
+
ok: true,
|
|
133
|
+
removed: true
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// Publishing org cannot review itself, however many accounts it has.
|
|
137
|
+
if (await canActAsPublisher(firestore, decoded.uid, listing.profileId)) {
|
|
138
|
+
return res.status(403).json({
|
|
139
|
+
error: 'You cannot review your organization’s own listing'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const rawRating = (_req_body1 = req.body) == null ? void 0 : _req_body1.rating;
|
|
143
|
+
const rating = rawRating == null || rawRating === '' ? 0 : Math.min(5, Math.max(1, Math.round(Number(rawRating) || 0)));
|
|
144
|
+
const comment = String((_ref1 = (_req_body2 = req.body) == null ? void 0 : _req_body2.comment) != null ? _ref1 : '').trim().slice(0, MAX_COMMENT_LENGTH);
|
|
145
|
+
if (!rating && !comment) {
|
|
146
|
+
return res.status(400).json({
|
|
147
|
+
error: 'Add a rating or a comment'
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// A star rating drives ranking, so it is reserved for accounts that both
|
|
151
|
+
// verified their email (AGL-865/479) AND actually installed the thing.
|
|
152
|
+
// Commenting stays open to any signed-in account — the checks below only
|
|
153
|
+
// guard `rating`, never `comment`. Impersonation sessions are exempt from
|
|
154
|
+
// the email gate the same way every other AGL-479 chokepoint is.
|
|
155
|
+
if (rating && !decoded.email_verified && !isImpersonationSession(decoded)) {
|
|
156
|
+
return res.status(403).json({
|
|
157
|
+
error: 'Verify your email to rate — you can still leave a comment',
|
|
158
|
+
reason: 'email-unverified'
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const installer = await isVerifiedInstaller(firestore, decoded.uid, listingId);
|
|
162
|
+
if (rating && !installer.verified) {
|
|
163
|
+
return res.status(403).json({
|
|
164
|
+
error: 'Only organizations that have installed this can rate it — you ' + 'can still leave a comment'
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
await firestore.runTransaction(async (tx)=>{
|
|
169
|
+
var _existing_get, _decoded_name, _existing_get1;
|
|
170
|
+
const [existing, listingNow] = await Promise.all([
|
|
171
|
+
tx.get(reviewRef),
|
|
172
|
+
tx.get(listingRef)
|
|
173
|
+
]);
|
|
174
|
+
const previousRating = Number((_existing_get = existing.get('rating')) != null ? _existing_get : 0) || 0;
|
|
175
|
+
const hadComment = existing.exists && !!existing.get('comment');
|
|
176
|
+
tx.set(reviewRef, _extends({
|
|
177
|
+
uid: decoded.uid,
|
|
178
|
+
displayName: String((_decoded_name = decoded.name) != null ? _decoded_name : 'Someone').slice(0, 80)
|
|
179
|
+
}, rating ? {
|
|
180
|
+
rating
|
|
181
|
+
} : {
|
|
182
|
+
rating: null
|
|
183
|
+
}, comment ? {
|
|
184
|
+
comment
|
|
185
|
+
} : {
|
|
186
|
+
comment: null
|
|
187
|
+
}, {
|
|
188
|
+
verifiedInstaller: installer.verified
|
|
189
|
+
}, installer.orgId ? {
|
|
190
|
+
orgId: installer.orgId
|
|
191
|
+
} : {}, {
|
|
192
|
+
// Preserved across edits: a moderator's decision must not be
|
|
193
|
+
// undone by the author simply resubmitting.
|
|
194
|
+
hidden: (_existing_get1 = existing.get('hidden')) != null ? _existing_get1 : false
|
|
195
|
+
}, existing.exists ? {} : {
|
|
196
|
+
createdAtMs: now
|
|
197
|
+
}, {
|
|
198
|
+
updatedAtMs: now
|
|
199
|
+
}), {
|
|
200
|
+
merge: true
|
|
201
|
+
});
|
|
202
|
+
tx.update(listingRef, nextAggregates(listingNow, rating - previousRating, (rating ? 1 : 0) - (previousRating ? 1 : 0), (comment ? 1 : 0) - (hadComment ? 1 : 0)));
|
|
203
|
+
});
|
|
204
|
+
return res.status(200).json({
|
|
205
|
+
ok: true,
|
|
206
|
+
rating: rating || null,
|
|
207
|
+
verifiedInstaller: installer.verified
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error(error);
|
|
211
|
+
return res.status(500).json({
|
|
212
|
+
error: 'Review failed'
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Absolute aggregate values after a change.
|
|
218
|
+
*
|
|
219
|
+
* Written as numbers rather than `FieldValue.increment` because
|
|
220
|
+
* `ratingAverage` has to be stored — browse sorts on it, and a transaction
|
|
221
|
+
* cannot divide two pending increments. Reading the listing inside the
|
|
222
|
+
* transaction makes the read-modify-write atomic, so concurrent reviews
|
|
223
|
+
* cannot interleave and lose a vote.
|
|
224
|
+
*/ function nextAggregates(current, ratingSumDelta, ratingCountDelta, commentCountDelta) {
|
|
225
|
+
var _current_get, _current_get1, _current_get2;
|
|
226
|
+
const ratingSum = Math.max(0, Number((_current_get = current.get('ratingSum')) != null ? _current_get : 0) + ratingSumDelta);
|
|
227
|
+
const ratingCount = Math.max(0, Number((_current_get1 = current.get('ratingCount')) != null ? _current_get1 : 0) + ratingCountDelta);
|
|
228
|
+
const commentCount = Math.max(0, Number((_current_get2 = current.get('commentCount')) != null ? _current_get2 : 0) + commentCountDelta);
|
|
229
|
+
return {
|
|
230
|
+
ratingSum,
|
|
231
|
+
ratingCount,
|
|
232
|
+
commentCount,
|
|
233
|
+
// One decimal — enough for "4.2", and avoids a long float in the UI.
|
|
234
|
+
ratingAverage: ratingCount ? Math.round(ratingSum / ratingCount * 10) / 10 : 0,
|
|
235
|
+
updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
export default reviewsHandler;
|
|
239
|
+
|
|
240
|
+
//# sourceMappingURL=reviews.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/reviews.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, isImpersonationSession } from '@aglyn/tenant-data-admin'\nimport { canActAsPublisher } from './publisher-profile'\n\nconst MAX_COMMENT_LENGTH = 2000\n/** Orgs scanned when looking for an install pin. */\nconst MAX_ORGS_CHECKED = 25\n\n/**\n * Whether the author has actually used this listing (AGL-655).\n *\n * An install pin lives at `orgs/{orgId}/installs/{listingId}` (org-wide) or\n * `hosts/{hostId}/installs/{listingId}` (one site), and **it is one or the\n * other** — `install-plugin` reassigns its target ref to the org path for an\n * org-scoped install rather than writing both.\n *\n * This used to read the org pin alone, on the stated reasoning that \"a host\n * pin implies the org installed it somewhere, and the org mirror is what the\n * install routes write\" (AGL-1006). There is no org mirror, so a per-site\n * install left nothing to find and the workspace was refused a rating — and\n * it refused exactly the careful installers, the ones who scoped a plugin to\n * one site before rolling it out. Ratings drive marketplace ranking, so that\n * skewed the sample toward org-wide installers.\n *\n * Host pins are found through the org doc's own `hosts` map (written by\n * `registerOrgHost`), then read in ONE batched `getAll` alongside the org\n * pin. Two round trips per org, bounded and index-free — deliberately not a\n * collection-group query on `installs`, which would need a new index and\n * would match every tenant's pins for a listing, unbounded, to answer a\n * question about one org.\n */\nconst MAX_HOSTS_CHECKED = 50\n\n// Exported for the AGL-1006 regression test: it takes its firestore, so the\n// rule \"a host pin counts\" is checkable without standing up the whole\n// handler (auth, transactions, aggregate writes).\nexport async function isVerifiedInstaller(\n firestore: FirebaseFirestore.Firestore,\n uid: string,\n listingId: string,\n): Promise<{ verified: boolean; orgId?: string }> {\n const memberships = await firestore\n .collection('users')\n .doc(uid)\n .collection('orgs')\n .limit(MAX_ORGS_CHECKED)\n .get()\n for (const membership of memberships.docs) {\n const orgId = membership.id\n const orgRef = firestore.collection('orgs').doc(orgId)\n const org = await orgRef.get()\n const hostIds = Object.keys(\n (org.get('hosts') as Record<string, unknown> | undefined) ?? {},\n ).slice(0, MAX_HOSTS_CHECKED)\n const refs = [\n orgRef.collection('installs').doc(listingId),\n ...hostIds.map((hostId) =>\n firestore\n .collection('hosts')\n .doc(hostId)\n .collection('installs')\n .doc(listingId),\n ),\n ]\n const pins = await firestore.getAll(...refs)\n if (pins.some((pin) => pin.exists)) return { verified: true, orgId }\n }\n return { verified: false }\n}\n\n/**\n * Ratings and comments on a marketplace listing (AGL-655).\n *\n * One review per account, keyed by uid, so re-submitting edits rather than\n * stacking. Two rules shape everything else:\n *\n * - **Rating requires a verified email AND having installed it (AGL-865).**\n * A star rating is the signal that drives ranking, so it is reserved for\n * accounts that verified their email and actually used the thing.\n * Commenting stays open to any signed-in account — questions before\n * installing are useful, and a comment moves no numbers.\n * - **The publisher cannot review their own listing.** Org-scoped, so a\n * second account in the same org does not slip through.\n *\n * Aggregates are adjusted in the same transaction as the review, and are\n * frozen from client writes in rules. Kept as a running `ratingSum` +\n * `ratingCount` so a submission costs one read, not a scan of every review.\n */\nexport const reviewsHandler: PluginApiHandler = async (req, res) => {\n const method = req.method === 'DELETE' ? 'DELETE' : 'POST'\n if (req.method !== 'POST' && req.method !== 'DELETE') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const listingId = String(req.body?.listingId ?? '')\n if (!listingId) return res.status(400).json({ error: 'Missing listingId' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const listingRef = firestore.collection('marketplaceListings').doc(listingId)\n const listingSnapshot = await listingRef.get()\n const listing = listingSnapshot.data() as any\n if (!listing || listing.deletedAt) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n const reviewRef = listingRef.collection('reviews').doc(decoded.uid)\n\n if (method === 'DELETE') {\n await firestore.runTransaction(async (tx) => {\n const [existing, listingNow] = await Promise.all([\n tx.get(reviewRef),\n tx.get(listingRef),\n ])\n if (!existing.exists) return\n const previousRating = Number(existing.get('rating') ?? 0) || 0\n const hadComment = !!existing.get('comment')\n tx.delete(reviewRef)\n tx.update(\n listingRef,\n nextAggregates(\n listingNow,\n -previousRating,\n previousRating ? -1 : 0,\n hadComment ? -1 : 0,\n ),\n )\n })\n return res.status(200).json({ ok: true, removed: true })\n }\n\n // Publishing org cannot review itself, however many accounts it has.\n if (await canActAsPublisher(firestore, decoded.uid, listing.profileId)) {\n return res.status(403).json({\n error: 'You cannot review your organization’s own listing',\n })\n }\n\n const rawRating = req.body?.rating\n const rating =\n rawRating == null || rawRating === ''\n ? 0\n : Math.min(5, Math.max(1, Math.round(Number(rawRating) || 0)))\n const comment = String(req.body?.comment ?? '')\n .trim()\n .slice(0, MAX_COMMENT_LENGTH)\n if (!rating && !comment) {\n return res.status(400).json({ error: 'Add a rating or a comment' })\n }\n\n // A star rating drives ranking, so it is reserved for accounts that both\n // verified their email (AGL-865/479) AND actually installed the thing.\n // Commenting stays open to any signed-in account — the checks below only\n // guard `rating`, never `comment`. Impersonation sessions are exempt from\n // the email gate the same way every other AGL-479 chokepoint is.\n if (rating && !decoded.email_verified && !isImpersonationSession(decoded)) {\n return res.status(403).json({\n error: 'Verify your email to rate — you can still leave a comment',\n reason: 'email-unverified',\n })\n }\n const installer = await isVerifiedInstaller(firestore, decoded.uid, listingId)\n if (rating && !installer.verified) {\n return res.status(403).json({\n error:\n 'Only organizations that have installed this can rate it — you ' +\n 'can still leave a comment',\n })\n }\n\n const now = Date.now()\n await firestore.runTransaction(async (tx) => {\n const [existing, listingNow] = await Promise.all([\n tx.get(reviewRef),\n tx.get(listingRef),\n ])\n const previousRating = Number(existing.get('rating') ?? 0) || 0\n const hadComment = existing.exists && !!existing.get('comment')\n tx.set(\n reviewRef,\n {\n uid: decoded.uid,\n displayName: String(decoded.name ?? 'Someone').slice(0, 80),\n ...(rating ? { rating } : { rating: null }),\n ...(comment ? { comment } : { comment: null }),\n verifiedInstaller: installer.verified,\n ...(installer.orgId ? { orgId: installer.orgId } : {}),\n // Preserved across edits: a moderator's decision must not be\n // undone by the author simply resubmitting.\n hidden: existing.get('hidden') ?? false,\n ...(existing.exists ? {} : { createdAtMs: now }),\n updatedAtMs: now,\n },\n { merge: true },\n )\n tx.update(\n listingRef,\n nextAggregates(\n listingNow,\n rating - previousRating,\n (rating ? 1 : 0) - (previousRating ? 1 : 0),\n (comment ? 1 : 0) - (hadComment ? 1 : 0),\n ),\n )\n })\n\n return res.status(200).json({\n ok: true,\n rating: rating || null,\n verifiedInstaller: installer.verified,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Review failed' })\n }\n}\n\n/**\n * Absolute aggregate values after a change.\n *\n * Written as numbers rather than `FieldValue.increment` because\n * `ratingAverage` has to be stored — browse sorts on it, and a transaction\n * cannot divide two pending increments. Reading the listing inside the\n * transaction makes the read-modify-write atomic, so concurrent reviews\n * cannot interleave and lose a vote.\n */\nfunction nextAggregates(\n current: FirebaseFirestore.DocumentSnapshot,\n ratingSumDelta: number,\n ratingCountDelta: number,\n commentCountDelta: number,\n): Record<string, unknown> {\n const ratingSum = Math.max(0, Number(current.get('ratingSum') ?? 0) + ratingSumDelta)\n const ratingCount = Math.max(\n 0,\n Number(current.get('ratingCount') ?? 0) + ratingCountDelta,\n )\n const commentCount = Math.max(\n 0,\n Number(current.get('commentCount') ?? 0) + commentCountDelta,\n )\n return {\n ratingSum,\n ratingCount,\n commentCount,\n // One decimal — enough for \"4.2\", and avoids a long float in the UI.\n ratingAverage: ratingCount\n ? Math.round((ratingSum / ratingCount) * 10) / 10\n : 0,\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n }\n}\n\nexport default reviewsHandler\n"],"names":["firebaseAdmin","isImpersonationSession","canActAsPublisher","MAX_COMMENT_LENGTH","MAX_ORGS_CHECKED","MAX_HOSTS_CHECKED","isVerifiedInstaller","firestore","uid","listingId","memberships","collection","doc","limit","get","membership","docs","org","orgId","id","orgRef","hostIds","Object","keys","slice","refs","map","hostId","pins","getAll","some","pin","exists","verified","reviewsHandler","req","res","method","status","json","error","String","body","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","listingRef","listingSnapshot","listing","data","deletedAt","reviewRef","runTransaction","tx","existing","listingNow","Promise","all","previousRating","Number","hadComment","delete","update","nextAggregates","ok","removed","profileId","rawRating","rating","Math","min","max","round","comment","trim","email_verified","reason","installer","now","Date","set","displayName","name","verifiedInstaller","hidden","createdAtMs","updatedAtMs","merge","console","current","ratingSumDelta","ratingCountDelta","commentCountDelta","ratingSum","ratingCount","commentCount","ratingAverage","updatedAt","FieldValue","serverTimestamp"],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
18
|
+
export declare const updateArtifactHandler: PluginApiHandler;
|
|
19
|
+
export default updateArtifactHandler;
|