@aglyn/plugins-marketplace 1.0.0-beta.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +51 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/artifact-update-dialog.component.d.ts +27 -0
- package/src/lib/components/artifact-update-dialog.component.js +244 -0
- package/src/lib/components/artifact-update-dialog.component.js.map +1 -0
- package/src/lib/components/host-plugins-card.component.d.ts +23 -0
- package/src/lib/components/host-plugins-card.component.js +566 -0
- package/src/lib/components/host-plugins-card.component.js.map +1 -0
- package/src/lib/components/listing-content.component.d.ts +57 -0
- package/src/lib/components/listing-content.component.js +1880 -0
- package/src/lib/components/listing-content.component.js.map +1 -0
- package/src/lib/components/listing-image.component.d.ts +46 -0
- package/src/lib/components/listing-image.component.js +38 -0
- package/src/lib/components/listing-image.component.js.map +1 -0
- package/src/lib/components/listing-reviews.component.d.ts +22 -0
- package/src/lib/components/listing-reviews.component.js +419 -0
- package/src/lib/components/listing-reviews.component.js.map +1 -0
- package/src/lib/components/marketplace-browse.component.d.ts +38 -0
- package/src/lib/components/marketplace-browse.component.js +802 -0
- package/src/lib/components/marketplace-browse.component.js.map +1 -0
- package/src/lib/components/plugin-site-set-panel.component.d.ts +24 -0
- package/src/lib/components/plugin-site-set-panel.component.js +137 -0
- package/src/lib/components/plugin-site-set-panel.component.js.map +1 -0
- package/src/lib/components/plugin-site-set.component.d.ts +43 -0
- package/src/lib/components/plugin-site-set.component.js +425 -0
- package/src/lib/components/plugin-site-set.component.js.map +1 -0
- package/src/lib/components/rating-input.component.d.ts +3 -0
- package/src/lib/components/rating-input.component.js +47 -0
- package/src/lib/components/rating-input.component.js.map +1 -0
- package/src/lib/components/report-target.component.d.ts +36 -0
- package/src/lib/components/report-target.component.js +181 -0
- package/src/lib/components/report-target.component.js.map +1 -0
- package/src/lib/components/uninstall-impact-dialog.component.d.ts +40 -0
- package/src/lib/components/uninstall-impact-dialog.component.js +244 -0
- package/src/lib/components/uninstall-impact-dialog.component.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/hooks/use-artifact-update.d.ts +41 -0
- package/src/lib/hooks/use-artifact-update.js +169 -0
- package/src/lib/hooks/use-artifact-update.js.map +1 -0
- package/src/lib/hooks/use-marketplace-actions.d.ts +37 -0
- package/src/lib/hooks/use-marketplace-actions.js +523 -0
- package/src/lib/hooks/use-marketplace-actions.js.map +1 -0
- package/src/lib/model/artifact-merge.d.ts +154 -0
- package/src/lib/model/artifact-merge.js +228 -0
- package/src/lib/model/artifact-merge.js.map +1 -0
- package/src/lib/model/index.d.ts +43 -0
- package/src/lib/model/index.js +41 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/marketplace-props.d.ts +57 -0
- package/src/lib/model/marketplace-props.js +441 -0
- package/src/lib/model/marketplace-props.js.map +1 -0
- package/src/lib/model/marketplace.d.ts +833 -0
- package/src/lib/model/marketplace.js +929 -0
- package/src/lib/model/marketplace.js.map +1 -0
- package/src/lib/model/rating-field.d.ts +24 -0
- package/src/lib/model/rating-field.js +33 -0
- package/src/lib/model/rating-field.js.map +1 -0
- package/src/lib/plugin.d.ts +33 -0
- package/src/lib/plugin.js +86 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server/billing-webhook.d.ts +25 -0
- package/src/lib/server/billing-webhook.js +1062 -0
- package/src/lib/server/billing-webhook.js.map +1 -0
- package/src/lib/server/checkout.d.ts +76 -0
- package/src/lib/server/checkout.js +477 -0
- package/src/lib/server/checkout.js.map +1 -0
- package/src/lib/server/connect.d.ts +24 -0
- package/src/lib/server/connect.js +205 -0
- package/src/lib/server/connect.js.map +1 -0
- package/src/lib/server/install-dataset-schema.d.ts +32 -0
- package/src/lib/server/install-dataset-schema.js +255 -0
- package/src/lib/server/install-dataset-schema.js.map +1 -0
- package/src/lib/server/install-email-starter.d.ts +65 -0
- package/src/lib/server/install-email-starter.js +279 -0
- package/src/lib/server/install-email-starter.js.map +1 -0
- package/src/lib/server/install-email-template.d.ts +34 -0
- package/src/lib/server/install-email-template.js +252 -0
- package/src/lib/server/install-email-template.js.map +1 -0
- package/src/lib/server/install-layout.d.ts +29 -0
- package/src/lib/server/install-layout.js +323 -0
- package/src/lib/server/install-layout.js.map +1 -0
- package/src/lib/server/install-pin-counts.d.ts +173 -0
- package/src/lib/server/install-pin-counts.js +248 -0
- package/src/lib/server/install-pin-counts.js.map +1 -0
- package/src/lib/server/install-plugin.d.ts +18 -0
- package/src/lib/server/install-plugin.js +405 -0
- package/src/lib/server/install-plugin.js.map +1 -0
- package/src/lib/server/install-template.d.ts +37 -0
- package/src/lib/server/install-template.js +348 -0
- package/src/lib/server/install-template.js.map +1 -0
- package/src/lib/server/install-theme.d.ts +42 -0
- package/src/lib/server/install-theme.js +313 -0
- package/src/lib/server/install-theme.js.map +1 -0
- package/src/lib/server/install.d.ts +27 -0
- package/src/lib/server/install.js +276 -0
- package/src/lib/server/install.js.map +1 -0
- package/src/lib/server/listing-versions.d.ts +26 -0
- package/src/lib/server/listing-versions.js +378 -0
- package/src/lib/server/listing-versions.js.map +1 -0
- package/src/lib/server/preview-image.d.ts +37 -0
- package/src/lib/server/preview-image.js +216 -0
- package/src/lib/server/preview-image.js.map +1 -0
- package/src/lib/server/provenance.d.ts +100 -0
- package/src/lib/server/provenance.js +118 -0
- package/src/lib/server/provenance.js.map +1 -0
- package/src/lib/server/publish-dataset-schema.d.ts +31 -0
- package/src/lib/server/publish-dataset-schema.js +202 -0
- package/src/lib/server/publish-dataset-schema.js.map +1 -0
- package/src/lib/server/publish-email-starter.d.ts +44 -0
- package/src/lib/server/publish-email-starter.js +231 -0
- package/src/lib/server/publish-email-starter.js.map +1 -0
- package/src/lib/server/publish-email-template.d.ts +33 -0
- package/src/lib/server/publish-email-template.js +211 -0
- package/src/lib/server/publish-email-template.js.map +1 -0
- package/src/lib/server/publish-layout.d.ts +33 -0
- package/src/lib/server/publish-layout.js +243 -0
- package/src/lib/server/publish-layout.js.map +1 -0
- package/src/lib/server/publish-plugin.d.ts +18 -0
- package/src/lib/server/publish-plugin.js +485 -0
- package/src/lib/server/publish-plugin.js.map +1 -0
- package/src/lib/server/publish-preconditions.d.ts +86 -0
- package/src/lib/server/publish-preconditions.js +160 -0
- package/src/lib/server/publish-preconditions.js.map +1 -0
- package/src/lib/server/publish-template.d.ts +27 -0
- package/src/lib/server/publish-template.js +215 -0
- package/src/lib/server/publish-template.js.map +1 -0
- package/src/lib/server/publish-theme.d.ts +38 -0
- package/src/lib/server/publish-theme.js +186 -0
- package/src/lib/server/publish-theme.js.map +1 -0
- package/src/lib/server/publish.d.ts +26 -0
- package/src/lib/server/publish.js +204 -0
- package/src/lib/server/publish.js.map +1 -0
- package/src/lib/server/publisher-profile-save.d.ts +30 -0
- package/src/lib/server/publisher-profile-save.js +226 -0
- package/src/lib/server/publisher-profile-save.js.map +1 -0
- package/src/lib/server/publisher-profile.d.ts +86 -0
- package/src/lib/server/publisher-profile.js +116 -0
- package/src/lib/server/publisher-profile.js.map +1 -0
- package/src/lib/server/purchase-entitlement.d.ts +83 -0
- package/src/lib/server/purchase-entitlement.js +118 -0
- package/src/lib/server/purchase-entitlement.js.map +1 -0
- package/src/lib/server/report.d.ts +32 -0
- package/src/lib/server/report.js +101 -0
- package/src/lib/server/report.js.map +1 -0
- package/src/lib/server/reviews.d.ts +41 -0
- package/src/lib/server/reviews.js +240 -0
- package/src/lib/server/reviews.js.map +1 -0
- package/src/lib/server/update-artifact.d.ts +19 -0
- package/src/lib/server/update-artifact.js +569 -0
- package/src/lib/server/update-artifact.js.map +1 -0
- package/src/lib/server/verification-request.d.ts +31 -0
- package/src/lib/server/verification-request.js +173 -0
- package/src/lib/server/verification-request.js.map +1 -0
- package/src/lib/server/version-stats.d.ts +202 -0
- package/src/lib/server/version-stats.js +305 -0
- package/src/lib/server/version-stats.js.map +1 -0
- package/src/lib/server.d.ts +30 -0
- package/src/lib/server.js +95 -0
- package/src/lib/server.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
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 {};
|