@aglyn/plugins-marketplace 1.0.0-beta.199 → 1.0.0-beta.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/marketplace-browse.component.js +104 -169
- package/src/lib/components/marketplace-browse.component.js.map +1 -1
- package/src/lib/components/marketplace-reports-page.component.js +9 -0
- package/src/lib/components/marketplace-reports-page.component.js.map +1 -1
- package/src/lib/components/org-licences-panel.component.js +212 -170
- package/src/lib/components/org-licences-panel.component.js.map +1 -1
- package/src/lib/components/org-seller-panel.component.js +23 -5
- package/src/lib/components/org-seller-panel.component.js.map +1 -1
- package/src/lib/components/plugin-reviews-queue.component.d.ts +9 -0
- package/src/lib/components/plugin-reviews-queue.component.js +190 -114
- package/src/lib/components/plugin-reviews-queue.component.js.map +1 -1
- package/src/lib/hooks/use-listing-name-lookup.d.ts +34 -0
- package/src/lib/hooks/use-listing-name-lookup.js +131 -0
- package/src/lib/hooks/use-listing-name-lookup.js.map +1 -0
- package/src/lib/model/listing-query.d.ts +140 -0
- package/src/lib/model/listing-query.js +393 -0
- package/src/lib/model/listing-query.js.map +1 -0
- package/src/lib/model/marketplace.d.ts +11 -0
- package/src/lib/model/marketplace.js.map +1 -1
- package/src/lib/server/admin-reports.js +30 -12
- package/src/lib/server/admin-reports.js.map +1 -1
- package/src/lib/server/admin-reviews.js +180 -128
- package/src/lib/server/admin-reviews.js.map +1 -1
- package/src/lib/server/admin-trust.js +2 -1
- package/src/lib/server/admin-trust.js.map +1 -1
- package/src/lib/server/billing-webhook.js +13 -1
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/checkout.js +3 -1
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/install-email-starter.js +9 -2
- package/src/lib/server/install-email-starter.js.map +1 -1
- package/src/lib/server/install-layout.js +17 -5
- package/src/lib/server/install-layout.js.map +1 -1
- package/src/lib/server/install-template.js +15 -5
- package/src/lib/server/install-template.js.map +1 -1
- package/src/lib/server/install.js +10 -2
- package/src/lib/server/install.js.map +1 -1
- package/src/lib/server/listing-query-fields.d.ts +38 -0
- package/src/lib/server/listing-query-fields.js +50 -0
- package/src/lib/server/listing-query-fields.js.map +1 -0
- package/src/lib/server/publish-dataset-schema.js +6 -2
- package/src/lib/server/publish-dataset-schema.js.map +1 -1
- package/src/lib/server/publish-email-starter.js +6 -2
- package/src/lib/server/publish-email-starter.js.map +1 -1
- package/src/lib/server/publish-email-template.js +6 -2
- package/src/lib/server/publish-email-template.js.map +1 -1
- package/src/lib/server/publish-layout.js +6 -2
- package/src/lib/server/publish-layout.js.map +1 -1
- package/src/lib/server/publish-plugin.js +75 -7
- package/src/lib/server/publish-plugin.js.map +1 -1
- package/src/lib/server/publish-template.js +6 -2
- package/src/lib/server/publish-template.js.map +1 -1
- package/src/lib/server/publish-theme.js +6 -2
- package/src/lib/server/publish-theme.js.map +1 -1
- package/src/lib/server/publish.js +6 -2
- package/src/lib/server/publish.js.map +1 -1
- package/src/lib/server/update-artifact.js +18 -4
- package/src/lib/server/update-artifact.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/admin-reports.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 { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\n\n/**\n * The staff end of the marketplace report button (AGL-2310), served at\n * `/api/marketplace/admin/reports` for this plugin's Marketplace reports\n * staff page.\n *\n * It was `apps/console/app/api/admin/marketplace-reports/route.ts` until\n * AGL-3080. The console held it because the staff area had no way for a\n * plugin to own a page; it does (`staffPages`, AGL-2939), and the queue reads\n * a collection only this plugin writes.\n *\n * `./report.ts` has written\n * `marketplaceReports` since the button shipped — `reason`, `reporterUid`,\n * `listingName`, `publisherOrgId`, `targetType`, and `status: 'open'` — and\n * NOTHING read it. No `collectionGroup`, no admin surface, no cron. The\n * Firestore rules grant staff read, but rules are not a reader: every report a\n * user filed was stored, acknowledged, and unreachable, and nothing ever moved\n * one off `open` because no queue displayed it.\n *\n * The contrast that made it an omission rather than a design decision:\n * `abuseReports` has the identical shape AND a real staff queue. This mirrors\n * that queue's contract deliberately — list, transition, an `adminAudit` row\n * per transition — rather than inventing a second vocabulary for the same act.\n *\n * ## The reporter's identity is a super-tier fact\n *\n * `reporterUid` follows the same rule the abuse queue applies to a reporter's\n * email: `support` triages without it. A uid is pseudonymous rather than\n * personal, but it joins to a real account in one hop, and the read-only tier\n * has no reason to make that hop. `reporterKnown` stays visible to everyone,\n * because the useful distinction for triage is a report with somebody behind\n * it versus one without — not who.\n */\n\n/**\n * The reports collection, named once.\n *\n * The audit row's `target` is built from this, so the path staff search for\n * and the document the route actually mutates cannot drift apart.\n */\nconst REPORTS_COLLECTION = 'marketplaceReports'\n\nconst REPORT_ID = /^[a-f0-9]{40}$/\n\nconst asString = (value: unknown): string | null =>\n typeof value === 'string' && value ? value : null\n\nconst asMillis = (value: unknown): number | null => {\n const candidate = value as { toMillis?: () => number }\n if (typeof candidate?.toMillis === 'function') {\n const millis = candidate.toMillis()\n return Number.isFinite(millis) ? millis : null\n }\n return null\n}\n\n/**\n * Staff below `super` triage without the reporter's account (AGL-1964's rule,\n * applied here for the same reason).\n *\n * A MISSING claim resolves to `support`, never to `super`. Failing open would\n * hand identity to any staff token minted before the RBAC claim existed.\n */\nconst identityVisible = (decoded: Record<string, unknown>): boolean =>\n String(decoded['staffRole'] ?? 'support') === 'super'\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, body, headers: rawHeaders } =\n await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n const firestore = firebaseAdmin.app().firestore()\n const collection = firestore.collection(REPORTS_COLLECTION)\n const showIdentity = identityVisible(decoded as Record<string, unknown>)\n\n if (method === 'GET') {\n /**\n * Ordered by `updatedAt`, filtered by status in JS.\n *\n * A `where('status','==',…)` beside this ordering is a composite index,\n * and a composite deploys by hand outside the git pipeline — the queue\n * would have shipped ahead of its index and thrown FAILED_PRECONDITION\n * on the one surface that exists to be read. The window is small enough\n * that filtering it here costs nothing.\n */\n const requested = String(query?.['status'] ?? '')\n const snapshot = await collection\n .orderBy('updatedAt', 'desc')\n .limit(200)\n .get()\n // Reports written before `updatedAt` existed still have to appear —\n // an ordering that silently drops rows is how a queue reads as empty.\n .catch(() => collection.limit(200).get())\n\n const reports = snapshot.docs\n .map((doc) => {\n const data = doc.data() as Record<string, unknown>\n return {\n id: doc.id,\n status: asString(data['status']) ?? 'open',\n targetType: asString(data['targetType']) ?? 'listing',\n listingId: asString(data['listingId']),\n listingName: asString(data['listingName']),\n publisherOrgId: asString(data['publisherOrgId']),\n reviewUid: showIdentity ? asString(data['reviewUid']) : null,\n // The report IS the reason. Everything else on the row is context\n // for it, which is why it is never redacted by tier.\n reason: asString(data['reason']),\n // Told apart from a redacted one, so `support` can still see that\n // there IS somebody to follow up with (AGL-1964's distinction).\n reporterKnown: Boolean(asString(data['reporterUid'])),\n reporterUid: showIdentity ? asString(data['reporterUid']) : null,\n resolution: asString(data['resolution']),\n resolvedByEmail: asString(data['resolvedByEmail']),\n createdAtMs: asMillis(data['createdAt']),\n updatedAtMs: asMillis(data['updatedAt']),\n }\n })\n .filter((report) => !requested || report.status === requested)\n\n return Response.json(\n {\n reports,\n actorRole: showIdentity ? 'super' : 'support',\n identityVisible: showIdentity,\n statuses: Aglyn.ABUSE_REPORT_STATUSES,\n },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n }\n\n if (method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n\n const id = String((body as any)?.['id'] ?? '').trim()\n if (!REPORT_ID.test(id)) {\n return Response.json({ error: 'id is missing or malformed' }, { status: 400 })\n }\n const status = String((body as any)?.['status'] ?? '')\n if (!Aglyn.isAbuseReportStatus(status)) {\n return Response.json(\n {\n error: `status must be one of ${Aglyn.ABUSE_REPORT_STATUSES.join(', ')}`,\n },\n { status: 400 },\n )\n }\n // Required to CLOSE, optional otherwise — the abuse queue's rule, for the\n // abuse queue's reason: \"actioned\" with no note is the row that, months\n // later, nobody can act on or defend.\n const resolution = String((body as any)?.['resolution'] ?? '')\n .trim()\n .slice(0, 2000)\n const closing = status === 'actioned' || status === 'dismissed'\n if (closing && !resolution) {\n return Response.json(\n {\n error:\n 'Say what you did — a closed report with no note is unreadable ' +\n 'later',\n },\n { status: 400 },\n )\n }\n\n const ref = collection.doc(id)\n const before = await ref.get()\n if (!before.exists) {\n return Response.json({ error: 'No such report' }, { status: 404 })\n }\n const beforeStatus = asString(before.get('status')) ?? 'open'\n // Present only when a REVIEW was reported; a reported listing has none.\n const reportedReviewUid = asString(before.get('reviewUid'))\n const FieldValue = firebaseAdmin.firestore.FieldValue\n\n await ref.set(\n {\n status,\n resolution: resolution || null,\n resolvedByUid: closing ? decoded.uid : null,\n resolvedByEmail: closing && decoded.email ? String(decoded.email) : null,\n resolvedAt: closing ? FieldValue.serverTimestamp() : null,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n /**\n * The audit row carries BOTH statuses.\n *\n * A row saying only \"someone set this to dismissed\" cannot answer the\n * question anyone actually asks later — what was it before, and who\n * decided. `before`/`after` here match the abuse queue's shape so the two\n * queues read the same way in one audit log.\n */\n /*\n * `target` AND `scope`, the same two fields the abuse queue writes.\n *\n * An entry nobody can retrieve is indistinguishable from one that was\n * never written. `target` is what every reader of this collection looks\n * up by — the audit log page filters on it and exports it, and the org\n * page matches it by substring — so a row without one is recorded and\n * unreachable by the thing it acted on. It is the document this route\n * mutates: the report. The listing stays beside it, because the row has\n * to still mean something a year later.\n *\n * `targetType`/`targetId` are gone rather than kept alongside. Nothing\n * read either, and `targetType` here said `marketplaceReport` while\n * `targetType` on the report document says `listing` or `review` — the\n * same key naming two different things one hop apart.\n */\n await firestore.collection('adminAudit').add({\n action: 'marketplace-report-status',\n actorUid: decoded.uid,\n actorEmail: decoded.email ? String(decoded.email) : null,\n scope: 'marketplaceReport',\n target: `${REPORTS_COLLECTION}/${id}`,\n /*\n * The subject is the REVIEW'S AUTHOR, and only when a review is what\n * was reported.\n *\n * A report against a listing lands on its publisher, which is an\n * ORGANIZATION (`publisherOrgId`) and not a person — so there is no uid\n * to name, and resolving one from the org's roster would file a\n * moderation decision under whichever member happened to answer the\n * lookup. A review document is keyed by its author's uid, so that case\n * has a real person, and it is the same subject a takedown of the same\n * review records.\n *\n * The REPORTER is deliberately not it. `reporterUid` is withheld from\n * staff below `super` by this route, and `adminAudit` is readable by\n * any staff role — writing it here would hand every support-tier\n * session the identity the queue above refuses them.\n */\n ...(reportedReviewUid ? { subjectUid: reportedReviewUid } : {}),\n listingId: asString(before.get('listingId')),\n before: { status: beforeStatus },\n after: { status, resolution: resolution || null },\n at: FieldValue.serverTimestamp(),\n })\n\n return Response.json(\n { ok: true, id, status, previousStatus: beforeStatus },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error(error)\n return Response.json({ error: 'Marketplace reports failed' }, { status: 500 })\n }\n}\n\nexport { handler as marketplaceAdminReports }\n"],"names":["pluginRequestFromWeb","Aglyn","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","invalidIdTokenResponse","REPORTS_COLLECTION","REPORT_ID","asString","value","asMillis","candidate","toMillis","millis","Number","isFinite","identityVisible","decoded","String","handler","request","headers","method","query","body","rawHeaders","authorization","idToken","startsWith","slice","length","undefined","Response","json","error","status","app","auth","verifyIdToken","email_verified","firestore","collection","showIdentity","requested","snapshot","orderBy","limit","get","catch","reports","docs","map","doc","data","id","targetType","listingId","listingName","publisherOrgId","reviewUid","reason","reporterKnown","Boolean","reporterUid","resolution","resolvedByEmail","createdAtMs","updatedAtMs","filter","report","actorRole","statuses","ABUSE_REPORT_STATUSES","trim","test","isAbuseReportStatus","join","closing","ref","before","exists","beforeStatus","reportedReviewUid","FieldValue","set","resolvedByUid","uid","email","resolvedAt","serverTimestamp","updatedAt","merge","add","action","actorUid","actorEmail","scope","target","subjectUid","after","at","ok","previousStatus","unauthenticated","console","marketplaceAdminReports"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketplace/src/lib/server/admin-reports.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 { pluginRequestFromWeb } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n} from '@aglyn/tenant-data-admin'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { applyListQuery } from '@aglyn/tenant-data-admin/server/list-query'\nimport { nameSearchNormalizers } from '@aglyn/aglyn/app-utils/name-search'\nimport { planListQuery } from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { MARKETPLACE_REPORTS_QUERY } from '../model/listing-query'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/**\n * The staff end of the marketplace report button (AGL-2310), served at\n * `/api/marketplace/admin/reports` for this plugin's Marketplace reports\n * staff page.\n *\n * It was `apps/console/app/api/admin/marketplace-reports/route.ts` until\n * AGL-3080. The console held it because the staff area had no way for a\n * plugin to own a page; it does (`staffPages`, AGL-2939), and the queue reads\n * a collection only this plugin writes.\n *\n * `./report.ts` has written\n * `marketplaceReports` since the button shipped — `reason`, `reporterUid`,\n * `listingName`, `publisherOrgId`, `targetType`, and `status: 'open'` — and\n * NOTHING read it. No `collectionGroup`, no admin surface, no cron. The\n * Firestore rules grant staff read, but rules are not a reader: every report a\n * user filed was stored, acknowledged, and unreachable, and nothing ever moved\n * one off `open` because no queue displayed it.\n *\n * The contrast that made it an omission rather than a design decision:\n * `abuseReports` has the identical shape AND a real staff queue. This mirrors\n * that queue's contract deliberately — list, transition, an `adminAudit` row\n * per transition — rather than inventing a second vocabulary for the same act.\n *\n * ## The reporter's identity is a super-tier fact\n *\n * `reporterUid` follows the same rule the abuse queue applies to a reporter's\n * email: `support` triages without it. A uid is pseudonymous rather than\n * personal, but it joins to a real account in one hop, and the read-only tier\n * has no reason to make that hop. `reporterKnown` stays visible to everyone,\n * because the useful distinction for triage is a report with somebody behind\n * it versus one without — not who.\n */\n\n/**\n * The reports collection, named once.\n *\n * The audit row's `target` is built from this, so the path staff search for\n * and the document the route actually mutates cannot drift apart.\n */\nconst REPORTS_COLLECTION = 'marketplaceReports'\n\nconst REPORT_ID = /^[a-f0-9]{40}$/\n\n/** How many reports one read of the queue returns. */\nconst REPORTS_WINDOW = 200\n\nconst asString = (value: unknown): string | null =>\n typeof value === 'string' && value ? value : null\n\nconst asMillis = (value: unknown): number | null => {\n const candidate = value as { toMillis?: () => number }\n if (typeof candidate?.toMillis === 'function') {\n const millis = candidate.toMillis()\n return Number.isFinite(millis) ? millis : null\n }\n return null\n}\n\n/**\n * Staff below `super` triage without the reporter's account (AGL-1964's rule,\n * applied here for the same reason).\n *\n * A MISSING claim resolves to `support`, never to `super`. Failing open would\n * hand identity to any staff token minted before the RBAC claim existed.\n */\nconst identityVisible = (decoded: Record<string, unknown>): boolean =>\n String(decoded['staffRole'] ?? 'support') === 'super'\n\nasync function handler(request: Request): Promise<Response> {\n const { method, query, body, headers: rawHeaders } =\n await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n if (!decoded['staff']) {\n return Response.json({ error: 'Staff only' }, { status: 403 })\n }\n const firestore = firebaseAdmin.app().firestore()\n const collection = firestore.collection(REPORTS_COLLECTION)\n const showIdentity = identityVisible(decoded as Record<string, unknown>)\n\n if (method === 'GET') {\n /**\n * Status on the QUERY, newest activity first (AGL-3321).\n *\n * It was matched in JS over the newest two hundred reports, so an open\n * report older than two hundred newer ones of any status was not in the\n * Open queue at all — the one list whose job is not to lose a report.\n * `MARKETPLACE_REPORTS_QUERY` puts it on the query, served by the\n * `(status, updatedAt DESC)` composite its spec pins. `report.ts` has\n * written `status` and `updatedAt` on every report since the button\n * shipped, so the order drops none. A queue longer than its window says\n * so (`more`) instead of reading as complete.\n */\n const requested = String(query?.['status'] ?? '')\n const plan = planListQuery(\n MARKETPLACE_REPORTS_QUERY,\n {\n clauses: requested\n ? [{ field: 'status', op: 'equals', value: requested }]\n : [],\n },\n nameSearchNormalizers,\n )\n const snapshot = await applyListQuery(collection, plan)\n .limit(REPORTS_WINDOW + 1)\n .get()\n const more = snapshot.docs.length > REPORTS_WINDOW\n\n const reports = snapshot.docs\n .slice(0, REPORTS_WINDOW)\n .map((doc) => {\n const data = doc.data() as Record<string, unknown>\n return {\n id: doc.id,\n status: asString(data['status']) ?? 'open',\n targetType: asString(data['targetType']) ?? 'listing',\n listingId: asString(data['listingId']),\n listingName: asString(data['listingName']),\n publisherOrgId: asString(data['publisherOrgId']),\n reviewUid: showIdentity ? asString(data['reviewUid']) : null,\n // The report IS the reason. Everything else on the row is context\n // for it, which is why it is never redacted by tier.\n reason: asString(data['reason']),\n // Told apart from a redacted one, so `support` can still see that\n // there IS somebody to follow up with (AGL-1964's distinction).\n reporterKnown: Boolean(asString(data['reporterUid'])),\n reporterUid: showIdentity ? asString(data['reporterUid']) : null,\n resolution: asString(data['resolution']),\n resolvedByEmail: asString(data['resolvedByEmail']),\n createdAtMs: asMillis(data['createdAt']),\n updatedAtMs: asMillis(data['updatedAt']),\n }\n })\n\n return Response.json(\n {\n reports,\n more,\n actorRole: showIdentity ? 'super' : 'support',\n identityVisible: showIdentity,\n statuses: Aglyn.ABUSE_REPORT_STATUSES,\n },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n }\n\n if (method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n\n const id = String((body as any)?.['id'] ?? '').trim()\n if (!REPORT_ID.test(id)) {\n return Response.json({ error: 'id is missing or malformed' }, { status: 400 })\n }\n const status = String((body as any)?.['status'] ?? '')\n if (!Aglyn.isAbuseReportStatus(status)) {\n return Response.json(\n {\n error: `status must be one of ${Aglyn.ABUSE_REPORT_STATUSES.join(', ')}`,\n },\n { status: 400 },\n )\n }\n // Required to CLOSE, optional otherwise — the abuse queue's rule, for the\n // abuse queue's reason: \"actioned\" with no note is the row that, months\n // later, nobody can act on or defend.\n const resolution = String((body as any)?.['resolution'] ?? '')\n .trim()\n .slice(0, 2000)\n const closing = status === 'actioned' || status === 'dismissed'\n if (closing && !resolution) {\n return Response.json(\n {\n error:\n 'Say what you did — a closed report with no note is unreadable ' +\n 'later',\n },\n { status: 400 },\n )\n }\n\n const ref = collection.doc(id)\n const before = await ref.get()\n if (!before.exists) {\n return Response.json({ error: 'No such report' }, { status: 404 })\n }\n const beforeStatus = asString(before.get('status')) ?? 'open'\n // Present only when a REVIEW was reported; a reported listing has none.\n const reportedReviewUid = asString(before.get('reviewUid'))\n const FieldValue = firebaseAdmin.firestore.FieldValue\n\n await ref.set(\n {\n status,\n resolution: resolution || null,\n resolvedByUid: closing ? decoded.uid : null,\n resolvedByEmail: closing && decoded.email ? String(decoded.email) : null,\n resolvedAt: closing ? FieldValue.serverTimestamp() : null,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n /**\n * The audit row carries BOTH statuses.\n *\n * A row saying only \"someone set this to dismissed\" cannot answer the\n * question anyone actually asks later — what was it before, and who\n * decided. `before`/`after` here match the abuse queue's shape so the two\n * queues read the same way in one audit log.\n */\n /*\n * `target` AND `scope`, the same two fields the abuse queue writes.\n *\n * An entry nobody can retrieve is indistinguishable from one that was\n * never written. `target` is what every reader of this collection looks\n * up by — the audit log page filters on it and exports it, and the org\n * page matches it by substring — so a row without one is recorded and\n * unreachable by the thing it acted on. It is the document this route\n * mutates: the report. The listing stays beside it, because the row has\n * to still mean something a year later.\n *\n * `targetType`/`targetId` are gone rather than kept alongside. Nothing\n * read either, and `targetType` here said `marketplaceReport` while\n * `targetType` on the report document says `listing` or `review` — the\n * same key naming two different things one hop apart.\n */\n await addAdminAudit(firestore, {\n action: 'marketplace-report-status',\n actorUid: decoded.uid,\n actorEmail: decoded.email ? String(decoded.email) : null,\n scope: 'marketplaceReport',\n target: `${REPORTS_COLLECTION}/${id}`,\n /*\n * The subject is the REVIEW'S AUTHOR, and only when a review is what\n * was reported.\n *\n * A report against a listing lands on its publisher, which is an\n * ORGANIZATION (`publisherOrgId`) and not a person — so there is no uid\n * to name, and resolving one from the org's roster would file a\n * moderation decision under whichever member happened to answer the\n * lookup. A review document is keyed by its author's uid, so that case\n * has a real person, and it is the same subject a takedown of the same\n * review records.\n *\n * The REPORTER is deliberately not it. `reporterUid` is withheld from\n * staff below `super` by this route, and `adminAudit` is readable by\n * any staff role — writing it here would hand every support-tier\n * session the identity the queue above refuses them.\n */\n ...(reportedReviewUid ? { subjectUid: reportedReviewUid } : {}),\n listingId: asString(before.get('listingId')),\n before: { status: beforeStatus },\n after: { status, resolution: resolution || null },\n at: FieldValue.serverTimestamp(),\n })\n\n return Response.json(\n { ok: true, id, status, previousStatus: beforeStatus },\n { status: 200, headers: { 'Cache-Control': 'no-store' } },\n )\n } catch (error) {\n // An unverifiable credential is a 401, not a fault of ours\n // (AGL-1993). Null for anything else, so a real failure keeps its 500.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n console.error(error)\n return Response.json({ error: 'Marketplace reports failed' }, { status: 500 })\n }\n}\n\nexport { handler as marketplaceAdminReports }\n"],"names":["pluginRequestFromWeb","Aglyn","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","invalidIdTokenResponse","applyListQuery","nameSearchNormalizers","planListQuery","MARKETPLACE_REPORTS_QUERY","addAdminAudit","REPORTS_COLLECTION","REPORT_ID","REPORTS_WINDOW","asString","value","asMillis","candidate","toMillis","millis","Number","isFinite","identityVisible","decoded","String","handler","request","headers","method","query","body","rawHeaders","authorization","idToken","startsWith","slice","length","undefined","Response","json","error","status","app","auth","verifyIdToken","email_verified","firestore","collection","showIdentity","requested","plan","clauses","field","op","snapshot","limit","get","more","docs","reports","map","doc","data","id","targetType","listingId","listingName","publisherOrgId","reviewUid","reason","reporterKnown","Boolean","reporterUid","resolution","resolvedByEmail","createdAtMs","updatedAtMs","actorRole","statuses","ABUSE_REPORT_STATUSES","trim","test","isAbuseReportStatus","join","closing","ref","before","exists","beforeStatus","reportedReviewUid","FieldValue","set","resolvedByUid","uid","email","resolvedAt","serverTimestamp","updatedAt","merge","action","actorUid","actorEmail","scope","target","subjectUid","after","at","ok","previousStatus","unauthenticated","console","marketplaceAdminReports"],"mappings":"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"}
|
|
@@ -27,6 +27,11 @@ import { VERIFICATION_DECLINE_COOLDOWN_DAYS } from "../model/listing-verificatio
|
|
|
27
27
|
import { PUBLISHER_AGREEMENT_VERSION, publisherAgreementState } from "@aglyn/aglyn/app-utils/publisher-agreement";
|
|
28
28
|
import { FieldValue } from "firebase-admin/firestore";
|
|
29
29
|
import { notifyOrgAdmins } from "@aglyn/tenant-data-admin";
|
|
30
|
+
import { refreshListingQueryFields } from "./listing-query-fields.js";
|
|
31
|
+
import { nameSearchNormalizers } from "@aglyn/aglyn/app-utils/name-search";
|
|
32
|
+
import { planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
|
|
33
|
+
import { applyListQuery } from "@aglyn/tenant-data-admin/server/list-query";
|
|
34
|
+
import { REVIEW_QUEUE_QUERY, reviewQueueBase, reviewQueueStatusClauses } from "../model/listing-query.js";
|
|
30
35
|
/*
|
|
31
36
|
* The publisher fan-out lives beside the usage-alert one in `_lib` rather
|
|
32
37
|
* than here. Both are multi-recipient platform sends that have to read the
|
|
@@ -34,6 +39,7 @@ import { notifyOrgAdmins } from "@aglyn/tenant-data-admin";
|
|
|
34
39
|
* be reachable by a spec that does not drag the admin SDK in behind a route.
|
|
35
40
|
*/ import { emailPublisher } from "./publisher-review-email.js";
|
|
36
41
|
import { dropCachesForListing } from "./revoke-cache-drop.js";
|
|
42
|
+
import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
|
|
37
43
|
/**
|
|
38
44
|
* Marketplace review queue (AGL-432) — Strapi Market's two-phase review
|
|
39
45
|
* as staff tooling. GET returns plugin listings awaiting review
|
|
@@ -368,6 +374,166 @@ const ACTIONS = {
|
|
|
368
374
|
updatedAt: FieldValue.serverTimestamp()
|
|
369
375
|
});
|
|
370
376
|
}
|
|
377
|
+
/** Rows a queue section reads before it says there are more. */ const REVIEW_QUEUE_PAGE = 100;
|
|
378
|
+
/** The most a reviewer can page the queue out to. */ const REVIEW_QUEUE_MAX = 1000;
|
|
379
|
+
const queryParam = (value)=>{
|
|
380
|
+
var _value_;
|
|
381
|
+
return String(Array.isArray(value) ? (_value_ = value[0]) != null ? _value_ : '' : value != null ? value : '');
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* The staff review index (AGL-961), every section on its query (AGL-3321).
|
|
385
|
+
*
|
|
386
|
+
* Three sections, each its own query over live plugin listings:
|
|
387
|
+
*
|
|
388
|
+
* - Awaiting review: the newest bytes have no approval. Read from the
|
|
389
|
+
* listing's `latestVersionReviewState` summary, whose `pending` and
|
|
390
|
+
* `rejected` are exactly "the version's own `reviewState` is not
|
|
391
|
+
* `approved`" — `revoked` is the one summary value a version keeps its
|
|
392
|
+
* `approved` under, and it is not in `AWAITING_REVIEW_STATES`, so a killed
|
|
393
|
+
* version never re-enters the queue looking unread (AGL-1121).
|
|
394
|
+
* - Listed: `reviewStatus` listed or verified.
|
|
395
|
+
* - Verification requested: the publisher's standing ask (AGL-1217).
|
|
396
|
+
*
|
|
397
|
+
* The Status select and the search (`?status=`, `?q=`) are clauses on every
|
|
398
|
+
* section's query, planned once by `REVIEW_QUEUE_QUERY`; search reads the
|
|
399
|
+
* listing name's `nameTokens`. It used to be matched over one window of a
|
|
400
|
+
* hundred plugin listings, so a plugin past it was in no section at all.
|
|
401
|
+
* `?limit=` pages each section out; a section that holds more than it read
|
|
402
|
+
* says so rather than presenting a short list as the whole queue.
|
|
403
|
+
*/ async function reviewQueue(firestore, query) {
|
|
404
|
+
const clauses = reviewQueueStatusClauses(queryParam(query['status']));
|
|
405
|
+
const words = queryParam(query['q']).split(/\s+/).filter(Boolean);
|
|
406
|
+
const asked = Math.floor(Number(queryParam(query['limit'])));
|
|
407
|
+
const pageLimit = Number.isFinite(asked) && asked > 0 ? Math.min(asked, REVIEW_QUEUE_MAX) : REVIEW_QUEUE_PAGE;
|
|
408
|
+
const listings = firestore.collection('marketplaceListings');
|
|
409
|
+
let refused = [];
|
|
410
|
+
let notices = [];
|
|
411
|
+
const more = {
|
|
412
|
+
queue: false,
|
|
413
|
+
listed: false,
|
|
414
|
+
verification: false
|
|
415
|
+
};
|
|
416
|
+
const read = async (section)=>{
|
|
417
|
+
const base = reviewQueueBase(section, clauses);
|
|
418
|
+
// A Status the section cannot hold: empty by definition, never queried.
|
|
419
|
+
if (!base) return [];
|
|
420
|
+
const plan = planListQuery(REVIEW_QUEUE_QUERY, {
|
|
421
|
+
clauses,
|
|
422
|
+
search: words,
|
|
423
|
+
base
|
|
424
|
+
}, nameSearchNormalizers);
|
|
425
|
+
refused = plan.refused;
|
|
426
|
+
notices = plan.notices;
|
|
427
|
+
const snapshot = await applyListQuery(listings, plan).limit(pageLimit + 1).get();
|
|
428
|
+
more[section] = snapshot.docs.length > pageLimit;
|
|
429
|
+
return snapshot.docs.slice(0, pageLimit);
|
|
430
|
+
};
|
|
431
|
+
const [queueDocs, listedDocs, verificationDocs] = await Promise.all([
|
|
432
|
+
read('queue'),
|
|
433
|
+
read('listed'),
|
|
434
|
+
read('verification')
|
|
435
|
+
]);
|
|
436
|
+
// Rows only (AGL-961). This used to DOWNLOAD every queued bundle to re-run
|
|
437
|
+
// the verifier, so the index paid a Storage round trip per submission
|
|
438
|
+
// before it could paint. The verifier now runs on the detail page, for the
|
|
439
|
+
// one listing being read.
|
|
440
|
+
const reviewRow = async (doc)=>{
|
|
441
|
+
var _listing_latestVersion, _listing_displayName, _listing_description, _listing_license, _listing_categories, _listing_reviewStatus, _listing_priceUsd, _ref, _listing_verificationRequest;
|
|
442
|
+
const listing = doc.data();
|
|
443
|
+
const version = String((_listing_latestVersion = listing.latestVersion) != null ? _listing_latestVersion : '');
|
|
444
|
+
const latest = version ? await doc.ref.collection('pluginVersions').doc(version).get() : null;
|
|
445
|
+
return {
|
|
446
|
+
listingId: doc.id,
|
|
447
|
+
displayName: (_listing_displayName = listing.displayName) != null ? _listing_displayName : doc.id,
|
|
448
|
+
description: (_listing_description = listing.description) != null ? _listing_description : '',
|
|
449
|
+
license: (_listing_license = listing.license) != null ? _listing_license : '',
|
|
450
|
+
categories: (_listing_categories = listing.categories) != null ? _listing_categories : [],
|
|
451
|
+
profileId: listing.profileId,
|
|
452
|
+
reviewStatus: (_listing_reviewStatus = listing.reviewStatus) != null ? _listing_reviewStatus : 'submitted',
|
|
453
|
+
priceUsd: Number((_listing_priceUsd = listing.priceUsd) != null ? _listing_priceUsd : 0),
|
|
454
|
+
version,
|
|
455
|
+
hidden: Boolean(listing.hiddenAt),
|
|
456
|
+
private: listing.visibility === 'private',
|
|
457
|
+
latestReviewState: String((_ref = latest == null ? void 0 : latest.get('reviewState')) != null ? _ref : 'pending'),
|
|
458
|
+
grandfathered: Boolean(latest == null ? void 0 : latest.get('grandfathered')),
|
|
459
|
+
// The publisher's standing ask for the badge (AGL-1217).
|
|
460
|
+
verificationRequest: (_listing_verificationRequest = listing.verificationRequest) != null ? _listing_verificationRequest : null
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
const queue = await Promise.all(queueDocs.map(reviewRow));
|
|
464
|
+
// A THIRD section, deliberately not folded into `queue` (AGL-1217).
|
|
465
|
+
// `queue` means "these bytes have not been read"; a verification request
|
|
466
|
+
// means "this publisher asked us to vouch for who they are". A listing can
|
|
467
|
+
// sit in both at once, and merging them would hide one behind the other.
|
|
468
|
+
const verificationRequests = await Promise.all(verificationDocs.map(reviewRow));
|
|
469
|
+
// Listed/verified plugins with per-version trust (AGL-885): once a listing
|
|
470
|
+
// leaves the queue the Grant/Revoke realm-trust actions used to leave with
|
|
471
|
+
// it. This keeps every listed plugin's version trust state administrable.
|
|
472
|
+
const listed = await Promise.all(listedDocs.map(async (doc)=>{
|
|
473
|
+
var _listing_displayName, _listing_latestVersion, _listing_hiddenReason;
|
|
474
|
+
const listing = doc.data();
|
|
475
|
+
const versionsSnapshot = await doc.ref.collection('pluginVersions').orderBy('publishedAt', 'desc').limit(10).get();
|
|
476
|
+
const versions = versionsSnapshot.docs.map((versionDoc)=>{
|
|
477
|
+
var _versionDoc_get, _versionDoc_get1, _versionDoc_get2;
|
|
478
|
+
return {
|
|
479
|
+
version: String((_versionDoc_get = versionDoc.get('version')) != null ? _versionDoc_get : versionDoc.id),
|
|
480
|
+
trust: (_versionDoc_get1 = versionDoc.get('trust')) != null ? _versionDoc_get1 : null,
|
|
481
|
+
reviewState: String((_versionDoc_get2 = versionDoc.get('reviewState')) != null ? _versionDoc_get2 : 'pending')
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
return {
|
|
485
|
+
listingId: doc.id,
|
|
486
|
+
displayName: (_listing_displayName = listing.displayName) != null ? _listing_displayName : doc.id,
|
|
487
|
+
reviewStatus: listing.reviewStatus,
|
|
488
|
+
profileId: listing.profileId,
|
|
489
|
+
latestVersion: String((_listing_latestVersion = listing.latestVersion) != null ? _listing_latestVersion : ''),
|
|
490
|
+
// Takedown state (AGL-952), so a row shows the current verdict rather
|
|
491
|
+
// than sending a reviewer into the detail page to find out whether
|
|
492
|
+
// the plugin is stopped.
|
|
493
|
+
hidden: Boolean(listing.hiddenAt),
|
|
494
|
+
hiddenReason: String((_listing_hiddenReason = listing.hiddenReason) != null ? _listing_hiddenReason : ''),
|
|
495
|
+
private: listing.visibility === 'private',
|
|
496
|
+
pendingVersions: versions.filter((entry)=>entry.reviewState !== 'approved').length,
|
|
497
|
+
// Summarised for the row; the per-version controls live on the detail
|
|
498
|
+
// page (AGL-960).
|
|
499
|
+
realmVersions: versions.filter((entry)=>entry.trust === 'realm').length,
|
|
500
|
+
versionCount: versions.length,
|
|
501
|
+
versions
|
|
502
|
+
};
|
|
503
|
+
}));
|
|
504
|
+
// Publisher names for scanning (AGL-961) — a listing id tells a reviewer
|
|
505
|
+
// nothing. One batched read for every section.
|
|
506
|
+
const publisherIds = [
|
|
507
|
+
...new Set([
|
|
508
|
+
...queue,
|
|
509
|
+
...listed,
|
|
510
|
+
...verificationRequests
|
|
511
|
+
].map((entry)=>{
|
|
512
|
+
var _entry_profileId;
|
|
513
|
+
return String((_entry_profileId = entry.profileId) != null ? _entry_profileId : '');
|
|
514
|
+
}).filter(Boolean))
|
|
515
|
+
];
|
|
516
|
+
const publishers = {};
|
|
517
|
+
if (publisherIds.length) {
|
|
518
|
+
const docs = await firestore.getAll(...publisherIds.map((id)=>firestore.collection('orgs').doc(id)));
|
|
519
|
+
for (const doc of docs){
|
|
520
|
+
var _doc_get;
|
|
521
|
+
if (doc.exists) publishers[doc.id] = String((_doc_get = doc.get('name')) != null ? _doc_get : doc.id);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return Response.json({
|
|
525
|
+
queue,
|
|
526
|
+
listed,
|
|
527
|
+
publishers,
|
|
528
|
+
verificationRequests,
|
|
529
|
+
refused,
|
|
530
|
+
notices,
|
|
531
|
+
more,
|
|
532
|
+
limit: pageLimit
|
|
533
|
+
}, {
|
|
534
|
+
status: 200
|
|
535
|
+
});
|
|
536
|
+
}
|
|
371
537
|
async function handler(request) {
|
|
372
538
|
var _headers_authorization;
|
|
373
539
|
const { method, body, query, headers: rawHeaders } = await pluginRequestFromWeb(request);
|
|
@@ -401,128 +567,7 @@ async function handler(request) {
|
|
|
401
567
|
var _query_version;
|
|
402
568
|
return listingDetail(firestore, detailId, String((_query_version = query['version']) != null ? _query_version : ''));
|
|
403
569
|
}
|
|
404
|
-
|
|
405
|
-
// waiting on a reviewer (AGL-966). The queue used to ask only for
|
|
406
|
-
// `reviewStatus in ['submitted','in_review']`, which meant an UPDATE
|
|
407
|
-
// to an already-listed plugin appeared nowhere: the listing kept its
|
|
408
|
-
// status, so nobody was ever shown the new version. Reading each
|
|
409
|
-
// listing's latest version doc costs one read per listing and needs
|
|
410
|
-
// no new composite index.
|
|
411
|
-
const snapshot = await firestore.collection('marketplaceListings').where('type', '==', 'plugin').limit(100).get();
|
|
412
|
-
// Rows only (AGL-961). This used to DOWNLOAD every queued bundle to
|
|
413
|
-
// re-run the verifier, so the index paid a Storage round trip per
|
|
414
|
-
// submission before it could paint. The verifier now runs on the
|
|
415
|
-
// detail page, for the one listing being read.
|
|
416
|
-
const rows = await Promise.all(snapshot.docs.filter((doc)=>!doc.get('deletedAt')).map(async (doc)=>{
|
|
417
|
-
var _listing_latestVersion, _listing_displayName, _listing_description, _listing_license, _listing_categories, _listing_reviewStatus, _listing_priceUsd, _ref, _listing_verificationRequest;
|
|
418
|
-
const listing = doc.data();
|
|
419
|
-
const version = String((_listing_latestVersion = listing.latestVersion) != null ? _listing_latestVersion : '');
|
|
420
|
-
const latest = version ? await doc.ref.collection('pluginVersions').doc(version).get() : null;
|
|
421
|
-
return {
|
|
422
|
-
listingId: doc.id,
|
|
423
|
-
displayName: (_listing_displayName = listing.displayName) != null ? _listing_displayName : doc.id,
|
|
424
|
-
description: (_listing_description = listing.description) != null ? _listing_description : '',
|
|
425
|
-
license: (_listing_license = listing.license) != null ? _listing_license : '',
|
|
426
|
-
categories: (_listing_categories = listing.categories) != null ? _listing_categories : [],
|
|
427
|
-
profileId: listing.profileId,
|
|
428
|
-
reviewStatus: (_listing_reviewStatus = listing.reviewStatus) != null ? _listing_reviewStatus : 'submitted',
|
|
429
|
-
priceUsd: Number((_listing_priceUsd = listing.priceUsd) != null ? _listing_priceUsd : 0),
|
|
430
|
-
version,
|
|
431
|
-
hidden: Boolean(listing.hiddenAt),
|
|
432
|
-
private: listing.visibility === 'private',
|
|
433
|
-
latestReviewState: String((_ref = latest == null ? void 0 : latest.get('reviewState')) != null ? _ref : 'pending'),
|
|
434
|
-
grandfathered: Boolean(latest == null ? void 0 : latest.get('grandfathered')),
|
|
435
|
-
// The publisher's standing ask for the badge (AGL-1217).
|
|
436
|
-
verificationRequest: (_listing_verificationRequest = listing.verificationRequest) != null ? _listing_verificationRequest : null
|
|
437
|
-
};
|
|
438
|
-
}));
|
|
439
|
-
// Awaiting review = the newest bytes have no approval, whatever the
|
|
440
|
-
// listing says. That is what puts an update in front of staff.
|
|
441
|
-
const queue = rows.filter((row)=>row.latestReviewState !== 'approved');
|
|
442
|
-
// A THIRD bucket, deliberately not folded into `queue` (AGL-1217).
|
|
443
|
-
// `queue` means "these bytes have not been read"; a verification request
|
|
444
|
-
// means "this publisher asked us to vouch for who they are". They are
|
|
445
|
-
// different questions with different work attached, and a listing can
|
|
446
|
-
// sit in both at once — an update pending review from a publisher who
|
|
447
|
-
// has also asked for the badge. Merging them would hide one behind the
|
|
448
|
-
// other and make the count meaningless.
|
|
449
|
-
const verificationRequests = rows.filter((row)=>{
|
|
450
|
-
var _row_verificationRequest;
|
|
451
|
-
return ((_row_verificationRequest = row.verificationRequest) == null ? void 0 : _row_verificationRequest.state) === 'pending';
|
|
452
|
-
});
|
|
453
|
-
// Listed/verified plugins with per-version trust (AGL-885): once a
|
|
454
|
-
// listing leaves the queue the Grant/Revoke realm-trust actions used
|
|
455
|
-
// to leave with it — revoking a live plugin's trust required a
|
|
456
|
-
// hand-crafted API call. This block keeps every listed plugin's
|
|
457
|
-
// version trust state administrable.
|
|
458
|
-
const listedSnapshot = {
|
|
459
|
-
docs: snapshot.docs.filter((doc)=>{
|
|
460
|
-
var _doc_get;
|
|
461
|
-
return [
|
|
462
|
-
'listed',
|
|
463
|
-
'verified'
|
|
464
|
-
].includes(String((_doc_get = doc.get('reviewStatus')) != null ? _doc_get : ''));
|
|
465
|
-
})
|
|
466
|
-
};
|
|
467
|
-
const listed = await Promise.all(listedSnapshot.docs.map(async (doc)=>{
|
|
468
|
-
var _listing_displayName, _listing_latestVersion, _listing_hiddenReason;
|
|
469
|
-
const listing = doc.data();
|
|
470
|
-
const versionsSnapshot = await doc.ref.collection('pluginVersions').orderBy('publishedAt', 'desc').limit(10).get();
|
|
471
|
-
const versions = versionsSnapshot.docs.map((versionDoc)=>{
|
|
472
|
-
var _versionDoc_get, _versionDoc_get1, _versionDoc_get2;
|
|
473
|
-
return {
|
|
474
|
-
version: String((_versionDoc_get = versionDoc.get('version')) != null ? _versionDoc_get : versionDoc.id),
|
|
475
|
-
trust: (_versionDoc_get1 = versionDoc.get('trust')) != null ? _versionDoc_get1 : null,
|
|
476
|
-
reviewState: String((_versionDoc_get2 = versionDoc.get('reviewState')) != null ? _versionDoc_get2 : 'pending')
|
|
477
|
-
};
|
|
478
|
-
});
|
|
479
|
-
return {
|
|
480
|
-
listingId: doc.id,
|
|
481
|
-
displayName: (_listing_displayName = listing.displayName) != null ? _listing_displayName : doc.id,
|
|
482
|
-
reviewStatus: listing.reviewStatus,
|
|
483
|
-
profileId: listing.profileId,
|
|
484
|
-
latestVersion: String((_listing_latestVersion = listing.latestVersion) != null ? _listing_latestVersion : ''),
|
|
485
|
-
// Takedown state (AGL-952), so a row shows the current verdict
|
|
486
|
-
// rather than sending a reviewer into the detail page to find
|
|
487
|
-
// out whether the plugin is stopped.
|
|
488
|
-
hidden: Boolean(listing.hiddenAt),
|
|
489
|
-
hiddenReason: String((_listing_hiddenReason = listing.hiddenReason) != null ? _listing_hiddenReason : ''),
|
|
490
|
-
private: listing.visibility === 'private',
|
|
491
|
-
pendingVersions: versions.filter((entry)=>entry.reviewState !== 'approved').length,
|
|
492
|
-
// Summarised for the row; the per-version controls live on the
|
|
493
|
-
// detail page (AGL-960).
|
|
494
|
-
realmVersions: versions.filter((entry)=>entry.trust === 'realm').length,
|
|
495
|
-
versionCount: versions.length,
|
|
496
|
-
versions
|
|
497
|
-
};
|
|
498
|
-
}));
|
|
499
|
-
// Publisher names for scanning (AGL-961) — a listing id tells a
|
|
500
|
-
// reviewer nothing. One batched read for both sections.
|
|
501
|
-
const publisherIds = [
|
|
502
|
-
...new Set([
|
|
503
|
-
...queue,
|
|
504
|
-
...listed
|
|
505
|
-
].map((entry)=>{
|
|
506
|
-
var _entry_profileId;
|
|
507
|
-
return String((_entry_profileId = entry.profileId) != null ? _entry_profileId : '');
|
|
508
|
-
}).filter(Boolean))
|
|
509
|
-
];
|
|
510
|
-
const publishers = {};
|
|
511
|
-
if (publisherIds.length) {
|
|
512
|
-
const docs = await firestore.getAll(...publisherIds.map((id)=>firestore.collection('orgs').doc(id)));
|
|
513
|
-
for (const doc of docs){
|
|
514
|
-
var _doc_get;
|
|
515
|
-
if (doc.exists) publishers[doc.id] = String((_doc_get = doc.get('name')) != null ? _doc_get : doc.id);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
return Response.json({
|
|
519
|
-
queue,
|
|
520
|
-
listed,
|
|
521
|
-
publishers,
|
|
522
|
-
verificationRequests
|
|
523
|
-
}, {
|
|
524
|
-
status: 200
|
|
525
|
-
});
|
|
570
|
+
return reviewQueue(firestore, query);
|
|
526
571
|
}
|
|
527
572
|
if (method !== 'POST') {
|
|
528
573
|
return Response.json({
|
|
@@ -599,6 +644,9 @@ async function handler(request) {
|
|
|
599
644
|
}, {
|
|
600
645
|
merge: true
|
|
601
646
|
});
|
|
647
|
+
// A takedown leaves browse for everyone but its publisher, and the
|
|
648
|
+
// queue's Taken down status reads it (AGL-3321).
|
|
649
|
+
if (!reviewUid) await refreshListingQueryFields(target);
|
|
602
650
|
// Takedown means takedown (AGL-948). Hiding a PLUGIN listing also
|
|
603
651
|
// writes the kill switch, so one staff action has one outcome
|
|
604
652
|
// instead of leaving the bundle executing in every workspace that
|
|
@@ -681,7 +729,7 @@ async function handler(request) {
|
|
|
681
729
|
await repairLatestApprovedVersion(firestore.collection('marketplaceListings').doc(listingId), next);
|
|
682
730
|
}
|
|
683
731
|
}
|
|
684
|
-
await firestore
|
|
732
|
+
await addAdminAudit(firestore, _extends({
|
|
685
733
|
actorUid: decoded.uid,
|
|
686
734
|
action: `plugins.takedown.${action}`,
|
|
687
735
|
target: reviewUid ? `marketplaceListings/${listingId}/reviews/${reviewUid}` : `marketplaceListings/${listingId}`
|
|
@@ -748,7 +796,7 @@ async function handler(request) {
|
|
|
748
796
|
// Audited (AGL-971): a checklist tick is the record that a human
|
|
749
797
|
// looked, so it needs the same trail as the verdict it unlocks —
|
|
750
798
|
// including WHICH bytes were looked at.
|
|
751
|
-
await firestore
|
|
799
|
+
await addAdminAudit(firestore, {
|
|
752
800
|
actorUid: decoded.uid,
|
|
753
801
|
action: `plugins.review.checklist.${checked ? 'check' : 'uncheck'}`,
|
|
754
802
|
target: `marketplaceListings/${listingId}/pluginVersions/${version}`,
|
|
@@ -911,6 +959,8 @@ async function handler(request) {
|
|
|
911
959
|
reviewStatus: 'listed',
|
|
912
960
|
updatedAt: FieldValue.serverTimestamp()
|
|
913
961
|
});
|
|
962
|
+
// Listed is browsable: the browse audience follows (AGL-3321).
|
|
963
|
+
await refreshListingQueryFields(listingRef);
|
|
914
964
|
}
|
|
915
965
|
// Denormalise the newest approved version onto the listing (AGL-1016).
|
|
916
966
|
// `pluginVersions` is server-only, so a console showing "update
|
|
@@ -938,7 +988,7 @@ async function handler(request) {
|
|
|
938
988
|
const revocation = (await firestore.collection('revocations').doc(listingId).get()).data();
|
|
939
989
|
await repairLatestApprovedVersion(listingRef, revocation);
|
|
940
990
|
}
|
|
941
|
-
await firestore
|
|
991
|
+
await addAdminAudit(firestore, {
|
|
942
992
|
actorUid: decoded.uid,
|
|
943
993
|
action: `plugins.review.version.${approving ? 'approve' : 'reject'}`,
|
|
944
994
|
target: `marketplaceListings/${listingId}/pluginVersions/${version}`,
|
|
@@ -1073,7 +1123,7 @@ async function handler(request) {
|
|
|
1073
1123
|
// advertises an update that cannot be taken; un-revoking restores it.
|
|
1074
1124
|
// Both directions, because both change what is installable.
|
|
1075
1125
|
await repairLatestApprovedVersion(listingRef, next);
|
|
1076
|
-
await firestore
|
|
1126
|
+
await addAdminAudit(firestore, {
|
|
1077
1127
|
actorUid: decoded.uid,
|
|
1078
1128
|
action: `plugins.revocation.${revoking ? 'revoke' : 'restore'}`,
|
|
1079
1129
|
target: `marketplaceListings/${listingId}/pluginVersions/${version}`,
|
|
@@ -1167,7 +1217,7 @@ async function handler(request) {
|
|
|
1167
1217
|
* buried in `after`, because the audit page renders and exports that
|
|
1168
1218
|
* column and searches it (AGL-1652) — a why nobody can filter on is a
|
|
1169
1219
|
* why nobody reads.
|
|
1170
|
-
*/ await firestore
|
|
1220
|
+
*/ await addAdminAudit(firestore, {
|
|
1171
1221
|
actorUid: decoded.uid,
|
|
1172
1222
|
actorEmail: decoded.email ? String(decoded.email) : null,
|
|
1173
1223
|
action: 'plugins.verification.decline',
|
|
@@ -1272,6 +1322,8 @@ async function handler(request) {
|
|
|
1272
1322
|
}), {
|
|
1273
1323
|
merge: true
|
|
1274
1324
|
});
|
|
1325
|
+
// The verdict decides who browse shows the listing to (AGL-3321).
|
|
1326
|
+
await refreshListingQueryFields(listingRef);
|
|
1275
1327
|
// Tell the publisher their listing moved (rejections especially).
|
|
1276
1328
|
if (listing.profileId && [
|
|
1277
1329
|
'reject',
|
|
@@ -1306,7 +1358,7 @@ async function handler(request) {
|
|
|
1306
1358
|
await emailPublisher(publisherOrgId, action === 'reject' ? `${listing.displayName} was rejected — ${rejectionHeadline(category, reason)}` : action === 'delist' ? `${listing.displayName} was removed from the marketplace` : `${listing.displayName} is now ${nextStatus}`, action === 'reject' || action === 'delist' ? reason || 'It has been removed from the marketplace while we take ' + 'another look. Existing installs keep working.' : 'Your plugin passed review and is live in the marketplace.');
|
|
1307
1359
|
}
|
|
1308
1360
|
}
|
|
1309
|
-
await firestore
|
|
1361
|
+
await addAdminAudit(firestore, {
|
|
1310
1362
|
actorUid: decoded.uid,
|
|
1311
1363
|
action: `plugins.review.${action}`,
|
|
1312
1364
|
target: `marketplaceListings/${listingId}`,
|