@aglyn/plugins-marketplace 1.0.0-beta.201 → 1.0.0-beta.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +14 -14
  2. package/src/lib/model/publisher-risk.d.ts +52 -0
  3. package/src/lib/model/publisher-risk.js +74 -0
  4. package/src/lib/model/publisher-risk.js.map +1 -0
  5. package/src/lib/server/billing-webhook.js +72 -4
  6. package/src/lib/server/billing-webhook.js.map +1 -1
  7. package/src/lib/server/checkout.js +18 -1
  8. package/src/lib/server/checkout.js.map +1 -1
  9. package/src/lib/server/connect.js +15 -1
  10. package/src/lib/server/connect.js.map +1 -1
  11. package/src/lib/server/install-dataset-schema.js +3 -1
  12. package/src/lib/server/install-dataset-schema.js.map +1 -1
  13. package/src/lib/server/install-email-starter.js +3 -1
  14. package/src/lib/server/install-email-starter.js.map +1 -1
  15. package/src/lib/server/install-email-template.js +3 -1
  16. package/src/lib/server/install-email-template.js.map +1 -1
  17. package/src/lib/server/install-layout.js +3 -1
  18. package/src/lib/server/install-layout.js.map +1 -1
  19. package/src/lib/server/install-plugin.js +3 -1
  20. package/src/lib/server/install-plugin.js.map +1 -1
  21. package/src/lib/server/install-template.js +3 -1
  22. package/src/lib/server/install-template.js.map +1 -1
  23. package/src/lib/server/install-theme.js +3 -1
  24. package/src/lib/server/install-theme.js.map +1 -1
  25. package/src/lib/server/install.js +3 -1
  26. package/src/lib/server/install.js.map +1 -1
  27. package/src/lib/server/listing-screen.d.ts +83 -0
  28. package/src/lib/server/listing-screen.js +244 -0
  29. package/src/lib/server/listing-screen.js.map +1 -0
  30. package/src/lib/server/publish-dataset-schema.js +12 -0
  31. package/src/lib/server/publish-dataset-schema.js.map +1 -1
  32. package/src/lib/server/publish-email-starter.js +15 -1
  33. package/src/lib/server/publish-email-starter.js.map +1 -1
  34. package/src/lib/server/publish-email-template.js +15 -1
  35. package/src/lib/server/publish-email-template.js.map +1 -1
  36. package/src/lib/server/publish-layout.js +15 -1
  37. package/src/lib/server/publish-layout.js.map +1 -1
  38. package/src/lib/server/publish-plugin.js +41 -3
  39. package/src/lib/server/publish-plugin.js.map +1 -1
  40. package/src/lib/server/publish-template.js +15 -1
  41. package/src/lib/server/publish-template.js.map +1 -1
  42. package/src/lib/server/publish-theme.js +14 -1
  43. package/src/lib/server/publish-theme.js.map +1 -1
  44. package/src/lib/server/publish.js +15 -1
  45. package/src/lib/server/publish.js.map +1 -1
  46. package/src/lib/server/publisher-profile-save.js +36 -0
  47. package/src/lib/server/publisher-profile-save.js.map +1 -1
  48. package/src/lib/server/publisher-review-email.js +15 -5
  49. package/src/lib/server/publisher-review-email.js.map +1 -1
  50. package/src/lib/server/reviews.d.ts +8 -0
  51. package/src/lib/server/reviews.js +43 -3
  52. package/src/lib/server/reviews.js.map +1 -1
  53. package/src/lib/server/sale-risk.d.ts +105 -0
  54. package/src/lib/server/sale-risk.js +315 -0
  55. package/src/lib/server/sale-risk.js.map +1 -0
  56. package/src/lib/server/update-artifact.js +3 -1
  57. package/src/lib/server/update-artifact.js.map +1 -1
@@ -0,0 +1,315 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*==========================================
18
+ * A MARKETPLACE SALE'S FRAUD SHAPE (AGL-3365).
19
+ *
20
+ * Two questions the card network cannot answer, because both sides of the
21
+ * sale are ours:
22
+ *
23
+ * 1. **Is the publisher paying itself?** A publisher that buys its own
24
+ * listing with stolen cards turns them into a payout. Checkout refuses a
25
+ * buyer who is a member of the publishing workspace; a second workspace
26
+ * that shares a member with it, or ONE card buying from one publisher for
27
+ * several workspaces, is the same actor one step removed.
28
+ * 2. **Is the publisher brand new?** See `model/publisher-risk.ts`: its
29
+ * payouts are held, and a large sale files a signal while the share can
30
+ * still be reversed.
31
+ *
32
+ * What it does is what the seller-pattern ledger does: an urgent row in the
33
+ * abuse queue, staff told once. NOTHING IS REFUNDED, REVERSED OR PAUSED
34
+ * HERE — staff decide, with the young tier's payout delay holding the money
35
+ * in reach while they do.
36
+ *
37
+ * Never throws: the sale it reads has already been recorded.
38
+ *=========================================*/ import { createHash } from "crypto";
39
+ import { ABUSE_REPORT_COLLECTION } from "@aglyn/aglyn/app-utils/abuse-report";
40
+ import { normalizeOrgLockdown } from "@aglyn/aglyn/app-utils/lockdown";
41
+ import { firebaseAdmin, notifyOrgAdmins } from "@aglyn/tenant-data-admin";
42
+ import { orgAgeDays } from "@aglyn/tenant-data-admin/server/org-age";
43
+ import { describeSaleRiskSignals, isYoungPublisher, publisherPayoutDelayDays, YOUNG_PUBLISHER_REVIEW_SALE_CENTS } from "../model/publisher-risk.js";
44
+ /** Members read per workspace when looking for a shared one. */ const MAX_MEMBERS_COMPARED = 200;
45
+ /** Earlier purchases read when looking for the same card. */ const MAX_CARD_MATCHES = 20;
46
+ /**
47
+ * Whether `uid` is ANY member of `orgId` — not only a manager, which is all
48
+ * `canActAsPublisher` asks. A publishing workspace's editor buying its
49
+ * listing is the publisher buying it.
50
+ */ export async function isOrgMember(firestore, orgId, uid) {
51
+ if (!orgId || !uid) return false;
52
+ const member = await firestore.collection('orgs').doc(orgId).collection('members').doc(uid).get();
53
+ return member.exists;
54
+ }
55
+ /**
56
+ * Whether the publishing workspace is under a SECURITY lock (AGL-3365) — the
57
+ * install and update doors hand nothing from it over. Security only: a
58
+ * workspace locked over its own unpaid bill has not made its listings
59
+ * dangerous, and a buyer who paid for one keeps installing it. (Checkout
60
+ * refuses a sale to a workspace under ANY lock; that is money moving to it.)
61
+ *
62
+ * The shared normalizer reads the reason, so a lock written before reason
63
+ * codes existed reads as `manual`, not security. One read of the org.
64
+ */ export async function isPublisherSecurityLocked(firestore, publisherOrgId) {
65
+ var _normalizeOrgLockdown;
66
+ const orgId = typeof publisherOrgId === 'string' ? publisherOrgId : '';
67
+ if (!orgId) return false;
68
+ const org = (await firestore.collection('orgs').doc(orgId).get()).data();
69
+ return ((_normalizeOrgLockdown = normalizeOrgLockdown(org)) == null ? void 0 : _normalizeOrgLockdown.reason) === 'security';
70
+ }
71
+ async function memberUids(firestore, orgId) {
72
+ const snapshot = await firestore.collection('orgs').doc(orgId).collection('members').limit(MAX_MEMBERS_COMPARED).get();
73
+ return snapshot.docs.map((doc)=>doc.id);
74
+ }
75
+ async function stripeRequest(path, stripeKey, params) {
76
+ const response = await fetch(`https://api.stripe.com/v1/${path}`, _extends({
77
+ method: params ? 'POST' : 'GET',
78
+ headers: _extends({
79
+ Authorization: `Bearer ${stripeKey}`
80
+ }, params ? {
81
+ 'Content-Type': 'application/x-www-form-urlencoded'
82
+ } : {})
83
+ }, params ? {
84
+ body: params.toString()
85
+ } : {}));
86
+ const body = await response.json().catch(()=>null);
87
+ return {
88
+ ok: response.ok,
89
+ body
90
+ };
91
+ }
92
+ /**
93
+ * Put a publisher's connected account on the payout schedule its age calls
94
+ * for (`publisherPayoutDelayDays`), and note it on the profile.
95
+ *
96
+ * Writes to Stripe only when the answer CHANGES what this code last set: a
97
+ * young publisher is moved to the delay once, and moved back to the standard
98
+ * schedule once when it ages out. An account this code never delayed is
99
+ * never written — a schedule staff set by hand in the Dashboard is theirs.
100
+ *
101
+ * Never throws: onboarding and a sale must both go on if Stripe says no.
102
+ */ export async function applyPublisherPayoutPolicy(input) {
103
+ try {
104
+ var _profile_get, _input_nowMs;
105
+ if (!input.stripeKey || !input.publisherOrgId) return 'skipped';
106
+ const profileRef = input.firestore.collection('publisherProfiles').doc(input.publisherOrgId);
107
+ const profile = await profileRef.get();
108
+ const accountId = String((_profile_get = profile.get('stripeAccountId')) != null ? _profile_get : '');
109
+ if (!accountId) return 'skipped';
110
+ const desired = publisherPayoutDelayDays(input.ageDays);
111
+ const stored = profile.get('payoutDelayDays');
112
+ if (stored === desired) return 'current';
113
+ // Never delayed by this code and not young: leave the account alone.
114
+ if (stored === undefined && desired === null) return 'current';
115
+ const result = await stripeRequest(`accounts/${encodeURIComponent(accountId)}`, input.stripeKey, new URLSearchParams({
116
+ 'settings[payouts][schedule][delay_days]': desired === null ? 'minimum' : String(desired)
117
+ }));
118
+ if (!result.ok) {
119
+ var _result_body_error, _result_body;
120
+ console.error('[marketplace] the payout schedule could not be set', {
121
+ publisherOrgId: input.publisherOrgId,
122
+ error: (_result_body = result.body) == null ? void 0 : (_result_body_error = _result_body.error) == null ? void 0 : _result_body_error.message
123
+ });
124
+ return 'failed';
125
+ }
126
+ await profileRef.set({
127
+ payoutDelayDays: desired,
128
+ payoutPolicyAtMs: (_input_nowMs = input.nowMs) != null ? _input_nowMs : Date.now()
129
+ }, {
130
+ merge: true
131
+ });
132
+ await notifyPublisherPayoutSchedule(input.publisherOrgId, desired !== null);
133
+ return 'applied';
134
+ } catch (error) {
135
+ console.error('[marketplace] the payout schedule could not be set', error);
136
+ return 'failed';
137
+ }
138
+ }
139
+ /** The card that paid a payment intent, as Stripe fingerprints it, or null. */ async function cardFingerprintOf(paymentIntentId, stripeKey) {
140
+ var _result_body_latest_charge_payment_method_details_card, _result_body_latest_charge_payment_method_details, _result_body_latest_charge, _result_body;
141
+ const result = await stripeRequest(`payment_intents/${encodeURIComponent(paymentIntentId)}?expand[]=latest_charge`, stripeKey);
142
+ const fingerprint = (_result_body = result.body) == null ? void 0 : (_result_body_latest_charge = _result_body.latest_charge) == null ? void 0 : (_result_body_latest_charge_payment_method_details = _result_body_latest_charge.payment_method_details) == null ? void 0 : (_result_body_latest_charge_payment_method_details_card = _result_body_latest_charge_payment_method_details.card) == null ? void 0 : _result_body_latest_charge_payment_method_details_card.fingerprint;
143
+ return result.ok && typeof fingerprint === 'string' && fingerprint ? fingerprint : null;
144
+ }
145
+ /** Tell staff a sale was flagged (AGL-3365), once per row. Never throws. */ export async function notifyStaffOfSaleRisk(notify, flagged) {
146
+ await notify({
147
+ type: 'system.abuseReportUrgent',
148
+ title: 'Marketplace sale flagged — possible self-dealing or card fraud',
149
+ body: `${describeSaleRiskSignals(flagged.signals).join(' ')} Nothing has been refunded or ` + `paused. Review the sale and the publisher. Reference ${flagged.reference}.`,
150
+ link: '/admin/abuse-reports'
151
+ }).catch(()=>undefined);
152
+ }
153
+ /**
154
+ * Tell the publisher's owners and admins a sale is under review (AGL-3365).
155
+ * The outcome and the way forward only — never which shape flagged it, which
156
+ * would teach the actor it describes how to avoid it. Never throws.
157
+ */ export async function notifyPublisherSaleUnderReview(publisherOrgId, flagged) {
158
+ await notifyOrgAdmins(publisherOrgId, {
159
+ type: 'marketplace.review',
160
+ title: 'A marketplace sale is under review',
161
+ body: 'One of your recent marketplace sales is being reviewed by our team, and its payout may ' + 'take longer than usual. You do not need to do anything. To ask about it, contact ' + `support with reference ${flagged.reference}.`,
162
+ link: '/manage/marketplace'
163
+ }).catch(()=>undefined);
164
+ }
165
+ /**
166
+ * Tell the publisher's owners and admins their payout timing changed
167
+ * (AGL-3365) — when a hold starts, and when it ends. Never the rule that set
168
+ * it. Never throws.
169
+ */ export async function notifyPublisherPayoutSchedule(publisherOrgId, held) {
170
+ await notifyOrgAdmins(publisherOrgId, {
171
+ type: 'marketplace.review',
172
+ title: held ? 'Your marketplace payouts are on an extended schedule' : 'Your marketplace payouts are back on the standard schedule',
173
+ body: held ? 'Payouts to new publishers are held for a period before they reach your bank. Your ' + 'sales are recorded as usual and pay out automatically when the period ends. To ask ' + 'about it, contact support.' : 'Your marketplace sales now pay out on your payout account’s standard schedule.',
174
+ link: '/manage/marketplace'
175
+ }).catch(()=>undefined);
176
+ }
177
+ /** The sale-risk row's id: one per sale. */ export function saleRiskReviewId(sessionId) {
178
+ return createHash('sha256').update(`marketplace-sale-risk:${sessionId}`).digest('hex').slice(0, 40);
179
+ }
180
+ /**
181
+ * Read one recorded sale for the fraud shapes above, hold a young
182
+ * publisher's payouts, and file a staff row when anything is found.
183
+ */ export async function screenMarketplaceSale(input, deps) {
184
+ var _deps_nowMs;
185
+ const nowMs = (_deps_nowMs = deps.nowMs) != null ? _deps_nowMs : Date.now();
186
+ const signals = [];
187
+ try {
188
+ var _deps_firestore, _data;
189
+ const firestore = (_deps_firestore = deps.firestore) != null ? _deps_firestore : firebaseAdmin.app().firestore();
190
+ const sellerOrg = (_data = (await firestore.collection('orgs').doc(input.sellerOrgId).get()).data()) != null ? _data : {};
191
+ const ageDays = orgAgeDays(sellerOrg.createdAt, nowMs);
192
+ // 1. A young publisher: hold its payouts; a large sale is a signal.
193
+ await applyPublisherPayoutPolicy({
194
+ firestore,
195
+ publisherOrgId: input.sellerOrgId,
196
+ ageDays,
197
+ stripeKey: deps.stripeKey,
198
+ nowMs
199
+ });
200
+ if (isYoungPublisher(ageDays) && input.amountCents >= YOUNG_PUBLISHER_REVIEW_SALE_CENTS) {
201
+ signals.push({
202
+ code: 'young-publisher-large-sale',
203
+ ageDays: ageDays,
204
+ amountCents: input.amountCents
205
+ });
206
+ }
207
+ // 2. A member in both workspaces — the buyer themself included.
208
+ const sellerMembers = new Set(await memberUids(firestore, input.sellerOrgId));
209
+ const buyerSide = new Set([
210
+ input.buyerUid,
211
+ ...input.buyerOrgId && input.buyerOrgId !== input.sellerOrgId ? await memberUids(firestore, input.buyerOrgId) : []
212
+ ]);
213
+ const shared = [
214
+ ...buyerSide
215
+ ].filter((uid)=>uid && sellerMembers.has(uid)).sort();
216
+ if (shared.length || input.buyerOrgId && input.buyerOrgId === input.sellerOrgId) {
217
+ signals.push({
218
+ code: 'shared-member',
219
+ uids: shared
220
+ });
221
+ }
222
+ // 3. One card, several buyer workspaces, one publisher.
223
+ if (deps.stripeKey && input.paymentIntentId) {
224
+ const fingerprint = await cardFingerprintOf(input.paymentIntentId, deps.stripeKey);
225
+ if (fingerprint) {
226
+ await input.purchaseRef.set({
227
+ cardFingerprint: fingerprint
228
+ }, {
229
+ merge: true
230
+ });
231
+ const matches = await firestore.collection('marketplacePurchases').where('sellerOrgId', '==', input.sellerOrgId).where('cardFingerprint', '==', fingerprint).limit(MAX_CARD_MATCHES).get();
232
+ const others = [
233
+ ...new Set(matches.docs.map((doc)=>{
234
+ var _doc_get;
235
+ return String((_doc_get = doc.get('buyerOrgId')) != null ? _doc_get : '');
236
+ }).filter((orgId)=>{
237
+ var _input_buyerOrgId;
238
+ return orgId && orgId !== ((_input_buyerOrgId = input.buyerOrgId) != null ? _input_buyerOrgId : '');
239
+ }))
240
+ ].sort();
241
+ if (others.length) {
242
+ signals.push({
243
+ code: 'card-reused-across-buyers',
244
+ otherBuyerOrgIds: others
245
+ });
246
+ }
247
+ }
248
+ }
249
+ if (!signals.length) return {
250
+ signals,
251
+ filed: null
252
+ };
253
+ const reviewId = saleRiskReviewId(input.sessionId);
254
+ const reference = `MR-${reviewId.slice(0, 10).toUpperCase()}`;
255
+ const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
256
+ const first = !(await ref.get()).exists;
257
+ await ref.set(_extends({
258
+ reference,
259
+ category: 'phishing',
260
+ severity: 'urgent',
261
+ source: 'marketplace-sale-risk',
262
+ url: null,
263
+ reportedHostname: null,
264
+ hostId: null,
265
+ orgId: input.sellerOrgId,
266
+ details: [
267
+ `A marketplace sale (checkout ${input.sessionId}, payment ${input.paymentIntentId || 'unknown'}) ` + `paid publisher workspace ${input.sellerOrgId} $${(input.amountCents / 100).toFixed(2)} ` + `from buyer ${input.buyerUid}${input.buyerOrgId ? ` for workspace ${input.buyerOrgId}` : ''}` + (input.livemode ? '' : ' — TEST MODE') + '.',
268
+ ...describeSaleRiskSignals(signals),
269
+ 'A publisher buying its own listings with stolen cards, to cash them out as payouts, leaves this shape.',
270
+ 'Nothing has been refunded, reversed or paused. If it is the publisher, lock its workspace and ' + "refund the sale (the refund takes the publisher's share back), then close this row with what you did."
271
+ ].join('\n').slice(0, 5000),
272
+ reporterEmail: null,
273
+ reporterName: null,
274
+ dmca: null,
275
+ reportCount: 1,
276
+ saleRisk: {
277
+ sessionId: input.sessionId,
278
+ paymentIntentId: input.paymentIntentId || null,
279
+ sellerOrgId: input.sellerOrgId,
280
+ buyerOrgId: input.buyerOrgId,
281
+ buyerUid: input.buyerUid,
282
+ amountCents: input.amountCents,
283
+ signals,
284
+ livemode: input.livemode
285
+ },
286
+ updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
287
+ }, first ? {
288
+ status: 'open',
289
+ createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
290
+ } : {}), {
291
+ merge: true
292
+ });
293
+ if (first) {
294
+ await notifyStaffOfSaleRisk(deps.notify, {
295
+ reference,
296
+ signals
297
+ });
298
+ await notifyPublisherSaleUnderReview(input.sellerOrgId, {
299
+ reference
300
+ });
301
+ }
302
+ return {
303
+ signals,
304
+ filed: reference
305
+ };
306
+ } catch (error) {
307
+ console.error('[marketplace] a sale could not be screened', error);
308
+ return {
309
+ signals,
310
+ filed: null
311
+ };
312
+ }
313
+ }
314
+
315
+ //# sourceMappingURL=sale-risk.js.map
@@ -0,0 +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 { normalizeOrgLockdown } from '@aglyn/aglyn/app-utils/lockdown'\nimport { firebaseAdmin, notifyOrgAdmins } 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_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 normalizeOrgLockdown(org as Parameters<typeof normalizeOrgLockdown>[0])?.reason === 'security'\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' | '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 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/** Tell staff a sale was flagged (AGL-3365), once per row. Never throws. */\nexport async function notifyStaffOfSaleRisk(\n notify: (payload: {\n type: 'system.abuseReportUrgent'\n title: string\n body: string\n link: string\n }) => Promise<void>,\n flagged: { reference: string; signals: readonly SaleRiskSignal[] },\n): Promise<void> {\n await notify({\n type: 'system.abuseReportUrgent',\n title: 'Marketplace sale flagged — possible self-dealing or card fraud',\n body:\n `${describeSaleRiskSignals(flagged.signals).join(' ')} Nothing has been refunded or ` +\n `paused. Review the sale and the publisher. Reference ${flagged.reference}.`,\n link: '/admin/abuse-reports',\n }).catch(() => undefined)\n}\n\n/**\n * Tell the publisher's owners and admins a sale is under review (AGL-3365).\n * The outcome and the way forward only — never which shape flagged it, which\n * would teach the actor it describes how to avoid it. Never throws.\n */\nexport async function notifyPublisherSaleUnderReview(\n publisherOrgId: string,\n flagged: { reference: string },\n): Promise<void> {\n await notifyOrgAdmins(publisherOrgId, {\n type: 'marketplace.review',\n title: 'A marketplace sale is under review',\n body:\n 'One of your recent marketplace sales is being reviewed by our team, and its payout may ' +\n 'take longer than usual. You do not need to do anything. To ask about it, contact ' +\n `support with reference ${flagged.reference}.`,\n link: '/manage/marketplace',\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, and when it ends. Never the rule that set\n * it. Never throws.\n */\nexport async function notifyPublisherPayoutSchedule(\n publisherOrgId: string,\n held: boolean,\n): Promise<void> {\n await notifyOrgAdmins(publisherOrgId, {\n type: 'marketplace.review',\n title: held\n ? 'Your marketplace payouts are on an extended schedule'\n : 'Your marketplace payouts are back on the standard schedule',\n body: held\n ? 'Payouts to new publishers are held for a period before they reach your bank. Your ' +\n 'sales are recorded as usual and pay out automatically when the period ends. To ask ' +\n 'about it, contact support.'\n : 'Your marketplace sales now pay out on your payout account’s standard schedule.',\n link: '/manage/marketplace',\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 notify: (payload: {\n type: 'system.abuseReportUrgent'\n title: string\n body: string\n link: string\n }) => Promise<void>\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 notifyStaffOfSaleRisk(deps.notify, { reference, signals })\n await notifyPublisherSaleUnderReview(input.sellerOrgId, { reference })\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","normalizeOrgLockdown","firebaseAdmin","notifyOrgAdmins","orgAgeDays","describeSaleRiskSignals","isYoungPublisher","publisherPayoutDelayDays","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","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","notifyStaffOfSaleRisk","notify","flagged","type","title","signals","join","reference","link","notifyPublisherSaleUnderReview","held","saleRiskReviewId","sessionId","update","digest","slice","screenMarketplaceSale","deps","app","sellerOrg","sellerOrgId","createdAt","amountCents","push","code","sellerMembers","Set","buyerSide","buyerUid","buyerOrgId","shared","filter","has","sort","length","uids","purchaseRef","cardFingerprint","matches","where","others","otherBuyerOrgIds","filed","reviewId","toUpperCase","ref","first","category","severity","source","url","reportedHostname","hostId","details","toFixed","livemode","reporterEmail","reporterName","dmca","reportCount","saleRisk","updatedAt","FieldValue","serverTimestamp","status"],"mappings":"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"}
@@ -29,6 +29,7 @@ import { recordInstallProvenance } from "./provenance.js";
29
29
  import { canActAsPublisher } from "./publisher-profile.js";
30
30
  import { requirePurchase } from "./purchase-entitlement.js";
31
31
  import { recordVersionMove } from "./version-stats.js";
32
+ import { isPublisherSecurityLocked } from "./sale-risk.js";
32
33
  const trimChanges = (changes)=>// The dialog lists changes; a 400-node tree that changed wholesale would
33
34
  // otherwise ship its entire content twice through a JSON response.
34
35
  changes.slice(0, 200);
@@ -93,7 +94,8 @@ export const updateArtifactHandler = async (req, res)=>{
93
94
  //
94
95
  // No owner exemption, matching `install-plugin.ts`: a takedown is a
95
96
  // moderation decision about the artifact, not about who is asking.
96
- listing.hiddenAt) {
97
+ listing.hiddenAt || // A publisher under a SECURITY lock hands nothing over (AGL-3365).
98
+ await isPublisherSecurityLocked(firestore, listing.profileId)) {
97
99
  return res.status(404).json({
98
100
  error: 'Unknown listing'
99
101
  });