@aglyn/plugins-marketplace 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/listing-content.component.js +78 -36
  3. package/src/lib/components/listing-content.component.js.map +1 -1
  4. package/src/lib/components/listing-detail-page.component.js +22 -9
  5. package/src/lib/components/listing-detail-page.component.js.map +1 -1
  6. package/src/lib/components/listing-review-status.component.d.ts +8 -0
  7. package/src/lib/components/listing-review-status.component.js +23 -3
  8. package/src/lib/components/listing-review-status.component.js.map +1 -1
  9. package/src/lib/components/listing-verification-request.component.d.ts +8 -0
  10. package/src/lib/components/listing-verification-request.component.js +12 -5
  11. package/src/lib/components/listing-verification-request.component.js.map +1 -1
  12. package/src/lib/components/marketplace-hub.component.js +2 -1
  13. package/src/lib/components/marketplace-hub.component.js.map +1 -1
  14. package/src/lib/components/org-seller-panel.component.d.ts +10 -0
  15. package/src/lib/components/org-seller-panel.component.js +26 -4
  16. package/src/lib/components/org-seller-panel.component.js.map +1 -1
  17. package/src/lib/components/starter-picker-shelf.component.js +13 -8
  18. package/src/lib/components/starter-picker-shelf.component.js.map +1 -1
  19. package/src/lib/declarations.d.ts +2 -1
  20. package/src/lib/declarations.js +5 -1
  21. package/src/lib/declarations.js.map +1 -1
  22. package/src/lib/model/marketplace-publisher-permissions.d.ts +26 -0
  23. package/src/lib/model/marketplace-publisher-permissions.js +64 -0
  24. package/src/lib/model/marketplace-publisher-permissions.js.map +1 -0
  25. package/src/lib/model/marketplace.js +2 -1
  26. package/src/lib/model/marketplace.js.map +1 -1
  27. package/src/lib/model/own-listing.d.ts +52 -0
  28. package/src/lib/model/own-listing.js +72 -0
  29. package/src/lib/model/own-listing.js.map +1 -0
  30. package/src/lib/model/starter-query.js +2 -0
  31. package/src/lib/model/starter-query.js.map +1 -1
  32. package/src/lib/server/connect.js +11 -5
  33. package/src/lib/server/connect.js.map +1 -1
  34. package/src/lib/server/install-dataset-schema.js +10 -2
  35. package/src/lib/server/install-dataset-schema.js.map +1 -1
  36. package/src/lib/server/install-email-starter.js +8 -2
  37. package/src/lib/server/install-email-starter.js.map +1 -1
  38. package/src/lib/server/install-email-template.js +8 -2
  39. package/src/lib/server/install-email-template.js.map +1 -1
  40. package/src/lib/server/install-layout.js +8 -2
  41. package/src/lib/server/install-layout.js.map +1 -1
  42. package/src/lib/server/install-plugin.js +24 -20
  43. package/src/lib/server/install-plugin.js.map +1 -1
  44. package/src/lib/server/install-template.js +8 -5
  45. package/src/lib/server/install-template.js.map +1 -1
  46. package/src/lib/server/install-theme.js +8 -2
  47. package/src/lib/server/install-theme.js.map +1 -1
  48. package/src/lib/server/install.js +10 -5
  49. package/src/lib/server/install.js.map +1 -1
  50. package/src/lib/server/listing-versions.js +24 -9
  51. package/src/lib/server/listing-versions.js.map +1 -1
  52. package/src/lib/server/own-listing.d.ts +46 -0
  53. package/src/lib/server/own-listing.js +52 -0
  54. package/src/lib/server/own-listing.js.map +1 -0
  55. package/src/lib/server/preview-image.d.ts +1 -1
  56. package/src/lib/server/preview-image.js +9 -8
  57. package/src/lib/server/preview-image.js.map +1 -1
  58. package/src/lib/server/publish-plugin.js +17 -19
  59. package/src/lib/server/publish-plugin.js.map +1 -1
  60. package/src/lib/server/publisher-permissions.d.ts +63 -0
  61. package/src/lib/server/publisher-permissions.js +51 -0
  62. package/src/lib/server/publisher-permissions.js.map +1 -0
  63. package/src/lib/server/publisher-profile-save.d.ts +4 -2
  64. package/src/lib/server/publisher-profile-save.js +12 -5
  65. package/src/lib/server/publisher-profile-save.js.map +1 -1
  66. package/src/lib/server/publisher-profile.d.ts +18 -6
  67. package/src/lib/server/publisher-profile.js +19 -8
  68. package/src/lib/server/publisher-profile.js.map +1 -1
  69. package/src/lib/server/reviews.js +6 -5
  70. package/src/lib/server/reviews.js.map +1 -1
  71. package/src/lib/server/sale-risk.d.ts +0 -6
  72. package/src/lib/server/sale-risk.js +0 -9
  73. package/src/lib/server/sale-risk.js.map +1 -1
  74. package/src/lib/server/sale.js +10 -9
  75. package/src/lib/server/sale.js.map +1 -1
  76. package/src/lib/server/site-starter-source.js +43 -17
  77. package/src/lib/server/site-starter-source.js.map +1 -1
  78. package/src/lib/server/update-artifact.js +11 -4
  79. package/src/lib/server/update-artifact.js.map +1 -1
  80. package/src/lib/server/verification-request.js +23 -7
  81. package/src/lib/server/verification-request.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/sale-risk.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/*==========================================\n * A MARKETPLACE SALE'S FRAUD SHAPE (AGL-3365).\n *\n * Two questions the card network cannot answer, because both sides of the\n * sale are ours:\n *\n * 1. **Is the publisher paying itself?** A publisher that buys its own\n * listing with stolen cards turns them into a payout. Checkout refuses a\n * buyer who is a member of the publishing workspace; a second workspace\n * that shares a member with it, or ONE card buying from one publisher for\n * several workspaces, is the same actor one step removed.\n * 2. **Is the publisher brand new?** See `model/publisher-risk.ts`: its\n * payouts are held, and a large sale files a signal while the share can\n * still be reversed.\n *\n * What it does is what the seller-pattern ledger does: an urgent row in the\n * abuse queue, staff told once. NOTHING IS REFUNDED, REVERSED OR PAUSED\n * HERE — staff decide, with the young tier's payout delay holding the money\n * in reach while they do.\n *\n * Never throws: the sale it reads has already been recorded.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { isSecurityClassLockdownReason, normalizeOrgLockdown } from '@aglyn/aglyn/app-utils/lockdown'\nimport {\n LOCKDOWN_BILLING_PAUSES_COLLECTION,\n lockdownPayoutPauseRecordId,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-lockdown'\nimport { firebaseAdmin, notifyRiskEvent, type RiskEventInput } from '@aglyn/tenant-data-admin'\nimport { orgAgeDays } from '@aglyn/tenant-data-admin/server/org-age'\nimport {\n describeSaleRiskSignals,\n isYoungPublisher,\n publisherPayoutDelayDays,\n type SaleRiskSignal,\n YOUNG_PUBLISHER_DAYS,\n YOUNG_PUBLISHER_PAYOUT_DELAY_DAYS,\n YOUNG_PUBLISHER_REVIEW_SALE_CENTS,\n} from '../model/publisher-risk'\n\n/** Members read per workspace when looking for a shared one. */\nconst MAX_MEMBERS_COMPARED = 200\n/** Earlier purchases read when looking for the same card. */\nconst MAX_CARD_MATCHES = 20\n\n/**\n * Whether `uid` is ANY member of `orgId` — not only a manager, which is all\n * `canActAsPublisher` asks. A publishing workspace's editor buying its\n * listing is the publisher buying it.\n */\nexport async function isOrgMember(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): Promise<boolean> {\n if (!orgId || !uid) return false\n const member = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .doc(uid)\n .get()\n return member.exists\n}\n\n/**\n * Whether the publishing workspace is under a SECURITY lock (AGL-3365) — the\n * install and update doors hand nothing from it over. Security only: a\n * workspace locked over its own unpaid bill has not made its listings\n * dangerous, and a buyer who paid for one keeps installing it. (Checkout\n * refuses a sale to a workspace under ANY lock; that is money moving to it.)\n *\n * The shared normalizer reads the reason, so a lock written before reason\n * codes existed reads as `manual`, not security. One read of the org.\n */\nexport async function isPublisherSecurityLocked(\n firestore: FirebaseFirestore.Firestore,\n publisherOrgId: unknown,\n): Promise<boolean> {\n const orgId = typeof publisherOrgId === 'string' ? publisherOrgId : ''\n if (!orgId) return false\n const org = (await firestore.collection('orgs').doc(orgId).get()).data()\n return isSecurityClassLockdownReason(\n normalizeOrgLockdown(org as Parameters<typeof normalizeOrgLockdown>[0])?.reason,\n )\n}\n\nasync function memberUids(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .limit(MAX_MEMBERS_COMPARED)\n .get()\n return snapshot.docs.map((doc) => doc.id)\n}\n\nasync function stripeRequest(\n path: string,\n stripeKey: string,\n params?: URLSearchParams,\n): Promise<{ ok: boolean; body: any }> {\n const response = await fetch(`https://api.stripe.com/v1/${path}`, {\n method: params ? 'POST' : 'GET',\n headers: {\n Authorization: `Bearer ${stripeKey}`,\n ...(params ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),\n },\n ...(params ? { body: params.toString() } : {}),\n })\n const body = await response.json().catch(() => null)\n return { ok: response.ok, body }\n}\n\n/**\n * Put a publisher's connected account on the payout schedule its age calls\n * for (`publisherPayoutDelayDays`), and note it on the profile.\n *\n * Writes to Stripe only when the answer CHANGES what this code last set: a\n * young publisher is moved to the delay once, and moved back to the standard\n * schedule once when it ages out. An account this code never delayed is\n * never written — a schedule staff set by hand in the Dashboard is theirs.\n *\n * Never throws: onboarding and a sale must both go on if Stripe says no.\n */\nexport async function applyPublisherPayoutPolicy(input: {\n firestore: FirebaseFirestore.Firestore\n publisherOrgId: string\n ageDays: number | null\n stripeKey: string | undefined\n nowMs?: number\n}): Promise<'applied' | 'current' | 'held-by-lock' | 'skipped' | 'failed'> {\n try {\n if (!input.stripeKey || !input.publisherOrgId) return 'skipped'\n const profileRef = input.firestore.collection('publisherProfiles').doc(input.publisherOrgId)\n const profile = await profileRef.get()\n const accountId = String(profile.get('stripeAccountId') ?? '')\n if (!accountId) return 'skipped'\n // A workspace lock holds this account's payouts (AGL-3364/3365): its\n // schedule is manual and the lock saved the one to restore. Moving it\n // now would either undo the pause or leave the lift restoring a delay\n // this record no longer describes, so it waits. The next sale after the\n // lift sets it right.\n const lockHold = await input.firestore\n .collection(LOCKDOWN_BILLING_PAUSES_COLLECTION)\n .doc(lockdownPayoutPauseRecordId(accountId))\n .get()\n if (lockHold.exists) return 'held-by-lock'\n const desired = publisherPayoutDelayDays(input.ageDays)\n const stored = profile.get('payoutDelayDays') as number | null | undefined\n if (stored === desired) return 'current'\n // Never delayed by this code and not young: leave the account alone.\n if (stored === undefined && desired === null) return 'current'\n const result = await stripeRequest(\n `accounts/${encodeURIComponent(accountId)}`,\n input.stripeKey,\n new URLSearchParams({\n 'settings[payouts][schedule][delay_days]': desired === null ? 'minimum' : String(desired),\n }),\n )\n if (!result.ok) {\n console.error('[marketplace] the payout schedule could not be set', {\n publisherOrgId: input.publisherOrgId,\n error: result.body?.error?.message,\n })\n return 'failed'\n }\n await profileRef.set(\n { payoutDelayDays: desired, payoutPolicyAtMs: input.nowMs ?? Date.now() },\n { merge: true },\n )\n await notifyPublisherPayoutSchedule(input.publisherOrgId, desired !== null)\n return 'applied'\n } catch (error) {\n console.error('[marketplace] the payout schedule could not be set', error)\n return 'failed'\n }\n}\n\n/** The card that paid a payment intent, as Stripe fingerprints it, or null. */\nasync function cardFingerprintOf(\n paymentIntentId: string,\n stripeKey: string,\n): Promise<string | null> {\n const result = await stripeRequest(\n `payment_intents/${encodeURIComponent(paymentIntentId)}?expand[]=latest_charge`,\n stripeKey,\n )\n const fingerprint = result.body?.latest_charge?.payment_method_details?.card?.fingerprint\n return result.ok && typeof fingerprint === 'string' && fingerprint ? fingerprint : null\n}\n\n/** The display name of the listing a recorded sale is for, or null. Never throws. */\nasync function soldListingName(\n firestore: FirebaseFirestore.Firestore,\n purchaseRef: FirebaseFirestore.DocumentReference,\n): Promise<string | null> {\n try {\n const listingId = String((await purchaseRef.get()).get('listingId') ?? '')\n if (!listingId) return null\n const listing = await firestore.collection('marketplaceListings').doc(listingId).get()\n const name = String(listing.get('displayName') ?? '').trim()\n return name || null\n } catch {\n return null\n }\n}\n\n/**\n * Tell staff and the publisher's owners and admins that a sale was flagged\n * (AGL-3365), once per row, through the risk notice seam (AGL-3368). Staff\n * get the shapes that flagged it and the row; the publisher gets the\n * catalog's `marketplace-sale-review` words — the outcome and the way to\n * talk to us, never which shape flagged it, which would teach the actor it\n * describes how to avoid it. Never throws.\n */\nexport async function notifySaleRisk(\n notifyRisk: (input: RiskEventInput) => Promise<unknown>,\n flagged: {\n publisherOrgId: string\n reviewId: string\n reference: string\n signals: readonly SaleRiskSignal[]\n amountCents: number\n paymentIntentId: string\n livemode: boolean\n /** The listing sold, by its display name, when the sale names one. */\n listingName?: string | null\n },\n): Promise<void> {\n const listingName = String(flagged.listingName ?? '').trim()\n await notifyRisk({\n kind: 'marketplace-sale-review',\n orgId: flagged.publisherOrgId,\n reviewId: flagged.reviewId,\n reference: flagged.reference,\n item: {\n // Which sale, for a publisher with several listings.\n label: listingName\n ? `a sale of your listing \"${listingName.replace(/\"/g, '”')}\"`\n : 'a recent marketplace sale',\n path: '/org/marketplace/payouts',\n },\n amount: `$${(flagged.amountCents / 100).toFixed(2)}`,\n stripeUrl: flagged.paymentIntentId\n ? `https://dashboard.stripe.com/${flagged.livemode ? '' : 'test/'}payments/${encodeURIComponent(flagged.paymentIntentId)}`\n : null,\n staffEvidence: describeSaleRiskSignals(flagged.signals).join(' '),\n }).catch(() => undefined)\n}\n\n/**\n * Tell the publisher's owners and admins their payout timing changed\n * (AGL-3365) — when a hold starts, with the schedule it puts them on, and\n * when it ends — through the risk notice seam (AGL-3368). Never throws.\n */\nexport async function notifyPublisherPayoutSchedule(\n publisherOrgId: string,\n held: boolean,\n notifyRisk: (input: RiskEventInput) => Promise<unknown> = notifyRiskEvent,\n): Promise<void> {\n await notifyRisk({\n kind: held ? 'publisher-payouts-held' : 'publisher-payouts-standard',\n orgId: publisherOrgId,\n item: { label: 'your marketplace payouts', path: '/org/marketplace/payouts' },\n // The schedule the publisher is on, in the same numbers the policy set.\n ...(held\n ? { payout: { delayDays: YOUNG_PUBLISHER_PAYOUT_DELAY_DAYS, untilWorkspaceAgeDays: YOUNG_PUBLISHER_DAYS } }\n : {}),\n }).catch(() => undefined)\n}\n\n/** The sale-risk row's id: one per sale. */\nexport function saleRiskReviewId(sessionId: string): string {\n return createHash('sha256').update(`marketplace-sale-risk:${sessionId}`).digest('hex').slice(0, 40)\n}\n\n/**\n * Read one recorded sale for the fraud shapes above, hold a young\n * publisher's payouts, and file a staff row when anything is found.\n */\nexport async function screenMarketplaceSale(\n input: {\n purchaseRef: FirebaseFirestore.DocumentReference\n sessionId: string\n buyerUid: string\n buyerOrgId: string | null\n sellerOrgId: string\n amountCents: number\n paymentIntentId: string\n livemode: boolean\n },\n deps: {\n firestore?: FirebaseFirestore.Firestore\n stripeKey?: string\n notifyRisk: (input: RiskEventInput) => Promise<unknown>\n nowMs?: number\n },\n): Promise<{ signals: SaleRiskSignal[]; filed: string | null }> {\n const nowMs = deps.nowMs ?? Date.now()\n const signals: SaleRiskSignal[] = []\n try {\n const firestore = deps.firestore ?? firebaseAdmin.app().firestore()\n const sellerOrg = (await firestore.collection('orgs').doc(input.sellerOrgId).get()).data() ?? {}\n const ageDays = orgAgeDays((sellerOrg as { createdAt?: unknown }).createdAt, nowMs)\n\n // 1. A young publisher: hold its payouts; a large sale is a signal.\n await applyPublisherPayoutPolicy({\n firestore,\n publisherOrgId: input.sellerOrgId,\n ageDays,\n stripeKey: deps.stripeKey,\n nowMs,\n })\n if (isYoungPublisher(ageDays) && input.amountCents >= YOUNG_PUBLISHER_REVIEW_SALE_CENTS) {\n signals.push({\n code: 'young-publisher-large-sale',\n ageDays: ageDays as number,\n amountCents: input.amountCents,\n })\n }\n\n // 2. A member in both workspaces — the buyer themself included.\n const sellerMembers = new Set(await memberUids(firestore, input.sellerOrgId))\n const buyerSide = new Set([\n input.buyerUid,\n ...(input.buyerOrgId && input.buyerOrgId !== input.sellerOrgId\n ? await memberUids(firestore, input.buyerOrgId)\n : []),\n ])\n const shared = [...buyerSide].filter((uid) => uid && sellerMembers.has(uid)).sort()\n if (shared.length || (input.buyerOrgId && input.buyerOrgId === input.sellerOrgId)) {\n signals.push({ code: 'shared-member', uids: shared })\n }\n\n // 3. One card, several buyer workspaces, one publisher.\n if (deps.stripeKey && input.paymentIntentId) {\n const fingerprint = await cardFingerprintOf(input.paymentIntentId, deps.stripeKey)\n if (fingerprint) {\n await input.purchaseRef.set({ cardFingerprint: fingerprint }, { merge: true })\n const matches = await firestore\n .collection('marketplacePurchases')\n .where('sellerOrgId', '==', input.sellerOrgId)\n .where('cardFingerprint', '==', fingerprint)\n .limit(MAX_CARD_MATCHES)\n .get()\n const others = [\n ...new Set(\n matches.docs\n .map((doc) => String(doc.get('buyerOrgId') ?? ''))\n .filter((orgId) => orgId && orgId !== (input.buyerOrgId ?? '')),\n ),\n ].sort()\n if (others.length) {\n signals.push({ code: 'card-reused-across-buyers', otherBuyerOrgIds: others })\n }\n }\n }\n\n if (!signals.length) return { signals, filed: null }\n\n const reviewId = saleRiskReviewId(input.sessionId)\n const reference = `MR-${reviewId.slice(0, 10).toUpperCase()}`\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'marketplace-sale-risk',\n url: null,\n reportedHostname: null,\n hostId: null,\n orgId: input.sellerOrgId,\n details: [\n `A marketplace sale (checkout ${input.sessionId}, payment ${input.paymentIntentId || 'unknown'}) ` +\n `paid publisher workspace ${input.sellerOrgId} $${(input.amountCents / 100).toFixed(2)} ` +\n `from buyer ${input.buyerUid}${input.buyerOrgId ? ` for workspace ${input.buyerOrgId}` : ''}` +\n (input.livemode ? '' : ' — TEST MODE') +\n '.',\n ...describeSaleRiskSignals(signals),\n 'A publisher buying its own listings with stolen cards, to cash them out as payouts, leaves this shape.',\n 'Nothing has been refunded, reversed or paused. If it is the publisher, lock its workspace and ' +\n \"refund the sale (the refund takes the publisher's share back), then close this row with what you did.\",\n ]\n .join('\\n')\n .slice(0, 5000),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: 1,\n saleRisk: {\n sessionId: input.sessionId,\n paymentIntentId: input.paymentIntentId || null,\n sellerOrgId: input.sellerOrgId,\n buyerOrgId: input.buyerOrgId,\n buyerUid: input.buyerUid,\n amountCents: input.amountCents,\n signals,\n livemode: input.livemode,\n },\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifySaleRisk(deps.notifyRisk, {\n publisherOrgId: input.sellerOrgId,\n reviewId,\n reference,\n signals,\n amountCents: input.amountCents,\n paymentIntentId: input.paymentIntentId,\n livemode: input.livemode,\n listingName: await soldListingName(firestore, input.purchaseRef),\n })\n }\n return { signals, filed: reference }\n } catch (error) {\n console.error('[marketplace] a sale could not be screened', error)\n return { signals, filed: null }\n }\n}\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","isSecurityClassLockdownReason","normalizeOrgLockdown","LOCKDOWN_BILLING_PAUSES_COLLECTION","lockdownPayoutPauseRecordId","firebaseAdmin","notifyRiskEvent","orgAgeDays","describeSaleRiskSignals","isYoungPublisher","publisherPayoutDelayDays","YOUNG_PUBLISHER_DAYS","YOUNG_PUBLISHER_PAYOUT_DELAY_DAYS","YOUNG_PUBLISHER_REVIEW_SALE_CENTS","MAX_MEMBERS_COMPARED","MAX_CARD_MATCHES","isOrgMember","firestore","orgId","uid","member","collection","doc","get","exists","isPublisherSecurityLocked","publisherOrgId","org","data","reason","memberUids","snapshot","limit","docs","map","id","stripeRequest","path","stripeKey","params","response","fetch","method","headers","Authorization","body","toString","json","catch","ok","applyPublisherPayoutPolicy","input","profile","profileRef","accountId","String","lockHold","desired","ageDays","stored","undefined","result","encodeURIComponent","URLSearchParams","console","error","message","set","payoutDelayDays","payoutPolicyAtMs","nowMs","Date","now","merge","notifyPublisherPayoutSchedule","cardFingerprintOf","paymentIntentId","fingerprint","latest_charge","payment_method_details","card","soldListingName","purchaseRef","listing","listingId","name","trim","notifySaleRisk","notifyRisk","flagged","listingName","kind","reviewId","reference","item","label","replace","amount","amountCents","toFixed","stripeUrl","livemode","staffEvidence","signals","join","held","payout","delayDays","untilWorkspaceAgeDays","saleRiskReviewId","sessionId","update","digest","slice","screenMarketplaceSale","deps","app","sellerOrg","sellerOrgId","createdAt","push","code","sellerMembers","Set","buyerSide","buyerUid","buyerOrgId","shared","filter","has","sort","length","uids","cardFingerprint","matches","where","others","otherBuyerOrgIds","filed","toUpperCase","ref","first","category","severity","source","url","reportedHostname","hostId","details","reporterEmail","reporterName","dmca","reportCount","saleRisk","updatedAt","FieldValue","serverTimestamp","status"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/sale-risk.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/*==========================================\n * A MARKETPLACE SALE'S FRAUD SHAPE (AGL-3365).\n *\n * Two questions the card network cannot answer, because both sides of the\n * sale are ours:\n *\n * 1. **Is the publisher paying itself?** A publisher that buys its own\n * listing with stolen cards turns them into a payout. Checkout refuses a\n * buyer who is a member of the publishing workspace; a second workspace\n * that shares a member with it, or ONE card buying from one publisher for\n * several workspaces, is the same actor one step removed.\n * 2. **Is the publisher brand new?** See `model/publisher-risk.ts`: its\n * payouts are held, and a large sale files a signal while the share can\n * still be reversed.\n *\n * What it does is what the seller-pattern ledger does: an urgent row in the\n * abuse queue, staff told once. NOTHING IS REFUNDED, REVERSED OR PAUSED\n * HERE — staff decide, with the young tier's payout delay holding the money\n * in reach while they do.\n *\n * Never throws: the sale it reads has already been recorded.\n *=========================================*/\n\nimport { createHash } from 'crypto'\nimport { ABUSE_REPORT_COLLECTION } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { isSecurityClassLockdownReason, normalizeOrgLockdown } from '@aglyn/aglyn/app-utils/lockdown'\nimport {\n LOCKDOWN_BILLING_PAUSES_COLLECTION,\n lockdownPayoutPauseRecordId,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-lockdown'\nimport { firebaseAdmin, notifyRiskEvent, type RiskEventInput } from '@aglyn/tenant-data-admin'\nimport { orgAgeDays } from '@aglyn/tenant-data-admin/server/org-age'\nimport {\n describeSaleRiskSignals,\n isYoungPublisher,\n publisherPayoutDelayDays,\n type SaleRiskSignal,\n YOUNG_PUBLISHER_DAYS,\n YOUNG_PUBLISHER_PAYOUT_DELAY_DAYS,\n YOUNG_PUBLISHER_REVIEW_SALE_CENTS,\n} from '../model/publisher-risk'\n\n/** Members read per workspace when looking for a shared one. */\nconst MAX_MEMBERS_COMPARED = 200\n/** Earlier purchases read when looking for the same card. */\nconst MAX_CARD_MATCHES = 20\n\n/**\n * Whether the publishing workspace is under a SECURITY lock (AGL-3365) — the\n * install and update doors hand nothing from it over. Security only: a\n * workspace locked over its own unpaid bill has not made its listings\n * dangerous, and a buyer who paid for one keeps installing it. (Checkout\n * refuses a sale to a workspace under ANY lock; that is money moving to it.)\n *\n * The shared normalizer reads the reason, so a lock written before reason\n * codes existed reads as `manual`, not security. One read of the org.\n */\nexport async function isPublisherSecurityLocked(\n firestore: FirebaseFirestore.Firestore,\n publisherOrgId: unknown,\n): Promise<boolean> {\n const orgId = typeof publisherOrgId === 'string' ? publisherOrgId : ''\n if (!orgId) return false\n const org = (await firestore.collection('orgs').doc(orgId).get()).data()\n return isSecurityClassLockdownReason(\n normalizeOrgLockdown(org as Parameters<typeof normalizeOrgLockdown>[0])?.reason,\n )\n}\n\nasync function memberUids(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .limit(MAX_MEMBERS_COMPARED)\n .get()\n return snapshot.docs.map((doc) => doc.id)\n}\n\nasync function stripeRequest(\n path: string,\n stripeKey: string,\n params?: URLSearchParams,\n): Promise<{ ok: boolean; body: any }> {\n const response = await fetch(`https://api.stripe.com/v1/${path}`, {\n method: params ? 'POST' : 'GET',\n headers: {\n Authorization: `Bearer ${stripeKey}`,\n ...(params ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),\n },\n ...(params ? { body: params.toString() } : {}),\n })\n const body = await response.json().catch(() => null)\n return { ok: response.ok, body }\n}\n\n/**\n * Put a publisher's connected account on the payout schedule its age calls\n * for (`publisherPayoutDelayDays`), and note it on the profile.\n *\n * Writes to Stripe only when the answer CHANGES what this code last set: a\n * young publisher is moved to the delay once, and moved back to the standard\n * schedule once when it ages out. An account this code never delayed is\n * never written — a schedule staff set by hand in the Dashboard is theirs.\n *\n * Never throws: onboarding and a sale must both go on if Stripe says no.\n */\nexport async function applyPublisherPayoutPolicy(input: {\n firestore: FirebaseFirestore.Firestore\n publisherOrgId: string\n ageDays: number | null\n stripeKey: string | undefined\n nowMs?: number\n}): Promise<'applied' | 'current' | 'held-by-lock' | 'skipped' | 'failed'> {\n try {\n if (!input.stripeKey || !input.publisherOrgId) return 'skipped'\n const profileRef = input.firestore.collection('publisherProfiles').doc(input.publisherOrgId)\n const profile = await profileRef.get()\n const accountId = String(profile.get('stripeAccountId') ?? '')\n if (!accountId) return 'skipped'\n // A workspace lock holds this account's payouts (AGL-3364/3365): its\n // schedule is manual and the lock saved the one to restore. Moving it\n // now would either undo the pause or leave the lift restoring a delay\n // this record no longer describes, so it waits. The next sale after the\n // lift sets it right.\n const lockHold = await input.firestore\n .collection(LOCKDOWN_BILLING_PAUSES_COLLECTION)\n .doc(lockdownPayoutPauseRecordId(accountId))\n .get()\n if (lockHold.exists) return 'held-by-lock'\n const desired = publisherPayoutDelayDays(input.ageDays)\n const stored = profile.get('payoutDelayDays') as number | null | undefined\n if (stored === desired) return 'current'\n // Never delayed by this code and not young: leave the account alone.\n if (stored === undefined && desired === null) return 'current'\n const result = await stripeRequest(\n `accounts/${encodeURIComponent(accountId)}`,\n input.stripeKey,\n new URLSearchParams({\n 'settings[payouts][schedule][delay_days]': desired === null ? 'minimum' : String(desired),\n }),\n )\n if (!result.ok) {\n console.error('[marketplace] the payout schedule could not be set', {\n publisherOrgId: input.publisherOrgId,\n error: result.body?.error?.message,\n })\n return 'failed'\n }\n await profileRef.set(\n { payoutDelayDays: desired, payoutPolicyAtMs: input.nowMs ?? Date.now() },\n { merge: true },\n )\n await notifyPublisherPayoutSchedule(input.publisherOrgId, desired !== null)\n return 'applied'\n } catch (error) {\n console.error('[marketplace] the payout schedule could not be set', error)\n return 'failed'\n }\n}\n\n/** The card that paid a payment intent, as Stripe fingerprints it, or null. */\nasync function cardFingerprintOf(\n paymentIntentId: string,\n stripeKey: string,\n): Promise<string | null> {\n const result = await stripeRequest(\n `payment_intents/${encodeURIComponent(paymentIntentId)}?expand[]=latest_charge`,\n stripeKey,\n )\n const fingerprint = result.body?.latest_charge?.payment_method_details?.card?.fingerprint\n return result.ok && typeof fingerprint === 'string' && fingerprint ? fingerprint : null\n}\n\n/** The display name of the listing a recorded sale is for, or null. Never throws. */\nasync function soldListingName(\n firestore: FirebaseFirestore.Firestore,\n purchaseRef: FirebaseFirestore.DocumentReference,\n): Promise<string | null> {\n try {\n const listingId = String((await purchaseRef.get()).get('listingId') ?? '')\n if (!listingId) return null\n const listing = await firestore.collection('marketplaceListings').doc(listingId).get()\n const name = String(listing.get('displayName') ?? '').trim()\n return name || null\n } catch {\n return null\n }\n}\n\n/**\n * Tell staff and the publisher's owners and admins that a sale was flagged\n * (AGL-3365), once per row, through the risk notice seam (AGL-3368). Staff\n * get the shapes that flagged it and the row; the publisher gets the\n * catalog's `marketplace-sale-review` words — the outcome and the way to\n * talk to us, never which shape flagged it, which would teach the actor it\n * describes how to avoid it. Never throws.\n */\nexport async function notifySaleRisk(\n notifyRisk: (input: RiskEventInput) => Promise<unknown>,\n flagged: {\n publisherOrgId: string\n reviewId: string\n reference: string\n signals: readonly SaleRiskSignal[]\n amountCents: number\n paymentIntentId: string\n livemode: boolean\n /** The listing sold, by its display name, when the sale names one. */\n listingName?: string | null\n },\n): Promise<void> {\n const listingName = String(flagged.listingName ?? '').trim()\n await notifyRisk({\n kind: 'marketplace-sale-review',\n orgId: flagged.publisherOrgId,\n reviewId: flagged.reviewId,\n reference: flagged.reference,\n item: {\n // Which sale, for a publisher with several listings.\n label: listingName\n ? `a sale of your listing \"${listingName.replace(/\"/g, '”')}\"`\n : 'a recent marketplace sale',\n path: '/org/marketplace/payouts',\n },\n amount: `$${(flagged.amountCents / 100).toFixed(2)}`,\n stripeUrl: flagged.paymentIntentId\n ? `https://dashboard.stripe.com/${flagged.livemode ? '' : 'test/'}payments/${encodeURIComponent(flagged.paymentIntentId)}`\n : null,\n staffEvidence: describeSaleRiskSignals(flagged.signals).join(' '),\n }).catch(() => undefined)\n}\n\n/**\n * Tell the publisher's owners and admins their payout timing changed\n * (AGL-3365) — when a hold starts, with the schedule it puts them on, and\n * when it ends — through the risk notice seam (AGL-3368). Never throws.\n */\nexport async function notifyPublisherPayoutSchedule(\n publisherOrgId: string,\n held: boolean,\n notifyRisk: (input: RiskEventInput) => Promise<unknown> = notifyRiskEvent,\n): Promise<void> {\n await notifyRisk({\n kind: held ? 'publisher-payouts-held' : 'publisher-payouts-standard',\n orgId: publisherOrgId,\n item: { label: 'your marketplace payouts', path: '/org/marketplace/payouts' },\n // The schedule the publisher is on, in the same numbers the policy set.\n ...(held\n ? { payout: { delayDays: YOUNG_PUBLISHER_PAYOUT_DELAY_DAYS, untilWorkspaceAgeDays: YOUNG_PUBLISHER_DAYS } }\n : {}),\n }).catch(() => undefined)\n}\n\n/** The sale-risk row's id: one per sale. */\nexport function saleRiskReviewId(sessionId: string): string {\n return createHash('sha256').update(`marketplace-sale-risk:${sessionId}`).digest('hex').slice(0, 40)\n}\n\n/**\n * Read one recorded sale for the fraud shapes above, hold a young\n * publisher's payouts, and file a staff row when anything is found.\n */\nexport async function screenMarketplaceSale(\n input: {\n purchaseRef: FirebaseFirestore.DocumentReference\n sessionId: string\n buyerUid: string\n buyerOrgId: string | null\n sellerOrgId: string\n amountCents: number\n paymentIntentId: string\n livemode: boolean\n },\n deps: {\n firestore?: FirebaseFirestore.Firestore\n stripeKey?: string\n notifyRisk: (input: RiskEventInput) => Promise<unknown>\n nowMs?: number\n },\n): Promise<{ signals: SaleRiskSignal[]; filed: string | null }> {\n const nowMs = deps.nowMs ?? Date.now()\n const signals: SaleRiskSignal[] = []\n try {\n const firestore = deps.firestore ?? firebaseAdmin.app().firestore()\n const sellerOrg = (await firestore.collection('orgs').doc(input.sellerOrgId).get()).data() ?? {}\n const ageDays = orgAgeDays((sellerOrg as { createdAt?: unknown }).createdAt, nowMs)\n\n // 1. A young publisher: hold its payouts; a large sale is a signal.\n await applyPublisherPayoutPolicy({\n firestore,\n publisherOrgId: input.sellerOrgId,\n ageDays,\n stripeKey: deps.stripeKey,\n nowMs,\n })\n if (isYoungPublisher(ageDays) && input.amountCents >= YOUNG_PUBLISHER_REVIEW_SALE_CENTS) {\n signals.push({\n code: 'young-publisher-large-sale',\n ageDays: ageDays as number,\n amountCents: input.amountCents,\n })\n }\n\n // 2. A member in both workspaces — the buyer themself included.\n const sellerMembers = new Set(await memberUids(firestore, input.sellerOrgId))\n const buyerSide = new Set([\n input.buyerUid,\n ...(input.buyerOrgId && input.buyerOrgId !== input.sellerOrgId\n ? await memberUids(firestore, input.buyerOrgId)\n : []),\n ])\n const shared = [...buyerSide].filter((uid) => uid && sellerMembers.has(uid)).sort()\n if (shared.length || (input.buyerOrgId && input.buyerOrgId === input.sellerOrgId)) {\n signals.push({ code: 'shared-member', uids: shared })\n }\n\n // 3. One card, several buyer workspaces, one publisher.\n if (deps.stripeKey && input.paymentIntentId) {\n const fingerprint = await cardFingerprintOf(input.paymentIntentId, deps.stripeKey)\n if (fingerprint) {\n await input.purchaseRef.set({ cardFingerprint: fingerprint }, { merge: true })\n const matches = await firestore\n .collection('marketplacePurchases')\n .where('sellerOrgId', '==', input.sellerOrgId)\n .where('cardFingerprint', '==', fingerprint)\n .limit(MAX_CARD_MATCHES)\n .get()\n const others = [\n ...new Set(\n matches.docs\n .map((doc) => String(doc.get('buyerOrgId') ?? ''))\n .filter((orgId) => orgId && orgId !== (input.buyerOrgId ?? '')),\n ),\n ].sort()\n if (others.length) {\n signals.push({ code: 'card-reused-across-buyers', otherBuyerOrgIds: others })\n }\n }\n }\n\n if (!signals.length) return { signals, filed: null }\n\n const reviewId = saleRiskReviewId(input.sessionId)\n const reference = `MR-${reviewId.slice(0, 10).toUpperCase()}`\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n await ref.set(\n {\n reference,\n category: 'phishing',\n severity: 'urgent',\n source: 'marketplace-sale-risk',\n url: null,\n reportedHostname: null,\n hostId: null,\n orgId: input.sellerOrgId,\n details: [\n `A marketplace sale (checkout ${input.sessionId}, payment ${input.paymentIntentId || 'unknown'}) ` +\n `paid publisher workspace ${input.sellerOrgId} $${(input.amountCents / 100).toFixed(2)} ` +\n `from buyer ${input.buyerUid}${input.buyerOrgId ? ` for workspace ${input.buyerOrgId}` : ''}` +\n (input.livemode ? '' : ' — TEST MODE') +\n '.',\n ...describeSaleRiskSignals(signals),\n 'A publisher buying its own listings with stolen cards, to cash them out as payouts, leaves this shape.',\n 'Nothing has been refunded, reversed or paused. If it is the publisher, lock its workspace and ' +\n \"refund the sale (the refund takes the publisher's share back), then close this row with what you did.\",\n ]\n .join('\\n')\n .slice(0, 5000),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n reportCount: 1,\n saleRisk: {\n sessionId: input.sessionId,\n paymentIntentId: input.paymentIntentId || null,\n sellerOrgId: input.sellerOrgId,\n buyerOrgId: input.buyerOrgId,\n buyerUid: input.buyerUid,\n amountCents: input.amountCents,\n signals,\n livemode: input.livemode,\n },\n updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n ...(first\n ? { status: 'open', createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp() }\n : {}),\n },\n { merge: true },\n )\n if (first) {\n await notifySaleRisk(deps.notifyRisk, {\n publisherOrgId: input.sellerOrgId,\n reviewId,\n reference,\n signals,\n amountCents: input.amountCents,\n paymentIntentId: input.paymentIntentId,\n livemode: input.livemode,\n listingName: await soldListingName(firestore, input.purchaseRef),\n })\n }\n return { signals, filed: reference }\n } catch (error) {\n console.error('[marketplace] a sale could not be screened', error)\n return { signals, filed: null }\n }\n}\n"],"names":["createHash","ABUSE_REPORT_COLLECTION","isSecurityClassLockdownReason","normalizeOrgLockdown","LOCKDOWN_BILLING_PAUSES_COLLECTION","lockdownPayoutPauseRecordId","firebaseAdmin","notifyRiskEvent","orgAgeDays","describeSaleRiskSignals","isYoungPublisher","publisherPayoutDelayDays","YOUNG_PUBLISHER_DAYS","YOUNG_PUBLISHER_PAYOUT_DELAY_DAYS","YOUNG_PUBLISHER_REVIEW_SALE_CENTS","MAX_MEMBERS_COMPARED","MAX_CARD_MATCHES","isPublisherSecurityLocked","firestore","publisherOrgId","orgId","org","collection","doc","get","data","reason","memberUids","snapshot","limit","docs","map","id","stripeRequest","path","stripeKey","params","response","fetch","method","headers","Authorization","body","toString","json","catch","ok","applyPublisherPayoutPolicy","input","profile","profileRef","accountId","String","lockHold","exists","desired","ageDays","stored","undefined","result","encodeURIComponent","URLSearchParams","console","error","message","set","payoutDelayDays","payoutPolicyAtMs","nowMs","Date","now","merge","notifyPublisherPayoutSchedule","cardFingerprintOf","paymentIntentId","fingerprint","latest_charge","payment_method_details","card","soldListingName","purchaseRef","listing","listingId","name","trim","notifySaleRisk","notifyRisk","flagged","listingName","kind","reviewId","reference","item","label","replace","amount","amountCents","toFixed","stripeUrl","livemode","staffEvidence","signals","join","held","payout","delayDays","untilWorkspaceAgeDays","saleRiskReviewId","sessionId","update","digest","slice","screenMarketplaceSale","deps","app","sellerOrg","sellerOrgId","createdAt","push","code","sellerMembers","Set","buyerSide","buyerUid","buyerOrgId","shared","filter","uid","has","sort","length","uids","cardFingerprint","matches","where","others","otherBuyerOrgIds","filed","toUpperCase","ref","first","category","severity","source","url","reportedHostname","hostId","details","reporterEmail","reporterName","dmca","reportCount","saleRisk","updatedAt","FieldValue","serverTimestamp","status"],"mappings":"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"}
@@ -20,9 +20,8 @@ import { SITE_STARTER_ARTIFACT_TYPE } from "@aglyn/aglyn/app-utils/site-starter"
20
20
  import { isListingBrowsable, isPrivateListing, listingArtifactType } from "../model/marketplace.js";
21
21
  import { sellsAsPlatform } from "../model/platform-seller.js";
22
22
  import { readPlatformOrgId } from "./platform-org.js";
23
- import { canActAsPublisher, resolvePublisherProfile } from "./publisher-profile.js";
23
+ import { isPublisherMember, resolvePublisherProfile } from "./publisher-profile.js";
24
24
  import { hasLivePurchase } from "./purchase-entitlement.js";
25
- import { isOrgMember } from "./sale-risk.js";
26
25
  /**
27
26
  * THE TAX CLASSIFICATION OF A MARKETPLACE SALE (AGL-1553).
28
27
  *
@@ -142,7 +141,7 @@ const refuse = (status, body)=>({
142
141
  // route has always been stricter than that, and a sale is the wrong place
143
142
  // to loosen a gate.
144
143
  //
145
- // No owner exemption, unlike the install routes: `canActAsPublisher`
144
+ // No owner exemption, unlike the install routes: `isPublisherMember`
146
145
  // below already refuses a publisher buying their own listing, so the
147
146
  // buyer here is never the owner.
148
147
  if (!isListingBrowsable(listing) || isPrivateListing(listing)) {
@@ -159,12 +158,14 @@ const refuse = (status, body)=>({
159
158
  // Publishing is org-owned (AGL-652): `profileId` IS the publisher org id.
160
159
  const sellerOrgId = String((_listing_profileId = listing.profileId) != null ? _listing_profileId : '');
161
160
  // ANY member of the publishing workspace, and any purchase FOR it
162
- // (AGL-3365). `canActAsPublisher` asks only for an owner or admin, so an
163
- // editor of the publisher — or an owner buying "for" the publisher's own
164
- // workspace through a role that can install — passed it, and a publisher
165
- // could pay itself: the stolen-card cash-out the payout exists to make
166
- // worth doing. A workspace one step removed is the sale screen's to flag.
167
- if (buyer.orgId === sellerOrgId || await canActAsPublisher(firestore, buyer.uid, sellerOrgId) || await isOrgMember(firestore, sellerOrgId, buyer.uid)) {
161
+ // (AGL-3365). Asking only for an owner or admin let an editor of the
162
+ // publisher (or an owner buying "for" the publisher's own workspace
163
+ // through a role that can install) through, and a publisher could pay
164
+ // itself: the stolen-card cash-out the payout exists to make worth doing.
165
+ // Membership, never a permission (AGL-4047): an admin whose publisher keys
166
+ // are refused still published the listing. A workspace one step removed is
167
+ // the sale screen's to flag.
168
+ if (buyer.orgId === sellerOrgId || await isPublisherMember(firestore, buyer.uid, sellerOrgId)) {
168
169
  return refuse(400, {
169
170
  error: 'Your organization published this listing'
170
171
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/sale.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 { merchantAccountIsReady } from '@aglyn/tenant-data-admin/server/payment-provider'\nimport {\n checkEntitlement,\n isListingWideRevocation,\n resolveMarketplaceFeePct,\n type PluginRevocation,\n} from '@aglyn/aglyn/server'\nimport { permissionRefusalText } from '@aglyn/aglyn/app-utils/org-permissions'\nimport { SITE_STARTER_ARTIFACT_TYPE } from '@aglyn/aglyn/app-utils/site-starter'\nimport { isListingBrowsable, isPrivateListing, listingArtifactType } from '../model/marketplace'\nimport { sellsAsPlatform } from '../model/platform-seller'\nimport { readPlatformOrgId } from './platform-org'\nimport { canActAsPublisher, resolvePublisherProfile } 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/** Who is buying: the person, the workspace the licence is for, and what they may do in it. */\nexport interface SaleBuyer {\n uid: string\n orgId: string\n permissions: { createHosts?: boolean; installPlugins?: boolean }\n}\n\n/** One sale, decided: everything the payment doors need and nothing they chose. */\nexport interface MarketplaceSale {\n listing: Record<string, any>\n /** A site starter is bought to create a site with, everything else to install. */\n siteStarter: boolean\n sellerOrgId: string\n feePercent: number\n amountCents: number\n feeCents: number\n /** What the seller receives: 0 when the platform's own org is the seller. */\n transferCents: number\n /** The seller's Connect account; absent when {@link platformSale}. */\n accountId: string | undefined\n /** The platform's own org is selling, so no transfer is made at all. */\n platformSale: boolean\n}\n\n/** The answer a door sends instead of taking money: the status and body, decided together. */\nexport interface SaleRefusal {\n status: number\n body: Record<string, unknown>\n}\n\nexport type SaleResolution = { sale: MarketplaceSale } | { refusal: SaleRefusal }\n\nconst refuse = (status: number, body: Record<string, unknown>): SaleResolution => ({\n refusal: { status, body },\n})\n\n/**\n * EVERYTHING A MARKETPLACE SALE DECIDES BEFORE STRIPE (AGL-4049).\n *\n * One value, for both doors that take money for a listing: the hosted\n * checkout (`checkout.ts`) and the in-app payment (`pay.ts`). A second copy of\n * these gates is how a door comes to sell something the other refuses, and\n * every refusal below has already cost a real sale or a real refund to learn.\n *\n * The caller has settled WHO is buying, through the person's own membership;\n * this settles whether that workspace may buy this listing, from this seller,\n * today, and at what split.\n *\n * Deliberately before the door's idempotency claim, so none of these burns the\n * key: each is a state somebody fixes before pressing the same button again.\n */\nexport async function resolveSale(input: {\n firestore: FirebaseFirestore.Firestore\n listingId: string\n buyer: SaleBuyer\n}): Promise<SaleResolution> {\n const { firestore, listingId, buyer } = input\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 refuse(404, { error: 'Unknown listing' })\n }\n // A site starter is bought to create a site with (AGL-4049), so buying\n // one takes the permission that creates sites, as picking one in Create\n // site does; everything else is bought to install, as before. Asked of\n // the listing, never of the request, and before anything else about the\n // sale is read.\n const siteStarter = listingArtifactType(listing) === SITE_STARTER_ARTIFACT_TYPE\n if (siteStarter ? !buyer.permissions.createHosts : !buyer.permissions.installPlugins) {\n return refuse(403, {\n error: siteStarter\n ? permissionRefusalText('hosts.create')\n : 'Your organization role does not allow installing from the marketplace',\n })\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 refuse(404, { 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 refuse(404, { error: 'Unknown listing' })\n }\n const priceUsd = Number(listing.priceUsd ?? 0)\n if (!(priceUsd > 0)) {\n return refuse(400, { 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 buyer.orgId === sellerOrgId ||\n (await canActAsPublisher(firestore, buyer.uid, sellerOrgId)) ||\n (await isOrgMember(firestore, sellerOrgId, buyer.uid))\n ) {\n return refuse(400, { 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: buyer.uid,\n buyerOrgId: buyer.orgId,\n listingId,\n })\n ) {\n return refuse(409, {\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 refuse(404, { error: 'Unknown listing' })\n }\n const publisher = await resolvePublisherProfile(firestore, sellerOrgId)\n const accountId = publisher?.stripeAccountId\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 const feePercent = resolveMarketplaceFeePct(sellerOrg)\n // THE PLATFORM'S OWN ORG, AT THE STAFF WAIVER (AGL-4049). Nobody to pay, so\n // no account to be ready and no transfer to make. Asked here, ahead of the\n // payout gate, which is the only refusal it changes: a lock and the plan\n // below hold for it as for anyone.\n const platformSale = sellsAsPlatform({\n sellerOrgId,\n feePercent,\n platformOrgId: readPlatformOrgId(),\n })\n if (\n !platformSale &&\n !merchantAccountIsReady(\n {\n accountId,\n chargesEnabled: publisher?.stripeChargesEnabled,\n accountLivemode: publisher?.stripeAccountLivemode,\n },\n { subject: `marketplace publisher org ${sellerOrgId}` },\n )\n ) {\n return refuse(409, { error: 'The publisher has not enabled payouts yet' })\n }\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 refuse(404, { error: 'Unknown listing' })\n }\n if (!checkEntitlement(sellerOrg, 'marketplaceSelling')) {\n return refuse(409, {\n error:\n \"The publisher's current plan does not include marketplace selling\",\n })\n }\n const amountCents = Math.round(priceUsd * 100)\n const feeCents = Math.round((amountCents * feePercent) / 100)\n return {\n sale: {\n listing,\n siteStarter,\n sellerOrgId,\n feePercent,\n amountCents,\n feeCents,\n transferCents: platformSale ? 0 : amountCents - feeCents,\n accountId: platformSale ? undefined : accountId,\n platformSale,\n },\n }\n}\n"],"names":["merchantAccountIsReady","checkEntitlement","isListingWideRevocation","resolveMarketplaceFeePct","permissionRefusalText","SITE_STARTER_ARTIFACT_TYPE","isListingBrowsable","isPrivateListing","listingArtifactType","sellsAsPlatform","readPlatformOrgId","canActAsPublisher","resolvePublisherProfile","hasLivePurchase","isOrgMember","MARKETPLACE_TAX_CODE","refuse","status","body","refusal","resolveSale","input","listing","sellerOrgDoc","firestore","listingId","buyer","listingSnapshot","collection","doc","get","data","deletedAt","error","siteStarter","permissions","createHosts","installPlugins","reviewStatus","priceUsd","Number","sellerOrgId","String","profileId","orgId","uid","buyerUid","buyerOrgId","code","revocation","publisher","accountId","stripeAccountId","sellerOrg","feePercent","platformSale","platformOrgId","chargesEnabled","stripeChargesEnabled","accountLivemode","stripeAccountLivemode","subject","suspendedAt","amountCents","Math","round","feeCents","sale","transferCents","undefined"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/sale.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 { merchantAccountIsReady } from '@aglyn/tenant-data-admin/server/payment-provider'\nimport {\n checkEntitlement,\n isListingWideRevocation,\n resolveMarketplaceFeePct,\n type PluginRevocation,\n} from '@aglyn/aglyn/server'\nimport { permissionRefusalText } from '@aglyn/aglyn/app-utils/org-permissions'\nimport { SITE_STARTER_ARTIFACT_TYPE } from '@aglyn/aglyn/app-utils/site-starter'\nimport { isListingBrowsable, isPrivateListing, listingArtifactType } from '../model/marketplace'\nimport { sellsAsPlatform } from '../model/platform-seller'\nimport { readPlatformOrgId } from './platform-org'\nimport { isPublisherMember, resolvePublisherProfile } 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/** Who is buying: the person, the workspace the licence is for, and what they may do in it. */\nexport interface SaleBuyer {\n uid: string\n orgId: string\n permissions: { createHosts?: boolean; installPlugins?: boolean }\n}\n\n/** One sale, decided: everything the payment doors need and nothing they chose. */\nexport interface MarketplaceSale {\n listing: Record<string, any>\n /** A site starter is bought to create a site with, everything else to install. */\n siteStarter: boolean\n sellerOrgId: string\n feePercent: number\n amountCents: number\n feeCents: number\n /** What the seller receives: 0 when the platform's own org is the seller. */\n transferCents: number\n /** The seller's Connect account; absent when {@link platformSale}. */\n accountId: string | undefined\n /** The platform's own org is selling, so no transfer is made at all. */\n platformSale: boolean\n}\n\n/** The answer a door sends instead of taking money: the status and body, decided together. */\nexport interface SaleRefusal {\n status: number\n body: Record<string, unknown>\n}\n\nexport type SaleResolution = { sale: MarketplaceSale } | { refusal: SaleRefusal }\n\nconst refuse = (status: number, body: Record<string, unknown>): SaleResolution => ({\n refusal: { status, body },\n})\n\n/**\n * EVERYTHING A MARKETPLACE SALE DECIDES BEFORE STRIPE (AGL-4049).\n *\n * One value, for both doors that take money for a listing: the hosted\n * checkout (`checkout.ts`) and the in-app payment (`pay.ts`). A second copy of\n * these gates is how a door comes to sell something the other refuses, and\n * every refusal below has already cost a real sale or a real refund to learn.\n *\n * The caller has settled WHO is buying, through the person's own membership;\n * this settles whether that workspace may buy this listing, from this seller,\n * today, and at what split.\n *\n * Deliberately before the door's idempotency claim, so none of these burns the\n * key: each is a state somebody fixes before pressing the same button again.\n */\nexport async function resolveSale(input: {\n firestore: FirebaseFirestore.Firestore\n listingId: string\n buyer: SaleBuyer\n}): Promise<SaleResolution> {\n const { firestore, listingId, buyer } = input\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 refuse(404, { error: 'Unknown listing' })\n }\n // A site starter is bought to create a site with (AGL-4049), so buying\n // one takes the permission that creates sites, as picking one in Create\n // site does; everything else is bought to install, as before. Asked of\n // the listing, never of the request, and before anything else about the\n // sale is read.\n const siteStarter = listingArtifactType(listing) === SITE_STARTER_ARTIFACT_TYPE\n if (siteStarter ? !buyer.permissions.createHosts : !buyer.permissions.installPlugins) {\n return refuse(403, {\n error: siteStarter\n ? permissionRefusalText('hosts.create')\n : 'Your organization role does not allow installing from the marketplace',\n })\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 refuse(404, { 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: `isPublisherMember`\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 refuse(404, { error: 'Unknown listing' })\n }\n const priceUsd = Number(listing.priceUsd ?? 0)\n if (!(priceUsd > 0)) {\n return refuse(400, { 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). Asking only for an owner or admin let an editor of the\n // publisher (or an owner buying \"for\" the publisher's own workspace\n // through a role that can install) through, and a publisher could pay\n // itself: the stolen-card cash-out the payout exists to make worth doing.\n // Membership, never a permission (AGL-4047): an admin whose publisher keys\n // are refused still published the listing. A workspace one step removed is\n // the sale screen's to flag.\n if (\n buyer.orgId === sellerOrgId ||\n (await isPublisherMember(firestore, buyer.uid, sellerOrgId))\n ) {\n return refuse(400, { 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: buyer.uid,\n buyerOrgId: buyer.orgId,\n listingId,\n })\n ) {\n return refuse(409, {\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 refuse(404, { error: 'Unknown listing' })\n }\n const publisher = await resolvePublisherProfile(firestore, sellerOrgId)\n const accountId = publisher?.stripeAccountId\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 const feePercent = resolveMarketplaceFeePct(sellerOrg)\n // THE PLATFORM'S OWN ORG, AT THE STAFF WAIVER (AGL-4049). Nobody to pay, so\n // no account to be ready and no transfer to make. Asked here, ahead of the\n // payout gate, which is the only refusal it changes: a lock and the plan\n // below hold for it as for anyone.\n const platformSale = sellsAsPlatform({\n sellerOrgId,\n feePercent,\n platformOrgId: readPlatformOrgId(),\n })\n if (\n !platformSale &&\n !merchantAccountIsReady(\n {\n accountId,\n chargesEnabled: publisher?.stripeChargesEnabled,\n accountLivemode: publisher?.stripeAccountLivemode,\n },\n { subject: `marketplace publisher org ${sellerOrgId}` },\n )\n ) {\n return refuse(409, { error: 'The publisher has not enabled payouts yet' })\n }\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 refuse(404, { error: 'Unknown listing' })\n }\n if (!checkEntitlement(sellerOrg, 'marketplaceSelling')) {\n return refuse(409, {\n error:\n \"The publisher's current plan does not include marketplace selling\",\n })\n }\n const amountCents = Math.round(priceUsd * 100)\n const feeCents = Math.round((amountCents * feePercent) / 100)\n return {\n sale: {\n listing,\n siteStarter,\n sellerOrgId,\n feePercent,\n amountCents,\n feeCents,\n transferCents: platformSale ? 0 : amountCents - feeCents,\n accountId: platformSale ? undefined : accountId,\n platformSale,\n },\n }\n}\n"],"names":["merchantAccountIsReady","checkEntitlement","isListingWideRevocation","resolveMarketplaceFeePct","permissionRefusalText","SITE_STARTER_ARTIFACT_TYPE","isListingBrowsable","isPrivateListing","listingArtifactType","sellsAsPlatform","readPlatformOrgId","isPublisherMember","resolvePublisherProfile","hasLivePurchase","MARKETPLACE_TAX_CODE","refuse","status","body","refusal","resolveSale","input","listing","sellerOrgDoc","firestore","listingId","buyer","listingSnapshot","collection","doc","get","data","deletedAt","error","siteStarter","permissions","createHosts","installPlugins","reviewStatus","priceUsd","Number","sellerOrgId","String","profileId","orgId","uid","buyerUid","buyerOrgId","code","revocation","publisher","accountId","stripeAccountId","sellerOrg","feePercent","platformSale","platformOrgId","chargesEnabled","stripeChargesEnabled","accountLivemode","stripeAccountLivemode","subject","suspendedAt","amountCents","Math","round","feeCents","sale","transferCents","undefined"],"mappings":"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"}
@@ -19,7 +19,9 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
19
19
  import { BROWSE_EVERYONE, LISTING_BROWSE_AUDIENCE, LISTING_BROWSE_TOKENS } from "../model/listing-query.js";
20
20
  import { isListingBrowsable, isListingDeleted, isPrivateListing, listingArtifactType } from "../model/marketplace.js";
21
21
  import { officialStartersFirst, SITE_STARTER_KNOWN_IDS, siteStarterSearchLimit, siteStarterSearchTokens, siteStarterSummaryOf } from "../model/site-starter-listing.js";
22
- import { canActAsPublisher, PUBLISHER_PROFILES } from "./publisher-profile.js";
22
+ import { publisherMemberBuyReason } from "../model/own-listing.js";
23
+ import { memberOfPublisherElsewhere, ownsListingHere } from "./own-listing.js";
24
+ import { PUBLISHER_PROFILES } from "./publisher-profile.js";
23
25
  import { hasLivePurchase, requirePurchase } from "./purchase-entitlement.js";
24
26
  import { isPublisherSecurityLocked } from "./sale-risk.js";
25
27
  import { recordVersionMove } from "./version-stats.js";
@@ -61,16 +63,19 @@ const gone = (error = 'Unknown starter')=>({
61
63
  return names;
62
64
  }
63
65
  /**
64
- * Whether the viewer acts for the listing's publisher, in the publisher's
66
+ * Whether the viewer belongs to the listing's publisher, in the publisher's
65
67
  * own workspace: it picks its own starter free, and only into itself — the
66
68
  * waiver is about the publisher's OWN sites (AGL-2484), so a person who
67
69
  * administers the publisher and creates a site elsewhere is a buyer like
68
- * anyone else there.
70
+ * anyone else there. Membership, not a permission (AGL-4047): who published
71
+ * the starter is a fact, not a capability a role grants.
69
72
  */ async function viewerPublishes(firestore, listing, viewer) {
70
- var _listing_profileId, _ref, _ref1;
71
- const publisherOrgId = String((_listing_profileId = listing.profileId) != null ? _listing_profileId : '');
72
- const viewerOrgId = String((_ref = viewer == null ? void 0 : viewer.orgId) != null ? _ref : '');
73
- return viewerOrgId ? publisherOrgId !== '' && viewerOrgId === publisherOrgId && canActAsPublisher(firestore, String((_ref1 = viewer == null ? void 0 : viewer.uid) != null ? _ref1 : ''), listing.profileId) : false;
73
+ var _ref;
74
+ return ownsListingHere(firestore, {
75
+ uid: String((_ref = viewer == null ? void 0 : viewer.uid) != null ? _ref : ''),
76
+ publisherOrgId: listing.profileId,
77
+ targetOrgId: viewer == null ? void 0 : viewer.orgId
78
+ });
74
79
  }
75
80
  /**
76
81
  * A starter listing this viewer may pick, with whether they publish it, or
@@ -114,9 +119,25 @@ const gone = (error = 'Unknown starter')=>({
114
119
  listingId
115
120
  });
116
121
  }
117
- /** A summary with `owned` set where the listing has a price, and left out where it has none. */ const withOwned = (summary, owned)=>owned === undefined ? summary : _extends({}, summary, {
118
- owned
122
+ /**
123
+ * Why this viewer's Buy is off, for a priced starter they do not own: they
124
+ * belong to the publisher, and the sale refuses that (AGL-4047). `undefined`
125
+ * for anyone who can buy it, and for a starter with nothing to buy.
126
+ */ async function buyBlockedReasonFor(firestore, listing, viewer, owned, authorName) {
127
+ var _ref;
128
+ if (owned !== false) return undefined;
129
+ const member = await memberOfPublisherElsewhere(firestore, {
130
+ uid: String((_ref = viewer == null ? void 0 : viewer.uid) != null ? _ref : ''),
131
+ publisherOrgId: listing.profileId,
132
+ targetOrgId: viewer == null ? void 0 : viewer.orgId
119
133
  });
134
+ return member ? publisherMemberBuyReason(authorName) : undefined;
135
+ }
136
+ /** A summary with what this viewer's answer says of buying it, left out where it says nothing. */ const withOwned = (summary, owned, buyBlockedReason)=>_extends({}, summary, owned === undefined ? {} : {
137
+ owned
138
+ }, buyBlockedReason ? {
139
+ buyBlockedReason
140
+ } : {});
120
141
  /**
121
142
  * What anyone may see: the search asks the public audience only, so who is
122
143
  * asking changes nothing about WHICH starters it lists. The viewer is read
@@ -160,14 +181,17 @@ const gone = (error = 'Unknown starter')=>({
160
181
  var _listing_profileId;
161
182
  return String((_listing_profileId = listing['profileId']) != null ? _listing_profileId : '');
162
183
  }));
163
- const owned = await Promise.all(ordered.map(({ id, listing })=>ownedBy(firestore, id, listing, viewer != null ? viewer : {
164
- orgId: null,
165
- uid: ''
166
- })));
167
- return ordered.map(({ id, listing }, index)=>{
184
+ const asker = viewer != null ? viewer : {
185
+ orgId: null,
186
+ uid: ''
187
+ };
188
+ const owned = await Promise.all(ordered.map(({ id, listing })=>ownedBy(firestore, id, listing, asker)));
189
+ const authorOf = (listing)=>{
168
190
  var _names_get, _listing_profileId;
169
- return withOwned(siteStarterSummaryOf(id, listing, (_names_get = names.get(String((_listing_profileId = listing['profileId']) != null ? _listing_profileId : ''))) != null ? _names_get : ''), owned[index]);
170
- });
191
+ return (_names_get = names.get(String((_listing_profileId = listing['profileId']) != null ? _listing_profileId : ''))) != null ? _names_get : '';
192
+ };
193
+ const blocked = await Promise.all(ordered.map(({ listing }, index)=>buyBlockedReasonFor(firestore, listing, asker, owned[index], authorOf(listing))));
194
+ return ordered.map(({ id, listing }, index)=>withOwned(siteStarterSummaryOf(id, listing, authorOf(listing)), owned[index], blocked[index]));
171
195
  }
172
196
  /**
173
197
  * One starter as a search would list it for this viewer (AGL-4049), or
@@ -188,7 +212,9 @@ const gone = (error = 'Unknown starter')=>({
188
212
  profileId
189
213
  ]);
190
214
  const owned = await ownedBy(firestore, listingId, listing, viewer, publishes);
191
- return withOwned(siteStarterSummaryOf(listingId, listing, (_names_get = names.get(profileId)) != null ? _names_get : ''), owned);
215
+ const authorName = (_names_get = names.get(profileId)) != null ? _names_get : '';
216
+ const blocked = await buyBlockedReasonFor(firestore, listing, viewer, owned, authorName);
217
+ return withOwned(siteStarterSummaryOf(listingId, listing, authorName), owned, blocked);
192
218
  }
193
219
  async function resolve(choice, viewer) {
194
220
  var _ref, _listing_priceUsd, _ref1, _ref2, _choice_version;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/site-starter-source.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 matchSiteStarterFacets,\n SITE_STARTER_ARTIFACT_TYPE,\n siteStarterFacetsOf,\n siteStarterRefusal,\n type SiteStarterChoice,\n type SiteStarterFacets,\n type SiteStarterPayload,\n} from '@aglyn/aglyn/app-utils/site-starter'\nimport type {\n PluginSiteStarterSource,\n SiteStarterSummary,\n SiteStarterViewer,\n} from '@aglyn/aglyn/plugin-manager/plugin-site-starters'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n BROWSE_EVERYONE,\n LISTING_BROWSE_AUDIENCE,\n LISTING_BROWSE_TOKENS,\n} from '../model/listing-query'\nimport {\n isListingBrowsable,\n isListingDeleted,\n isPrivateListing,\n listingArtifactType,\n} from '../model/marketplace'\nimport {\n officialStartersFirst,\n SITE_STARTER_KNOWN_IDS,\n siteStarterSearchLimit,\n siteStarterSearchTokens,\n siteStarterSummaryOf,\n} from '../model/site-starter-listing'\nimport { canActAsPublisher, PUBLISHER_PROFILES } from './publisher-profile'\nimport { hasLivePurchase, requirePurchase } from './purchase-entitlement'\nimport { isPublisherSecurityLocked } from './sale-risk'\nimport { recordVersionMove } from './version-stats'\n\n/**\n * Where the console's \"Create site\" finds its starters (AGL-4049): the\n * marketplace's `siteStarter` listings, behind core's `core.site-starters`\n * seam (`plugin-manager/plugin-site-starters.ts`), registered from\n * `declarations.server.ts`.\n *\n * `resolve` is a content door like the install routes: it hands over a\n * listing's stored version, so it refuses what they refuse — a taken-down\n * listing, a publisher under a security lock, a private listing to anyone\n * but its own workspace, an unpaid priced one (`install-listing-gates.spec.ts`\n * holds the three). A starter still in review is its own workspace's to try,\n * as a plugin is.\n */\n\nconst LISTINGS = 'marketplaceListings'\n\nconst firestoreOf = () => firebaseAdmin.app().firestore()\n\ntype ListingDoc = Record<string, any>\n\n/** The answer a refused pick gives, in the seam's shape. */\ntype Refused = { error: string; status: 402 | 403 | 404; priceUsd?: number }\n\nconst gone = (error = 'Unknown starter'): Refused => ({ error, status: 404 })\n\n/**\n * Each publisher's name as a search card shows it, read once per publisher\n * in one batch: the profile's display name, else its handle.\n */\nasync function authorNames(\n firestore: FirebaseFirestore.Firestore,\n profileIds: readonly string[],\n): Promise<Map<string, string>> {\n const ids = [...new Set(profileIds.filter((id) => typeof id === 'string' && id))]\n const names = new Map<string, string>()\n if (!ids.length) return names\n const refs = ids.map((id) => firestore.collection(PUBLISHER_PROFILES).doc(id))\n const snapshots = await firestore.getAll(...refs)\n snapshots.forEach((snapshot, index) => {\n const displayName = snapshot.get('displayName')\n const handle = snapshot.get('handle')\n const name =\n typeof displayName === 'string' && displayName.trim()\n ? displayName.trim()\n : typeof handle === 'string'\n ? handle\n : ''\n names.set(ids[index], name)\n })\n return names\n}\n\n/**\n * Whether the viewer acts for the listing's publisher, in the publisher's\n * own workspace: it picks its own starter free, and only into itself — the\n * waiver is about the publisher's OWN sites (AGL-2484), so a person who\n * administers the publisher and creates a site elsewhere is a buyer like\n * anyone else there.\n */\nasync function viewerPublishes(\n firestore: FirebaseFirestore.Firestore,\n listing: ListingDoc,\n viewer: SiteStarterViewer,\n): Promise<boolean> {\n const publisherOrgId = String(listing.profileId ?? '')\n const viewerOrgId = String(viewer?.orgId ?? '')\n return viewerOrgId\n ? publisherOrgId !== '' &&\n viewerOrgId === publisherOrgId &&\n canActAsPublisher(firestore, String(viewer?.uid ?? ''), listing.profileId)\n : false\n}\n\n/**\n * A starter listing this viewer may pick, with whether they publish it, or\n * `null` when it is gone as far as they are concerned: deleted, taken down,\n * from a publisher under a security lock, not a starter, or private or not\n * yet listed and not theirs.\n */\nasync function pickableListing(\n firestore: FirebaseFirestore.Firestore,\n listingId: string,\n viewer: SiteStarterViewer,\n): Promise<{ listing: ListingDoc; publishes: boolean } | null> {\n const listing = (await firestore.collection(LISTINGS).doc(listingId).get()).data() as ListingDoc | undefined\n if (\n !listing ||\n listing.deletedAt ||\n // A staff takedown hands nothing over, to anyone (AGL-2290).\n listing.hiddenAt ||\n // Nor does a publisher under a security lock (AGL-3365).\n (await isPublisherSecurityLocked(firestore, listing.profileId)) ||\n listingArtifactType(listing) !== SITE_STARTER_ARTIFACT_TYPE\n ) {\n return null\n }\n const ownsListing = await viewerPublishes(firestore, listing, viewer)\n // Private means its own workspace only (AGL-968).\n if (isPrivateListing(listing) && !ownsListing) return null\n // A starter still in review, or rejected, is its publisher's to try.\n if (!isListingBrowsable(listing) && !ownsListing) return null\n return { listing, publishes: ownsListing }\n}\n\n/**\n * For a priced starter, whether the asking workspace may create from it\n * without paying (AGL-4049): its own, or bought and not refunded — the same\n * answer `resolve` gives, so a card never says \"owned\" for a pick the create\n * then asks payment for. `undefined` for a free starter, which needs no\n * purchase, and `false` for a viewer naming nobody.\n */\nasync function ownedBy(\n firestore: FirebaseFirestore.Firestore,\n listingId: string,\n listing: ListingDoc,\n viewer: SiteStarterViewer,\n publishes?: boolean,\n): Promise<boolean | undefined> {\n if (!(Number(listing.priceUsd ?? 0) > 0)) return undefined\n const buyerOrgId = String(viewer?.orgId ?? '')\n const buyerUid = String(viewer?.uid ?? '')\n if (!buyerOrgId && !buyerUid) return false\n if (publishes ?? (await viewerPublishes(firestore, listing, viewer))) return true\n return hasLivePurchase({ firestore, buyerUid, buyerOrgId, listingId })\n}\n\n/** A summary with `owned` set where the listing has a price, and left out where it has none. */\nconst withOwned = (summary: SiteStarterSummary, owned: boolean | undefined): SiteStarterSummary =>\n owned === undefined ? summary : { ...summary, owned }\n\n/**\n * What anyone may see: the search asks the public audience only, so who is\n * asking changes nothing about WHICH starters it lists. The viewer is read\n * for one thing, on the priced ones: whether their workspace already owns\n * it, so a card can say so rather than offer to sell it again.\n */\nasync function search(\n input: { word: string; filter: Partial<SiteStarterFacets>; limit: number },\n viewer?: SiteStarterViewer,\n): Promise<SiteStarterSummary[]> {\n const limit = siteStarterSearchLimit(input?.limit)\n if (!limit) return []\n const filter: Partial<SiteStarterFacets> = input?.filter ?? {}\n const firestore = firestoreOf()\n\n let query: FirebaseFirestore.Query = firestore\n .collection(LISTINGS)\n .where('artifactType', '==', SITE_STARTER_ARTIFACT_TYPE)\n if (filter.mode === 'base' || filter.mode === 'ai') {\n query = query.where('starterMode', '==', filter.mode)\n }\n if (typeof filter.kind === 'string' && filter.kind) {\n query = query.where('starterKind', '==', filter.kind)\n }\n if (typeof filter.base === 'string' && filter.base) {\n query = query.where('starterBase', '==', filter.base)\n }\n // One array clause answers who may see it, and the search folds into it:\n // `browseTokens` holds only `scope~word` pairs, never the bare scope.\n const tokens = siteStarterSearchTokens(input?.word)\n query = tokens.length\n ? query.where(LISTING_BROWSE_TOKENS, 'array-contains-any', tokens)\n : query.where(LISTING_BROWSE_AUDIENCE, 'array-contains', BROWSE_EVERYONE)\n // Twice the page, so what the facets the query cannot ask (fonts, pages)\n // filter out in memory still leaves a full one, most of the time.\n const snapshot = await query.orderBy('createdAt', 'desc').limit(limit * 2).get()\n\n const rows = snapshot.docs\n .map((doc) => ({ id: doc.id, listing: (doc.data() ?? {}) as ListingDoc }))\n // Defense in depth: the audience clause already admits only these.\n .filter(({ listing }) => !isListingDeleted(listing) && isListingBrowsable(listing))\n .filter(({ listing }) => matchSiteStarterFacets(siteStarterFacetsOf(listing), filter))\n const ordered = officialStartersFirst(\n rows.map((row) => ({ ...row, official: row.listing['official'] === true })),\n ).slice(0, limit)\n const names = await authorNames(\n firestore,\n ordered.map(({ listing }) => String(listing['profileId'] ?? '')),\n )\n const owned = await Promise.all(\n ordered.map(({ id, listing }) => ownedBy(firestore, id, listing, viewer ?? { orgId: null, uid: '' })),\n )\n return ordered.map(({ id, listing }, index) =>\n withOwned(siteStarterSummaryOf(id, listing, names.get(String(listing['profileId'] ?? '')) ?? ''), owned[index]),\n )\n}\n\n/**\n * One starter as a search would list it for this viewer (AGL-4049), or\n * `null` when it is not one they may pick: the same gates `resolve` holds,\n * answered from the listing, never from its stored pages. The create path\n * records and names a site's starter from this, and \"Choose your starter\"\n * restores a pick with it.\n */\nasync function describe(\n choice: SiteStarterChoice,\n viewer: SiteStarterViewer,\n): Promise<SiteStarterSummary | null> {\n const listingId = typeof choice?.listingId === 'string' ? choice.listingId : ''\n if (!listingId) return null\n const firestore = firestoreOf()\n const found = await pickableListing(firestore, listingId, viewer)\n if (!found) return null\n const { listing, publishes } = found\n const profileId = String(listing['profileId'] ?? '')\n const names = await authorNames(firestore, [profileId])\n const owned = await ownedBy(firestore, listingId, listing, viewer, publishes)\n return withOwned(siteStarterSummaryOf(listingId, listing, names.get(profileId) ?? ''), owned)\n}\n\nasync function resolve(\n choice: SiteStarterChoice,\n viewer: SiteStarterViewer,\n): Promise<SiteStarterPayload | Refused> {\n const listingId = typeof choice?.listingId === 'string' ? choice.listingId : ''\n if (!listingId) return gone()\n const firestore = firestoreOf()\n const listingRef = firestore.collection(LISTINGS).doc(listingId)\n const found = await pickableListing(firestore, listingId, viewer)\n if (!found) return gone()\n const { listing, publishes: ownsListing } = found\n const viewerOrgId = String(viewer?.orgId ?? '')\n\n const priceUsd = Number(listing.priceUsd ?? 0)\n const unpaid = await requirePurchase({\n firestore,\n buyerUid: String(viewer?.uid ?? ''),\n buyerOrgId: viewerOrgId,\n listingId,\n priceUsd,\n ownsListing,\n })\n if (unpaid) return { error: unpaid.error, status: 402, priceUsd: unpaid.priceUsd }\n\n const version = String(choice.version ?? listing.latestVersion ?? '')\n if (!version) return gone()\n const versionSnapshot = await listingRef.collection('versions').doc(version).get()\n const payload = versionSnapshot.get('starter') as SiteStarterPayload | undefined\n if (!payload || typeof payload !== 'object') return gone()\n // A starter that cannot start a site is gone, as far as a buyer is concerned.\n const refusal = siteStarterRefusal(payload, SITE_STARTER_KNOWN_IDS)\n if (refusal) return gone(refusal)\n return payload\n}\n\nasync function installed(\n choice: SiteStarterChoice,\n _viewer: SiteStarterViewer,\n site: { hostId: string },\n): Promise<void> {\n try {\n const listingId = typeof choice?.listingId === 'string' ? choice.listingId : ''\n if (!listingId) return\n const firestore = firestoreOf()\n const listingRef = firestore.collection(LISTINGS).doc(listingId)\n const latest = choice.version ?? (await listingRef.get()).get('latestVersion')\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: SITE_STARTER_ARTIFACT_TYPE,\n from: null,\n to: latest ?? null,\n })\n await listingRef.update({\n installCount: firebaseAdmin.firestore.FieldValue.increment(1),\n })\n } catch (error) {\n // Counting never fails the site it counts.\n console.error('site starter install count failed', {\n listingId: choice?.listingId,\n hostId: site?.hostId,\n error,\n })\n }\n}\n\n/** The marketplace's `core.site-starters` source (AGL-4049). */\nexport const marketplaceSiteStarterSource: Required<PluginSiteStarterSource> = {\n search,\n resolve,\n installed,\n describe,\n}\n"],"names":["matchSiteStarterFacets","SITE_STARTER_ARTIFACT_TYPE","siteStarterFacetsOf","siteStarterRefusal","firebaseAdmin","BROWSE_EVERYONE","LISTING_BROWSE_AUDIENCE","LISTING_BROWSE_TOKENS","isListingBrowsable","isListingDeleted","isPrivateListing","listingArtifactType","officialStartersFirst","SITE_STARTER_KNOWN_IDS","siteStarterSearchLimit","siteStarterSearchTokens","siteStarterSummaryOf","canActAsPublisher","PUBLISHER_PROFILES","hasLivePurchase","requirePurchase","isPublisherSecurityLocked","recordVersionMove","LISTINGS","firestoreOf","app","firestore","gone","error","status","authorNames","profileIds","ids","Set","filter","id","names","Map","length","refs","map","collection","doc","snapshots","getAll","forEach","snapshot","index","displayName","get","handle","name","trim","set","viewerPublishes","listing","viewer","publisherOrgId","String","profileId","viewerOrgId","orgId","uid","pickableListing","listingId","data","deletedAt","hiddenAt","ownsListing","publishes","ownedBy","Number","priceUsd","undefined","buyerOrgId","buyerUid","withOwned","summary","owned","search","input","limit","query","where","mode","kind","base","tokens","word","orderBy","rows","docs","ordered","row","official","slice","Promise","all","describe","choice","found","resolve","listingRef","unpaid","version","latestVersion","versionSnapshot","payload","refusal","installed","_viewer","site","latest","artifactType","from","to","update","installCount","FieldValue","increment","console","hostId","marketplaceSiteStarterSource"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/site-starter-source.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 matchSiteStarterFacets,\n SITE_STARTER_ARTIFACT_TYPE,\n siteStarterFacetsOf,\n siteStarterRefusal,\n type SiteStarterChoice,\n type SiteStarterFacets,\n type SiteStarterPayload,\n} from '@aglyn/aglyn/app-utils/site-starter'\nimport type {\n PluginSiteStarterSource,\n SiteStarterSummary,\n SiteStarterViewer,\n} from '@aglyn/aglyn/plugin-manager/plugin-site-starters'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n BROWSE_EVERYONE,\n LISTING_BROWSE_AUDIENCE,\n LISTING_BROWSE_TOKENS,\n} from '../model/listing-query'\nimport {\n isListingBrowsable,\n isListingDeleted,\n isPrivateListing,\n listingArtifactType,\n} from '../model/marketplace'\nimport {\n officialStartersFirst,\n SITE_STARTER_KNOWN_IDS,\n siteStarterSearchLimit,\n siteStarterSearchTokens,\n siteStarterSummaryOf,\n} from '../model/site-starter-listing'\nimport { publisherMemberBuyReason } from '../model/own-listing'\nimport { memberOfPublisherElsewhere, ownsListingHere } from './own-listing'\nimport { PUBLISHER_PROFILES } from './publisher-profile'\nimport { hasLivePurchase, requirePurchase } from './purchase-entitlement'\nimport { isPublisherSecurityLocked } from './sale-risk'\nimport { recordVersionMove } from './version-stats'\n\n/**\n * Where the console's \"Create site\" finds its starters (AGL-4049): the\n * marketplace's `siteStarter` listings, behind core's `core.site-starters`\n * seam (`plugin-manager/plugin-site-starters.ts`), registered from\n * `declarations.server.ts`.\n *\n * `resolve` is a content door like the install routes: it hands over a\n * listing's stored version, so it refuses what they refuse — a taken-down\n * listing, a publisher under a security lock, a private listing to anyone\n * but its own workspace, an unpaid priced one (`install-listing-gates.spec.ts`\n * holds the three). A starter still in review is its own workspace's to try,\n * as a plugin is.\n */\n\nconst LISTINGS = 'marketplaceListings'\n\nconst firestoreOf = () => firebaseAdmin.app().firestore()\n\ntype ListingDoc = Record<string, any>\n\n/** The answer a refused pick gives, in the seam's shape. */\ntype Refused = { error: string; status: 402 | 403 | 404; priceUsd?: number }\n\nconst gone = (error = 'Unknown starter'): Refused => ({ error, status: 404 })\n\n/**\n * Each publisher's name as a search card shows it, read once per publisher\n * in one batch: the profile's display name, else its handle.\n */\nasync function authorNames(\n firestore: FirebaseFirestore.Firestore,\n profileIds: readonly string[],\n): Promise<Map<string, string>> {\n const ids = [...new Set(profileIds.filter((id) => typeof id === 'string' && id))]\n const names = new Map<string, string>()\n if (!ids.length) return names\n const refs = ids.map((id) => firestore.collection(PUBLISHER_PROFILES).doc(id))\n const snapshots = await firestore.getAll(...refs)\n snapshots.forEach((snapshot, index) => {\n const displayName = snapshot.get('displayName')\n const handle = snapshot.get('handle')\n const name =\n typeof displayName === 'string' && displayName.trim()\n ? displayName.trim()\n : typeof handle === 'string'\n ? handle\n : ''\n names.set(ids[index], name)\n })\n return names\n}\n\n/**\n * Whether the viewer belongs to the listing's publisher, in the publisher's\n * own workspace: it picks its own starter free, and only into itself — the\n * waiver is about the publisher's OWN sites (AGL-2484), so a person who\n * administers the publisher and creates a site elsewhere is a buyer like\n * anyone else there. Membership, not a permission (AGL-4047): who published\n * the starter is a fact, not a capability a role grants.\n */\nasync function viewerPublishes(\n firestore: FirebaseFirestore.Firestore,\n listing: ListingDoc,\n viewer: SiteStarterViewer,\n): Promise<boolean> {\n return ownsListingHere(firestore, {\n uid: String(viewer?.uid ?? ''),\n publisherOrgId: listing.profileId,\n targetOrgId: viewer?.orgId,\n })\n}\n\n/**\n * A starter listing this viewer may pick, with whether they publish it, or\n * `null` when it is gone as far as they are concerned: deleted, taken down,\n * from a publisher under a security lock, not a starter, or private or not\n * yet listed and not theirs.\n */\nasync function pickableListing(\n firestore: FirebaseFirestore.Firestore,\n listingId: string,\n viewer: SiteStarterViewer,\n): Promise<{ listing: ListingDoc; publishes: boolean } | null> {\n const listing = (await firestore.collection(LISTINGS).doc(listingId).get()).data() as ListingDoc | undefined\n if (\n !listing ||\n listing.deletedAt ||\n // A staff takedown hands nothing over, to anyone (AGL-2290).\n listing.hiddenAt ||\n // Nor does a publisher under a security lock (AGL-3365).\n (await isPublisherSecurityLocked(firestore, listing.profileId)) ||\n listingArtifactType(listing) !== SITE_STARTER_ARTIFACT_TYPE\n ) {\n return null\n }\n const ownsListing = await viewerPublishes(firestore, listing, viewer)\n // Private means its own workspace only (AGL-968).\n if (isPrivateListing(listing) && !ownsListing) return null\n // A starter still in review, or rejected, is its publisher's to try.\n if (!isListingBrowsable(listing) && !ownsListing) return null\n return { listing, publishes: ownsListing }\n}\n\n/**\n * For a priced starter, whether the asking workspace may create from it\n * without paying (AGL-4049): its own, or bought and not refunded — the same\n * answer `resolve` gives, so a card never says \"owned\" for a pick the create\n * then asks payment for. `undefined` for a free starter, which needs no\n * purchase, and `false` for a viewer naming nobody.\n */\nasync function ownedBy(\n firestore: FirebaseFirestore.Firestore,\n listingId: string,\n listing: ListingDoc,\n viewer: SiteStarterViewer,\n publishes?: boolean,\n): Promise<boolean | undefined> {\n if (!(Number(listing.priceUsd ?? 0) > 0)) return undefined\n const buyerOrgId = String(viewer?.orgId ?? '')\n const buyerUid = String(viewer?.uid ?? '')\n if (!buyerOrgId && !buyerUid) return false\n if (publishes ?? (await viewerPublishes(firestore, listing, viewer))) return true\n return hasLivePurchase({ firestore, buyerUid, buyerOrgId, listingId })\n}\n\n/**\n * Why this viewer's Buy is off, for a priced starter they do not own: they\n * belong to the publisher, and the sale refuses that (AGL-4047). `undefined`\n * for anyone who can buy it, and for a starter with nothing to buy.\n */\nasync function buyBlockedReasonFor(\n firestore: FirebaseFirestore.Firestore,\n listing: ListingDoc,\n viewer: SiteStarterViewer,\n owned: boolean | undefined,\n authorName: string,\n): Promise<string | undefined> {\n if (owned !== false) return undefined\n const member = await memberOfPublisherElsewhere(firestore, {\n uid: String(viewer?.uid ?? ''),\n publisherOrgId: listing.profileId,\n targetOrgId: viewer?.orgId,\n })\n return member ? publisherMemberBuyReason(authorName) : undefined\n}\n\n/** A summary with what this viewer's answer says of buying it, left out where it says nothing. */\nconst withOwned = (\n summary: SiteStarterSummary,\n owned: boolean | undefined,\n buyBlockedReason?: string,\n): SiteStarterSummary => ({\n ...summary,\n ...(owned === undefined ? {} : { owned }),\n ...(buyBlockedReason ? { buyBlockedReason } : {}),\n})\n\n/**\n * What anyone may see: the search asks the public audience only, so who is\n * asking changes nothing about WHICH starters it lists. The viewer is read\n * for one thing, on the priced ones: whether their workspace already owns\n * it, so a card can say so rather than offer to sell it again.\n */\nasync function search(\n input: { word: string; filter: Partial<SiteStarterFacets>; limit: number },\n viewer?: SiteStarterViewer,\n): Promise<SiteStarterSummary[]> {\n const limit = siteStarterSearchLimit(input?.limit)\n if (!limit) return []\n const filter: Partial<SiteStarterFacets> = input?.filter ?? {}\n const firestore = firestoreOf()\n\n let query: FirebaseFirestore.Query = firestore\n .collection(LISTINGS)\n .where('artifactType', '==', SITE_STARTER_ARTIFACT_TYPE)\n if (filter.mode === 'base' || filter.mode === 'ai') {\n query = query.where('starterMode', '==', filter.mode)\n }\n if (typeof filter.kind === 'string' && filter.kind) {\n query = query.where('starterKind', '==', filter.kind)\n }\n if (typeof filter.base === 'string' && filter.base) {\n query = query.where('starterBase', '==', filter.base)\n }\n // One array clause answers who may see it, and the search folds into it:\n // `browseTokens` holds only `scope~word` pairs, never the bare scope.\n const tokens = siteStarterSearchTokens(input?.word)\n query = tokens.length\n ? query.where(LISTING_BROWSE_TOKENS, 'array-contains-any', tokens)\n : query.where(LISTING_BROWSE_AUDIENCE, 'array-contains', BROWSE_EVERYONE)\n // Twice the page, so what the facets the query cannot ask (fonts, pages)\n // filter out in memory still leaves a full one, most of the time.\n const snapshot = await query.orderBy('createdAt', 'desc').limit(limit * 2).get()\n\n const rows = snapshot.docs\n .map((doc) => ({ id: doc.id, listing: (doc.data() ?? {}) as ListingDoc }))\n // Defense in depth: the audience clause already admits only these.\n .filter(({ listing }) => !isListingDeleted(listing) && isListingBrowsable(listing))\n .filter(({ listing }) => matchSiteStarterFacets(siteStarterFacetsOf(listing), filter))\n const ordered = officialStartersFirst(\n rows.map((row) => ({ ...row, official: row.listing['official'] === true })),\n ).slice(0, limit)\n const names = await authorNames(\n firestore,\n ordered.map(({ listing }) => String(listing['profileId'] ?? '')),\n )\n const asker = viewer ?? { orgId: null, uid: '' }\n const owned = await Promise.all(ordered.map(({ id, listing }) => ownedBy(firestore, id, listing, asker)))\n const authorOf = (listing: ListingDoc) => names.get(String(listing['profileId'] ?? '')) ?? ''\n const blocked = await Promise.all(\n ordered.map(({ listing }, index) =>\n buyBlockedReasonFor(firestore, listing, asker, owned[index], authorOf(listing)),\n ),\n )\n return ordered.map(({ id, listing }, index) =>\n withOwned(siteStarterSummaryOf(id, listing, authorOf(listing)), owned[index], blocked[index]),\n )\n}\n\n/**\n * One starter as a search would list it for this viewer (AGL-4049), or\n * `null` when it is not one they may pick: the same gates `resolve` holds,\n * answered from the listing, never from its stored pages. The create path\n * records and names a site's starter from this, and \"Choose your starter\"\n * restores a pick with it.\n */\nasync function describe(\n choice: SiteStarterChoice,\n viewer: SiteStarterViewer,\n): Promise<SiteStarterSummary | null> {\n const listingId = typeof choice?.listingId === 'string' ? choice.listingId : ''\n if (!listingId) return null\n const firestore = firestoreOf()\n const found = await pickableListing(firestore, listingId, viewer)\n if (!found) return null\n const { listing, publishes } = found\n const profileId = String(listing['profileId'] ?? '')\n const names = await authorNames(firestore, [profileId])\n const owned = await ownedBy(firestore, listingId, listing, viewer, publishes)\n const authorName = names.get(profileId) ?? ''\n const blocked = await buyBlockedReasonFor(firestore, listing, viewer, owned, authorName)\n return withOwned(siteStarterSummaryOf(listingId, listing, authorName), owned, blocked)\n}\n\nasync function resolve(\n choice: SiteStarterChoice,\n viewer: SiteStarterViewer,\n): Promise<SiteStarterPayload | Refused> {\n const listingId = typeof choice?.listingId === 'string' ? choice.listingId : ''\n if (!listingId) return gone()\n const firestore = firestoreOf()\n const listingRef = firestore.collection(LISTINGS).doc(listingId)\n const found = await pickableListing(firestore, listingId, viewer)\n if (!found) return gone()\n const { listing, publishes: ownsListing } = found\n const viewerOrgId = String(viewer?.orgId ?? '')\n\n const priceUsd = Number(listing.priceUsd ?? 0)\n const unpaid = await requirePurchase({\n firestore,\n buyerUid: String(viewer?.uid ?? ''),\n buyerOrgId: viewerOrgId,\n listingId,\n priceUsd,\n ownsListing,\n })\n if (unpaid) return { error: unpaid.error, status: 402, priceUsd: unpaid.priceUsd }\n\n const version = String(choice.version ?? listing.latestVersion ?? '')\n if (!version) return gone()\n const versionSnapshot = await listingRef.collection('versions').doc(version).get()\n const payload = versionSnapshot.get('starter') as SiteStarterPayload | undefined\n if (!payload || typeof payload !== 'object') return gone()\n // A starter that cannot start a site is gone, as far as a buyer is concerned.\n const refusal = siteStarterRefusal(payload, SITE_STARTER_KNOWN_IDS)\n if (refusal) return gone(refusal)\n return payload\n}\n\nasync function installed(\n choice: SiteStarterChoice,\n _viewer: SiteStarterViewer,\n site: { hostId: string },\n): Promise<void> {\n try {\n const listingId = typeof choice?.listingId === 'string' ? choice.listingId : ''\n if (!listingId) return\n const firestore = firestoreOf()\n const listingRef = firestore.collection(LISTINGS).doc(listingId)\n const latest = choice.version ?? (await listingRef.get()).get('latestVersion')\n await recordVersionMove({\n firestore,\n listingRef,\n artifactType: SITE_STARTER_ARTIFACT_TYPE,\n from: null,\n to: latest ?? null,\n })\n await listingRef.update({\n installCount: firebaseAdmin.firestore.FieldValue.increment(1),\n })\n } catch (error) {\n // Counting never fails the site it counts.\n console.error('site starter install count failed', {\n listingId: choice?.listingId,\n hostId: site?.hostId,\n error,\n })\n }\n}\n\n/** The marketplace's `core.site-starters` source (AGL-4049). */\nexport const marketplaceSiteStarterSource: Required<PluginSiteStarterSource> = {\n search,\n resolve,\n installed,\n describe,\n}\n"],"names":["matchSiteStarterFacets","SITE_STARTER_ARTIFACT_TYPE","siteStarterFacetsOf","siteStarterRefusal","firebaseAdmin","BROWSE_EVERYONE","LISTING_BROWSE_AUDIENCE","LISTING_BROWSE_TOKENS","isListingBrowsable","isListingDeleted","isPrivateListing","listingArtifactType","officialStartersFirst","SITE_STARTER_KNOWN_IDS","siteStarterSearchLimit","siteStarterSearchTokens","siteStarterSummaryOf","publisherMemberBuyReason","memberOfPublisherElsewhere","ownsListingHere","PUBLISHER_PROFILES","hasLivePurchase","requirePurchase","isPublisherSecurityLocked","recordVersionMove","LISTINGS","firestoreOf","app","firestore","gone","error","status","authorNames","profileIds","ids","Set","filter","id","names","Map","length","refs","map","collection","doc","snapshots","getAll","forEach","snapshot","index","displayName","get","handle","name","trim","set","viewerPublishes","listing","viewer","uid","String","publisherOrgId","profileId","targetOrgId","orgId","pickableListing","listingId","data","deletedAt","hiddenAt","ownsListing","publishes","ownedBy","Number","priceUsd","undefined","buyerOrgId","buyerUid","buyBlockedReasonFor","owned","authorName","member","withOwned","summary","buyBlockedReason","search","input","limit","query","where","mode","kind","base","tokens","word","orderBy","rows","docs","ordered","row","official","slice","asker","Promise","all","authorOf","blocked","describe","choice","found","resolve","listingRef","viewerOrgId","unpaid","version","latestVersion","versionSnapshot","payload","refusal","installed","_viewer","site","latest","artifactType","from","to","update","installCount","FieldValue","increment","console","hostId","marketplaceSiteStarterSource"],"mappings":"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"}
@@ -30,7 +30,7 @@ import { listingArtifactType } from "../model/marketplace.js";
30
30
  import { readPublishedProps } from "../model/marketplace-props.js";
31
31
  import { artifactTypeOwnerOrRefusal, marketplaceInstallStamp } from "./artifact-owner.js";
32
32
  import { recordInstallProvenance } from "./provenance.js";
33
- import { canActAsPublisher } from "./publisher-profile.js";
33
+ import { ownsListingHere } from "./own-listing.js";
34
34
  import { requirePurchase } from "./purchase-entitlement.js";
35
35
  import { recordVersionMove } from "./version-stats.js";
36
36
  import { isPublisherSecurityLocked } from "./sale-risk.js";
@@ -111,8 +111,11 @@ export const updateArtifactHandler = async (req, res)=>{
111
111
  // version fresh — kept pulling every future paid release through here.
112
112
  //
113
113
  // Deliberately not a regression for anyone legitimate: a live purchase,
114
- // a free listing, or the publisher's own listing all pass, and nobody
115
- // without one of those could have had an installed copy to update.
114
+ // a free listing, or the publisher's own listing on the publisher's own
115
+ // site all pass, and nobody without one of those could have had an
116
+ // installed copy to update. A publisher's member taking a new version on
117
+ // ANOTHER workspace's site needs that workspace's licence, as installing
118
+ // it there does (AGL-2484, AGL-4047).
116
119
  // The `priceUsd > 0` test is out here rather than left to the helper so
117
120
  // a free listing — most of them — does not pay for a publisher lookup.
118
121
  const priceUsd = Number((_listing_priceUsd = listing.priceUsd) != null ? _listing_priceUsd : 0);
@@ -129,7 +132,11 @@ export const updateArtifactHandler = async (req, res)=>{
129
132
  buyerOrgId: (_membership_orgId = membership.orgId) != null ? _membership_orgId : '',
130
133
  listingId,
131
134
  priceUsd,
132
- ownsListing: await canActAsPublisher(firestore, decoded.uid, listing.profileId)
135
+ ownsListing: await ownsListingHere(firestore, {
136
+ uid: decoded.uid,
137
+ publisherOrgId: listing.profileId,
138
+ targetOrgId: membership.orgId
139
+ })
133
140
  });
134
141
  if (unpaid) return res.status(402).json(unpaid);
135
142
  }