@aglyn/plugins-marketplace 1.0.0-beta.201 → 1.0.0-beta.202
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/model/publisher-risk.d.ts +52 -0
- package/src/lib/model/publisher-risk.js +74 -0
- package/src/lib/model/publisher-risk.js.map +1 -0
- package/src/lib/server/billing-webhook.js +72 -4
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/checkout.js +18 -1
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/connect.js +15 -1
- package/src/lib/server/connect.js.map +1 -1
- package/src/lib/server/install-dataset-schema.js +3 -1
- package/src/lib/server/install-dataset-schema.js.map +1 -1
- package/src/lib/server/install-email-starter.js +3 -1
- package/src/lib/server/install-email-starter.js.map +1 -1
- package/src/lib/server/install-email-template.js +3 -1
- package/src/lib/server/install-email-template.js.map +1 -1
- package/src/lib/server/install-layout.js +3 -1
- package/src/lib/server/install-layout.js.map +1 -1
- package/src/lib/server/install-plugin.js +3 -1
- package/src/lib/server/install-plugin.js.map +1 -1
- package/src/lib/server/install-template.js +3 -1
- package/src/lib/server/install-template.js.map +1 -1
- package/src/lib/server/install-theme.js +3 -1
- package/src/lib/server/install-theme.js.map +1 -1
- package/src/lib/server/install.js +3 -1
- package/src/lib/server/install.js.map +1 -1
- package/src/lib/server/listing-screen.d.ts +83 -0
- package/src/lib/server/listing-screen.js +244 -0
- package/src/lib/server/listing-screen.js.map +1 -0
- package/src/lib/server/publish-dataset-schema.js +12 -0
- package/src/lib/server/publish-dataset-schema.js.map +1 -1
- package/src/lib/server/publish-email-starter.js +15 -1
- package/src/lib/server/publish-email-starter.js.map +1 -1
- package/src/lib/server/publish-email-template.js +15 -1
- package/src/lib/server/publish-email-template.js.map +1 -1
- package/src/lib/server/publish-layout.js +15 -1
- package/src/lib/server/publish-layout.js.map +1 -1
- package/src/lib/server/publish-plugin.js +41 -3
- package/src/lib/server/publish-plugin.js.map +1 -1
- package/src/lib/server/publish-template.js +15 -1
- package/src/lib/server/publish-template.js.map +1 -1
- package/src/lib/server/publish-theme.js +14 -1
- package/src/lib/server/publish-theme.js.map +1 -1
- package/src/lib/server/publish.js +15 -1
- package/src/lib/server/publish.js.map +1 -1
- package/src/lib/server/publisher-profile-save.js +36 -0
- package/src/lib/server/publisher-profile-save.js.map +1 -1
- package/src/lib/server/publisher-review-email.js +15 -5
- package/src/lib/server/publisher-review-email.js.map +1 -1
- package/src/lib/server/reviews.d.ts +8 -0
- package/src/lib/server/reviews.js +43 -3
- package/src/lib/server/reviews.js.map +1 -1
- package/src/lib/server/sale-risk.d.ts +105 -0
- package/src/lib/server/sale-risk.js +315 -0
- package/src/lib/server/sale-risk.js.map +1 -0
- package/src/lib/server/update-artifact.js +3 -1
- package/src/lib/server/update-artifact.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publish.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 { displayNameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n checkEntitlement,\n createResourceUid,\n decodeStoredNodes,\n} from '@aglyn/aglyn/server'\nimport {\n marketplacePriceRefusal,\n sanitizeMarketplaceDefinition,\n} from '../model'\nimport { sanitizeMarketplaceProps } from '../model/marketplace-props'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { resolvePublisherProfile } from './publisher-profile'\nimport { publishPreconditionRefusal } from './publish-preconditions'\nimport { refreshListingQueryFields } from './listing-query-fields'\n\n/**\n * Publishes a host reusable component to the marketplace (AGL-44). Runs\n * server-side so the sanitization pass (component-id allowlist, key strip,\n * size cap — see `sanitizeMarketplaceDefinition`) cannot be bypassed: clients\n * have no write access to `marketplaceListings` creates. Requirements: host\n * admin, a marketplace profile, and the `marketplaceSelling` plan flag.\n * Re-publishing the same component bumps the listing's version.\n */\nexport const publishHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const componentId = String(req.body?.componentId ?? '')\n const displayName = String(req.body?.displayName ?? '').slice(0, 80)\n const description = String(req.body?.description ?? '').slice(0, 500)\n const category = String(req.body?.category ?? '').slice(0, 40)\n const priceUsd = Math.round(Number(req.body?.priceUsd ?? 0)) || 0\n // The price floor and ceiling, from ONE validator (AGL-2343): a paid listing\n // under `MARKETPLACE_MIN_PRICE_USD` loses money on every sale, because\n // marketplace checkout is a destination charge whose Stripe fee is debited\n // from the platform's balance. Also enforced in `publishPreconditionRefusal`\n // below, so a door that drops this line is still covered.\n const priceRefusal = marketplacePriceRefusal(priceUsd)\n if (priceRefusal) {\n return res.status(400).json({ error: priceRefusal })\n }\n if (!hostId || !componentId || !displayName.trim()) {\n return res\n .status(400)\n .json({ error: 'Missing hostId, componentId, or displayName' })\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 try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n // Org-role permission gate (AGL-238).\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!membership.permissions.publishToMarketplace) {\n return res.status(403).json({\n error: 'Your organization role does not allow publishing to the marketplace',\n })\n }\n const firestore = firebaseAdmin.app().firestore()\n\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n // Plan gate rides the owning org's doc (AGL-238).\n const orgForHost = await getOrgForHost(hostId)\n const org = orgForHost?.org ?? {}\n if (!checkEntitlement(org, 'marketplaceSelling')) {\n return res.status(403).json({\n error: 'Publishing to the marketplace requires a Pro plan',\n })\n }\n if (!orgForHost?.orgId) {\n return res.status(409).json({ error: 'Site has no owning organization' })\n }\n\n // Publishing is ORG-ONLY (AGL-652): the publisher is the org, not\n // whoever clicked publish. `profileId` keeps its name — it is still the\n // publisher profile's doc id, which is now the org id.\n const publisher = await resolvePublisherProfile(firestore, orgForHost.orgId)\n // Profile, payouts AND the publisher agreement, in one gate\n // (AGL-2282) — see `publishPreconditionRefusal`.\n const refusal = publishPreconditionRefusal(publisher, {\n priceUsd,\n sells: 'components',\n })\n if (refusal) return res.status(refusal.status).json(refusal.body)\n\n const definitionSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('components')\n .doc(componentId)\n .get()\n // Decoded, like the screen and layout publish paths (AGL-1395). This is\n // the component DOCUMENT rather than its versions subcollection, and it\n // is now compressed like everything else (AGL-1151) — a plain map from\n // before that comes back unchanged.\n //\n // `sanitizeMarketplaceDefinition` refuses an undecoded tree by name, so\n // reading raw here is the difference between publishing a component and\n // being told the design is stored compressed on every attempt.\n const definition = definitionSnapshot.data() as any\n if (!definition || definition.deletedAt) {\n return res.status(404).json({ error: 'Unknown component' })\n }\n // The properties the tree binds to (AGL-2933), read off the same document\n // the tree is: a publish copies a version's props onto it with its nodes.\n // A default the sanitizer refuses is cleared and its property kept — see\n // `sanitizeMarketplaceProps`.\n const props = sanitizeMarketplaceProps(definition.props)\n if (props.ok === false) {\n return res.status(422).json({ error: props.error })\n }\n // Held to the rule with those properties beside it, so a link or image\n // bound whole to a Link or Image property travels bound.\n const sanitized = sanitizeMarketplaceDefinition(\n {\n rootId: definition.rootId,\n nodes: decodeStoredNodes<Record<string, any>>(definition.nodes) ?? {},\n },\n { declaredProps: props.props },\n )\n if (sanitized.ok === false) {\n return res.status(422).json({ error: sanitized.error })\n }\n\n // One listing per source component: re-publish bumps latestVersion.\n const existing = await firestore\n .collection('marketplaceListings')\n .where('profileId', '==', publisher.orgId)\n .where('sourceComponentId', '==', componentId)\n .limit(1)\n .get()\n const listingRef = existing.empty\n ? firestore.collection('marketplaceListings').doc(createResourceUid())\n : existing.docs[0].ref\n const version = existing.empty\n ? 1\n : Number(existing.docs[0].get('latestVersion') ?? 0) + 1\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n\n await listingRef.set(\n {\n profileId: publisher.orgId,\n artifactType: 'component',\n sourceComponentId: componentId,\n displayName: displayName.trim(),\n // The keys a listing is searched by (AGL-3321): the template gallery's\n // marketplace shelf asks `nameTokens` on its query.\n ...displayNameSearchFields(displayName.trim()),\n ...(description.trim() && { description: description.trim() }),\n ...(category.trim() && { category: category.trim() }),\n priceUsd,\n latestVersion: version,\n deletedAt: null,\n ...(existing.empty && { createdAt: now }),\n updatedAt: now,\n // Version metadata for the detail page (AGL-95) — snapshots\n // themselves stay server-only, so history rides the listing doc.\n versionHistory: firebaseAdmin.firestore.FieldValue.arrayUnion({\n version,\n publishedAt: firebaseAdmin.firestore.Timestamp.now(),\n }),\n },\n { merge: true },\n )\n // The lists this listing appears in query by fields derived from it (AGL-3321).\n await refreshListingQueryFields(listingRef)\n await listingRef.collection('versions').doc(String(version)).set({\n rootId: sanitized.rootId,\n nodes: sanitized.nodes,\n // Always a list, even an empty one: install and update read a version\n // with none as one published before properties were carried.\n props: props.props,\n publishedAt: now,\n })\n\n return res\n .status(200)\n .json({ listingId: listingRef.id, version })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["displayNameSearchFields","checkEntitlement","createResourceUid","decodeStoredNodes","marketplacePriceRefusal","sanitizeMarketplaceDefinition","sanitizeMarketplaceProps","firebaseAdmin","getOrgForHost","resolveOrgPermissions","resolvePublisherProfile","publishPreconditionRefusal","refreshListingQueryFields","publishHandler","req","res","method","status","json","error","hostId","String","body","componentId","displayName","slice","description","category","priceUsd","Math","round","Number","priceRefusal","trim","authorization","headers","idToken","startsWith","length","undefined","hostSnapshot","existing","decoded","app","auth","verifyIdToken","membership","uid","permissions","publishToMarketplace","firestore","collection","doc","get","exists","memberRole","orgForHost","org","orgId","publisher","refusal","sells","definitionSnapshot","definition","data","deletedAt","props","ok","sanitized","rootId","nodes","declaredProps","where","limit","listingRef","empty","docs","ref","version","now","FieldValue","serverTimestamp","set","profileId","artifactType","sourceComponentId","latestVersion","createdAt","updatedAt","versionHistory","arrayUnion","publishedAt","Timestamp","merge","listingId","id","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publish.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 { displayNameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n checkEntitlement,\n createResourceUid,\n decodeStoredNodes,\n} from '@aglyn/aglyn/server'\nimport {\n marketplacePriceRefusal,\n sanitizeMarketplaceDefinition,\n} from '../model'\nimport { sanitizeMarketplaceProps } from '../model/marketplace-props'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { resolvePublisherProfile } from './publisher-profile'\nimport { publishPreconditionRefusal } from './publish-preconditions'\nimport { listingSubmissionRefusal } from './listing-screen'\nimport { refreshListingQueryFields } from './listing-query-fields'\n\n/**\n * Publishes a host reusable component to the marketplace (AGL-44). Runs\n * server-side so the sanitization pass (component-id allowlist, key strip,\n * size cap — see `sanitizeMarketplaceDefinition`) cannot be bypassed: clients\n * have no write access to `marketplaceListings` creates. Requirements: host\n * admin, a marketplace profile, and the `marketplaceSelling` plan flag.\n * Re-publishing the same component bumps the listing's version.\n */\nexport const publishHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const componentId = String(req.body?.componentId ?? '')\n const displayName = String(req.body?.displayName ?? '').slice(0, 80)\n const description = String(req.body?.description ?? '').slice(0, 500)\n const category = String(req.body?.category ?? '').slice(0, 40)\n const priceUsd = Math.round(Number(req.body?.priceUsd ?? 0)) || 0\n // The price floor and ceiling, from ONE validator (AGL-2343): a paid listing\n // under `MARKETPLACE_MIN_PRICE_USD` loses money on every sale, because\n // marketplace checkout is a destination charge whose Stripe fee is debited\n // from the platform's balance. Also enforced in `publishPreconditionRefusal`\n // below, so a door that drops this line is still covered.\n const priceRefusal = marketplacePriceRefusal(priceUsd)\n if (priceRefusal) {\n return res.status(400).json({ error: priceRefusal })\n }\n if (!hostId || !componentId || !displayName.trim()) {\n return res\n .status(400)\n .json({ error: 'Missing hostId, componentId, or displayName' })\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 try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n // Org-role permission gate (AGL-238).\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!membership.permissions.publishToMarketplace) {\n return res.status(403).json({\n error: 'Your organization role does not allow publishing to the marketplace',\n })\n }\n const firestore = firebaseAdmin.app().firestore()\n\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not a site admin or editor' })\n }\n\n // Plan gate rides the owning org's doc (AGL-238).\n const orgForHost = await getOrgForHost(hostId)\n const org = orgForHost?.org ?? {}\n if (!checkEntitlement(org, 'marketplaceSelling')) {\n return res.status(403).json({\n error: 'Publishing to the marketplace requires a Pro plan',\n })\n }\n if (!orgForHost?.orgId) {\n return res.status(409).json({ error: 'Site has no owning organization' })\n }\n\n // Publishing is ORG-ONLY (AGL-652): the publisher is the org, not\n // whoever clicked publish. `profileId` keeps its name — it is still the\n // publisher profile's doc id, which is now the org id.\n const publisher = await resolvePublisherProfile(firestore, orgForHost.orgId)\n // Profile, payouts AND the publisher agreement, in one gate\n // (AGL-2282) — see `publishPreconditionRefusal`.\n const refusal = publishPreconditionRefusal(publisher, {\n priceUsd,\n sells: 'components',\n })\n if (refusal) return res.status(refusal.status).json(refusal.body)\n\n const definitionSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('components')\n .doc(componentId)\n .get()\n // Decoded, like the screen and layout publish paths (AGL-1395). This is\n // the component DOCUMENT rather than its versions subcollection, and it\n // is now compressed like everything else (AGL-1151) — a plain map from\n // before that comes back unchanged.\n //\n // `sanitizeMarketplaceDefinition` refuses an undecoded tree by name, so\n // reading raw here is the difference between publishing a component and\n // being told the design is stored compressed on every attempt.\n const definition = definitionSnapshot.data() as any\n if (!definition || definition.deletedAt) {\n return res.status(404).json({ error: 'Unknown component' })\n }\n // The properties the tree binds to (AGL-2933), read off the same document\n // the tree is: a publish copies a version's props onto it with its nodes.\n // A default the sanitizer refuses is cleared and its property kept — see\n // `sanitizeMarketplaceProps`.\n const props = sanitizeMarketplaceProps(definition.props)\n if (props.ok === false) {\n return res.status(422).json({ error: props.error })\n }\n // Held to the rule with those properties beside it, so a link or image\n // bound whole to a Link or Image property travels bound.\n const sanitized = sanitizeMarketplaceDefinition(\n {\n rootId: definition.rootId,\n nodes: decodeStoredNodes<Record<string, any>>(definition.nodes) ?? {},\n },\n { declaredProps: props.props },\n )\n if (sanitized.ok === false) {\n return res.status(422).json({ error: sanitized.error })\n }\n\n // One listing per source component: re-publish bumps latestVersion.\n const existing = await firestore\n .collection('marketplaceListings')\n .where('profileId', '==', publisher.orgId)\n .where('sourceComponentId', '==', componentId)\n .limit(1)\n .get()\n const listingRef = existing.empty\n ? firestore.collection('marketplaceListings').doc(createResourceUid())\n : existing.docs[0].ref\n const version = existing.empty\n ? 1\n : Number(existing.docs[0].get('latestVersion') ?? 0) + 1\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n\n // The phishing screen, before anything is listed (AGL-3365).\n const screened = await listingSubmissionRefusal({\n publisherOrgId: publisher.orgId,\n content: {\n displayName,\n publisherName: publisher.displayName,\n description,\n pageNodes: sanitized.nodes,\n },\n official: decoded['staff'] === true,\n org: orgForHost.org ?? null,\n })\n if (screened) return res.status(screened.status).json(screened.body)\n\n await listingRef.set(\n {\n profileId: publisher.orgId,\n artifactType: 'component',\n sourceComponentId: componentId,\n displayName: displayName.trim(),\n // The keys a listing is searched by (AGL-3321): the template gallery's\n // marketplace shelf asks `nameTokens` on its query.\n ...displayNameSearchFields(displayName.trim()),\n ...(description.trim() && { description: description.trim() }),\n ...(category.trim() && { category: category.trim() }),\n priceUsd,\n latestVersion: version,\n deletedAt: null,\n ...(existing.empty && { createdAt: now }),\n updatedAt: now,\n // Version metadata for the detail page (AGL-95) — snapshots\n // themselves stay server-only, so history rides the listing doc.\n versionHistory: firebaseAdmin.firestore.FieldValue.arrayUnion({\n version,\n publishedAt: firebaseAdmin.firestore.Timestamp.now(),\n }),\n },\n { merge: true },\n )\n // The lists this listing appears in query by fields derived from it (AGL-3321).\n await refreshListingQueryFields(listingRef)\n await listingRef.collection('versions').doc(String(version)).set({\n rootId: sanitized.rootId,\n nodes: sanitized.nodes,\n // Always a list, even an empty one: install and update read a version\n // with none as one published before properties were carried.\n props: props.props,\n publishedAt: now,\n })\n\n return res\n .status(200)\n .json({ listingId: listingRef.id, version })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["displayNameSearchFields","checkEntitlement","createResourceUid","decodeStoredNodes","marketplacePriceRefusal","sanitizeMarketplaceDefinition","sanitizeMarketplaceProps","firebaseAdmin","getOrgForHost","resolveOrgPermissions","resolvePublisherProfile","publishPreconditionRefusal","listingSubmissionRefusal","refreshListingQueryFields","publishHandler","req","res","method","status","json","error","hostId","String","body","componentId","displayName","slice","description","category","priceUsd","Math","round","Number","priceRefusal","trim","authorization","headers","idToken","startsWith","length","undefined","hostSnapshot","existing","orgForHost","decoded","app","auth","verifyIdToken","membership","uid","permissions","publishToMarketplace","firestore","collection","doc","get","exists","memberRole","org","orgId","publisher","refusal","sells","definitionSnapshot","definition","data","deletedAt","props","ok","sanitized","rootId","nodes","declaredProps","where","limit","listingRef","empty","docs","ref","version","now","FieldValue","serverTimestamp","screened","publisherOrgId","content","publisherName","pageNodes","official","set","profileId","artifactType","sourceComponentId","latestVersion","createdAt","updatedAt","versionHistory","arrayUnion","publishedAt","Timestamp","merge","listingId","id","console"],"mappings":"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"}
|
|
@@ -18,6 +18,7 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
|
18
18
|
import { PUBLISHER_AGREEMENT_BYTES, PUBLISHER_AGREEMENT_SHA256, PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE, PUBLISHER_AGREEMENT_VERSION, publisherAgreementIsPublished } from "@aglyn/aglyn/app-utils/publisher-agreement";
|
|
19
19
|
import { isValidPublisherHandle, validatePublisherProfileContent } from "../model/marketplace.js";
|
|
20
20
|
import { canActAsPublisher, claimPublisherHandle, PublisherHandleTakenError, PUBLISHER_PROFILES } from "./publisher-profile.js";
|
|
21
|
+
import { listingSubmissionRefusal, publisherIdentityImpersonation } from "./listing-screen.js";
|
|
21
22
|
/**
|
|
22
23
|
* Record an org's acceptance of the marketplace publisher agreement
|
|
23
24
|
* (AGL-1077).
|
|
@@ -159,6 +160,41 @@ import { canActAsPublisher, claimPublisherHandle, PublisherHandleTakenError, PUB
|
|
|
159
160
|
error: 'Only an organization owner or admin can edit the publisher profile'
|
|
160
161
|
});
|
|
161
162
|
}
|
|
163
|
+
// The publisher's public identity, screened before the handle is claimed
|
|
164
|
+
// (AGL-3365): a name or handle that wears the platform's name or speaks
|
|
165
|
+
// for a brand is refused, and the name, bio and links are read by the
|
|
166
|
+
// same phishing screen as a listing — the profile is what every one of
|
|
167
|
+
// its listings says it is from.
|
|
168
|
+
const official = decoded['staff'] === true;
|
|
169
|
+
const impersonation = publisherIdentityImpersonation({
|
|
170
|
+
displayName,
|
|
171
|
+
handle,
|
|
172
|
+
official
|
|
173
|
+
});
|
|
174
|
+
if (impersonation) {
|
|
175
|
+
return res.status(422).json({
|
|
176
|
+
error: impersonation,
|
|
177
|
+
code: 'impersonation'
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const text = (key)=>{
|
|
181
|
+
var _req_body;
|
|
182
|
+
return typeof ((_req_body = req.body) == null ? void 0 : _req_body[key]) === 'string' ? req.body[key] : null;
|
|
183
|
+
};
|
|
184
|
+
const screened = await listingSubmissionRefusal({
|
|
185
|
+
publisherOrgId: orgId,
|
|
186
|
+
content: {
|
|
187
|
+
publisherName: displayName,
|
|
188
|
+
description: text('bio'),
|
|
189
|
+
urls: [
|
|
190
|
+
text('website'),
|
|
191
|
+
text('supportUrl'),
|
|
192
|
+
text('supportEmail')
|
|
193
|
+
]
|
|
194
|
+
},
|
|
195
|
+
official
|
|
196
|
+
});
|
|
197
|
+
if (screened) return res.status(screened.status).json(screened.body);
|
|
162
198
|
const ref = firestore.collection(PUBLISHER_PROFILES).doc(orgId);
|
|
163
199
|
const previousHandle = (await ref.get()).get('handle');
|
|
164
200
|
// Claim first: if the handle is taken there must be no profile write, or
|
|
@@ -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_BYTES,\n PUBLISHER_AGREEMENT_SHA256,\n PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE,\n PUBLISHER_AGREEMENT_VERSION,\n publisherAgreementIsPublished,\n} from '@aglyn/aglyn/app-utils/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'\n\n/**\n * Record an org's acceptance of the marketplace publisher agreement\n * (AGL-1077).\n *\n * Server-owned, like the handle and the payout keys: an acceptance a client\n * could write is not evidence of anything. The rules block the field, and\n * this is the only path that sets it.\n *\n * The submitted version must be the one currently in force. A client that\n * echoes back some other string is refused rather than recorded — otherwise\n * \"accepted v1 forever\" becomes a way to never see a changed agreement, and\n * the whole point of versioning it is that a change re-asks.\n *\n * And the document must exist (AGL-1660). The console hides the control while\n * it does not, but the console is UI: this route is the only writer, so this\n * is the only place the refusal is a fact. Recording an acceptance of text we\n * never served would put a signature in the record with nothing behind it,\n * which is strictly worse than having no record — it looks like evidence.\n */\nconst acceptPublisherAgreement: PluginApiHandler = async (req, res) => {\n const orgId = String(req.body?.orgId ?? '')\n const version = String(req.body?.version ?? '')\n if (!orgId) return res.status(400).json({ error: 'Missing orgId' })\n if (!publisherAgreementIsPublished()) {\n // 409, the same conflict-with-current-state answer a version mismatch\n // gets: the request is well-formed, the world is not ready for it.\n return res.status(409).json({\n error: PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE,\n unavailable: true,\n currentVersion: PUBLISHER_AGREEMENT_VERSION,\n })\n }\n if (version !== PUBLISHER_AGREEMENT_VERSION) {\n return res.status(409).json({\n error:\n 'The agreement changed while you were reading it. Reload and read ' +\n 'the current version before accepting.',\n currentVersion: PUBLISHER_AGREEMENT_VERSION,\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 try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n // The ORG is the party (AGL-652), and only someone who can bind it may\n // say so — the same gate that owns the profile's public identity.\n if (!(await canActAsPublisher(firestore, decoded.uid, orgId))) {\n return res.status(403).json({\n error:\n 'Only an organization owner or admin can accept the publisher ' +\n 'agreement on its behalf',\n })\n }\n await firestore\n .collection(PUBLISHER_PROFILES)\n .doc(orgId)\n .set(\n {\n publisherAgreement: {\n version: PUBLISHER_AGREEMENT_VERSION,\n // The snapshot pin (AGL-1678): the version is a label we control\n // and can reuse, so the record also carries the sha256 and byte\n // length of the archived snapshot of the text the publisher was\n // shown — self-contained evidence of content, same machinery as\n // the signup clickwrap (AGL-1497).\n sha256: PUBLISHER_AGREEMENT_SHA256,\n bytes: PUBLISHER_AGREEMENT_BYTES,\n // A uid, not the org: \"this organization agreed\" is not a fact\n // anybody can be held to. A named person on a date is.\n acceptedBy: decoded.uid,\n acceptedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n },\n { merge: true },\n )\n return res\n .status(200)\n .json({ ok: true, version: PUBLISHER_AGREEMENT_VERSION })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Could not record the acceptance' })\n }\n}\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 (AGL-1077) is not a profile edit — it writes a\n // field the profile save must never touch, so it takes its own branch\n // rather than riding along with a name change.\n if (req.body?.action === 'accept-agreement') {\n return acceptPublisherAgreement(req, res)\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\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 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 const ref = firestore.collection(PUBLISHER_PROFILES).doc(orgId)\n const previousHandle = (await ref.get()).get('handle') as string | undefined\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 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_BYTES","PUBLISHER_AGREEMENT_SHA256","PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE","PUBLISHER_AGREEMENT_VERSION","publisherAgreementIsPublished","isValidPublisherHandle","validatePublisherProfileContent","canActAsPublisher","claimPublisherHandle","PublisherHandleTakenError","PUBLISHER_PROFILES","acceptPublisherAgreement","req","res","orgId","String","body","version","status","json","error","unavailable","currentVersion","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","uid","collection","doc","set","publisherAgreement","sha256","bytes","acceptedBy","acceptedAt","FieldValue","serverTimestamp","merge","ok","console","publisherProfileSaveHandler","method","action","handle","trim","toLowerCase","displayName","extras","ref","previousHandle","get","bioProvided","bio","extraWrites","key","value","Object","entries","content","delete","updatedAt"],"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_BYTES,\n PUBLISHER_AGREEMENT_SHA256,\n PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE,\n PUBLISHER_AGREEMENT_VERSION,\n publisherAgreementIsPublished,\n} from '@aglyn/aglyn/app-utils/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'\n\n/**\n * Record an org's acceptance of the marketplace publisher agreement\n * (AGL-1077).\n *\n * Server-owned, like the handle and the payout keys: an acceptance a client\n * could write is not evidence of anything. The rules block the field, and\n * this is the only path that sets it.\n *\n * The submitted version must be the one currently in force. A client that\n * echoes back some other string is refused rather than recorded — otherwise\n * \"accepted v1 forever\" becomes a way to never see a changed agreement, and\n * the whole point of versioning it is that a change re-asks.\n *\n * And the document must exist (AGL-1660). The console hides the control while\n * it does not, but the console is UI: this route is the only writer, so this\n * is the only place the refusal is a fact. Recording an acceptance of text we\n * never served would put a signature in the record with nothing behind it,\n * which is strictly worse than having no record — it looks like evidence.\n */\nconst acceptPublisherAgreement: PluginApiHandler = async (req, res) => {\n const orgId = String(req.body?.orgId ?? '')\n const version = String(req.body?.version ?? '')\n if (!orgId) return res.status(400).json({ error: 'Missing orgId' })\n if (!publisherAgreementIsPublished()) {\n // 409, the same conflict-with-current-state answer a version mismatch\n // gets: the request is well-formed, the world is not ready for it.\n return res.status(409).json({\n error: PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE,\n unavailable: true,\n currentVersion: PUBLISHER_AGREEMENT_VERSION,\n })\n }\n if (version !== PUBLISHER_AGREEMENT_VERSION) {\n return res.status(409).json({\n error:\n 'The agreement changed while you were reading it. Reload and read ' +\n 'the current version before accepting.',\n currentVersion: PUBLISHER_AGREEMENT_VERSION,\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 try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n // The ORG is the party (AGL-652), and only someone who can bind it may\n // say so — the same gate that owns the profile's public identity.\n if (!(await canActAsPublisher(firestore, decoded.uid, orgId))) {\n return res.status(403).json({\n error:\n 'Only an organization owner or admin can accept the publisher ' +\n 'agreement on its behalf',\n })\n }\n await firestore\n .collection(PUBLISHER_PROFILES)\n .doc(orgId)\n .set(\n {\n publisherAgreement: {\n version: PUBLISHER_AGREEMENT_VERSION,\n // The snapshot pin (AGL-1678): the version is a label we control\n // and can reuse, so the record also carries the sha256 and byte\n // length of the archived snapshot of the text the publisher was\n // shown — self-contained evidence of content, same machinery as\n // the signup clickwrap (AGL-1497).\n sha256: PUBLISHER_AGREEMENT_SHA256,\n bytes: PUBLISHER_AGREEMENT_BYTES,\n // A uid, not the org: \"this organization agreed\" is not a fact\n // anybody can be held to. A named person on a date is.\n acceptedBy: decoded.uid,\n acceptedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n },\n },\n { merge: true },\n )\n return res\n .status(200)\n .json({ ok: true, version: PUBLISHER_AGREEMENT_VERSION })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Could not record the acceptance' })\n }\n}\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 (AGL-1077) is not a profile edit — it writes a\n // field the profile save must never touch, so it takes its own branch\n // rather than riding along with a name change.\n if (req.body?.action === 'accept-agreement') {\n return acceptPublisherAgreement(req, res)\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\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 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 // 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 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_BYTES","PUBLISHER_AGREEMENT_SHA256","PUBLISHER_AGREEMENT_UNAVAILABLE_NOTICE","PUBLISHER_AGREEMENT_VERSION","publisherAgreementIsPublished","isValidPublisherHandle","validatePublisherProfileContent","canActAsPublisher","claimPublisherHandle","PublisherHandleTakenError","PUBLISHER_PROFILES","listingSubmissionRefusal","publisherIdentityImpersonation","acceptPublisherAgreement","req","res","orgId","String","body","version","status","json","error","unavailable","currentVersion","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","uid","collection","doc","set","publisherAgreement","sha256","bytes","acceptedBy","acceptedAt","FieldValue","serverTimestamp","merge","ok","console","publisherProfileSaveHandler","method","action","handle","trim","toLowerCase","displayName","extras","official","impersonation","code","text","key","screened","publisherOrgId","content","publisherName","description","urls","ref","previousHandle","get","bioProvided","bio","extraWrites","value","Object","entries","delete","updatedAt"],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
* the barrel is silently replaced by `undefined` or by whatever the factory
|
|
23
24
|
* happens to list, which is a check that reads as present and runs never.
|
|
24
25
|
*/ import { filterSuppressedEmails } from "@aglyn/tenant-data-admin/server/email-suppression";
|
|
26
|
+
import { renderSystemEmailContent, systemEmailBrand } from "@aglyn/tenant-data-admin/server/render-system-email";
|
|
25
27
|
import { sendEmail } from "@aglyn/shared-util-email";
|
|
26
28
|
/**
|
|
27
29
|
* Emails a publisher's owners and admins about a review outcome (AGL-972).
|
|
@@ -68,12 +70,20 @@ import { sendEmail } from "@aglyn/shared-util-email";
|
|
|
68
70
|
// silence the verdict for the rest of them.
|
|
69
71
|
const recipients = await filterSuppressedEmails(addresses, options == null ? void 0 : options.firestore);
|
|
70
72
|
if (!recipients.length) return;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// The `plugin-review-update` system email (AGL-3367), in the platform's
|
|
74
|
+
// brand: review is the platform talking about its own marketplace.
|
|
75
|
+
const content = await renderSystemEmailContent('plugin-review-update', {
|
|
76
|
+
'review.subject': subject,
|
|
77
|
+
'review.body': text
|
|
78
|
+
}, systemEmailBrand(null), {
|
|
79
|
+
subject,
|
|
80
|
+
text
|
|
81
|
+
});
|
|
82
|
+
const results = await Promise.all(recipients.map((to)=>sendEmail(_extends({
|
|
83
|
+
to
|
|
84
|
+
}, content, {
|
|
75
85
|
context: 'plugin review update'
|
|
76
|
-
})));
|
|
86
|
+
}))));
|
|
77
87
|
// Cost meter (AGL-1438). Platform-scoped: marketplace review is Aglyn's
|
|
78
88
|
// own workflow talking to a publisher, not mail the publisher's org sent.
|
|
79
89
|
await meterPlatformEmail(results.filter((result)=>result.sent).length);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publisher-review-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n firebaseAdmin,\n listOrgMembers,\n meterPlatformEmail,\n} from '@aglyn/tenant-data-admin'\n/*\n * From the LEAF, not the barrel (AGL-2407), for the reason\n * `usage-alert-email.ts` states: the review route's own spec mocks\n * `@aglyn/tenant-data-admin` wholesale because its graph reaches the admin\n * SDK, and a `jest.mock` factory is a closed world. A gate imported through\n * the barrel is silently replaced by `undefined` or by whatever the factory\n * happens to list, which is a check that reads as present and runs never.\n */\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { sendEmail } from '@aglyn/shared-util-email'\n\n/**\n * Emails a publisher's owners and admins about a review outcome (AGL-972).\n *\n * In-app notifications alone assume the publisher is sitting in the console\n * — but review is asynchronous by nature: a submission can wait days, and\n * the publisher has no reason to keep checking. Best effort, and never\n * allowed to fail the verdict that triggered it.\n *\n * ## The platform hard-bounce list, and deliberately NOT the marketing gate\n *\n * This is the console's one multi-recipient platform fan-out, and a fan-out\n * is the shape the suppression list exists for: a publisher who submits\n * monthly is mailed monthly, and an address that permanently bounced is\n * re-attempted on every submission forever unless something looks. Repeat\n * delivery to a mailbox that has said it does not exist is what a mailbox\n * provider scores a sending domain on, and `aglyn.com` carries the password\n * resets and receipts on the same key.\n *\n * So it reads {@link filterSuppressedEmails} — the PLATFORM list, which\n * answers \"did this mailbox permanently fail, or did its owner report spam,\n * anywhere in the product\". It does not read `filterSendableForHost`: that\n * one adds `hosts/{hostId}/suppressions`, whose entries mean \"not from this\n * SITE'S campaigns\", and a review verdict is sent on no site's behalf. There\n * is no host to key it by, and a publisher opting out of their own site's\n * marketing says nothing about whether they want to hear that their plugin\n * was rejected.\n *\n * It also does not pass the marketing gate. A review verdict is a platform\n * notice to a publisher about their own submission — nearer a receipt than a\n * newsletter — and the gate would apply a topic the publisher never\n * subscribed to and a frequency cap that would silently drop the second\n * verdict of a busy week.\n */\nexport async function emailPublisher(\n orgId: string,\n subject: string,\n text: string,\n /** Injectable for tests; the list read defaults to the admin Firestore. */\n options?: { firestore?: any },\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const uids = members\n .filter((member) => member.role === 'owner' || member.role === 'admin')\n .map((member) => member.$id)\n if (!uids.length) return\n const users = await firebaseAdmin\n .app()\n .auth()\n .getUsers(uids.map((uid) => ({ uid })))\n const addresses = users.users\n .map((user) => user.email)\n .filter((email): email is string => Boolean(email))\n // Per address, so one dead mailbox among a publisher's admins does not\n // silence the verdict for the rest of them.\n const recipients = await filterSuppressedEmails(\n addresses,\n options?.firestore,\n )\n if (!recipients.length) return\n const results = await Promise.all(\n recipients.map((to) =>\n sendEmail({ to,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/publisher-review-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n firebaseAdmin,\n listOrgMembers,\n meterPlatformEmail,\n} from '@aglyn/tenant-data-admin'\n/*\n * From the LEAF, not the barrel (AGL-2407), for the reason\n * `usage-alert-email.ts` states: the review route's own spec mocks\n * `@aglyn/tenant-data-admin` wholesale because its graph reaches the admin\n * SDK, and a `jest.mock` factory is a closed world. A gate imported through\n * the barrel is silently replaced by `undefined` or by whatever the factory\n * happens to list, which is a check that reads as present and runs never.\n */\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n renderSystemEmailContent,\n systemEmailBrand,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { sendEmail } from '@aglyn/shared-util-email'\n\n/**\n * Emails a publisher's owners and admins about a review outcome (AGL-972).\n *\n * In-app notifications alone assume the publisher is sitting in the console\n * — but review is asynchronous by nature: a submission can wait days, and\n * the publisher has no reason to keep checking. Best effort, and never\n * allowed to fail the verdict that triggered it.\n *\n * ## The platform hard-bounce list, and deliberately NOT the marketing gate\n *\n * This is the console's one multi-recipient platform fan-out, and a fan-out\n * is the shape the suppression list exists for: a publisher who submits\n * monthly is mailed monthly, and an address that permanently bounced is\n * re-attempted on every submission forever unless something looks. Repeat\n * delivery to a mailbox that has said it does not exist is what a mailbox\n * provider scores a sending domain on, and `aglyn.com` carries the password\n * resets and receipts on the same key.\n *\n * So it reads {@link filterSuppressedEmails} — the PLATFORM list, which\n * answers \"did this mailbox permanently fail, or did its owner report spam,\n * anywhere in the product\". It does not read `filterSendableForHost`: that\n * one adds `hosts/{hostId}/suppressions`, whose entries mean \"not from this\n * SITE'S campaigns\", and a review verdict is sent on no site's behalf. There\n * is no host to key it by, and a publisher opting out of their own site's\n * marketing says nothing about whether they want to hear that their plugin\n * was rejected.\n *\n * It also does not pass the marketing gate. A review verdict is a platform\n * notice to a publisher about their own submission — nearer a receipt than a\n * newsletter — and the gate would apply a topic the publisher never\n * subscribed to and a frequency cap that would silently drop the second\n * verdict of a busy week.\n */\nexport async function emailPublisher(\n orgId: string,\n subject: string,\n text: string,\n /** Injectable for tests; the list read defaults to the admin Firestore. */\n options?: { firestore?: any },\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const uids = members\n .filter((member) => member.role === 'owner' || member.role === 'admin')\n .map((member) => member.$id)\n if (!uids.length) return\n const users = await firebaseAdmin\n .app()\n .auth()\n .getUsers(uids.map((uid) => ({ uid })))\n const addresses = users.users\n .map((user) => user.email)\n .filter((email): email is string => Boolean(email))\n // Per address, so one dead mailbox among a publisher's admins does not\n // silence the verdict for the rest of them.\n const recipients = await filterSuppressedEmails(\n addresses,\n options?.firestore,\n )\n if (!recipients.length) return\n // The `plugin-review-update` system email (AGL-3367), in the platform's\n // brand: review is the platform talking about its own marketplace.\n const content = await renderSystemEmailContent(\n 'plugin-review-update',\n { 'review.subject': subject, 'review.body': text },\n systemEmailBrand(null),\n { subject, text },\n )\n const results = await Promise.all(\n recipients.map((to) =>\n sendEmail({ to, ...content, context: 'plugin review update' }),\n ),\n )\n // Cost meter (AGL-1438). Platform-scoped: marketplace review is Aglyn's\n // own workflow talking to a publisher, not mail the publisher's org sent.\n await meterPlatformEmail(results.filter((result) => result.sent).length)\n } catch (error) {\n console.error('publisher review email failed', error)\n }\n}\n"],"names":["firebaseAdmin","listOrgMembers","meterPlatformEmail","filterSuppressedEmails","renderSystemEmailContent","systemEmailBrand","sendEmail","emailPublisher","orgId","subject","text","options","members","uids","filter","member","role","map","$id","length","users","app","auth","getUsers","uid","addresses","user","email","Boolean","recipients","firestore","content","results","Promise","all","to","context","result","sent","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,aAAa,EACbC,cAAc,EACdC,kBAAkB,QACb,2BAA0B;AACjC;;;;;;;CAOC,GACD,SAASC,sBAAsB,QAAQ,oDAAmD;AAC1F,SACEC,wBAAwB,EACxBC,gBAAgB,QACX,sDAAqD;AAC5D,SAASC,SAAS,QAAQ,2BAA0B;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCC,GACD,OAAO,eAAeC,eACpBC,KAAa,EACbC,OAAe,EACfC,IAAY,EACZ,yEAAyE,GACzEC,OAA6B;IAE7B,IAAI;QACF,MAAMC,UAAU,MAAMX,eAAeO;QACrC,MAAMK,OAAOD,QACVE,MAAM,CAAC,CAACC,SAAWA,OAAOC,IAAI,KAAK,WAAWD,OAAOC,IAAI,KAAK,SAC9DC,GAAG,CAAC,CAACF,SAAWA,OAAOG,GAAG;QAC7B,IAAI,CAACL,KAAKM,MAAM,EAAE;QAClB,MAAMC,QAAQ,MAAMpB,cACjBqB,GAAG,GACHC,IAAI,GACJC,QAAQ,CAACV,KAAKI,GAAG,CAAC,CAACO,MAAS,CAAA;gBAAEA;YAAI,CAAA;QACrC,MAAMC,YAAYL,MAAMA,KAAK,CAC1BH,GAAG,CAAC,CAACS,OAASA,KAAKC,KAAK,EACxBb,MAAM,CAAC,CAACa,QAA2BC,QAAQD;QAC9C,uEAAuE;QACvE,4CAA4C;QAC5C,MAAME,aAAa,MAAM1B,uBACvBsB,WACAd,2BAAAA,QAASmB,SAAS;QAEpB,IAAI,CAACD,WAAWV,MAAM,EAAE;QACxB,wEAAwE;QACxE,mEAAmE;QACnE,MAAMY,UAAU,MAAM3B,yBACpB,wBACA;YAAE,kBAAkBK;YAAS,eAAeC;QAAK,GACjDL,iBAAiB,OACjB;YAAEI;YAASC;QAAK;QAElB,MAAMsB,UAAU,MAAMC,QAAQC,GAAG,CAC/BL,WAAWZ,GAAG,CAAC,CAACkB,KACd7B,UAAU;gBAAE6B;eAAOJ;gBAASK,SAAS;;QAGzC,wEAAwE;QACxE,0EAA0E;QAC1E,MAAMlC,mBAAmB8B,QAAQlB,MAAM,CAAC,CAACuB,SAAWA,OAAOC,IAAI,EAAEnB,MAAM;IACzE,EAAE,OAAOoB,OAAO;QACdC,QAAQD,KAAK,CAAC,iCAAiCA;IACjD;AACF"}
|
|
@@ -19,6 +19,14 @@ export declare function isVerifiedInstaller(firestore: FirebaseFirestore.Firesto
|
|
|
19
19
|
verified: boolean;
|
|
20
20
|
orgId?: string;
|
|
21
21
|
}>;
|
|
22
|
+
/** An account younger than this may comment but not rate (AGL-3365). */
|
|
23
|
+
export declare const REVIEW_MIN_ACCOUNT_AGE_DAYS = 7;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the Firebase account is younger than
|
|
26
|
+
* {@link REVIEW_MIN_ACCOUNT_AGE_DAYS}. An account whose creation time cannot
|
|
27
|
+
* be read is not new — the answer that never locks an existing customer out.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isNewAccount(uid: string, nowMs?: number): Promise<boolean>;
|
|
22
30
|
/**
|
|
23
31
|
* Ratings and comments on a marketplace listing (AGL-655).
|
|
24
32
|
*
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
17
|
import { firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
|
|
18
|
+
import { findUserByUidAcrossPools } from "@aglyn/tenant-data-admin/server/auth-pools";
|
|
18
19
|
import { canActAsPublisher } from "./publisher-profile.js";
|
|
20
|
+
import { isOrgMember } from "./sale-risk.js";
|
|
19
21
|
const MAX_COMMENT_LENGTH = 2000;
|
|
20
22
|
/** Orgs scanned when looking for an install pin. */ const MAX_ORGS_CHECKED = 25;
|
|
21
23
|
/**
|
|
@@ -66,6 +68,24 @@ export async function isVerifiedInstaller(firestore, uid, listingId) {
|
|
|
66
68
|
verified: false
|
|
67
69
|
};
|
|
68
70
|
}
|
|
71
|
+
/** An account younger than this may comment but not rate (AGL-3365). */ export const REVIEW_MIN_ACCOUNT_AGE_DAYS = 7;
|
|
72
|
+
/**
|
|
73
|
+
* Whether the Firebase account is younger than
|
|
74
|
+
* {@link REVIEW_MIN_ACCOUNT_AGE_DAYS}. An account whose creation time cannot
|
|
75
|
+
* be read is not new — the answer that never locks an existing customer out.
|
|
76
|
+
*/ export async function isNewAccount(uid, nowMs = Date.now()) {
|
|
77
|
+
try {
|
|
78
|
+
var _ref;
|
|
79
|
+
var _this, _user_metadata;
|
|
80
|
+
// Across pools (AGL-1122): an SSO reviewer is not in the project pool.
|
|
81
|
+
const user = (_this = await findUserByUidAcrossPools(uid)) == null ? void 0 : _this.record;
|
|
82
|
+
const createdMs = Date.parse(String((_ref = user == null ? void 0 : (_user_metadata = user.metadata) == null ? void 0 : _user_metadata.creationTime) != null ? _ref : ''));
|
|
83
|
+
if (!Number.isFinite(createdMs)) return false;
|
|
84
|
+
return nowMs - createdMs < REVIEW_MIN_ACCOUNT_AGE_DAYS * 86400000;
|
|
85
|
+
} catch (unused) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
69
89
|
/**
|
|
70
90
|
* Ratings and comments on a marketplace listing (AGL-655).
|
|
71
91
|
*
|
|
@@ -102,7 +122,7 @@ export async function isVerifiedInstaller(firestore, uid, listingId) {
|
|
|
102
122
|
error: 'Unauthenticated'
|
|
103
123
|
});
|
|
104
124
|
try {
|
|
105
|
-
var _ref1;
|
|
125
|
+
var _listing_profileId, _ref1;
|
|
106
126
|
var _req_body1, _req_body2;
|
|
107
127
|
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
108
128
|
const firestore = firebaseAdmin.app().firestore();
|
|
@@ -133,8 +153,10 @@ export async function isVerifiedInstaller(firestore, uid, listingId) {
|
|
|
133
153
|
removed: true
|
|
134
154
|
});
|
|
135
155
|
}
|
|
136
|
-
// Publishing org cannot review itself, however many accounts it has
|
|
137
|
-
|
|
156
|
+
// Publishing org cannot review itself, however many accounts it has —
|
|
157
|
+
// ANY member of it (AGL-3365). `canActAsPublisher` answers only for an
|
|
158
|
+
// owner or admin, so an editor of the publisher could rate its listing.
|
|
159
|
+
if (await canActAsPublisher(firestore, decoded.uid, listing.profileId) || await isOrgMember(firestore, String((_listing_profileId = listing.profileId) != null ? _listing_profileId : ''), decoded.uid)) {
|
|
138
160
|
return res.status(403).json({
|
|
139
161
|
error: 'You cannot review your organization’s own listing'
|
|
140
162
|
});
|
|
@@ -158,7 +180,25 @@ export async function isVerifiedInstaller(firestore, uid, listingId) {
|
|
|
158
180
|
reason: 'email-unverified'
|
|
159
181
|
});
|
|
160
182
|
}
|
|
183
|
+
// A rating from a brand-new account is refused (AGL-3365): a burst of
|
|
184
|
+
// fresh accounts installing a free listing and rating it five stars is
|
|
185
|
+
// the cheapest way to buy ranking. The comment stays open, and the words
|
|
186
|
+
// say what to do, never where the line is.
|
|
187
|
+
if (rating && await isNewAccount(decoded.uid)) {
|
|
188
|
+
return res.status(403).json({
|
|
189
|
+
error: 'Ratings open once your account has been active for a little while — ' + 'you can still leave a comment',
|
|
190
|
+
reason: 'account-too-new'
|
|
191
|
+
});
|
|
192
|
+
}
|
|
161
193
|
const installer = await isVerifiedInstaller(firestore, decoded.uid, listingId);
|
|
194
|
+
// The workspace that installed it must not be the publisher's own
|
|
195
|
+
// (AGL-3365) — the member check above covers the reviewer, this the org
|
|
196
|
+
// whose install vouches for the rating.
|
|
197
|
+
if (rating && installer.orgId && installer.orgId === listing.profileId) {
|
|
198
|
+
return res.status(403).json({
|
|
199
|
+
error: 'You cannot review your organization’s own listing'
|
|
200
|
+
});
|
|
201
|
+
}
|
|
162
202
|
if (rating && !installer.verified) {
|
|
163
203
|
return res.status(403).json({
|
|
164
204
|
error: 'Only organizations that have installed this can rate it — you ' + 'can still leave a comment'
|
|
@@ -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 { 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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"}
|
|
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":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,aAAa,EAAEC,sBAAsB,QAAQ,2BAA0B;AAChF,SAASC,wBAAwB,QAAQ,6CAA4C;AACrF,SAASC,iBAAiB,QAAQ,yBAAqB;AACvD,SAASC,WAAW,QAAQ,iBAAa;AAEzC,MAAMC,qBAAqB;AAC3B,kDAAkD,GAClD,MAAMC,mBAAmB;AAEzB;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,MAAMC,oBAAoB;AAE1B,4EAA4E;AAC5E,sEAAsE;AACtE,kDAAkD;AAClD,OAAO,eAAeC,oBACpBC,SAAsC,EACtCC,GAAW,EACXC,SAAiB;IAEjB,MAAMC,cAAc,MAAMH,UACvBI,UAAU,CAAC,SACXC,GAAG,CAACJ,KACJG,UAAU,CAAC,QACXE,KAAK,CAACT,kBACNU,GAAG;IACN,KAAK,MAAMC,cAAcL,YAAYM,IAAI,CAAE;YAKtCC;QAJH,MAAMC,QAAQH,WAAWI,EAAE;QAC3B,MAAMC,SAASb,UAAUI,UAAU,CAAC,QAAQC,GAAG,CAACM;QAChD,MAAMD,MAAM,MAAMG,OAAON,GAAG;QAC5B,MAAMO,UAAUC,OAAOC,IAAI,EACxBN,WAAAA,IAAIH,GAAG,CAAC,oBAARG,WAA4D,CAAC,GAC9DO,KAAK,CAAC,GAAGnB;QACX,MAAMoB,OAAO;YACXL,OAAOT,UAAU,CAAC,YAAYC,GAAG,CAACH;eAC/BY,QAAQK,GAAG,CAAC,CAACC,SACdpB,UACGI,UAAU,CAAC,SACXC,GAAG,CAACe,QACJhB,UAAU,CAAC,YACXC,GAAG,CAACH;SAEV;QACD,MAAMmB,OAAO,MAAMrB,UAAUsB,MAAM,IAAIJ;QACvC,IAAIG,KAAKE,IAAI,CAAC,CAACC,MAAQA,IAAIC,MAAM,GAAG,OAAO;YAAEC,UAAU;YAAMf;QAAM;IACrE;IACA,OAAO;QAAEe,UAAU;IAAM;AAC3B;AAEA,sEAAsE,GACtE,OAAO,MAAMC,8BAA8B,EAAC;AAE5C;;;;CAIC,GACD,OAAO,eAAeC,aAAa3B,GAAW,EAAE4B,QAAQC,KAAKC,GAAG,EAAE;IAChE,IAAI;;YAEY,OACsBC;QAFpC,uEAAuE;QACvE,MAAMA,QAAQ,QAAA,MAAMvC,yBAAyBQ,yBAAhC,AAAC,MAAsCgC,MAAM;QAC1D,MAAMC,YAAYJ,KAAKK,KAAK,CAACC,eAAOJ,yBAAAA,iBAAAA,KAAMK,QAAQ,qBAAdL,eAAgBM,YAAY,mBAAI;QACpE,IAAI,CAACC,OAAOC,QAAQ,CAACN,YAAY,OAAO;QACxC,OAAOL,QAAQK,YAAYP,8BAA8B;IAC3D,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMc,iBAAmC,OAAOC,KAAKC;cAQ7BD;QAHJA;IAJzB,MAAME,SAASF,IAAIE,MAAM,KAAK,WAAW,WAAW;IACpD,IAAIF,IAAIE,MAAM,KAAK,UAAUF,IAAIE,MAAM,KAAK,UAAU;QACpD,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAM7C,YAAYkC,gBAAOM,YAAAA,IAAIM,IAAI,qBAARN,UAAUxC,SAAS,mBAAI;IAChD,IAAI,CAACA,WAAW,OAAOyC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAoB;IAEzE,MAAME,gBAAgBb,QAAOM,6BAAAA,IAAIQ,OAAO,CAACD,aAAa,YAAzBP,6BAA6B;IAC1D,MAAMS,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAchC,KAAK,CAAC,UAAUoC,MAAM,IACpCC;IACJ,IAAI,CAACH,SAAS,OAAOR,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IAErE,IAAI;YAuCqCQ;YAOrBb,YAKKA;QAlDvB,MAAMc,UAAU,MAAMjE,cAAckE,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACR;QAC/D,MAAMnD,YAAYT,cAAckE,GAAG,GAAGzD,SAAS;QAC/C,MAAM4D,aAAa5D,UAAUI,UAAU,CAAC,uBAAuBC,GAAG,CAACH;QACnE,MAAM2D,kBAAkB,MAAMD,WAAWrD,GAAG;QAC5C,MAAMgD,UAAUM,gBAAgBC,IAAI;QACpC,IAAI,CAACP,WAAWA,QAAQQ,SAAS,EAAE;YACjC,OAAOpB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;QACzD;QACA,MAAMiB,YAAYJ,WAAWxD,UAAU,CAAC,WAAWC,GAAG,CAACmD,QAAQvD,GAAG;QAElE,IAAI2C,WAAW,UAAU;YACvB,MAAM5C,UAAUiE,cAAc,CAAC,OAAOC;oBAMNC;gBAL9B,MAAM,CAACA,UAAUC,WAAW,GAAG,MAAMC,QAAQC,GAAG,CAAC;oBAC/CJ,GAAG3D,GAAG,CAACyD;oBACPE,GAAG3D,GAAG,CAACqD;iBACR;gBACD,IAAI,CAACO,SAAS1C,MAAM,EAAE;gBACtB,MAAM8C,iBAAiBhC,QAAO4B,gBAAAA,SAAS5D,GAAG,CAAC,qBAAb4D,gBAA0B,MAAM;gBAC9D,MAAMK,aAAa,CAAC,CAACL,SAAS5D,GAAG,CAAC;gBAClC2D,GAAGO,MAAM,CAACT;gBACVE,GAAGQ,MAAM,CACPd,YACAe,eACEP,YACA,CAACG,gBACDA,iBAAiB,CAAC,IAAI,GACtBC,aAAa,CAAC,IAAI;YAGxB;YACA,OAAO7B,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAE8B,IAAI;gBAAMC,SAAS;YAAK;QACxD;QAEA,sEAAsE;QACtE,uEAAuE;QACvE,wEAAwE;QACxE,IACE,AAAC,MAAMnF,kBAAkBM,WAAWwD,QAAQvD,GAAG,EAAEsD,QAAQuB,SAAS,KACjE,MAAMnF,YAAYK,WAAWoC,QAAOmB,qBAAAA,QAAQuB,SAAS,YAAjBvB,qBAAqB,KAAKC,QAAQvD,GAAG,GAC1E;YACA,OAAO0C,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OAAO;YACT;QACF;QAEA,MAAMgC,aAAYrC,aAAAA,IAAIM,IAAI,qBAARN,WAAUsC,MAAM;QAClC,MAAMA,SACJD,aAAa,QAAQA,cAAc,KAC/B,IACAE,KAAKC,GAAG,CAAC,GAAGD,KAAKE,GAAG,CAAC,GAAGF,KAAKG,KAAK,CAAC7C,OAAOwC,cAAc;QAC9D,MAAMM,UAAUjD,iBAAOM,aAAAA,IAAIM,IAAI,qBAARN,WAAU2C,OAAO,oBAAI,IACzCC,IAAI,GACJrE,KAAK,CAAC,GAAGrB;QACZ,IAAI,CAACoF,UAAU,CAACK,SAAS;YACvB,OAAO1C,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA4B;QACnE;QAEA,yEAAyE;QACzE,uEAAuE;QACvE,yEAAyE;QACzE,0EAA0E;QAC1E,iEAAiE;QACjE,IAAIiC,UAAU,CAACxB,QAAQ+B,cAAc,IAAI,CAAC/F,uBAAuBgE,UAAU;YACzE,OAAOb,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OAAO;gBACPyC,QAAQ;YACV;QACF;QACA,sEAAsE;QACtE,uEAAuE;QACvE,yEAAyE;QACzE,2CAA2C;QAC3C,IAAIR,UAAW,MAAMpD,aAAa4B,QAAQvD,GAAG,GAAI;YAC/C,OAAO0C,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE,yEACA;gBACFyC,QAAQ;YACV;QACF;QACA,MAAMC,YAAY,MAAM1F,oBAAoBC,WAAWwD,QAAQvD,GAAG,EAAEC;QACpE,kEAAkE;QAClE,wEAAwE;QACxE,wCAAwC;QACxC,IAAI8E,UAAUS,UAAU9E,KAAK,IAAI8E,UAAU9E,KAAK,KAAK4C,QAAQuB,SAAS,EAAE;YACtE,OAAOnC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OAAO;YACT;QACF;QACA,IAAIiC,UAAU,CAACS,UAAU/D,QAAQ,EAAE;YACjC,OAAOiB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE,mEACA;YACJ;QACF;QAEA,MAAMhB,MAAMD,KAAKC,GAAG;QACpB,MAAM/B,UAAUiE,cAAc,CAAC,OAAOC;gBAKNC,eAMNX,eAOZW;YAjBZ,MAAM,CAACA,UAAUC,WAAW,GAAG,MAAMC,QAAQC,GAAG,CAAC;gBAC/CJ,GAAG3D,GAAG,CAACyD;gBACPE,GAAG3D,GAAG,CAACqD;aACR;YACD,MAAMW,iBAAiBhC,QAAO4B,gBAAAA,SAAS5D,GAAG,CAAC,qBAAb4D,gBAA0B,MAAM;YAC9D,MAAMK,aAAaL,SAAS1C,MAAM,IAAI,CAAC,CAAC0C,SAAS5D,GAAG,CAAC;YACrD2D,GAAGwB,GAAG,CACJ1B,WACA;gBACE/D,KAAKuD,QAAQvD,GAAG;gBAChB0F,aAAavD,QAAOoB,gBAAAA,QAAQoC,IAAI,YAAZpC,gBAAgB,WAAWvC,KAAK,CAAC,GAAG;eACpD+D,SAAS;gBAAEA;YAAO,IAAI;gBAAEA,QAAQ;YAAK,GACrCK,UAAU;gBAAEA;YAAQ,IAAI;gBAAEA,SAAS;YAAK;gBAC5CQ,mBAAmBJ,UAAU/D,QAAQ;eACjC+D,UAAU9E,KAAK,GAAG;gBAAEA,OAAO8E,UAAU9E,KAAK;YAAC,IAAI,CAAC;gBACpD,6DAA6D;gBAC7D,4CAA4C;gBAC5CmF,MAAM,GAAE3B,iBAAAA,SAAS5D,GAAG,CAAC,qBAAb4D,iBAA0B;eAC9BA,SAAS1C,MAAM,GAAG,CAAC,IAAI;gBAAEsE,aAAahE;YAAI;gBAC9CiE,aAAajE;gBAEf;gBAAEkE,OAAO;YAAK;YAEhB/B,GAAGQ,MAAM,CACPd,YACAe,eACEP,YACAY,SAAST,gBACT,AAACS,CAAAA,SAAS,IAAI,CAAA,IAAMT,CAAAA,iBAAiB,IAAI,CAAA,GACzC,AAACc,CAAAA,UAAU,IAAI,CAAA,IAAMb,CAAAA,aAAa,IAAI,CAAA;QAG5C;QAEA,OAAO7B,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1B8B,IAAI;YACJI,QAAQA,UAAU;YAClBa,mBAAmBJ,UAAU/D,QAAQ;QACvC;IACF,EAAE,OAAOqB,OAAO;QACdmD,QAAQnD,KAAK,CAACA;QACd,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAgB;IACvD;AACF,EAAC;AAED;;;;;;;;CAQC,GACD,SAAS4B,eACPwB,OAA2C,EAC3CC,cAAsB,EACtBC,gBAAwB,EACxBC,iBAAyB;QAEYH,cAG5BA,eAIAA;IAPT,MAAMI,YAAYtB,KAAKE,GAAG,CAAC,GAAG5C,QAAO4D,eAAAA,QAAQ5F,GAAG,CAAC,wBAAZ4F,eAA4B,KAAKC;IACtE,MAAMI,cAAcvB,KAAKE,GAAG,CAC1B,GACA5C,QAAO4D,gBAAAA,QAAQ5F,GAAG,CAAC,0BAAZ4F,gBAA8B,KAAKE;IAE5C,MAAMI,eAAexB,KAAKE,GAAG,CAC3B,GACA5C,QAAO4D,gBAAAA,QAAQ5F,GAAG,CAAC,2BAAZ4F,gBAA+B,KAAKG;IAE7C,OAAO;QACLC;QACAC;QACAC;QACA,qEAAqE;QACrEC,eAAeF,cACXvB,KAAKG,KAAK,CAAC,AAACmB,YAAYC,cAAe,MAAM,KAC7C;QACJG,WAAWpH,cAAcS,SAAS,CAAC4G,UAAU,CAACC,eAAe;IAC/D;AACF;AAEA,eAAepE,eAAc"}
|
|
@@ -0,0 +1,105 @@
|
|
|
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 SaleRiskSignal } from '../model/publisher-risk';
|
|
18
|
+
/**
|
|
19
|
+
* Whether `uid` is ANY member of `orgId` — not only a manager, which is all
|
|
20
|
+
* `canActAsPublisher` asks. A publishing workspace's editor buying its
|
|
21
|
+
* listing is the publisher buying it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isOrgMember(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string): Promise<boolean>;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the publishing workspace is under a SECURITY lock (AGL-3365) — the
|
|
26
|
+
* install and update doors hand nothing from it over. Security only: a
|
|
27
|
+
* workspace locked over its own unpaid bill has not made its listings
|
|
28
|
+
* dangerous, and a buyer who paid for one keeps installing it. (Checkout
|
|
29
|
+
* refuses a sale to a workspace under ANY lock; that is money moving to it.)
|
|
30
|
+
*
|
|
31
|
+
* The shared normalizer reads the reason, so a lock written before reason
|
|
32
|
+
* codes existed reads as `manual`, not security. One read of the org.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isPublisherSecurityLocked(firestore: FirebaseFirestore.Firestore, publisherOrgId: unknown): Promise<boolean>;
|
|
35
|
+
/**
|
|
36
|
+
* Put a publisher's connected account on the payout schedule its age calls
|
|
37
|
+
* for (`publisherPayoutDelayDays`), and note it on the profile.
|
|
38
|
+
*
|
|
39
|
+
* Writes to Stripe only when the answer CHANGES what this code last set: a
|
|
40
|
+
* young publisher is moved to the delay once, and moved back to the standard
|
|
41
|
+
* schedule once when it ages out. An account this code never delayed is
|
|
42
|
+
* never written — a schedule staff set by hand in the Dashboard is theirs.
|
|
43
|
+
*
|
|
44
|
+
* Never throws: onboarding and a sale must both go on if Stripe says no.
|
|
45
|
+
*/
|
|
46
|
+
export declare function applyPublisherPayoutPolicy(input: {
|
|
47
|
+
firestore: FirebaseFirestore.Firestore;
|
|
48
|
+
publisherOrgId: string;
|
|
49
|
+
ageDays: number | null;
|
|
50
|
+
stripeKey: string | undefined;
|
|
51
|
+
nowMs?: number;
|
|
52
|
+
}): Promise<'applied' | 'current' | 'skipped' | 'failed'>;
|
|
53
|
+
/** Tell staff a sale was flagged (AGL-3365), once per row. Never throws. */
|
|
54
|
+
export declare function notifyStaffOfSaleRisk(notify: (payload: {
|
|
55
|
+
type: 'system.abuseReportUrgent';
|
|
56
|
+
title: string;
|
|
57
|
+
body: string;
|
|
58
|
+
link: string;
|
|
59
|
+
}) => Promise<void>, flagged: {
|
|
60
|
+
reference: string;
|
|
61
|
+
signals: readonly SaleRiskSignal[];
|
|
62
|
+
}): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Tell the publisher's owners and admins a sale is under review (AGL-3365).
|
|
65
|
+
* The outcome and the way forward only — never which shape flagged it, which
|
|
66
|
+
* would teach the actor it describes how to avoid it. Never throws.
|
|
67
|
+
*/
|
|
68
|
+
export declare function notifyPublisherSaleUnderReview(publisherOrgId: string, flagged: {
|
|
69
|
+
reference: string;
|
|
70
|
+
}): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Tell the publisher's owners and admins their payout timing changed
|
|
73
|
+
* (AGL-3365) — when a hold starts, and when it ends. Never the rule that set
|
|
74
|
+
* it. Never throws.
|
|
75
|
+
*/
|
|
76
|
+
export declare function notifyPublisherPayoutSchedule(publisherOrgId: string, held: boolean): Promise<void>;
|
|
77
|
+
/** The sale-risk row's id: one per sale. */
|
|
78
|
+
export declare function saleRiskReviewId(sessionId: string): string;
|
|
79
|
+
/**
|
|
80
|
+
* Read one recorded sale for the fraud shapes above, hold a young
|
|
81
|
+
* publisher's payouts, and file a staff row when anything is found.
|
|
82
|
+
*/
|
|
83
|
+
export declare function screenMarketplaceSale(input: {
|
|
84
|
+
purchaseRef: FirebaseFirestore.DocumentReference;
|
|
85
|
+
sessionId: string;
|
|
86
|
+
buyerUid: string;
|
|
87
|
+
buyerOrgId: string | null;
|
|
88
|
+
sellerOrgId: string;
|
|
89
|
+
amountCents: number;
|
|
90
|
+
paymentIntentId: string;
|
|
91
|
+
livemode: boolean;
|
|
92
|
+
}, deps: {
|
|
93
|
+
firestore?: FirebaseFirestore.Firestore;
|
|
94
|
+
stripeKey?: string;
|
|
95
|
+
notify: (payload: {
|
|
96
|
+
type: 'system.abuseReportUrgent';
|
|
97
|
+
title: string;
|
|
98
|
+
body: string;
|
|
99
|
+
link: string;
|
|
100
|
+
}) => Promise<void>;
|
|
101
|
+
nowMs?: number;
|
|
102
|
+
}): Promise<{
|
|
103
|
+
signals: SaleRiskSignal[];
|
|
104
|
+
filed: string | null;
|
|
105
|
+
}>;
|