@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/update-artifact.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 newResourceScopeFields,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n type ScopeToken,\n} from '@aglyn/aglyn/server'\nimport {\n applyArtifactUpdate,\n planArtifactUpdate,\n summarizeSchemaChange,\n type ArtifactChange,\n type ArtifactUpdatePlan,\n} from '../model/artifact-merge'\nimport { ARTIFACT_BASE_COLLECTION } from '@aglyn/aglyn/app-utils/marketplace-provenance'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n listingArtifactType,\n resolveInstalledDatasetSchema,\n} from '../model/marketplace'\nimport { readPublishedProps } from '../model/marketplace-props'\nimport { recordInstallProvenance } from './provenance'\nimport { canActAsPublisher } from './publisher-profile'\nimport { requirePurchase } from './purchase-entitlement'\nimport { recordVersionMove } from './version-stats'\n\n/**\n * Updating a COPIED marketplace artifact (AGL-1018).\n *\n * The install routes update by overwriting: a component re-install writes the\n * publisher's tree straight over the workspace's copy, and a template or layout\n * re-install soft-deletes the previous bundle. Both are silent, and both throw\n * away work the customer did after installing.\n *\n * This route never overwrites without saying what will be lost. `preview`\n * classifies every field against AGL-1015's base snapshot and returns the plan;\n * `apply` writes only what the plan (and, for conflicts, the caller) chose.\n * There are two ways to take a new version and both are safe:\n *\n * * **merge** — take the publisher's changes to fields nobody here edited, keep\n * this workspace's edits, and overwrite a contested field only when it was\n * explicitly picked.\n * * **copy** — install the new version fresh and DETACH the customised copy,\n * which keeps it as an ordinary workspace artifact rather than deleting it.\n *\n * Without a base snapshot only `copy` is offered. Inventing an origin to diff\n * against is how a merge becomes confidently wrong.\n */\n\ntype Mode = 'merge' | 'copy'\n\n/** What a preview tells the client, and what the dialog renders. */\ninterface UpdatePreview {\n artifactType: string\n installedVersion: string | null\n availableVersion: string | null\n /** A three-way merge is possible: a base snapshot exists and the type supports it. */\n mergeable: boolean\n /** Why not, when it isn't. */\n reason?: string\n safe: ArtifactChange[]\n kept: ArtifactChange[]\n conflicts: ArtifactChange[]\n unchanged: number\n identical: boolean\n /** Dataset schemas only: what the change does to existing records. */\n schema?: {\n added: string[]\n removed: string[]\n retyped: string[]\n additiveOnly: boolean\n recordCount: number\n }\n}\n\n/** Everything the route needs to diff and write one copied artifact. */\ninterface Target {\n ref: FirebaseFirestore.DocumentReference\n /** The copy as it is now, in the same shape the base snapshot holds. */\n current: unknown\n /** Fields to write alongside merged content. */\n write: (content: any) => Record<string, unknown>\n /** Fields that detach this copy from its listing, for `copy` mode. */\n detach?: Record<string, unknown>\n /**\n * The AGL-1037 scope the FORKED copy is created with, or `null` when the\n * destination collection is not scoped (AGL-1478).\n *\n * Required, and required on purpose. `copy` mode writes a brand-new\n * document into `target.ref.parent` — whatever collection that turns out\n * to be — from a spread of carried fields, and a spread cannot carry a\n * field the source never had. For `orgs/{orgId}/datasets` that is the\n * AGL-1466 bug exactly: an unstamped dataset is not \"unrestricted\", it is\n * matched by no `array-contains-any` reader in the product, so it renders\n * on no site and is missing from the workflows and reference-health cards\n * even for an org-wide member.\n *\n * Making it optional with a sensible default would have been the smaller\n * diff and is the thing that fails: the two `mediaFolders` creators that\n * each forgot `visibleTo` were both written by people who had the field\n * in front of them. A sixth artifact type cannot be added here without\n * answering this, which is the only guarantee that survives a stranger.\n *\n * `null` is a real answer, not an escape hatch: components live in\n * `hosts/{hostId}/components` and layouts in `hosts/{hostId}/templates`,\n * both private to their host by construction and neither carrying a scope.\n */\n forkScope: readonly ScopeToken[] | null\n /**\n * The declared properties the new version carries (AGL-2933), for a\n * component or layout, sanitized; `undefined` when the version was published\n * before properties were carried, or the type declares none.\n *\n * Not part of the merged content. Every base snapshot recorded before this\n * field existed holds only the tree, so diffing properties three ways would\n * read each installed copy's declarations as a local edit to keep. They are\n * the publisher's contract with the tree instead: whatever the merge takes\n * of the nodes binds to them, so the incoming list replaces the installed\n * one, in both modes.\n */\n props?: ReturnType<typeof readPublishedProps>\n}\n\nconst trimChanges = (changes: ArtifactChange[]): ArtifactChange[] =>\n // The dialog lists changes; a 400-node tree that changed wholesale would\n // otherwise ship its entire content twice through a JSON response.\n changes.slice(0, 200)\n\nexport const updateArtifactHandler: 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 const action = req.body?.action === 'apply' ? 'apply' : 'preview'\n const mode: Mode = req.body?.mode === 'copy' ? 'copy' : 'merge'\n const takePaths: string[] = Array.isArray(req.body?.takePaths)\n ? req.body.takePaths.map((path: unknown) => String(path))\n : []\n const confirmDestructive = Boolean(req.body?.confirmDestructive)\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 // The same gate installing uses: taking a new version of something is\n // installing it, and a role that cannot install must not be able to\n // rewrite an installed artifact through a different door.\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!membership.permissions.installPlugins) {\n return res.status(403).json({\n error: 'Your organization role does not allow installing from the marketplace',\n })\n }\n const firestore = firebaseAdmin.app().firestore()\n 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\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 ) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // Taking a NEW version of a paid listing is buying content, and this\n // route never asked whether the caller still owned it (AGL-1699). The\n // install routes gate; this one only checked the org ROLE, so a buyer\n // who was refunded — and even in `copy` mode, which installs the new\n // version fresh — kept pulling every future paid release through here.\n //\n // Deliberately not a regression for anyone legitimate: a live purchase,\n // a free listing, or the publisher's own listing all pass, and nobody\n // without one of those could have had an installed copy to update.\n // The `priceUsd > 0` test is out here rather than left to the helper so\n // a free listing — most of them — does not pay for a publisher lookup.\n const priceUsd = Number(listing.priceUsd ?? 0)\n if (priceUsd > 0) {\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: await canActAsPublisher(\n firestore,\n decoded.uid,\n listing.profileId,\n ),\n })\n if (unpaid) return res.status(402).json(unpaid)\n }\n\n const artifactType = listingArtifactType(listing)\n if (artifactType === 'plugin') {\n // Plugins are pinned, not copied: an update moves a pointer at immutable\n // bytes (AGL-1017) and has nothing to merge.\n return res.status(400).json({\n error: 'Plugins update by re-pinning — use the install route',\n })\n }\n\n const versionSnapshot = await listingRef\n .collection('versions')\n .doc(String(listing.latestVersion))\n .get()\n if (!versionSnapshot.exists) {\n return res.status(404).json({ error: 'Listing version missing' })\n }\n const availableVersion =\n listing.latestVersion == null ? null : String(listing.latestVersion)\n\n const orgId = (await getOrgForHost(hostId))?.orgId ?? ''\n\n /** The installed copy, the base it was installed from, and the offer. */\n let target: Target | null = null\n let incoming: unknown = null\n let installedVersion: string | null = null\n let baseSha: string | null = null\n let notMergeable: string | undefined\n let schemaSummary: UpdatePreview['schema']\n\n if (artifactType === 'component') {\n const existing = await hostRef\n .collection('components')\n .where('marketplace.listingId', '==', listingId)\n .limit(1)\n .get()\n const doc = existing.docs.find((entry) => !entry.get('deletedAt'))\n if (!doc) return res.status(404).json({ error: 'Not installed on this site' })\n const version = versionSnapshot.data() as any\n incoming = { rootId: version.rootId, nodes: version.nodes }\n installedVersion = String(\n doc.get('installedFrom.version') ?? doc.get('marketplace.version') ?? '',\n ) || null\n baseSha = doc.get('installedFrom.sha256') ?? null\n target = {\n ref: doc.ref,\n current: {\n rootId: doc.get('rootId'),\n // DECODED, because everything downstream compares by VALUE: the\n // divergence hash, and `planArtifactUpdate`'s three-way diff over\n // dotted paths like `nodes.abc123.props.title`. A stored `Bytes`\n // has no such paths, so the merge would see the whole tree as\n // removed and write an empty one back (AGL-1151).\n nodes: decodeStoredNodes(doc.get('nodes')),\n },\n write: (content) => ({\n rootId: content?.rootId ?? null,\n // Compressed at rest, like every other writer of this document.\n nodes: Buffer.from(encodeStoredNodes(content?.nodes ?? {})!),\n }),\n // Detaching keeps the customised component as an ordinary one: it stays\n // on every page that uses it, and only stops being tracked against the\n // listing — which is what makes \"install as a new copy\" non-destructive\n // without leaving two documents claiming to be the same install.\n detach: {\n marketplace: firebaseAdmin.firestore.FieldValue.delete(),\n detachedFrom: { listingId, version: installedVersion },\n },\n // `hosts/{hostId}/components` is private to its host; no scope.\n forkScope: null,\n props: readPublishedProps(version.props),\n }\n } else if (artifactType === 'layout') {\n const existing = await hostRef\n .collection('templates')\n .where('source.listingId', '==', listingId)\n .get()\n const doc = existing.docs.find(\n (entry) => !entry.get('deletedAt') && entry.get('kind') === 'layout',\n )\n if (!doc) return res.status(404).json({ error: 'Not installed on this site' })\n const layout = versionSnapshot.get('layout') as any\n incoming = { rootId: layout?.rootId, nodes: layout?.nodes }\n installedVersion = String(\n doc.get('installedFrom.version') ?? doc.get('source.version') ?? '',\n ) || null\n baseSha = doc.get('installedFrom.sha256') ?? null\n target = {\n ref: doc.ref,\n current: {\n rootId: doc.get('rootId'),\n // DECODED, because everything downstream compares by VALUE: the\n // divergence hash, and `planArtifactUpdate`'s three-way diff over\n // dotted paths like `nodes.abc123.props.title`. A stored `Bytes`\n // has no such paths, so the merge would see the whole tree as\n // removed and write an empty one back (AGL-1151).\n nodes: decodeStoredNodes(doc.get('nodes')),\n },\n write: (content) => ({\n rootId: content?.rootId ?? null,\n // Compressed at rest, like every other writer of this document.\n nodes: Buffer.from(encodeStoredNodes(content?.nodes ?? {})!),\n }),\n detach: {\n source: { type: 'workspace' },\n detachedFrom: { listingId, version: installedVersion },\n },\n // `hosts/{hostId}/templates` is private to its host; no scope.\n forkScope: null,\n props: readPublishedProps(layout?.props),\n }\n } else if (artifactType === 'datasetSchema') {\n if (!orgId) {\n return res.status(404).json({ error: 'Site has no owning organization' })\n }\n const orgRef = firestore.collection('orgs').doc(orgId)\n const datasets = await orgRef.collection('datasets').get()\n const doc = datasets.docs.find(\n (entry) =>\n entry.get('source.listingId') === listingId && !entry.get('deletedAt'),\n )\n if (!doc) {\n return res.status(404).json({ error: 'Not installed in this organization' })\n }\n // Relink the incoming schema onto THIS org's datasets exactly as install\n // does — the base holds the relinked shape, so diffing the published one\n // would report every reference field as a user edit.\n const byLabel: Record<string, string> = {}\n for (const entry of datasets.docs) {\n const label = String(entry.get('displayName') ?? '').toLowerCase()\n if (label && !byLabel[label]) byLabel[label] = entry.id\n }\n const { schema } = resolveInstalledDatasetSchema(\n versionSnapshot.get('datasetSchema') as any,\n byLabel,\n )\n incoming = schema\n installedVersion = String(\n doc.get('installedFrom.version') ?? doc.get('source.version') ?? '',\n ) || null\n baseSha = doc.get('installedFrom.sha256') ?? null\n const current = doc.get('model') ?? { order: doc.get('fields') ?? [], fields: {} }\n const summary = summarizeSchemaChange(current as any, schema as any)\n // The count is read for the preview, not estimated: \"3 fields will be\n // removed\" means nothing without \"from 1,240 records\".\n const records = await doc.ref\n .collection('records')\n .count()\n .get()\n .catch(() => null)\n schemaSummary = {\n added: summary.added,\n removed: summary.removed,\n retyped: summary.retyped,\n additiveOnly: summary.additiveOnly,\n recordCount: records?.data().count ?? 0,\n }\n const installedScope = doc.get('visibleTo')\n target = {\n ref: doc.ref,\n current,\n write: (content) => ({\n model: content,\n // The v1 flat list the older editor still reads, kept in step.\n fields: content?.order ?? [],\n }),\n // A fork INHERITS the copy it forked from, never a fresh default: a\n // dataset restricted to one client's site must not reappear org-wide\n // because somebody took a publisher update as a separate copy. An\n // absent scope reads as org-wide everywhere else (`visibleToHost`),\n // so it does here. A stored empty array — \"visible to nobody\" — is\n // passed through and throws at the write rather than being widened,\n // which is the right direction to fail in.\n forkScope: Array.isArray(installedScope)\n ? (installedScope as ScopeToken[])\n : [ORG_SCOPE_TOKEN],\n }\n } else if (artifactType === 'theme') {\n // A theme lives on the host document itself, so there is no artifact doc\n // to look up and no \"not installed on this site\" — the site either runs\n // this listing's theme or it does not.\n if (hostSnapshot.get('themeInstalledFrom.listingId') !== listingId) {\n return res.status(404).json({ error: 'Not installed on this site' })\n }\n incoming = versionSnapshot.get('theme') ?? {}\n installedVersion =\n String(hostSnapshot.get('themeInstalledFrom.version') ?? '') || null\n baseSha = hostSnapshot.get('themeInstalledFrom.sha256') ?? null\n target = {\n ref: hostRef,\n current: hostSnapshot.get('theme') ?? {},\n write: (content) => ({ theme: content ?? {} }),\n // No `detach`: a site has exactly one theme, so \"install as a separate\n // copy\" has nowhere to put the second one. Merge is the only safe way\n // to take a theme update, and the preview says so.\n //\n // A theme is a FIELD on the host document, so there is no document to\n // scope even if it could fork.\n forkScope: null,\n }\n } else if (artifactType === 'template') {\n // A published template bundle's screens carry no stable identity — only a\n // displayName and a slug — so there is nothing to pair an old screen to a\n // new one by. Any merge would be a guess dressed up as a diff.\n notMergeable =\n 'Template bundles have no stable per-screen identity, so their screens ' +\n 'cannot be matched across versions. Installing the new version replaces ' +\n 'this bundle in your Templates library — pages you already created from ' +\n 'the old templates are ordinary screens and are untouched.'\n } else if (artifactType === 'emailTemplate') {\n // Already safe by construction: an email template installs as an inactive\n // draft version, so taking a new one changes nothing that is being sent.\n notMergeable =\n 'Email templates install as a draft version and never replace what your ' +\n 'site is sending. Add the new version as another draft, then activate ' +\n 'it when you are ready.'\n }\n\n if (notMergeable) {\n const preview: UpdatePreview = {\n artifactType,\n installedVersion: null,\n availableVersion,\n mergeable: false,\n reason: notMergeable,\n safe: [],\n kept: [],\n conflicts: [],\n unchanged: 0,\n identical: false,\n }\n return res.status(200).json({ preview })\n }\n if (!target) {\n return res.status(404).json({ error: 'Nothing installed to update' })\n }\n\n const baseSnapshot = baseSha\n ? await firestore\n .collection(ARTIFACT_BASE_COLLECTION)\n .doc(baseSha)\n .get()\n .catch(() => null)\n : null\n const base = baseSnapshot?.exists ? baseSnapshot.get('content') : undefined\n const mergeable = base !== undefined\n\n let plan: ArtifactUpdatePlan | null = null\n if (mergeable) plan = planArtifactUpdate(base, target.current, incoming)\n\n if (action === 'preview') {\n const preview: UpdatePreview = {\n artifactType,\n installedVersion,\n availableVersion,\n mergeable,\n ...(mergeable\n ? {}\n : {\n reason:\n 'This was installed before update tracking recorded an ' +\n 'original, so there is no way to tell your changes from the ' +\n 'publisher’s. Installing the new version as a separate copy ' +\n 'keeps yours intact.',\n }),\n safe: trimChanges(plan?.safe ?? []),\n kept: trimChanges(plan?.kept ?? []),\n conflicts: trimChanges(plan?.conflicts ?? []),\n unchanged: plan?.unchanged ?? 0,\n identical: plan?.identical ?? false,\n ...(schemaSummary ? { schema: schemaSummary } : {}),\n }\n return res.status(200).json({ preview })\n }\n\n // ---- apply ----\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n\n if (mode === 'merge') {\n if (!plan) {\n return res.status(409).json({\n error:\n 'No original snapshot for this install — it can only be taken as a new copy',\n })\n }\n // A schema change that removes or retypes a field reinterprets rows that\n // already exist. It is applicable, but never without the caller having\n // been told the count and said yes to it specifically.\n if (schemaSummary && !schemaSummary.additiveOnly && !confirmDestructive) {\n return res.status(409).json({\n error:\n `This update removes or retypes ${\n schemaSummary.removed.length + schemaSummary.retyped.length\n } field(s) on a dataset holding ${schemaSummary.recordCount} record(s).`,\n needsConfirmation: true,\n schema: schemaSummary,\n })\n }\n const merged = applyArtifactUpdate(plan, target.current, { takePaths })\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: artifactType as never,\n // The base for NEXT time is the publisher's version, not the merged\n // result: the base answers \"what did the publisher ship\", and seeding\n // it with the merge would quietly launder this update's kept edits into\n // the publisher's column, where the next update could take them away.\n content: incoming,\n })\n await target.ref.set(\n {\n ...target.write(merged.content),\n ...(target.props && { props: target.props }),\n // A theme is a FIELD on the host document, not a document of its own,\n // so it namespaces its provenance (`themeInstalledFrom`) and has no\n // legacy `source`/`marketplace` pair — writing those here would put a\n // stray marketplace stamp on the host itself, where `resolveProvenance`\n // would later read it as the host being an installed artifact.\n ...(artifactType === 'theme'\n ? { themeInstalledFrom: provenance.installedFrom }\n : {\n installedFrom: provenance.installedFrom,\n // The legacy per-type version fields the console still reads.\n ...(artifactType === 'component'\n ? {\n marketplace: {\n listingId,\n profileId: listing.profileId ?? null,\n version: listing.latestVersion ?? null,\n },\n }\n : {\n source: {\n type: 'marketplace' as const,\n listingId,\n version: listing.latestVersion ?? null,\n },\n }),\n }),\n updatedAt: now,\n },\n { merge: true },\n )\n // Per-version tally (AGL-1036): this install just moved versions, which\n // is the whole reason the per-version count exists.\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType,\n from: installedVersion,\n to: availableVersion,\n })\n return res.status(200).json({\n updated: true,\n mode,\n version: availableVersion,\n applied: merged.applied.length,\n keptLocal: plan.kept.length,\n skipped: merged.skipped,\n baseStored: provenance.baseStored,\n })\n }\n\n // ---- copy ----\n if (!target.detach) {\n // Only types with a detachable link get a copy from here. A dataset's\n // \"new copy\" is a whole new dataset, which consumes org quota and lands\n // empty — exactly what the install route already does, with the quota\n // check this one does not have.\n //\n // This return is also, today, the only thing keeping the fork below\n // from creating an unstamped `orgs/{orgId}/datasets` document\n // (AGL-1478): a dataset never reaches it because it has no `detach`.\n // That is a guard by accident, three statements away from the write\n // and phrased as a quota argument — give `datasetSchema` a `detach`\n // for any reason and the scope hole opens with it. `Target.forkScope`\n // is the guard that stays true whichever way this branch goes.\n return res.status(400).json({\n error:\n artifactType === 'theme'\n ? // A site has one theme, so there is no second place to put a\n // copy. Merging keeps this site's edits; the way back to the\n // previous theme is the install route's `revert`.\n 'A site has only one theme, so this update can only be merged. ' +\n 'Your edits to fields the publisher did not touch are kept.'\n : 'Install a fresh copy of this from the listing instead',\n })\n }\n // The customised copy is detached rather than deleted or overwritten: it\n // keeps working everywhere it is used, and only stops being the document\n // this listing updates. Without the detach two documents would claim the\n // same install and the next update would pick between them arbitrarily.\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: artifactType as never,\n content: incoming,\n })\n const batch = firestore.batch()\n if (target.detach) {\n batch.set(target.ref, { ...target.detach, updatedAt: now }, { merge: true })\n }\n const collectionRef = target.ref.parent\n const freshRef = collectionRef.doc(createResourceUid())\n const carried = (await target.ref.get()).data() ?? {}\n batch.set(freshRef, {\n // Everything that is not content travels from the copy being replaced —\n // the display name, description and kind — so the new one lands in the\n // same place in the same library rather than as an unrecognisable stub.\n ...(carried.kind ? { kind: carried.kind } : {}),\n displayName: String(listing.displayName ?? carried.displayName ?? 'Untitled'),\n ...(listing.description ? { description: listing.description } : {}),\n // Not part of the `carried` spread above, deliberately: a spread\n // cannot carry a field the source is missing, and \"the source had one\"\n // is not the question a scoped collection asks. `Target.forkScope`\n // makes every artifact type answer it (AGL-1478), and this throws\n // rather than storing a scope nobody can see through.\n ...newResourceScopeFields(target.forkScope),\n ...target.write(incoming),\n ...(target.props\n ? { props: target.props }\n : Array.isArray(carried.props)\n ? { props: carried.props }\n : {}),\n ...(artifactType === 'component'\n ? {\n marketplace: {\n listingId,\n profileId: listing.profileId ?? null,\n version: listing.latestVersion ?? null,\n },\n }\n : {\n source: {\n type: 'marketplace' as const,\n listingId,\n version: listing.latestVersion ?? null,\n },\n }),\n installedFrom: provenance.installedFrom,\n deletedAt: null,\n createdAt: now,\n updatedAt: now,\n })\n await batch.commit()\n // Per-version tally (AGL-1036). Still a move, not a second install: the\n // detached copy stops being tracked against the listing, so the version it\n // held is genuinely vacated.\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType,\n from: installedVersion,\n to: availableVersion,\n })\n return res.status(200).json({\n updated: true,\n mode,\n version: availableVersion,\n newId: freshRef.id,\n detached: Boolean(target.detach),\n baseStored: provenance.baseStored,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Update failed' })\n }\n}\n\nexport default updateArtifactHandler\n"],"names":["createResourceUid","decodeStoredNodes","encodeStoredNodes","newResourceScopeFields","ORG_SCOPE_TOKEN","applyArtifactUpdate","planArtifactUpdate","summarizeSchemaChange","ARTIFACT_BASE_COLLECTION","firebaseAdmin","getOrgForHost","resolveOrgPermissions","listingArtifactType","resolveInstalledDatasetSchema","readPublishedProps","recordInstallProvenance","canActAsPublisher","requirePurchase","recordVersionMove","trimChanges","changes","slice","updateArtifactHandler","req","res","method","status","json","error","listingId","String","body","hostId","action","mode","takePaths","Array","isArray","map","path","confirmDestructive","Boolean","authorization","headers","idToken","startsWith","length","undefined","listing","decoded","app","auth","verifyIdToken","membership","uid","permissions","installPlugins","firestore","hostRef","collection","doc","hostSnapshot","get","exists","listingRef","listingSnapshot","data","deletedAt","hiddenAt","priceUsd","Number","unpaid","buyerUid","buyerOrgId","orgId","ownsListing","profileId","artifactType","versionSnapshot","latestVersion","availableVersion","target","incoming","installedVersion","baseSha","notMergeable","schemaSummary","existing","where","limit","docs","find","entry","version","rootId","nodes","ref","current","write","content","Buffer","from","detach","marketplace","FieldValue","delete","detachedFrom","forkScope","props","layout","source","type","orgRef","datasets","byLabel","label","toLowerCase","id","schema","order","fields","summary","records","count","catch","added","removed","retyped","additiveOnly","recordCount","installedScope","model","theme","preview","mergeable","reason","safe","kept","conflicts","unchanged","identical","baseSnapshot","base","plan","now","serverTimestamp","needsConfirmation","merged","provenance","set","themeInstalledFrom","installedFrom","updatedAt","merge","to","updated","applied","keptLocal","skipped","baseStored","batch","collectionRef","parent","freshRef","carried","kind","displayName","description","createdAt","commit","newId","detached","console"],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
* A publisher asking for — or taking back — the Verified badge (AGL-1217).
|
|
20
|
+
*
|
|
21
|
+
* Asking grants nothing. This route only ever moves the request between
|
|
22
|
+
* `pending` and `withdrawn`; the checklist gate on the staff `verify` action
|
|
23
|
+
* stays the single route to the badge itself. That separation is the whole
|
|
24
|
+
* safety property here, because this endpoint is reachable by any publisher.
|
|
25
|
+
*
|
|
26
|
+
* Eligibility is decided by `verificationRequestBlock`, the same function the
|
|
27
|
+
* button calls. Sharing it is the point: a client-side rule the server does
|
|
28
|
+
* not enforce is decoration, and a server rule the client does not know
|
|
29
|
+
* produces a button that fails with no explanation.
|
|
30
|
+
*/
|
|
31
|
+
export declare const verificationRequestHandler: PluginApiHandler;
|
|
@@ -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
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
18
|
+
import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
19
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
20
|
+
import { VERIFICATION_BLOCK_MESSAGES, verificationRequestBlock } from "../model/marketplace.js";
|
|
21
|
+
import { canActAsPublisher } from "./publisher-profile.js";
|
|
22
|
+
/**
|
|
23
|
+
* A publisher asking for — or taking back — the Verified badge (AGL-1217).
|
|
24
|
+
*
|
|
25
|
+
* Asking grants nothing. This route only ever moves the request between
|
|
26
|
+
* `pending` and `withdrawn`; the checklist gate on the staff `verify` action
|
|
27
|
+
* stays the single route to the badge itself. That separation is the whole
|
|
28
|
+
* safety property here, because this endpoint is reachable by any publisher.
|
|
29
|
+
*
|
|
30
|
+
* Eligibility is decided by `verificationRequestBlock`, the same function the
|
|
31
|
+
* button calls. Sharing it is the point: a client-side rule the server does
|
|
32
|
+
* not enforce is decoration, and a server rule the client does not know
|
|
33
|
+
* produces a button that fails with no explanation.
|
|
34
|
+
*/ export const verificationRequestHandler = async (req, res)=>{
|
|
35
|
+
var _ref, _ref1, _req_headers_authorization;
|
|
36
|
+
var _req_body, _req_body1;
|
|
37
|
+
if (req.method !== 'POST') {
|
|
38
|
+
return res.status(405).json({
|
|
39
|
+
error: 'Method not allowed'
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const listingId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.listingId) != null ? _ref : '').trim();
|
|
43
|
+
const action = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.action) != null ? _ref1 : 'request');
|
|
44
|
+
if (!listingId) return res.status(400).json({
|
|
45
|
+
error: 'Missing listingId'
|
|
46
|
+
});
|
|
47
|
+
if (action !== 'request' && action !== 'withdraw') {
|
|
48
|
+
return res.status(400).json({
|
|
49
|
+
error: 'Unknown action'
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
|
|
53
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : '';
|
|
54
|
+
if (!idToken) return res.status(401).json({
|
|
55
|
+
error: 'Unauthenticated'
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
var _listing_profileId;
|
|
59
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
60
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
61
|
+
const listingRef = firestore.collection('marketplaceListings').doc(listingId);
|
|
62
|
+
const listing = (await listingRef.get()).data();
|
|
63
|
+
if (!listing) return res.status(404).json({
|
|
64
|
+
error: 'Unknown listing'
|
|
65
|
+
});
|
|
66
|
+
// Membership of the PUBLISHING org, resolved from the listing rather than
|
|
67
|
+
// from anything the caller sent. A client-supplied orgId here would let a
|
|
68
|
+
// publisher of one listing act on another.
|
|
69
|
+
const publisherOrgId = String((_listing_profileId = listing.profileId) != null ? _listing_profileId : '');
|
|
70
|
+
if (!publisherOrgId || !await canActAsPublisher(firestore, decoded.uid, publisherOrgId)) {
|
|
71
|
+
// 404, not 403: a listing the caller does not publish should not be
|
|
72
|
+
// confirmed to exist by the shape of the refusal.
|
|
73
|
+
return res.status(404).json({
|
|
74
|
+
error: 'Unknown listing'
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (action === 'withdraw') {
|
|
78
|
+
var _listing_verificationRequest;
|
|
79
|
+
if (((_listing_verificationRequest = listing.verificationRequest) == null ? void 0 : _listing_verificationRequest.state) !== 'pending') {
|
|
80
|
+
return res.status(409).json({
|
|
81
|
+
error: 'No request is waiting'
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
await listingRef.set({
|
|
85
|
+
verificationRequest: _extends({}, listing.verificationRequest, {
|
|
86
|
+
state: 'withdrawn',
|
|
87
|
+
decidedAt: FieldValue.serverTimestamp(),
|
|
88
|
+
// The publisher decided this one, so `decidedBy` is them. Staff
|
|
89
|
+
// uids and publisher uids share the field because the question it
|
|
90
|
+
// answers — who ended this request — has one answer either way.
|
|
91
|
+
decidedBy: decoded.uid
|
|
92
|
+
})
|
|
93
|
+
}, {
|
|
94
|
+
merge: true
|
|
95
|
+
});
|
|
96
|
+
return res.status(200).json({
|
|
97
|
+
ok: true,
|
|
98
|
+
state: 'withdrawn'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
// Re-checked in a transaction rather than trusting the read above: two
|
|
102
|
+
// tabs pressing the button together would otherwise both pass the check
|
|
103
|
+
// and the second would overwrite the first request's timestamp, resetting
|
|
104
|
+
// its place in the queue.
|
|
105
|
+
const outcome = await firestore.runTransaction(async (tx)=>{
|
|
106
|
+
var _fresh_profileId;
|
|
107
|
+
const fresh = (await tx.get(listingRef)).data();
|
|
108
|
+
if (!fresh) return {
|
|
109
|
+
error: 'Unknown listing',
|
|
110
|
+
status: 404
|
|
111
|
+
};
|
|
112
|
+
// `viewerOrgId` is fed the listing's own `profileId` on purpose, which
|
|
113
|
+
// makes the `not-publisher` branch unreachable here. That is NOT the
|
|
114
|
+
// authorization check — `canActAsPublisher` above is, and it ran against
|
|
115
|
+
// the caller's uid. This call is asking the remaining question: is the
|
|
116
|
+
// listing in a state that can carry a request right now.
|
|
117
|
+
const blocked = verificationRequestBlock({
|
|
118
|
+
listing: fresh,
|
|
119
|
+
viewerOrgId: String((_fresh_profileId = fresh.profileId) != null ? _fresh_profileId : ''),
|
|
120
|
+
nowMs: Date.now()
|
|
121
|
+
});
|
|
122
|
+
if (blocked) {
|
|
123
|
+
return {
|
|
124
|
+
error: VERIFICATION_BLOCK_MESSAGES[blocked],
|
|
125
|
+
reason: blocked,
|
|
126
|
+
status: 409
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
tx.set(listingRef, {
|
|
130
|
+
verificationRequest: {
|
|
131
|
+
state: 'pending',
|
|
132
|
+
requestedAt: FieldValue.serverTimestamp(),
|
|
133
|
+
requestedBy: decoded.uid,
|
|
134
|
+
// Explicitly cleared, because `merge: true` deep-merges MAPS.
|
|
135
|
+
// Without these a re-request after a decline would keep the old
|
|
136
|
+
// `declineReason` and `decidedBy`, and the staff queue would show
|
|
137
|
+
// a brand-new pending request already carrying a verdict.
|
|
138
|
+
decidedAt: FieldValue.delete(),
|
|
139
|
+
decidedBy: FieldValue.delete(),
|
|
140
|
+
declineReason: FieldValue.delete()
|
|
141
|
+
}
|
|
142
|
+
}, {
|
|
143
|
+
merge: true
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
ok: true
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
if ('error' in outcome) {
|
|
150
|
+
return res.status(outcome.status).json({
|
|
151
|
+
error: outcome.error,
|
|
152
|
+
reason: outcome.reason
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return res.status(200).json({
|
|
156
|
+
ok: true,
|
|
157
|
+
state: 'pending'
|
|
158
|
+
});
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// Only a refused credential is the caller's 401. The same try reads the
|
|
161
|
+
// listing and runs the transaction, and a failure there — or in checking
|
|
162
|
+
// the token at all — is ours and keeps a 5xx, so it pages (AGL-2852).
|
|
163
|
+
if (isRefusedIdToken(error)) return res.status(401).json({
|
|
164
|
+
error: 'Unauthenticated'
|
|
165
|
+
});
|
|
166
|
+
console.error('verification-request failed', error);
|
|
167
|
+
return res.status(500).json({
|
|
168
|
+
error: 'The verification request could not be saved'
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
//# sourceMappingURL=verification-request.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/verification-request.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n VERIFICATION_BLOCK_MESSAGES,\n verificationRequestBlock,\n} from '../model/marketplace'\nimport { canActAsPublisher } from './publisher-profile'\n\n/**\n * A publisher asking for — or taking back — the Verified badge (AGL-1217).\n *\n * Asking grants nothing. This route only ever moves the request between\n * `pending` and `withdrawn`; the checklist gate on the staff `verify` action\n * stays the single route to the badge itself. That separation is the whole\n * safety property here, because this endpoint is reachable by any publisher.\n *\n * Eligibility is decided by `verificationRequestBlock`, the same function the\n * button calls. Sharing it is the point: a client-side rule the server does\n * not enforce is decoration, and a server rule the client does not know\n * produces a button that fails with no explanation.\n */\nexport const verificationRequestHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const listingId = String(req.body?.listingId ?? '').trim()\n const action = String(req.body?.action ?? 'request')\n if (!listingId) return res.status(400).json({ error: 'Missing listingId' })\n if (action !== 'request' && action !== 'withdraw') {\n return res.status(400).json({ error: 'Unknown action' })\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const listingRef = firestore.collection('marketplaceListings').doc(listingId)\n\n const listing = (await listingRef.get()).data()\n if (!listing) return res.status(404).json({ error: 'Unknown listing' })\n\n // Membership of the PUBLISHING org, resolved from the listing rather than\n // from anything the caller sent. A client-supplied orgId here would let a\n // publisher of one listing act on another.\n const publisherOrgId = String(listing.profileId ?? '')\n if (\n !publisherOrgId ||\n !(await canActAsPublisher(firestore, decoded.uid, publisherOrgId))\n ) {\n // 404, not 403: a listing the caller does not publish should not be\n // confirmed to exist by the shape of the refusal.\n return res.status(404).json({ error: 'Unknown listing' })\n }\n\n if (action === 'withdraw') {\n if (listing.verificationRequest?.state !== 'pending') {\n return res.status(409).json({ error: 'No request is waiting' })\n }\n await listingRef.set(\n {\n verificationRequest: {\n ...listing.verificationRequest,\n state: 'withdrawn',\n decidedAt: FieldValue.serverTimestamp(),\n // The publisher decided this one, so `decidedBy` is them. Staff\n // uids and publisher uids share the field because the question it\n // answers — who ended this request — has one answer either way.\n decidedBy: decoded.uid,\n },\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, state: 'withdrawn' })\n }\n\n // Re-checked in a transaction rather than trusting the read above: two\n // tabs pressing the button together would otherwise both pass the check\n // and the second would overwrite the first request's timestamp, resetting\n // its place in the queue.\n const outcome = await firestore.runTransaction(async (tx) => {\n const fresh = (await tx.get(listingRef)).data()\n if (!fresh) return { error: 'Unknown listing', status: 404 } as const\n // `viewerOrgId` is fed the listing's own `profileId` on purpose, which\n // makes the `not-publisher` branch unreachable here. That is NOT the\n // authorization check — `canActAsPublisher` above is, and it ran against\n // the caller's uid. This call is asking the remaining question: is the\n // listing in a state that can carry a request right now.\n const blocked = verificationRequestBlock({\n listing: fresh as never,\n viewerOrgId: String(fresh.profileId ?? ''),\n nowMs: Date.now(),\n })\n if (blocked) {\n return {\n error: VERIFICATION_BLOCK_MESSAGES[blocked],\n reason: blocked,\n status: 409,\n } as const\n }\n tx.set(\n listingRef,\n {\n verificationRequest: {\n state: 'pending',\n requestedAt: FieldValue.serverTimestamp(),\n requestedBy: decoded.uid,\n // Explicitly cleared, because `merge: true` deep-merges MAPS.\n // Without these a re-request after a decline would keep the old\n // `declineReason` and `decidedBy`, and the staff queue would show\n // a brand-new pending request already carrying a verdict.\n decidedAt: FieldValue.delete(),\n decidedBy: FieldValue.delete(),\n declineReason: FieldValue.delete(),\n },\n },\n { merge: true },\n )\n return { ok: true } as const\n })\n\n if ('error' in outcome) {\n return res\n .status(outcome.status)\n .json({ error: outcome.error, reason: outcome.reason })\n }\n return res.status(200).json({ ok: true, state: 'pending' })\n } catch (error) {\n // Only a refused credential is the caller's 401. The same try reads the\n // listing and runs the transaction, and a failure there — or in checking\n // the token at all — is ours and keeps a 5xx, so it pages (AGL-2852).\n if (isRefusedIdToken(error)) return res.status(401).json({ error: 'Unauthenticated' })\n console.error('verification-request failed', error)\n return res.status(500).json({ error: 'The verification request could not be saved' })\n }\n}\n"],"names":["firebaseAdmin","isRefusedIdToken","FieldValue","VERIFICATION_BLOCK_MESSAGES","verificationRequestBlock","canActAsPublisher","verificationRequestHandler","req","res","method","status","json","error","listingId","String","body","trim","action","authorization","headers","idToken","startsWith","slice","length","listing","decoded","app","auth","verifyIdToken","firestore","listingRef","collection","doc","get","data","publisherOrgId","profileId","uid","verificationRequest","state","set","decidedAt","serverTimestamp","decidedBy","merge","ok","outcome","runTransaction","tx","fresh","blocked","viewerOrgId","nowMs","Date","now","reason","requestedAt","requestedBy","delete","declineReason","console"],"mappings":"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"}
|
|
@@ -0,0 +1,202 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* Per-version install counters (AGL-1036).
|
|
20
|
+
*
|
|
21
|
+
* The listing already carries `installCount` (cumulative) and `activeInstalls`
|
|
22
|
+
* (live pins), but neither answers the question a publisher asks before
|
|
23
|
+
* changing anything: **who is still on the old version?** A listing-level
|
|
24
|
+
* number cannot, and a re-pin does not even move it — the pin already existed,
|
|
25
|
+
* so nothing increments while the install quietly changes version underneath.
|
|
26
|
+
*
|
|
27
|
+
* This module is the one writer of the per-version pair, deliberately: the
|
|
28
|
+
* listing-level counters drifted apart precisely because five install routes
|
|
29
|
+
* each incremented their own, and the per-version pair has twice the surface —
|
|
30
|
+
* every version change is TWO writes, a decrement of what was left and an
|
|
31
|
+
* increment of what was taken.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Where a version's counters live, which differs by artifact type.
|
|
35
|
+
*
|
|
36
|
+
* Plugins keep their versions in `pluginVersions` (publish internals, review
|
|
37
|
+
* state, bundle pointers); everything else keeps content snapshots in
|
|
38
|
+
* `versions`. Both are server-only, which is why the counters are read back
|
|
39
|
+
* out through the listing-versions route rather than by the client.
|
|
40
|
+
*/
|
|
41
|
+
export declare function versionCollectionFor(artifactType: MarketplaceArtifactType | string | null | undefined): 'pluginVersions' | 'versions';
|
|
42
|
+
/** One version's pair of counters, as the version doc stores them. */
|
|
43
|
+
export interface VersionInstallTally {
|
|
44
|
+
version: string;
|
|
45
|
+
installCount: number;
|
|
46
|
+
activeInstalls: number;
|
|
47
|
+
}
|
|
48
|
+
/** The listing-level pair, which counts the same two things (AGL-1418). */
|
|
49
|
+
export interface ListingInstallTally {
|
|
50
|
+
installCount?: number | null;
|
|
51
|
+
activeInstalls?: number | null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The same quantity counted from the pins, which are ground truth (AGL-1419).
|
|
55
|
+
*
|
|
56
|
+
* Both stored levels are accumulators, and an accumulator cannot come back
|
|
57
|
+
* down: nothing decrements when a tenant erase sweeps `installs` or the
|
|
58
|
+
* console deletes a host pin client-side. The pins can, because they ARE the
|
|
59
|
+
* installs — so when this is present it does not get reconciled against
|
|
60
|
+
* anything, it simply wins.
|
|
61
|
+
*/
|
|
62
|
+
export interface LivePinTally {
|
|
63
|
+
/** Live pins for the listing. A verified `0` is a count, not a gap. */
|
|
64
|
+
activeInstalls: number;
|
|
65
|
+
/** Live pins per version id, when the split was derived too. */
|
|
66
|
+
byVersion?: Map<string, number> | Record<string, number> | null;
|
|
67
|
+
}
|
|
68
|
+
export interface ReconciledInstallTallies {
|
|
69
|
+
/** Per-version counters, reconciled against the listing totals. */
|
|
70
|
+
versions: VersionInstallTally[];
|
|
71
|
+
/** The totals the page should print, and that the versions sum to. */
|
|
72
|
+
installCount: number;
|
|
73
|
+
activeInstalls: number;
|
|
74
|
+
/**
|
|
75
|
+
* Installs the totals include but no version claims. Non-zero means the
|
|
76
|
+
* per-version split is INCOMPLETE, and a caller must say so rather than
|
|
77
|
+
* print a breakdown that does not add up.
|
|
78
|
+
*/
|
|
79
|
+
untrackedInstallCount: number;
|
|
80
|
+
untrackedActiveInstalls: number;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Makes the listing-level and per-version counters agree (AGL-1418).
|
|
84
|
+
*
|
|
85
|
+
* The marketplace keeps the same two quantities twice: once on the listing and
|
|
86
|
+
* once per version. They are written by different code under different trigger
|
|
87
|
+
* conditions, each swallowing its own failures, and nothing ever checked that
|
|
88
|
+
* they agree — so the listing page printed `7 installs · 2 active` beside
|
|
89
|
+
* `3 installs · 1 on this version` for a listing with ONE version and two live
|
|
90
|
+
* pins. Three numbers, one quantity, and a publisher with no way to tell which
|
|
91
|
+
* to believe.
|
|
92
|
+
*
|
|
93
|
+
* Neither level is trustworthy on its own:
|
|
94
|
+
*
|
|
95
|
+
* * The per-version pair only exists from AGL-1036 onwards and was never
|
|
96
|
+
* backfilled, so it under-counts every listing older than that.
|
|
97
|
+
* * The listing-level pair is not incremented at all by the copied-artifact
|
|
98
|
+
* install routes, so it under-counts every component, theme, layout, email
|
|
99
|
+
* template and dataset schema.
|
|
100
|
+
*
|
|
101
|
+
* Both fail in the same direction — downwards — which is why the total is the
|
|
102
|
+
* larger of the two rather than an average or a preference.
|
|
103
|
+
*
|
|
104
|
+
* `activeInstalls` is clamped to `installCount` at both levels. That is not a
|
|
105
|
+
* guess: every writer increments the pair together, so "more live than ever
|
|
106
|
+
* landed" is unrepresentable in a healthy database and printing it would
|
|
107
|
+
* advertise the corruption rather than the count.
|
|
108
|
+
*
|
|
109
|
+
* Pure, and deliberately: it decides what to SAY, and the caller decides
|
|
110
|
+
* whether to repair anything.
|
|
111
|
+
*
|
|
112
|
+
* ## When the pins are in hand (AGL-1419)
|
|
113
|
+
*
|
|
114
|
+
* Pass `pins` and the reasoning above stops applying to `activeInstalls`,
|
|
115
|
+
* because it only existed to pick between two unreliable readings. The pin
|
|
116
|
+
* count is the quantity itself, so it is taken exactly — including DOWNWARDS,
|
|
117
|
+
* which no amount of reconciling between two accumulators could ever do, and
|
|
118
|
+
* which is the whole reason `z6glT_UDAQ` advertised three active installs
|
|
119
|
+
* against two live pins.
|
|
120
|
+
*
|
|
121
|
+
* The clamp flips direction with it. Without pins, `activeInstalls` is capped
|
|
122
|
+
* at `installCount`, because "more live than ever landed" means one of the two
|
|
123
|
+
* is corrupt and the all-time figure is the more believable. With pins it is
|
|
124
|
+
* `installCount` that gets raised: a pin that exists now is an install that
|
|
125
|
+
* landed, so an all-time counter below the live one is simply behind.
|
|
126
|
+
*/
|
|
127
|
+
export declare function reconcileInstallTallies(versions: VersionInstallTally[], listing: ListingInstallTally, pins?: LivePinTally | null): ReconciledInstallTallies;
|
|
128
|
+
export interface VersionMoveInput {
|
|
129
|
+
firestore: FirebaseFirestore.Firestore;
|
|
130
|
+
listingRef: FirebaseFirestore.DocumentReference;
|
|
131
|
+
artifactType: MarketplaceArtifactType | string | null | undefined;
|
|
132
|
+
/** The version this install was on, if any. Null for a first install. */
|
|
133
|
+
from?: string | number | null;
|
|
134
|
+
/** The version it is on now. Null for an uninstall. */
|
|
135
|
+
to?: string | number | null;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Moves one install from one version's tally to another's.
|
|
139
|
+
*
|
|
140
|
+
* Rules, all of which exist because a wrong count is worse than no count:
|
|
141
|
+
*
|
|
142
|
+
* * `activeInstalls` is clamped at zero, so a repeat uninstall — which the
|
|
143
|
+
* plugin route already tolerates at listing level — cannot go negative.
|
|
144
|
+
* * `installCount` only rises, and only on the version being taken: it records
|
|
145
|
+
* that an install once landed there, which stays true after they leave.
|
|
146
|
+
* * A version whose document is gone is skipped rather than created. Writing
|
|
147
|
+
* `increment()` blind would resurrect a deleted version as a counters-only
|
|
148
|
+
* stub, and in `pluginVersions` that stub would surface through
|
|
149
|
+
* `newestApprovedVersion` as a version with no publish date and no review.
|
|
150
|
+
* * `from === to` is a no-op. Re-installing the same version is not a new
|
|
151
|
+
* install of it, and counting it would inflate every re-install.
|
|
152
|
+
* * **A move CONSERVES (AGL-1420).** When a `from` version is named, the
|
|
153
|
+
* arrival on `to` is recorded only if the departure from `from` actually
|
|
154
|
+
* landed. See below — this is the rule that stops a restored install
|
|
155
|
+
* minting a real one.
|
|
156
|
+
*
|
|
157
|
+
* Never on the critical path: a counter that cannot be written must not fail
|
|
158
|
+
* an install the user asked for.
|
|
159
|
+
*
|
|
160
|
+
* ## Why a move has to conserve
|
|
161
|
+
*
|
|
162
|
+
* The two halves used to be independent writes, either of which could
|
|
163
|
+
* silently no-op while the other landed — and both no-op paths are on the
|
|
164
|
+
* `from` half, so the error was one-directional: **net +1, every time.**
|
|
165
|
+
*
|
|
166
|
+
* * `if (!snapshot.exists) return` skipped a `from` version whose document
|
|
167
|
+
* had been deleted, and the `to` increment still landed.
|
|
168
|
+
* * `Math.max(0, active - 1)` swallowed a decrement against a version whose
|
|
169
|
+
* tally was already zero, and the `to` increment still landed.
|
|
170
|
+
*
|
|
171
|
+
* A `from` that cannot be decremented is not a bookkeeping inconvenience; it
|
|
172
|
+
* is evidence that **this copy was never in the tracked population**. The
|
|
173
|
+
* case that produces it in the wild is a site restore: `IMPORTABLE_FIELDS`
|
|
174
|
+
* deliberately carries `marketplace` / `installedFrom` / `source` through
|
|
175
|
+
* `POST /api/hosts/import` (site-export-round-trip.spec.ts asserts it, and it
|
|
176
|
+
* is what keeps update-available detection working on a legitimate backup),
|
|
177
|
+
* so a restored component or dataset arrives fully stamped with provenance
|
|
178
|
+
* having never run an install route. No `installCount` increment, no pin,
|
|
179
|
+
* nothing counted. The first time the publisher ships an update and someone
|
|
180
|
+
* presses Update, `update-artifact` reads that stamp and calls this function
|
|
181
|
+
* with a `from` nothing ever credited.
|
|
182
|
+
*
|
|
183
|
+
* That phantom then propagates rather than staying local: `reconcileCounter`
|
|
184
|
+
* takes `max(versionSum, stored)` — because every accumulator in this system
|
|
185
|
+
* fails DOWNWARD and the larger is therefore the better estimate — so an
|
|
186
|
+
* inflated per-version tally raises the listing total the page prints to
|
|
187
|
+
* buyers. Nothing can pull it back down again except a pin count, and
|
|
188
|
+
* `verifiedLivePins` returns `null` for everything that is not a plugin,
|
|
189
|
+
* which is exactly the set the import can restore. Over-counts are permanent;
|
|
190
|
+
* under-counts are representable (`untracked`) and recoverable.
|
|
191
|
+
*
|
|
192
|
+
* So the bias goes the way the rest of the arithmetic already goes. The cost
|
|
193
|
+
* is a real install whose `from` version document the publisher has since
|
|
194
|
+
* deleted: its arrival is not credited to `to`. That install is still counted
|
|
195
|
+
* at listing level, so `max(versionSum, stored)` keeps the total right and
|
|
196
|
+
* the per-version breakdown reports it as `untracked` — the shape this module
|
|
197
|
+
* already has for "we know it happened, we cannot say where".
|
|
198
|
+
*
|
|
199
|
+
* A first install (`from` null) is unaffected: there is nothing to conserve,
|
|
200
|
+
* and `to` is credited exactly as before.
|
|
201
|
+
*/
|
|
202
|
+
export declare function recordVersionMove(input: VersionMoveInput): Promise<void>;
|