@aglyn/plugins-marketplace 1.0.0-beta.201 → 1.0.0-beta.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/model/publisher-risk.d.ts +52 -0
- package/src/lib/model/publisher-risk.js +74 -0
- package/src/lib/model/publisher-risk.js.map +1 -0
- package/src/lib/server/billing-webhook.js +72 -4
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/checkout.js +18 -1
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/connect.js +15 -1
- package/src/lib/server/connect.js.map +1 -1
- package/src/lib/server/install-dataset-schema.js +3 -1
- package/src/lib/server/install-dataset-schema.js.map +1 -1
- package/src/lib/server/install-email-starter.js +3 -1
- package/src/lib/server/install-email-starter.js.map +1 -1
- package/src/lib/server/install-email-template.js +3 -1
- package/src/lib/server/install-email-template.js.map +1 -1
- package/src/lib/server/install-layout.js +3 -1
- package/src/lib/server/install-layout.js.map +1 -1
- package/src/lib/server/install-plugin.js +3 -1
- package/src/lib/server/install-plugin.js.map +1 -1
- package/src/lib/server/install-template.js +3 -1
- package/src/lib/server/install-template.js.map +1 -1
- package/src/lib/server/install-theme.js +3 -1
- package/src/lib/server/install-theme.js.map +1 -1
- package/src/lib/server/install.js +3 -1
- package/src/lib/server/install.js.map +1 -1
- package/src/lib/server/listing-screen.d.ts +83 -0
- package/src/lib/server/listing-screen.js +244 -0
- package/src/lib/server/listing-screen.js.map +1 -0
- package/src/lib/server/publish-dataset-schema.js +12 -0
- package/src/lib/server/publish-dataset-schema.js.map +1 -1
- package/src/lib/server/publish-email-starter.js +15 -1
- package/src/lib/server/publish-email-starter.js.map +1 -1
- package/src/lib/server/publish-email-template.js +15 -1
- package/src/lib/server/publish-email-template.js.map +1 -1
- package/src/lib/server/publish-layout.js +15 -1
- package/src/lib/server/publish-layout.js.map +1 -1
- package/src/lib/server/publish-plugin.js +41 -3
- package/src/lib/server/publish-plugin.js.map +1 -1
- package/src/lib/server/publish-template.js +15 -1
- package/src/lib/server/publish-template.js.map +1 -1
- package/src/lib/server/publish-theme.js +14 -1
- package/src/lib/server/publish-theme.js.map +1 -1
- package/src/lib/server/publish.js +15 -1
- package/src/lib/server/publish.js.map +1 -1
- package/src/lib/server/publisher-profile-save.js +36 -0
- package/src/lib/server/publisher-profile-save.js.map +1 -1
- package/src/lib/server/publisher-review-email.js +15 -5
- package/src/lib/server/publisher-review-email.js.map +1 -1
- package/src/lib/server/reviews.d.ts +8 -0
- package/src/lib/server/reviews.js +43 -3
- package/src/lib/server/reviews.js.map +1 -1
- package/src/lib/server/sale-risk.d.ts +105 -0
- package/src/lib/server/sale-risk.js +315 -0
- package/src/lib/server/sale-risk.js.map +1 -0
- package/src/lib/server/update-artifact.js +3 -1
- package/src/lib/server/update-artifact.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/checkout.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 { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { connectLinkageIsReady } from '@aglyn/tenant-data-admin/server/stripe-account-mode'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\nimport {\n buildRoute,\n checkEntitlement,\n claimAttempt,\n isListingWideRevocation,\n resolveMarketplaceFeePct,\n Route,\n type AttemptClaim,\n type PluginApiHandler,\n type PluginRevocation,\n} from '@aglyn/aglyn/server'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { isListingBrowsable, isPrivateListing } from '../model/marketplace'\nimport {\n canActAsPublisher,\n resolvePublisherProfile,\n} from './publisher-profile'\nimport { hasLivePurchase } from './purchase-entitlement'\n\n/**\n * THE TAX CLASSIFICATION OF A MARKETPLACE SALE (AGL-1553).\n *\n * \"Software as a service (SaaS) — business use\". Sent explicitly on the line\n * item below, because this session builds its product ad hoc from\n * `price_data` and an ad-hoc product with no `tax_code` is classified by the\n * ACCOUNT-LEVEL preset instead — a value set by hand in the Stripe Dashboard\n * for Aglyn's own subscription products (AGL-1537/1877), which happens to be\n * this same string.\n *\n * So this is not a change of tax position, and deliberately not: it is the\n * position Aglyn already collects on, already verified, and already states in\n * a signed filing. The pending Texas private letter ruling request proposes at\n * Part 6.1 that plugin charges are DATA PROCESSING SERVICES under Tax Code\n * § 151.0035, of which § 151.351 exempts 20%, so tax is due on 80% of the\n * charge; Part 4.5 ¶ 4 represents to the Comptroller that \"the payment\n * processor's tax classification applied to these charges is the\n * software-as-a-service category\" and that the 80% measure was confirmed by\n * computation. AGL-1817 ran that computation against the live account: $6.60\n * of tax on a $100.00 charge at an 8.25% Texas address — 8.25% x $80.\n *\n * What was actually broken is that none of that was expressed HERE. The\n * classification of every marketplace sale hung on a Dashboard field owned by\n * a different product line. Re-preset the account for storefront goods or\n * bookings and these sales silently re-classify, moving the Texas base off the\n * 80% the filing describes — with no diff to review, no failing test, and no\n * symptom short of reconciling a return. Naming it here is what makes the\n * filed representation survive a Dashboard edit.\n *\n * NOT merged with `PLATFORM_TAX_CODE` in `tools/scripts/lib/stripe-tax-code.mjs`,\n * which stamps Aglyn's OWN products with the same string today. They answer\n * different questions — Aglyn's first-party subscription vs. a third party's\n * sale through Aglyn's marketplace — and the ruling request itself flags at\n * Part 8 ¶ 8 that the Comptroller could characterize the two differently. One\n * shared constant would make a divergence the filing anticipates impossible to\n * express without touching the other business.\n *\n * DO NOT change this value to a downloadable-software code (`txcd_10202003`,\n * `txcd_10203001`) on the reasoning that a plugin is \"downloaded\". That is the\n * OPPOSING characterization — tangible personal property under Rule 3.308,\n * taxable in full — which Part 6.1 argues against and which the filing already\n * states against itself, in full, at Part 8 ¶¶ 1-2. Adopting\n * it here would raise the tax charged to every buyer by a quarter of the base\n * and make Part 4.5 ¶ 4 false. It is the Comptroller's call, and if the ruling\n * goes that way the change is a one-line edit at this constant plus a\n * prospective conformance — which is exactly what naming it here buys.\n */\nexport const MARKETPLACE_TAX_CODE = 'txcd_10103001'\n\n/**\n * Checkout for a paid marketplace listing (AGL-46): one-time destination\n * charge to the publisher's Express account. The platform's cut comes from\n * the seller org's ENTITLEMENTS (AGL-1543: 20%, 30% effective-free), sales\n * tax is collected platform-side per the marketplace-provider registration\n * (AGL-1544), and the seller receives a fixed transfer of their pre-tax\n * share. The webhook writes the purchase record on\n * `checkout.session.completed`; install stays gated on that record. 501\n * without Stripe env.\n */\nexport const checkoutHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res\n .status(501)\n .json({ error: 'Purchases are not configured (STRIPE_SECRET_KEY).' })\n }\n const listingId = String(req.body?.listingId ?? '')\n const hostId = String(req.body?.hostId ?? '')\n // The org this licence is being bought FOR (AGL-2331). Requested, never\n // trusted: it is resolved through the caller's own membership below, exactly\n // as `connect.ts` resolves the org a payout account is being set up for.\n const requestedOrgId = String(req.body?.orgId ?? '')\n // One purchase attempt, minted by the browser (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n )\n .trim()\n .slice(0, 200)\n if (!listingId) return res.status(400).json({ error: 'Missing listingId' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n let claim: AttemptClaim | null = null\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n // WHICH ORGANIZATION IS BUYING (AGL-2331).\n //\n // A marketplace purchase licenses an organization, not a person — see\n // `purchaseEntitlesOrg` in the model for why, and for what the published\n // Publisher Agreement §8.1 already says about it. So a Checkout session\n // that cannot name the org it licenses must not open: the purchase\n // document it produces would carry no `buyerOrgId`, and a half-populated\n // org field is a worse premise for an entitlement than an absent one —\n // the gate would have to guess whether a missing value means \"legacy,\n // grandfathered\" or \"modern, but nobody wrote it\".\n //\n // Resolved through `resolveOrgPermissions`, not from the body and not from\n // `hostIndex`. The old derivation was `hostId` → `hostIndex.orgId` with no\n // membership test at all, which is a real hole once the field grants\n // anything: post any host id and the purchase would be stamped with an org\n // the buyer has no standing in, entitling strangers. This is the same\n // resolution the seven install routes and `connect.ts` use, so the org\n // that gets the licence is by construction an org the buyer could install\n // into.\n //\n // `installPlugins` rather than a bare membership: buying is the act of\n // acquiring something to install, and a viewer who cannot install must not\n // be able to spend the workspace's money on one. It is also what keeps\n // this door and the install door agreeing about who the licence is for.\n const membership = await resolveOrgPermissions(decoded.uid, {\n orgId: requestedOrgId || null,\n hostId: requestedOrgId ? null : hostId || null,\n })\n const buyerOrgId = membership.orgId ?? ''\n if (!buyerOrgId) {\n return res.status(400).json({\n error:\n 'A purchase is licensed to an organization — open the marketplace ' +\n 'from the workspace you are buying for.',\n })\n }\n if (requestedOrgId && buyerOrgId !== requestedOrgId) {\n return res.status(403).json({ error: 'Not a member of that organization' })\n }\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 listingSnapshot = await firestore\n .collection('marketplaceListings')\n .doc(listingId)\n .get()\n const listing = listingSnapshot.data() as any\n if (!listing || listing.deletedAt) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // Review gating holds on the PAID path too (AGL-1543): browse hides\n // rejected/submitted/in_review listings, so a direct checkout call must\n // not be a side door that takes money for one. Absent = legacy = listed\n // (AGL-432), and the same 404 as an unknown listing — purchasability is\n // not a probe for review state.\n const reviewStatus = listing.reviewStatus as string | undefined\n if (reviewStatus && reviewStatus !== 'listed' && reviewStatus !== 'verified') {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // A TAKEN-DOWN or PRIVATE listing is not on sale (AGL-2288).\n //\n // The check above is a hand-rolled subset of `isListingBrowsable`, and\n // the two lines it dropped are the two that matter here: `hiddenAt` and\n // `isPrivateListing`. A staff takedown does NOT set a blocking\n // `reviewStatus` — `plugin-reviews` demotes a verified listing to\n // `VERIFIED_STRIPPED_STATUS`, which is literally `'listed'` — so after a\n // takedown this route read `reviewStatus: 'listed'` and sold the listing\n // in full, while `install-plugin` refused the very same listing at\n // `hiddenAt` with a 404. The buyer was charged for something the platform\n // had already decided nobody may install, and the only way out was a\n // refund — which is Aglyn's Stripe fee, Aglyn's support time, and (until\n // the partial-refund fix) a real hole in the split.\n //\n // Both tests are kept rather than replaced by the shared predicate: for a\n // NON-plugin artifact `isListingBrowsable` returns true whatever the\n // review status says, because pre-publication review is plugin-only. This\n // route has always been stricter than that, and a sale is the wrong place\n // to loosen a gate.\n //\n // No owner exemption, unlike the install routes: `canActAsPublisher`\n // below already refuses a publisher buying their own listing, so the\n // buyer here is never the owner.\n if (!isListingBrowsable(listing) || isPrivateListing(listing)) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n const priceUsd = Number(listing.priceUsd ?? 0)\n if (!(priceUsd > 0)) {\n return res.status(400).json({ error: 'Listing is free' })\n }\n // Publishing is org-owned (AGL-652): `profileId` IS the publisher org id.\n const sellerOrgId = String(listing.profileId ?? '')\n if (await canActAsPublisher(firestore, decoded.uid, sellerOrgId)) {\n return res.status(400).json({ error: 'Your organization published this listing' })\n }\n // You cannot buy the same component twice (AGL-1697).\n //\n // This route validated review status, self-purchase and the seller's\n // entitlements, and then never asked the one question the buyer cares\n // about: do they already own it. A marketplace listing is a buy-once good —\n // a second purchase buys nothing, since `requirePurchase` is satisfied by\n // the first — so a buyer who clicked Buy again from a stale tab, or came\n // back through a bookmarked checkout, was simply charged twice for it.\n //\n // Deliberately the SAME predicate the seven install routes gate on\n // (AGL-1546/1699) rather than a second query beside it: \"has this buyer\n // paid for this listing\" must have one answer, or the marketplace can take\n // money for something it will not install, or refuse to sell something it\n // will not install either. That predicate reads a fully REFUNDED purchase\n // as absent, which is exactly right here too — a refunded buyer must be\n // able to buy again, and a refunded row sitting beside a live one does not\n // veto it.\n //\n // 409 rather than 400: nothing about the request is malformed, the buyer\n // is just asking for a state that already holds.\n //\n // KEYED ON THE ORGANIZATION SINCE AGL-2331, and that is what turns this\n // guard from a blocked sale back into a real one. A licence belongs to a\n // workspace, so an agency developer who bought a component for one client\n // must be able to buy it again for the next — the person-keyed version of\n // this check answered `409 already_purchased` to exactly the second sale\n // we want. It still refuses the case it was written for: the same\n // workspace buying the same listing twice.\n //\n // A pre-AGL-2331 purchase still refuses, through the legacy grant inside\n // the shared predicate: that buyer can already install this listing\n // anywhere, so selling them a second copy would take money for nothing.\n if (\n await hasLivePurchase({\n firestore,\n buyerUid: decoded.uid,\n buyerOrgId,\n listingId,\n })\n ) {\n return res.status(409).json({\n error:\n 'This organization already owns this listing — install it from ' +\n 'your library.',\n code: 'already_purchased',\n })\n }\n // The kill switch, on the SELL side too (AGL-2288).\n //\n // `revocations/{listingId}` with `versions: 'all'` is the listing-wide\n // kill: `install-plugin` 409s on it through this same predicate, and the\n // tenant runtime renders the disabled placeholder for anything already\n // pinned. Selling into that state takes money for an artifact the\n // platform has switched off.\n //\n // Only `'all'` is consulted, deliberately. A per-version revocation\n // (AGL-1085) stops one build while other approved versions stay\n // installable, and refusing the sale for it would turn a targeted control\n // into a de-listing. Whether the newest approved version happens to be\n // the revoked one is a question `install-plugin` answers with live data;\n // duplicating that resolution here would be a second copy of it.\n //\n // One extra read on a path that already makes several Firestore and\n // Stripe round trips, and it is the cheap half of the takedown check\n // above: a hand-written revocation doc — which the rules permit any staff\n // client to write — is caught here even when `hiddenAt` was never set.\n const revocation = (\n await firestore.collection('revocations').doc(listingId).get()\n ).data() as PluginRevocation | undefined\n if (isListingWideRevocation(revocation)) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n const publisher = await resolvePublisherProfile(firestore, sellerOrgId)\n const accountId = publisher?.stripeAccountId\n if (\n !connectLinkageIsReady(\n {\n accountId,\n chargesEnabled: publisher?.stripeChargesEnabled,\n accountLivemode: publisher?.stripeAccountLivemode,\n },\n { subject: `marketplace publisher org ${sellerOrgId}` },\n )\n ) {\n return res\n .status(409)\n .json({ error: 'The publisher has not enabled payouts yet' })\n }\n\n // The platform's cut comes from ENTITLEMENTS (AGL-1543), never the raw\n // `plan` field: `resolveMarketplaceFeePct` prices a dead subscription\n // as free-plan (30%) while the stale field still says pro, and honors\n // a negotiated per-org `marketplaceFeePct` override. Same read gates\n // SALE time on `marketplaceSelling` — publish already refuses without\n // it, and a publisher who downgrades must stop selling, not merely\n // stop publishing. Resolved per request; entitlements are never cached.\n const sellerOrgDoc = await firestore.collection('orgs').doc(sellerOrgId).get()\n const sellerOrg = (sellerOrgDoc.data() ?? {}) as any\n if (!checkEntitlement(sellerOrg, 'marketplaceSelling')) {\n return res.status(409).json({\n error:\n \"The publisher's current plan does not include marketplace selling\",\n })\n }\n const feePercent = resolveMarketplaceFeePct(sellerOrg)\n const amountCents = Math.round(priceUsd * 100)\n const feeCents = Math.round((amountCents * feePercent) / 100)\n\n const origin = req.headers.origin ?? `https://${req.headers.host}`\n // Stripe returns the BUYER to the org marketplace (AGL-775) — the\n // per-site marketplace page it used to land on was retired. Only the\n // buyer's org slug is needed now (`sellerOrgId` is the wrong org), and\n // it's resolved from the host here since server code cannot call\n // useConsoleHostRoute. `/manage/marketplace` is the hostless fallback.\n //\n // The slug comes off the VALIDATED buyer org resolved at the top of the\n // handler now (AGL-2331) rather than a second, unvalidated `hostIndex`\n // read: one org id decides where the buyer lands, which org the licence\n // entitles, and which org's erasure sweeps the idempotency claim, so\n // having two derivations of it was a way for those three to disagree.\n const buyerOrgSlug = (\n await firestore.collection('orgs').doc(buyerOrgId).get()\n ).get('slug') as string | undefined\n const returnPath = buyerOrgSlug\n ? buildRoute(Route.ORG_MARKETPLACE, { orgSlug: buyerOrgSlug })\n : Route.ORG_MARKETPLACE\n // Tax + funds flow (AGL-1544). Aglyn registered as a marketplace\n // provider with the Texas Comptroller: it collects and remits sales tax\n // on sellers' behalf. That is the PLATFORM's charge — no `on_behalf_of`,\n // `automatic_tax` on the platform session, tax added ON TOP of the\n // listing price (`tax_behavior: exclusive`) with a full billing address\n // to compute from. The transfer is a FIXED amount (the seller's share of\n // the pre-tax price) rather than `application_fee_amount`, because the\n // fee form makes Stripe transfer `amount_total − fee` — and amount_total\n // includes the tax, which must stay with the platform that owes it.\n const transferCents = amountCents - feeCents\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(amountCents),\n 'line_items[0][price_data][tax_behavior]': 'exclusive',\n 'line_items[0][price_data][product_data][name]': String(\n listing.displayName ?? 'Marketplace component',\n ).slice(0, 120),\n // The classification, stated rather than inherited (AGL-1553). See\n // MARKETPLACE_TAX_CODE above: same value the account preset carries, so\n // the tax computed is unchanged, but it no longer depends on a Dashboard\n // field owned by Aglyn's subscription products.\n 'line_items[0][price_data][product_data][tax_code]': MARKETPLACE_TAX_CODE,\n 'automatic_tax[enabled]': 'true',\n billing_address_collection: 'required',\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n 'payment_intent_data[transfer_data][amount]': String(transferCents),\n // THE DISCRIMINATOR THE REFUND DOOR HAS NO OTHER WAY TO GET (AGL-2148).\n //\n // `metadata[type]` below rides on the SESSION, and `charge.refunded`\n // never sees a session — its object is the charge. The platform\n // endpoint receives `charge.refunded` for storefront orders and\n // subscription charges too, so the webhook cannot tell a marketplace\n // refund from any other one except by joining on the payment intent,\n // which is exactly the join that comes up empty during the window this\n // metadata exists to close (the purchase document is written by\n // `checkout.session.completed`, and that delivery retries).\n //\n // Stripe copies a PaymentIntent's metadata onto the charge it creates,\n // so stamping it here makes `charge.metadata.type` readable straight\n // off the refund event — no Firestore join, no Stripe round trip, and\n // no orphan record written for the commerce and subscription refunds\n // that share this endpoint.\n //\n // `listingId`/`buyerUid` ride along for forensics only: an orphan that\n // never drains is a support case, and the payment intent alone does not\n // say whose purchase of what it was.\n 'payment_intent_data[metadata][type]': 'marketplace-purchase',\n 'payment_intent_data[metadata][listingId]': listingId,\n 'payment_intent_data[metadata][buyerUid]': decoded.uid,\n 'payment_intent_data[metadata][buyerOrgId]': buyerOrgId,\n success_url: `${origin}${returnPath}?purchase=success`,\n cancel_url: `${origin}${returnPath}?purchase=canceled`,\n client_reference_id: decoded.uid,\n 'metadata[type]': 'marketplace-purchase',\n 'metadata[listingId]': listingId,\n 'metadata[buyerUid]': decoded.uid,\n // THE ORG THE LICENCE IS FOR (AGL-2331). The webhook writes the\n // purchase document from this metadata and had no buyer org to write,\n // which is why the entitlement could only ever be person-scoped: the\n // one field the gate needs was never carried across the Stripe\n // boundary. Validated above against the caller's own membership, so a\n // session can never license an org the buyer has no standing in.\n 'metadata[buyerOrgId]': buyerOrgId,\n 'metadata[sellerOrgId]': sellerOrgId,\n 'metadata[feeCents]': String(feeCents),\n 'metadata[transferCents]': String(transferCents),\n // The buyer's GA client id (AGL-1638), so the server-side `purchase`\n // the webhook sends joins the browser session that produced it. The\n // webhook has always READ `metadata.ga_client_id`; nothing wrote it,\n // so the read was dead and every marketplace sale fell through to a\n // synthesized, sessionless GA user — making plugin revenue permanently\n // unattributable to any channel.\n //\n // Carried on the SESSION rather than a subscription because this is a\n // one-time payment; `checkout.session.completed` is where it is read.\n //\n // Validated to GA's `<digits>.<digits>` shape and dropped otherwise:\n // the value is client-supplied and ends up in Stripe metadata, so it is\n // not accepted on trust. Same guard /api/billing/checkout applies.\n ...(/^\\d+\\.\\d+$/.test(String(req.body?.gaClientId ?? ''))\n ? { 'metadata[ga_client_id]': String(req.body.gaClientId) }\n : {}),\n ...(decoded.email ? { customer_email: decoded.email } : {}),\n })\n // Point of no return (AGL-1697). The purchase check above closes the\n // SEQUENTIAL duplicate — a second buy after the webhook has written the\n // first purchase — and cannot close the concurrent one, because between two\n // clicks a second apart there is no purchase record yet to find. The claim\n // is what covers that window: `create()` on a document Firestore refuses to\n // create twice.\n //\n // Scoped to the listing AND the buyer, so two people buying the same\n // component at the same moment never contend, and one person's two tabs\n // always do.\n //\n // The claim is taken here rather than at the top so that every deterministic\n // refusal above it — unknown listing, free listing, self-purchase, already\n // owned, publisher not paid out, seller cannot sell — answers without\n // burning the key. Those are all states a buyer or publisher fixes and then\n // presses the same button again.\n const claimed = await claimAttempt(firestore, {\n kind: 'marketplace-checkout',\n // Scoped to the ORG as well as the buyer since AGL-2331. The scope is\n // what decides which two attempts are allowed to contend, and a\n // licence for one workspace and a licence for another are now two\n // different goods — an agency owner buying for two clients from two\n // tabs must not have one of them read as a double-click of the other.\n scopeId: `${listingId}:${buyerOrgId}:${decoded.uid}`,\n // The BUYER's org, so `eraseOrgIdempotencyKeys` sweeps this with the\n // buyer's data (AGL-1448) — the seller keeps the sale.\n orgId: buyerOrgId,\n key: idempotencyKey,\n busyMessage: 'This purchase is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money. Stripe replays the existing\n // session for a repeated key instead of opening a second one, which\n // covers the window where our own claim is written but the response\n // never arrives — the failure this route was most exposed to, since\n // it writes nothing of its own to reconcile against.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe checkout error', session.error)\n // The purchase did not start — let the same key be tried again rather\n // than locking this buyer out of this listing over one flaky moment.\n await claim.release()\n return res.status(502).json({ error: 'Stripe checkout failed' })\n }\n const payload = { url: session.url }\n // A repeat of this attempt now replays the SAME session url. Better than\n // refusing it: the buyer lands back on the checkout they already had open.\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // and lock the buyer out of this listing until they reload.\n await claim?.release()\n return res.status(500).json({ error: 'Checkout failed' })\n }\n}\n"],"names":["firebaseAdmin","connectLinkageIsReady","checkoutSessionCardAuthenticationParams","buildRoute","checkEntitlement","claimAttempt","isListingWideRevocation","resolveMarketplaceFeePct","Route","resolveOrgPermissions","isListingBrowsable","isPrivateListing","canActAsPublisher","resolvePublisherProfile","hasLivePurchase","MARKETPLACE_TAX_CODE","checkoutHandler","req","res","method","status","json","error","process","env","STRIPE_SECRET_KEY","listingId","String","body","hostId","requestedOrgId","orgId","idempotencyKey","headers","trim","slice","authorization","idToken","startsWith","length","undefined","claim","membership","listing","sellerOrgDoc","decoded","app","auth","verifyIdToken","uid","buyerOrgId","permissions","installPlugins","firestore","listingSnapshot","collection","doc","get","data","deletedAt","reviewStatus","priceUsd","Number","sellerOrgId","profileId","buyerUid","code","revocation","publisher","accountId","stripeAccountId","chargesEnabled","stripeChargesEnabled","accountLivemode","stripeAccountLivemode","subject","sellerOrg","feePercent","amountCents","Math","round","feeCents","origin","host","buyerOrgSlug","returnPath","ORG_MARKETPLACE","orgSlug","transferCents","params","URLSearchParams","mode","displayName","billing_address_collection","success_url","cancel_url","client_reference_id","test","gaClientId","email","customer_email","claimed","kind","scopeId","key","busyMessage","replay","response","fetch","Authorization","stripeKey","toString","session","ok","url","console","release","payload","record"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/checkout.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 { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { connectLinkageIsReady } from '@aglyn/tenant-data-admin/server/stripe-account-mode'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\nimport {\n buildRoute,\n checkEntitlement,\n claimAttempt,\n isListingWideRevocation,\n resolveMarketplaceFeePct,\n Route,\n type AttemptClaim,\n type PluginApiHandler,\n type PluginRevocation,\n} from '@aglyn/aglyn/server'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { isListingBrowsable, isPrivateListing } from '../model/marketplace'\nimport {\n canActAsPublisher,\n resolvePublisherProfile,\n} from './publisher-profile'\nimport { hasLivePurchase } from './purchase-entitlement'\nimport { isOrgMember } from './sale-risk'\n\n/**\n * THE TAX CLASSIFICATION OF A MARKETPLACE SALE (AGL-1553).\n *\n * \"Software as a service (SaaS) — business use\". Sent explicitly on the line\n * item below, because this session builds its product ad hoc from\n * `price_data` and an ad-hoc product with no `tax_code` is classified by the\n * ACCOUNT-LEVEL preset instead — a value set by hand in the Stripe Dashboard\n * for Aglyn's own subscription products (AGL-1537/1877), which happens to be\n * this same string.\n *\n * So this is not a change of tax position, and deliberately not: it is the\n * position Aglyn already collects on, already verified, and already states in\n * a signed filing. The pending Texas private letter ruling request proposes at\n * Part 6.1 that plugin charges are DATA PROCESSING SERVICES under Tax Code\n * § 151.0035, of which § 151.351 exempts 20%, so tax is due on 80% of the\n * charge; Part 4.5 ¶ 4 represents to the Comptroller that \"the payment\n * processor's tax classification applied to these charges is the\n * software-as-a-service category\" and that the 80% measure was confirmed by\n * computation. AGL-1817 ran that computation against the live account: $6.60\n * of tax on a $100.00 charge at an 8.25% Texas address — 8.25% x $80.\n *\n * What was actually broken is that none of that was expressed HERE. The\n * classification of every marketplace sale hung on a Dashboard field owned by\n * a different product line. Re-preset the account for storefront goods or\n * bookings and these sales silently re-classify, moving the Texas base off the\n * 80% the filing describes — with no diff to review, no failing test, and no\n * symptom short of reconciling a return. Naming it here is what makes the\n * filed representation survive a Dashboard edit.\n *\n * NOT merged with `PLATFORM_TAX_CODE` in `tools/scripts/lib/stripe-tax-code.mjs`,\n * which stamps Aglyn's OWN products with the same string today. They answer\n * different questions — Aglyn's first-party subscription vs. a third party's\n * sale through Aglyn's marketplace — and the ruling request itself flags at\n * Part 8 ¶ 8 that the Comptroller could characterize the two differently. One\n * shared constant would make a divergence the filing anticipates impossible to\n * express without touching the other business.\n *\n * DO NOT change this value to a downloadable-software code (`txcd_10202003`,\n * `txcd_10203001`) on the reasoning that a plugin is \"downloaded\". That is the\n * OPPOSING characterization — tangible personal property under Rule 3.308,\n * taxable in full — which Part 6.1 argues against and which the filing already\n * states against itself, in full, at Part 8 ¶¶ 1-2. Adopting\n * it here would raise the tax charged to every buyer by a quarter of the base\n * and make Part 4.5 ¶ 4 false. It is the Comptroller's call, and if the ruling\n * goes that way the change is a one-line edit at this constant plus a\n * prospective conformance — which is exactly what naming it here buys.\n */\nexport const MARKETPLACE_TAX_CODE = 'txcd_10103001'\n\n/**\n * Checkout for a paid marketplace listing (AGL-46): one-time destination\n * charge to the publisher's Express account. The platform's cut comes from\n * the seller org's ENTITLEMENTS (AGL-1543: 20%, 30% effective-free), sales\n * tax is collected platform-side per the marketplace-provider registration\n * (AGL-1544), and the seller receives a fixed transfer of their pre-tax\n * share. The webhook writes the purchase record on\n * `checkout.session.completed`; install stays gated on that record. 501\n * without Stripe env.\n */\nexport const checkoutHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res\n .status(501)\n .json({ error: 'Purchases are not configured (STRIPE_SECRET_KEY).' })\n }\n const listingId = String(req.body?.listingId ?? '')\n const hostId = String(req.body?.hostId ?? '')\n // The org this licence is being bought FOR (AGL-2331). Requested, never\n // trusted: it is resolved through the caller's own membership below, exactly\n // as `connect.ts` resolves the org a payout account is being set up for.\n const requestedOrgId = String(req.body?.orgId ?? '')\n // One purchase attempt, minted by the browser (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n )\n .trim()\n .slice(0, 200)\n if (!listingId) return res.status(400).json({ error: 'Missing listingId' })\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n let claim: AttemptClaim | null = null\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n // WHICH ORGANIZATION IS BUYING (AGL-2331).\n //\n // A marketplace purchase licenses an organization, not a person — see\n // `purchaseEntitlesOrg` in the model for why, and for what the published\n // Publisher Agreement §8.1 already says about it. So a Checkout session\n // that cannot name the org it licenses must not open: the purchase\n // document it produces would carry no `buyerOrgId`, and a half-populated\n // org field is a worse premise for an entitlement than an absent one —\n // the gate would have to guess whether a missing value means \"legacy,\n // grandfathered\" or \"modern, but nobody wrote it\".\n //\n // Resolved through `resolveOrgPermissions`, not from the body and not from\n // `hostIndex`. The old derivation was `hostId` → `hostIndex.orgId` with no\n // membership test at all, which is a real hole once the field grants\n // anything: post any host id and the purchase would be stamped with an org\n // the buyer has no standing in, entitling strangers. This is the same\n // resolution the seven install routes and `connect.ts` use, so the org\n // that gets the licence is by construction an org the buyer could install\n // into.\n //\n // `installPlugins` rather than a bare membership: buying is the act of\n // acquiring something to install, and a viewer who cannot install must not\n // be able to spend the workspace's money on one. It is also what keeps\n // this door and the install door agreeing about who the licence is for.\n const membership = await resolveOrgPermissions(decoded.uid, {\n orgId: requestedOrgId || null,\n hostId: requestedOrgId ? null : hostId || null,\n })\n const buyerOrgId = membership.orgId ?? ''\n if (!buyerOrgId) {\n return res.status(400).json({\n error:\n 'A purchase is licensed to an organization — open the marketplace ' +\n 'from the workspace you are buying for.',\n })\n }\n if (requestedOrgId && buyerOrgId !== requestedOrgId) {\n return res.status(403).json({ error: 'Not a member of that organization' })\n }\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 listingSnapshot = await firestore\n .collection('marketplaceListings')\n .doc(listingId)\n .get()\n const listing = listingSnapshot.data() as any\n if (!listing || listing.deletedAt) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // Review gating holds on the PAID path too (AGL-1543): browse hides\n // rejected/submitted/in_review listings, so a direct checkout call must\n // not be a side door that takes money for one. Absent = legacy = listed\n // (AGL-432), and the same 404 as an unknown listing — purchasability is\n // not a probe for review state.\n const reviewStatus = listing.reviewStatus as string | undefined\n if (reviewStatus && reviewStatus !== 'listed' && reviewStatus !== 'verified') {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n // A TAKEN-DOWN or PRIVATE listing is not on sale (AGL-2288).\n //\n // The check above is a hand-rolled subset of `isListingBrowsable`, and\n // the two lines it dropped are the two that matter here: `hiddenAt` and\n // `isPrivateListing`. A staff takedown does NOT set a blocking\n // `reviewStatus` — `plugin-reviews` demotes a verified listing to\n // `VERIFIED_STRIPPED_STATUS`, which is literally `'listed'` — so after a\n // takedown this route read `reviewStatus: 'listed'` and sold the listing\n // in full, while `install-plugin` refused the very same listing at\n // `hiddenAt` with a 404. The buyer was charged for something the platform\n // had already decided nobody may install, and the only way out was a\n // refund — which is Aglyn's Stripe fee, Aglyn's support time, and (until\n // the partial-refund fix) a real hole in the split.\n //\n // Both tests are kept rather than replaced by the shared predicate: for a\n // NON-plugin artifact `isListingBrowsable` returns true whatever the\n // review status says, because pre-publication review is plugin-only. This\n // route has always been stricter than that, and a sale is the wrong place\n // to loosen a gate.\n //\n // No owner exemption, unlike the install routes: `canActAsPublisher`\n // below already refuses a publisher buying their own listing, so the\n // buyer here is never the owner.\n if (!isListingBrowsable(listing) || isPrivateListing(listing)) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n const priceUsd = Number(listing.priceUsd ?? 0)\n if (!(priceUsd > 0)) {\n return res.status(400).json({ error: 'Listing is free' })\n }\n // Publishing is org-owned (AGL-652): `profileId` IS the publisher org id.\n const sellerOrgId = String(listing.profileId ?? '')\n // ANY member of the publishing workspace, and any purchase FOR it\n // (AGL-3365). `canActAsPublisher` asks only for an owner or admin, so an\n // editor of the publisher — or an owner buying \"for\" the publisher's own\n // workspace through a role that can install — passed it, and a publisher\n // could pay itself: the stolen-card cash-out the payout exists to make\n // worth doing. A workspace one step removed is the sale screen's to flag.\n if (\n buyerOrgId === sellerOrgId ||\n (await canActAsPublisher(firestore, decoded.uid, sellerOrgId)) ||\n (await isOrgMember(firestore, sellerOrgId, decoded.uid))\n ) {\n return res.status(400).json({ error: 'Your organization published this listing' })\n }\n // You cannot buy the same component twice (AGL-1697).\n //\n // This route validated review status, self-purchase and the seller's\n // entitlements, and then never asked the one question the buyer cares\n // about: do they already own it. A marketplace listing is a buy-once good —\n // a second purchase buys nothing, since `requirePurchase` is satisfied by\n // the first — so a buyer who clicked Buy again from a stale tab, or came\n // back through a bookmarked checkout, was simply charged twice for it.\n //\n // Deliberately the SAME predicate the seven install routes gate on\n // (AGL-1546/1699) rather than a second query beside it: \"has this buyer\n // paid for this listing\" must have one answer, or the marketplace can take\n // money for something it will not install, or refuse to sell something it\n // will not install either. That predicate reads a fully REFUNDED purchase\n // as absent, which is exactly right here too — a refunded buyer must be\n // able to buy again, and a refunded row sitting beside a live one does not\n // veto it.\n //\n // 409 rather than 400: nothing about the request is malformed, the buyer\n // is just asking for a state that already holds.\n //\n // KEYED ON THE ORGANIZATION SINCE AGL-2331, and that is what turns this\n // guard from a blocked sale back into a real one. A licence belongs to a\n // workspace, so an agency developer who bought a component for one client\n // must be able to buy it again for the next — the person-keyed version of\n // this check answered `409 already_purchased` to exactly the second sale\n // we want. It still refuses the case it was written for: the same\n // workspace buying the same listing twice.\n //\n // A pre-AGL-2331 purchase still refuses, through the legacy grant inside\n // the shared predicate: that buyer can already install this listing\n // anywhere, so selling them a second copy would take money for nothing.\n if (\n await hasLivePurchase({\n firestore,\n buyerUid: decoded.uid,\n buyerOrgId,\n listingId,\n })\n ) {\n return res.status(409).json({\n error:\n 'This organization already owns this listing — install it from ' +\n 'your library.',\n code: 'already_purchased',\n })\n }\n // The kill switch, on the SELL side too (AGL-2288).\n //\n // `revocations/{listingId}` with `versions: 'all'` is the listing-wide\n // kill: `install-plugin` 409s on it through this same predicate, and the\n // tenant runtime renders the disabled placeholder for anything already\n // pinned. Selling into that state takes money for an artifact the\n // platform has switched off.\n //\n // Only `'all'` is consulted, deliberately. A per-version revocation\n // (AGL-1085) stops one build while other approved versions stay\n // installable, and refusing the sale for it would turn a targeted control\n // into a de-listing. Whether the newest approved version happens to be\n // the revoked one is a question `install-plugin` answers with live data;\n // duplicating that resolution here would be a second copy of it.\n //\n // One extra read on a path that already makes several Firestore and\n // Stripe round trips, and it is the cheap half of the takedown check\n // above: a hand-written revocation doc — which the rules permit any staff\n // client to write — is caught here even when `hiddenAt` was never set.\n const revocation = (\n await firestore.collection('revocations').doc(listingId).get()\n ).data() as PluginRevocation | undefined\n if (isListingWideRevocation(revocation)) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n const publisher = await resolvePublisherProfile(firestore, sellerOrgId)\n const accountId = publisher?.stripeAccountId\n if (\n !connectLinkageIsReady(\n {\n accountId,\n chargesEnabled: publisher?.stripeChargesEnabled,\n accountLivemode: publisher?.stripeAccountLivemode,\n },\n { subject: `marketplace publisher org ${sellerOrgId}` },\n )\n ) {\n return res\n .status(409)\n .json({ error: 'The publisher has not enabled payouts yet' })\n }\n\n // The platform's cut comes from ENTITLEMENTS (AGL-1543), never the raw\n // `plan` field: `resolveMarketplaceFeePct` prices a dead subscription\n // as free-plan (30%) while the stale field still says pro, and honors\n // a negotiated per-org `marketplaceFeePct` override. Same read gates\n // SALE time on `marketplaceSelling` — publish already refuses without\n // it, and a publisher who downgrades must stop selling, not merely\n // stop publishing. Resolved per request; entitlements are never cached.\n const sellerOrgDoc = await firestore.collection('orgs').doc(sellerOrgId).get()\n const sellerOrg = (sellerOrgDoc.data() ?? {}) as any\n // A LOCKED publisher sells nothing (AGL-3365). Any lock, not only a\n // security one: a sale to a workspace staff have stopped is money moving\n // to it, and the lockdown's payout pause cannot reach a share that is\n // never charged. The same answer as a taken-down listing — a lock is not\n // a buyer's business.\n if (sellerOrg.suspendedAt != null) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n if (!checkEntitlement(sellerOrg, 'marketplaceSelling')) {\n return res.status(409).json({\n error:\n \"The publisher's current plan does not include marketplace selling\",\n })\n }\n const feePercent = resolveMarketplaceFeePct(sellerOrg)\n const amountCents = Math.round(priceUsd * 100)\n const feeCents = Math.round((amountCents * feePercent) / 100)\n\n const origin = req.headers.origin ?? `https://${req.headers.host}`\n // Stripe returns the BUYER to the org marketplace (AGL-775) — the\n // per-site marketplace page it used to land on was retired. Only the\n // buyer's org slug is needed now (`sellerOrgId` is the wrong org), and\n // it's resolved from the host here since server code cannot call\n // useConsoleHostRoute. `/manage/marketplace` is the hostless fallback.\n //\n // The slug comes off the VALIDATED buyer org resolved at the top of the\n // handler now (AGL-2331) rather than a second, unvalidated `hostIndex`\n // read: one org id decides where the buyer lands, which org the licence\n // entitles, and which org's erasure sweeps the idempotency claim, so\n // having two derivations of it was a way for those three to disagree.\n const buyerOrgSlug = (\n await firestore.collection('orgs').doc(buyerOrgId).get()\n ).get('slug') as string | undefined\n const returnPath = buyerOrgSlug\n ? buildRoute(Route.ORG_MARKETPLACE, { orgSlug: buyerOrgSlug })\n : Route.ORG_MARKETPLACE\n // Tax + funds flow (AGL-1544). Aglyn registered as a marketplace\n // provider with the Texas Comptroller: it collects and remits sales tax\n // on sellers' behalf. That is the PLATFORM's charge — no `on_behalf_of`,\n // `automatic_tax` on the platform session, tax added ON TOP of the\n // listing price (`tax_behavior: exclusive`) with a full billing address\n // to compute from. The transfer is a FIXED amount (the seller's share of\n // the pre-tax price) rather than `application_fee_amount`, because the\n // fee form makes Stripe transfer `amount_total − fee` — and amount_total\n // includes the tax, which must stay with the platform that owes it.\n const transferCents = amountCents - feeCents\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(amountCents),\n 'line_items[0][price_data][tax_behavior]': 'exclusive',\n 'line_items[0][price_data][product_data][name]': String(\n listing.displayName ?? 'Marketplace component',\n ).slice(0, 120),\n // The classification, stated rather than inherited (AGL-1553). See\n // MARKETPLACE_TAX_CODE above: same value the account preset carries, so\n // the tax computed is unchanged, but it no longer depends on a Dashboard\n // field owned by Aglyn's subscription products.\n 'line_items[0][price_data][product_data][tax_code]': MARKETPLACE_TAX_CODE,\n 'automatic_tax[enabled]': 'true',\n billing_address_collection: 'required',\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n 'payment_intent_data[transfer_data][amount]': String(transferCents),\n // THE DISCRIMINATOR THE REFUND DOOR HAS NO OTHER WAY TO GET (AGL-2148).\n //\n // `metadata[type]` below rides on the SESSION, and `charge.refunded`\n // never sees a session — its object is the charge. The platform\n // endpoint receives `charge.refunded` for storefront orders and\n // subscription charges too, so the webhook cannot tell a marketplace\n // refund from any other one except by joining on the payment intent,\n // which is exactly the join that comes up empty during the window this\n // metadata exists to close (the purchase document is written by\n // `checkout.session.completed`, and that delivery retries).\n //\n // Stripe copies a PaymentIntent's metadata onto the charge it creates,\n // so stamping it here makes `charge.metadata.type` readable straight\n // off the refund event — no Firestore join, no Stripe round trip, and\n // no orphan record written for the commerce and subscription refunds\n // that share this endpoint.\n //\n // `listingId`/`buyerUid` ride along for forensics only: an orphan that\n // never drains is a support case, and the payment intent alone does not\n // say whose purchase of what it was.\n 'payment_intent_data[metadata][type]': 'marketplace-purchase',\n 'payment_intent_data[metadata][listingId]': listingId,\n 'payment_intent_data[metadata][buyerUid]': decoded.uid,\n 'payment_intent_data[metadata][buyerOrgId]': buyerOrgId,\n success_url: `${origin}${returnPath}?purchase=success`,\n cancel_url: `${origin}${returnPath}?purchase=canceled`,\n client_reference_id: decoded.uid,\n 'metadata[type]': 'marketplace-purchase',\n 'metadata[listingId]': listingId,\n 'metadata[buyerUid]': decoded.uid,\n // THE ORG THE LICENCE IS FOR (AGL-2331). The webhook writes the\n // purchase document from this metadata and had no buyer org to write,\n // which is why the entitlement could only ever be person-scoped: the\n // one field the gate needs was never carried across the Stripe\n // boundary. Validated above against the caller's own membership, so a\n // session can never license an org the buyer has no standing in.\n 'metadata[buyerOrgId]': buyerOrgId,\n 'metadata[sellerOrgId]': sellerOrgId,\n 'metadata[feeCents]': String(feeCents),\n 'metadata[transferCents]': String(transferCents),\n // The buyer's GA client id (AGL-1638), so the server-side `purchase`\n // the webhook sends joins the browser session that produced it. The\n // webhook has always READ `metadata.ga_client_id`; nothing wrote it,\n // so the read was dead and every marketplace sale fell through to a\n // synthesized, sessionless GA user — making plugin revenue permanently\n // unattributable to any channel.\n //\n // Carried on the SESSION rather than a subscription because this is a\n // one-time payment; `checkout.session.completed` is where it is read.\n //\n // Validated to GA's `<digits>.<digits>` shape and dropped otherwise:\n // the value is client-supplied and ends up in Stripe metadata, so it is\n // not accepted on trust. Same guard /api/billing/checkout applies.\n ...(/^\\d+\\.\\d+$/.test(String(req.body?.gaClientId ?? ''))\n ? { 'metadata[ga_client_id]': String(req.body.gaClientId) }\n : {}),\n ...(decoded.email ? { customer_email: decoded.email } : {}),\n })\n // Point of no return (AGL-1697). The purchase check above closes the\n // SEQUENTIAL duplicate — a second buy after the webhook has written the\n // first purchase — and cannot close the concurrent one, because between two\n // clicks a second apart there is no purchase record yet to find. The claim\n // is what covers that window: `create()` on a document Firestore refuses to\n // create twice.\n //\n // Scoped to the listing AND the buyer, so two people buying the same\n // component at the same moment never contend, and one person's two tabs\n // always do.\n //\n // The claim is taken here rather than at the top so that every deterministic\n // refusal above it — unknown listing, free listing, self-purchase, already\n // owned, publisher not paid out, seller cannot sell — answers without\n // burning the key. Those are all states a buyer or publisher fixes and then\n // presses the same button again.\n const claimed = await claimAttempt(firestore, {\n kind: 'marketplace-checkout',\n // Scoped to the ORG as well as the buyer since AGL-2331. The scope is\n // what decides which two attempts are allowed to contend, and a\n // licence for one workspace and a licence for another are now two\n // different goods — an agency owner buying for two clients from two\n // tabs must not have one of them read as a double-click of the other.\n scopeId: `${listingId}:${buyerOrgId}:${decoded.uid}`,\n // The BUYER's org, so `eraseOrgIdempotencyKeys` sweeps this with the\n // buyer's data (AGL-1448) — the seller keeps the sale.\n orgId: buyerOrgId,\n key: idempotencyKey,\n busyMessage: 'This purchase is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money. Stripe replays the existing\n // session for a repeated key instead of opening a second one, which\n // covers the window where our own claim is written but the response\n // never arrives — the failure this route was most exposed to, since\n // it writes nothing of its own to reconcile against.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe checkout error', session.error)\n // The purchase did not start — let the same key be tried again rather\n // than locking this buyer out of this listing over one flaky moment.\n await claim.release()\n return res.status(502).json({ error: 'Stripe checkout failed' })\n }\n const payload = { url: session.url }\n // A repeat of this attempt now replays the SAME session url. Better than\n // refusing it: the buyer lands back on the checkout they already had open.\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // and lock the buyer out of this listing until they reload.\n await claim?.release()\n return res.status(500).json({ error: 'Checkout failed' })\n }\n}\n"],"names":["firebaseAdmin","connectLinkageIsReady","checkoutSessionCardAuthenticationParams","buildRoute","checkEntitlement","claimAttempt","isListingWideRevocation","resolveMarketplaceFeePct","Route","resolveOrgPermissions","isListingBrowsable","isPrivateListing","canActAsPublisher","resolvePublisherProfile","hasLivePurchase","isOrgMember","MARKETPLACE_TAX_CODE","checkoutHandler","req","res","method","status","json","error","process","env","STRIPE_SECRET_KEY","listingId","String","body","hostId","requestedOrgId","orgId","idempotencyKey","headers","trim","slice","authorization","idToken","startsWith","length","undefined","claim","membership","listing","sellerOrgDoc","decoded","app","auth","verifyIdToken","uid","buyerOrgId","permissions","installPlugins","firestore","listingSnapshot","collection","doc","get","data","deletedAt","reviewStatus","priceUsd","Number","sellerOrgId","profileId","buyerUid","code","revocation","publisher","accountId","stripeAccountId","chargesEnabled","stripeChargesEnabled","accountLivemode","stripeAccountLivemode","subject","sellerOrg","suspendedAt","feePercent","amountCents","Math","round","feeCents","origin","host","buyerOrgSlug","returnPath","ORG_MARKETPLACE","orgSlug","transferCents","params","URLSearchParams","mode","displayName","billing_address_collection","success_url","cancel_url","client_reference_id","test","gaClientId","email","customer_email","claimed","kind","scopeId","key","busyMessage","replay","response","fetch","Authorization","stripeKey","toString","session","ok","url","console","release","payload","record"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,aAAa,QAAQ,2BAA0B;AACxD,SAASC,qBAAqB,QAAQ,sDAAqD;AAC3F,SAASC,uCAAuC,QAAQ,6DAA4D;AACpH,SACEC,UAAU,EACVC,gBAAgB,EAChBC,YAAY,EACZC,uBAAuB,EACvBC,wBAAwB,EACxBC,KAAK,QAIA,sBAAqB;AAC5B,SAASC,qBAAqB,QAAQ,wCAAuC;AAC7E,SAASC,kBAAkB,EAAEC,gBAAgB,QAAQ,0BAAsB;AAC3E,SACEC,iBAAiB,EACjBC,uBAAuB,QAClB,yBAAqB;AAC5B,SAASC,eAAe,QAAQ,4BAAwB;AACxD,SAASC,WAAW,QAAQ,iBAAa;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8CC,GACD,OAAO,MAAMC,uBAAuB,gBAAe;AAEnD;;;;;;;;;CASC,GACD,OAAO,MAAMC,kBAAoC,OAAOC,KAAKC;4BAmBzDD,OAAAA,6BAM2BA;QAhBJA,WACHA,YAIQA;IAb9B,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,IAAI,CAACC,QAAQC,GAAG,CAACC,iBAAiB,EAAE;QAClC,OAAOP,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;YAAEC,OAAO;QAAoD;IACvE;IACA,MAAMI,YAAYC,gBAAOV,YAAAA,IAAIW,IAAI,qBAARX,UAAUS,SAAS,mBAAI;IAChD,MAAMG,SAASF,iBAAOV,aAAAA,IAAIW,IAAI,qBAARX,WAAUY,MAAM,oBAAI;IAC1C,wEAAwE;IACxE,6EAA6E;IAC7E,yEAAyE;IACzE,MAAMC,iBAAiBH,iBAAOV,aAAAA,IAAIW,IAAI,qBAARX,WAAUc,KAAK,oBAAI;IACjD,0EAA0E;IAC1E,0EAA0E;IAC1E,iCAAiC;IACjC,MAAMC,iBAAiBL,QACrBV,SAAAA,8BAAAA,IAAIgB,OAAO,CAAC,kBAAkB,YAA9BhB,8BAAkCA,IAAIgB,OAAO,CAAC,kBAAkB,YAAhEhB,QAAoE,IAEnEiB,IAAI,GACJC,KAAK,CAAC,GAAG;IACZ,IAAI,CAACT,WAAW,OAAOR,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAoB;IAEzE,MAAMc,gBAAgBT,QAAOV,6BAAAA,IAAIgB,OAAO,CAACG,aAAa,YAAzBnB,6BAA6B;IAC1D,MAAMoB,UAAUD,cAAcE,UAAU,CAAC,aACrCF,cAAcD,KAAK,CAAC,UAAUI,MAAM,IACpCC;IACJ,IAAI,CAACH,SAAS,OAAOnB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IAErE,IAAImB,QAA6B;IACjC,IAAI;YA8BiBC,mBA6DKC,mBAKGA,oBA+GRC,oBAmBJ3B,qBAqCX0B;YAgE2B1B;QAtU/B,MAAM4B,UAAU,MAAM9C,cAAc+C,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACX;QAC/D,2CAA2C;QAC3C,EAAE;QACF,sEAAsE;QACtE,yEAAyE;QACzE,wEAAwE;QACxE,mEAAmE;QACnE,yEAAyE;QACzE,uEAAuE;QACvE,sEAAsE;QACtE,mDAAmD;QACnD,EAAE;QACF,2EAA2E;QAC3E,2EAA2E;QAC3E,qEAAqE;QACrE,2EAA2E;QAC3E,sEAAsE;QACtE,uEAAuE;QACvE,0EAA0E;QAC1E,QAAQ;QACR,EAAE;QACF,uEAAuE;QACvE,2EAA2E;QAC3E,uEAAuE;QACvE,wEAAwE;QACxE,MAAMK,aAAa,MAAMlC,sBAAsBqC,QAAQI,GAAG,EAAE;YAC1DlB,OAAOD,kBAAkB;YACzBD,QAAQC,iBAAiB,OAAOD,UAAU;QAC5C;QACA,MAAMqB,cAAaR,oBAAAA,WAAWX,KAAK,YAAhBW,oBAAoB;QACvC,IAAI,CAACQ,YAAY;YACf,OAAOhC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE,sEACA;YACJ;QACF;QACA,IAAIQ,kBAAkBoB,eAAepB,gBAAgB;YACnD,OAAOZ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAoC;QAC3E;QACA,IAAI,CAACoB,WAAWS,WAAW,CAACC,cAAc,EAAE;YAC1C,OAAOlC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE;YACJ;QACF;QACA,MAAM+B,YAAYtD,cAAc+C,GAAG,GAAGO,SAAS;QAC/C,MAAMC,kBAAkB,MAAMD,UAC3BE,UAAU,CAAC,uBACXC,GAAG,CAAC9B,WACJ+B,GAAG;QACN,MAAMd,UAAUW,gBAAgBI,IAAI;QACpC,IAAI,CAACf,WAAWA,QAAQgB,SAAS,EAAE;YACjC,OAAOzC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;QACzD;QACA,oEAAoE;QACpE,wEAAwE;QACxE,wEAAwE;QACxE,wEAAwE;QACxE,gCAAgC;QAChC,MAAMsC,eAAejB,QAAQiB,YAAY;QACzC,IAAIA,gBAAgBA,iBAAiB,YAAYA,iBAAiB,YAAY;YAC5E,OAAO1C,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;QACzD;QACA,6DAA6D;QAC7D,EAAE;QACF,uEAAuE;QACvE,wEAAwE;QACxE,+DAA+D;QAC/D,kEAAkE;QAClE,yEAAyE;QACzE,yEAAyE;QACzE,mEAAmE;QACnE,0EAA0E;QAC1E,qEAAqE;QACrE,yEAAyE;QACzE,oDAAoD;QACpD,EAAE;QACF,0EAA0E;QAC1E,qEAAqE;QACrE,0EAA0E;QAC1E,0EAA0E;QAC1E,oBAAoB;QACpB,EAAE;QACF,qEAAqE;QACrE,qEAAqE;QACrE,iCAAiC;QACjC,IAAI,CAACb,mBAAmBkC,YAAYjC,iBAAiBiC,UAAU;YAC7D,OAAOzB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;QACzD;QACA,MAAMuC,WAAWC,QAAOnB,oBAAAA,QAAQkB,QAAQ,YAAhBlB,oBAAoB;QAC5C,IAAI,CAAEkB,CAAAA,WAAW,CAAA,GAAI;YACnB,OAAO3C,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;QACzD;QACA,0EAA0E;QAC1E,MAAMyC,cAAcpC,QAAOgB,qBAAAA,QAAQqB,SAAS,YAAjBrB,qBAAqB;QAChD,kEAAkE;QAClE,yEAAyE;QACzE,yEAAyE;QACzE,yEAAyE;QACzE,uEAAuE;QACvE,0EAA0E;QAC1E,IACEO,eAAea,eACd,MAAMpD,kBAAkB0C,WAAWR,QAAQI,GAAG,EAAEc,gBAChD,MAAMjD,YAAYuC,WAAWU,aAAalB,QAAQI,GAAG,GACtD;YACA,OAAO/B,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA2C;QAClF;QACA,sDAAsD;QACtD,EAAE;QACF,qEAAqE;QACrE,sEAAsE;QACtE,4EAA4E;QAC5E,0EAA0E;QAC1E,yEAAyE;QACzE,uEAAuE;QACvE,EAAE;QACF,mEAAmE;QACnE,wEAAwE;QACxE,2EAA2E;QAC3E,0EAA0E;QAC1E,0EAA0E;QAC1E,wEAAwE;QACxE,2EAA2E;QAC3E,WAAW;QACX,EAAE;QACF,yEAAyE;QACzE,iDAAiD;QACjD,EAAE;QACF,wEAAwE;QACxE,yEAAyE;QACzE,0EAA0E;QAC1E,0EAA0E;QAC1E,yEAAyE;QACzE,kEAAkE;QAClE,2CAA2C;QAC3C,EAAE;QACF,yEAAyE;QACzE,oEAAoE;QACpE,wEAAwE;QACxE,IACE,MAAMT,gBAAgB;YACpBwC;YACAY,UAAUpB,QAAQI,GAAG;YACrBC;YACAxB;QACF,IACA;YACA,OAAOR,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE,mEACA;gBACF4C,MAAM;YACR;QACF;QACA,oDAAoD;QACpD,EAAE;QACF,uEAAuE;QACvE,yEAAyE;QACzE,uEAAuE;QACvE,kEAAkE;QAClE,6BAA6B;QAC7B,EAAE;QACF,oEAAoE;QACpE,gEAAgE;QAChE,0EAA0E;QAC1E,uEAAuE;QACvE,yEAAyE;QACzE,iEAAiE;QACjE,EAAE;QACF,oEAAoE;QACpE,qEAAqE;QACrE,0EAA0E;QAC1E,uEAAuE;QACvE,MAAMC,aAAa,AACjB,CAAA,MAAMd,UAAUE,UAAU,CAAC,eAAeC,GAAG,CAAC9B,WAAW+B,GAAG,EAAC,EAC7DC,IAAI;QACN,IAAIrD,wBAAwB8D,aAAa;YACvC,OAAOjD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;QACzD;QACA,MAAM8C,YAAY,MAAMxD,wBAAwByC,WAAWU;QAC3D,MAAMM,YAAYD,6BAAAA,UAAWE,eAAe;QAC5C,IACE,CAACtE,sBACC;YACEqE;YACAE,cAAc,EAAEH,6BAAAA,UAAWI,oBAAoB;YAC/CC,eAAe,EAAEL,6BAAAA,UAAWM,qBAAqB;QACnD,GACA;YAAEC,SAAS,CAAC,0BAA0B,EAAEZ,aAAa;QAAC,IAExD;YACA,OAAO7C,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAO;YAA4C;QAC/D;QAEA,uEAAuE;QACvE,sEAAsE;QACtE,sEAAsE;QACtE,qEAAqE;QACrE,sEAAsE;QACtE,mEAAmE;QACnE,wEAAwE;QACxE,MAAMsB,eAAe,MAAMS,UAAUE,UAAU,CAAC,QAAQC,GAAG,CAACO,aAAaN,GAAG;QAC5E,MAAMmB,aAAahC,qBAAAA,aAAac,IAAI,cAAjBd,qBAAuB,CAAC;QAC3C,oEAAoE;QACpE,yEAAyE;QACzE,sEAAsE;QACtE,yEAAyE;QACzE,sBAAsB;QACtB,IAAIgC,UAAUC,WAAW,IAAI,MAAM;YACjC,OAAO3D,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;QACzD;QACA,IAAI,CAACnB,iBAAiByE,WAAW,uBAAuB;YACtD,OAAO1D,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE;YACJ;QACF;QACA,MAAMwD,aAAaxE,yBAAyBsE;QAC5C,MAAMG,cAAcC,KAAKC,KAAK,CAACpB,WAAW;QAC1C,MAAMqB,WAAWF,KAAKC,KAAK,CAAC,AAACF,cAAcD,aAAc;QAEzD,MAAMK,UAASlE,sBAAAA,IAAIgB,OAAO,CAACkD,MAAM,YAAlBlE,sBAAsB,CAAC,QAAQ,EAAEA,IAAIgB,OAAO,CAACmD,IAAI,EAAE;QAClE,kEAAkE;QAClE,qEAAqE;QACrE,uEAAuE;QACvE,iEAAiE;QACjE,uEAAuE;QACvE,EAAE;QACF,wEAAwE;QACxE,uEAAuE;QACvE,wEAAwE;QACxE,qEAAqE;QACrE,sEAAsE;QACtE,MAAMC,eAAe,AACnB,CAAA,MAAMhC,UAAUE,UAAU,CAAC,QAAQC,GAAG,CAACN,YAAYO,GAAG,EAAC,EACvDA,GAAG,CAAC;QACN,MAAM6B,aAAaD,eACfnF,WAAWK,MAAMgF,eAAe,EAAE;YAAEC,SAASH;QAAa,KAC1D9E,MAAMgF,eAAe;QACzB,iEAAiE;QACjE,wEAAwE;QACxE,yEAAyE;QACzE,mEAAmE;QACnE,wEAAwE;QACxE,yEAAyE;QACzE,uEAAuE;QACvE,yEAAyE;QACzE,oEAAoE;QACpE,MAAME,gBAAgBV,cAAcG;QACpC,MAAMQ,SAAS,IAAIC,gBAAgB;YACjCC,MAAM;WAEH3F,wCAAwC;YAC3C,2BAA2B;YAC3B,uCAAuC;YACvC,0CAA0C0B,OAAOoD;YACjD,2CAA2C;YAC3C,iDAAiDpD,QAC/CgB,uBAAAA,QAAQkD,WAAW,YAAnBlD,uBAAuB,yBACvBR,KAAK,CAAC,GAAG;YACX,mEAAmE;YACnE,wEAAwE;YACxE,yEAAyE;YACzE,gDAAgD;YAChD,qDAAqDpB;YACrD,0BAA0B;YAC1B+E,4BAA4B;YAC5B,mDAAmDnE,OAAO0C;YAC1D,8CAA8C1C,OAAO8D;YACrD,wEAAwE;YACxE,EAAE;YACF,qEAAqE;YACrE,gEAAgE;YAChE,gEAAgE;YAChE,qEAAqE;YACrE,qEAAqE;YACrE,uEAAuE;YACvE,gEAAgE;YAChE,4DAA4D;YAC5D,EAAE;YACF,uEAAuE;YACvE,qEAAqE;YACrE,sEAAsE;YACtE,qEAAqE;YACrE,4BAA4B;YAC5B,EAAE;YACF,uEAAuE;YACvE,wEAAwE;YACxE,qCAAqC;YACrC,uCAAuC;YACvC,4CAA4C/D;YAC5C,2CAA2CmB,QAAQI,GAAG;YACtD,6CAA6CC;YAC7C6C,aAAa,GAAGZ,SAASG,WAAW,iBAAiB,CAAC;YACtDU,YAAY,GAAGb,SAASG,WAAW,kBAAkB,CAAC;YACtDW,qBAAqBpD,QAAQI,GAAG;YAChC,kBAAkB;YAClB,uBAAuBvB;YACvB,sBAAsBmB,QAAQI,GAAG;YACjC,gEAAgE;YAChE,sEAAsE;YACtE,qEAAqE;YACrE,+DAA+D;YAC/D,sEAAsE;YACtE,iEAAiE;YACjE,wBAAwBC;YACxB,yBAAyBa;YACzB,sBAAsBpC,OAAOuD;YAC7B,2BAA2BvD,OAAO8D;WAc9B,aAAaS,IAAI,CAACvE,iBAAOV,aAAAA,IAAIW,IAAI,qBAARX,WAAUkF,UAAU,oBAAI,OACjD;YAAE,0BAA0BxE,OAAOV,IAAIW,IAAI,CAACuE,UAAU;QAAE,IACxD,CAAC,GACDtD,QAAQuD,KAAK,GAAG;YAAEC,gBAAgBxD,QAAQuD,KAAK;QAAC,IAAI,CAAC;QAE3D,qEAAqE;QACrE,wEAAwE;QACxE,4EAA4E;QAC5E,2EAA2E;QAC3E,4EAA4E;QAC5E,gBAAgB;QAChB,EAAE;QACF,qEAAqE;QACrE,wEAAwE;QACxE,aAAa;QACb,EAAE;QACF,6EAA6E;QAC7E,2EAA2E;QAC3E,sEAAsE;QACtE,4EAA4E;QAC5E,iCAAiC;QACjC,MAAME,UAAU,MAAMlG,aAAaiD,WAAW;YAC5CkD,MAAM;YACN,sEAAsE;YACtE,gEAAgE;YAChE,kEAAkE;YAClE,oEAAoE;YACpE,sEAAsE;YACtEC,SAAS,GAAG9E,UAAU,CAAC,EAAEwB,WAAW,CAAC,EAAEL,QAAQI,GAAG,EAAE;YACpD,qEAAqE;YACrE,uDAAuD;YACvDlB,OAAOmB;YACPuD,KAAKzE;YACL0E,aAAa;QACf;QACA,IAAI,YAAYJ,SAAS;YACvB,OAAOpF,IAAIE,MAAM,CAACkF,QAAQK,MAAM,CAACvF,MAAM,EAAEC,IAAI,CAACiF,QAAQK,MAAM,CAAC/E,IAAI;QACnE;QACAa,QAAQ6D,QAAQ7D,KAAK;QAErB,MAAMmE,WAAW,MAAMC,MACrB,+CACA;YACE1F,QAAQ;YACRc,SAAS;gBACP6E,eAAe,CAAC,OAAO,EAAEvF,QAAQC,GAAG,CAACC,iBAAiB,EAAE;gBACxD,gBAAgB;eAMZgB,MAAMsE,SAAS,GAAG;gBAAE,mBAAmBtE,MAAMsE,SAAS;YAAC,IAAI,CAAC;YAElEnF,MAAM8D,OAAOsB,QAAQ;QACvB;QAEF,MAAMC,UAAW,MAAML,SAASvF,IAAI;QACpC,IAAI,CAACuF,SAASM,EAAE,IAAI,CAACD,QAAQE,GAAG,EAAE;YAChCC,QAAQ9F,KAAK,CAAC,yBAAyB2F,QAAQ3F,KAAK;YACpD,sEAAsE;YACtE,qEAAqE;YACrE,MAAMmB,MAAM4E,OAAO;YACnB,OAAOnG,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAyB;QAChE;QACA,MAAMgG,UAAU;YAAEH,KAAKF,QAAQE,GAAG;QAAC;QACnC,yEAAyE;QACzE,2EAA2E;QAC3E,MAAM1E,MAAM8E,MAAM,CAAC,KAAKD;QACxB,OAAOpG,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAACiG;IAC9B,EAAE,OAAOhG,OAAO;QACd8F,QAAQ9F,KAAK,CAACA;QACd,wEAAwE;QACxE,4DAA4D;QAC5D,OAAMmB,yBAAAA,MAAO4E,OAAO;QACpB,OAAOnG,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAkB;IACzD;AACF,EAAC"}
|
|
@@ -17,7 +17,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
17
17
|
*/ import { firebaseAdmin, getOrgForUser } from "@aglyn/tenant-data-admin";
|
|
18
18
|
import { resolvePlatformStripeMode } from "@aglyn/tenant-data-admin/server/stripe-account-mode";
|
|
19
19
|
import { buildRoute, Route } from "@aglyn/aglyn/server";
|
|
20
|
+
import { orgAgeDays } from "@aglyn/tenant-data-admin/server/org-age";
|
|
20
21
|
import { canActAsPublisher } from "./publisher-profile.js";
|
|
22
|
+
import { applyPublisherPayoutPolicy } from "./sale-risk.js";
|
|
21
23
|
async function stripe(path, params) {
|
|
22
24
|
const response = await fetch(`https://api.stripe.com/v1/${path}`, _extends({
|
|
23
25
|
method: params ? 'POST' : 'GET',
|
|
@@ -140,7 +142,19 @@ async function stripe(path, params) {
|
|
|
140
142
|
merge: true
|
|
141
143
|
});
|
|
142
144
|
const origin = (_req_headers_origin = req.headers.origin) != null ? _req_headers_origin : `https://${req.headers.host}`;
|
|
143
|
-
const
|
|
145
|
+
const orgSnapshot = await firestore.collection('orgs').doc(orgId).get();
|
|
146
|
+
const orgSlug = orgSnapshot.get('slug');
|
|
147
|
+
// A young publisher's payouts are held (AGL-3365) — see
|
|
148
|
+
// `model/publisher-risk.ts`. Here, where the account is created and
|
|
149
|
+
// where the publisher comes back to it; the sale webhook applies the
|
|
150
|
+
// same policy, so a publisher who never returns still ages out of it.
|
|
151
|
+
// Never throws, and writes to Stripe only when the answer changes.
|
|
152
|
+
await applyPublisherPayoutPolicy({
|
|
153
|
+
firestore,
|
|
154
|
+
publisherOrgId: orgId,
|
|
155
|
+
ageDays: orgAgeDays(orgSnapshot.get('createdAt')),
|
|
156
|
+
stripeKey: process.env.STRIPE_SECRET_KEY
|
|
157
|
+
});
|
|
144
158
|
// Return people to the Marketplace page (AGL-861), not the retired
|
|
145
159
|
// `/[orgSlug]/marketplace` surface. Stripe bakes these URLs into the
|
|
146
160
|
// onboarding link, so an extra redirect hop mid-onboarding is avoidable.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/connect.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 { firebaseAdmin, getOrgForUser } from '@aglyn/tenant-data-admin'\nimport { resolvePlatformStripeMode } from '@aglyn/tenant-data-admin/server/stripe-account-mode'\nimport { buildRoute, Route, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { canActAsPublisher } from './publisher-profile'\n\nasync function stripe(path: string, params?: URLSearchParams) {\n const response = await fetch(`https://api.stripe.com/v1/${path}`, {\n method: params ? 'POST' : 'GET',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n ...(params && {\n 'Content-Type': 'application/x-www-form-urlencoded',\n }),\n },\n ...(params && { body: params.toString() }),\n })\n const payload = await response.json()\n if (!response.ok) {\n throw new Error(payload?.error?.message ?? `Stripe ${path} failed`)\n }\n return payload\n}\n\n/**\n * Stripe Connect onboarding for marketplace publishers (AGL-46): creates an\n * Express account on first call (id stored on the profile via Admin SDK),\n * refreshes `stripeChargesEnabled` on every call, and returns an\n * account-link URL while onboarding is incomplete. 501 without Stripe env.\n */\nexport const connectHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res\n .status(501)\n .json({ error: 'Payouts are not configured (STRIPE_SECRET_KEY).' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n // Payouts belong to the publishing ORG (AGL-652) — the marketplace pays\n // the organization that published, not whoever set the account up. Only\n // a manager may bind a payout destination.\n //\n // The acting org comes from the client (AGL-861): the URL is the source of\n // truth for which workspace the seller is in, and a member can belong to\n // several. Guessing with `getOrgForUser(uid)` returned the FIRST org from\n // the reverse index, so the profile check ran against the wrong org and\n // 412'd even when the acting org's profile was fully set up. Passing the\n // requested org resolves membership for that specific org; falling back to\n // the first only when the client omits it keeps older callers working.\n const requestedOrgId =\n String((req.body as { orgId?: string } | undefined)?.orgId ?? '').trim() ||\n undefined\n const orgForUser = await getOrgForUser(decoded.uid, requestedOrgId)\n const orgId = orgForUser?.orgId\n if (!orgId || !(await canActAsPublisher(firestore, decoded.uid, orgId))) {\n return res.status(403).json({\n error: 'Only an organization owner or admin can set up payouts',\n })\n }\n const profileRef = firestore.collection('publisherProfiles').doc(orgId)\n const profileSnapshot = await profileRef.get()\n if (!profileSnapshot.exists) {\n return res.status(412).json({\n error:\n 'Set up your publisher profile first — Marketplace → Profile.',\n })\n }\n\n let accountId = profileSnapshot.get('stripeAccountId') as\n | string\n | undefined\n if (!accountId) {\n const account = await stripe(\n 'accounts',\n new URLSearchParams({\n type: 'express',\n // Explicit capabilities (AGL-1547): without them the account's\n // abilities depend on unverified dashboard platform-profile\n // defaults, and a destination charge (`transfer_data`) requires\n // the destination to hold `transfers` — an account onboarded\n // without it takes money that can never pay out.\n 'capabilities[card_payments][requested]': 'true',\n 'capabilities[transfers][requested]': 'true',\n 'metadata[publisherOrgId]': orgId,\n ...(decoded.email ? { email: decoded.email } : {}),\n }),\n )\n accountId = account.id\n await profileRef.set(\n { stripeAccountId: accountId, stripeChargesEnabled: false },\n { merge: true },\n )\n }\n\n const account = await stripe(`accounts/${accountId}`)\n const chargesEnabled = Boolean(account?.charges_enabled)\n // Which Stripe world this account lives in (AGL-2471) — two of the three\n // poisoned production linkages were publisherProfiles, written by this\n // very route from a localhost session holding the test key. See the\n // commerce twin and `stripe-account-mode.ts` for why the retrieve above\n // is the proof and the `acct_…` string is not.\n const platformMode = await resolvePlatformStripeMode()\n // Payout readiness rides along (AGL-1547): the gate stays on\n // charges_enabled, but the profile records whether the money can\n // actually LEAVE Stripe — the live drill (AGL-1548) reads it, and a\n // seller panel can warn before the first sale strands funds.\n const payoutsEnabled = Boolean(account?.payouts_enabled)\n await profileRef.set(\n {\n stripeChargesEnabled: chargesEnabled,\n stripePayoutsEnabled: payoutsEnabled,\n // ALWAYS written, and `null` when Stripe would not say (AGL-2471).\n // Writing unconditionally is what makes re-onboarding safe: a fresh\n // `stripeAccountId` on a document that still carried the PREVIOUS\n // account's verdict would otherwise inherit it. `null` is not a\n // boolean, so the gate reads it as `mode-unverified` and refuses —\n // never a fabricated `true`.\n stripeAccountLivemode: platformMode ? platformMode === 'live' : null,\n },\n { merge: true },\n )\n const origin = req.headers.origin ?? `https://${req.headers.host}`\n const orgSlug = (\n await firestore.collection('orgs').doc(orgId).get()\n ).get('slug') as string | undefined\n // Return people to the Marketplace page (AGL-861), not the retired\n // `/[orgSlug]/marketplace` surface. Stripe bakes these URLs into the\n // onboarding link, so an extra redirect hop mid-onboarding is avoidable.\n const payoutsPath = orgSlug\n ? buildRoute(Route.ORG_MARKETPLACE, { orgSlug })\n : Route.ORG_MARKETPLACE\n // A CONNECTED PUBLISHER STILL NEEDS A DOOR INTO STRIPE. The\n // commerce twin carries the full note; the shape and the reasoning are the\n // same, and the seller panel's \"Finish payout setup in Stripe\" button was\n // the surface that named a problem it could not act on.\n if (chargesEnabled) {\n if (!payoutsEnabled) {\n const fix = await stripe(\n 'account_links',\n new URLSearchParams({\n account: accountId as string,\n type: 'account_onboarding',\n refresh_url: `${origin}${payoutsPath}?connect=refresh`,\n return_url: `${origin}${payoutsPath}?connect=done`,\n }),\n )\n return res.status(200).json({\n accountId,\n chargesEnabled: true,\n payoutsEnabled,\n url: fix.url,\n })\n }\n // Best-effort, for the same reason as the twin: a missing convenience\n // link must never turn a working status check into an error.\n let dashboardUrl: string | undefined\n try {\n const login = await stripe(\n `accounts/${accountId}/login_links`,\n new URLSearchParams(),\n )\n dashboardUrl = typeof login?.url === 'string' ? login.url : undefined\n } catch (error) {\n console.error('Stripe Express dashboard link failed', error)\n }\n return res.status(200).json({\n accountId,\n chargesEnabled: true,\n payoutsEnabled,\n ...(dashboardUrl ? { dashboardUrl } : {}),\n })\n }\n\n const link = await stripe(\n 'account_links',\n new URLSearchParams({\n account: accountId as string,\n type: 'account_onboarding',\n refresh_url: `${origin}${payoutsPath}?connect=refresh`,\n return_url: `${origin}${payoutsPath}?connect=done`,\n }),\n )\n return res\n .status(200)\n .json({ accountId, chargesEnabled: false, url: link.url })\n } catch (error) {\n console.error(error)\n return res.status(502).json({ error: 'Payout setup failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForUser","resolvePlatformStripeMode","buildRoute","Route","canActAsPublisher","stripe","path","params","response","fetch","method","headers","Authorization","process","env","STRIPE_SECRET_KEY","body","toString","payload","json","ok","Error","error","message","connectHandler","req","res","status","authorization","String","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","requestedOrgId","orgId","trim","orgForUser","uid","profileRef","collection","doc","profileSnapshot","get","exists","accountId","account","URLSearchParams","type","email","id","set","stripeAccountId","stripeChargesEnabled","merge","chargesEnabled","Boolean","charges_enabled","platformMode","payoutsEnabled","payouts_enabled","stripePayoutsEnabled","stripeAccountLivemode","origin","host","orgSlug","payoutsPath","ORG_MARKETPLACE","fix","refresh_url","return_url","url","dashboardUrl","login","console","link"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/connect.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 { firebaseAdmin, getOrgForUser } from '@aglyn/tenant-data-admin'\nimport { resolvePlatformStripeMode } from '@aglyn/tenant-data-admin/server/stripe-account-mode'\nimport { buildRoute, Route, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { orgAgeDays } from '@aglyn/tenant-data-admin/server/org-age'\nimport { canActAsPublisher } from './publisher-profile'\nimport { applyPublisherPayoutPolicy } from './sale-risk'\n\nasync function stripe(path: string, params?: URLSearchParams) {\n const response = await fetch(`https://api.stripe.com/v1/${path}`, {\n method: params ? 'POST' : 'GET',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n ...(params && {\n 'Content-Type': 'application/x-www-form-urlencoded',\n }),\n },\n ...(params && { body: params.toString() }),\n })\n const payload = await response.json()\n if (!response.ok) {\n throw new Error(payload?.error?.message ?? `Stripe ${path} failed`)\n }\n return payload\n}\n\n/**\n * Stripe Connect onboarding for marketplace publishers (AGL-46): creates an\n * Express account on first call (id stored on the profile via Admin SDK),\n * refreshes `stripeChargesEnabled` on every call, and returns an\n * account-link URL while onboarding is incomplete. 501 without Stripe env.\n */\nexport const connectHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res\n .status(501)\n .json({ error: 'Payouts are not configured (STRIPE_SECRET_KEY).' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n // Payouts belong to the publishing ORG (AGL-652) — the marketplace pays\n // the organization that published, not whoever set the account up. Only\n // a manager may bind a payout destination.\n //\n // The acting org comes from the client (AGL-861): the URL is the source of\n // truth for which workspace the seller is in, and a member can belong to\n // several. Guessing with `getOrgForUser(uid)` returned the FIRST org from\n // the reverse index, so the profile check ran against the wrong org and\n // 412'd even when the acting org's profile was fully set up. Passing the\n // requested org resolves membership for that specific org; falling back to\n // the first only when the client omits it keeps older callers working.\n const requestedOrgId =\n String((req.body as { orgId?: string } | undefined)?.orgId ?? '').trim() ||\n undefined\n const orgForUser = await getOrgForUser(decoded.uid, requestedOrgId)\n const orgId = orgForUser?.orgId\n if (!orgId || !(await canActAsPublisher(firestore, decoded.uid, orgId))) {\n return res.status(403).json({\n error: 'Only an organization owner or admin can set up payouts',\n })\n }\n const profileRef = firestore.collection('publisherProfiles').doc(orgId)\n const profileSnapshot = await profileRef.get()\n if (!profileSnapshot.exists) {\n return res.status(412).json({\n error:\n 'Set up your publisher profile first — Marketplace → Profile.',\n })\n }\n\n let accountId = profileSnapshot.get('stripeAccountId') as\n | string\n | undefined\n if (!accountId) {\n const account = await stripe(\n 'accounts',\n new URLSearchParams({\n type: 'express',\n // Explicit capabilities (AGL-1547): without them the account's\n // abilities depend on unverified dashboard platform-profile\n // defaults, and a destination charge (`transfer_data`) requires\n // the destination to hold `transfers` — an account onboarded\n // without it takes money that can never pay out.\n 'capabilities[card_payments][requested]': 'true',\n 'capabilities[transfers][requested]': 'true',\n 'metadata[publisherOrgId]': orgId,\n ...(decoded.email ? { email: decoded.email } : {}),\n }),\n )\n accountId = account.id\n await profileRef.set(\n { stripeAccountId: accountId, stripeChargesEnabled: false },\n { merge: true },\n )\n }\n\n const account = await stripe(`accounts/${accountId}`)\n const chargesEnabled = Boolean(account?.charges_enabled)\n // Which Stripe world this account lives in (AGL-2471) — two of the three\n // poisoned production linkages were publisherProfiles, written by this\n // very route from a localhost session holding the test key. See the\n // commerce twin and `stripe-account-mode.ts` for why the retrieve above\n // is the proof and the `acct_…` string is not.\n const platformMode = await resolvePlatformStripeMode()\n // Payout readiness rides along (AGL-1547): the gate stays on\n // charges_enabled, but the profile records whether the money can\n // actually LEAVE Stripe — the live drill (AGL-1548) reads it, and a\n // seller panel can warn before the first sale strands funds.\n const payoutsEnabled = Boolean(account?.payouts_enabled)\n await profileRef.set(\n {\n stripeChargesEnabled: chargesEnabled,\n stripePayoutsEnabled: payoutsEnabled,\n // ALWAYS written, and `null` when Stripe would not say (AGL-2471).\n // Writing unconditionally is what makes re-onboarding safe: a fresh\n // `stripeAccountId` on a document that still carried the PREVIOUS\n // account's verdict would otherwise inherit it. `null` is not a\n // boolean, so the gate reads it as `mode-unverified` and refuses —\n // never a fabricated `true`.\n stripeAccountLivemode: platformMode ? platformMode === 'live' : null,\n },\n { merge: true },\n )\n const origin = req.headers.origin ?? `https://${req.headers.host}`\n const orgSnapshot = await firestore.collection('orgs').doc(orgId).get()\n const orgSlug = orgSnapshot.get('slug') as string | undefined\n // A young publisher's payouts are held (AGL-3365) — see\n // `model/publisher-risk.ts`. Here, where the account is created and\n // where the publisher comes back to it; the sale webhook applies the\n // same policy, so a publisher who never returns still ages out of it.\n // Never throws, and writes to Stripe only when the answer changes.\n await applyPublisherPayoutPolicy({\n firestore,\n publisherOrgId: orgId,\n ageDays: orgAgeDays(orgSnapshot.get('createdAt')),\n stripeKey: process.env.STRIPE_SECRET_KEY,\n })\n // Return people to the Marketplace page (AGL-861), not the retired\n // `/[orgSlug]/marketplace` surface. Stripe bakes these URLs into the\n // onboarding link, so an extra redirect hop mid-onboarding is avoidable.\n const payoutsPath = orgSlug\n ? buildRoute(Route.ORG_MARKETPLACE, { orgSlug })\n : Route.ORG_MARKETPLACE\n // A CONNECTED PUBLISHER STILL NEEDS A DOOR INTO STRIPE. The\n // commerce twin carries the full note; the shape and the reasoning are the\n // same, and the seller panel's \"Finish payout setup in Stripe\" button was\n // the surface that named a problem it could not act on.\n if (chargesEnabled) {\n if (!payoutsEnabled) {\n const fix = await stripe(\n 'account_links',\n new URLSearchParams({\n account: accountId as string,\n type: 'account_onboarding',\n refresh_url: `${origin}${payoutsPath}?connect=refresh`,\n return_url: `${origin}${payoutsPath}?connect=done`,\n }),\n )\n return res.status(200).json({\n accountId,\n chargesEnabled: true,\n payoutsEnabled,\n url: fix.url,\n })\n }\n // Best-effort, for the same reason as the twin: a missing convenience\n // link must never turn a working status check into an error.\n let dashboardUrl: string | undefined\n try {\n const login = await stripe(\n `accounts/${accountId}/login_links`,\n new URLSearchParams(),\n )\n dashboardUrl = typeof login?.url === 'string' ? login.url : undefined\n } catch (error) {\n console.error('Stripe Express dashboard link failed', error)\n }\n return res.status(200).json({\n accountId,\n chargesEnabled: true,\n payoutsEnabled,\n ...(dashboardUrl ? { dashboardUrl } : {}),\n })\n }\n\n const link = await stripe(\n 'account_links',\n new URLSearchParams({\n account: accountId as string,\n type: 'account_onboarding',\n refresh_url: `${origin}${payoutsPath}?connect=refresh`,\n return_url: `${origin}${payoutsPath}?connect=done`,\n }),\n )\n return res\n .status(200)\n .json({ accountId, chargesEnabled: false, url: link.url })\n } catch (error) {\n console.error(error)\n return res.status(502).json({ error: 'Payout setup failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForUser","resolvePlatformStripeMode","buildRoute","Route","orgAgeDays","canActAsPublisher","applyPublisherPayoutPolicy","stripe","path","params","response","fetch","method","headers","Authorization","process","env","STRIPE_SECRET_KEY","body","toString","payload","json","ok","Error","error","message","connectHandler","req","res","status","authorization","String","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","firestore","requestedOrgId","orgId","trim","orgForUser","uid","profileRef","collection","doc","profileSnapshot","get","exists","accountId","account","URLSearchParams","type","email","id","set","stripeAccountId","stripeChargesEnabled","merge","chargesEnabled","Boolean","charges_enabled","platformMode","payoutsEnabled","payouts_enabled","stripePayoutsEnabled","stripeAccountLivemode","origin","host","orgSnapshot","orgSlug","publisherOrgId","ageDays","stripeKey","payoutsPath","ORG_MARKETPLACE","fix","refresh_url","return_url","url","dashboardUrl","login","console","link"],"mappings":"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"}
|
|
@@ -22,6 +22,7 @@ import { canActAsPublisher } from "./publisher-profile.js";
|
|
|
22
22
|
import { requirePurchase } from "./purchase-entitlement.js";
|
|
23
23
|
import { recordInstallProvenance } from "./provenance.js";
|
|
24
24
|
import { recordVersionMove } from "./version-stats.js";
|
|
25
|
+
import { isPublisherSecurityLocked } from "./sale-risk.js";
|
|
25
26
|
/**
|
|
26
27
|
* Installs a marketplace dataset schema into an org (AGL-657).
|
|
27
28
|
*
|
|
@@ -101,7 +102,8 @@ import { recordVersionMove } from "./version-stats.js";
|
|
|
101
102
|
//
|
|
102
103
|
// No owner exemption, matching `install-plugin.ts`: a takedown is a
|
|
103
104
|
// moderation decision about the artifact, not about who is asking.
|
|
104
|
-
listing.hiddenAt ||
|
|
105
|
+
listing.hiddenAt || // A publisher under a SECURITY lock hands nothing over (AGL-3365).
|
|
106
|
+
await isPublisherSecurityLocked(firestore, listing.profileId) || listingArtifactType(listing) !== 'datasetSchema') {
|
|
105
107
|
return res.status(404).json({
|
|
106
108
|
error: 'Unknown dataset schema'
|
|
107
109
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install-dataset-schema.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 checkDatasetQuota,\n checkEntitlement,\n createResourceUid,\n defaultScopeForNewResource,\n newResourceScopeFields,\n scopeCovers,\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 resolveInstalledDatasetSchema,\nlistingArtifactType,\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 dataset schema into an org (AGL-657).\n *\n * Creates a NEW, EMPTY dataset from the published model — installing never\n * merges into an existing dataset, because a schema change on a dataset that\n * already holds rows would silently reinterpret live data. Re-installing makes\n * another dataset rather than replacing one, for the same reason.\n *\n * Accepts either `orgId` or a `hostId` to derive it, so the shared install\n * hook (which is host-oriented) works unchanged. The site is ONLY that: a way\n * to find the org. It is never the site the dataset is created in — see the\n * scope decision below.\n */\nexport const installDatasetSchemaHandler: 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 const bodyOrgId = String(req.body?.orgId ?? '')\n if (!listingId || (!hostId && !bodyOrgId)) {\n return res.status(400).json({ error: 'Missing listingId or orgId' })\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 orgId =\n bodyOrgId || (hostId ? ((await getOrgForHost(hostId))?.orgId ?? '') : '')\n if (!orgId) {\n return res.status(404).json({ error: 'Site has no owning organization' })\n }\n const membership = await resolveOrgPermissions(decoded.uid, { orgId })\n if (membership.orgId !== orgId || !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 orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown organization' })\n }\n const org = orgSnapshot.data() as any\n if (!checkEntitlement(org, 'dataStore')) {\n return res\n .status(403)\n .json({ error: 'Datasets require a Starter plan or higher' })\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) !== 'datasetSchema'\n ) {\n return res.status(404).json({ error: 'Unknown dataset schema' })\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 published = versionSnapshot.get('datasetSchema') as any\n if (!published?.order?.length) {\n return res.status(500).json({ error: 'Dataset schema version missing' })\n }\n\n // Creating a dataset consumes org quota exactly like the console's own\n // create path (AGL-473) — installing must not be a way around it.\n // Deliberately counts EVERY dataset the org owns, not the visible ones\n // (AGL-1046). Scoping decides who can see a dataset, never who pays for\n // it — the org owns all of them. Counting per scope would also make a\n // collaborator's remaining quota disagree with the admin's.\n const datasets = await orgRef.collection('datasets').get()\n const quota = checkDatasetQuota(org, datasets.size)\n if (!quota.allowed) {\n return res.status(403).json({\n error: `Dataset limit reached (${quota.limit}) — see Billing to upgrade.`,\n })\n }\n\n /**\n * Who the new dataset is shared with. Decided before relinking, because\n * it also decides what a reference field may point at.\n *\n * A dataset schema installs at ORGANIZATION scope and nowhere else\n * (`INSTALL_TARGETS.datasetSchema`), from the organization Marketplace —\n * the one surface that installs anything. The `hostId` that surface sends\n * is the site it acts THROUGH so the org can be resolved: the org's first\n * site, not a site anyone is working in. So no site is in context, and the\n * dataset starts where a create on an organization page starts: All\n * sites, whatever the org's Default sharing says. Honoring the acting site\n * instead would hide the dataset from every other site, under an install\n * dialog that says it lands on \"the whole organization — every site\".\n */\n const visibleTo = defaultScopeForNewResource({\n defaultResourceScope: org?.defaultResourceScope,\n hostId: null,\n })\n\n // Relink reference fields onto this org's datasets by display name; what\n // can't be relinked degrades to text and is reported to the installer.\n // A candidate has to be visible everywhere the new dataset is, which is\n // the rule every reference answers to (`scopeCovers`, AGL-1044): one\n // pointing at a dataset some of those sites cannot see resolves to nothing\n // there, and nothing says why. That is also what keeps an install from\n // binding its reference fields to an agency's internal dataset of the same\n // name (AGL-1046) — the display-name collision AGL-1039 fixed on the\n // render path, arriving here instead.\n const byLabel: Record<string, string> = {}\n for (const entry of datasets.docs) {\n if (!scopeCovers(entry.get('visibleTo'), visibleTo)) continue\n const label = String(entry.get('displayName') ?? '').toLowerCase()\n if (label && !byLabel[label]) byLabel[label] = entry.id\n }\n const { schema, degradedFieldIds } = resolveInstalledDatasetSchema(\n published,\n byLabel,\n )\n\n const datasetId = createResourceUid()\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n // Provenance + base snapshot (AGL-1015). The RELINKED schema, not the\n // published one: relinking rewrites reference fields onto this org's\n // datasets, so a base holding the publisher's ids would report every\n // relinked field as a user edit the moment anything is diffed.\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: 'datasetSchema',\n content: schema,\n })\n await orgRef\n .collection('datasets')\n .doc(datasetId)\n .create({\n displayName: String(listing.displayName ?? 'Dataset').slice(0, 120),\n ...(listing.description && { description: listing.description }),\n // `fields` is the v1 flat list the older editor still reads; keep it in\n // step with the model so both readers agree (AGL-102).\n fields: schema.order,\n model: schema,\n source: {\n type: 'marketplace' as const,\n listingId,\n version: listing.latestVersion ?? null,\n },\n installedFrom: provenance.installedFrom,\n // The scope decided above. Stamping it is not optional — a dataset\n // with no `visibleTo` matches no scoped read and would render on no\n // site at all.\n //\n // Through the AGL-1478 gate since AGL-1484, so \"every other dataset\n // creator\" is a fact about the type rather than about four object\n // literals that happen to agree today.\n ...newResourceScopeFields(visibleTo),\n createdAt: now,\n })\n\n // Per-version tally (AGL-1036). Installing a schema always CREATES a new\n // dataset rather than replacing one, so there is never a version to leave.\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'datasetSchema',\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 datasetId,\n fields: schema.order.length,\n degradedFieldIds,\n version: listing.latestVersion ?? null,\n baseStored: provenance.baseStored,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Dataset schema install failed' })\n }\n}\n\nexport default installDatasetSchemaHandler\n"],"names":["checkDatasetQuota","checkEntitlement","createResourceUid","defaultScopeForNewResource","newResourceScopeFields","scopeCovers","firebaseAdmin","getOrgForHost","resolveOrgPermissions","isPrivateListing","resolveInstalledDatasetSchema","listingArtifactType","canActAsPublisher","requirePurchase","recordInstallProvenance","recordVersionMove","installDatasetSchemaHandler","req","res","method","status","json","error","listingId","String","body","hostId","bodyOrgId","orgId","authorization","headers","idToken","startsWith","slice","length","undefined","listing","membership","published","decoded","app","auth","verifyIdToken","uid","permissions","installPlugins","firestore","orgRef","collection","doc","orgSnapshot","get","exists","org","data","listingRef","listingSnapshot","deletedAt","hiddenAt","priceUsd","Number","ownsListing","profileId","unpaid","buyerUid","buyerOrgId","versionSnapshot","latestVersion","order","datasets","quota","size","allowed","limit","visibleTo","defaultResourceScope","byLabel","entry","docs","label","toLowerCase","id","schema","degradedFieldIds","datasetId","now","FieldValue","serverTimestamp","provenance","version","artifactType","content","create","displayName","description","fields","model","source","type","installedFrom","createdAt","to","update","installCount","increment","catch","installed","baseStored","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install-dataset-schema.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 checkDatasetQuota,\n checkEntitlement,\n createResourceUid,\n defaultScopeForNewResource,\n newResourceScopeFields,\n scopeCovers,\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 resolveInstalledDatasetSchema,\nlistingArtifactType,\n} from '../model/marketplace'\nimport { canActAsPublisher } from './publisher-profile'\nimport { requirePurchase } from './purchase-entitlement'\nimport { recordInstallProvenance } from './provenance'\nimport { recordVersionMove } from './version-stats'\nimport { isPublisherSecurityLocked } from './sale-risk'\n\n/**\n * Installs a marketplace dataset schema into an org (AGL-657).\n *\n * Creates a NEW, EMPTY dataset from the published model — installing never\n * merges into an existing dataset, because a schema change on a dataset that\n * already holds rows would silently reinterpret live data. Re-installing makes\n * another dataset rather than replacing one, for the same reason.\n *\n * Accepts either `orgId` or a `hostId` to derive it, so the shared install\n * hook (which is host-oriented) works unchanged. The site is ONLY that: a way\n * to find the org. It is never the site the dataset is created in — see the\n * scope decision below.\n */\nexport const installDatasetSchemaHandler: 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 const bodyOrgId = String(req.body?.orgId ?? '')\n if (!listingId || (!hostId && !bodyOrgId)) {\n return res.status(400).json({ error: 'Missing listingId or orgId' })\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 orgId =\n bodyOrgId || (hostId ? ((await getOrgForHost(hostId))?.orgId ?? '') : '')\n if (!orgId) {\n return res.status(404).json({ error: 'Site has no owning organization' })\n }\n const membership = await resolveOrgPermissions(decoded.uid, { orgId })\n if (membership.orgId !== orgId || !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 orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown organization' })\n }\n const org = orgSnapshot.data() as any\n if (!checkEntitlement(org, 'dataStore')) {\n return res\n .status(403)\n .json({ error: 'Datasets require a Starter plan or higher' })\n }\n\n const listingRef = firestore.collection('marketplaceListings').doc(listingId)\n const listingSnapshot = await listingRef.get()\n const listing = listingSnapshot.data() as any\n if (\n !listing ||\n listing.deletedAt ||\n // Staff takedown blocks new installs on EVERY artifact type\n // (AGL-2290). AGL-948 extended takedown past plugins in the browse\n // predicate and in `resolveMarketplacePluginVersion`, but the gate that\n // decides whether content is HANDED OVER was only ever added to\n // `install-plugin.ts`. So a component, theme, template, layout, email\n // template or dataset schema that staff had taken down stayed\n // installable by anyone holding its listing id — which makes takedown a\n // suggestion for six of the seven artifact types.\n //\n // No owner exemption, matching `install-plugin.ts`: a takedown is a\n // moderation decision about the artifact, not about who is asking.\n listing.hiddenAt ||\n // A publisher under a SECURITY lock hands nothing over (AGL-3365).\n (await isPublisherSecurityLocked(firestore, listing.profileId)) ||\n listingArtifactType(listing) !== 'datasetSchema'\n ) {\n return res.status(404).json({ error: 'Unknown dataset schema' })\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 published = versionSnapshot.get('datasetSchema') as any\n if (!published?.order?.length) {\n return res.status(500).json({ error: 'Dataset schema version missing' })\n }\n\n // Creating a dataset consumes org quota exactly like the console's own\n // create path (AGL-473) — installing must not be a way around it.\n // Deliberately counts EVERY dataset the org owns, not the visible ones\n // (AGL-1046). Scoping decides who can see a dataset, never who pays for\n // it — the org owns all of them. Counting per scope would also make a\n // collaborator's remaining quota disagree with the admin's.\n const datasets = await orgRef.collection('datasets').get()\n const quota = checkDatasetQuota(org, datasets.size)\n if (!quota.allowed) {\n return res.status(403).json({\n error: `Dataset limit reached (${quota.limit}) — see Billing to upgrade.`,\n })\n }\n\n /**\n * Who the new dataset is shared with. Decided before relinking, because\n * it also decides what a reference field may point at.\n *\n * A dataset schema installs at ORGANIZATION scope and nowhere else\n * (`INSTALL_TARGETS.datasetSchema`), from the organization Marketplace —\n * the one surface that installs anything. The `hostId` that surface sends\n * is the site it acts THROUGH so the org can be resolved: the org's first\n * site, not a site anyone is working in. So no site is in context, and the\n * dataset starts where a create on an organization page starts: All\n * sites, whatever the org's Default sharing says. Honoring the acting site\n * instead would hide the dataset from every other site, under an install\n * dialog that says it lands on \"the whole organization — every site\".\n */\n const visibleTo = defaultScopeForNewResource({\n defaultResourceScope: org?.defaultResourceScope,\n hostId: null,\n })\n\n // Relink reference fields onto this org's datasets by display name; what\n // can't be relinked degrades to text and is reported to the installer.\n // A candidate has to be visible everywhere the new dataset is, which is\n // the rule every reference answers to (`scopeCovers`, AGL-1044): one\n // pointing at a dataset some of those sites cannot see resolves to nothing\n // there, and nothing says why. That is also what keeps an install from\n // binding its reference fields to an agency's internal dataset of the same\n // name (AGL-1046) — the display-name collision AGL-1039 fixed on the\n // render path, arriving here instead.\n const byLabel: Record<string, string> = {}\n for (const entry of datasets.docs) {\n if (!scopeCovers(entry.get('visibleTo'), visibleTo)) continue\n const label = String(entry.get('displayName') ?? '').toLowerCase()\n if (label && !byLabel[label]) byLabel[label] = entry.id\n }\n const { schema, degradedFieldIds } = resolveInstalledDatasetSchema(\n published,\n byLabel,\n )\n\n const datasetId = createResourceUid()\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n // Provenance + base snapshot (AGL-1015). The RELINKED schema, not the\n // published one: relinking rewrites reference fields onto this org's\n // datasets, so a base holding the publisher's ids would report every\n // relinked field as a user edit the moment anything is diffed.\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version: listing.latestVersion,\n artifactType: 'datasetSchema',\n content: schema,\n })\n await orgRef\n .collection('datasets')\n .doc(datasetId)\n .create({\n displayName: String(listing.displayName ?? 'Dataset').slice(0, 120),\n ...(listing.description && { description: listing.description }),\n // `fields` is the v1 flat list the older editor still reads; keep it in\n // step with the model so both readers agree (AGL-102).\n fields: schema.order,\n model: schema,\n source: {\n type: 'marketplace' as const,\n listingId,\n version: listing.latestVersion ?? null,\n },\n installedFrom: provenance.installedFrom,\n // The scope decided above. Stamping it is not optional — a dataset\n // with no `visibleTo` matches no scoped read and would render on no\n // site at all.\n //\n // Through the AGL-1478 gate since AGL-1484, so \"every other dataset\n // creator\" is a fact about the type rather than about four object\n // literals that happen to agree today.\n ...newResourceScopeFields(visibleTo),\n createdAt: now,\n })\n\n // Per-version tally (AGL-1036). Installing a schema always CREATES a new\n // dataset rather than replacing one, so there is never a version to leave.\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'datasetSchema',\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 datasetId,\n fields: schema.order.length,\n degradedFieldIds,\n version: listing.latestVersion ?? null,\n baseStored: provenance.baseStored,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Dataset schema install failed' })\n }\n}\n\nexport default installDatasetSchemaHandler\n"],"names":["checkDatasetQuota","checkEntitlement","createResourceUid","defaultScopeForNewResource","newResourceScopeFields","scopeCovers","firebaseAdmin","getOrgForHost","resolveOrgPermissions","isPrivateListing","resolveInstalledDatasetSchema","listingArtifactType","canActAsPublisher","requirePurchase","recordInstallProvenance","recordVersionMove","isPublisherSecurityLocked","installDatasetSchemaHandler","req","res","method","status","json","error","listingId","String","body","hostId","bodyOrgId","orgId","authorization","headers","idToken","startsWith","slice","length","undefined","listing","membership","published","decoded","app","auth","verifyIdToken","uid","permissions","installPlugins","firestore","orgRef","collection","doc","orgSnapshot","get","exists","org","data","listingRef","listingSnapshot","deletedAt","hiddenAt","profileId","priceUsd","Number","ownsListing","unpaid","buyerUid","buyerOrgId","versionSnapshot","latestVersion","order","datasets","quota","size","allowed","limit","visibleTo","defaultResourceScope","byLabel","entry","docs","label","toLowerCase","id","schema","degradedFieldIds","datasetId","now","FieldValue","serverTimestamp","provenance","version","artifactType","content","create","displayName","description","fields","model","source","type","installedFrom","createdAt","to","update","installCount","increment","catch","installed","baseStored","console"],"mappings":"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"}
|
|
@@ -26,6 +26,7 @@ import { canActAsPublisher } from "./publisher-profile.js";
|
|
|
26
26
|
import { requirePurchase } from "./purchase-entitlement.js";
|
|
27
27
|
import { recordInstallProvenance } from "./provenance.js";
|
|
28
28
|
import { recordVersionMove } from "./version-stats.js";
|
|
29
|
+
import { isPublisherSecurityLocked } from "./sale-risk.js";
|
|
29
30
|
/**
|
|
30
31
|
* Installs a marketplace email starter as a new email on a site.
|
|
31
32
|
*
|
|
@@ -119,7 +120,8 @@ import { recordVersionMove } from "./version-stats.js";
|
|
|
119
120
|
const listingRef = firestore.collection('marketplaceListings').doc(listingId);
|
|
120
121
|
const listingSnapshot = await listingRef.get();
|
|
121
122
|
const listing = listingSnapshot.data();
|
|
122
|
-
if (!listing || listing.deletedAt || listing.hiddenAt ||
|
|
123
|
+
if (!listing || listing.deletedAt || listing.hiddenAt || // A publisher under a SECURITY lock hands nothing over (AGL-3365).
|
|
124
|
+
await isPublisherSecurityLocked(firestore, listing.profileId) || listingArtifactType(listing) !== 'emailStarter') {
|
|
123
125
|
return res.status(404).json({
|
|
124
126
|
error: 'Unknown email starter'
|
|
125
127
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install-email-starter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The leaf module, not the server barrel: the list keys every artifact\n// create stamps (AGL-3321), which the install specs' closed-world barrel\n// mocks have no reason to stage.\nimport { artifactCreateListKeys } from '@aglyn/aglyn/app-utils/artifact-list-keys'\nimport { createResourceUid, isPluginRevoked } from '@aglyn/aglyn/server'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginRevocation } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n emailStarterRefusal,\n inspectEmailStarter,\n isPrivateListing,\n listingArtifactType,\n resolveEmailStarterAssurance,\n} from '../model'\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 starter as a new email on a site.\n *\n * ## The install is a COPY, and that is the versioning answer\n *\n * The published tree is written into a screen document of the tenant's own —\n * `hosts/{h}/screens/{newId}` with `kind: 'email'`, plus its first version —\n * in exactly the shape `createEmailScreen` writes, so it appears in the site's\n * Email templates list beside every email they designed themselves and opens\n * in the same besigner. From that write on, nothing reads the listing again.\n *\n * That is deliberate, and it is the property a live reference could not have:\n * a publisher pushing version 4 must not rewrite the email a tenant is\n * mailing from this afternoon. A newer version surfaces as an offer — the\n * shared `resolveUpdateState` comparison every other artifact type uses — and\n * is taken through `update-artifact`, which three-way-merges against the base\n * snapshot recorded here so the tenant's own edits survive it.\n *\n * The one thing a copy does NOT sever is a `media:` reference, which still\n * resolves against the publisher's media. Reported by the inspection rather\n * than refused: our own CDN serves it, so no third party learns who opened the\n * mail, but the bytes behind it can still change.\n *\n * ## Three refusals that are not in the other install routes\n *\n * **The kill switch, at install.** Every other copied artifact treats\n * `revocations` as a plugin concern. An installed email is mailed from the\n * shared sending domain, so a stopped version must not be handed out — and,\n * separately, must stop being sent by tenants who already have it, which is\n * `emailStarterSendBlock` on the send path.\n *\n * **A rejected version, for everybody including its publisher.** Review is not\n * a precondition for installing an email starter — they are auto-listed like\n * every other copied artifact — so most versions are honestly `unreviewed` and\n * install fine. A verdict of `rejected` is different: somebody looked and said\n * no, and the publisher's own site is on the same sending domain as everyone\n * else's.\n *\n * **The policy, re-run over the listing's own bytes.** The publish route\n * already refused a violating design. Re-running it here is not belt and\n * braces about that route — it is the gate for content that never went through\n * it: a version published before this policy existed, a document written by a\n * staff tool, an import. A tracking pixel is only refused if the refusal sits\n * where the content is HANDED OVER, which is the lesson `install-email-template`\n * records about takedown.\n */\nexport const installEmailStarterHandler: 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 listing.hiddenAt ||\n listingArtifactType(listing) !== 'emailStarter'\n ) {\n return res.status(404).json({ error: 'Unknown email starter' })\n }\n\n const ownsListing = await canActAsPublisher(\n firestore,\n decoded.uid,\n listing.profileId,\n )\n if (isPrivateListing(listing) && !ownsListing) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n const priceUsd = Number(listing.priceUsd ?? 0)\n const unpaid = await requirePurchase({\n firestore,\n buyerUid: decoded.uid,\n buyerOrgId: membership.orgId ?? '',\n listingId,\n priceUsd,\n ownsListing,\n })\n if (unpaid) return res.status(402).json(unpaid)\n\n const version = listing.latestVersion\n const revocation = (\n await firestore.collection('revocations').doc(listingId).get()\n ).data() as PluginRevocation | undefined\n if (isPluginRevoked(revocation, String(version ?? ''))) {\n return res\n .status(409)\n .json({ error: 'This email design has been stopped by Aglyn' })\n }\n\n const versionSnapshot = await listingRef\n .collection('versions')\n .doc(String(version))\n .get()\n if (!versionSnapshot.exists) {\n return res.status(500).json({ error: 'Email starter version missing' })\n }\n /**\n * The assurance of THIS version, from THIS version's document.\n *\n * Read here rather than off the listing, and stamped rather than\n * recomputed later, because the listing's own review fields answer a\n * different question. `reviewStatus: 'verified'` is a claim about the\n * publisher; `latestVersionReviewState` describes whatever is newest. A\n * reader that consulted either would report version 7 as reviewed on the\n * strength of version 3, which is how \"reviewed\" stops meaning anything.\n */\n const assurance = resolveEmailStarterAssurance({\n reviewState: versionSnapshot.get('reviewState'),\n })\n if (assurance === 'rejected') {\n return res.status(409).json({\n error: 'This email design was turned down in review and cannot be installed',\n })\n }\n\n const inspection = inspectEmailStarter(versionSnapshot.get('nodes'), {\n hostId,\n })\n const violation = emailStarterRefusal(inspection)\n if (violation) {\n return res\n .status(422)\n .json({ error: violation, violations: inspection.violations })\n }\n const nodes = inspection.nodes\n if (!nodes || !Object.keys(nodes).length) {\n return res.status(500).json({ error: 'Email starter version missing' })\n }\n\n const subject = String(versionSnapshot.get('subject') ?? '')\n const preheader = String(versionSnapshot.get('preheader') ?? '')\n const rootId = versionSnapshot.get('rootId') ?? null\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version,\n artifactType: 'emailStarter',\n content: { rootId, nodes, subject, preheader },\n })\n const installedFrom = { ...provenance.installedFrom, assurance }\n\n const screenId = createResourceUid()\n const versionId = createResourceUid()\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n const displayName = String(listing.displayName ?? 'Installed email').slice(\n 0,\n 120,\n )\n const batch = firestore.batch()\n const screenRef = hostRef.collection('screens').doc(screenId)\n /**\n * The same document shape `createEmailScreen` writes, so the Email\n * templates list, the screen besigner and the campaign composer all find\n * it without knowing it came from anywhere.\n *\n * NOT routed, and `kind: 'email'`, which is what keeps it out of\n * `countBillableScreens` — an installed email spends the same allowance a\n * hand-made one does, which is none.\n *\n * `nodes` is written as a plain map here, exactly as the first version\n * from `/api/hosts/versions` is. The besigner's converter compresses on\n * the first save, and `decodeStoredNodes` is what every reader on this\n * path already goes through, so both forms are handled from the start.\n */\n batch.set(screenRef, {\n displayName,\n // The keys the email templates list finds it by (AGL-3321).\n ...artifactCreateListKeys('screens', { displayName }),\n kind: 'email',\n versionId,\n ...(subject && { emailSubject: subject }),\n ...(preheader && { emailPreheader: preheader }),\n installedFrom,\n createdAt: now,\n updatedAt: now,\n })\n batch.set(screenRef.collection('versions').doc(versionId), {\n screenId,\n ...(rootId ? { rootId } : {}),\n nodes,\n // Stamped on the VERSION as well as the screen, because the version is\n // what the send path renders and the send path is where the kill switch\n // has to bite. A stamp only on the parent would be one document away\n // from the bytes it describes.\n installedFrom,\n createdAt: now,\n updatedAt: now,\n })\n await batch.commit()\n\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'emailStarter',\n to: version,\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 screenId,\n versionId,\n version: version ?? null,\n assurance,\n linkHosts: inspection.linkHosts,\n foreignMediaScopes: inspection.foreignMediaScopes,\n baseStored: provenance.baseStored,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Email starter install failed' })\n }\n}\n\nexport default installEmailStarterHandler\n"],"names":["artifactCreateListKeys","createResourceUid","isPluginRevoked","firebaseAdmin","resolveOrgPermissions","emailStarterRefusal","inspectEmailStarter","isPrivateListing","listingArtifactType","resolveEmailStarterAssurance","canActAsPublisher","requirePurchase","recordInstallProvenance","recordVersionMove","installEmailStarterHandler","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","ownsListing","profileId","priceUsd","Number","unpaid","buyerUid","buyerOrgId","orgId","version","latestVersion","revocation","assurance","reviewState","inspection","violation","violations","nodes","Object","keys","subject","preheader","rootId","provenance","artifactType","content","installedFrom","screenId","versionId","now","FieldValue","serverTimestamp","displayName","batch","screenRef","set","kind","emailSubject","emailPreheader","createdAt","updatedAt","commit","to","update","installCount","increment","catch","installed","linkHosts","foreignMediaScopes","baseStored","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,uEAAuE;AACvE,yEAAyE;AACzE,iCAAiC;AACjC,SAASA,sBAAsB,QAAQ,4CAA2C;AAClF,SAASC,iBAAiB,EAAEC,eAAe,QAAQ,sBAAqB;AAGxE,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,qBAAqB,QAAQ,wCAAuC;AAC7E,SACEC,mBAAmB,EACnBC,mBAAmB,EACnBC,gBAAgB,EAChBC,mBAAmB,EACnBC,4BAA4B,QACvB,oBAAU;AACjB,SAASC,iBAAiB,QAAQ,yBAAqB;AACvD,SAASC,eAAe,QAAQ,4BAAwB;AACxD,SAASC,uBAAuB,QAAQ,kBAAc;AACtD,SAASC,iBAAiB,QAAQ,qBAAiB;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6CC,GACD,OAAO,MAAMC,6BAA+C,OAAOC,KAAKC;qBASzCD;QALJA,WACHA;IAJtB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,YAAYC,gBAAOP,YAAAA,IAAIQ,IAAI,qBAARR,UAAUM,SAAS,mBAAI;IAChD,MAAMG,SAASF,iBAAOP,aAAAA,IAAIQ,IAAI,qBAARR,WAAUS,MAAM,oBAAI;IAC1C,IAAI,CAACH,aAAa,CAACG,QAAQ;QACzB,OAAOR,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA8B;IACrE;IACA,MAAMK,gBAAgBH,QAAOP,6BAAAA,IAAIW,OAAO,CAACD,aAAa,YAAzBV,6BAA6B;IAC1D,MAAMY,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS,OAAOX,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IAErE,IAAI;YAekBY,mBAyBIC,mBAIVC,mBAyDSC,sBACEA,uBACVA,uBAcYF;QApH3B,MAAMG,UAAU,MAAMjC,cAAckC,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACZ;QAC/D,MAAMO,aAAa,MAAM9B,sBAAsBgC,QAAQI,GAAG,EAAE;YAAEhB;QAAO;QACrE,IAAI,CAACU,WAAWO,WAAW,CAACC,cAAc,EAAE;YAC1C,OAAO1B,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE;YACJ;QACF;QACA,MAAMuB,YAAYxC,cAAckC,GAAG,GAAGM,SAAS;QAC/C,MAAMC,UAAUD,UAAUE,UAAU,CAAC,SAASC,GAAG,CAACtB;QAClD,MAAMQ,eAAe,MAAMY,QAAQG,GAAG;QACtC,IAAI,CAACf,aAAagB,MAAM,EAAE;YACxB,OAAOhC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;QACtD;QACA,MAAM6B,aAAa,EAACjB,oBAAAA,aAAae,GAAG,CAAC,0BAAjBf,oBAAmC,CAAC,EAAE,CAACI,QAAQI,GAAG,CAAC;QACvE,IAAIS,eAAe,WAAWA,eAAe,UAAU;YACrD,OAAOjC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA6B;QACpE;QAEA,MAAM8B,aAAaP,UAAUE,UAAU,CAAC,uBAAuBC,GAAG,CAACzB;QACnE,MAAM8B,kBAAkB,MAAMD,WAAWH,GAAG;QAC5C,MAAMd,UAAUkB,gBAAgBC,IAAI;QACpC,IACE,CAACnB,WACDA,QAAQoB,SAAS,IACjBpB,QAAQqB,QAAQ,IAChB9C,oBAAoByB,aAAa,gBACjC;YACA,OAAOjB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAwB;QAC/D;QAEA,MAAMmC,cAAc,MAAM7C,kBACxBiC,WACAP,QAAQI,GAAG,EACXP,QAAQuB,SAAS;QAEnB,IAAIjD,iBAAiB0B,YAAY,CAACsB,aAAa;YAC7C,OAAOvC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;QACzD;QACA,MAAMqC,WAAWC,QAAOzB,oBAAAA,QAAQwB,QAAQ,YAAhBxB,oBAAoB;QAC5C,MAAM0B,SAAS,MAAMhD,gBAAgB;YACnCgC;YACAiB,UAAUxB,QAAQI,GAAG;YACrBqB,UAAU,GAAE3B,oBAAAA,WAAW4B,KAAK,YAAhB5B,oBAAoB;YAChCb;YACAoC;YACAF;QACF;QACA,IAAII,QAAQ,OAAO3C,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAACwC;QAExC,MAAMI,UAAU9B,QAAQ+B,aAAa;QACrC,MAAMC,aAAa,AACjB,CAAA,MAAMtB,UAAUE,UAAU,CAAC,eAAeC,GAAG,CAACzB,WAAW0B,GAAG,EAAC,EAC7DK,IAAI;QACN,IAAIlD,gBAAgB+D,YAAY3C,OAAOyC,kBAAAA,UAAW,MAAM;YACtD,OAAO/C,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAO;YAA8C;QACjE;QAEA,MAAMe,kBAAkB,MAAMe,WAC3BL,UAAU,CAAC,YACXC,GAAG,CAACxB,OAAOyC,UACXhB,GAAG;QACN,IAAI,CAACZ,gBAAgBa,MAAM,EAAE;YAC3B,OAAOhC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAgC;QACvE;QACA;;;;;;;;;KASC,GACD,MAAM8C,YAAYzD,6BAA6B;YAC7C0D,aAAahC,gBAAgBY,GAAG,CAAC;QACnC;QACA,IAAImB,cAAc,YAAY;YAC5B,OAAOlD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OAAO;YACT;QACF;QAEA,MAAMgD,aAAa9D,oBAAoB6B,gBAAgBY,GAAG,CAAC,UAAU;YACnEvB;QACF;QACA,MAAM6C,YAAYhE,oBAAoB+D;QACtC,IAAIC,WAAW;YACb,OAAOrD,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAOiD;gBAAWC,YAAYF,WAAWE,UAAU;YAAC;QAChE;QACA,MAAMC,QAAQH,WAAWG,KAAK;QAC9B,IAAI,CAACA,SAAS,CAACC,OAAOC,IAAI,CAACF,OAAOzC,MAAM,EAAE;YACxC,OAAOd,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAgC;QACvE;QAEA,MAAMsD,UAAUpD,QAAOa,uBAAAA,gBAAgBY,GAAG,CAAC,sBAApBZ,uBAAkC;QACzD,MAAMwC,YAAYrD,QAAOa,wBAAAA,gBAAgBY,GAAG,CAAC,wBAApBZ,wBAAoC;QAC7D,MAAMyC,UAASzC,wBAAAA,gBAAgBY,GAAG,CAAC,qBAApBZ,wBAAiC;QAChD,MAAM0C,aAAa,MAAMjE,wBAAwB;YAC/C+B;YACAtB;YACAY;YACA8B;YACAe,cAAc;YACdC,SAAS;gBAAEH;gBAAQL;gBAAOG;gBAASC;YAAU;QAC/C;QACA,MAAMK,gBAAgB,aAAKH,WAAWG,aAAa;YAAEd;;QAErD,MAAMe,WAAWhF;QACjB,MAAMiF,YAAYjF;QAClB,MAAMkF,MAAMhF,cAAcwC,SAAS,CAACyC,UAAU,CAACC,eAAe;QAC9D,MAAMC,cAAchE,QAAOW,uBAAAA,QAAQqD,WAAW,YAAnBrD,uBAAuB,mBAAmBJ,KAAK,CACxE,GACA;QAEF,MAAM0D,QAAQ5C,UAAU4C,KAAK;QAC7B,MAAMC,YAAY5C,QAAQC,UAAU,CAAC,WAAWC,GAAG,CAACmC;QACpD;;;;;;;;;;;;;KAaC,GACDM,MAAME,GAAG,CAACD,WAAW;YACnBF;WAEGtF,uBAAuB,WAAW;YAAEsF;QAAY;YACnDI,MAAM;YACNR;WACIR,WAAW;YAAEiB,cAAcjB;QAAQ,GACnCC,aAAa;YAAEiB,gBAAgBjB;QAAU;YAC7CK;YACAa,WAAWV;YACXW,WAAWX;;QAEbI,MAAME,GAAG,CAACD,UAAU3C,UAAU,CAAC,YAAYC,GAAG,CAACoC,YAAY;YACzDD;WACIL,SAAS;YAAEA;QAAO,IAAI,CAAC;YAC3BL;YACA,uEAAuE;YACvE,wEAAwE;YACxE,qEAAqE;YACrE,+BAA+B;YAC/BS;YACAa,WAAWV;YACXW,WAAWX;;QAEb,MAAMI,MAAMQ,MAAM;QAElB,MAAMlF,kBAAkB;YACtB8B;YACAO;YACA4B,cAAc;YACdkB,IAAIjC;QACN;QACA,MAAMb,WACH+C,MAAM,CAAC;YACNC,cAAc/F,cAAcwC,SAAS,CAACyC,UAAU,CAACe,SAAS,CAAC;QAC7D,GACCC,KAAK,CAAC,IAAMrE;QAEf,OAAOf,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1BkF,WAAW;YACXpB;YACAC;YACAnB,OAAO,EAAEA,kBAAAA,UAAW;YACpBG;YACAoC,WAAWlC,WAAWkC,SAAS;YAC/BC,oBAAoBnC,WAAWmC,kBAAkB;YACjDC,YAAY3B,WAAW2B,UAAU;QACnC;IACF,EAAE,OAAOpF,OAAO;QACdqF,QAAQrF,KAAK,CAACA;QACd,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;IACtE;AACF,EAAC;AAED,eAAeN,2BAA0B"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/install-email-starter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The leaf module, not the server barrel: the list keys every artifact\n// create stamps (AGL-3321), which the install specs' closed-world barrel\n// mocks have no reason to stage.\nimport { artifactCreateListKeys } from '@aglyn/aglyn/app-utils/artifact-list-keys'\nimport { createResourceUid, isPluginRevoked } from '@aglyn/aglyn/server'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginRevocation } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport {\n emailStarterRefusal,\n inspectEmailStarter,\n isPrivateListing,\n listingArtifactType,\n resolveEmailStarterAssurance,\n} from '../model'\nimport { canActAsPublisher } from './publisher-profile'\nimport { requirePurchase } from './purchase-entitlement'\nimport { recordInstallProvenance } from './provenance'\nimport { recordVersionMove } from './version-stats'\nimport { isPublisherSecurityLocked } from './sale-risk'\n\n/**\n * Installs a marketplace email starter as a new email on a site.\n *\n * ## The install is a COPY, and that is the versioning answer\n *\n * The published tree is written into a screen document of the tenant's own —\n * `hosts/{h}/screens/{newId}` with `kind: 'email'`, plus its first version —\n * in exactly the shape `createEmailScreen` writes, so it appears in the site's\n * Email templates list beside every email they designed themselves and opens\n * in the same besigner. From that write on, nothing reads the listing again.\n *\n * That is deliberate, and it is the property a live reference could not have:\n * a publisher pushing version 4 must not rewrite the email a tenant is\n * mailing from this afternoon. A newer version surfaces as an offer — the\n * shared `resolveUpdateState` comparison every other artifact type uses — and\n * is taken through `update-artifact`, which three-way-merges against the base\n * snapshot recorded here so the tenant's own edits survive it.\n *\n * The one thing a copy does NOT sever is a `media:` reference, which still\n * resolves against the publisher's media. Reported by the inspection rather\n * than refused: our own CDN serves it, so no third party learns who opened the\n * mail, but the bytes behind it can still change.\n *\n * ## Three refusals that are not in the other install routes\n *\n * **The kill switch, at install.** Every other copied artifact treats\n * `revocations` as a plugin concern. An installed email is mailed from the\n * shared sending domain, so a stopped version must not be handed out — and,\n * separately, must stop being sent by tenants who already have it, which is\n * `emailStarterSendBlock` on the send path.\n *\n * **A rejected version, for everybody including its publisher.** Review is not\n * a precondition for installing an email starter — they are auto-listed like\n * every other copied artifact — so most versions are honestly `unreviewed` and\n * install fine. A verdict of `rejected` is different: somebody looked and said\n * no, and the publisher's own site is on the same sending domain as everyone\n * else's.\n *\n * **The policy, re-run over the listing's own bytes.** The publish route\n * already refused a violating design. Re-running it here is not belt and\n * braces about that route — it is the gate for content that never went through\n * it: a version published before this policy existed, a document written by a\n * staff tool, an import. A tracking pixel is only refused if the refusal sits\n * where the content is HANDED OVER, which is the lesson `install-email-template`\n * records about takedown.\n */\nexport const installEmailStarterHandler: 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 listing.hiddenAt ||\n // A publisher under a SECURITY lock hands nothing over (AGL-3365).\n (await isPublisherSecurityLocked(firestore, listing.profileId)) ||\n listingArtifactType(listing) !== 'emailStarter'\n ) {\n return res.status(404).json({ error: 'Unknown email starter' })\n }\n\n const ownsListing = await canActAsPublisher(\n firestore,\n decoded.uid,\n listing.profileId,\n )\n if (isPrivateListing(listing) && !ownsListing) {\n return res.status(404).json({ error: 'Unknown listing' })\n }\n const priceUsd = Number(listing.priceUsd ?? 0)\n const unpaid = await requirePurchase({\n firestore,\n buyerUid: decoded.uid,\n buyerOrgId: membership.orgId ?? '',\n listingId,\n priceUsd,\n ownsListing,\n })\n if (unpaid) return res.status(402).json(unpaid)\n\n const version = listing.latestVersion\n const revocation = (\n await firestore.collection('revocations').doc(listingId).get()\n ).data() as PluginRevocation | undefined\n if (isPluginRevoked(revocation, String(version ?? ''))) {\n return res\n .status(409)\n .json({ error: 'This email design has been stopped by Aglyn' })\n }\n\n const versionSnapshot = await listingRef\n .collection('versions')\n .doc(String(version))\n .get()\n if (!versionSnapshot.exists) {\n return res.status(500).json({ error: 'Email starter version missing' })\n }\n /**\n * The assurance of THIS version, from THIS version's document.\n *\n * Read here rather than off the listing, and stamped rather than\n * recomputed later, because the listing's own review fields answer a\n * different question. `reviewStatus: 'verified'` is a claim about the\n * publisher; `latestVersionReviewState` describes whatever is newest. A\n * reader that consulted either would report version 7 as reviewed on the\n * strength of version 3, which is how \"reviewed\" stops meaning anything.\n */\n const assurance = resolveEmailStarterAssurance({\n reviewState: versionSnapshot.get('reviewState'),\n })\n if (assurance === 'rejected') {\n return res.status(409).json({\n error: 'This email design was turned down in review and cannot be installed',\n })\n }\n\n const inspection = inspectEmailStarter(versionSnapshot.get('nodes'), {\n hostId,\n })\n const violation = emailStarterRefusal(inspection)\n if (violation) {\n return res\n .status(422)\n .json({ error: violation, violations: inspection.violations })\n }\n const nodes = inspection.nodes\n if (!nodes || !Object.keys(nodes).length) {\n return res.status(500).json({ error: 'Email starter version missing' })\n }\n\n const subject = String(versionSnapshot.get('subject') ?? '')\n const preheader = String(versionSnapshot.get('preheader') ?? '')\n const rootId = versionSnapshot.get('rootId') ?? null\n const provenance = await recordInstallProvenance({\n firestore,\n listingId,\n listing,\n version,\n artifactType: 'emailStarter',\n content: { rootId, nodes, subject, preheader },\n })\n const installedFrom = { ...provenance.installedFrom, assurance }\n\n const screenId = createResourceUid()\n const versionId = createResourceUid()\n const now = firebaseAdmin.firestore.FieldValue.serverTimestamp()\n const displayName = String(listing.displayName ?? 'Installed email').slice(\n 0,\n 120,\n )\n const batch = firestore.batch()\n const screenRef = hostRef.collection('screens').doc(screenId)\n /**\n * The same document shape `createEmailScreen` writes, so the Email\n * templates list, the screen besigner and the campaign composer all find\n * it without knowing it came from anywhere.\n *\n * NOT routed, and `kind: 'email'`, which is what keeps it out of\n * `countBillableScreens` — an installed email spends the same allowance a\n * hand-made one does, which is none.\n *\n * `nodes` is written as a plain map here, exactly as the first version\n * from `/api/hosts/versions` is. The besigner's converter compresses on\n * the first save, and `decodeStoredNodes` is what every reader on this\n * path already goes through, so both forms are handled from the start.\n */\n batch.set(screenRef, {\n displayName,\n // The keys the email templates list finds it by (AGL-3321).\n ...artifactCreateListKeys('screens', { displayName }),\n kind: 'email',\n versionId,\n ...(subject && { emailSubject: subject }),\n ...(preheader && { emailPreheader: preheader }),\n installedFrom,\n createdAt: now,\n updatedAt: now,\n })\n batch.set(screenRef.collection('versions').doc(versionId), {\n screenId,\n ...(rootId ? { rootId } : {}),\n nodes,\n // Stamped on the VERSION as well as the screen, because the version is\n // what the send path renders and the send path is where the kill switch\n // has to bite. A stamp only on the parent would be one document away\n // from the bytes it describes.\n installedFrom,\n createdAt: now,\n updatedAt: now,\n })\n await batch.commit()\n\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: 'emailStarter',\n to: version,\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 screenId,\n versionId,\n version: version ?? null,\n assurance,\n linkHosts: inspection.linkHosts,\n foreignMediaScopes: inspection.foreignMediaScopes,\n baseStored: provenance.baseStored,\n })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Email starter install failed' })\n }\n}\n\nexport default installEmailStarterHandler\n"],"names":["artifactCreateListKeys","createResourceUid","isPluginRevoked","firebaseAdmin","resolveOrgPermissions","emailStarterRefusal","inspectEmailStarter","isPrivateListing","listingArtifactType","resolveEmailStarterAssurance","canActAsPublisher","requirePurchase","recordInstallProvenance","recordVersionMove","isPublisherSecurityLocked","installEmailStarterHandler","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","profileId","ownsListing","priceUsd","Number","unpaid","buyerUid","buyerOrgId","orgId","version","latestVersion","revocation","assurance","reviewState","inspection","violation","violations","nodes","Object","keys","subject","preheader","rootId","provenance","artifactType","content","installedFrom","screenId","versionId","now","FieldValue","serverTimestamp","displayName","batch","screenRef","set","kind","emailSubject","emailPreheader","createdAt","updatedAt","commit","to","update","installCount","increment","catch","installed","linkHosts","foreignMediaScopes","baseStored","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,uEAAuE;AACvE,yEAAyE;AACzE,iCAAiC;AACjC,SAASA,sBAAsB,QAAQ,4CAA2C;AAClF,SAASC,iBAAiB,EAAEC,eAAe,QAAQ,sBAAqB;AAGxE,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,qBAAqB,QAAQ,wCAAuC;AAC7E,SACEC,mBAAmB,EACnBC,mBAAmB,EACnBC,gBAAgB,EAChBC,mBAAmB,EACnBC,4BAA4B,QACvB,oBAAU;AACjB,SAASC,iBAAiB,QAAQ,yBAAqB;AACvD,SAASC,eAAe,QAAQ,4BAAwB;AACxD,SAASC,uBAAuB,QAAQ,kBAAc;AACtD,SAASC,iBAAiB,QAAQ,qBAAiB;AACnD,SAASC,yBAAyB,QAAQ,iBAAa;AAEvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6CC,GACD,OAAO,MAAMC,6BAA+C,OAAOC,KAAKC;qBASzCD;QALJA,WACHA;IAJtB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,YAAYC,gBAAOP,YAAAA,IAAIQ,IAAI,qBAARR,UAAUM,SAAS,mBAAI;IAChD,MAAMG,SAASF,iBAAOP,aAAAA,IAAIQ,IAAI,qBAARR,WAAUS,MAAM,oBAAI;IAC1C,IAAI,CAACH,aAAa,CAACG,QAAQ;QACzB,OAAOR,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA8B;IACrE;IACA,MAAMK,gBAAgBH,QAAOP,6BAAAA,IAAIW,OAAO,CAACD,aAAa,YAAzBV,6BAA6B;IAC1D,MAAMY,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcI,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS,OAAOX,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IAErE,IAAI;YAekBY,mBA2BIC,mBAIVC,mBAyDSC,sBACEA,uBACVA,uBAcYF;QAtH3B,MAAMG,UAAU,MAAMlC,cAAcmC,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACZ;QAC/D,MAAMO,aAAa,MAAM/B,sBAAsBiC,QAAQI,GAAG,EAAE;YAAEhB;QAAO;QACrE,IAAI,CAACU,WAAWO,WAAW,CAACC,cAAc,EAAE;YAC1C,OAAO1B,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OACE;YACJ;QACF;QACA,MAAMuB,YAAYzC,cAAcmC,GAAG,GAAGM,SAAS;QAC/C,MAAMC,UAAUD,UAAUE,UAAU,CAAC,SAASC,GAAG,CAACtB;QAClD,MAAMQ,eAAe,MAAMY,QAAQG,GAAG;QACtC,IAAI,CAACf,aAAagB,MAAM,EAAE;YACxB,OAAOhC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;QACtD;QACA,MAAM6B,aAAa,EAACjB,oBAAAA,aAAae,GAAG,CAAC,0BAAjBf,oBAAmC,CAAC,EAAE,CAACI,QAAQI,GAAG,CAAC;QACvE,IAAIS,eAAe,WAAWA,eAAe,UAAU;YACrD,OAAOjC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA6B;QACpE;QAEA,MAAM8B,aAAaP,UAAUE,UAAU,CAAC,uBAAuBC,GAAG,CAACzB;QACnE,MAAM8B,kBAAkB,MAAMD,WAAWH,GAAG;QAC5C,MAAMd,UAAUkB,gBAAgBC,IAAI;QACpC,IACE,CAACnB,WACDA,QAAQoB,SAAS,IACjBpB,QAAQqB,QAAQ,IAChB,mEAAmE;QAClE,MAAMzC,0BAA0B8B,WAAWV,QAAQsB,SAAS,KAC7DhD,oBAAoB0B,aAAa,gBACjC;YACA,OAAOjB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAwB;QAC/D;QAEA,MAAMoC,cAAc,MAAM/C,kBACxBkC,WACAP,QAAQI,GAAG,EACXP,QAAQsB,SAAS;QAEnB,IAAIjD,iBAAiB2B,YAAY,CAACuB,aAAa;YAC7C,OAAOxC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;QACzD;QACA,MAAMqC,WAAWC,QAAOzB,oBAAAA,QAAQwB,QAAQ,YAAhBxB,oBAAoB;QAC5C,MAAM0B,SAAS,MAAMjD,gBAAgB;YACnCiC;YACAiB,UAAUxB,QAAQI,GAAG;YACrBqB,UAAU,GAAE3B,oBAAAA,WAAW4B,KAAK,YAAhB5B,oBAAoB;YAChCb;YACAoC;YACAD;QACF;QACA,IAAIG,QAAQ,OAAO3C,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAACwC;QAExC,MAAMI,UAAU9B,QAAQ+B,aAAa;QACrC,MAAMC,aAAa,AACjB,CAAA,MAAMtB,UAAUE,UAAU,CAAC,eAAeC,GAAG,CAACzB,WAAW0B,GAAG,EAAC,EAC7DK,IAAI;QACN,IAAInD,gBAAgBgE,YAAY3C,OAAOyC,kBAAAA,UAAW,MAAM;YACtD,OAAO/C,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAO;YAA8C;QACjE;QAEA,MAAMe,kBAAkB,MAAMe,WAC3BL,UAAU,CAAC,YACXC,GAAG,CAACxB,OAAOyC,UACXhB,GAAG;QACN,IAAI,CAACZ,gBAAgBa,MAAM,EAAE;YAC3B,OAAOhC,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAgC;QACvE;QACA;;;;;;;;;KASC,GACD,MAAM8C,YAAY1D,6BAA6B;YAC7C2D,aAAahC,gBAAgBY,GAAG,CAAC;QACnC;QACA,IAAImB,cAAc,YAAY;YAC5B,OAAOlD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAC1BC,OAAO;YACT;QACF;QAEA,MAAMgD,aAAa/D,oBAAoB8B,gBAAgBY,GAAG,CAAC,UAAU;YACnEvB;QACF;QACA,MAAM6C,YAAYjE,oBAAoBgE;QACtC,IAAIC,WAAW;YACb,OAAOrD,IACJE,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAOiD;gBAAWC,YAAYF,WAAWE,UAAU;YAAC;QAChE;QACA,MAAMC,QAAQH,WAAWG,KAAK;QAC9B,IAAI,CAACA,SAAS,CAACC,OAAOC,IAAI,CAACF,OAAOzC,MAAM,EAAE;YACxC,OAAOd,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAgC;QACvE;QAEA,MAAMsD,UAAUpD,QAAOa,uBAAAA,gBAAgBY,GAAG,CAAC,sBAApBZ,uBAAkC;QACzD,MAAMwC,YAAYrD,QAAOa,wBAAAA,gBAAgBY,GAAG,CAAC,wBAApBZ,wBAAoC;QAC7D,MAAMyC,UAASzC,wBAAAA,gBAAgBY,GAAG,CAAC,qBAApBZ,wBAAiC;QAChD,MAAM0C,aAAa,MAAMlE,wBAAwB;YAC/CgC;YACAtB;YACAY;YACA8B;YACAe,cAAc;YACdC,SAAS;gBAAEH;gBAAQL;gBAAOG;gBAASC;YAAU;QAC/C;QACA,MAAMK,gBAAgB,aAAKH,WAAWG,aAAa;YAAEd;;QAErD,MAAMe,WAAWjF;QACjB,MAAMkF,YAAYlF;QAClB,MAAMmF,MAAMjF,cAAcyC,SAAS,CAACyC,UAAU,CAACC,eAAe;QAC9D,MAAMC,cAAchE,QAAOW,uBAAAA,QAAQqD,WAAW,YAAnBrD,uBAAuB,mBAAmBJ,KAAK,CACxE,GACA;QAEF,MAAM0D,QAAQ5C,UAAU4C,KAAK;QAC7B,MAAMC,YAAY5C,QAAQC,UAAU,CAAC,WAAWC,GAAG,CAACmC;QACpD;;;;;;;;;;;;;KAaC,GACDM,MAAME,GAAG,CAACD,WAAW;YACnBF;WAEGvF,uBAAuB,WAAW;YAAEuF;QAAY;YACnDI,MAAM;YACNR;WACIR,WAAW;YAAEiB,cAAcjB;QAAQ,GACnCC,aAAa;YAAEiB,gBAAgBjB;QAAU;YAC7CK;YACAa,WAAWV;YACXW,WAAWX;;QAEbI,MAAME,GAAG,CAACD,UAAU3C,UAAU,CAAC,YAAYC,GAAG,CAACoC,YAAY;YACzDD;WACIL,SAAS;YAAEA;QAAO,IAAI,CAAC;YAC3BL;YACA,uEAAuE;YACvE,wEAAwE;YACxE,qEAAqE;YACrE,+BAA+B;YAC/BS;YACAa,WAAWV;YACXW,WAAWX;;QAEb,MAAMI,MAAMQ,MAAM;QAElB,MAAMnF,kBAAkB;YACtB+B;YACAO;YACA4B,cAAc;YACdkB,IAAIjC;QACN;QACA,MAAMb,WACH+C,MAAM,CAAC;YACNC,cAAchG,cAAcyC,SAAS,CAACyC,UAAU,CAACe,SAAS,CAAC;QAC7D,GACCC,KAAK,CAAC,IAAMrE;QAEf,OAAOf,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAC1BkF,WAAW;YACXpB;YACAC;YACAnB,OAAO,EAAEA,kBAAAA,UAAW;YACpBG;YACAoC,WAAWlC,WAAWkC,SAAS;YAC/BC,oBAAoBnC,WAAWmC,kBAAkB;YACjDC,YAAY3B,WAAW2B,UAAU;QACnC;IACF,EAAE,OAAOpF,OAAO;QACdqF,QAAQrF,KAAK,CAACA;QACd,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;IACtE;AACF,EAAC;AAED,eAAeN,2BAA0B"}
|
|
@@ -23,6 +23,7 @@ import { canActAsPublisher } from "./publisher-profile.js";
|
|
|
23
23
|
import { requirePurchase } from "./purchase-entitlement.js";
|
|
24
24
|
import { recordInstallProvenance } from "./provenance.js";
|
|
25
25
|
import { recordVersionMove } from "./version-stats.js";
|
|
26
|
+
import { isPublisherSecurityLocked } from "./sale-risk.js";
|
|
26
27
|
/**
|
|
27
28
|
* Installs a marketplace email template into a site (AGL-657).
|
|
28
29
|
*
|
|
@@ -96,7 +97,8 @@ import { recordVersionMove } from "./version-stats.js";
|
|
|
96
97
|
//
|
|
97
98
|
// No owner exemption, matching `install-plugin.ts`: a takedown is a
|
|
98
99
|
// moderation decision about the artifact, not about who is asking.
|
|
99
|
-
listing.hiddenAt ||
|
|
100
|
+
listing.hiddenAt || // A publisher under a SECURITY lock hands nothing over (AGL-3365).
|
|
101
|
+
await isPublisherSecurityLocked(firestore, listing.profileId) || listingArtifactType(listing) !== 'emailTemplate') {
|
|
100
102
|
return res.status(404).json({
|
|
101
103
|
error: 'Unknown email template'
|
|
102
104
|
});
|