@aglyn/plugins-marketplace 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +51 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/artifact-update-dialog.component.d.ts +27 -0
  8. package/src/lib/components/artifact-update-dialog.component.js +244 -0
  9. package/src/lib/components/artifact-update-dialog.component.js.map +1 -0
  10. package/src/lib/components/host-plugins-card.component.d.ts +23 -0
  11. package/src/lib/components/host-plugins-card.component.js +566 -0
  12. package/src/lib/components/host-plugins-card.component.js.map +1 -0
  13. package/src/lib/components/listing-content.component.d.ts +57 -0
  14. package/src/lib/components/listing-content.component.js +1880 -0
  15. package/src/lib/components/listing-content.component.js.map +1 -0
  16. package/src/lib/components/listing-image.component.d.ts +46 -0
  17. package/src/lib/components/listing-image.component.js +38 -0
  18. package/src/lib/components/listing-image.component.js.map +1 -0
  19. package/src/lib/components/listing-reviews.component.d.ts +22 -0
  20. package/src/lib/components/listing-reviews.component.js +419 -0
  21. package/src/lib/components/listing-reviews.component.js.map +1 -0
  22. package/src/lib/components/marketplace-browse.component.d.ts +38 -0
  23. package/src/lib/components/marketplace-browse.component.js +802 -0
  24. package/src/lib/components/marketplace-browse.component.js.map +1 -0
  25. package/src/lib/components/plugin-site-set-panel.component.d.ts +24 -0
  26. package/src/lib/components/plugin-site-set-panel.component.js +137 -0
  27. package/src/lib/components/plugin-site-set-panel.component.js.map +1 -0
  28. package/src/lib/components/plugin-site-set.component.d.ts +43 -0
  29. package/src/lib/components/plugin-site-set.component.js +425 -0
  30. package/src/lib/components/plugin-site-set.component.js.map +1 -0
  31. package/src/lib/components/rating-input.component.d.ts +3 -0
  32. package/src/lib/components/rating-input.component.js +47 -0
  33. package/src/lib/components/rating-input.component.js.map +1 -0
  34. package/src/lib/components/report-target.component.d.ts +36 -0
  35. package/src/lib/components/report-target.component.js +181 -0
  36. package/src/lib/components/report-target.component.js.map +1 -0
  37. package/src/lib/components/uninstall-impact-dialog.component.d.ts +40 -0
  38. package/src/lib/components/uninstall-impact-dialog.component.js +244 -0
  39. package/src/lib/components/uninstall-impact-dialog.component.js.map +1 -0
  40. package/src/lib/constants/bundle-common.d.ts +8 -0
  41. package/src/lib/constants/bundle-common.js +9 -0
  42. package/src/lib/constants/bundle-common.js.map +1 -0
  43. package/src/lib/hooks/use-artifact-update.d.ts +41 -0
  44. package/src/lib/hooks/use-artifact-update.js +169 -0
  45. package/src/lib/hooks/use-artifact-update.js.map +1 -0
  46. package/src/lib/hooks/use-marketplace-actions.d.ts +37 -0
  47. package/src/lib/hooks/use-marketplace-actions.js +523 -0
  48. package/src/lib/hooks/use-marketplace-actions.js.map +1 -0
  49. package/src/lib/model/artifact-merge.d.ts +154 -0
  50. package/src/lib/model/artifact-merge.js +228 -0
  51. package/src/lib/model/artifact-merge.js.map +1 -0
  52. package/src/lib/model/index.d.ts +43 -0
  53. package/src/lib/model/index.js +41 -0
  54. package/src/lib/model/index.js.map +1 -0
  55. package/src/lib/model/marketplace-props.d.ts +57 -0
  56. package/src/lib/model/marketplace-props.js +441 -0
  57. package/src/lib/model/marketplace-props.js.map +1 -0
  58. package/src/lib/model/marketplace.d.ts +833 -0
  59. package/src/lib/model/marketplace.js +929 -0
  60. package/src/lib/model/marketplace.js.map +1 -0
  61. package/src/lib/model/rating-field.d.ts +24 -0
  62. package/src/lib/model/rating-field.js +33 -0
  63. package/src/lib/model/rating-field.js.map +1 -0
  64. package/src/lib/plugin.d.ts +33 -0
  65. package/src/lib/plugin.js +86 -0
  66. package/src/lib/plugin.js.map +1 -0
  67. package/src/lib/server/billing-webhook.d.ts +25 -0
  68. package/src/lib/server/billing-webhook.js +1062 -0
  69. package/src/lib/server/billing-webhook.js.map +1 -0
  70. package/src/lib/server/checkout.d.ts +76 -0
  71. package/src/lib/server/checkout.js +477 -0
  72. package/src/lib/server/checkout.js.map +1 -0
  73. package/src/lib/server/connect.d.ts +24 -0
  74. package/src/lib/server/connect.js +205 -0
  75. package/src/lib/server/connect.js.map +1 -0
  76. package/src/lib/server/install-dataset-schema.d.ts +32 -0
  77. package/src/lib/server/install-dataset-schema.js +255 -0
  78. package/src/lib/server/install-dataset-schema.js.map +1 -0
  79. package/src/lib/server/install-email-starter.d.ts +65 -0
  80. package/src/lib/server/install-email-starter.js +279 -0
  81. package/src/lib/server/install-email-starter.js.map +1 -0
  82. package/src/lib/server/install-email-template.d.ts +34 -0
  83. package/src/lib/server/install-email-template.js +252 -0
  84. package/src/lib/server/install-email-template.js.map +1 -0
  85. package/src/lib/server/install-layout.d.ts +29 -0
  86. package/src/lib/server/install-layout.js +323 -0
  87. package/src/lib/server/install-layout.js.map +1 -0
  88. package/src/lib/server/install-pin-counts.d.ts +173 -0
  89. package/src/lib/server/install-pin-counts.js +248 -0
  90. package/src/lib/server/install-pin-counts.js.map +1 -0
  91. package/src/lib/server/install-plugin.d.ts +18 -0
  92. package/src/lib/server/install-plugin.js +405 -0
  93. package/src/lib/server/install-plugin.js.map +1 -0
  94. package/src/lib/server/install-template.d.ts +37 -0
  95. package/src/lib/server/install-template.js +348 -0
  96. package/src/lib/server/install-template.js.map +1 -0
  97. package/src/lib/server/install-theme.d.ts +42 -0
  98. package/src/lib/server/install-theme.js +313 -0
  99. package/src/lib/server/install-theme.js.map +1 -0
  100. package/src/lib/server/install.d.ts +27 -0
  101. package/src/lib/server/install.js +276 -0
  102. package/src/lib/server/install.js.map +1 -0
  103. package/src/lib/server/listing-versions.d.ts +26 -0
  104. package/src/lib/server/listing-versions.js +378 -0
  105. package/src/lib/server/listing-versions.js.map +1 -0
  106. package/src/lib/server/preview-image.d.ts +37 -0
  107. package/src/lib/server/preview-image.js +216 -0
  108. package/src/lib/server/preview-image.js.map +1 -0
  109. package/src/lib/server/provenance.d.ts +100 -0
  110. package/src/lib/server/provenance.js +118 -0
  111. package/src/lib/server/provenance.js.map +1 -0
  112. package/src/lib/server/publish-dataset-schema.d.ts +31 -0
  113. package/src/lib/server/publish-dataset-schema.js +202 -0
  114. package/src/lib/server/publish-dataset-schema.js.map +1 -0
  115. package/src/lib/server/publish-email-starter.d.ts +44 -0
  116. package/src/lib/server/publish-email-starter.js +231 -0
  117. package/src/lib/server/publish-email-starter.js.map +1 -0
  118. package/src/lib/server/publish-email-template.d.ts +33 -0
  119. package/src/lib/server/publish-email-template.js +211 -0
  120. package/src/lib/server/publish-email-template.js.map +1 -0
  121. package/src/lib/server/publish-layout.d.ts +33 -0
  122. package/src/lib/server/publish-layout.js +243 -0
  123. package/src/lib/server/publish-layout.js.map +1 -0
  124. package/src/lib/server/publish-plugin.d.ts +18 -0
  125. package/src/lib/server/publish-plugin.js +485 -0
  126. package/src/lib/server/publish-plugin.js.map +1 -0
  127. package/src/lib/server/publish-preconditions.d.ts +86 -0
  128. package/src/lib/server/publish-preconditions.js +160 -0
  129. package/src/lib/server/publish-preconditions.js.map +1 -0
  130. package/src/lib/server/publish-template.d.ts +27 -0
  131. package/src/lib/server/publish-template.js +215 -0
  132. package/src/lib/server/publish-template.js.map +1 -0
  133. package/src/lib/server/publish-theme.d.ts +38 -0
  134. package/src/lib/server/publish-theme.js +186 -0
  135. package/src/lib/server/publish-theme.js.map +1 -0
  136. package/src/lib/server/publish.d.ts +26 -0
  137. package/src/lib/server/publish.js +204 -0
  138. package/src/lib/server/publish.js.map +1 -0
  139. package/src/lib/server/publisher-profile-save.d.ts +30 -0
  140. package/src/lib/server/publisher-profile-save.js +226 -0
  141. package/src/lib/server/publisher-profile-save.js.map +1 -0
  142. package/src/lib/server/publisher-profile.d.ts +86 -0
  143. package/src/lib/server/publisher-profile.js +116 -0
  144. package/src/lib/server/publisher-profile.js.map +1 -0
  145. package/src/lib/server/purchase-entitlement.d.ts +83 -0
  146. package/src/lib/server/purchase-entitlement.js +118 -0
  147. package/src/lib/server/purchase-entitlement.js.map +1 -0
  148. package/src/lib/server/report.d.ts +32 -0
  149. package/src/lib/server/report.js +101 -0
  150. package/src/lib/server/report.js.map +1 -0
  151. package/src/lib/server/reviews.d.ts +41 -0
  152. package/src/lib/server/reviews.js +240 -0
  153. package/src/lib/server/reviews.js.map +1 -0
  154. package/src/lib/server/update-artifact.d.ts +19 -0
  155. package/src/lib/server/update-artifact.js +569 -0
  156. package/src/lib/server/update-artifact.js.map +1 -0
  157. package/src/lib/server/verification-request.d.ts +31 -0
  158. package/src/lib/server/verification-request.js +173 -0
  159. package/src/lib/server/verification-request.js.map +1 -0
  160. package/src/lib/server/version-stats.d.ts +202 -0
  161. package/src/lib/server/version-stats.js +305 -0
  162. package/src/lib/server/version-stats.js.map +1 -0
  163. package/src/lib/server.d.ts +30 -0
  164. package/src/lib/server.js +95 -0
  165. package/src/lib/server.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install-email-template.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 createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n} from '@aglyn/aglyn/server'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n getTenantEmail,\n isTenantEmailEditable,\n TENANT_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email'\nimport {\n isPrivateListing,\n listingArtifactType,\n} from '../model/marketplace'\nimport { canActAsPublisher } from './publisher-profile'\nimport { requirePurchase } from './purchase-entitlement'\nimport { recordInstallProvenance } from './provenance'\nimport { recordVersionMove } from './version-stats'\n\n/**\n * Installs a marketplace email template into a site (AGL-657).\n *\n * Lands as an INACTIVE version: the design is written to\n * `hosts/{h}/emailTemplates/{key}/versions/{newId}` and the template doc's\n * `versionId` — the pointer `loadHostEmail` reads at send time — is left\n * alone. That keeps the AGL-669/671 rule intact for the one artifact type\n * where breaking it would be worst: an email template installs onto a FIXED\n * catalog key, so writing it live would silently replace the design of an\n * email the site is already sending its customers. The owner activates it in\n * the email besigner, exactly as with any other draft version.\n *\n * `subject`/`preheader` ride the version doc rather than the template doc for\n * the same reason — they are live send-time copy.\n */\nexport const installEmailTemplateHandler: PluginApiHandler = async (\n req,\n res,\n) => {\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 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\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) !== 'emailTemplate'\n ) {\n return res.status(404).json({ error: 'Unknown email template' })\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 // A listing version holds the sanitized tree as a plain map, and this\n // decodes anyway: `decodeStoredNodes` returns one unchanged, and the\n // alternative — reading raw — is a guard that passes on a `Buffer`\n // because `Object.keys` counts byte indices (AGL-1223).\n const nodes = decodeStoredNodes<Record<string, any>>(\n versionSnapshot.get('nodes'),\n )\n if (!nodes || !Object.keys(nodes).length) {\n return res.status(500).json({ error: 'Email template version missing' })\n }\n // The catalog key is part of the artifact: this design is FOR a specific\n // transactional email, so it can only install onto that same key.\n const templateKey = String(\n versionSnapshot.get('emailTemplateKey') ??\n listing.emailTemplateKey ??\n '',\n )\n const entry = templateKey ? getTenantEmail(templateKey) : undefined\n if (!entry || !isTenantEmailEditable(entry)) {\n return res\n .status(422)\n .json({ error: 'This listing targets an email this site cannot send' })\n }\n // The email's plugin has to be present, or the design targets an email\n // that is never sent from here — better to say so than to install a\n // version nobody can reach.\n const enabledPlugins = hostSnapshot.get('enabledPlugins')\n if (\n entry.pluginId &&\n Array.isArray(enabledPlugins) &&\n !enabledPlugins.includes(entry.pluginId)\n ) {\n return res.status(409).json({\n error: `This email is sent by the ${entry.plugin} plugin — enable it on this site first`,\n })\n }\n\n const templateRef = hostRef\n .collection(TENANT_EMAIL_COLLECTION)\n .doc(templateKey)\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n const versionId = createResourceUid()\n // Provenance + base snapshot (AGL-1015). The subject and preheader are\n // part of the design and travel with it, so they belong in the base — a\n // reworded subject is a user edit like any other.\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: 'emailTemplate',\n content: {\n rootId: versionSnapshot.get('rootId') ?? null,\n nodes,\n subject: String(versionSnapshot.get('subject') ?? ''),\n preheader: String(versionSnapshot.get('preheader') ?? ''),\n },\n })\n const batch = firestore.batch()\n batch.set(\n templateRef.collection('versions').doc(versionId),\n {\n rootId: versionSnapshot.get('rootId') ?? null,\n // Compressed at rest, matching what the email besigner writes on the\n // first save after this install (AGL-1151). The provenance base above\n // keeps the decoded map: it is compared by value, never stored as a\n // document the editor opens.\n nodes: Buffer.from(encodeStoredNodes(nodes)!),\n // Copy travels with the design but stays inert until activation.\n subject: String(versionSnapshot.get('subject') ?? ''),\n preheader: String(versionSnapshot.get('preheader') ?? ''),\n source: {\n type: 'marketplace' as const,\n listingId,\n version: listing.latestVersion ?? null,\n },\n installedFrom: provenance.installedFrom,\n createdAt: now,\n updatedAt: now,\n },\n )\n // Create the template doc if this site has never designed this email, but\n // WITHOUT `versionId` — an absent pointer keeps the built-in copy sending.\n //\n // `installedFrom` is a read marker, not behavior (AGL-789): the installed\n // design lives in a versions SUBcollection, which browse can only reach\n // with a collectionGroup query that would collide with every other\n // `versions` subcollection in the schema. Recording it on the flat\n // template doc keeps \"is this listing installed here?\" a single read.\n batch.set(\n templateRef,\n {\n updatedAt: now,\n // The full AGL-1015 stamp, plus the `versionId` pointer this marker\n // has carried since AGL-789 so \"is this listing installed here?\" stays\n // a single flat read.\n installedFrom: { ...provenance.installedFrom, versionId },\n },\n { merge: true },\n )\n await batch.commit()\n\n // Per-version tally (AGL-1036). Each install adds another draft version\n // rather than replacing the last, so nothing leaves a version here either.\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'emailTemplate',\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 templateKey,\n versionId,\n activated: false,\n version: listing.latestVersion ?? null,\n baseStored: provenance.baseStored,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Email template install failed' })\n }\n}\n\nexport default installEmailTemplateHandler\n"],"names":["createResourceUid","decodeStoredNodes","encodeStoredNodes","firebaseAdmin","resolveOrgPermissions","getTenantEmail","isTenantEmailEditable","TENANT_EMAIL_COLLECTION","isPrivateListing","listingArtifactType","canActAsPublisher","requirePurchase","recordInstallProvenance","recordVersionMove","installEmailTemplateHandler","req","res","method","status","json","error","listingId","String","body","hostId","authorization","headers","idToken","startsWith","slice","length","undefined","hostSnapshot","listing","membership","versionSnapshot","decoded","app","auth","verifyIdToken","uid","permissions","installPlugins","firestore","hostRef","collection","doc","get","exists","memberRole","listingRef","listingSnapshot","data","deletedAt","hiddenAt","priceUsd","Number","ownsListing","profileId","unpaid","buyerUid","buyerOrgId","orgId","latestVersion","nodes","Object","keys","templateKey","emailTemplateKey","entry","enabledPlugins","pluginId","Array","isArray","includes","plugin","templateRef","now","FieldValue","serverTimestamp","versionId","provenance","version","artifactType","content","rootId","subject","preheader","batch","set","Buffer","from","source","type","installedFrom","createdAt","updatedAt","merge","commit","to","update","installCount","increment","catch","installed","activated","baseStored","console"],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * Installs a marketplace layout into a host's template library (AGL-671).
20
+ *
21
+ * Lands as a `kind: 'layout'` template rather than a live layout, for the
22
+ * same reason template installs do (AGL-669): installing must never change
23
+ * a running site. Creating the actual layout is the deliberate second step
24
+ * in the templates library.
25
+ *
26
+ * Access mirrors template installs: free, purchased, or your own listing.
27
+ */
28
+ export declare const installLayoutHandler: PluginApiHandler;
29
+ export default installLayoutHandler;
@@ -0,0 +1,323 @@
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
+ */ import { checkQuota, createResourceUid, decodeStoredNodes, encodeStoredNodes } from "@aglyn/aglyn/server";
18
+ import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
19
+ import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
20
+ import { isPrivateListing, listingArtifactType } from "../model/marketplace.js";
21
+ import { readPublishedProps } from "../model/marketplace-props.js";
22
+ import { canActAsPublisher } from "./publisher-profile.js";
23
+ import { requirePurchase } from "./purchase-entitlement.js";
24
+ import { hasDivergedFromBase, recordInstallProvenance } from "./provenance.js";
25
+ import { recordVersionMove } from "./version-stats.js";
26
+ /**
27
+ * Installs a marketplace layout into a host's template library (AGL-671).
28
+ *
29
+ * Lands as a `kind: 'layout'` template rather than a live layout, for the
30
+ * same reason template installs do (AGL-669): installing must never change
31
+ * a running site. Creating the actual layout is the deliberate second step
32
+ * in the templates library.
33
+ *
34
+ * Access mirrors template installs: free, purchased, or your own listing.
35
+ */ export const installLayoutHandler = async (req, res)=>{
36
+ var _ref, _ref1, _req_headers_authorization;
37
+ var _req_body, _req_body1;
38
+ if (req.method !== 'POST') {
39
+ return res.status(405).json({
40
+ error: 'Method not allowed'
41
+ });
42
+ }
43
+ const listingId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.listingId) != null ? _ref : '');
44
+ const hostId = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.hostId) != null ? _ref1 : '');
45
+ if (!listingId || !hostId) {
46
+ return res.status(400).json({
47
+ error: 'Missing listingId or hostId'
48
+ });
49
+ }
50
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
51
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
52
+ if (!idToken) return res.status(401).json({
53
+ error: 'Unauthenticated'
54
+ });
55
+ try {
56
+ var _hostSnapshot_get, _listing_priceUsd, _membership_orgId, _listing_latestVersion;
57
+ var _this;
58
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
59
+ const membership = await resolveOrgPermissions(decoded.uid, {
60
+ hostId
61
+ });
62
+ if (!membership.permissions.installPlugins) {
63
+ return res.status(403).json({
64
+ error: 'Your organization role does not allow installing from the marketplace'
65
+ });
66
+ }
67
+ const firestore = firebaseAdmin.app().firestore();
68
+ const hostRef = firestore.collection('hosts').doc(hostId);
69
+ const hostSnapshot = await hostRef.get();
70
+ if (!hostSnapshot.exists) {
71
+ return res.status(404).json({
72
+ error: 'Unknown site'
73
+ });
74
+ }
75
+ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
76
+ if (memberRole !== 'admin' && memberRole !== 'editor') {
77
+ return res.status(403).json({
78
+ error: 'Not a site admin or editor'
79
+ });
80
+ }
81
+ const listingRef = firestore.collection('marketplaceListings').doc(listingId);
82
+ const listingSnapshot = await listingRef.get();
83
+ const listing = listingSnapshot.data();
84
+ if (!listing || listing.deletedAt || // Staff takedown blocks new installs on EVERY artifact type
85
+ // (AGL-2290). AGL-948 extended takedown past plugins in the browse
86
+ // predicate and in `resolveMarketplacePluginVersion`, but the gate that
87
+ // decides whether content is HANDED OVER was only ever added to
88
+ // `install-plugin.ts`. So a component, theme, template, layout, email
89
+ // template or dataset schema that staff had taken down stayed
90
+ // installable by anyone holding its listing id — which makes takedown a
91
+ // suggestion for six of the seven artifact types.
92
+ //
93
+ // No owner exemption, matching `install-plugin.ts`: a takedown is a
94
+ // moderation decision about the artifact, not about who is asking.
95
+ listing.hiddenAt || listingArtifactType(listing) !== 'layout') {
96
+ return res.status(404).json({
97
+ error: 'Unknown layout'
98
+ });
99
+ }
100
+ const priceUsd = Number((_listing_priceUsd = listing.priceUsd) != null ? _listing_priceUsd : 0);
101
+ const ownsListing = await canActAsPublisher(firestore, decoded.uid, listing.profileId);
102
+ // Private listings install ONLY for the owning org (AGL-2290).
103
+ //
104
+ // `install-plugin.ts` has carried this since AGL-968; the other six never
105
+ // did, so a private component, theme, template, layout, email template or
106
+ // dataset schema was installable by anyone who knew its listing id. Browse
107
+ // hides them and the detail page 404s, but neither is a control — the
108
+ // route is.
109
+ if (isPrivateListing(listing) && !ownsListing) {
110
+ return res.status(404).json({
111
+ error: 'Unknown listing'
112
+ });
113
+ }
114
+ // A FULLY refunded purchase stops entitling (AGL-1546), and until
115
+ // AGL-1699 only the component route knew that: this one asked whether a
116
+ // purchase doc EXISTED, so buy/install/refund kept the artifact. The
117
+ // predicate lives in one place now so the next route cannot miss it.
118
+ const unpaid = await requirePurchase({
119
+ firestore,
120
+ buyerUid: decoded.uid,
121
+ // THE ORG THE LICENCE HAS TO COVER (AGL-2331). `membership.orgId` is
122
+ // resolved server-side from the caller's own membership by the
123
+ // permission gate above — never a request-body field — so this is the
124
+ // workspace the install actually lands in, and the only one a purchase
125
+ // can entitle here.
126
+ buyerOrgId: (_membership_orgId = membership.orgId) != null ? _membership_orgId : '',
127
+ listingId,
128
+ priceUsd,
129
+ ownsListing
130
+ });
131
+ if (unpaid) return res.status(402).json(unpaid);
132
+ const versionSnapshot = await listingRef.collection('versions').doc(String(listing.latestVersion)).get();
133
+ const layout = versionSnapshot.get('layout');
134
+ if (!(layout == null ? void 0 : layout.nodes) || !Object.keys(layout.nodes).length) {
135
+ return res.status(500).json({
136
+ error: 'Layout version missing'
137
+ });
138
+ }
139
+ // The layout properties its tree binds to (AGL-2933), held to the
140
+ // sanitizer again on the way in; `undefined` for a version published
141
+ // before they were carried.
142
+ const props = readPublishedProps(layout.props);
143
+ const org = (_this = await getOrgForHost(hostId)) == null ? void 0 : _this.org;
144
+ const templatesRef = hostRef.collection('templates');
145
+ /**
146
+ * The library slots this install would spend, off a set of template docs.
147
+ *
148
+ * The copy this listing already installed is about to be replaced, so
149
+ * counting it would fail the quota on an update the user is entitled to.
150
+ *
151
+ * A function rather than an inlined filter because the count is now taken
152
+ * TWICE — once here as a fast refusal, once inside the transaction as the
153
+ * authority — and two copies of a counting rule is how the rule drifts.
154
+ */ const slotsAfterInstall = (docs)=>docs.filter((entry)=>!entry.get('deletedAt') && entry.get('source.listingId') !== listingId).length;
155
+ const overQuota = (limit)=>`Your plan allows ${limit} templates — see Billing to upgrade.`;
156
+ const existing = await templatesRef.get();
157
+ {
158
+ // The fast refusal. NOT the enforcement point — see the transaction
159
+ // below — but it keeps the base-snapshot write and the version tally off
160
+ // the path of an install that was never going to be allowed, and it is
161
+ // what answers a caller who is simply over their limit.
162
+ const quota = checkQuota(org, 'templatesPerHost', slotsAfterInstall(existing.docs));
163
+ if (!quota.allowed) {
164
+ return res.status(403).json({
165
+ error: overQuota(quota.limit)
166
+ });
167
+ }
168
+ }
169
+ // Replacing a layout the workspace has edited destroys those edits — the
170
+ // old doc is soft-deleted, so the change is invisible until someone goes
171
+ // looking for it. Refuse unless the caller has been shown what goes and
172
+ // asked for a replacement anyway (AGL-1018).
173
+ const installedLayout = existing.docs.find((entry)=>!entry.get('deletedAt') && entry.get('source.listingId') === listingId);
174
+ if (installedLayout) {
175
+ var _req_body2;
176
+ const diverged = await hasDivergedFromBase({
177
+ firestore,
178
+ sha256: installedLayout.get('installedFrom.sha256'),
179
+ current: {
180
+ rootId: installedLayout.get('rootId'),
181
+ // DECODED, because the comparison is by value: the base snapshot
182
+ // recorded at install is a node map, and `hasDivergedFromBase`
183
+ // hashes a stable stringification of both sides. A stored `Bytes`
184
+ // never matches a map, so an untouched copy would read as edited
185
+ // and every re-install would be refused as a divergence
186
+ // (AGL-1151).
187
+ nodes: decodeStoredNodes(installedLayout.get('nodes'))
188
+ }
189
+ });
190
+ if (diverged && ((_req_body2 = req.body) == null ? void 0 : _req_body2.mode) !== 'replace') {
191
+ return res.status(409).json({
192
+ error: 'Your copy of this layout has been edited since it was installed. ' + 'Review the update to see what would change.',
193
+ diverged: true
194
+ });
195
+ }
196
+ }
197
+ const now = firebaseAdmin.firestore.FieldValue.serverTimestamp();
198
+ // Provenance + base snapshot (AGL-1015) — the layout tree as published,
199
+ // without the display name the user is free to change.
200
+ //
201
+ // Stays OUTSIDE the transaction below: it does its own read and write, and
202
+ // repeating those on every retry attempt is exactly what a transaction
203
+ // body must not do. That is safe because the base collection is
204
+ // content-addressed and shared — a race that ends in a refusal leaves at
205
+ // most a snapshot of a published version, keyed by its own hash, which the
206
+ // next successful install of that version reuses.
207
+ const provenance = await recordInstallProvenance({
208
+ firestore,
209
+ listingId,
210
+ listing,
211
+ version: listing.latestVersion,
212
+ artifactType: 'layout',
213
+ content: {
214
+ rootId: layout.rootId,
215
+ nodes: layout.nodes
216
+ }
217
+ });
218
+ /**
219
+ * Re-install replaces the prior copy rather than stacking (AGL-671).
220
+ *
221
+ * THE ENFORCEMENT POINT: the count, the decision and every write in ONE
222
+ * transaction (AGL-2371, the AGL-2231 treatment).
223
+ *
224
+ * The check above ran, then `hasDivergedFromBase` awaited, then
225
+ * `recordInstallProvenance` awaited, and only then did a `WriteBatch`
226
+ * commit. Every await is a yield, so N concurrent installs each read the
227
+ * same pre-count, each found room, and each landed — and nothing re-counts
228
+ * afterwards, so the extra templates were permanent. A batch is atomic but
229
+ * NOT conditional on a read taken before it, which is the same lesson
230
+ * AGL-2369 paid for one route over.
231
+ *
232
+ * `tx.get` on the templates collection takes a pessimistic lock on every
233
+ * document it matched, so the loser of a race retries, re-reads the higher
234
+ * count and is refused.
235
+ *
236
+ * The DIVERGENCE gate above deliberately stays outside. It is not a count:
237
+ * it asks whether this workspace edited its own copy, which no concurrent
238
+ * install of a different listing can change, and it reads a base snapshot
239
+ * through a helper that does its own `get`.
240
+ *
241
+ * A refusal comes back as data and is rendered outside. Building a response
242
+ * inside a body that can run several times reads as if the transaction were
243
+ * a place effects happen.
244
+ */ const outcome = await firestore.runTransaction(async (tx)=>{
245
+ var _listing_displayName, _layout_nodes, _listing_latestVersion, _ref, _ref1;
246
+ var _superseded_, _superseded_1;
247
+ const live = await tx.get(templatesRef);
248
+ const quota = checkQuota(org, 'templatesPerHost', slotsAfterInstall(live.docs));
249
+ if (!quota.allowed) return {
250
+ error: overQuota(quota.limit)
251
+ };
252
+ // ALL READS BEFORE THE WRITES, which Firestore requires.
253
+ const superseded = live.docs.filter((entry)=>!entry.get('deletedAt') && entry.get('source.listingId') === listingId);
254
+ for (const stale of superseded){
255
+ tx.update(stale.ref, {
256
+ deletedAt: now,
257
+ updatedAt: now
258
+ });
259
+ }
260
+ tx.set(templatesRef.doc(createResourceUid()), _extends({
261
+ kind: 'layout',
262
+ displayName: String((_listing_displayName = listing.displayName) != null ? _listing_displayName : 'Layout').slice(0, 80)
263
+ }, listing.description && {
264
+ description: listing.description
265
+ }, {
266
+ rootId: layout.rootId,
267
+ // Compressed at rest, matching the template converter (AGL-1151).
268
+ // The provenance base recorded above keeps the decoded map: it is
269
+ // compared by value, never opened as a document.
270
+ nodes: Buffer.from(encodeStoredNodes((_layout_nodes = layout.nodes) != null ? _layout_nodes : {}))
271
+ }, props && {
272
+ props
273
+ }, {
274
+ source: {
275
+ type: 'marketplace',
276
+ listingId,
277
+ version: (_listing_latestVersion = listing.latestVersion) != null ? _listing_latestVersion : null
278
+ },
279
+ installedFrom: provenance.installedFrom,
280
+ createdAt: now,
281
+ updatedAt: now
282
+ }));
283
+ // Per-version tally input (AGL-1036): the replaced copy leaves its
284
+ // version — read off the doc the transaction actually replaced, not off
285
+ // the snapshot taken before it opened.
286
+ return {
287
+ replaced: superseded.length,
288
+ from: (_ref = (_ref1 = (_superseded_ = superseded[0]) == null ? void 0 : _superseded_.get('installedFrom.version')) != null ? _ref1 : (_superseded_1 = superseded[0]) == null ? void 0 : _superseded_1.get('source.version')) != null ? _ref : null
289
+ };
290
+ });
291
+ if ('error' in outcome) {
292
+ return res.status(403).json({
293
+ error: outcome.error
294
+ });
295
+ }
296
+ const { replaced } = outcome;
297
+ await recordVersionMove({
298
+ firestore,
299
+ listingRef,
300
+ artifactType: 'layout',
301
+ from: outcome.from,
302
+ to: listing.latestVersion
303
+ });
304
+ await listingRef.update({
305
+ installCount: firebaseAdmin.firestore.FieldValue.increment(1)
306
+ }).catch(()=>undefined);
307
+ return res.status(200).json({
308
+ installed: true,
309
+ templates: 1,
310
+ replaced,
311
+ version: (_listing_latestVersion = listing.latestVersion) != null ? _listing_latestVersion : null,
312
+ baseStored: provenance.baseStored
313
+ });
314
+ } catch (error) {
315
+ console.error(error);
316
+ return res.status(500).json({
317
+ error: 'Layout install failed'
318
+ });
319
+ }
320
+ };
321
+ export default installLayoutHandler;
322
+
323
+ //# sourceMappingURL=install-layout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install-layout.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 checkQuota,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n} from '@aglyn/aglyn/server'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n isPrivateListing,\n listingArtifactType,\n} from '../model/marketplace'\nimport { readPublishedProps } from '../model/marketplace-props'\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 layout into a host's template library (AGL-671).\n *\n * Lands as a `kind: 'layout'` template rather than a live layout, for the\n * same reason template installs do (AGL-669): installing must never change\n * a running site. Creating the actual layout is the deliberate second step\n * in the templates library.\n *\n * Access mirrors template installs: free, purchased, or your own listing.\n */\nexport const installLayoutHandler: 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 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\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) !== 'layout'\n ) {\n return res.status(404).json({ error: 'Unknown layout' })\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 layout = versionSnapshot.get('layout') as any\n if (!layout?.nodes || !Object.keys(layout.nodes).length) {\n return res.status(500).json({ error: 'Layout version missing' })\n }\n // The layout properties its tree binds to (AGL-2933), held to the\n // sanitizer again on the way in; `undefined` for a version published\n // before they were carried.\n const props = readPublishedProps(layout.props)\n\n const org = (await getOrgForHost(hostId))?.org\n const templatesRef = hostRef.collection('templates')\n /**\n * The library slots this install would spend, off a set of template docs.\n *\n * The copy this listing already installed is about to be replaced, so\n * counting it would fail the quota on an update the user is entitled to.\n *\n * A function rather than an inlined filter because the count is now taken\n * TWICE — once here as a fast refusal, once inside the transaction as the\n * authority — and two copies of a counting rule is how the rule drifts.\n */\n const slotsAfterInstall = (\n docs: Array<FirebaseFirestore.QueryDocumentSnapshot>,\n ) =>\n docs.filter(\n (entry) =>\n !entry.get('deletedAt') && entry.get('source.listingId') !== listingId,\n ).length\n const overQuota = (limit: number) =>\n `Your plan allows ${limit} templates — see Billing to upgrade.`\n const existing = await templatesRef.get()\n {\n // The fast refusal. NOT the enforcement point — see the transaction\n // below — but it keeps the base-snapshot write and the version tally off\n // the path of an install that was never going to be allowed, and it is\n // what answers a caller who is simply over their limit.\n const quota = checkQuota(\n org as any,\n 'templatesPerHost',\n slotsAfterInstall(existing.docs),\n )\n if (!quota.allowed) {\n return res.status(403).json({ error: overQuota(quota.limit) })\n }\n }\n\n // Replacing a layout the workspace has edited destroys those edits — the\n // old doc is soft-deleted, so the change is invisible until someone goes\n // looking for it. Refuse unless the caller has been shown what goes and\n // asked for a replacement anyway (AGL-1018).\n const installedLayout = existing.docs.find(\n (entry) =>\n !entry.get('deletedAt') && entry.get('source.listingId') === listingId,\n )\n if (installedLayout) {\n const diverged = await hasDivergedFromBase({\n firestore,\n sha256: installedLayout.get('installedFrom.sha256'),\n current: {\n rootId: installedLayout.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 // never matches a map, so an untouched copy would read as edited\n // and every re-install would be refused as a divergence\n // (AGL-1151).\n nodes: decodeStoredNodes(installedLayout.get('nodes')),\n },\n })\n if (diverged && req.body?.mode !== 'replace') {\n return res.status(409).json({\n error:\n 'Your copy of this layout has been edited since it was installed. ' +\n '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 layout tree as published,\n // without the display name the user is free to change.\n //\n // Stays OUTSIDE the transaction below: it does its own read and write, and\n // repeating those on every retry attempt is exactly what a transaction\n // body must not do. That is safe because the base collection is\n // content-addressed and shared — a race that ends in a refusal leaves at\n // most a snapshot of a published version, keyed by its own hash, which the\n // next successful install of that version reuses.\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: 'layout',\n content: { rootId: layout.rootId, nodes: layout.nodes },\n })\n /**\n * Re-install replaces the prior copy rather than stacking (AGL-671).\n *\n * THE ENFORCEMENT POINT: the count, the decision and every write in ONE\n * transaction (AGL-2371, the AGL-2231 treatment).\n *\n * The check above ran, then `hasDivergedFromBase` awaited, then\n * `recordInstallProvenance` awaited, and only then did a `WriteBatch`\n * commit. Every await is a yield, so N concurrent installs each read the\n * same pre-count, each found room, and each landed — and nothing re-counts\n * afterwards, so the extra templates were permanent. A batch is atomic but\n * NOT conditional on a read taken before it, which is the same lesson\n * AGL-2369 paid for one route over.\n *\n * `tx.get` on the templates collection takes a pessimistic lock on every\n * document it matched, so the loser of a race retries, re-reads the higher\n * count and is refused.\n *\n * The DIVERGENCE gate above deliberately stays outside. It is not a count:\n * it asks whether this workspace edited its own copy, which no concurrent\n * install of a different listing can change, and it reads a base snapshot\n * through a helper that does its own `get`.\n *\n * A refusal comes back as data and is rendered outside. Building a response\n * inside a body that can run several times reads as if the transaction were\n * a place effects happen.\n */\n const outcome = await firestore.runTransaction<\n { error: string } | { replaced: number; from: string | number | null }\n >(async (tx) => {\n const live = await tx.get(templatesRef)\n const quota = checkQuota(\n org as any,\n 'templatesPerHost',\n slotsAfterInstall(live.docs),\n )\n if (!quota.allowed) return { error: overQuota(quota.limit) }\n\n // ALL READS BEFORE THE WRITES, which Firestore requires.\n const superseded = live.docs.filter(\n (entry) =>\n !entry.get('deletedAt') && entry.get('source.listingId') === listingId,\n )\n for (const stale of superseded) {\n tx.update(stale.ref, { deletedAt: now, updatedAt: now })\n }\n tx.set(templatesRef.doc(createResourceUid()), {\n kind: 'layout',\n displayName: String(listing.displayName ?? 'Layout').slice(0, 80),\n ...(listing.description && { description: listing.description }),\n rootId: layout.rootId,\n // Compressed at rest, matching the template converter (AGL-1151).\n // The provenance base recorded above keeps the decoded map: it is\n // compared by value, never opened as a document.\n nodes: Buffer.from(encodeStoredNodes(layout.nodes ?? {})!),\n // Use template seeds the layout's first version with these\n // (AGL-2932), so a screen using it has its layout values to set.\n ...(props && { props }),\n source: {\n type: 'marketplace' as const,\n listingId,\n version: listing.latestVersion ?? null,\n },\n installedFrom: provenance.installedFrom,\n createdAt: now,\n updatedAt: now,\n })\n // Per-version tally input (AGL-1036): the replaced copy leaves its\n // version — read off the doc the transaction actually replaced, not off\n // the snapshot taken before it opened.\n return {\n replaced: superseded.length,\n from:\n superseded[0]?.get('installedFrom.version') ??\n superseded[0]?.get('source.version') ??\n null,\n }\n })\n if ('error' in outcome) {\n return res.status(403).json({ error: outcome.error })\n }\n const { replaced } = outcome\n\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'layout',\n from: outcome.from,\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 templates: 1,\n replaced,\n version: listing.latestVersion ?? null,\n baseStored: provenance.baseStored,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Layout install failed' })\n }\n}\n\nexport default installLayoutHandler\n"],"names":["checkQuota","createResourceUid","decodeStoredNodes","encodeStoredNodes","firebaseAdmin","getOrgForHost","resolveOrgPermissions","isPrivateListing","listingArtifactType","readPublishedProps","canActAsPublisher","requirePurchase","hasDivergedFromBase","recordInstallProvenance","recordVersionMove","installLayoutHandler","req","res","method","status","json","error","listingId","String","body","hostId","authorization","headers","idToken","startsWith","slice","length","undefined","hostSnapshot","listing","membership","decoded","app","auth","verifyIdToken","uid","permissions","installPlugins","firestore","hostRef","collection","doc","get","exists","memberRole","listingRef","listingSnapshot","data","deletedAt","hiddenAt","priceUsd","Number","ownsListing","profileId","unpaid","buyerUid","buyerOrgId","orgId","versionSnapshot","latestVersion","layout","nodes","Object","keys","props","org","templatesRef","slotsAfterInstall","docs","filter","entry","overQuota","limit","existing","quota","allowed","installedLayout","find","diverged","sha256","current","rootId","mode","now","FieldValue","serverTimestamp","provenance","version","artifactType","content","outcome","runTransaction","tx","superseded","live","stale","update","ref","updatedAt","set","kind","displayName","description","Buffer","from","source","type","installedFrom","createdAt","replaced","to","installCount","increment","catch","installed","templates","baseStored","console"],"mappings":"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"}
@@ -0,0 +1,173 @@
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 { MarketplaceArtifactType } from '../model/marketplace';
18
+ import type { LivePinTally } from './version-stats';
19
+ /**
20
+ * Install counts derived from the pins, which are the only ground truth
21
+ * (AGL-1419).
22
+ *
23
+ * AGL-1418 made the page internally consistent and said so honestly: the
24
+ * stored numbers were still wrong, because both stored levels are
25
+ * accumulators and an accumulator can only fail downwards from a missed
26
+ * increment — or upwards forever from a pin that disappeared without one. Six
27
+ * of eight production listings disagreed with their real pin counts;
28
+ * `z6glT_UDAQ` advertised `3 active` against two live pins, and nothing in the
29
+ * product could ever have noticed.
30
+ *
31
+ * Nothing decrements when a pin dies out of band, and there are three ways for
32
+ * that to happen: `recursiveDelete` on a tenant erase (`erase.ts` never names
33
+ * `installs`, it just sweeps the subtree), the console's client-side host-pin
34
+ * delete in the promote-to-org flow, and any cascade. So the derived count is
35
+ * not a nicety — it is the only thing that can bring a count back DOWN.
36
+ *
37
+ * ## Why a collection group here, when three other places refuse one
38
+ *
39
+ * `reviews.ts` and `listing-content.component.tsx` both avoid
40
+ * `collectionGroup('installs')`, and both are right to: they ask "which of MY
41
+ * org's sites has this pinned", and a cross-tenant scan to answer a
42
+ * single-org question is unbounded work plus pins the caller must never see.
43
+ *
44
+ * This asks the opposite question — "how many pins exist anywhere" — which is
45
+ * cross-tenant by definition, and answers it with `count()`, which returns an
46
+ * integer and never a document. No pin, no path, no tenant identity leaves
47
+ * this function. It runs in the Admin SDK on a server route, so it is not a
48
+ * rules question either.
49
+ *
50
+ * ## Cost
51
+ *
52
+ * Firestore bills an aggregation at roughly one document read per 1000 index
53
+ * entries scanned, not one per document, so a listing with 500 installs costs
54
+ * ~1 read rather than 500. That is the single biggest lever here, and it is
55
+ * why counting pins on a buyer-facing page is affordable at all.
56
+ *
57
+ * On top of it, `verifiedLivePins` only issues the aggregation when the stored
58
+ * copy is stale, and writes the answer back — so the ordinary request pays
59
+ * ZERO extra reads and serves a number that was derived from the pins.
60
+ */
61
+ /** How long a derived count stays good without re-deriving it. */
62
+ export declare const PIN_VERIFY_TTL_MS: number;
63
+ /**
64
+ * How long one process stops re-issuing an aggregation that failed.
65
+ *
66
+ * In-process on purpose. It exists to stop an error storm when the index is
67
+ * missing, not to remember anything — a cold instance SHOULD try again and log
68
+ * again, because the message names the index and is how anyone finds out.
69
+ */
70
+ export declare const PIN_COUNT_BACKOFF_MS: number;
71
+ /** The listing fields this module maintains, on top of the two counters. */
72
+ export interface PinVerificationFields {
73
+ /**
74
+ * The last count derived from the pins. Kept beside `activeInstalls` rather
75
+ * than instead of it so an accumulator write by an install or uninstall
76
+ * route makes the pair disagree, which is what marks the cache stale — the
77
+ * write paths invalidate this without knowing it exists.
78
+ */
79
+ pinnedActiveInstalls?: number | null;
80
+ /** The derived per-version split, so a cache hit serves the same breakdown. */
81
+ pinnedVersionInstalls?: Record<string, number> | null;
82
+ pinsVerifiedAtMs?: number | null;
83
+ }
84
+ interface CountableFirestore {
85
+ collectionGroup: (id: string) => {
86
+ where: (field: string, op: '==', value: unknown) => {
87
+ where: (field: string, op: '==', value: unknown) => {
88
+ count: () => {
89
+ get: () => Promise<{
90
+ data: () => {
91
+ count?: unknown;
92
+ };
93
+ }>;
94
+ };
95
+ };
96
+ count: () => {
97
+ get: () => Promise<{
98
+ data: () => {
99
+ count?: unknown;
100
+ };
101
+ }>;
102
+ };
103
+ };
104
+ };
105
+ }
106
+ /** Exported for tests: a module-level cache outlives a `jest` test otherwise. */
107
+ export declare function resetPinCountBackoff(): void;
108
+ /**
109
+ * Live pins for one listing, or `null` when the count could not be taken.
110
+ *
111
+ * Needs a `COLLECTION_GROUP_ASC` single-field index on `installs.listingId`
112
+ * (a `fieldOverrides` entry — Firestore auto-creates single-field indexes at
113
+ * COLLECTION scope only, never at COLLECTION_GROUP scope).
114
+ */
115
+ export declare function countLivePins(firestore: CountableFirestore, listingId: string): Promise<number | null>;
116
+ /**
117
+ * Live pins per version, or `null` when the split could not be taken.
118
+ *
119
+ * One aggregation per version, which is bounded by the caller's `limit(20)` on
120
+ * the version history — and paid once per TTL window, not once per request.
121
+ * A composite `listingId ASC, version ASC` collection-group index covers it.
122
+ *
123
+ * `null` and "every version zero" are different answers and the caller treats
124
+ * them differently, so a single failing member fails the whole split rather
125
+ * than reporting a hole as a zero.
126
+ */
127
+ export declare function countLivePinsByVersion(firestore: CountableFirestore, listingId: string, versionIds: string[]): Promise<Map<string, number> | null>;
128
+ export interface VerifyLivePinsInput {
129
+ firestore: CountableFirestore;
130
+ listingRef: {
131
+ update: (data: Record<string, unknown>, precondition?: {
132
+ lastUpdateTime?: unknown;
133
+ }) => Promise<unknown>;
134
+ };
135
+ listingId: string;
136
+ /** The listing document as already read by the route. Never re-read here. */
137
+ listing: Record<string, unknown> | undefined | null;
138
+ artifactType: MarketplaceArtifactType | string | null | undefined;
139
+ /** Version ids in the history, for the per-version split. */
140
+ versionIds?: string[];
141
+ /**
142
+ * `snapshot.updateTime`, so the write-back cannot clobber an install that
143
+ * landed between the read and the write. Omit to write unconditionally.
144
+ */
145
+ updateTime?: unknown;
146
+ nowMs?: number;
147
+ }
148
+ /**
149
+ * The count the page should print, derived from pins and cached in the listing.
150
+ *
151
+ * Returns `null` when there is no verified answer — a copied artifact, a stale
152
+ * cache the aggregation could not refresh, or a listing whose index is not
153
+ * deployed yet. `null` means "fall back to the stored accumulators", which is
154
+ * exactly AGL-1418's behaviour, so a missing index degrades to the previous
155
+ * release rather than to zeros.
156
+ *
157
+ * Read cost per request:
158
+ *
159
+ * * **Copied artifact** — 0. They install by copying and hold no pin, so a
160
+ * derived count would be a derived zero over a real number.
161
+ * * **Cache hit** — 0. The listing document was already read by the route.
162
+ * * **Cache miss** — 1 aggregation (~1 read per 1000 pins), plus one more per
163
+ * version only when the listing has more than one version, plus 1 write.
164
+ *
165
+ * The cache goes stale two ways. The TTL covers silent drift — a pin swept by
166
+ * a tenant erase, which decrements nothing. The `pinnedActiveInstalls`
167
+ * comparison covers real installs: every install and uninstall route moves
168
+ * `activeInstalls`, which breaks the pair immediately, so a genuine install
169
+ * shows on the next request instead of up to `PIN_VERIFY_TTL_MS` later. No
170
+ * install route had to learn about any of this.
171
+ */
172
+ export declare function verifiedLivePins(input: VerifyLivePinsInput): Promise<LivePinTally | null>;
173
+ export {};