@aglyn/plugins-marketplace 1.0.0-beta.149 → 1.0.0-beta.151

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 (92) hide show
  1. package/package.json +15 -11
  2. package/src/lib/components/edit-listing-dialog.component.d.ts +20 -0
  3. package/src/lib/components/edit-listing-dialog.component.js +51 -0
  4. package/src/lib/components/edit-listing-dialog.component.js.map +1 -0
  5. package/src/lib/components/listing-detail-editor.component.d.ts +24 -0
  6. package/src/lib/components/listing-detail-editor.component.js +457 -0
  7. package/src/lib/components/listing-detail-editor.component.js.map +1 -0
  8. package/src/lib/components/listing-detail-page.component.d.ts +28 -0
  9. package/src/lib/components/listing-detail-page.component.js +151 -0
  10. package/src/lib/components/listing-detail-page.component.js.map +1 -0
  11. package/src/lib/components/listing-review-status.component.d.ts +67 -0
  12. package/src/lib/components/listing-review-status.component.js +301 -0
  13. package/src/lib/components/listing-review-status.component.js.map +1 -0
  14. package/src/lib/components/listing-verification-request.component.d.ts +40 -0
  15. package/src/lib/components/listing-verification-request.component.js +170 -0
  16. package/src/lib/components/listing-verification-request.component.js.map +1 -0
  17. package/src/lib/components/marketplace-hub.component.d.ts +43 -0
  18. package/src/lib/components/marketplace-hub.component.js +244 -0
  19. package/src/lib/components/marketplace-hub.component.js.map +1 -0
  20. package/src/lib/components/marketplace-reports-page.component.d.ts +23 -0
  21. package/src/lib/components/marketplace-reports-page.component.js +324 -0
  22. package/src/lib/components/marketplace-reports-page.component.js.map +1 -0
  23. package/src/lib/components/org-licences-panel.component.d.ts +73 -0
  24. package/src/lib/components/org-licences-panel.component.js +318 -0
  25. package/src/lib/components/org-licences-panel.component.js.map +1 -0
  26. package/src/lib/components/org-publish-panel.component.d.ts +45 -0
  27. package/src/lib/components/org-publish-panel.component.js +402 -0
  28. package/src/lib/components/org-publish-panel.component.js.map +1 -0
  29. package/src/lib/components/org-seller-panel.component.d.ts +28 -0
  30. package/src/lib/components/org-seller-panel.component.js +956 -0
  31. package/src/lib/components/org-seller-panel.component.js.map +1 -0
  32. package/src/lib/components/plugin-review-detail.component.d.ts +16 -0
  33. package/src/lib/components/plugin-review-detail.component.js +1438 -0
  34. package/src/lib/components/plugin-review-detail.component.js.map +1 -0
  35. package/src/lib/components/plugin-reviews-queue.component.d.ts +13 -0
  36. package/src/lib/components/plugin-reviews-queue.component.js +385 -0
  37. package/src/lib/components/plugin-reviews-queue.component.js.map +1 -0
  38. package/src/lib/components/plugin-reviews-surface.component.d.ts +19 -0
  39. package/src/lib/components/plugin-reviews-surface.component.js +46 -0
  40. package/src/lib/components/plugin-reviews-surface.component.js.map +1 -0
  41. package/src/lib/components/publish-plugin-form.component.d.ts +61 -0
  42. package/src/lib/components/publish-plugin-form.component.js +857 -0
  43. package/src/lib/components/publish-plugin-form.component.js.map +1 -0
  44. package/src/lib/components/publish-plugin-page.component.d.ts +29 -0
  45. package/src/lib/components/publish-plugin-page.component.js +92 -0
  46. package/src/lib/components/publish-plugin-page.component.js.map +1 -0
  47. package/src/lib/components/publisher-profile-page.component.d.ts +13 -0
  48. package/src/lib/components/publisher-profile-page.component.js +261 -0
  49. package/src/lib/components/publisher-profile-page.component.js.map +1 -0
  50. package/src/lib/constants/listing-media.d.ts +28 -0
  51. package/src/lib/constants/listing-media.js +28 -0
  52. package/src/lib/constants/listing-media.js.map +1 -0
  53. package/src/lib/model/listing-social-card.d.ts +94 -0
  54. package/src/lib/model/listing-social-card.js +177 -0
  55. package/src/lib/model/listing-social-card.js.map +1 -0
  56. package/src/lib/model/marketplace-paths.d.ts +54 -0
  57. package/src/lib/model/marketplace-paths.js +59 -0
  58. package/src/lib/model/marketplace-paths.js.map +1 -0
  59. package/src/lib/model/payout-readiness.d.ts +81 -0
  60. package/src/lib/model/payout-readiness.js +52 -0
  61. package/src/lib/model/payout-readiness.js.map +1 -0
  62. package/src/lib/model/plugin-rejection-categories.d.ts +66 -0
  63. package/src/lib/model/plugin-rejection-categories.js +124 -0
  64. package/src/lib/model/plugin-rejection-categories.js.map +1 -0
  65. package/src/lib/model/plugin-review-checklist.d.ts +62 -0
  66. package/src/lib/model/plugin-review-checklist.js +131 -0
  67. package/src/lib/model/plugin-review-checklist.js.map +1 -0
  68. package/src/lib/model/plugin-review-status.d.ts +104 -0
  69. package/src/lib/model/plugin-review-status.js +126 -0
  70. package/src/lib/model/plugin-review-status.js.map +1 -0
  71. package/src/lib/model/seller-ledger-totals.d.ts +100 -0
  72. package/src/lib/model/seller-ledger-totals.js +96 -0
  73. package/src/lib/model/seller-ledger-totals.js.map +1 -0
  74. package/src/lib/plugin.js +170 -40
  75. package/src/lib/plugin.js.map +1 -1
  76. package/src/lib/server/admin-reports.d.ts +18 -0
  77. package/src/lib/server/admin-reports.js +280 -0
  78. package/src/lib/server/admin-reports.js.map +1 -0
  79. package/src/lib/server/admin-reviews.d.ts +18 -0
  80. package/src/lib/server/admin-reviews.js +1341 -0
  81. package/src/lib/server/admin-reviews.js.map +1 -0
  82. package/src/lib/server/listing-social-card.server.d.ts +24 -0
  83. package/src/lib/server/listing-social-card.server.js +65 -0
  84. package/src/lib/server/listing-social-card.server.js.map +1 -0
  85. package/src/lib/server/publisher-review-email.d.ts +54 -0
  86. package/src/lib/server/publisher-review-email.js +85 -0
  87. package/src/lib/server/publisher-review-email.js.map +1 -0
  88. package/src/lib/server/revoke-cache-drop.d.ts +59 -0
  89. package/src/lib/server/revoke-cache-drop.js +74 -0
  90. package/src/lib/server/revoke-cache-drop.js.map +1 -0
  91. package/src/lib/server.js +47 -0
  92. package/src/lib/server.js.map +1 -1
@@ -0,0 +1,1341 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
3
+ /**
4
+ * @license
5
+ * Copyright 2026 Aglyn LLC
6
+ *
7
+ * Licensed under the Apache License, Version 2.0 (the "License");
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */ import { REVOKED_VERSION_REVIEW_STATE, revocationWithdrawsReviewedClaim, shouldStripVerifiedOnTakedown, VERIFIED_STRIPPED_STATUS } from "../model/plugin-review-status.js";
19
+ import { checkPluginBundle, isPluginRevoked, newestInstallableVersion, isStoredVerdictCurrent, nextRevocationState, PLUGIN_HOST_ABI_VERSION, PLUGIN_VERIFIER_VERSION, pluginArtifactPath, pluginRequestFromWeb, readPluginContributions } from "@aglyn/aglyn/server";
20
+ import { emailUnverifiedResponse, findUserByUidAcrossPools, firebaseAdmin, isImpersonationSession, updateExisting } from "@aglyn/tenant-data-admin";
21
+ import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
22
+ import { buildRoute, compareArtifactVersions, Route } from "@aglyn/aglyn/server";
23
+ import { outstandingChecklistItems, PLUGIN_REVIEW_CHECKLIST } from "../model/plugin-review-checklist.js";
24
+ import { pluginRejectionCategory, rejectionHeadline, rejectionInputError } from "../model/plugin-rejection-categories.js";
25
+ import { attestationsForBytes } from "@aglyn/aglyn/app-utils/publisher-attestation";
26
+ import { VERIFICATION_DECLINE_COOLDOWN_DAYS } from "@aglyn/aglyn/app-utils/marketplace-verification";
27
+ import { PUBLISHER_AGREEMENT_VERSION, publisherAgreementState } from "@aglyn/aglyn/app-utils/publisher-agreement";
28
+ import { FieldValue } from "firebase-admin/firestore";
29
+ import { notifyOrgAdmins } from "@aglyn/tenant-data-admin";
30
+ /*
31
+ * The publisher fan-out lives beside the usage-alert one in `_lib` rather
32
+ * than here. Both are multi-recipient platform sends that have to read the
33
+ * platform suppression list before addressing anybody, and that list has to
34
+ * be reachable by a spec that does not drag the admin SDK in behind a route.
35
+ */ import { emailPublisher } from "./publisher-review-email.js";
36
+ import { dropCachesForListing } from "./revoke-cache-drop.js";
37
+ /**
38
+ * Marketplace review queue (AGL-432) — Strapi Market's two-phase review
39
+ * as staff tooling. GET returns plugin listings awaiting review
40
+ * (submitted/in_review) with the static-verifier verdict re-run against
41
+ * the stored artifact (the same AGL-426 checks the publish API enforced —
42
+ * re-run here so a reviewer sees them without trusting the publish-time
43
+ * result). POST moves a listing through the lifecycle:
44
+ *
45
+ * start-review → in_review list → listed (publicly browsable)
46
+ * verify → verified (✅ badge) reject → rejected (+ reason,
47
+ * notification to the publisher)
48
+ *
49
+ * Realm trust stays a SEPARATE, super-staff grant (sign-plugin route) —
50
+ * listing/verifying here never signs anything. Every action lands in
51
+ * adminAudit.
52
+ */ /** Every id the checklist route will accept (AGL-963). */ const REQUIRED_OR_OPTIONAL = new Set(PLUGIN_REVIEW_CHECKLIST.map((item)=>item.id));
53
+ const ACTIONS = {
54
+ 'start-review': 'in_review',
55
+ list: 'listed',
56
+ verify: 'verified',
57
+ reject: 'rejected',
58
+ // Moves BACK down the ladder (AGL-965). Review used to be a one-way
59
+ // ratchet: the only retreats were `reject`, which reads as a verdict on
60
+ // the whole listing and notifies the publisher as such, and takedown,
61
+ // which is the kill switch and stops the plugin in every workspace that
62
+ // already installed it. Neither expresses "pull it from the marketplace
63
+ // while we look again, without breaking the sites already using it".
64
+ delist: 'in_review',
65
+ unverify: 'listed'
66
+ };
67
+ /**
68
+ * One listing in full, for the detail page (AGL-959). The index deals in
69
+ * rows; everything a reviewer needs to actually decide — manifest,
70
+ * capabilities, ABI, verifier findings, per-version trust, takedown state —
71
+ * is assembled here rather than smeared across the list payload.
72
+ */ async function listingDetail(firestore, listingId, requestedVersion) {
73
+ var _listing_profileId, _listing_latestVersion, _ref, _ref1, _ref2, _ref3, _listing_displayName, _listing_description, _listing_verificationRequest_state, _ref4, _listing_verificationRequest_declineReason, _listing_readme, _listing_license, _listing_categories, _listing_homepageUrl, _ref5, _ref6, _listing_reviewStatus, _listing_rejectionReason, _listing_rejectionCategory, _listing_priceUsd, _listing_latestVersion1, _listing_activeInstalls, _listing_hiddenReason, _ref7, _ref8, _ref9, _ref10;
74
+ var _versions_find, _attestationEntries_find, _attestationEntries_find_at_toDate, _attestationEntries_find_at_toDate1, _attestationEntries_find_at, _attestationEntries_find1, _listing_verificationRequest_requestedAt_toDate, _listing_verificationRequest_requestedAt_toDate1, _listing_verificationRequest_requestedAt, _acceptance_acceptedAt_toDate, _acceptance_acceptedAt_toDate1, _acceptance_acceptedAt;
75
+ const listingRef = firestore.collection('marketplaceListings').doc(listingId);
76
+ const snapshot = await listingRef.get();
77
+ const listing = snapshot.data();
78
+ if (!listing) {
79
+ return Response.json({
80
+ error: 'Unknown listing'
81
+ }, {
82
+ status: 404
83
+ });
84
+ }
85
+ const versionsSnapshot = await listingRef.collection('pluginVersions').orderBy('publishedAt', 'desc').limit(25).get();
86
+ const versions = versionsSnapshot.docs.map((doc)=>{
87
+ var _doc_get, _doc_get1, _doc_get2, _doc_get3, _readPluginContributions, _doc_get4, _ref, _doc_get5, _doc_get6, _doc_get7, _doc_get8, _doc_get9, _doc_get10, _doc_get11;
88
+ var _publishedAt_toDate, _publishedAt_toDate1;
89
+ const publishedAt = doc.get('publishedAt');
90
+ return {
91
+ version: String((_doc_get = doc.get('version')) != null ? _doc_get : doc.id),
92
+ trust: (_doc_get1 = doc.get('trust')) != null ? _doc_get1 : null,
93
+ sha256: String((_doc_get2 = doc.get('sha256')) != null ? _doc_get2 : ''),
94
+ hostAbi: Number(doc.get('manifest.hostAbi')) || null,
95
+ capabilities: (_doc_get3 = doc.get('manifest.capabilities')) != null ? _doc_get3 : {},
96
+ // What these bytes declare they contribute (AGL-3116); `null` when the
97
+ // version predates the contract and loads under its default.
98
+ contributes: (_readPluginContributions = readPluginContributions(doc.get('manifest.contributes'))) != null ? _readPluginContributions : null,
99
+ // Declared canvas elements (AGL-1031), for THESE bytes. Publisher copy
100
+ // that renders in the customer's element picker and nowhere on the
101
+ // listing page — so without it here, the `element-metadata` criterion
102
+ // asks a reviewer to check text this page never shows (AGL-2486).
103
+ elements: (_doc_get4 = doc.get('manifest.elements')) != null ? _doc_get4 : [],
104
+ publishedAt: (_ref = publishedAt == null ? void 0 : (_publishedAt_toDate1 = publishedAt.toDate) == null ? void 0 : (_publishedAt_toDate = _publishedAt_toDate1.call(publishedAt)) == null ? void 0 : _publishedAt_toDate.toISOString()) != null ? _ref : null,
105
+ // Per-version installs (AGL-1036). "How many workspaces would a revoke
106
+ // actually hit" is a per-version question, and the listing total shown
107
+ // elsewhere on this page answers a different one.
108
+ installCount: Number((_doc_get5 = doc.get('installCount')) != null ? _doc_get5 : 0),
109
+ activeInstalls: Number((_doc_get6 = doc.get('activeInstalls')) != null ? _doc_get6 : 0),
110
+ signed: Boolean(doc.get('signature')),
111
+ // The repo as declared for THESE bytes (AGL-1076); absent on versions
112
+ // published before it was collected.
113
+ repositoryUrl: String((_doc_get7 = doc.get('repositoryUrl')) != null ? _doc_get7 : ''),
114
+ reviewState: String((_doc_get8 = doc.get('reviewState')) != null ? _doc_get8 : 'pending'),
115
+ grandfathered: Boolean(doc.get('grandfathered')),
116
+ // Server-side only — used to decide whether the verdict below needs
117
+ // recomputing; stripped before the payload goes out.
118
+ verification: (_doc_get9 = doc.get('verification')) != null ? _doc_get9 : null,
119
+ reviewChecklist: (_doc_get10 = doc.get('reviewChecklist')) != null ? _doc_get10 : null,
120
+ publisherAttestation: (_doc_get11 = doc.get('publisherAttestation')) != null ? _doc_get11 : null
121
+ };
122
+ });
123
+ // Publisher is an ORG since AGL-652 — show the name, not the raw id.
124
+ const publisherId = String((_listing_profileId = listing.profileId) != null ? _listing_profileId : '');
125
+ const publisher = publisherId ? await firestore.collection('orgs').doc(publisherId).get() : null;
126
+ // Which terms this publisher is actually under (AGL-1077). A reviewer
127
+ // weighing a takedown, a delist, or a revocation is deciding what we are
128
+ // entitled to do — and that is answered by the agreement the ORG accepted,
129
+ // not by the per-version attestation beside it.
130
+ const publisherProfile = publisherId ? await firestore.collection('publisherProfiles').doc(publisherId).get() : null;
131
+ const acceptance = publisherProfile == null ? void 0 : publisherProfile.get('publisherAgreement');
132
+ // Kill switch, so the page can say whether the plugin is actually stopped
133
+ // rather than only de-listed (AGL-948).
134
+ const revocation = (await firestore.collection('revocations').doc(listingId).get()).data();
135
+ // The verifier verdict for the version under review (AGL-426), served
136
+ // from the version doc when it is current (AGL-962).
137
+ //
138
+ // `checkPluginBundle` is pure and the artifact is immutable and
139
+ // content-addressed, so a verdict pinned to {sha256, verifierVersion} is
140
+ // exactly as trustworthy as re-running it — and re-running meant
141
+ // downloading up to a megabyte from Storage on EVERY page view. A stored
142
+ // verdict from an older checker, or for different bytes, is ignored.
143
+ // Which version this page is reviewing (AGL-966). Defaults to the oldest
144
+ // version still awaiting a verdict — that is the work — falling back to
145
+ // the latest when everything is decided. A reviewer can pin any version
146
+ // explicitly; the checklist and verifier verdict follow the selection,
147
+ // because both are statements about specific bytes.
148
+ const pendingFirst = [
149
+ ...versions
150
+ ].reverse().find((entry)=>entry.reviewState !== 'approved');
151
+ const reviewVersion = requestedVersion && ((_versions_find = versions.find((entry)=>entry.version === requestedVersion)) == null ? void 0 : _versions_find.version) || (pendingFirst == null ? void 0 : pendingFirst.version) || String((_listing_latestVersion = listing.latestVersion) != null ? _listing_latestVersion : '');
152
+ const artifactsBucket = process.env.PLUGIN_ARTIFACTS_BUCKET;
153
+ const reviewEntry = versions.find((entry)=>entry.version === reviewVersion);
154
+ const reviewSha = reviewEntry == null ? void 0 : reviewEntry.sha256;
155
+ const stored = reviewEntry == null ? void 0 : reviewEntry.verification;
156
+ const storedIsCurrent = isStoredVerdictCurrent(stored, reviewSha != null ? reviewSha : '');
157
+ let verifier = storedIsCurrent ? {
158
+ ok: stored == null ? void 0 : stored.ok,
159
+ problems: (_ref = stored == null ? void 0 : stored.problems) != null ? _ref : [],
160
+ // The per-check summary rides along in the stored verdict (AGL-1087)
161
+ // so the page can show what was checked without re-downloading a
162
+ // megabyte of artifact on every view.
163
+ checks: (_ref1 = stored == null ? void 0 : stored.checks) != null ? _ref1 : []
164
+ } : null;
165
+ let verifierCached = storedIsCurrent;
166
+ if (!storedIsCurrent && artifactsBucket && reviewSha) {
167
+ try {
168
+ var _ref11;
169
+ var _reviewEntry_capabilities;
170
+ const [bytes] = await firebaseAdmin.app().storage().bucket(artifactsBucket).file(pluginArtifactPath(listingId, reviewVersion, reviewSha)).download();
171
+ // The manifest for THESE bytes, so an undeclared network call is a
172
+ // finding rather than a warning (AGL-964). Read from the version doc,
173
+ // never from the listing — capabilities change between versions.
174
+ const result = checkPluginBundle(bytes.toString('utf8'), {
175
+ declaredNetwork: Array.isArray(reviewEntry == null ? void 0 : (_reviewEntry_capabilities = reviewEntry.capabilities) == null ? void 0 : _reviewEntry_capabilities.network) ? reviewEntry.capabilities.network.map((origin)=>String(origin)) : [],
176
+ // Compared, never required: a version published before the contract
177
+ // loads under its default, and review must not refuse it for a rule
178
+ // it was never held to.
179
+ declaredContributions: (_ref11 = reviewEntry == null ? void 0 : reviewEntry.contributes) != null ? _ref11 : null
180
+ });
181
+ verifier = result;
182
+ // Write it back so this sha is verified at most once platform-wide —
183
+ // covers every listing published before the verdict was persisted at
184
+ // publish time, and any version whose checker has since moved on.
185
+ // Best effort: a failed write costs the next reader a re-run, nothing
186
+ // more, so it must never fail the page.
187
+ await listingRef.collection('pluginVersions').doc(reviewVersion).set({
188
+ verification: {
189
+ ok: result.ok,
190
+ problems: result.problems,
191
+ checks: result.checks,
192
+ sha256: reviewSha,
193
+ verifierVersion: PLUGIN_VERIFIER_VERSION,
194
+ checkedAt: FieldValue.serverTimestamp()
195
+ }
196
+ }, {
197
+ merge: true
198
+ }).catch(()=>undefined);
199
+ } catch (unused) {
200
+ verifier = {
201
+ error: 'artifact unavailable'
202
+ };
203
+ verifierCached = false;
204
+ }
205
+ }
206
+ // Who attested, by name (AGL-969). "A named publisher said this on a date"
207
+ // is the half that makes a false attestation actionable, and a raw uid
208
+ // names nobody. Best effort — a deleted account must not blank the page.
209
+ const attestationEntries = Object.values((_ref2 = reviewEntry == null ? void 0 : reviewEntry.publisherAttestation) != null ? _ref2 : {}).filter((entry)=>(entry == null ? void 0 : entry.sha256) === reviewSha);
210
+ const attesterUid = (_attestationEntries_find = attestationEntries.find((entry)=>entry.by)) == null ? void 0 : _attestationEntries_find.by;
211
+ const attestedAt = (_ref3 = (_attestationEntries_find1 = attestationEntries.find((entry)=>entry.at)) == null ? void 0 : (_attestationEntries_find_at = _attestationEntries_find1.at) == null ? void 0 : (_attestationEntries_find_at_toDate1 = _attestationEntries_find_at.toDate) == null ? void 0 : (_attestationEntries_find_at_toDate = _attestationEntries_find_at_toDate1.call(_attestationEntries_find_at)) == null ? void 0 : _attestationEntries_find_at_toDate.toISOString()) != null ? _ref3 : null;
212
+ let attestedBy = attesterUid != null ? attesterUid : null;
213
+ if (attesterUid) {
214
+ try {
215
+ // Across pools (AGL-1122): an SSO attester is invisible to project-level
216
+ // `getUser`, so this fell through to the catch and the review page named
217
+ // the attester by raw uid — indistinguishable from "account deleted".
218
+ const found = await findUserByUidAcrossPools(attesterUid);
219
+ if (found) {
220
+ var _ref12, _found_record_email;
221
+ attestedBy = (_ref12 = (_found_record_email = found.record.email) != null ? _found_record_email : found.record.displayName) != null ? _ref12 : attesterUid;
222
+ }
223
+ } catch (unused) {
224
+ // Account gone — the uid still identifies the statement.
225
+ }
226
+ }
227
+ return Response.json({
228
+ listingId,
229
+ displayName: (_listing_displayName = listing.displayName) != null ? _listing_displayName : listingId,
230
+ description: (_listing_description = listing.description) != null ? _listing_description : '',
231
+ // The publisher's ask, so the page can offer Decline beside Verify
232
+ // (AGL-1217). Serialized: `decidedAt`/`requestedAt` are Timestamps and
233
+ // this payload crosses JSON.
234
+ verificationRequest: listing.verificationRequest ? {
235
+ state: String((_listing_verificationRequest_state = listing.verificationRequest.state) != null ? _listing_verificationRequest_state : ''),
236
+ requestedAt: (_ref4 = (_listing_verificationRequest_requestedAt = listing.verificationRequest.requestedAt) == null ? void 0 : (_listing_verificationRequest_requestedAt_toDate1 = _listing_verificationRequest_requestedAt.toDate) == null ? void 0 : (_listing_verificationRequest_requestedAt_toDate = _listing_verificationRequest_requestedAt_toDate1.call(_listing_verificationRequest_requestedAt)) == null ? void 0 : _listing_verificationRequest_requestedAt_toDate.toISOString()) != null ? _ref4 : null,
237
+ declineReason: (_listing_verificationRequest_declineReason = listing.verificationRequest.declineReason) != null ? _listing_verificationRequest_declineReason : null
238
+ } : null,
239
+ readme: (_listing_readme = listing.readme) != null ? _listing_readme : '',
240
+ license: (_listing_license = listing.license) != null ? _listing_license : '',
241
+ categories: (_listing_categories = listing.categories) != null ? _listing_categories : [],
242
+ homepageUrl: (_listing_homepageUrl = listing.homepageUrl) != null ? _listing_homepageUrl : '',
243
+ // Prefer what the version under review declared (AGL-1076). The
244
+ // listing carries whatever the most recent publish said, and a repo
245
+ // can move between versions — the link a reviewer opens should be the
246
+ // one attested against the bytes in front of them. Falls back to the
247
+ // listing for versions published before this was collected.
248
+ repositoryUrl: (reviewEntry == null ? void 0 : reviewEntry.repositoryUrl) || listing.repositoryUrl || '',
249
+ publisherId,
250
+ publisherName: (_ref5 = publisher == null ? void 0 : publisher.get('name')) != null ? _ref5 : publisherId,
251
+ publisherSlug: (_ref6 = publisher == null ? void 0 : publisher.get('slug')) != null ? _ref6 : null,
252
+ reviewStatus: (_listing_reviewStatus = listing.reviewStatus) != null ? _listing_reviewStatus : 'submitted',
253
+ rejectionReason: (_listing_rejectionReason = listing.rejectionReason) != null ? _listing_rejectionReason : '',
254
+ rejectionCategory: (_listing_rejectionCategory = listing.rejectionCategory) != null ? _listing_rejectionCategory : '',
255
+ priceUsd: Number((_listing_priceUsd = listing.priceUsd) != null ? _listing_priceUsd : 0),
256
+ latestVersion: String((_listing_latestVersion1 = listing.latestVersion) != null ? _listing_latestVersion1 : ''),
257
+ // The version the checklist, verifier verdict and approve/reject
258
+ // actions on this payload refer to.
259
+ reviewVersion,
260
+ activeInstalls: Number((_listing_activeInstalls = listing.activeInstalls) != null ? _listing_activeInstalls : 0),
261
+ hidden: Boolean(listing.hiddenAt),
262
+ hiddenReason: String((_listing_hiddenReason = listing.hiddenReason) != null ? _listing_hiddenReason : ''),
263
+ revoked: Boolean(revocation),
264
+ revokedVersions: (_ref7 = revocation == null ? void 0 : revocation.versions) != null ? _ref7 : null,
265
+ unpublished: Boolean(listing.deletedAt),
266
+ private: listing.visibility === 'private',
267
+ platformHostAbi: PLUGIN_HOST_ABI_VERSION,
268
+ // Where the bundle bytes actually live (AGL-990). Staff-only payload,
269
+ // and a bucket NAME is not a credential — reaching the object still
270
+ // needs a Google account with access. Without it the page cannot build
271
+ // a Cloud console link, and this bucket does not appear in the Firebase
272
+ // console at all, so a reviewer has no way to find it by hand.
273
+ artifactsBucket: artifactsBucket != null ? artifactsBucket : null,
274
+ versions: versions.map(// The rest-strip is the point: `verification`, `reviewChecklist` and
275
+ // `publisherAttestation` are named only so they DO NOT reach the
276
+ // client payload, which is what the disable below is for. The
277
+ // duplicate directive that used to sit above `versions.map(` covered
278
+ // a line with nothing unused on it, and this config treats an
279
+ // unused disable as a failure.
280
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
281
+ (_0)=>{
282
+ let { verification, reviewChecklist, publisherAttestation } = _0, entry = _object_without_properties_loose(_0, [
283
+ "verification",
284
+ "reviewChecklist",
285
+ "publisherAttestation"
286
+ ]);
287
+ return _extends({}, entry, {
288
+ // Per-version kill-switch state (AGL-1085), read through the same
289
+ // helper the loaders use so the page cannot disagree with the
290
+ // runtime about whether these bytes are stopped.
291
+ revoked: isPluginRevoked(revocation, entry.version)
292
+ });
293
+ }),
294
+ // Review checklist for the version under review (AGL-963). Ticks made
295
+ // against different bytes do not count, so a republish starts clean.
296
+ checklist: Object.fromEntries(Object.entries((_ref8 = reviewEntry == null ? void 0 : reviewEntry.reviewChecklist) != null ? _ref8 : {}).filter(([, entry])=>(entry == null ? void 0 : entry.sha256) === reviewSha).map(([id, entry])=>{
297
+ var _entry_by;
298
+ return [
299
+ id,
300
+ {
301
+ by: (_entry_by = entry.by) != null ? _entry_by : null
302
+ }
303
+ ];
304
+ })),
305
+ checklistOutstanding: outstandingChecklistItems(reviewEntry == null ? void 0 : reviewEntry.reviewChecklist, reviewSha != null ? reviewSha : ''),
306
+ // What the publisher stated about THESE bytes (AGL-969). Not a review
307
+ // input — a reviewer still checks everything themselves — but it says
308
+ // what was claimed, so a discrepancy is a documented false statement
309
+ // rather than a difference of opinion. Attestations recorded against
310
+ // other bytes are dropped, exactly like staff ticks.
311
+ attestation: attestationsForBytes(reviewEntry == null ? void 0 : reviewEntry.publisherAttestation, reviewSha != null ? reviewSha : ''),
312
+ attestedBy,
313
+ attestedAt,
314
+ // The publisher agreement this ORG is under (AGL-1077). `current` is
315
+ // computed here rather than shipped as a boolean so the page can say
316
+ // "on an older version" — which is a different thing from never
317
+ // having accepted, and matters differently to a takedown decision.
318
+ publisherAgreement: {
319
+ version: (_ref9 = acceptance == null ? void 0 : acceptance.version) != null ? _ref9 : null,
320
+ acceptedAt: (_ref10 = acceptance == null ? void 0 : (_acceptance_acceptedAt = acceptance.acceptedAt) == null ? void 0 : (_acceptance_acceptedAt_toDate1 = _acceptance_acceptedAt.toDate) == null ? void 0 : (_acceptance_acceptedAt_toDate = _acceptance_acceptedAt_toDate1.call(_acceptance_acceptedAt)) == null ? void 0 : _acceptance_acceptedAt_toDate.toISOString()) != null ? _ref10 : null,
321
+ required: PUBLISHER_AGREEMENT_VERSION,
322
+ state: publisherAgreementState(acceptance)
323
+ },
324
+ verifier,
325
+ verifierCached,
326
+ verifierVersion: PLUGIN_VERIFIER_VERSION
327
+ }, {
328
+ status: 200
329
+ });
330
+ }
331
+ /**
332
+ * Re-derive `latestApprovedVersion` from the versions themselves (AGL-2306).
333
+ *
334
+ * The mirror was written on approval only, with a comment saying it "only ever
335
+ * moves forward" — true of approvals, and the reason it is there. What nothing
336
+ * did was move it BACK. A version approved and later rejected on re-review, or
337
+ * killed with the per-version switch, left the listing still advertising it:
338
+ * `host-plugins-card` and `marketplace-update-state` read this field to offer
339
+ * "Update to vX", and `install-plugin` would then refuse those exact bytes.
340
+ *
341
+ * Contained, because `install-plugin` recomputes approval live and never
342
+ * trusts this field — no unreviewed or revoked bytes could ship — but it is
343
+ * the AGL-1016 guarantee leaking back out through a badge, which is where
344
+ * AGL-966 came in.
345
+ *
346
+ * Recomputed rather than decremented: "the newest version that is approved AND
347
+ * not revoked" is a property of the whole set, and stepping it back one at a
348
+ * time is how a mirror drifts in the other direction.
349
+ *
350
+ * Revoked versions are excluded because the offer has to be installable, and
351
+ * `install-plugin` 409s on a revoked pin. That also makes un-revoking restore
352
+ * the offer, which is why this runs on both revoke and unrevoke.
353
+ *
354
+ * Deletes the field when nothing qualifies: an absent mirror reads as "no
355
+ * update to offer", while a stale string reads as an update that does not
356
+ * exist.
357
+ */ async function repairLatestApprovedVersion(listingRef, revocation) {
358
+ const versions = await listingRef.collection('pluginVersions').orderBy('publishedAt', 'desc').limit(50).get();
359
+ // The DECISION lives in core (`newestInstallableVersion`), where it is
360
+ // testable without a Firestore double and shared with anything else that
361
+ // has to answer "what is on offer". This function is only the query.
362
+ const newest = newestInstallableVersion(versions.docs.map((doc)=>({
363
+ version: doc.id,
364
+ reviewState: doc.get('reviewState')
365
+ })), revocation, compareArtifactVersions);
366
+ await updateExisting(listingRef, {
367
+ latestApprovedVersion: newest != null ? newest : FieldValue.delete(),
368
+ updatedAt: FieldValue.serverTimestamp()
369
+ });
370
+ }
371
+ async function handler(request) {
372
+ var _headers_authorization;
373
+ const { method, body, query, headers: rawHeaders } = await pluginRequestFromWeb(request);
374
+ const headers = rawHeaders;
375
+ const authorization = (_headers_authorization = headers.authorization) != null ? _headers_authorization : '';
376
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
377
+ if (!idToken) return Response.json({
378
+ error: 'Unauthenticated'
379
+ }, {
380
+ status: 401
381
+ });
382
+ try {
383
+ var _ref, _ref1, _ref2, _ref3;
384
+ var _listing_verificationRequest;
385
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
386
+ if (!decoded.email_verified && !isImpersonationSession(decoded)) {
387
+ return emailUnverifiedResponse();
388
+ }
389
+ if (!decoded['staff']) {
390
+ return Response.json({
391
+ error: 'Staff only'
392
+ }, {
393
+ status: 403
394
+ });
395
+ }
396
+ const firestore = firebaseAdmin.app().firestore();
397
+ if (method === 'GET') {
398
+ var _query_listingId;
399
+ const detailId = String((_query_listingId = query['listingId']) != null ? _query_listingId : '');
400
+ if (detailId) {
401
+ var _query_version;
402
+ return listingDetail(firestore, detailId, String((_query_version = query['version']) != null ? _query_version : ''));
403
+ }
404
+ // Every plugin listing, bucketed by whether its newest bytes are
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
+ });
526
+ }
527
+ if (method !== 'POST') {
528
+ return Response.json({
529
+ error: 'Method not allowed'
530
+ }, {
531
+ status: 405
532
+ });
533
+ }
534
+ const listingId = String((_ref = body == null ? void 0 : body.listingId) != null ? _ref : '');
535
+ const action = String((_ref1 = body == null ? void 0 : body.action) != null ? _ref1 : '');
536
+ // Takedown (AGL-658) is separate from the review verdict: it applies to
537
+ // EVERY artifact type, not just plugins, and it must not overwrite a
538
+ // plugin's `reviewStatus` — a hidden plugin that later gets un-hidden
539
+ // should return to the verdict it had, not to square one.
540
+ if (action === 'hide' || action === 'unhide') {
541
+ var _ref4, _ref5;
542
+ if (!listingId) {
543
+ return Response.json({
544
+ error: 'Missing listingId'
545
+ }, {
546
+ status: 400
547
+ });
548
+ }
549
+ const hideReason = String((_ref4 = body == null ? void 0 : body.reason) != null ? _ref4 : '').slice(0, 500);
550
+ if (action === 'hide' && !hideReason.trim()) {
551
+ return Response.json({
552
+ error: 'Hiding needs a reason'
553
+ }, {
554
+ status: 400
555
+ });
556
+ }
557
+ const reviewUid = String((_ref5 = body == null ? void 0 : body.reviewUid) != null ? _ref5 : '');
558
+ const target = reviewUid ? firestore.collection('marketplaceListings').doc(listingId).collection('reviews').doc(reviewUid) : firestore.collection('marketplaceListings').doc(listingId);
559
+ const targetSnapshot = await target.get();
560
+ if (!targetSnapshot.exists) {
561
+ return Response.json({
562
+ error: 'Unknown target'
563
+ }, {
564
+ status: 404
565
+ });
566
+ }
567
+ // A takedown strips the Verified badge (AGL-1121, decided 2026-08-03).
568
+ //
569
+ // Verified is OUR claim that a human vouched for this publisher. Taking
570
+ // the listing down is us saying the opposite out loud, so the two cannot
571
+ // both stand — a badge surviving a takedown would still be asserting the
572
+ // vouch on the listing page the moment it was restored.
573
+ //
574
+ // Demoted to `listed`, not to `rejected` or `submitted`: the takedown
575
+ // itself is what makes it non-browsable (`hiddenAt`), and rewriting the
576
+ // review verdict would destroy the record of a review that did happen.
577
+ //
578
+ // Restoring deliberately does NOT put the badge back. Re-granting is
579
+ // `verify`, which re-checks the review checklist server-side; an automatic
580
+ // regrant would hand back the badge without anyone re-forming the opinion
581
+ // behind it. Losing it to a takedown is meant to cost a re-review.
582
+ const stripVerified = shouldStripVerifiedOnTakedown({
583
+ action,
584
+ reviewStatus: targetSnapshot.get('reviewStatus'),
585
+ isListingTarget: !reviewUid
586
+ });
587
+ await target.set(reviewUid ? {
588
+ hidden: action === 'hide'
589
+ } : action === 'hide' ? _extends({
590
+ hiddenAt: FieldValue.serverTimestamp(),
591
+ hiddenBy: decoded.uid,
592
+ hiddenReason: hideReason
593
+ }, stripVerified ? {
594
+ reviewStatus: VERIFIED_STRIPPED_STATUS
595
+ } : {}) : {
596
+ hiddenAt: FieldValue.delete(),
597
+ hiddenBy: FieldValue.delete(),
598
+ hiddenReason: FieldValue.delete()
599
+ }, {
600
+ merge: true
601
+ });
602
+ // Takedown means takedown (AGL-948). Hiding a PLUGIN listing also
603
+ // writes the kill switch, so one staff action has one outcome
604
+ // instead of leaving the bundle executing in every workspace that
605
+ // already installed it. The realm join blocks on `hiddenAt` too, but
606
+ // the revocation is what the client-side loaders and the public
607
+ // listing-versions endpoint already understand — belt and braces on
608
+ // the one action where a gap means running attacker code.
609
+ const isPlugin = !reviewUid && (targetSnapshot.get('artifactType') === 'plugin' || targetSnapshot.get('type') === 'plugin');
610
+ /**
611
+ * An email starter earns the same treatment for the same reason.
612
+ *
613
+ * It is copied on install, so `hiddenAt` alone reaches only the
614
+ * storefront: every tenant who already installed it goes on MAILING it,
615
+ * from the sending domain every other tenant shares. The revocation is
616
+ * what `emailStarterSendBlock` reads at the send, which is the only
617
+ * chokepoint that can still refuse.
618
+ *
619
+ * The five other copied types are deliberately not here. Their content
620
+ * is rendered on the tenant's own site, where `hiddenAt` blocking the
621
+ * next install is the whole of what a takedown can honestly promise —
622
+ * stopping a page from rendering is a different decision, and one nobody
623
+ * has made.
624
+ */ const killsInstalledCopies = isPlugin || !reviewUid && targetSnapshot.get('artifactType') === 'emailStarter';
625
+ if (killsInstalledCopies) {
626
+ const revocationRef = firestore.collection('revocations').doc(listingId);
627
+ const current = (await revocationRef.get()).data();
628
+ // Both transitions go through the shared helper (AGL-1085) so a
629
+ // takedown and a per-version review revocation can coexist. Restoring
630
+ // used to DELETE the doc whenever it was takedown-owned, which also
631
+ // un-revoked any version a reviewer had stopped for an unrelated
632
+ // reason — `reviewVersions` is what survives the round trip.
633
+ const next = nextRevocationState(current, {
634
+ type: action === 'hide' ? 'takedown' : 'restore'
635
+ });
636
+ if (next) {
637
+ await revocationRef.set(_extends({}, next, action === 'hide' ? {
638
+ reason: hideReason
639
+ } : {}, {
640
+ revokedBy: decoded.uid,
641
+ revokedAt: FieldValue.serverTimestamp()
642
+ }, action === 'hide' ? {} : {
643
+ source: FieldValue.delete()
644
+ }), {
645
+ merge: true
646
+ });
647
+ } else if (current) {
648
+ await revocationRef.delete();
649
+ }
650
+ // Make the kill switch reach ALREADY-CACHED pages (AGL-1152). The
651
+ // tenant stamps each plugin node with its revocation state at compose
652
+ // time, so without this the bundle we just killed keeps executing on
653
+ // every cached page until it happens to re-render. Best effort: the
654
+ // tenant refuses a revoked plugin at render time regardless.
655
+ //
656
+ // Plugin-only: it walks the plugin INSTALL PINS, which an email
657
+ // starter has none of, and an email is never part of a cached page —
658
+ // its kill is felt at the send, where nothing is cached.
659
+ if (isPlugin) await dropCachesForListing(firestore, listingId);
660
+ // The offer follows the kill switch here too (AGL-2368). AGL-2306
661
+ // taught the per-version revoke and the reject path to repair the
662
+ // mirror and left this one out, so a takedown flattened `versions` to
663
+ // `'all'` — revoking every build — while `latestApprovedVersion` went
664
+ // on naming one. `hiddenAt` hides the listing from browse and blocks
665
+ // the install route, so nothing was installable through it; but a
666
+ // restore clears `hiddenAt` first and the mirror is what the update
667
+ // badges read, and `nextRevocationState` can return a document that
668
+ // still holds a reviewer's per-version revocations. Recomputed from
669
+ // the versions in both directions rather than reasoned about.
670
+ //
671
+ // Named explicitly rather than reusing `target`: `isPlugin` is only
672
+ // true when `reviewUid` is empty, so they are the same document — but
673
+ // that is a fact two conditions apart, and a repair pointed at a
674
+ // review subdocument would write a `latestApprovedVersion` onto it.
675
+ //
676
+ // Plugin-only, unlike the revocation write above: the mirror is
677
+ // recomputed from `pluginVersions`, a collection no other artifact
678
+ // type has, so running it for an email starter would derive "nothing
679
+ // approved" from an empty read and write that over the listing.
680
+ if (isPlugin) {
681
+ await repairLatestApprovedVersion(firestore.collection('marketplaceListings').doc(listingId), next);
682
+ }
683
+ }
684
+ await firestore.collection('adminAudit').add(_extends({
685
+ actorUid: decoded.uid,
686
+ action: `plugins.takedown.${action}`,
687
+ target: reviewUid ? `marketplaceListings/${listingId}/reviews/${reviewUid}` : `marketplaceListings/${listingId}`
688
+ }, reviewUid ? {
689
+ subjectUid: reviewUid
690
+ } : {}, {
691
+ after: _extends({
692
+ hidden: action === 'hide'
693
+ }, hideReason ? {
694
+ reason: hideReason
695
+ } : {}, killsInstalledCopies ? {
696
+ revoked: action === 'hide'
697
+ } : {}, stripVerified ? {
698
+ unverified: true
699
+ } : {}),
700
+ at: FieldValue.serverTimestamp()
701
+ }));
702
+ return Response.json({
703
+ ok: true,
704
+ hidden: action === 'hide',
705
+ revoked: killsInstalledCopies && action === 'hide',
706
+ // The caller shows this — a badge silently disappearing would be
707
+ // worse than one that never existed.
708
+ unverified: stripVerified
709
+ }, {
710
+ status: 200
711
+ });
712
+ }
713
+ // Review checklist (AGL-963). Ticks are per {version, sha256} and carry
714
+ // who + when, so "verified" is a recorded act rather than a click.
715
+ if (action === 'checklist') {
716
+ var _ref6, _ref7, _versionSnapshot_get, _versionSnapshot_get1;
717
+ const version = String((_ref6 = body == null ? void 0 : body.version) != null ? _ref6 : '');
718
+ const itemId = String((_ref7 = body == null ? void 0 : body.itemId) != null ? _ref7 : '');
719
+ const checked = (body == null ? void 0 : body.checked) !== false;
720
+ if (!listingId || !version || !REQUIRED_OR_OPTIONAL.has(itemId)) {
721
+ return Response.json({
722
+ error: 'Unknown checklist item'
723
+ }, {
724
+ status: 400
725
+ });
726
+ }
727
+ const versionRef = firestore.collection('marketplaceListings').doc(listingId).collection('pluginVersions').doc(version);
728
+ const versionSnapshot = await versionRef.get();
729
+ if (!versionSnapshot.exists) {
730
+ return Response.json({
731
+ error: 'Unknown version'
732
+ }, {
733
+ status: 404
734
+ });
735
+ }
736
+ await versionRef.set({
737
+ reviewChecklist: {
738
+ [itemId]: checked ? {
739
+ by: decoded.uid,
740
+ at: FieldValue.serverTimestamp(),
741
+ // Pins the tick to the bytes it was made against.
742
+ sha256: String((_versionSnapshot_get = versionSnapshot.get('sha256')) != null ? _versionSnapshot_get : '')
743
+ } : FieldValue.delete()
744
+ }
745
+ }, {
746
+ merge: true
747
+ });
748
+ // Audited (AGL-971): a checklist tick is the record that a human
749
+ // looked, so it needs the same trail as the verdict it unlocks —
750
+ // including WHICH bytes were looked at.
751
+ await firestore.collection('adminAudit').add({
752
+ actorUid: decoded.uid,
753
+ action: `plugins.review.checklist.${checked ? 'check' : 'uncheck'}`,
754
+ target: `marketplaceListings/${listingId}/pluginVersions/${version}`,
755
+ after: {
756
+ itemId,
757
+ checked,
758
+ sha256: String((_versionSnapshot_get1 = versionSnapshot.get('sha256')) != null ? _versionSnapshot_get1 : '')
759
+ },
760
+ at: FieldValue.serverTimestamp()
761
+ });
762
+ return Response.json({
763
+ ok: true,
764
+ itemId,
765
+ checked
766
+ }, {
767
+ status: 200
768
+ });
769
+ }
770
+ // Per-version verdicts (AGL-966). Approval is a statement about bytes,
771
+ // so it lives on the version doc and installs resolve the newest
772
+ // approved one. Listing-level list/verify still exist, but they can no
773
+ // longer ship code past review on their own.
774
+ if (action === 'approve-version' || action === 'reject-version') {
775
+ var _ref8, _versionSnapshot_get2, _ref9, _ref10, _listing_latestVersion, _listing_reviewStatus, _listing_latestApprovedVersion, _versionSnapshot_get3;
776
+ const version = String((_ref8 = body == null ? void 0 : body.version) != null ? _ref8 : '');
777
+ const listingRef = firestore.collection('marketplaceListings').doc(listingId);
778
+ const versionRef = listingRef.collection('pluginVersions').doc(version);
779
+ const versionSnapshot = await versionRef.get();
780
+ if (!versionSnapshot.exists) {
781
+ return Response.json({
782
+ error: 'Unknown version'
783
+ }, {
784
+ status: 404
785
+ });
786
+ }
787
+ // The LISTING has to exist too, and the check belongs up here rather
788
+ // than beside the writes that need it (AGL-1766). A version outliving
789
+ // its listing is not hypothetical — Firestore deletes are not
790
+ // recursive, so `pluginVersions` survives whatever removed the parent.
791
+ //
792
+ // The read below used to be `(await listingRef.get()).data() ?? {}`,
793
+ // and that default was not a convenience: it is what made the two
794
+ // mirrors fire. Both of their conditions are TRUE on `{}` —
795
+ // `!['listed','verified'].includes('')` and `previous == null` — so an
796
+ // absent listing was resurrected as `{ reviewStatus: 'listed' }`, i.e.
797
+ // browsable in the marketplace, from a merge-set. (The third mirror,
798
+ // `latestVersionReviewState`, compares `String(latestVersion ?? '')`
799
+ // against a non-empty `version` and is false on `{}` — it could not
800
+ // fire. The pattern is not uniform even inside one block, which is
801
+ // exactly why AGL-1763 could not express this as a lint rule.)
802
+ //
803
+ // Read BEFORE the verdict write, not after it. Refusing must not
804
+ // discard work that already happened (AGL-1760); refusing here happens
805
+ // before anything has, so the reviewer simply retries against a
806
+ // listing that exists. `?? {}` is gone with it — kept, it would have
807
+ // traded a resurrection for an `update()` rejection served as a 500.
808
+ const listingSnapshot = await listingRef.get();
809
+ if (!listingSnapshot.exists) {
810
+ return Response.json({
811
+ error: 'Unknown listing'
812
+ }, {
813
+ status: 404
814
+ });
815
+ }
816
+ const listing = listingSnapshot.data();
817
+ const sha256 = String((_versionSnapshot_get2 = versionSnapshot.get('sha256')) != null ? _versionSnapshot_get2 : '');
818
+ const approving = action === 'approve-version';
819
+ if (approving) {
820
+ const outstanding = outstandingChecklistItems(versionSnapshot.get('reviewChecklist'), sha256);
821
+ if (outstanding.length) {
822
+ return Response.json({
823
+ error: `Review checklist incomplete — ${outstanding.length} required item(s) outstanding for these bytes`,
824
+ outstanding
825
+ }, {
826
+ status: 409
827
+ });
828
+ }
829
+ }
830
+ const reason = String((_ref9 = body == null ? void 0 : body.reason) != null ? _ref9 : '').slice(0, 500);
831
+ // Structured rejection (AGL-977). The category is what makes rejections
832
+ // comparable across reviewers and queryable afterwards; the comment is
833
+ // what makes one actionable. Requiring only free text gave neither.
834
+ const category = String((_ref10 = body == null ? void 0 : body.category) != null ? _ref10 : '');
835
+ if (!approving) {
836
+ const invalid = rejectionInputError(category, reason);
837
+ if (invalid) return Response.json({
838
+ error: invalid
839
+ }, {
840
+ status: 400
841
+ });
842
+ }
843
+ await versionRef.set(_extends({
844
+ reviewState: approving ? 'approved' : 'rejected',
845
+ reviewedBy: decoded.uid,
846
+ reviewedAt: FieldValue.serverTimestamp(),
847
+ // Records WHICH bytes were approved, so a later republish under
848
+ // the same version string can never inherit this verdict.
849
+ reviewedSha256: sha256,
850
+ grandfathered: FieldValue.delete()
851
+ }, approving ? {
852
+ // Clear a previous rejection's fields on approval, or a
853
+ // version that was rejected and then approved keeps
854
+ // explaining why it was turned down.
855
+ reviewRejectionReason: FieldValue.delete(),
856
+ reviewRejectionCategory: FieldValue.delete()
857
+ } : {
858
+ reviewRejectionReason: reason,
859
+ reviewRejectionCategory: category,
860
+ // REALM TRUST IS WITHDRAWN WITH THE APPROVAL (AGL-2306).
861
+ //
862
+ // `sign-plugin` refuses to grant `trust: 'realm'` unless the
863
+ // version is approved — approval gates the grant. Nothing
864
+ // ungated it: the realm load path checks `hiddenAt`, the sha,
865
+ // the signature and the revocation, and never re-reads
866
+ // `reviewState`. So approve → sign → re-review → reject left
867
+ // the version loading IN THE APP REALM, where neither the
868
+ // sandbox iframe nor the plugin-origin CSP is between it and
869
+ // user data, until staff separately reached for the kill
870
+ // switch.
871
+ //
872
+ // The same four fields `sign-plugin`'s own revoke branch
873
+ // clears, so there is one shape of "untrusted" rather than
874
+ // two. This does NOT stop the bundle running as a sandboxed
875
+ // plugin — that is what the per-version revocation beside
876
+ // this decision is for, and it is deliberately a separate
877
+ // choice (a thin README is the usual rejection, and an
878
+ // unannounced site outage is worse). It withdraws only the
879
+ // privilege that approval was the precondition for.
880
+ trust: FieldValue.delete(),
881
+ signature: FieldValue.delete(),
882
+ trustGrantedBy: FieldValue.delete(),
883
+ trustGrantedAt: FieldValue.delete()
884
+ }), {
885
+ merge: true
886
+ });
887
+ // A plugin with its first approved version becomes browsable. Later
888
+ // approvals never change listing status — that is the point: an
889
+ // update is reviewed without disturbing what customers already have.
890
+ //
891
+ // The three mirrors below write through `updateExisting` as the second
892
+ // line for the window the check above cannot close — a listing deleted
893
+ // mid-handler. Every payload here is FLAT with no delete sentinel, so
894
+ // the switch is semantically identical to the merge-set it replaces;
895
+ // the `false` return needs no branch, because a listing that is gone by
896
+ // now wants nothing mirrored onto it.
897
+ // Mirror this verdict onto the listing for the marketplace (AGL-1121),
898
+ // but ONLY when it is the newest version. Approving an old version
899
+ // must not let the listing claim its newest bytes were reviewed —
900
+ // which is the exact shape of the bug this mirror exists to fix.
901
+ if (String((_listing_latestVersion = listing.latestVersion) != null ? _listing_latestVersion : '') === version) {
902
+ await updateExisting(listingRef, {
903
+ latestVersionReviewState: approving ? 'approved' : 'rejected'
904
+ });
905
+ }
906
+ if (approving && ![
907
+ 'listed',
908
+ 'verified'
909
+ ].includes(String((_listing_reviewStatus = listing.reviewStatus) != null ? _listing_reviewStatus : ''))) {
910
+ await updateExisting(listingRef, {
911
+ reviewStatus: 'listed',
912
+ updatedAt: FieldValue.serverTimestamp()
913
+ });
914
+ }
915
+ // Denormalise the newest approved version onto the listing (AGL-1016).
916
+ // `pluginVersions` is server-only, so a console showing "update
917
+ // available" has nothing else to read — and reading `latestVersion`
918
+ // instead would advertise a version the install route refuses, leaking
919
+ // AGL-966's guarantee back out through a badge.
920
+ //
921
+ // Only ever moves forward: approving an older version after a newer one
922
+ // is a real sequence (a reviewer working through a backlog) and must not
923
+ // walk the offer backwards.
924
+ if (approving) {
925
+ var _compareArtifactVersions;
926
+ const previous = listing.latestApprovedVersion;
927
+ if (previous == null || ((_compareArtifactVersions = compareArtifactVersions(String(previous), version)) != null ? _compareArtifactVersions : 0) < 0) {
928
+ await updateExisting(listingRef, {
929
+ latestApprovedVersion: version,
930
+ updatedAt: FieldValue.serverTimestamp()
931
+ });
932
+ }
933
+ } else if (String((_listing_latestApprovedVersion = listing.latestApprovedVersion) != null ? _listing_latestApprovedVersion : '') === version) {
934
+ // …and BACK when the version being offered is the one just rejected
935
+ // (AGL-2306). Nothing did this, so a re-review that turned a live
936
+ // version down left the listing advertising it — an "Update to vX"
937
+ // for bytes `install-plugin` answers 409 to.
938
+ const revocation = (await firestore.collection('revocations').doc(listingId).get()).data();
939
+ await repairLatestApprovedVersion(listingRef, revocation);
940
+ }
941
+ await firestore.collection('adminAudit').add({
942
+ actorUid: decoded.uid,
943
+ action: `plugins.review.version.${approving ? 'approve' : 'reject'}`,
944
+ target: `marketplaceListings/${listingId}/pluginVersions/${version}`,
945
+ after: _extends({
946
+ sha256
947
+ }, reason ? {
948
+ reason
949
+ } : {}, category ? {
950
+ category
951
+ } : {}),
952
+ at: FieldValue.serverTimestamp()
953
+ });
954
+ // Pins sitting on these exact bytes right now (AGL-1085). Rejecting a
955
+ // version does NOT stop it — the runtime resolves a pin by
956
+ // {version, sha256} and only asks whether it is revoked — so both
957
+ // sides have to be told, and the reviewer needs the number before
958
+ // deciding whether to reach for the kill switch.
959
+ const liveInstalls = Number((_versionSnapshot_get3 = versionSnapshot.get('activeInstalls')) != null ? _versionSnapshot_get3 : 0);
960
+ const stranded = !approving && liveInstalls > 0;
961
+ if (listing.profileId) {
962
+ await notifyOrgAdmins(String(listing.profileId), {
963
+ type: 'marketplace.review',
964
+ title: approving ? `"${listing.displayName}" v${version} approved` : `"${listing.displayName}" v${version} was not approved`,
965
+ body: approving ? 'It is now the version new installs receive.' : stranded ? `${reason}\n\nThis version is still installed on ` + `${liveInstalls} site${liveInstalls === 1 ? '' : 's'}. ` + 'Rejecting it does not remove it — uninstall or roll back.' : reason,
966
+ orgId: String(listing.profileId),
967
+ link: '/'
968
+ }).catch(()=>undefined);
969
+ await emailPublisher(String(listing.profileId), approving ? `${listing.displayName} v${version} passed review` : `${listing.displayName} v${version} was not approved`, approving ? `Your plugin version ${version} has been approved and is now ` + 'the version new installs receive. Existing installs stay on ' + 'the version they pinned until their site owners upgrade.' : `Your plugin version ${version} was not approved.\n\n` + `Reason: ${reason}\n\nPublishing a new version puts it back ` + 'in the review queue. The previously approved version, if any, ' + 'keeps installing in the meantime.' + (stranded ? `\n\nNote: v${version} is still installed on ` + `${liveInstalls} site${liveInstalls === 1 ? '' : 's'}. ` + 'A rejection stops new installs, it does not remove an ' + 'existing one — uninstall it or roll back to an approved ' + 'version.' : ''));
970
+ }
971
+ return Response.json({
972
+ ok: true,
973
+ version,
974
+ reviewState: approving ? 'approved' : 'rejected',
975
+ // Lets the reviewer's confirmation say what it actually did, and
976
+ // offer the kill switch when the bytes are live somewhere.
977
+ liveInstalls,
978
+ stranded
979
+ }, {
980
+ status: 200
981
+ });
982
+ }
983
+ // Per-version kill switch (AGL-1085). Until now a reviewer's only way to
984
+ // stop live bytes was a listing-wide takedown, which also revokes the
985
+ // approved version customers are happily running and hides the listing.
986
+ // Rejection deliberately stays a verdict rather than a kill: most
987
+ // rejections are a thin README, and an unannounced site outage is worse
988
+ // than the gap. This is the deliberate, adjacent action.
989
+ //
990
+ // This does NOT strip the Verified badge, unlike a takedown (AGL-1121).
991
+ // The two claims are scoped differently and the split is the whole point:
992
+ // Verified says a human vouched for the PUBLISHER and survives version
993
+ // bumps, while the separate "Reviewed" chip speaks for THESE bytes and is
994
+ // re-earned per version. Stripping the publisher claim for one bad release
995
+ // would punish an honest mistake, and is not reversible without a full
996
+ // re-review. A takedown is the action that contradicts the publisher claim,
997
+ // and that one does strip it.
998
+ //
999
+ // That reasoning only holds because the BYTES claim is withdrawn instead —
1000
+ // which it now is, below. It was not before, and the gap is what made
1001
+ // keeping the badge unsafe rather than merely debatable.
1002
+ if (action === 'revoke-version' || action === 'unrevoke-version') {
1003
+ var _ref11, _data, _listing_latestVersion1, _ref12;
1004
+ const version = String((_ref11 = body == null ? void 0 : body.version) != null ? _ref11 : '');
1005
+ if (!listingId || !version) {
1006
+ return Response.json({
1007
+ error: 'Listing and version required'
1008
+ }, {
1009
+ status: 400
1010
+ });
1011
+ }
1012
+ const listingRef = firestore.collection('marketplaceListings').doc(listingId);
1013
+ const listing = (_data = (await listingRef.get()).data()) != null ? _data : {};
1014
+ const revocationRef = firestore.collection('revocations').doc(listingId);
1015
+ const current = (await revocationRef.get()).data();
1016
+ const revoking = action === 'revoke-version';
1017
+ const next = nextRevocationState(current, {
1018
+ type: revoking ? 'revoke-version' : 'unrevoke-version',
1019
+ version
1020
+ });
1021
+ if (next) {
1022
+ var _ref13, _ref14;
1023
+ await revocationRef.set(_extends({}, next, {
1024
+ reason: String((_ref13 = (_ref14 = body == null ? void 0 : body.reason) != null ? _ref14 : current == null ? void 0 : current.reason) != null ? _ref13 : '').slice(0, 500),
1025
+ revokedBy: decoded.uid,
1026
+ revokedAt: FieldValue.serverTimestamp()
1027
+ }), {
1028
+ merge: true
1029
+ });
1030
+ } else {
1031
+ await revocationRef.delete();
1032
+ }
1033
+ // Same reach as the takedown path above (AGL-1152): a per-version
1034
+ // revocation changes what every cached page renders, and nothing else
1035
+ // drops that HTML.
1036
+ await dropCachesForListing(firestore, listingId);
1037
+ // Withdraw the "Reviewed" chip when the bytes we just killed are the ones
1038
+ // customers are being offered (AGL-1121).
1039
+ //
1040
+ // This was ASSUMED to happen already and did not. `latestVersionReviewState`
1041
+ // is written only by approve/reject and on publish, so a version approved
1042
+ // and later revoked kept `'approved'` — and since a per-version revocation
1043
+ // deliberately does NOT hide the listing (that is the whole difference from
1044
+ // a takedown), the marketplace went on showing "A human at Aglyn read these
1045
+ // exact bytes" about bytes we had just stopped from executing. Revocation
1046
+ // is not surfaced anywhere else in the browse or listing UI either, so
1047
+ // nothing contradicted it.
1048
+ //
1049
+ // `revoked` rather than `rejected`: it was not turned down in review, it
1050
+ // was killed afterwards, and the audit trail should not blur those. Both
1051
+ // consumers test against `'approved'`, so this correctly drops the chip and
1052
+ // raises the caution alert.
1053
+ //
1054
+ // Only when it is the LATEST version, because that is what both consumers
1055
+ // describe. Revoking an old version says nothing about what is on offer.
1056
+ const isLatest = revocationWithdrawsReviewedClaim({
1057
+ revokedVersion: version,
1058
+ latestVersion: String((_listing_latestVersion1 = listing['latestVersion']) != null ? _listing_latestVersion1 : '')
1059
+ });
1060
+ if (isLatest) {
1061
+ var _versionSnapshot_get4;
1062
+ const versionSnapshot = await listingRef.collection('pluginVersions').doc(version).get();
1063
+ await listingRef.set({
1064
+ latestVersionReviewState: revoking ? REVOKED_VERSION_REVIEW_STATE : // says, rather than assuming `approved` — un-revoking a version
1065
+ // that was never approved must not promote it.
1066
+ String((_versionSnapshot_get4 = versionSnapshot.get('reviewState')) != null ? _versionSnapshot_get4 : 'pending')
1067
+ }, {
1068
+ merge: true
1069
+ });
1070
+ }
1071
+ // The offer follows the kill switch (AGL-2306). A revoked version is
1072
+ // refused by `install-plugin`, so leaving it as `latestApprovedVersion`
1073
+ // advertises an update that cannot be taken; un-revoking restores it.
1074
+ // Both directions, because both change what is installable.
1075
+ await repairLatestApprovedVersion(listingRef, next);
1076
+ await firestore.collection('adminAudit').add({
1077
+ actorUid: decoded.uid,
1078
+ action: `plugins.revocation.${revoking ? 'revoke' : 'restore'}`,
1079
+ target: `marketplaceListings/${listingId}/pluginVersions/${version}`,
1080
+ after: _extends({
1081
+ versions: (_ref12 = next == null ? void 0 : next.versions) != null ? _ref12 : null
1082
+ }, isLatest ? {
1083
+ latestVersionReviewStateChanged: true
1084
+ } : {}),
1085
+ at: FieldValue.serverTimestamp()
1086
+ });
1087
+ if (listing.profileId) {
1088
+ await notifyOrgAdmins(String(listing.profileId), {
1089
+ type: 'marketplace.review',
1090
+ title: revoking ? `"${listing.displayName}" v${version} was stopped` : `"${listing.displayName}" v${version} was allowed to run again`,
1091
+ body: revoking ? 'It has been disabled on every site running it. Sites showing ' + 'this plugin now render a placeholder.' : 'Sites running it are no longer blocked.',
1092
+ orgId: String(listing.profileId),
1093
+ link: '/'
1094
+ }).catch(()=>undefined);
1095
+ }
1096
+ return Response.json({
1097
+ ok: true,
1098
+ version,
1099
+ revoked: revoking
1100
+ }, {
1101
+ status: 200
1102
+ });
1103
+ }
1104
+ // Declining a verification request (AGL-1217). Handled before the ACTIONS
1105
+ // lookup because it deliberately has NO `reviewStatus` to move to: the
1106
+ // listing stays exactly as live as it was. Refusing a badge is not a
1107
+ // verdict on the code, and routing it through the status ladder would
1108
+ // make it one.
1109
+ if (action === 'decline-verification') {
1110
+ var _ref15, _listing_profileId;
1111
+ var _listing_verificationRequest1;
1112
+ const declineReason = String((_ref15 = body == null ? void 0 : body.reason) != null ? _ref15 : '').trim().slice(0, 500);
1113
+ if (!listingId) {
1114
+ return Response.json({
1115
+ error: 'Unknown action'
1116
+ }, {
1117
+ status: 400
1118
+ });
1119
+ }
1120
+ // Required, unlike most fields here. The publisher is notified of this
1121
+ // and then has to wait out a cooldown before asking again; "no, and we
1122
+ // will not say why, try in a month" is not a usable answer.
1123
+ if (!declineReason) {
1124
+ return Response.json({
1125
+ error: 'A reason is required — the publisher is told it'
1126
+ }, {
1127
+ status: 400
1128
+ });
1129
+ }
1130
+ const listingRef = firestore.collection('marketplaceListings').doc(listingId);
1131
+ const listing = (await listingRef.get()).data();
1132
+ if (!listing) {
1133
+ return Response.json({
1134
+ error: 'Unknown listing'
1135
+ }, {
1136
+ status: 404
1137
+ });
1138
+ }
1139
+ if (((_listing_verificationRequest1 = listing.verificationRequest) == null ? void 0 : _listing_verificationRequest1.state) !== 'pending') {
1140
+ return Response.json({
1141
+ error: 'No verification request is waiting'
1142
+ }, {
1143
+ status: 409
1144
+ });
1145
+ }
1146
+ await listingRef.set({
1147
+ verificationRequest: _extends({}, listing.verificationRequest, {
1148
+ state: 'declined',
1149
+ decidedAt: FieldValue.serverTimestamp(),
1150
+ decidedBy: decoded.uid,
1151
+ declineReason
1152
+ })
1153
+ }, {
1154
+ merge: true
1155
+ });
1156
+ /*
1157
+ * THE AUDIT ROW THIS BRANCH NEVER WROTE (AGL-2328).
1158
+ *
1159
+ * Every sibling branch in this file audits, and the file header at the
1160
+ * top claims the route is audited. This one mutated
1161
+ * `verificationRequest.state`, notified the publisher and started a
1162
+ * cooldown, and left no record — so a decline was the one staff
1163
+ * decision here that could not be reviewed afterwards, on the action
1164
+ * whose reason staff are REQUIRED to type.
1165
+ *
1166
+ * `reason` uses the audit log's own reason column rather than being
1167
+ * buried in `after`, because the audit page renders and exports that
1168
+ * column and searches it (AGL-1652) — a why nobody can filter on is a
1169
+ * why nobody reads.
1170
+ */ await firestore.collection('adminAudit').add({
1171
+ actorUid: decoded.uid,
1172
+ actorEmail: decoded.email ? String(decoded.email) : null,
1173
+ action: 'plugins.verification.decline',
1174
+ scope: 'marketplace',
1175
+ target: `marketplaceListings/${listingId}`,
1176
+ before: {
1177
+ verificationState: 'pending'
1178
+ },
1179
+ after: {
1180
+ verificationState: 'declined',
1181
+ cooldownDays: VERIFICATION_DECLINE_COOLDOWN_DAYS
1182
+ },
1183
+ reason: declineReason,
1184
+ at: FieldValue.serverTimestamp()
1185
+ });
1186
+ const publisherOrgId = String((_listing_profileId = listing.profileId) != null ? _listing_profileId : '');
1187
+ if (publisherOrgId) {
1188
+ await notifyOrgAdmins(publisherOrgId, {
1189
+ type: 'marketplace.review',
1190
+ title: `Verification declined for "${listing.displayName}"`,
1191
+ // Says plainly that nothing about the listing changed. Without it a
1192
+ // publisher reads "declined" as a takedown of the plugin itself.
1193
+ body: `${declineReason}\n\nYour plugin is unaffected and stays listed. ` + `You can request verification again in ` + `${VERIFICATION_DECLINE_COOLDOWN_DAYS} days.`,
1194
+ orgId: publisherOrgId,
1195
+ link: '/'
1196
+ }).catch(()=>undefined);
1197
+ }
1198
+ return Response.json({
1199
+ ok: true,
1200
+ state: 'declined'
1201
+ }, {
1202
+ status: 200
1203
+ });
1204
+ }
1205
+ const nextStatus = ACTIONS[action];
1206
+ if (!listingId || !nextStatus) {
1207
+ return Response.json({
1208
+ error: 'Unknown action'
1209
+ }, {
1210
+ status: 400
1211
+ });
1212
+ }
1213
+ const reason = String((_ref2 = body == null ? void 0 : body.reason) != null ? _ref2 : '').slice(0, 500);
1214
+ const category = String((_ref3 = body == null ? void 0 : body.category) != null ? _ref3 : '');
1215
+ if (action === 'reject') {
1216
+ const invalid = rejectionInputError(category, reason);
1217
+ if (invalid) return Response.json({
1218
+ error: invalid
1219
+ }, {
1220
+ status: 400
1221
+ });
1222
+ }
1223
+ const listingRef = firestore.collection('marketplaceListings').doc(listingId);
1224
+ const listing = (await listingRef.get()).data();
1225
+ if (!listing) return Response.json({
1226
+ error: 'Unknown listing'
1227
+ }, {
1228
+ status: 404
1229
+ });
1230
+ // Both gates that expose a plugin to customers require a completed
1231
+ // checklist (AGL-963).
1232
+ //
1233
+ // `list` matters MORE than `verify`, which is easy to get backwards:
1234
+ // listing is what makes `isListingBrowsable` true, so it is the moment
1235
+ // executable third-party code becomes installable by every workspace.
1236
+ // `verify` only adds the badge on top. Gating the badge while leaving
1237
+ // listing open would put the ceremony on the weaker action.
1238
+ //
1239
+ // `start-review` and `reject` stay ungated on purpose: a reviewer must
1240
+ // be able to throw something out without first ticking eight boxes
1241
+ // about a bundle they have already decided against.
1242
+ if (action === 'verify' || action === 'list') {
1243
+ var _listing_latestVersion2, _versionSnapshot_get5;
1244
+ const version = String((_listing_latestVersion2 = listing.latestVersion) != null ? _listing_latestVersion2 : '');
1245
+ const versionSnapshot = await listingRef.collection('pluginVersions').doc(version).get();
1246
+ const outstanding = outstandingChecklistItems(versionSnapshot.get('reviewChecklist'), String((_versionSnapshot_get5 = versionSnapshot.get('sha256')) != null ? _versionSnapshot_get5 : ''));
1247
+ if (outstanding.length) {
1248
+ return Response.json({
1249
+ error: `Review checklist incomplete — ${outstanding.length} required item(s) outstanding`,
1250
+ outstanding
1251
+ }, {
1252
+ status: 409
1253
+ });
1254
+ }
1255
+ }
1256
+ await listingRef.set(_extends({
1257
+ reviewStatus: nextStatus,
1258
+ reviewedBy: decoded.uid,
1259
+ reviewedAt: FieldValue.serverTimestamp()
1260
+ }, action === 'verify' && ((_listing_verificationRequest = listing.verificationRequest) == null ? void 0 : _listing_verificationRequest.state) === 'pending' ? {
1261
+ verificationRequest: _extends({}, listing.verificationRequest, {
1262
+ state: 'granted',
1263
+ decidedAt: FieldValue.serverTimestamp(),
1264
+ decidedBy: decoded.uid
1265
+ })
1266
+ } : {}, action === 'reject' ? {
1267
+ rejectionReason: reason,
1268
+ rejectionCategory: category
1269
+ } : {
1270
+ rejectionReason: FieldValue.delete(),
1271
+ rejectionCategory: FieldValue.delete()
1272
+ }), {
1273
+ merge: true
1274
+ });
1275
+ // Tell the publisher their listing moved (rejections especially).
1276
+ if (listing.profileId && [
1277
+ 'reject',
1278
+ 'list',
1279
+ 'verify',
1280
+ 'delist'
1281
+ ].includes(action)) {
1282
+ var _listing_profileId1;
1283
+ // Publishers are ORGS now (AGL-652), so `profileId` is an org id —
1284
+ // writing to users/{profileId}/notifications silently dropped every
1285
+ // verdict into a user document nobody reads. Notify the org's managers.
1286
+ const publisherOrgId = String((_listing_profileId1 = listing.profileId) != null ? _listing_profileId1 : '');
1287
+ const publisherSlug = publisherOrgId ? (await firestore.collection('orgs').doc(publisherOrgId).get()).get('slug') : undefined;
1288
+ if (publisherOrgId) {
1289
+ var _pluginRejectionCategory, _pluginRejectionCategory1;
1290
+ await notifyOrgAdmins(publisherOrgId, {
1291
+ type: 'marketplace.review',
1292
+ title: action === 'reject' ? `"${listing.displayName}" was rejected` : action === 'delist' ? `"${listing.displayName}" is back in review` : `"${listing.displayName}" is now ${nextStatus}`,
1293
+ body: action === 'reject' ? // words (AGL-977). The publisher's first question is "what do
1294
+ // I fix" — a bare comment answered that only as well as the
1295
+ // reviewer happened to write it.
1296
+ [
1297
+ (_pluginRejectionCategory = pluginRejectionCategory(category)) == null ? void 0 : _pluginRejectionCategory.label,
1298
+ (_pluginRejectionCategory1 = pluginRejectionCategory(category)) == null ? void 0 : _pluginRejectionCategory1.guidance,
1299
+ reason
1300
+ ].filter(Boolean).join('\n\n') : action === 'delist' ? reason || 'It has been removed from the marketplace while we take ' + 'another look. Existing installs keep working.' : 'Your plugin passed review.',
1301
+ orgId: publisherOrgId,
1302
+ link: publisherSlug ? buildRoute(Route.ORG_MARKETPLACE, {
1303
+ orgSlug: publisherSlug
1304
+ }) : '/'
1305
+ }).catch(()=>undefined);
1306
+ 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
+ }
1308
+ }
1309
+ await firestore.collection('adminAudit').add({
1310
+ actorUid: decoded.uid,
1311
+ action: `plugins.review.${action}`,
1312
+ target: `marketplaceListings/${listingId}`,
1313
+ after: _extends({
1314
+ reviewStatus: nextStatus
1315
+ }, reason ? {
1316
+ reason
1317
+ } : {}),
1318
+ at: FieldValue.serverTimestamp()
1319
+ });
1320
+ return Response.json({
1321
+ ok: true,
1322
+ reviewStatus: nextStatus
1323
+ }, {
1324
+ status: 200
1325
+ });
1326
+ } catch (error) {
1327
+ // An unverifiable credential is a 401, not a fault of ours
1328
+ // (AGL-1993). Null for anything else, so a real failure keeps its 500.
1329
+ const unauthenticated = invalidIdTokenResponse(error);
1330
+ if (unauthenticated) return unauthenticated;
1331
+ console.error(error);
1332
+ return Response.json({
1333
+ error: 'Review action failed'
1334
+ }, {
1335
+ status: 500
1336
+ });
1337
+ }
1338
+ }
1339
+ export { handler as marketplaceAdminReviews };
1340
+
1341
+ //# sourceMappingURL=admin-reviews.js.map