@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.
Files changed (57) hide show
  1. package/package.json +14 -14
  2. package/src/lib/model/publisher-risk.d.ts +52 -0
  3. package/src/lib/model/publisher-risk.js +74 -0
  4. package/src/lib/model/publisher-risk.js.map +1 -0
  5. package/src/lib/server/billing-webhook.js +72 -4
  6. package/src/lib/server/billing-webhook.js.map +1 -1
  7. package/src/lib/server/checkout.js +18 -1
  8. package/src/lib/server/checkout.js.map +1 -1
  9. package/src/lib/server/connect.js +15 -1
  10. package/src/lib/server/connect.js.map +1 -1
  11. package/src/lib/server/install-dataset-schema.js +3 -1
  12. package/src/lib/server/install-dataset-schema.js.map +1 -1
  13. package/src/lib/server/install-email-starter.js +3 -1
  14. package/src/lib/server/install-email-starter.js.map +1 -1
  15. package/src/lib/server/install-email-template.js +3 -1
  16. package/src/lib/server/install-email-template.js.map +1 -1
  17. package/src/lib/server/install-layout.js +3 -1
  18. package/src/lib/server/install-layout.js.map +1 -1
  19. package/src/lib/server/install-plugin.js +3 -1
  20. package/src/lib/server/install-plugin.js.map +1 -1
  21. package/src/lib/server/install-template.js +3 -1
  22. package/src/lib/server/install-template.js.map +1 -1
  23. package/src/lib/server/install-theme.js +3 -1
  24. package/src/lib/server/install-theme.js.map +1 -1
  25. package/src/lib/server/install.js +3 -1
  26. package/src/lib/server/install.js.map +1 -1
  27. package/src/lib/server/listing-screen.d.ts +83 -0
  28. package/src/lib/server/listing-screen.js +244 -0
  29. package/src/lib/server/listing-screen.js.map +1 -0
  30. package/src/lib/server/publish-dataset-schema.js +12 -0
  31. package/src/lib/server/publish-dataset-schema.js.map +1 -1
  32. package/src/lib/server/publish-email-starter.js +15 -1
  33. package/src/lib/server/publish-email-starter.js.map +1 -1
  34. package/src/lib/server/publish-email-template.js +15 -1
  35. package/src/lib/server/publish-email-template.js.map +1 -1
  36. package/src/lib/server/publish-layout.js +15 -1
  37. package/src/lib/server/publish-layout.js.map +1 -1
  38. package/src/lib/server/publish-plugin.js +41 -3
  39. package/src/lib/server/publish-plugin.js.map +1 -1
  40. package/src/lib/server/publish-template.js +15 -1
  41. package/src/lib/server/publish-template.js.map +1 -1
  42. package/src/lib/server/publish-theme.js +14 -1
  43. package/src/lib/server/publish-theme.js.map +1 -1
  44. package/src/lib/server/publish.js +15 -1
  45. package/src/lib/server/publish.js.map +1 -1
  46. package/src/lib/server/publisher-profile-save.js +36 -0
  47. package/src/lib/server/publisher-profile-save.js.map +1 -1
  48. package/src/lib/server/publisher-review-email.js +15 -5
  49. package/src/lib/server/publisher-review-email.js.map +1 -1
  50. package/src/lib/server/reviews.d.ts +8 -0
  51. package/src/lib/server/reviews.js +43 -3
  52. package/src/lib/server/reviews.js.map +1 -1
  53. package/src/lib/server/sale-risk.d.ts +105 -0
  54. package/src/lib/server/sale-risk.js +315 -0
  55. package/src/lib/server/sale-risk.js.map +1 -0
  56. package/src/lib/server/update-artifact.js +3 -1
  57. package/src/lib/server/update-artifact.js.map +1 -1
@@ -22,6 +22,7 @@ import { canActAsPublisher } from "./publisher-profile.js";
22
22
  import { requirePurchase } from "./purchase-entitlement.js";
23
23
  import { hasDivergedFromBase, recordInstallProvenance } from "./provenance.js";
24
24
  import { recordVersionMove } from "./version-stats.js";
25
+ import { isPublisherSecurityLocked } from "./sale-risk.js";
25
26
  /**
26
27
  * Installs a marketplace theme onto a site (AGL-1020).
27
28
  *
@@ -177,7 +178,8 @@ import { recordVersionMove } from "./version-stats.js";
177
178
  //
178
179
  // No owner exemption, matching `install-plugin.ts`: a takedown is a
179
180
  // moderation decision about the artifact, not about who is asking.
180
- listing.hiddenAt || listingArtifactType(listing) !== 'theme') {
181
+ listing.hiddenAt || // A publisher under a SECURITY lock hands nothing over (AGL-3365).
182
+ await isPublisherSecurityLocked(firestore, listing.profileId) || listingArtifactType(listing) !== 'theme') {
181
183
  return res.status(404).json({
182
184
  error: 'Unknown theme'
183
185
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install-theme.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 { describeTheme } from '@aglyn/aglyn/app-utils/marketplace-theme'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n isPrivateListing,\n listingArtifactType,\n} from '../model/marketplace'\nimport { canActAsPublisher } from './publisher-profile'\nimport { requirePurchase } from './purchase-entitlement'\nimport { hasDivergedFromBase, recordInstallProvenance } from './provenance'\nimport { recordVersionMove } from './version-stats'\n\n/**\n * Installs a marketplace theme onto a site (AGL-1020).\n *\n * Themes are the exception to \"installing never changes a running site\". A\n * template or a layout can land inert in a library because it is one thing\n * among many; a theme IS the site's appearance, and installing one into a\n * library where it does nothing would be a control that appears to work and\n * does not. So this writes `hosts/{hostId}.theme` and the site repaints.\n *\n * Which makes reversibility the requirement, not a nicety. Two things provide\n * it, and neither is a confirmation dialog:\n *\n * * The theme being replaced is kept verbatim in `themeReplaced`, so `revert`\n * restores exactly what was there — including a hand-built theme that was\n * never a marketplace artifact and exists nowhere else.\n * * `reset` clears the theme entirely, which is the \"use the default theme\"\n * the issue asks to keep first-class. It is a distinct action from revert\n * because \"back to how it was\" and \"back to stock\" are different intents and\n * guessing between them is how people lose work.\n *\n * `preview` changes nothing and returns what the swap would do, so the\n * confirmation can name it rather than showing a JSON blob.\n */\nexport const installThemeHandler: 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 action = ['revert', 'reset', 'preview', 'clear-overrides'].includes(\n req.body?.action,\n )\n ? (req.body.action as\n | 'revert'\n | 'reset'\n | 'preview'\n | 'clear-overrides')\n : 'install'\n const listingId = String(req.body?.listingId ?? '')\n if (!hostId || (action === 'install' && !listingId)) {\n return res.status(400).json({ error: 'Missing listingId or hostId' })\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 membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!membership.permissions.installPlugins) {\n return res.status(403).json({\n error:\n 'Your organization role does not allow installing from the marketplace',\n })\n }\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n 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 const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n const currentTheme = hostSnapshot.get('theme') ?? null\n\n // ---- reset: back to the platform default ----\n if (action === 'reset') {\n await hostRef.set(\n {\n // Kept, not dropped: resetting is reversible for the same reason\n // installing is, and someone who resets by mistake has otherwise\n // lost a theme that existed in no other document.\n themeReplaced: { theme: currentTheme, replacedAt: now },\n theme: firebaseAdmin.firestore.FieldValue.delete(),\n themeInstalledFrom: firebaseAdmin.firestore.FieldValue.delete(),\n themeOverride: firebaseAdmin.firestore.FieldValue.delete(),\n updatedAt: now,\n },\n { merge: true },\n )\n return res.status(200).json({ reset: true })\n }\n\n // ---- clear-overrides: drop this site's changes, keep the theme ----\n // The third way back, and the narrowest: \"reset to the publisher's\n // version\" is deleting the patch — the whole point of owning the patch\n // rather than the copy (AGL-1019). The theme itself is untouched.\n if (action === 'clear-overrides') {\n await hostRef.set(\n {\n themeOverride: firebaseAdmin.firestore.FieldValue.delete(),\n updatedAt: now,\n },\n { merge: true },\n )\n return res.status(200).json({ cleared: true })\n }\n\n // ---- revert: back to whatever this site had before the last swap ----\n if (action === 'revert') {\n const replaced = hostSnapshot.get('themeReplaced')\n if (!replaced) {\n return res.status(409).json({\n error: 'There is no previous theme to go back to on this site.',\n })\n }\n await hostRef.set(\n {\n theme: replaced.theme ?? firebaseAdmin.firestore.FieldValue.delete(),\n ...(replaced.installedFrom\n ? { themeInstalledFrom: replaced.installedFrom }\n : { themeInstalledFrom: firebaseAdmin.firestore.FieldValue.delete() }),\n // The overrides that were live alongside that theme come back with\n // it. Reverting the base and keeping a patch authored against the\n // theme being reverted FROM is the one combination nobody asked for.\n ...(replaced.override\n ? { themeOverride: replaced.override }\n : { themeOverride: firebaseAdmin.firestore.FieldValue.delete() }),\n themeReplaced: firebaseAdmin.firestore.FieldValue.delete(),\n updatedAt: now,\n },\n { merge: true },\n )\n return res.status(200).json({ reverted: true })\n }\n\n const listingRef = firestore.collection('marketplaceListings').doc(listingId)\n const listingSnapshot = await listingRef.get()\n const listing = listingSnapshot.data() as any\n if (\n !listing ||\n listing.deletedAt ||\n // Staff takedown blocks new installs on EVERY artifact type\n // (AGL-2290). AGL-948 extended takedown past plugins in the browse\n // predicate and in `resolveMarketplacePluginVersion`, but the gate that\n // decides whether content is HANDED OVER was only ever added to\n // `install-plugin.ts`. So a component, theme, template, layout, email\n // template or dataset schema that staff had taken down stayed\n // installable by anyone holding its listing id — which makes takedown a\n // suggestion for six of the seven artifact types.\n //\n // No owner exemption, matching `install-plugin.ts`: a takedown is a\n // moderation decision about the artifact, not about who is asking.\n listing.hiddenAt ||\n listingArtifactType(listing) !== 'theme'\n ) {\n return res.status(404).json({ error: 'Unknown theme' })\n }\n\n const priceUsd = Number(listing.priceUsd ?? 0)\n const ownsListing = await canActAsPublisher(\n firestore,\n decoded.uid,\n listing.profileId,\n )\n // Private listings install ONLY for the owning org (AGL-2290).\n //\n // `install-plugin.ts` has carried this since AGL-968; the other six never\n // did, so a private component, theme, template, layout, email template or\n // dataset schema was installable by anyone who knew its listing id. Browse\n // hides them and the detail page 404s, but neither is a control — the\n // route is.\n if (isPrivateListing(listing) && !ownsListing) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // A FULLY refunded purchase stops entitling (AGL-1546), and until\n // AGL-1699 only the component route knew that: this one asked whether a\n // purchase doc EXISTED, so buy/install/refund kept the artifact. The\n // predicate lives in one place now so the next route cannot miss it.\n const unpaid = await requirePurchase({\n firestore,\n buyerUid: decoded.uid,\n // THE ORG THE LICENCE HAS TO COVER (AGL-2331). `membership.orgId` is\n // resolved server-side from the caller's own membership by the\n // permission gate above — never a request-body field — so this is the\n // workspace the install actually lands in, and the only one a purchase\n // can entitle here.\n buyerOrgId: membership.orgId ?? '',\n listingId,\n priceUsd,\n ownsListing,\n })\n if (unpaid) return res.status(402).json(unpaid)\n\n const versionSnapshot = await listingRef\n .collection('versions')\n .doc(String(listing.latestVersion))\n .get()\n const theme = versionSnapshot.get('theme')\n if (!theme || !Object.keys(theme).length) {\n return res.status(500).json({ error: 'Theme version missing' })\n }\n\n // ---- preview: say what the swap changes, change nothing ----\n if (action === 'preview') {\n return res.status(200).json({\n incoming: describeTheme(theme),\n current: describeTheme(currentTheme),\n // A site with no theme is on the platform default, so there is nothing\n // to lose and the confirmation should not imply there is.\n replaces: Boolean(currentTheme && Object.keys(currentTheme).length),\n version: listing.latestVersion ?? null,\n })\n }\n\n // ---- install ----\n // Replacing a theme the workspace has edited destroys those edits (AGL-1018).\n // Unlike a component this is not recoverable by looking in a library — the\n // theme lives on one field on one document — so the refusal matters more.\n const diverged = await hasDivergedFromBase({\n firestore,\n sha256: hostSnapshot.get('themeInstalledFrom.sha256'),\n current: currentTheme,\n })\n if (diverged && req.body?.mode !== 'replace') {\n return res.status(409).json({\n error:\n 'This site’s theme has been edited since it was installed. Review ' +\n 'the update to see what would change.',\n diverged: true,\n })\n }\n\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: 'theme',\n content: theme,\n })\n const previousVersion =\n hostSnapshot.get('themeInstalledFrom.version') ?? null\n\n await hostRef.set(\n {\n theme,\n themeInstalledFrom: provenance.installedFrom,\n // Everything needed to put this site back exactly as it was, captured\n // BEFORE the write that changes it.\n themeReplaced: {\n theme: currentTheme,\n ...(hostSnapshot.get('themeInstalledFrom')\n ? { installedFrom: hostSnapshot.get('themeInstalledFrom') }\n : {}),\n ...(hostSnapshot.get('themeOverride')\n ? { override: hostSnapshot.get('themeOverride') }\n : {}),\n replacedAt: now,\n },\n // Overrides SURVIVE a theme swap (AGL-1021). A site that set its brand\n // colour still means it under a new theme, and silently discarding\n // that on install would be the more surprising of the two options.\n //\n // What must not happen is surviving *silently*: the override keeps the\n // `baseSha256` it was authored against, which no longer matches the\n // theme now installed, so `isOverrideForCurrentTheme` is false and the\n // editor says so with an offer to clear them. Rewriting that hash here\n // would launder a patch written for another theme into one that claims\n // to belong to this one.\n updatedAt: now,\n },\n { merge: true },\n )\n\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'theme',\n from: previousVersion,\n to: listing.latestVersion,\n })\n await listingRef\n .update({\n installCount: firebaseAdmin.firestore.FieldValue.increment(1),\n })\n .catch(() => undefined)\n\n return res.status(200).json({\n installed: true,\n version: listing.latestVersion ?? null,\n baseStored: provenance.baseStored,\n applied: describeTheme(theme),\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Theme install failed' })\n }\n}\n\nexport default installThemeHandler\n"],"names":["describeTheme","firebaseAdmin","resolveOrgPermissions","isPrivateListing","listingArtifactType","canActAsPublisher","requirePurchase","hasDivergedFromBase","recordInstallProvenance","recordVersionMove","installThemeHandler","req","res","method","status","json","error","hostId","String","body","action","includes","listingId","authorization","headers","idToken","startsWith","slice","length","undefined","hostSnapshot","listing","membership","decoded","app","auth","verifyIdToken","uid","permissions","installPlugins","firestore","hostRef","collection","doc","get","exists","memberRole","now","FieldValue","serverTimestamp","currentTheme","set","themeReplaced","theme","replacedAt","delete","themeInstalledFrom","themeOverride","updatedAt","merge","reset","cleared","replaced","installedFrom","override","reverted","listingRef","listingSnapshot","data","deletedAt","hiddenAt","priceUsd","Number","ownsListing","profileId","unpaid","buyerUid","buyerOrgId","orgId","versionSnapshot","latestVersion","Object","keys","incoming","current","replaces","Boolean","version","diverged","sha256","mode","provenance","artifactType","content","previousVersion","from","to","update","installCount","increment","catch","installed","baseStored","applied","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install-theme.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 { describeTheme } from '@aglyn/aglyn/app-utils/marketplace-theme'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n isPrivateListing,\n listingArtifactType,\n} from '../model/marketplace'\nimport { canActAsPublisher } from './publisher-profile'\nimport { requirePurchase } from './purchase-entitlement'\nimport { hasDivergedFromBase, recordInstallProvenance } from './provenance'\nimport { recordVersionMove } from './version-stats'\nimport { isPublisherSecurityLocked } from './sale-risk'\n\n/**\n * Installs a marketplace theme onto a site (AGL-1020).\n *\n * Themes are the exception to \"installing never changes a running site\". A\n * template or a layout can land inert in a library because it is one thing\n * among many; a theme IS the site's appearance, and installing one into a\n * library where it does nothing would be a control that appears to work and\n * does not. So this writes `hosts/{hostId}.theme` and the site repaints.\n *\n * Which makes reversibility the requirement, not a nicety. Two things provide\n * it, and neither is a confirmation dialog:\n *\n * * The theme being replaced is kept verbatim in `themeReplaced`, so `revert`\n * restores exactly what was there — including a hand-built theme that was\n * never a marketplace artifact and exists nowhere else.\n * * `reset` clears the theme entirely, which is the \"use the default theme\"\n * the issue asks to keep first-class. It is a distinct action from revert\n * because \"back to how it was\" and \"back to stock\" are different intents and\n * guessing between them is how people lose work.\n *\n * `preview` changes nothing and returns what the swap would do, so the\n * confirmation can name it rather than showing a JSON blob.\n */\nexport const installThemeHandler: 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 action = ['revert', 'reset', 'preview', 'clear-overrides'].includes(\n req.body?.action,\n )\n ? (req.body.action as\n | 'revert'\n | 'reset'\n | 'preview'\n | 'clear-overrides')\n : 'install'\n const listingId = String(req.body?.listingId ?? '')\n if (!hostId || (action === 'install' && !listingId)) {\n return res.status(400).json({ error: 'Missing listingId or hostId' })\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 membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!membership.permissions.installPlugins) {\n return res.status(403).json({\n error:\n 'Your organization role does not allow installing from the marketplace',\n })\n }\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n 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 const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n const currentTheme = hostSnapshot.get('theme') ?? null\n\n // ---- reset: back to the platform default ----\n if (action === 'reset') {\n await hostRef.set(\n {\n // Kept, not dropped: resetting is reversible for the same reason\n // installing is, and someone who resets by mistake has otherwise\n // lost a theme that existed in no other document.\n themeReplaced: { theme: currentTheme, replacedAt: now },\n theme: firebaseAdmin.firestore.FieldValue.delete(),\n themeInstalledFrom: firebaseAdmin.firestore.FieldValue.delete(),\n themeOverride: firebaseAdmin.firestore.FieldValue.delete(),\n updatedAt: now,\n },\n { merge: true },\n )\n return res.status(200).json({ reset: true })\n }\n\n // ---- clear-overrides: drop this site's changes, keep the theme ----\n // The third way back, and the narrowest: \"reset to the publisher's\n // version\" is deleting the patch — the whole point of owning the patch\n // rather than the copy (AGL-1019). The theme itself is untouched.\n if (action === 'clear-overrides') {\n await hostRef.set(\n {\n themeOverride: firebaseAdmin.firestore.FieldValue.delete(),\n updatedAt: now,\n },\n { merge: true },\n )\n return res.status(200).json({ cleared: true })\n }\n\n // ---- revert: back to whatever this site had before the last swap ----\n if (action === 'revert') {\n const replaced = hostSnapshot.get('themeReplaced')\n if (!replaced) {\n return res.status(409).json({\n error: 'There is no previous theme to go back to on this site.',\n })\n }\n await hostRef.set(\n {\n theme: replaced.theme ?? firebaseAdmin.firestore.FieldValue.delete(),\n ...(replaced.installedFrom\n ? { themeInstalledFrom: replaced.installedFrom }\n : { themeInstalledFrom: firebaseAdmin.firestore.FieldValue.delete() }),\n // The overrides that were live alongside that theme come back with\n // it. Reverting the base and keeping a patch authored against the\n // theme being reverted FROM is the one combination nobody asked for.\n ...(replaced.override\n ? { themeOverride: replaced.override }\n : { themeOverride: firebaseAdmin.firestore.FieldValue.delete() }),\n themeReplaced: firebaseAdmin.firestore.FieldValue.delete(),\n updatedAt: now,\n },\n { merge: true },\n )\n return res.status(200).json({ reverted: true })\n }\n\n const listingRef = firestore.collection('marketplaceListings').doc(listingId)\n const listingSnapshot = await listingRef.get()\n const listing = listingSnapshot.data() as any\n if (\n !listing ||\n listing.deletedAt ||\n // Staff takedown blocks new installs on EVERY artifact type\n // (AGL-2290). AGL-948 extended takedown past plugins in the browse\n // predicate and in `resolveMarketplacePluginVersion`, but the gate that\n // decides whether content is HANDED OVER was only ever added to\n // `install-plugin.ts`. So a component, theme, template, layout, email\n // template or dataset schema that staff had taken down stayed\n // installable by anyone holding its listing id — which makes takedown a\n // suggestion for six of the seven artifact types.\n //\n // No owner exemption, matching `install-plugin.ts`: a takedown is a\n // moderation decision about the artifact, not about who is asking.\n listing.hiddenAt ||\n // A publisher under a SECURITY lock hands nothing over (AGL-3365).\n (await isPublisherSecurityLocked(firestore, listing.profileId)) ||\n listingArtifactType(listing) !== 'theme'\n ) {\n return res.status(404).json({ error: 'Unknown theme' })\n }\n\n const priceUsd = Number(listing.priceUsd ?? 0)\n const ownsListing = await canActAsPublisher(\n firestore,\n decoded.uid,\n listing.profileId,\n )\n // Private listings install ONLY for the owning org (AGL-2290).\n //\n // `install-plugin.ts` has carried this since AGL-968; the other six never\n // did, so a private component, theme, template, layout, email template or\n // dataset schema was installable by anyone who knew its listing id. Browse\n // hides them and the detail page 404s, but neither is a control — the\n // route is.\n if (isPrivateListing(listing) && !ownsListing) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // A FULLY refunded purchase stops entitling (AGL-1546), and until\n // AGL-1699 only the component route knew that: this one asked whether a\n // purchase doc EXISTED, so buy/install/refund kept the artifact. The\n // predicate lives in one place now so the next route cannot miss it.\n const unpaid = await requirePurchase({\n firestore,\n buyerUid: decoded.uid,\n // THE ORG THE LICENCE HAS TO COVER (AGL-2331). `membership.orgId` is\n // resolved server-side from the caller's own membership by the\n // permission gate above — never a request-body field — so this is the\n // workspace the install actually lands in, and the only one a purchase\n // can entitle here.\n buyerOrgId: membership.orgId ?? '',\n listingId,\n priceUsd,\n ownsListing,\n })\n if (unpaid) return res.status(402).json(unpaid)\n\n const versionSnapshot = await listingRef\n .collection('versions')\n .doc(String(listing.latestVersion))\n .get()\n const theme = versionSnapshot.get('theme')\n if (!theme || !Object.keys(theme).length) {\n return res.status(500).json({ error: 'Theme version missing' })\n }\n\n // ---- preview: say what the swap changes, change nothing ----\n if (action === 'preview') {\n return res.status(200).json({\n incoming: describeTheme(theme),\n current: describeTheme(currentTheme),\n // A site with no theme is on the platform default, so there is nothing\n // to lose and the confirmation should not imply there is.\n replaces: Boolean(currentTheme && Object.keys(currentTheme).length),\n version: listing.latestVersion ?? null,\n })\n }\n\n // ---- install ----\n // Replacing a theme the workspace has edited destroys those edits (AGL-1018).\n // Unlike a component this is not recoverable by looking in a library — the\n // theme lives on one field on one document — so the refusal matters more.\n const diverged = await hasDivergedFromBase({\n firestore,\n sha256: hostSnapshot.get('themeInstalledFrom.sha256'),\n current: currentTheme,\n })\n if (diverged && req.body?.mode !== 'replace') {\n return res.status(409).json({\n error:\n 'This site’s theme has been edited since it was installed. Review ' +\n 'the update to see what would change.',\n diverged: true,\n })\n }\n\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: 'theme',\n content: theme,\n })\n const previousVersion =\n hostSnapshot.get('themeInstalledFrom.version') ?? null\n\n await hostRef.set(\n {\n theme,\n themeInstalledFrom: provenance.installedFrom,\n // Everything needed to put this site back exactly as it was, captured\n // BEFORE the write that changes it.\n themeReplaced: {\n theme: currentTheme,\n ...(hostSnapshot.get('themeInstalledFrom')\n ? { installedFrom: hostSnapshot.get('themeInstalledFrom') }\n : {}),\n ...(hostSnapshot.get('themeOverride')\n ? { override: hostSnapshot.get('themeOverride') }\n : {}),\n replacedAt: now,\n },\n // Overrides SURVIVE a theme swap (AGL-1021). A site that set its brand\n // colour still means it under a new theme, and silently discarding\n // that on install would be the more surprising of the two options.\n //\n // What must not happen is surviving *silently*: the override keeps the\n // `baseSha256` it was authored against, which no longer matches the\n // theme now installed, so `isOverrideForCurrentTheme` is false and the\n // editor says so with an offer to clear them. Rewriting that hash here\n // would launder a patch written for another theme into one that claims\n // to belong to this one.\n updatedAt: now,\n },\n { merge: true },\n )\n\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'theme',\n from: previousVersion,\n to: listing.latestVersion,\n })\n await listingRef\n .update({\n installCount: firebaseAdmin.firestore.FieldValue.increment(1),\n })\n .catch(() => undefined)\n\n return res.status(200).json({\n installed: true,\n version: listing.latestVersion ?? null,\n baseStored: provenance.baseStored,\n applied: describeTheme(theme),\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Theme install failed' })\n }\n}\n\nexport default installThemeHandler\n"],"names":["describeTheme","firebaseAdmin","resolveOrgPermissions","isPrivateListing","listingArtifactType","canActAsPublisher","requirePurchase","hasDivergedFromBase","recordInstallProvenance","recordVersionMove","isPublisherSecurityLocked","installThemeHandler","req","res","method","status","json","error","hostId","String","body","action","includes","listingId","authorization","headers","idToken","startsWith","slice","length","undefined","hostSnapshot","listing","membership","decoded","app","auth","verifyIdToken","uid","permissions","installPlugins","firestore","hostRef","collection","doc","get","exists","memberRole","now","FieldValue","serverTimestamp","currentTheme","set","themeReplaced","theme","replacedAt","delete","themeInstalledFrom","themeOverride","updatedAt","merge","reset","cleared","replaced","installedFrom","override","reverted","listingRef","listingSnapshot","data","deletedAt","hiddenAt","profileId","priceUsd","Number","ownsListing","unpaid","buyerUid","buyerOrgId","orgId","versionSnapshot","latestVersion","Object","keys","incoming","current","replaces","Boolean","version","diverged","sha256","mode","provenance","artifactType","content","previousVersion","from","to","update","installCount","increment","catch","installed","baseStored","applied","console"],"mappings":"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"}
@@ -27,6 +27,7 @@ import { canActAsPublisher } from "./publisher-profile.js";
27
27
  import { hasDivergedFromBase, recordInstallProvenance } from "./provenance.js";
28
28
  import { requirePurchase } from "./purchase-entitlement.js";
29
29
  import { recordVersionMove } from "./version-stats.js";
30
+ import { isPublisherSecurityLocked } from "./sale-risk.js";
30
31
  /**
31
32
  * Installs (or updates) a marketplace listing into a host (AGL-44/46).
32
33
  * Server-side because version snapshots are not client-readable — paid
@@ -125,7 +126,8 @@ import { recordVersionMove } from "./version-stats.js";
125
126
  //
126
127
  // No owner exemption, matching `install-plugin.ts`: a takedown is a
127
128
  // moderation decision about the artifact, not about who is asking.
128
- listing.hiddenAt) {
129
+ listing.hiddenAt || // A publisher under a SECURITY lock hands nothing over (AGL-3365).
130
+ await isPublisherSecurityLocked(firestore, listing.profileId)) {
129
131
  return res.status(404).json({
130
132
  error: 'Unknown listing'
131
133
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The leaf module, not the server barrel: the list keys every artifact\n// create stamps (AGL-3321), which the install specs' closed-world barrel\n// mocks have no reason to stage.\nimport { artifactCreateListKeys } from '@aglyn/aglyn/app-utils/artifact-list-keys'\nimport {\n checkEntitlement,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { isPrivateListing } from '../model/marketplace'\nimport { readPublishedProps } from '../model/marketplace-props'\nimport { canActAsPublisher } from './publisher-profile'\nimport { hasDivergedFromBase, recordInstallProvenance } from './provenance'\nimport { requirePurchase } from './purchase-entitlement'\nimport { recordVersionMove } from './version-stats'\n\n/**\n * Installs (or updates) a marketplace listing into a host (AGL-44/46).\n * Server-side because version snapshots are not client-readable — paid\n * content would otherwise be free to anyone who read the subcollection.\n * Access: the listing is free, the caller bought it (webhook-written\n * purchase record), or the caller published it. Installing copies the\n * latest version into `hosts/{hostId}/components` with `marketplace` source\n * metadata; re-installing updates the same component doc in place.\n */\nexport const installHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const listingId = String(req.body?.listingId ?? '')\n const hostId = String(req.body?.hostId ?? '')\n if (!listingId || !hostId) {\n return res.status(400).json({ error: 'Missing listingId or hostId' })\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.installPlugins) {\n return res.status(403).json({\n error: 'Your organization role does not allow installing from the marketplace',\n })\n }\n const firestore = firebaseAdmin.app().firestore()\n\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n 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 // The SAME gate the console's own create path applies to this exact\n // document (AGL-2072). `/api/hosts/resources` declares\n // `reusableComponent: { collection: 'components', entitlement:\n // 'reusableComponents' }` and says why: a reusable component renders on\n // the LIVE SITE, so the Starter+ gate has to be server-enforced rather\n // than merely hidden in the console (AGL-473). This route writes a\n // byte-for-byte equivalent doc into `hosts/{hostId}/components` and asked\n // nothing — so a free org installed from the marketplace and got the\n // working feature its own console would have refused.\n //\n // The assumption that hid it is written down at\n // `apps/console/app/api/hosts/import/route.ts:329-331`:\n // \"`reusableComponents` is a BOOLEAN entitlement, true on every plan that\n // can reach here\". True of import, which is Pro+. False here — any plan\n // reaches the marketplace, and free listings cost nothing to install.\n //\n // Ahead of `requirePurchase`, deliberately, and matching\n // `install-dataset-schema.ts:89`: refusing on plan AFTER taking someone's\n // money for the listing would be the worse order. The whole org doc is\n // read (`getOrgDoc` projects nothing), so `resolveEffectivePlan` sees the\n // subscription status and a LAPSED org is refused exactly as a free one\n // is — the gate is re-asked per install, never inherited from whatever\n // plan was in force when an earlier copy was installed.\n const org = (await getOrgForHost(hostId))?.org\n if (!checkEntitlement(org as any, 'reusableComponents')) {\n return res.status(403).json({\n error: 'Reusable components require a Starter plan or higher',\n })\n }\n\n const listingRef = firestore\n .collection('marketplaceListings')\n .doc(listingId)\n const listingSnapshot = await listingRef.get()\n const listing = listingSnapshot.data() as any\n if (\n !listing ||\n listing.deletedAt ||\n // Staff takedown blocks new installs on EVERY artifact type\n // (AGL-2290). AGL-948 extended takedown past plugins in the browse\n // predicate and in `resolveMarketplacePluginVersion`, but the gate that\n // decides whether content is HANDED OVER was only ever added to\n // `install-plugin.ts`. So a component, theme, template, layout, email\n // template or dataset schema that staff had taken down stayed\n // installable by anyone holding its listing id — which makes takedown a\n // suggestion for six of the seven artifact types.\n //\n // No owner exemption, matching `install-plugin.ts`: a takedown is a\n // moderation decision about the artifact, not about who is asking.\n listing.hiddenAt\n ) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n\n const priceUsd = Number(listing.priceUsd ?? 0)\n // The publisher installs their own listing for free. Org-owned now\n // (AGL-652), so this is a role check — comparing a uid to an org id\n // would never match and would charge publishers for their own work.\n const ownsListing = await canActAsPublisher(\n firestore,\n decoded.uid,\n listing.profileId,\n )\n // Private listings install ONLY for the owning org (AGL-2290).\n //\n // `install-plugin.ts` has carried this since AGL-968; the other six never\n // did, so a private component, theme, template, layout, email template or\n // dataset schema was installable by anyone who knew its listing id. Browse\n // hides them and the detail page 404s, but neither is a control — the\n // route is.\n if (isPrivateListing(listing) && !ownsListing) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // A FULLY refunded purchase no longer entitles (AGL-1546). The gate this\n // route once inlined is now shared (AGL-1699): it was the only one of\n // eight paid-content paths that had learned the refund rule.\n const unpaid = await requirePurchase({\n firestore,\n buyerUid: decoded.uid,\n // THE ORG THE LICENCE HAS TO COVER (AGL-2331). `membership.orgId` is\n // resolved server-side from the caller's own membership by the\n // permission gate above — never a request-body field — so this is the\n // workspace the install actually lands in, and the only one a purchase\n // can entitle here.\n buyerOrgId: membership.orgId ?? '',\n listingId,\n priceUsd,\n ownsListing,\n })\n if (unpaid) return res.status(402).json(unpaid)\n\n const versionSnapshot = await listingRef\n .collection('versions')\n .doc(String(listing.latestVersion))\n .get()\n const version = versionSnapshot.data() as any\n if (!version?.nodes || !version?.rootId) {\n return res.status(500).json({ error: 'Listing version missing' })\n }\n\n // The properties the tree binds to (AGL-2933), held to the sanitizer again\n // on the way in. `undefined` for a version published before they were\n // carried, which leaves a re-installed copy's own properties in place.\n const props = readPublishedProps(version.props)\n\n const componentsRef = hostRef.collection('components')\n const existing = await componentsRef\n .where('marketplace.listingId', '==', listingId)\n .limit(1)\n .get()\n const componentRef = existing.empty\n ? componentsRef.doc(createResourceUid())\n : existing.docs[0].ref\n // Re-installing over a copy the workspace has EDITED is the silent\n // destructive overwrite AGL-1018 exists to stop. The install still happens\n // when it is asked for explicitly (`mode: 'replace'`, which the update\n // dialog sends after showing what goes), but it is never the default.\n if (!existing.empty) {\n const diverged = await hasDivergedFromBase({\n firestore,\n sha256: existing.docs[0].get('installedFrom.sha256'),\n current: {\n rootId: existing.docs[0].get('rootId'),\n // DECODED, because the comparison is by value: the base snapshot\n // recorded at install is a node map and `hasDivergedFromBase`\n // hashes a stable stringification of both sides. A stored `Bytes`\n // matches no map, so an untouched copy would read as edited and\n // every re-install would 409 (AGL-1151).\n nodes: decodeStoredNodes(existing.docs[0].get('nodes')),\n },\n })\n if (diverged && req.body?.mode !== 'replace') {\n return res.status(409).json({\n error:\n 'Your copy of this component has been edited since it was ' +\n 'installed. Review the update to see what would change.',\n diverged: true,\n })\n }\n }\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n // Provenance + base snapshot (AGL-1015). The snapshot holds only the\n // vendored tree — the display name and description come from the listing\n // and the user may rename their copy, so including them would make every\n // rename read as a diverged component.\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: 'component',\n content: { rootId: version.rootId, nodes: version.nodes },\n })\n await componentRef.set(\n {\n displayName: listing.displayName,\n ...(listing.description && { description: listing.description }),\n rootId: version.rootId,\n // Compressed at rest, matching what a promote writes onto this same\n // document (AGL-1151). The provenance base above keeps the decoded\n // map: it is compared by value, never rendered.\n nodes: Buffer.from(encodeStoredNodes(version.nodes ?? {})!),\n // Beside the tree, as a publish writes them onto this document. The\n // first version the console mints for this component copies them\n // from here (AGL-2932).\n ...(props && { props }),\n deletedAt: null,\n // The keys the components list finds it by (AGL-3321): the listing's\n // name, and — on a first install — the stored kind; a re-install\n // keeps the kind the copy already has.\n ...artifactCreateListKeys('components', {\n displayName: listing.displayName,\n kind: existing.empty ? undefined : existing.docs[0].get('kind'),\n }),\n marketplace: {\n listingId,\n profileId: listing.profileId,\n version: listing.latestVersion,\n },\n installedFrom: provenance.installedFrom,\n ...(existing.empty && { createdAt: now }),\n updatedAt: now,\n },\n { merge: true },\n )\n // Per-version tally (AGL-1036): a re-install moves this copy off whatever\n // version it was on and onto the latest.\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'component',\n from: existing.empty\n ? null\n : (existing.docs[0].get('installedFrom.version') ??\n existing.docs[0].get('marketplace.version')),\n to: listing.latestVersion,\n })\n // First installs count toward the browse \"Most installed\" sort\n // (AGL-95); updates don't inflate it.\n if (existing.empty) {\n await listingRef\n .update({\n installCount: firebaseAdmin.firestore.FieldValue.increment(1),\n })\n .catch(() => undefined)\n }\n return res.status(200).json({\n installed: true,\n updated: !existing.empty,\n version: listing.latestVersion,\n baseStored: provenance.baseStored,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Install failed' })\n }\n}\n"],"names":["artifactCreateListKeys","checkEntitlement","createResourceUid","decodeStoredNodes","encodeStoredNodes","firebaseAdmin","getOrgForHost","resolveOrgPermissions","isPrivateListing","readPublishedProps","canActAsPublisher","hasDivergedFromBase","recordInstallProvenance","requirePurchase","recordVersionMove","installHandler","req","res","method","status","json","error","listingId","String","body","hostId","authorization","headers","idToken","startsWith","slice","length","undefined","hostSnapshot","listing","membership","version","existing","decoded","app","auth","verifyIdToken","uid","permissions","installPlugins","firestore","hostRef","collection","doc","get","exists","memberRole","org","listingRef","listingSnapshot","data","deletedAt","hiddenAt","priceUsd","Number","ownsListing","profileId","unpaid","buyerUid","buyerOrgId","orgId","versionSnapshot","latestVersion","nodes","rootId","props","componentsRef","where","limit","componentRef","empty","docs","ref","diverged","sha256","current","mode","now","FieldValue","serverTimestamp","provenance","artifactType","content","set","displayName","description","Buffer","from","kind","marketplace","installedFrom","createdAt","updatedAt","merge","to","update","installCount","increment","catch","installed","updated","baseStored","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The leaf module, not the server barrel: the list keys every artifact\n// create stamps (AGL-3321), which the install specs' closed-world barrel\n// mocks have no reason to stage.\nimport { artifactCreateListKeys } from '@aglyn/aglyn/app-utils/artifact-list-keys'\nimport {\n checkEntitlement,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { isPrivateListing } from '../model/marketplace'\nimport { readPublishedProps } from '../model/marketplace-props'\nimport { canActAsPublisher } from './publisher-profile'\nimport { hasDivergedFromBase, recordInstallProvenance } from './provenance'\nimport { requirePurchase } from './purchase-entitlement'\nimport { recordVersionMove } from './version-stats'\nimport { isPublisherSecurityLocked } from './sale-risk'\n\n/**\n * Installs (or updates) a marketplace listing into a host (AGL-44/46).\n * Server-side because version snapshots are not client-readable — paid\n * content would otherwise be free to anyone who read the subcollection.\n * Access: the listing is free, the caller bought it (webhook-written\n * purchase record), or the caller published it. Installing copies the\n * latest version into `hosts/{hostId}/components` with `marketplace` source\n * metadata; re-installing updates the same component doc in place.\n */\nexport const installHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const listingId = String(req.body?.listingId ?? '')\n const hostId = String(req.body?.hostId ?? '')\n if (!listingId || !hostId) {\n return res.status(400).json({ error: 'Missing listingId or hostId' })\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.installPlugins) {\n return res.status(403).json({\n error: 'Your organization role does not allow installing from the marketplace',\n })\n }\n const firestore = firebaseAdmin.app().firestore()\n\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n 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 // The SAME gate the console's own create path applies to this exact\n // document (AGL-2072). `/api/hosts/resources` declares\n // `reusableComponent: { collection: 'components', entitlement:\n // 'reusableComponents' }` and says why: a reusable component renders on\n // the LIVE SITE, so the Starter+ gate has to be server-enforced rather\n // than merely hidden in the console (AGL-473). This route writes a\n // byte-for-byte equivalent doc into `hosts/{hostId}/components` and asked\n // nothing — so a free org installed from the marketplace and got the\n // working feature its own console would have refused.\n //\n // The assumption that hid it is written down at\n // `apps/console/app/api/hosts/import/route.ts:329-331`:\n // \"`reusableComponents` is a BOOLEAN entitlement, true on every plan that\n // can reach here\". True of import, which is Pro+. False here — any plan\n // reaches the marketplace, and free listings cost nothing to install.\n //\n // Ahead of `requirePurchase`, deliberately, and matching\n // `install-dataset-schema.ts:89`: refusing on plan AFTER taking someone's\n // money for the listing would be the worse order. The whole org doc is\n // read (`getOrgDoc` projects nothing), so `resolveEffectivePlan` sees the\n // subscription status and a LAPSED org is refused exactly as a free one\n // is — the gate is re-asked per install, never inherited from whatever\n // plan was in force when an earlier copy was installed.\n const org = (await getOrgForHost(hostId))?.org\n if (!checkEntitlement(org as any, 'reusableComponents')) {\n return res.status(403).json({\n error: 'Reusable components require a Starter plan or higher',\n })\n }\n\n const listingRef = firestore\n .collection('marketplaceListings')\n .doc(listingId)\n const listingSnapshot = await listingRef.get()\n const listing = listingSnapshot.data() as any\n if (\n !listing ||\n listing.deletedAt ||\n // Staff takedown blocks new installs on EVERY artifact type\n // (AGL-2290). AGL-948 extended takedown past plugins in the browse\n // predicate and in `resolveMarketplacePluginVersion`, but the gate that\n // decides whether content is HANDED OVER was only ever added to\n // `install-plugin.ts`. So a component, theme, template, layout, email\n // template or dataset schema that staff had taken down stayed\n // installable by anyone holding its listing id — which makes takedown a\n // suggestion for six of the seven artifact types.\n //\n // No owner exemption, matching `install-plugin.ts`: a takedown is a\n // moderation decision about the artifact, not about who is asking.\n listing.hiddenAt ||\n // A publisher under a SECURITY lock hands nothing over (AGL-3365).\n (await isPublisherSecurityLocked(firestore, listing.profileId))\n ) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n\n const priceUsd = Number(listing.priceUsd ?? 0)\n // The publisher installs their own listing for free. Org-owned now\n // (AGL-652), so this is a role check — comparing a uid to an org id\n // would never match and would charge publishers for their own work.\n const ownsListing = await canActAsPublisher(\n firestore,\n decoded.uid,\n listing.profileId,\n )\n // Private listings install ONLY for the owning org (AGL-2290).\n //\n // `install-plugin.ts` has carried this since AGL-968; the other six never\n // did, so a private component, theme, template, layout, email template or\n // dataset schema was installable by anyone who knew its listing id. Browse\n // hides them and the detail page 404s, but neither is a control — the\n // route is.\n if (isPrivateListing(listing) && !ownsListing) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // A FULLY refunded purchase no longer entitles (AGL-1546). The gate this\n // route once inlined is now shared (AGL-1699): it was the only one of\n // eight paid-content paths that had learned the refund rule.\n const unpaid = await requirePurchase({\n firestore,\n buyerUid: decoded.uid,\n // THE ORG THE LICENCE HAS TO COVER (AGL-2331). `membership.orgId` is\n // resolved server-side from the caller's own membership by the\n // permission gate above — never a request-body field — so this is the\n // workspace the install actually lands in, and the only one a purchase\n // can entitle here.\n buyerOrgId: membership.orgId ?? '',\n listingId,\n priceUsd,\n ownsListing,\n })\n if (unpaid) return res.status(402).json(unpaid)\n\n const versionSnapshot = await listingRef\n .collection('versions')\n .doc(String(listing.latestVersion))\n .get()\n const version = versionSnapshot.data() as any\n if (!version?.nodes || !version?.rootId) {\n return res.status(500).json({ error: 'Listing version missing' })\n }\n\n // The properties the tree binds to (AGL-2933), held to the sanitizer again\n // on the way in. `undefined` for a version published before they were\n // carried, which leaves a re-installed copy's own properties in place.\n const props = readPublishedProps(version.props)\n\n const componentsRef = hostRef.collection('components')\n const existing = await componentsRef\n .where('marketplace.listingId', '==', listingId)\n .limit(1)\n .get()\n const componentRef = existing.empty\n ? componentsRef.doc(createResourceUid())\n : existing.docs[0].ref\n // Re-installing over a copy the workspace has EDITED is the silent\n // destructive overwrite AGL-1018 exists to stop. The install still happens\n // when it is asked for explicitly (`mode: 'replace'`, which the update\n // dialog sends after showing what goes), but it is never the default.\n if (!existing.empty) {\n const diverged = await hasDivergedFromBase({\n firestore,\n sha256: existing.docs[0].get('installedFrom.sha256'),\n current: {\n rootId: existing.docs[0].get('rootId'),\n // DECODED, because the comparison is by value: the base snapshot\n // recorded at install is a node map and `hasDivergedFromBase`\n // hashes a stable stringification of both sides. A stored `Bytes`\n // matches no map, so an untouched copy would read as edited and\n // every re-install would 409 (AGL-1151).\n nodes: decodeStoredNodes(existing.docs[0].get('nodes')),\n },\n })\n if (diverged && req.body?.mode !== 'replace') {\n return res.status(409).json({\n error:\n 'Your copy of this component has been edited since it was ' +\n 'installed. Review the update to see what would change.',\n diverged: true,\n })\n }\n }\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n // Provenance + base snapshot (AGL-1015). The snapshot holds only the\n // vendored tree — the display name and description come from the listing\n // and the user may rename their copy, so including them would make every\n // rename read as a diverged component.\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: 'component',\n content: { rootId: version.rootId, nodes: version.nodes },\n })\n await componentRef.set(\n {\n displayName: listing.displayName,\n ...(listing.description && { description: listing.description }),\n rootId: version.rootId,\n // Compressed at rest, matching what a promote writes onto this same\n // document (AGL-1151). The provenance base above keeps the decoded\n // map: it is compared by value, never rendered.\n nodes: Buffer.from(encodeStoredNodes(version.nodes ?? {})!),\n // Beside the tree, as a publish writes them onto this document. The\n // first version the console mints for this component copies them\n // from here (AGL-2932).\n ...(props && { props }),\n deletedAt: null,\n // The keys the components list finds it by (AGL-3321): the listing's\n // name, and — on a first install — the stored kind; a re-install\n // keeps the kind the copy already has.\n ...artifactCreateListKeys('components', {\n displayName: listing.displayName,\n kind: existing.empty ? undefined : existing.docs[0].get('kind'),\n }),\n marketplace: {\n listingId,\n profileId: listing.profileId,\n version: listing.latestVersion,\n },\n installedFrom: provenance.installedFrom,\n ...(existing.empty && { createdAt: now }),\n updatedAt: now,\n },\n { merge: true },\n )\n // Per-version tally (AGL-1036): a re-install moves this copy off whatever\n // version it was on and onto the latest.\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'component',\n from: existing.empty\n ? null\n : (existing.docs[0].get('installedFrom.version') ??\n existing.docs[0].get('marketplace.version')),\n to: listing.latestVersion,\n })\n // First installs count toward the browse \"Most installed\" sort\n // (AGL-95); updates don't inflate it.\n if (existing.empty) {\n await listingRef\n .update({\n installCount: firebaseAdmin.firestore.FieldValue.increment(1),\n })\n .catch(() => undefined)\n }\n return res.status(200).json({\n installed: true,\n updated: !existing.empty,\n version: listing.latestVersion,\n baseStored: provenance.baseStored,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Install failed' })\n }\n}\n"],"names":["artifactCreateListKeys","checkEntitlement","createResourceUid","decodeStoredNodes","encodeStoredNodes","firebaseAdmin","getOrgForHost","resolveOrgPermissions","isPrivateListing","readPublishedProps","canActAsPublisher","hasDivergedFromBase","recordInstallProvenance","requirePurchase","recordVersionMove","isPublisherSecurityLocked","installHandler","req","res","method","status","json","error","listingId","String","body","hostId","authorization","headers","idToken","startsWith","slice","length","undefined","hostSnapshot","listing","membership","version","existing","decoded","app","auth","verifyIdToken","uid","permissions","installPlugins","firestore","hostRef","collection","doc","get","exists","memberRole","org","listingRef","listingSnapshot","data","deletedAt","hiddenAt","profileId","priceUsd","Number","ownsListing","unpaid","buyerUid","buyerOrgId","orgId","versionSnapshot","latestVersion","nodes","rootId","props","componentsRef","where","limit","componentRef","empty","docs","ref","diverged","sha256","current","mode","now","FieldValue","serverTimestamp","provenance","artifactType","content","set","displayName","description","Buffer","from","kind","marketplace","installedFrom","createdAt","updatedAt","merge","to","update","installCount","increment","catch","installed","updated","baseStored","console"],"mappings":"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"}
@@ -0,0 +1,83 @@
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 PhishingScreenSignal } from '@aglyn/shared-util-email/outbound-phishing-screen';
18
+ import type { PublishRefusal } from './publish-preconditions';
19
+ /** What a submission says, as a stranger will read it. */
20
+ export interface ListingSubmissionContent {
21
+ displayName?: string | null;
22
+ /** The publisher's display name, read as the sender. */
23
+ publisherName?: string | null;
24
+ description?: string | null;
25
+ readme?: string | null;
26
+ changelog?: string | null;
27
+ /** Homepage, repository, support links. */
28
+ urls?: readonly (string | null | undefined)[];
29
+ /** Nodes that will be rendered as a page (template, layout, component). */
30
+ pageNodes?: unknown;
31
+ /** Nodes that will be sent as an email (email template, starter). */
32
+ emailNodes?: unknown;
33
+ }
34
+ export interface ListingScreenInput extends ListingSubmissionContent {
35
+ /** The publishing workspace's own names. */
36
+ ownNames?: readonly (string | null | undefined)[];
37
+ /** The actor is the platform's staff — the one owner of its brand. */
38
+ official?: boolean;
39
+ }
40
+ export interface ListingScreenFindings {
41
+ /** A name rule the submission breaks — refused, never held. */
42
+ impersonation: string | null;
43
+ /** Every signal the screen found, before the tiers. */
44
+ signals: PhishingScreenSignal[];
45
+ }
46
+ /**
47
+ * The refusal for a publisher NAME or HANDLE, or null (AGL-3365): it wears
48
+ * the platform's own name, or claims to speak for any brand, or — the handle
49
+ * being a URL segment, like a subdomain — has a brand lookalike's shape.
50
+ */
51
+ export declare function publisherIdentityImpersonation(input: {
52
+ displayName?: string | null;
53
+ handle?: string | null;
54
+ official?: boolean;
55
+ }): string | null;
56
+ /** Screen one submission. Pure. */
57
+ export declare function screenListingSubmission(input: ListingScreenInput): ListingScreenFindings;
58
+ /**
59
+ * Tell the publisher's owners and admins a submission is held (AGL-3365). The
60
+ * outcome and the way forward only — which rule held it stays with staff.
61
+ * Staff hear from `fileOutboundHold`, the same alert a held email or page
62
+ * raises. Never throws.
63
+ */
64
+ export declare function notifyPublisherSubmissionHeld(publisherOrgId: string, held: {
65
+ title: string;
66
+ reference: string;
67
+ }): Promise<void>;
68
+ /** The review row a publisher's held submission files, keyed on the publisher and the signals. */
69
+ export declare function listingReviewId(publisherOrgId: string, signals: readonly PhishingScreenSignal[]): string;
70
+ /**
71
+ * The whole gate for a marketplace submission: the refusal to send, or null
72
+ * when it may be published. Every publish door and the listing edit call it
73
+ * with what they are about to store (the sweep in
74
+ * `listing-screen-doors.spec.ts` keeps it that way).
75
+ */
76
+ export declare function listingSubmissionRefusal(input: {
77
+ publisherOrgId: string;
78
+ content: ListingSubmissionContent;
79
+ official?: boolean;
80
+ /** The publishing org's document, when the caller already holds it. */
81
+ org?: Record<string, unknown> | null;
82
+ nowMs?: number;
83
+ }): Promise<PublishRefusal | null>;
@@ -0,0 +1,244 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*==========================================
18
+ * THE PHISHING SCREEN, AT MARKETPLACE SUBMISSION (AGL-3365).
19
+ *
20
+ * A listing is content a stranger reads and trusts — the marketplace is the
21
+ * platform vouching for it — so it goes through the same screen as a
22
+ * tenant's email and published page, with the same brand list, the same
23
+ * lookalike rule and the same tiers (`signalsThatHold`), and it is held
24
+ * BEFORE it is published rather than found afterwards:
25
+ *
26
+ * - its title, description, readme, changelog and links are read as a
27
+ * message (`screenOutboundEmail`), with the publisher's display name as
28
+ * the sender — a listing "from" PayPal is mail from PayPal;
29
+ * - the nodes a template, layout, component or email template carries are
30
+ * read as the page they will become (`screenHostedPage`), so a phishing
31
+ * template never reaches a buyer's site through the catalog.
32
+ *
33
+ * What holds files the SAME abuse-queue row a held email or page files
34
+ * (`fileOutboundHold`, `heldSend.kind: 'listing'`), and the submission is
35
+ * refused with its reference. Staff dismiss the row to release it — the
36
+ * publisher submits again and it goes through, since the row is keyed on the
37
+ * publisher and the signals — or mark it actioned to reject it.
38
+ *
39
+ * Two NAME rules refuse outright rather than hold, because nothing is lost by
40
+ * asking an honest publisher to rename:
41
+ *
42
+ * - a listing title or publisher name that claims to SPEAK FOR a brand
43
+ * (`brandClaimedByName`: "PayPal Support", "Official Aglyn Plugins");
44
+ * - a publisher display name or handle that wears the platform's own name at
45
+ * all. A workspace owns any other brand by carrying its name; the
46
+ * platform's is owned only by the platform's staff.
47
+ *
48
+ * Pure first: a clean submission costs no read.
49
+ *=========================================*/ import { screenHostedPage } from "@aglyn/shared-util-email/hosted-page-screen";
50
+ import { ABUSE_REPORT_COLLECTION } from "@aglyn/aglyn/app-utils/abuse-report";
51
+ import { brandClaimedByName, brandForSubdomainLabel, phishingSignalTier, platformPhishingBrand, screenOutboundEmail, signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
52
+ import { firebaseAdmin, notifyOrgAdmins } from "@aglyn/tenant-data-admin";
53
+ import { orgAgeDays } from "@aglyn/tenant-data-admin/server/org-age";
54
+ import { canonicalJson, fileOutboundHold, flaggedHostOf, heldOutboundReference, heldOutboundReviewId, outboundContentHash } from "@aglyn/tenant-data-admin/server/outbound-send-review";
55
+ /** Names that mention the platform are not "own" unless the actor is its staff. */ function ownNamesFor(input) {
56
+ var _input_ownNames;
57
+ const platform = platformPhishingBrand();
58
+ return ((_input_ownNames = input.ownNames) != null ? _input_ownNames : []).filter((name)=>typeof name === 'string' && name.length > 0).filter((name)=>input.official === true || !platform.mention.test(name));
59
+ }
60
+ /**
61
+ * The refusal for a publisher NAME or HANDLE, or null (AGL-3365): it wears
62
+ * the platform's own name, or claims to speak for any brand, or — the handle
63
+ * being a URL segment, like a subdomain — has a brand lookalike's shape.
64
+ */ export function publisherIdentityImpersonation(input) {
65
+ var _input_displayName, _input_handle;
66
+ if (input.official === true) return null;
67
+ const platform = platformPhishingBrand();
68
+ const name = String((_input_displayName = input.displayName) != null ? _input_displayName : '');
69
+ const handle = String((_input_handle = input.handle) != null ? _input_handle : '').toLowerCase();
70
+ if (platform.mention.test(name) || platform.hostTokens.some((token)=>handle.includes(token))) {
71
+ return `A publisher name or handle cannot use "${platform.label}" — the marketplace would ` + `present it as ${platform.label}'s own. Choose a name of your own.`;
72
+ }
73
+ const claimed = brandClaimedByName(name);
74
+ if (claimed) {
75
+ return `"${name}" reads as ${claimed.label} itself. Choose a name of your own.`;
76
+ }
77
+ const lookalike = handle ? brandForSubdomainLabel(handle) : null;
78
+ if (lookalike) {
79
+ return `The handle "${handle}" is shaped like ${lookalike.label}'s. Choose a handle of your own.`;
80
+ }
81
+ return null;
82
+ }
83
+ /** Screen one submission. Pure. */ export function screenListingSubmission(input) {
84
+ var _input_displayName, _input_publisherName, _input_urls;
85
+ const title = String((_input_displayName = input.displayName) != null ? _input_displayName : '');
86
+ const claimed = input.official === true ? null : brandClaimedByName(title);
87
+ const impersonation = claimed ? `A listing titled "${title}" reads as ${claimed.label} itself. Name what it does instead ` + `(for example "… for ${claimed.label}").` : null;
88
+ const ownNames = ownNamesFor(input);
89
+ const email = screenOutboundEmail({
90
+ subject: title,
91
+ fromName: (_input_publisherName = input.publisherName) != null ? _input_publisherName : null,
92
+ bodies: [
93
+ input.description,
94
+ input.readme,
95
+ input.changelog,
96
+ ...(_input_urls = input.urls) != null ? _input_urls : [],
97
+ input.emailNodes === undefined ? null : JSON.stringify(input.emailNodes)
98
+ ],
99
+ ownNames
100
+ });
101
+ const page = input.pageNodes === undefined ? {
102
+ signals: []
103
+ } : screenHostedPage({
104
+ nodes: input.pageNodes,
105
+ ownNames
106
+ });
107
+ const seen = new Set();
108
+ const signals = [
109
+ ...email.signals,
110
+ ...page.signals
111
+ ].filter((signal)=>{
112
+ const key = canonicalJson(signal);
113
+ if (seen.has(key)) return false;
114
+ seen.add(key);
115
+ return true;
116
+ });
117
+ return {
118
+ impersonation,
119
+ signals
120
+ };
121
+ }
122
+ /**
123
+ * Tell the publisher's owners and admins a submission is held (AGL-3365). The
124
+ * outcome and the way forward only — which rule held it stays with staff.
125
+ * Staff hear from `fileOutboundHold`, the same alert a held email or page
126
+ * raises. Never throws.
127
+ */ export async function notifyPublisherSubmissionHeld(publisherOrgId, held) {
128
+ await notifyOrgAdmins(publisherOrgId, {
129
+ type: 'marketplace.review',
130
+ title: 'A marketplace submission is held for review',
131
+ body: `"${held.title}" was not listed yet: it is waiting for a review by our team. If it is ` + 'released, submit it again and it will go through. To ask about it, contact support ' + `with reference ${held.reference}.`,
132
+ link: '/manage/marketplace'
133
+ }).catch(()=>undefined);
134
+ }
135
+ /** The review row a publisher's held submission files, keyed on the publisher and the signals. */ export function listingReviewId(publisherOrgId, signals) {
136
+ const key = signals.map((signal)=>canonicalJson(signal)).sort().join('\n');
137
+ return heldOutboundReviewId(`publisherProfiles/${publisherOrgId}/submissions`, outboundContentHash([
138
+ key
139
+ ]));
140
+ }
141
+ /**
142
+ * The whole gate for a marketplace submission: the refusal to send, or null
143
+ * when it may be published. Every publish door and the listing edit call it
144
+ * with what they are about to store (the sweep in
145
+ * `listing-screen-doors.spec.ts` keeps it that way).
146
+ */ export async function listingSubmissionRefusal(input) {
147
+ var _input_nowMs;
148
+ const first = screenListingSubmission(_extends({}, input.content, {
149
+ official: input.official
150
+ }));
151
+ if (first.impersonation) {
152
+ return {
153
+ status: 422,
154
+ body: {
155
+ error: first.impersonation,
156
+ code: 'impersonation'
157
+ }
158
+ };
159
+ }
160
+ if (!first.signals.length) return null;
161
+ const nowMs = (_input_nowMs = input.nowMs) != null ? _input_nowMs : Date.now();
162
+ let reviewId = '';
163
+ try {
164
+ var _data, _input_content_displayName, _input_content_publisherName;
165
+ const org = input.org !== undefined ? input.org : (_data = (await firebaseAdmin.app().firestore().collection('orgs').doc(input.publisherOrgId).get()).data()) != null ? _data : null;
166
+ const text = (value)=>typeof value === 'string' ? value : '';
167
+ // Again, now knowing whose submission it is: a brand in the workspace's
168
+ // own name is its own (the platform's excepted — see `ownNamesFor`).
169
+ const { signals } = screenListingSubmission(_extends({}, input.content, {
170
+ official: input.official,
171
+ ownNames: [
172
+ text(org == null ? void 0 : org['name'])
173
+ ]
174
+ }));
175
+ const ageDays = orgAgeDays(org == null ? void 0 : org.createdAt, nowMs);
176
+ const holding = signalsThatHold(signals, {
177
+ ageDays
178
+ });
179
+ if (!holding.length) return null;
180
+ reviewId = listingReviewId(input.publisherOrgId, holding);
181
+ const title = String((_input_content_displayName = input.content.displayName) != null ? _input_content_displayName : '').slice(0, 300) || 'a listing';
182
+ // Whether this hold is new, so the publisher is told once, not on every
183
+ // resubmission while it waits.
184
+ const firstHold = !(await firebaseAdmin.app().firestore().collection(ABUSE_REPORT_COLLECTION).doc(reviewId).get()).exists;
185
+ const state = await fileOutboundHold({
186
+ reviewId,
187
+ heldSend: {
188
+ kind: 'listing',
189
+ path: `publisherProfiles/${input.publisherOrgId}`,
190
+ hostId: '',
191
+ orgId: input.publisherOrgId,
192
+ contentHash: outboundContentHash([
193
+ holding
194
+ ]),
195
+ subject: title,
196
+ fromName: (_input_content_publisherName = input.content.publisherName) != null ? _input_content_publisherName : null,
197
+ signals: holding,
198
+ state: 'held',
199
+ heldAtMs: nowMs,
200
+ ageDays
201
+ },
202
+ headline: `Held a marketplace submission ("${title}") from publisher workspace ` + `${input.publisherOrgId}, ${ageDays != null ? ageDays : '?'} day(s) old. Nothing was published.`,
203
+ alertTitle: 'Marketplace submission held for review — possible phishing',
204
+ alertBody: `A marketplace submission ("${title}") was held before it was listed.`,
205
+ url: null,
206
+ reportedHostname: flaggedHostOf(holding)
207
+ });
208
+ if (state === 'released') return null;
209
+ const reference = heldOutboundReference(reviewId);
210
+ if (state === 'held' && firstHold) {
211
+ await notifyPublisherSubmissionHeld(input.publisherOrgId, {
212
+ title,
213
+ reference
214
+ });
215
+ }
216
+ return {
217
+ status: 409,
218
+ body: {
219
+ // The outcome and what to do — never which rule held it.
220
+ error: state === 'rejected' ? `This submission was reviewed and cannot be published. Reference ${reference}.` : 'This submission is held for a review before it can be published. Nothing was ' + 'listed. Our team will look at it; if it is released, submit it again and it will ' + `go through. To ask about it, contact support with reference ${reference}.`,
221
+ code: state === 'rejected' ? 'rejected' : 'held-for-review',
222
+ reference
223
+ }
224
+ };
225
+ } catch (error) {
226
+ // Closed only for what holds whoever the publisher is, the page screen's
227
+ // posture: a soft signal needs the workspace's age to hold at all.
228
+ if (!first.signals.some((signal)=>phishingSignalTier(signal) === 'strong')) {
229
+ console.error('[listing-screen] a submission could not be reviewed — allowing it', error);
230
+ return null;
231
+ }
232
+ console.error('[listing-screen] a flagged submission could not be reviewed — refusing it', error);
233
+ return {
234
+ status: 503,
235
+ body: _extends({
236
+ error: 'This submission could not be reviewed just now — try again in a minute.'
237
+ }, reviewId ? {
238
+ reference: heldOutboundReference(reviewId)
239
+ } : {})
240
+ };
241
+ }
242
+ }
243
+
244
+ //# sourceMappingURL=listing-screen.js.map