@aglyn/plugins-marketplace 1.11.0 → 1.12.0
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 +14 -14
- package/src/lib/components/listing-content.component.js +78 -36
- package/src/lib/components/listing-content.component.js.map +1 -1
- package/src/lib/components/listing-detail-page.component.js +22 -9
- package/src/lib/components/listing-detail-page.component.js.map +1 -1
- package/src/lib/components/listing-review-status.component.d.ts +8 -0
- package/src/lib/components/listing-review-status.component.js +23 -3
- package/src/lib/components/listing-review-status.component.js.map +1 -1
- package/src/lib/components/listing-verification-request.component.d.ts +8 -0
- package/src/lib/components/listing-verification-request.component.js +12 -5
- package/src/lib/components/listing-verification-request.component.js.map +1 -1
- package/src/lib/components/marketplace-hub.component.js +2 -1
- package/src/lib/components/marketplace-hub.component.js.map +1 -1
- package/src/lib/components/org-seller-panel.component.d.ts +10 -0
- package/src/lib/components/org-seller-panel.component.js +26 -4
- package/src/lib/components/org-seller-panel.component.js.map +1 -1
- package/src/lib/components/starter-picker-shelf.component.js +13 -8
- package/src/lib/components/starter-picker-shelf.component.js.map +1 -1
- package/src/lib/declarations.d.ts +2 -1
- package/src/lib/declarations.js +5 -1
- package/src/lib/declarations.js.map +1 -1
- package/src/lib/model/marketplace-publisher-permissions.d.ts +26 -0
- package/src/lib/model/marketplace-publisher-permissions.js +64 -0
- package/src/lib/model/marketplace-publisher-permissions.js.map +1 -0
- package/src/lib/model/marketplace.js +2 -1
- package/src/lib/model/marketplace.js.map +1 -1
- package/src/lib/model/own-listing.d.ts +52 -0
- package/src/lib/model/own-listing.js +72 -0
- package/src/lib/model/own-listing.js.map +1 -0
- package/src/lib/model/starter-query.js +2 -0
- package/src/lib/model/starter-query.js.map +1 -1
- package/src/lib/server/connect.js +11 -5
- package/src/lib/server/connect.js.map +1 -1
- package/src/lib/server/install-dataset-schema.js +10 -2
- package/src/lib/server/install-dataset-schema.js.map +1 -1
- package/src/lib/server/install-email-starter.js +8 -2
- package/src/lib/server/install-email-starter.js.map +1 -1
- package/src/lib/server/install-email-template.js +8 -2
- package/src/lib/server/install-email-template.js.map +1 -1
- package/src/lib/server/install-layout.js +8 -2
- package/src/lib/server/install-layout.js.map +1 -1
- package/src/lib/server/install-plugin.js +24 -20
- package/src/lib/server/install-plugin.js.map +1 -1
- package/src/lib/server/install-template.js +8 -5
- package/src/lib/server/install-template.js.map +1 -1
- package/src/lib/server/install-theme.js +8 -2
- package/src/lib/server/install-theme.js.map +1 -1
- package/src/lib/server/install.js +10 -5
- package/src/lib/server/install.js.map +1 -1
- package/src/lib/server/listing-versions.js +24 -9
- package/src/lib/server/listing-versions.js.map +1 -1
- package/src/lib/server/own-listing.d.ts +46 -0
- package/src/lib/server/own-listing.js +52 -0
- package/src/lib/server/own-listing.js.map +1 -0
- package/src/lib/server/preview-image.d.ts +1 -1
- package/src/lib/server/preview-image.js +9 -8
- package/src/lib/server/preview-image.js.map +1 -1
- package/src/lib/server/publish-plugin.js +17 -19
- package/src/lib/server/publish-plugin.js.map +1 -1
- package/src/lib/server/publisher-permissions.d.ts +63 -0
- package/src/lib/server/publisher-permissions.js +51 -0
- package/src/lib/server/publisher-permissions.js.map +1 -0
- package/src/lib/server/publisher-profile-save.d.ts +4 -2
- package/src/lib/server/publisher-profile-save.js +12 -5
- package/src/lib/server/publisher-profile-save.js.map +1 -1
- package/src/lib/server/publisher-profile.d.ts +18 -6
- package/src/lib/server/publisher-profile.js +19 -8
- package/src/lib/server/publisher-profile.js.map +1 -1
- package/src/lib/server/reviews.js +6 -5
- package/src/lib/server/reviews.js.map +1 -1
- package/src/lib/server/sale-risk.d.ts +0 -6
- package/src/lib/server/sale-risk.js +0 -9
- package/src/lib/server/sale-risk.js.map +1 -1
- package/src/lib/server/sale.js +10 -9
- package/src/lib/server/sale.js.map +1 -1
- package/src/lib/server/site-starter-source.js +43 -17
- package/src/lib/server/site-starter-source.js.map +1 -1
- package/src/lib/server/update-artifact.js +11 -4
- package/src/lib/server/update-artifact.js.map +1 -1
- package/src/lib/server/verification-request.js +23 -7
- package/src/lib/server/verification-request.js.map +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
17
|
+
import { MARKETPLACE_MANAGE_LISTINGS_PERMISSION, marketplacePublisherPermissionRefusal } from "../model/marketplace-publisher-permissions.js";
|
|
18
|
+
export async function publisherPermissionVerdict(uid, publisherOrgId, permission) {
|
|
19
|
+
if (!uid || !publisherOrgId) return 'not-a-member';
|
|
20
|
+
const membership = await resolveOrgMembership(uid, publisherOrgId);
|
|
21
|
+
if (!membership) return 'not-a-member';
|
|
22
|
+
return await memberHasOrgPermission(publisherOrgId, membership.member, permission) ? 'granted' : 'refused';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The listing doors' gate (AGL-4047): editing a published listing, its
|
|
26
|
+
* picture, its visibility, and unpublishing or republishing it ask "Manage
|
|
27
|
+
* published listings" of a member of the workspace that published it. They
|
|
28
|
+
* asked for the owner or admin role, which is the key's default. Staff moderate any listing, as before. Someone outside the
|
|
29
|
+
* workspace hears the listing is not theirs, as before; a member without the
|
|
30
|
+
* key hears which permission they are missing.
|
|
31
|
+
*/ export async function manageListingsRefusal(caller, publisherOrgId, options) {
|
|
32
|
+
if (options.staffMayModerate && caller.staff === true) return null;
|
|
33
|
+
const verdict = await publisherPermissionVerdict(caller.uid, typeof publisherOrgId === 'string' ? publisherOrgId : null, MARKETPLACE_MANAGE_LISTINGS_PERMISSION);
|
|
34
|
+
if (verdict === 'granted') return null;
|
|
35
|
+
if (verdict === 'not-a-member') return {
|
|
36
|
+
status: 403,
|
|
37
|
+
body: {
|
|
38
|
+
error: 'Not your listing'
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
status: 403,
|
|
43
|
+
body: {
|
|
44
|
+
error: marketplacePublisherPermissionRefusal(MARKETPLACE_MANAGE_LISTINGS_PERMISSION),
|
|
45
|
+
reason: 'permission',
|
|
46
|
+
permission: MARKETPLACE_MANAGE_LISTINGS_PERMISSION
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=publisher-permissions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publisher-permissions.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 { memberHasOrgPermission, resolveOrgMembership } from '@aglyn/tenant-data-admin'\nimport {\n MARKETPLACE_MANAGE_LISTINGS_PERMISSION,\n marketplacePublisherPermissionRefusal,\n type MarketplacePublisherPermission,\n} from '../model/marketplace-publisher-permissions'\n\n/**\n * Where a member stands on one of the publisher keys of the workspace that\n * publishes (AGL-4047):\n *\n * - `granted`: a member who holds it, by their role's default (owners and\n * admins), their custom role or their own permissions;\n * - `refused`: a member who does not;\n * - `not-a-member`: someone with no membership in that workspace at all,\n * for whom the listing or the profile is simply not theirs.\n *\n * The same answer `publisherManager()` and `listingManager()` give in the\n * Firestore rules, read off the member's stamped permissions there, so the\n * route and the client-direct write cannot disagree. Payouts, the Verified\n * badge ask and the publisher's version history have no client-direct door to\n * agree with: the rules freeze the payout fields and `verificationRequest`,\n * and keep `pluginVersions` to staff, so their routes are the only way in.\n *\n * Whether someone belongs to the publishing workspace at all, with no key\n * asked, is `isPublisherMember` (no self-purchase, no self-review, and, with\n * the install landing in that workspace, free own installs: `ownsListingHere`).\n */\nexport type PublisherPermissionVerdict = 'granted' | 'refused' | 'not-a-member'\n\nexport async function publisherPermissionVerdict(\n uid: string | null | undefined,\n publisherOrgId: string | null | undefined,\n permission: MarketplacePublisherPermission,\n): Promise<PublisherPermissionVerdict> {\n if (!uid || !publisherOrgId) return 'not-a-member'\n const membership = await resolveOrgMembership(uid, publisherOrgId)\n if (!membership) return 'not-a-member'\n return (await memberHasOrgPermission(publisherOrgId, membership.member, permission as never)) ? 'granted' : 'refused'\n}\n\n/** A door's refusal, shaped for `res.status(status).json(body)`. */\nexport interface PublisherDoorRefusal {\n status: 403\n body: { error: string; reason?: 'permission'; permission?: MarketplacePublisherPermission }\n}\n\n/**\n * The listing doors' gate (AGL-4047): editing a published listing, its\n * picture, its visibility, and unpublishing or republishing it ask \"Manage\n * published listings\" of a member of the workspace that published it. They\n * asked for the owner or admin role, which is the key's default. Staff moderate any listing, as before. Someone outside the\n * workspace hears the listing is not theirs, as before; a member without the\n * key hears which permission they are missing.\n */\nexport async function manageListingsRefusal(\n caller: { uid: string; staff?: unknown },\n publisherOrgId: unknown,\n options: { staffMayModerate: boolean },\n): Promise<PublisherDoorRefusal | null> {\n if (options.staffMayModerate && caller.staff === true) return null\n const verdict = await publisherPermissionVerdict(\n caller.uid,\n typeof publisherOrgId === 'string' ? publisherOrgId : null,\n MARKETPLACE_MANAGE_LISTINGS_PERMISSION,\n )\n if (verdict === 'granted') return null\n if (verdict === 'not-a-member') return { status: 403, body: { error: 'Not your listing' } }\n return {\n status: 403,\n body: {\n error: marketplacePublisherPermissionRefusal(MARKETPLACE_MANAGE_LISTINGS_PERMISSION),\n reason: 'permission',\n permission: MARKETPLACE_MANAGE_LISTINGS_PERMISSION,\n },\n }\n}\n"],"names":["memberHasOrgPermission","resolveOrgMembership","MARKETPLACE_MANAGE_LISTINGS_PERMISSION","marketplacePublisherPermissionRefusal","publisherPermissionVerdict","uid","publisherOrgId","permission","membership","member","manageListingsRefusal","caller","options","staffMayModerate","staff","verdict","status","body","error","reason"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,EAAEC,oBAAoB,QAAQ,2BAA0B;AACvF,SACEC,sCAAsC,EACtCC,qCAAqC,QAEhC,gDAA4C;AAyBnD,OAAO,eAAeC,2BACpBC,GAA8B,EAC9BC,cAAyC,EACzCC,UAA0C;IAE1C,IAAI,CAACF,OAAO,CAACC,gBAAgB,OAAO;IACpC,MAAME,aAAa,MAAMP,qBAAqBI,KAAKC;IACnD,IAAI,CAACE,YAAY,OAAO;IACxB,OAAO,AAAC,MAAMR,uBAAuBM,gBAAgBE,WAAWC,MAAM,EAAEF,cAAwB,YAAY;AAC9G;AAQA;;;;;;;CAOC,GACD,OAAO,eAAeG,sBACpBC,MAAwC,EACxCL,cAAuB,EACvBM,OAAsC;IAEtC,IAAIA,QAAQC,gBAAgB,IAAIF,OAAOG,KAAK,KAAK,MAAM,OAAO;IAC9D,MAAMC,UAAU,MAAMX,2BACpBO,OAAON,GAAG,EACV,OAAOC,mBAAmB,WAAWA,iBAAiB,MACtDJ;IAEF,IAAIa,YAAY,WAAW,OAAO;IAClC,IAAIA,YAAY,gBAAgB,OAAO;QAAEC,QAAQ;QAAKC,MAAM;YAAEC,OAAO;QAAmB;IAAE;IAC1F,OAAO;QACLF,QAAQ;QACRC,MAAM;YACJC,OAAOf,sCAAsCD;YAC7CiB,QAAQ;YACRZ,YAAYL;QACd;IACF;AACF"}
|
|
@@ -24,7 +24,9 @@ import { type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
|
24
24
|
* handle is server-owned here, exactly like the host subdomain (AGL-642), and
|
|
25
25
|
* the rules freeze it against client writes.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* "Edit the publisher profile" (`marketplace.publisherProfile`, AGL-4047):
|
|
28
|
+
* the profile is the org's public identity and its payout anchor, so it asks
|
|
29
|
+
* the key the profile rules ask. Its default is the owner and admins this
|
|
30
|
+
* asked for by role before, so nobody's access moved.
|
|
29
31
|
*/
|
|
30
32
|
export declare const publisherProfileSaveHandler: PluginApiHandler;
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
18
18
|
import { PUBLISHER_AGREEMENT_CONSENT_LABEL, PUBLISHER_AGREEMENT_TITLE, publisherAgreementConsentRefusal } from "../model/publisher-agreement.js";
|
|
19
19
|
import { isValidPublisherHandle, validatePublisherProfileContent } from "../model/marketplace.js";
|
|
20
|
-
import {
|
|
20
|
+
import { claimPublisherHandle, PublisherHandleTakenError, PUBLISHER_PROFILES } from "./publisher-profile.js";
|
|
21
|
+
import { MARKETPLACE_PUBLISHER_PROFILE_PERMISSION, marketplacePublisherPermissionRefusal } from "../model/marketplace-publisher-permissions.js";
|
|
22
|
+
import { publisherPermissionVerdict } from "./publisher-permissions.js";
|
|
21
23
|
import { listingSubmissionRefusal, publisherIdentityImpersonation } from "./listing-screen.js";
|
|
22
24
|
import { publisherAgreementNote, recordPublisherAgreementAcceptance } from "./publisher-agreement-acceptance.js";
|
|
23
25
|
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
@@ -30,8 +32,10 @@ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refus
|
|
|
30
32
|
* handle is server-owned here, exactly like the host subdomain (AGL-642), and
|
|
31
33
|
* the rules freeze it against client writes.
|
|
32
34
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
+
* "Edit the publisher profile" (`marketplace.publisherProfile`, AGL-4047):
|
|
36
|
+
* the profile is the org's public identity and its payout anchor, so it asks
|
|
37
|
+
* the key the profile rules ask. Its default is the owner and admins this
|
|
38
|
+
* asked for by role before, so nobody's access moved.
|
|
35
39
|
*/ export const publisherProfileSaveHandler = async (req, res)=>{
|
|
36
40
|
var _ref, _ref1, _ref2, _req_headers_authorization;
|
|
37
41
|
var _req_body, _req_body1, _req_body2, _req_body3, _req_body4;
|
|
@@ -90,9 +94,12 @@ import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refus
|
|
|
90
94
|
var _ref3, _req_body5, _extras_content;
|
|
91
95
|
var _req_body6, _req_body7;
|
|
92
96
|
const firestore = firebaseAdmin.app().firestore();
|
|
93
|
-
|
|
97
|
+
const verdict = await publisherPermissionVerdict(decoded.uid, orgId, MARKETPLACE_PUBLISHER_PROFILE_PERMISSION);
|
|
98
|
+
if (verdict !== 'granted') {
|
|
94
99
|
return res.status(403).json({
|
|
95
|
-
error:
|
|
100
|
+
error: marketplacePublisherPermissionRefusal(MARKETPLACE_PUBLISHER_PROFILE_PERMISSION),
|
|
101
|
+
reason: 'permission',
|
|
102
|
+
permission: MARKETPLACE_PUBLISHER_PROFILE_PERMISSION
|
|
96
103
|
});
|
|
97
104
|
}
|
|
98
105
|
// The publisher's public identity, screened before the handle is claimed
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publisher-profile-save.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHER_AGREEMENT_CONSENT_LABEL,\n PUBLISHER_AGREEMENT_TITLE,\n publisherAgreementConsentRefusal,\n} from '../model/publisher-agreement'\nimport {\n isValidPublisherHandle,\n validatePublisherProfileContent,\n} from '../model/marketplace'\nimport {\n canActAsPublisher,\n claimPublisherHandle,\n PublisherHandleTakenError,\n PUBLISHER_PROFILES,\n} from './publisher-profile'\nimport {\n listingSubmissionRefusal,\n publisherIdentityImpersonation,\n} from './listing-screen'\nimport {\n publisherAgreementNote,\n recordPublisherAgreementAcceptance,\n} from './publisher-agreement-acceptance'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport type { DecodedIdToken } from 'firebase-admin/auth'\n\n/**\n * Save an org's publisher profile (AGL-652).\n *\n * The handle has to be claimed transactionally, and a client can't do that —\n * a read-then-write uniqueness check lets two orgs racing for the same handle\n * both succeed, and the loser silently loses its marketplace URL. So the\n * handle is server-owned here, exactly like the host subdomain (AGL-642), and\n * the rules freeze it against client writes.\n *\n * Manager-only: the profile is the org's public identity and its payout\n * anchor, so it follows the same gate as the profile rules.\n */\nexport const publisherProfileSaveHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n // Accepting the agreement once per organization (AGL-1077) is retired\n // (AGL-4105): every save agrees to it on its own, ticked in the form. An app\n // from before that still sends this, and is told what to do instead rather\n // than having it read as a profile save with no name.\n if (req.body?.action === 'accept-agreement') {\n return res.status(410).json({\n error:\n `The ${PUBLISHER_AGREEMENT_TITLE} is now agreed to on each save: tick ` +\n `'${PUBLISHER_AGREEMENT_CONSENT_LABEL}' above Save or Publish.`,\n })\n }\n const orgId = String(req.body?.orgId ?? '')\n const handle = String(req.body?.handle ?? '').trim().toLowerCase()\n const displayName = String(req.body?.displayName ?? '').trim().slice(0, 80)\n if (!orgId) return res.status(400).json({ error: 'Missing orgId' })\n if (!displayName) {\n return res.status(400).json({ error: 'A display name is required' })\n }\n if (!isValidPublisherHandle(handle)) {\n return res.status(400).json({\n error:\n 'Handles are 3–30 characters: lowercase letters, numbers and ' +\n 'hyphens, starting and ending alphanumeric.',\n })\n }\n // The publisher agreement, agreed to on this save (AGL-4105).\n const agreementRefusal = publisherAgreementConsentRefusal(req.body?.agreement)\n if (agreementRefusal) {\n return res.status(agreementRefusal.status).json(agreementRefusal.body)\n }\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 let decoded: DecodedIdToken\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // Only a refused credential is the caller's 401; failing to check it is\n // ours and keeps the dispatcher's 5xx (AGL-2852).\n if (!isRefusedIdToken(error)) throw error\n return res.status(401).json({ error: 'Unauthenticated' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n if (!(await canActAsPublisher(firestore, decoded.uid, orgId))) {\n return res.status(403).json({\n error: 'Only an organization owner or admin can edit the publisher profile',\n })\n }\n\n // The publisher's public identity, screened before the handle is claimed\n // (AGL-3365): a name or handle that wears the platform's name or speaks\n // for a brand is refused, and the name, bio and links are read by the\n // same phishing screen as a listing — the profile is what every one of\n // its listings says it is from.\n const official = decoded['staff'] === true\n const impersonation = publisherIdentityImpersonation({ displayName, handle, official })\n if (impersonation) {\n return res.status(422).json({ error: impersonation, code: 'impersonation' })\n }\n const text = (key: string) =>\n typeof req.body?.[key] === 'string' ? (req.body[key] as string) : null\n const screened = await listingSubmissionRefusal({\n publisherOrgId: orgId,\n content: {\n publisherName: displayName,\n description: text('bio'),\n urls: [text('website'), text('supportUrl'), text('supportEmail')],\n },\n official,\n })\n if (screened) return res.status(screened.status).json(screened.body)\n\n const ref = firestore.collection(PUBLISHER_PROFILES).doc(orgId)\n const previousHandle = (await ref.get()).get('handle') as string | undefined\n\n // The agreement this save was sent with, recorded before anything is\n // claimed or written (AGL-4105). The profile's note of it rides the\n // profile write below, which may be the one that creates the profile.\n const acceptance = await recordPublisherAgreementAcceptance({\n firestore,\n request: req,\n orgId,\n uid: decoded.uid,\n staff: official,\n action: 'save-profile',\n targetId: orgId,\n noteOnProfile: false,\n })\n\n // Claim first: if the handle is taken there must be no profile write, or\n // the org would show a handle it doesn't actually hold.\n try {\n await claimPublisherHandle(firestore, orgId, handle, previousHandle)\n } catch (error) {\n if (error instanceof PublisherHandleTakenError) {\n return res.status(409).json({ error: 'That handle is already taken' })\n }\n throw error\n }\n\n // PRESENT-AND-EMPTY CLEARS; ABSENT LEAVES ALONE.\n //\n // The write below is a merge, and `bio` was spread in only when truthy, so\n // an empty one was a no-op and the stored bio survived every attempt to\n // remove it. The publisher could rewrite it but never take it down — while\n // every URL field in this same write has cleared on empty since AGL-1009.\n //\n // Presence is what separates \"clear this\" from \"this request is not about\n // the bio\": `req.body.bio` absent must not delete a bio the caller never\n // mentioned, which a bare `?? ''` would do to any client that omits it.\n const bioProvided = req.body?.bio !== undefined\n const bio = String(req.body?.bio ?? '').trim().slice(0, 500)\n // Logo, support contact and social links (AGL-1009): validated here on\n // the save route — the same server-owned discipline as the handle — and\n // through `validatePublisherProfileContent`, which shares the listing\n // validator's https-only rule rather than growing a second URL parser.\n const extras = validatePublisherProfileContent(req.body ?? {})\n if (!extras.ok) {\n return res.status(400).json({ error: extras.error })\n }\n const extraWrites: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(extras.content ?? {})) {\n // An explicit empty string clears the stored field — otherwise a\n // publisher could add a link but never take one down.\n extraWrites[key] =\n value === '' ? firebaseAdmin.firestore.FieldValue.delete() : value\n }\n await ref.set(\n {\n handle,\n displayName,\n ...(bioProvided\n ? {\n bio: bio\n ? bio\n : firebaseAdmin.firestore.FieldValue.delete(),\n }\n : {}),\n ...extraWrites,\n publisherAgreement: publisherAgreementNote(acceptance),\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, handle })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Could not save the profile' })\n }\n}\n"],"names":["firebaseAdmin","PUBLISHER_AGREEMENT_CONSENT_LABEL","PUBLISHER_AGREEMENT_TITLE","publisherAgreementConsentRefusal","isValidPublisherHandle","validatePublisherProfileContent","canActAsPublisher","claimPublisherHandle","PublisherHandleTakenError","PUBLISHER_PROFILES","listingSubmissionRefusal","publisherIdentityImpersonation","publisherAgreementNote","recordPublisherAgreementAcceptance","isRefusedIdToken","publisherProfileSaveHandler","req","res","method","status","json","error","body","action","orgId","String","handle","trim","toLowerCase","displayName","slice","agreementRefusal","agreement","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","extras","firestore","uid","official","impersonation","code","text","key","screened","publisherOrgId","content","publisherName","description","urls","ref","collection","doc","previousHandle","get","acceptance","request","staff","targetId","noteOnProfile","bioProvided","bio","ok","extraWrites","value","Object","entries","FieldValue","delete","set","publisherAgreement","updatedAt","serverTimestamp","merge","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publisher-profile-save.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHER_AGREEMENT_CONSENT_LABEL,\n PUBLISHER_AGREEMENT_TITLE,\n publisherAgreementConsentRefusal,\n} from '../model/publisher-agreement'\nimport {\n isValidPublisherHandle,\n validatePublisherProfileContent,\n} from '../model/marketplace'\nimport {\n claimPublisherHandle,\n PublisherHandleTakenError,\n PUBLISHER_PROFILES,\n} from './publisher-profile'\nimport {\n MARKETPLACE_PUBLISHER_PROFILE_PERMISSION,\n marketplacePublisherPermissionRefusal,\n} from '../model/marketplace-publisher-permissions'\nimport { publisherPermissionVerdict } from './publisher-permissions'\nimport {\n listingSubmissionRefusal,\n publisherIdentityImpersonation,\n} from './listing-screen'\nimport {\n publisherAgreementNote,\n recordPublisherAgreementAcceptance,\n} from './publisher-agreement-acceptance'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport type { DecodedIdToken } from 'firebase-admin/auth'\n\n/**\n * Save an org's publisher profile (AGL-652).\n *\n * The handle has to be claimed transactionally, and a client can't do that —\n * a read-then-write uniqueness check lets two orgs racing for the same handle\n * both succeed, and the loser silently loses its marketplace URL. So the\n * handle is server-owned here, exactly like the host subdomain (AGL-642), and\n * the rules freeze it against client writes.\n *\n * \"Edit the publisher profile\" (`marketplace.publisherProfile`, AGL-4047):\n * the profile is the org's public identity and its payout anchor, so it asks\n * the key the profile rules ask. Its default is the owner and admins this\n * asked for by role before, so nobody's access moved.\n */\nexport const publisherProfileSaveHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n // Accepting the agreement once per organization (AGL-1077) is retired\n // (AGL-4105): every save agrees to it on its own, ticked in the form. An app\n // from before that still sends this, and is told what to do instead rather\n // than having it read as a profile save with no name.\n if (req.body?.action === 'accept-agreement') {\n return res.status(410).json({\n error:\n `The ${PUBLISHER_AGREEMENT_TITLE} is now agreed to on each save: tick ` +\n `'${PUBLISHER_AGREEMENT_CONSENT_LABEL}' above Save or Publish.`,\n })\n }\n const orgId = String(req.body?.orgId ?? '')\n const handle = String(req.body?.handle ?? '').trim().toLowerCase()\n const displayName = String(req.body?.displayName ?? '').trim().slice(0, 80)\n if (!orgId) return res.status(400).json({ error: 'Missing orgId' })\n if (!displayName) {\n return res.status(400).json({ error: 'A display name is required' })\n }\n if (!isValidPublisherHandle(handle)) {\n return res.status(400).json({\n error:\n 'Handles are 3–30 characters: lowercase letters, numbers and ' +\n 'hyphens, starting and ending alphanumeric.',\n })\n }\n // The publisher agreement, agreed to on this save (AGL-4105).\n const agreementRefusal = publisherAgreementConsentRefusal(req.body?.agreement)\n if (agreementRefusal) {\n return res.status(agreementRefusal.status).json(agreementRefusal.body)\n }\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 let decoded: DecodedIdToken\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // Only a refused credential is the caller's 401; failing to check it is\n // ours and keeps the dispatcher's 5xx (AGL-2852).\n if (!isRefusedIdToken(error)) throw error\n return res.status(401).json({ error: 'Unauthenticated' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const verdict = await publisherPermissionVerdict(\n decoded.uid,\n orgId,\n MARKETPLACE_PUBLISHER_PROFILE_PERMISSION,\n )\n if (verdict !== 'granted') {\n return res.status(403).json({\n error: marketplacePublisherPermissionRefusal(\n MARKETPLACE_PUBLISHER_PROFILE_PERMISSION,\n ),\n reason: 'permission',\n permission: MARKETPLACE_PUBLISHER_PROFILE_PERMISSION,\n })\n }\n\n // The publisher's public identity, screened before the handle is claimed\n // (AGL-3365): a name or handle that wears the platform's name or speaks\n // for a brand is refused, and the name, bio and links are read by the\n // same phishing screen as a listing — the profile is what every one of\n // its listings says it is from.\n const official = decoded['staff'] === true\n const impersonation = publisherIdentityImpersonation({ displayName, handle, official })\n if (impersonation) {\n return res.status(422).json({ error: impersonation, code: 'impersonation' })\n }\n const text = (key: string) =>\n typeof req.body?.[key] === 'string' ? (req.body[key] as string) : null\n const screened = await listingSubmissionRefusal({\n publisherOrgId: orgId,\n content: {\n publisherName: displayName,\n description: text('bio'),\n urls: [text('website'), text('supportUrl'), text('supportEmail')],\n },\n official,\n })\n if (screened) return res.status(screened.status).json(screened.body)\n\n const ref = firestore.collection(PUBLISHER_PROFILES).doc(orgId)\n const previousHandle = (await ref.get()).get('handle') as string | undefined\n\n // The agreement this save was sent with, recorded before anything is\n // claimed or written (AGL-4105). The profile's note of it rides the\n // profile write below, which may be the one that creates the profile.\n const acceptance = await recordPublisherAgreementAcceptance({\n firestore,\n request: req,\n orgId,\n uid: decoded.uid,\n staff: official,\n action: 'save-profile',\n targetId: orgId,\n noteOnProfile: false,\n })\n\n // Claim first: if the handle is taken there must be no profile write, or\n // the org would show a handle it doesn't actually hold.\n try {\n await claimPublisherHandle(firestore, orgId, handle, previousHandle)\n } catch (error) {\n if (error instanceof PublisherHandleTakenError) {\n return res.status(409).json({ error: 'That handle is already taken' })\n }\n throw error\n }\n\n // PRESENT-AND-EMPTY CLEARS; ABSENT LEAVES ALONE.\n //\n // The write below is a merge, and `bio` was spread in only when truthy, so\n // an empty one was a no-op and the stored bio survived every attempt to\n // remove it. The publisher could rewrite it but never take it down — while\n // every URL field in this same write has cleared on empty since AGL-1009.\n //\n // Presence is what separates \"clear this\" from \"this request is not about\n // the bio\": `req.body.bio` absent must not delete a bio the caller never\n // mentioned, which a bare `?? ''` would do to any client that omits it.\n const bioProvided = req.body?.bio !== undefined\n const bio = String(req.body?.bio ?? '').trim().slice(0, 500)\n // Logo, support contact and social links (AGL-1009): validated here on\n // the save route — the same server-owned discipline as the handle — and\n // through `validatePublisherProfileContent`, which shares the listing\n // validator's https-only rule rather than growing a second URL parser.\n const extras = validatePublisherProfileContent(req.body ?? {})\n if (!extras.ok) {\n return res.status(400).json({ error: extras.error })\n }\n const extraWrites: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(extras.content ?? {})) {\n // An explicit empty string clears the stored field — otherwise a\n // publisher could add a link but never take one down.\n extraWrites[key] =\n value === '' ? firebaseAdmin.firestore.FieldValue.delete() : value\n }\n await ref.set(\n {\n handle,\n displayName,\n ...(bioProvided\n ? {\n bio: bio\n ? bio\n : firebaseAdmin.firestore.FieldValue.delete(),\n }\n : {}),\n ...extraWrites,\n publisherAgreement: publisherAgreementNote(acceptance),\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, handle })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Could not save the profile' })\n }\n}\n"],"names":["firebaseAdmin","PUBLISHER_AGREEMENT_CONSENT_LABEL","PUBLISHER_AGREEMENT_TITLE","publisherAgreementConsentRefusal","isValidPublisherHandle","validatePublisherProfileContent","claimPublisherHandle","PublisherHandleTakenError","PUBLISHER_PROFILES","MARKETPLACE_PUBLISHER_PROFILE_PERMISSION","marketplacePublisherPermissionRefusal","publisherPermissionVerdict","listingSubmissionRefusal","publisherIdentityImpersonation","publisherAgreementNote","recordPublisherAgreementAcceptance","isRefusedIdToken","publisherProfileSaveHandler","req","res","method","status","json","error","body","action","orgId","String","handle","trim","toLowerCase","displayName","slice","agreementRefusal","agreement","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","extras","firestore","verdict","uid","reason","permission","official","impersonation","code","text","key","screened","publisherOrgId","content","publisherName","description","urls","ref","collection","doc","previousHandle","get","acceptance","request","staff","targetId","noteOnProfile","bioProvided","bio","ok","extraWrites","value","Object","entries","FieldValue","delete","set","publisherAgreement","updatedAt","serverTimestamp","merge","console"],"mappings":"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"}
|
|
@@ -61,15 +61,27 @@ export interface ResolvedPublisher {
|
|
|
61
61
|
*/
|
|
62
62
|
export declare function resolvePublisherProfile(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<ResolvedPublisher | null>;
|
|
63
63
|
/**
|
|
64
|
-
* Whether a user
|
|
64
|
+
* Whether a user belongs to the org that publishes a listing, in any role
|
|
65
|
+
* (AGL-652, AGL-4047).
|
|
66
|
+
*
|
|
67
|
+
* A FACT ABOUT OWNERSHIP, NOT A PERMISSION. It answers the questions that
|
|
68
|
+
* turn on who published the listing: nobody in that workspace may buy it or
|
|
69
|
+
* review it, and, on the workspace's OWN sites only, its members install it
|
|
70
|
+
* free (and may try a private or unreviewed one). The install doors ask that
|
|
71
|
+
* last one through `ownsListingHere` (`own-listing.ts`), which adds the
|
|
72
|
+
* "lands in the publishing workspace" half; off those sites they are buyers.
|
|
73
|
+
*
|
|
74
|
+
* A key could be refused to an admin, and the admin would still be part of
|
|
75
|
+
* the workspace that published it, so a permission here would let them pay
|
|
76
|
+
* themselves or rate their own work. What a member may DO
|
|
77
|
+
* for the publisher (its profile, its listings, its payouts, the Verified
|
|
78
|
+
* badge) asks a key instead: `publisherPermissionVerdict`.
|
|
65
79
|
*
|
|
66
80
|
* Ownership checks used to read `listing.profileId === decoded.uid`. With
|
|
67
|
-
* org-owned listings that comparison can NEVER be true
|
|
68
|
-
* id
|
|
69
|
-
* and make every paid listing chargeable to its own publisher. Managers
|
|
70
|
-
* (owner/admin) act for the org, matching the gate the profile rules apply.
|
|
81
|
+
* org-owned listings that comparison can NEVER be true (a uid is not an org
|
|
82
|
+
* id), which is why this asks the org's members.
|
|
71
83
|
*/
|
|
72
|
-
export declare function
|
|
84
|
+
export declare function isPublisherMember(firestore: FirebaseFirestore.Firestore, uid: string, publisherOrgId: string | undefined | null): Promise<boolean>;
|
|
73
85
|
export declare class PublisherHandleTakenError extends Error {
|
|
74
86
|
readonly handle: string;
|
|
75
87
|
constructor(handle: string);
|
|
@@ -64,18 +64,29 @@ import { readPlatformOrgId } from "./platform-org.js";
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
/**
|
|
67
|
-
* Whether a user
|
|
67
|
+
* Whether a user belongs to the org that publishes a listing, in any role
|
|
68
|
+
* (AGL-652, AGL-4047).
|
|
69
|
+
*
|
|
70
|
+
* A FACT ABOUT OWNERSHIP, NOT A PERMISSION. It answers the questions that
|
|
71
|
+
* turn on who published the listing: nobody in that workspace may buy it or
|
|
72
|
+
* review it, and, on the workspace's OWN sites only, its members install it
|
|
73
|
+
* free (and may try a private or unreviewed one). The install doors ask that
|
|
74
|
+
* last one through `ownsListingHere` (`own-listing.ts`), which adds the
|
|
75
|
+
* "lands in the publishing workspace" half; off those sites they are buyers.
|
|
76
|
+
*
|
|
77
|
+
* A key could be refused to an admin, and the admin would still be part of
|
|
78
|
+
* the workspace that published it, so a permission here would let them pay
|
|
79
|
+
* themselves or rate their own work. What a member may DO
|
|
80
|
+
* for the publisher (its profile, its listings, its payouts, the Verified
|
|
81
|
+
* badge) asks a key instead: `publisherPermissionVerdict`.
|
|
68
82
|
*
|
|
69
83
|
* Ownership checks used to read `listing.profileId === decoded.uid`. With
|
|
70
|
-
* org-owned listings that comparison can NEVER be true
|
|
71
|
-
* id
|
|
72
|
-
|
|
73
|
-
* (owner/admin) act for the org, matching the gate the profile rules apply.
|
|
74
|
-
*/ export async function canActAsPublisher(firestore, uid, publisherOrgId) {
|
|
84
|
+
* org-owned listings that comparison can NEVER be true (a uid is not an org
|
|
85
|
+
* id), which is why this asks the org's members.
|
|
86
|
+
*/ export async function isPublisherMember(firestore, uid, publisherOrgId) {
|
|
75
87
|
if (!uid || !publisherOrgId) return false;
|
|
76
88
|
const member = await firestore.collection('orgs').doc(publisherOrgId).collection('members').doc(uid).get();
|
|
77
|
-
|
|
78
|
-
return role === 'owner' || role === 'admin';
|
|
89
|
+
return member.exists;
|
|
79
90
|
}
|
|
80
91
|
export class PublisherHandleTakenError extends Error {
|
|
81
92
|
constructor(handle){
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publisher-profile.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { resolveMarketplaceFeePct } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { type PublisherAgreementAcceptance } from '../model/publisher-agreement'\nimport { isValidPublisherHandle } from '../model/marketplace'\nimport { sellsAsPlatform } from '../model/platform-seller'\nimport { readPlatformOrgId } from './platform-org'\n\n/** `publisherProfiles/{orgId}` — the org's marketplace identity (AGL-652). */\nexport const PUBLISHER_PROFILES = 'publisherProfiles'\n/** `publisherHandles/{handle}` — uniqueness reservations. */\nexport const PUBLISHER_HANDLES = 'publisherHandles'\n\nexport interface ResolvedPublisher {\n orgId: string\n handle: string\n displayName?: string\n stripeAccountId?: string\n stripeChargesEnabled?: boolean\n /**\n * Which Stripe world the payout account belongs to (AGL-2471).\n *\n * PROJECTED, and that is the load-bearing part. The sale gate in\n * `checkout.ts` reads this shape, not the raw document — an API projection\n * that drops a field its predicate needs turns the predicate into a\n * constant, and here the constant would be \"refuse every marketplace sale\".\n * Left three-valued deliberately: `undefined` means never established and\n * must stay distinguishable from `false`.\n */\n stripeAccountLivemode?: boolean\n /**\n * Which marketplace publisher agreement this ORG has accepted (AGL-1077).\n * Absent on every profile created before the agreement existed, which is\n * why publishing refuses rather than assuming consent.\n */\n agreement?: PublisherAgreementAcceptance\n /**\n * This org is the platform itself at the staff fee waiver (AGL-4049), so a\n * priced listing needs no payout account: there is nobody to pay.\n */\n sellsAsPlatform?: boolean\n}\n\n/**\n * Whether this org is the platform at the waived fee. Costs one extra read,\n * for the platform's own org only: every other publisher answers `false`\n * without touching its org document.\n */\nasync function publisherSellsAsPlatform(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<boolean> {\n const platformOrgId = readPlatformOrgId()\n if (platformOrgId !== orgId) return false\n const org = await firestore.collection('orgs').doc(orgId).get()\n return sellsAsPlatform({\n sellerOrgId: orgId,\n feePercent: resolveMarketplaceFeePct((org.data() ?? {}) as any),\n platformOrgId,\n })\n}\n\n/**\n * Load the publishing org's marketplace profile (AGL-652).\n *\n * Publishing is org-only: the publisher is the ORG, not the person who\n * happened to click publish. Every publish route resolves through here so\n * they can't drift on which identity a listing is attributed to.\n *\n * Returns `null` when the org has no profile yet — callers turn that into a\n * 412 telling the user to set one up, matching the previous behaviour when a\n * personal profile was missing.\n */\nexport async function resolvePublisherProfile(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<ResolvedPublisher | null> {\n const snapshot = await firestore.collection(PUBLISHER_PROFILES).doc(orgId).get()\n const handle = snapshot.get('handle') as string | undefined\n if (!snapshot.exists || !handle) return null\n return {\n orgId,\n handle,\n displayName: snapshot.get('displayName') as string | undefined,\n stripeAccountId: snapshot.get('stripeAccountId') as string | undefined,\n stripeChargesEnabled: snapshot.get('stripeChargesEnabled') === true,\n // NOT coerced with `=== true` like the flag above: `false` (a test-mode\n // account) and absent (never checked) are different answers, and\n // flattening them would hide which of the two a publisher is in.\n stripeAccountLivemode: snapshot.get('stripeAccountLivemode') as\n | boolean\n | undefined,\n agreement: snapshot.get('publisherAgreement') as\n | PublisherAgreementAcceptance\n | undefined,\n sellsAsPlatform: await publisherSellsAsPlatform(firestore, orgId),\n }\n}\n\n/**\n * Whether a user may act for a publishing org (AGL-652).\n *\n * Ownership checks used to read `listing.profileId === decoded.uid`. With\n * org-owned listings that comparison can NEVER be true — a uid is not an org\n * id — so leaving them would lock every publisher out of their own listing\n * and make every paid listing chargeable to its own publisher. Managers\n * (owner/admin) act for the org, matching the gate the profile rules apply.\n */\nexport async function canActAsPublisher(\n firestore: FirebaseFirestore.Firestore,\n uid: string,\n publisherOrgId: string | undefined | null,\n): Promise<boolean> {\n if (!uid || !publisherOrgId) return false\n const member = await firestore\n .collection('orgs')\n .doc(publisherOrgId)\n .collection('members')\n .doc(uid)\n .get()\n const role = member.get('role')\n return role === 'owner' || role === 'admin'\n}\n\nexport class PublisherHandleTakenError extends Error {\n constructor(public readonly handle: string) {\n super(`Handle \"${handle}\" is already taken`)\n this.name = 'PublisherHandleTakenError'\n }\n}\n\n/**\n * Claim a marketplace handle for an org, transactionally (AGL-652).\n *\n * Mirrors the `orgSlugs` reservation: the read and the write share one\n * transaction, so two orgs racing for the same handle can't both win — which\n * a read-then-write check permits, and which the marketplace URL space cannot\n * represent. A handle the org already holds is a no-op, so this is safe to\n * call on every profile save.\n *\n * A renamed handle leaves a `movedTo` tombstone behind so old marketplace\n * links can still resolve, and re-claiming a tombstone you own is allowed.\n *\n * @throws PublisherHandleTakenError when another org holds it.\n */\nexport async function claimPublisherHandle(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n handle: string,\n previousHandle?: string,\n): Promise<void> {\n if (!isValidPublisherHandle(handle)) {\n throw new Error(`Invalid publisher handle: ${handle}`)\n }\n await firestore.runTransaction(async (tx) => {\n const ref = firestore.collection(PUBLISHER_HANDLES).doc(handle)\n const existing = await tx.get(ref)\n if (existing.exists && existing.get('orgId') !== orgId) {\n throw new PublisherHandleTakenError(handle)\n }\n // A full set (not merge) clears any `movedTo` tombstone left by an\n // earlier rename — the same reason the org-slug claim replaces wholesale.\n //\n // \"A PREVIOUS OWNER'S rename\" is what this comment used to say, and it\n // cannot happen: a tombstone carries `orgId`, so the already-taken guard\n // above refuses every org but the one that renamed away. A renamed handle\n // is PARKED, not released, and only its original org can take it back.\n //\n // That was incidental until AGL-2312 and is load-bearing now: `movedTo`\n // is a live redirect target on the publisher page, so if a stranger could\n // claim the old handle, every link ever published to the original\n // publisher would resolve to whoever grabbed it. Pinned in\n // `publisher-handle-rename-tombstone.spec.ts`.\n tx.set(ref, { orgId })\n if (previousHandle && previousHandle !== handle) {\n tx.set(firestore.collection(PUBLISHER_HANDLES).doc(previousHandle), {\n orgId,\n movedTo: handle,\n renamedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n }\n })\n}\n"],"names":["firebaseAdmin","resolveMarketplaceFeePct","isValidPublisherHandle","sellsAsPlatform","readPlatformOrgId","PUBLISHER_PROFILES","PUBLISHER_HANDLES","publisherSellsAsPlatform","firestore","orgId","org","platformOrgId","collection","doc","get","sellerOrgId","feePercent","data","resolvePublisherProfile","snapshot","handle","exists","displayName","stripeAccountId","stripeChargesEnabled","stripeAccountLivemode","agreement","canActAsPublisher","uid","publisherOrgId","member","role","PublisherHandleTakenError","Error","name","claimPublisherHandle","previousHandle","runTransaction","tx","ref","existing","set","movedTo","renamedAt","FieldValue","serverTimestamp"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publisher-profile.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { resolveMarketplaceFeePct } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { type PublisherAgreementAcceptance } from '../model/publisher-agreement'\nimport { isValidPublisherHandle } from '../model/marketplace'\nimport { sellsAsPlatform } from '../model/platform-seller'\nimport { readPlatformOrgId } from './platform-org'\n\n/** `publisherProfiles/{orgId}` — the org's marketplace identity (AGL-652). */\nexport const PUBLISHER_PROFILES = 'publisherProfiles'\n/** `publisherHandles/{handle}` — uniqueness reservations. */\nexport const PUBLISHER_HANDLES = 'publisherHandles'\n\nexport interface ResolvedPublisher {\n orgId: string\n handle: string\n displayName?: string\n stripeAccountId?: string\n stripeChargesEnabled?: boolean\n /**\n * Which Stripe world the payout account belongs to (AGL-2471).\n *\n * PROJECTED, and that is the load-bearing part. The sale gate in\n * `checkout.ts` reads this shape, not the raw document — an API projection\n * that drops a field its predicate needs turns the predicate into a\n * constant, and here the constant would be \"refuse every marketplace sale\".\n * Left three-valued deliberately: `undefined` means never established and\n * must stay distinguishable from `false`.\n */\n stripeAccountLivemode?: boolean\n /**\n * Which marketplace publisher agreement this ORG has accepted (AGL-1077).\n * Absent on every profile created before the agreement existed, which is\n * why publishing refuses rather than assuming consent.\n */\n agreement?: PublisherAgreementAcceptance\n /**\n * This org is the platform itself at the staff fee waiver (AGL-4049), so a\n * priced listing needs no payout account: there is nobody to pay.\n */\n sellsAsPlatform?: boolean\n}\n\n/**\n * Whether this org is the platform at the waived fee. Costs one extra read,\n * for the platform's own org only: every other publisher answers `false`\n * without touching its org document.\n */\nasync function publisherSellsAsPlatform(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<boolean> {\n const platformOrgId = readPlatformOrgId()\n if (platformOrgId !== orgId) return false\n const org = await firestore.collection('orgs').doc(orgId).get()\n return sellsAsPlatform({\n sellerOrgId: orgId,\n feePercent: resolveMarketplaceFeePct((org.data() ?? {}) as any),\n platformOrgId,\n })\n}\n\n/**\n * Load the publishing org's marketplace profile (AGL-652).\n *\n * Publishing is org-only: the publisher is the ORG, not the person who\n * happened to click publish. Every publish route resolves through here so\n * they can't drift on which identity a listing is attributed to.\n *\n * Returns `null` when the org has no profile yet — callers turn that into a\n * 412 telling the user to set one up, matching the previous behaviour when a\n * personal profile was missing.\n */\nexport async function resolvePublisherProfile(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<ResolvedPublisher | null> {\n const snapshot = await firestore.collection(PUBLISHER_PROFILES).doc(orgId).get()\n const handle = snapshot.get('handle') as string | undefined\n if (!snapshot.exists || !handle) return null\n return {\n orgId,\n handle,\n displayName: snapshot.get('displayName') as string | undefined,\n stripeAccountId: snapshot.get('stripeAccountId') as string | undefined,\n stripeChargesEnabled: snapshot.get('stripeChargesEnabled') === true,\n // NOT coerced with `=== true` like the flag above: `false` (a test-mode\n // account) and absent (never checked) are different answers, and\n // flattening them would hide which of the two a publisher is in.\n stripeAccountLivemode: snapshot.get('stripeAccountLivemode') as\n | boolean\n | undefined,\n agreement: snapshot.get('publisherAgreement') as\n | PublisherAgreementAcceptance\n | undefined,\n sellsAsPlatform: await publisherSellsAsPlatform(firestore, orgId),\n }\n}\n\n/**\n * Whether a user belongs to the org that publishes a listing, in any role\n * (AGL-652, AGL-4047).\n *\n * A FACT ABOUT OWNERSHIP, NOT A PERMISSION. It answers the questions that\n * turn on who published the listing: nobody in that workspace may buy it or\n * review it, and, on the workspace's OWN sites only, its members install it\n * free (and may try a private or unreviewed one). The install doors ask that\n * last one through `ownsListingHere` (`own-listing.ts`), which adds the\n * \"lands in the publishing workspace\" half; off those sites they are buyers.\n *\n * A key could be refused to an admin, and the admin would still be part of\n * the workspace that published it, so a permission here would let them pay\n * themselves or rate their own work. What a member may DO\n * for the publisher (its profile, its listings, its payouts, the Verified\n * badge) asks a key instead: `publisherPermissionVerdict`.\n *\n * Ownership checks used to read `listing.profileId === decoded.uid`. With\n * org-owned listings that comparison can NEVER be true (a uid is not an org\n * id), which is why this asks the org's members.\n */\nexport async function isPublisherMember(\n firestore: FirebaseFirestore.Firestore,\n uid: string,\n publisherOrgId: string | undefined | null,\n): Promise<boolean> {\n if (!uid || !publisherOrgId) return false\n const member = await firestore\n .collection('orgs')\n .doc(publisherOrgId)\n .collection('members')\n .doc(uid)\n .get()\n return member.exists\n}\n\nexport class PublisherHandleTakenError extends Error {\n constructor(public readonly handle: string) {\n super(`Handle \"${handle}\" is already taken`)\n this.name = 'PublisherHandleTakenError'\n }\n}\n\n/**\n * Claim a marketplace handle for an org, transactionally (AGL-652).\n *\n * Mirrors the `orgSlugs` reservation: the read and the write share one\n * transaction, so two orgs racing for the same handle can't both win — which\n * a read-then-write check permits, and which the marketplace URL space cannot\n * represent. A handle the org already holds is a no-op, so this is safe to\n * call on every profile save.\n *\n * A renamed handle leaves a `movedTo` tombstone behind so old marketplace\n * links can still resolve, and re-claiming a tombstone you own is allowed.\n *\n * @throws PublisherHandleTakenError when another org holds it.\n */\nexport async function claimPublisherHandle(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n handle: string,\n previousHandle?: string,\n): Promise<void> {\n if (!isValidPublisherHandle(handle)) {\n throw new Error(`Invalid publisher handle: ${handle}`)\n }\n await firestore.runTransaction(async (tx) => {\n const ref = firestore.collection(PUBLISHER_HANDLES).doc(handle)\n const existing = await tx.get(ref)\n if (existing.exists && existing.get('orgId') !== orgId) {\n throw new PublisherHandleTakenError(handle)\n }\n // A full set (not merge) clears any `movedTo` tombstone left by an\n // earlier rename — the same reason the org-slug claim replaces wholesale.\n //\n // \"A PREVIOUS OWNER'S rename\" is what this comment used to say, and it\n // cannot happen: a tombstone carries `orgId`, so the already-taken guard\n // above refuses every org but the one that renamed away. A renamed handle\n // is PARKED, not released, and only its original org can take it back.\n //\n // That was incidental until AGL-2312 and is load-bearing now: `movedTo`\n // is a live redirect target on the publisher page, so if a stranger could\n // claim the old handle, every link ever published to the original\n // publisher would resolve to whoever grabbed it. Pinned in\n // `publisher-handle-rename-tombstone.spec.ts`.\n tx.set(ref, { orgId })\n if (previousHandle && previousHandle !== handle) {\n tx.set(firestore.collection(PUBLISHER_HANDLES).doc(previousHandle), {\n orgId,\n movedTo: handle,\n renamedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n }\n })\n}\n"],"names":["firebaseAdmin","resolveMarketplaceFeePct","isValidPublisherHandle","sellsAsPlatform","readPlatformOrgId","PUBLISHER_PROFILES","PUBLISHER_HANDLES","publisherSellsAsPlatform","firestore","orgId","org","platformOrgId","collection","doc","get","sellerOrgId","feePercent","data","resolvePublisherProfile","snapshot","handle","exists","displayName","stripeAccountId","stripeChargesEnabled","stripeAccountLivemode","agreement","isPublisherMember","uid","publisherOrgId","member","PublisherHandleTakenError","Error","name","claimPublisherHandle","previousHandle","runTransaction","tx","ref","existing","set","movedTo","renamedAt","FieldValue","serverTimestamp"],"mappings":"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"}
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
17
|
import { firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
|
|
18
18
|
import { findUserByUidAcrossPools } from "@aglyn/tenant-data-admin/server/auth-pools";
|
|
19
|
-
import {
|
|
20
|
-
import { isOrgMember } from "./sale-risk.js";
|
|
19
|
+
import { isPublisherMember } from "./publisher-profile.js";
|
|
21
20
|
const MAX_COMMENT_LENGTH = 2000;
|
|
22
21
|
/** Orgs scanned when looking for an install pin. */ const MAX_ORGS_CHECKED = 25;
|
|
23
22
|
/**
|
|
@@ -154,9 +153,11 @@ export async function isVerifiedInstaller(firestore, uid, listingId) {
|
|
|
154
153
|
});
|
|
155
154
|
}
|
|
156
155
|
// Publishing org cannot review itself, however many accounts it has —
|
|
157
|
-
// ANY member of it (AGL-3365)
|
|
158
|
-
//
|
|
159
|
-
|
|
156
|
+
// ANY member of it (AGL-3365): asking only for an owner or admin let an
|
|
157
|
+
// editor of the publisher rate its listing. Membership, never a
|
|
158
|
+
// permission (AGL-4047): an admin whose publisher keys are refused still
|
|
159
|
+
// published it.
|
|
160
|
+
if (await isPublisherMember(firestore, decoded.uid, String((_listing_profileId = listing.profileId) != null ? _listing_profileId : ''))) {
|
|
160
161
|
return res.status(403).json({
|
|
161
162
|
error: 'You cannot review your organization’s own listing'
|
|
162
163
|
});
|
|
@@ -1 +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 { findUserByUidAcrossPools } from '@aglyn/tenant-data-admin/server/auth-pools'\nimport { canActAsPublisher } from './publisher-profile'\nimport { isOrgMember } from './sale-risk'\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/** An account younger than this may comment but not rate (AGL-3365). */\nexport const REVIEW_MIN_ACCOUNT_AGE_DAYS = 7\n\n/**\n * Whether the Firebase account is younger than\n * {@link REVIEW_MIN_ACCOUNT_AGE_DAYS}. An account whose creation time cannot\n * be read is not new — the answer that never locks an existing customer out.\n */\nexport async function isNewAccount(uid: string, nowMs = Date.now()): Promise<boolean> {\n try {\n // Across pools (AGL-1122): an SSO reviewer is not in the project pool.\n const user = (await findUserByUidAcrossPools(uid))?.record\n const createdMs = Date.parse(String(user?.metadata?.creationTime ?? ''))\n if (!Number.isFinite(createdMs)) return false\n return nowMs - createdMs < REVIEW_MIN_ACCOUNT_AGE_DAYS * 86_400_000\n } catch {\n return false\n }\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 // ANY member of it (AGL-3365). `canActAsPublisher` answers only for an\n // owner or admin, so an editor of the publisher could rate its listing.\n if (\n (await canActAsPublisher(firestore, decoded.uid, listing.profileId)) ||\n (await isOrgMember(firestore, String(listing.profileId ?? ''), decoded.uid))\n ) {\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 // A rating from a brand-new account is refused (AGL-3365): a burst of\n // fresh accounts installing a free listing and rating it five stars is\n // the cheapest way to buy ranking. The comment stays open, and the words\n // say what to do, never where the line is.\n if (rating && (await isNewAccount(decoded.uid))) {\n return res.status(403).json({\n error:\n 'Ratings open once your account has been active for a little while — ' +\n 'you can still leave a comment',\n reason: 'account-too-new',\n })\n }\n const installer = await isVerifiedInstaller(firestore, decoded.uid, listingId)\n // The workspace that installed it must not be the publisher's own\n // (AGL-3365) — the member check above covers the reviewer, this the org\n // whose install vouches for the rating.\n if (rating && installer.orgId && installer.orgId === listing.profileId) {\n return res.status(403).json({\n error: 'You cannot review your organization’s own listing',\n })\n }\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","findUserByUidAcrossPools","canActAsPublisher","isOrgMember","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","REVIEW_MIN_ACCOUNT_AGE_DAYS","isNewAccount","nowMs","Date","now","user","record","createdMs","parse","String","metadata","creationTime","Number","isFinite","reviewsHandler","req","res","method","status","json","error","body","authorization","headers","idToken","startsWith","length","undefined","listing","decoded","app","auth","verifyIdToken","listingRef","listingSnapshot","data","deletedAt","reviewRef","runTransaction","tx","existing","listingNow","Promise","all","previousRating","hadComment","delete","update","nextAggregates","ok","removed","profileId","rawRating","rating","Math","min","max","round","comment","trim","email_verified","reason","installer","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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"}
|
|
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 { findUserByUidAcrossPools } from '@aglyn/tenant-data-admin/server/auth-pools'\nimport { isPublisherMember } 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/** An account younger than this may comment but not rate (AGL-3365). */\nexport const REVIEW_MIN_ACCOUNT_AGE_DAYS = 7\n\n/**\n * Whether the Firebase account is younger than\n * {@link REVIEW_MIN_ACCOUNT_AGE_DAYS}. An account whose creation time cannot\n * be read is not new — the answer that never locks an existing customer out.\n */\nexport async function isNewAccount(uid: string, nowMs = Date.now()): Promise<boolean> {\n try {\n // Across pools (AGL-1122): an SSO reviewer is not in the project pool.\n const user = (await findUserByUidAcrossPools(uid))?.record\n const createdMs = Date.parse(String(user?.metadata?.creationTime ?? ''))\n if (!Number.isFinite(createdMs)) return false\n return nowMs - createdMs < REVIEW_MIN_ACCOUNT_AGE_DAYS * 86_400_000\n } catch {\n return false\n }\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 // ANY member of it (AGL-3365): asking only for an owner or admin let an\n // editor of the publisher rate its listing. Membership, never a\n // permission (AGL-4047): an admin whose publisher keys are refused still\n // published it.\n if (await isPublisherMember(firestore, decoded.uid, String(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 // A rating from a brand-new account is refused (AGL-3365): a burst of\n // fresh accounts installing a free listing and rating it five stars is\n // the cheapest way to buy ranking. The comment stays open, and the words\n // say what to do, never where the line is.\n if (rating && (await isNewAccount(decoded.uid))) {\n return res.status(403).json({\n error:\n 'Ratings open once your account has been active for a little while — ' +\n 'you can still leave a comment',\n reason: 'account-too-new',\n })\n }\n const installer = await isVerifiedInstaller(firestore, decoded.uid, listingId)\n // The workspace that installed it must not be the publisher's own\n // (AGL-3365) — the member check above covers the reviewer, this the org\n // whose install vouches for the rating.\n if (rating && installer.orgId && installer.orgId === listing.profileId) {\n return res.status(403).json({\n error: 'You cannot review your organization’s own listing',\n })\n }\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","findUserByUidAcrossPools","isPublisherMember","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","REVIEW_MIN_ACCOUNT_AGE_DAYS","isNewAccount","nowMs","Date","now","user","record","createdMs","parse","String","metadata","creationTime","Number","isFinite","reviewsHandler","req","res","method","status","json","error","body","authorization","headers","idToken","startsWith","length","undefined","listing","decoded","app","auth","verifyIdToken","listingRef","listingSnapshot","data","deletedAt","reviewRef","runTransaction","tx","existing","listingNow","Promise","all","previousRating","hadComment","delete","update","nextAggregates","ok","removed","profileId","rawRating","rating","Math","min","max","round","comment","trim","email_verified","reason","installer","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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"}
|
|
@@ -16,12 +16,6 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { type RiskEventInput } from '@aglyn/tenant-data-admin';
|
|
18
18
|
import { type SaleRiskSignal } from '../model/publisher-risk';
|
|
19
|
-
/**
|
|
20
|
-
* Whether `uid` is ANY member of `orgId` — not only a manager, which is all
|
|
21
|
-
* `canActAsPublisher` asks. A publishing workspace's editor buying its
|
|
22
|
-
* listing is the publisher buying it.
|
|
23
|
-
*/
|
|
24
|
-
export declare function isOrgMember(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string): Promise<boolean>;
|
|
25
19
|
/**
|
|
26
20
|
* Whether the publishing workspace is under a SECURITY lock (AGL-3365) — the
|
|
27
21
|
* install and update doors hand nothing from it over. Security only: a
|
|
@@ -44,15 +44,6 @@ import { orgAgeDays } from "@aglyn/tenant-data-admin/server/org-age";
|
|
|
44
44
|
import { describeSaleRiskSignals, isYoungPublisher, publisherPayoutDelayDays, YOUNG_PUBLISHER_DAYS, YOUNG_PUBLISHER_PAYOUT_DELAY_DAYS, YOUNG_PUBLISHER_REVIEW_SALE_CENTS } from "../model/publisher-risk.js";
|
|
45
45
|
/** Members read per workspace when looking for a shared one. */ const MAX_MEMBERS_COMPARED = 200;
|
|
46
46
|
/** Earlier purchases read when looking for the same card. */ const MAX_CARD_MATCHES = 20;
|
|
47
|
-
/**
|
|
48
|
-
* Whether `uid` is ANY member of `orgId` — not only a manager, which is all
|
|
49
|
-
* `canActAsPublisher` asks. A publishing workspace's editor buying its
|
|
50
|
-
* listing is the publisher buying it.
|
|
51
|
-
*/ export async function isOrgMember(firestore, orgId, uid) {
|
|
52
|
-
if (!orgId || !uid) return false;
|
|
53
|
-
const member = await firestore.collection('orgs').doc(orgId).collection('members').doc(uid).get();
|
|
54
|
-
return member.exists;
|
|
55
|
-
}
|
|
56
47
|
/**
|
|
57
48
|
* Whether the publishing workspace is under a SECURITY lock (AGL-3365) — the
|
|
58
49
|
* install and update doors hand nothing from it over. Security only: a
|