@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.
- package/package.json +15 -11
- package/src/lib/components/edit-listing-dialog.component.d.ts +20 -0
- package/src/lib/components/edit-listing-dialog.component.js +51 -0
- package/src/lib/components/edit-listing-dialog.component.js.map +1 -0
- package/src/lib/components/listing-detail-editor.component.d.ts +24 -0
- package/src/lib/components/listing-detail-editor.component.js +457 -0
- package/src/lib/components/listing-detail-editor.component.js.map +1 -0
- package/src/lib/components/listing-detail-page.component.d.ts +28 -0
- package/src/lib/components/listing-detail-page.component.js +151 -0
- package/src/lib/components/listing-detail-page.component.js.map +1 -0
- package/src/lib/components/listing-review-status.component.d.ts +67 -0
- package/src/lib/components/listing-review-status.component.js +301 -0
- package/src/lib/components/listing-review-status.component.js.map +1 -0
- package/src/lib/components/listing-verification-request.component.d.ts +40 -0
- package/src/lib/components/listing-verification-request.component.js +170 -0
- package/src/lib/components/listing-verification-request.component.js.map +1 -0
- package/src/lib/components/marketplace-hub.component.d.ts +43 -0
- package/src/lib/components/marketplace-hub.component.js +244 -0
- package/src/lib/components/marketplace-hub.component.js.map +1 -0
- package/src/lib/components/marketplace-reports-page.component.d.ts +23 -0
- package/src/lib/components/marketplace-reports-page.component.js +324 -0
- package/src/lib/components/marketplace-reports-page.component.js.map +1 -0
- package/src/lib/components/org-licences-panel.component.d.ts +73 -0
- package/src/lib/components/org-licences-panel.component.js +318 -0
- package/src/lib/components/org-licences-panel.component.js.map +1 -0
- package/src/lib/components/org-publish-panel.component.d.ts +45 -0
- package/src/lib/components/org-publish-panel.component.js +402 -0
- package/src/lib/components/org-publish-panel.component.js.map +1 -0
- package/src/lib/components/org-seller-panel.component.d.ts +28 -0
- package/src/lib/components/org-seller-panel.component.js +956 -0
- package/src/lib/components/org-seller-panel.component.js.map +1 -0
- package/src/lib/components/plugin-review-detail.component.d.ts +16 -0
- package/src/lib/components/plugin-review-detail.component.js +1438 -0
- package/src/lib/components/plugin-review-detail.component.js.map +1 -0
- package/src/lib/components/plugin-reviews-queue.component.d.ts +13 -0
- package/src/lib/components/plugin-reviews-queue.component.js +385 -0
- package/src/lib/components/plugin-reviews-queue.component.js.map +1 -0
- package/src/lib/components/plugin-reviews-surface.component.d.ts +19 -0
- package/src/lib/components/plugin-reviews-surface.component.js +46 -0
- package/src/lib/components/plugin-reviews-surface.component.js.map +1 -0
- package/src/lib/components/publish-plugin-form.component.d.ts +61 -0
- package/src/lib/components/publish-plugin-form.component.js +857 -0
- package/src/lib/components/publish-plugin-form.component.js.map +1 -0
- package/src/lib/components/publish-plugin-page.component.d.ts +29 -0
- package/src/lib/components/publish-plugin-page.component.js +92 -0
- package/src/lib/components/publish-plugin-page.component.js.map +1 -0
- package/src/lib/components/publisher-profile-page.component.d.ts +13 -0
- package/src/lib/components/publisher-profile-page.component.js +261 -0
- package/src/lib/components/publisher-profile-page.component.js.map +1 -0
- package/src/lib/constants/listing-media.d.ts +28 -0
- package/src/lib/constants/listing-media.js +28 -0
- package/src/lib/constants/listing-media.js.map +1 -0
- package/src/lib/model/listing-social-card.d.ts +94 -0
- package/src/lib/model/listing-social-card.js +177 -0
- package/src/lib/model/listing-social-card.js.map +1 -0
- package/src/lib/model/marketplace-paths.d.ts +54 -0
- package/src/lib/model/marketplace-paths.js +59 -0
- package/src/lib/model/marketplace-paths.js.map +1 -0
- package/src/lib/model/payout-readiness.d.ts +81 -0
- package/src/lib/model/payout-readiness.js +52 -0
- package/src/lib/model/payout-readiness.js.map +1 -0
- package/src/lib/model/plugin-rejection-categories.d.ts +66 -0
- package/src/lib/model/plugin-rejection-categories.js +124 -0
- package/src/lib/model/plugin-rejection-categories.js.map +1 -0
- package/src/lib/model/plugin-review-checklist.d.ts +62 -0
- package/src/lib/model/plugin-review-checklist.js +131 -0
- package/src/lib/model/plugin-review-checklist.js.map +1 -0
- package/src/lib/model/plugin-review-status.d.ts +104 -0
- package/src/lib/model/plugin-review-status.js +126 -0
- package/src/lib/model/plugin-review-status.js.map +1 -0
- package/src/lib/model/seller-ledger-totals.d.ts +100 -0
- package/src/lib/model/seller-ledger-totals.js +96 -0
- package/src/lib/model/seller-ledger-totals.js.map +1 -0
- package/src/lib/plugin.js +170 -40
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/admin-reports.d.ts +18 -0
- package/src/lib/server/admin-reports.js +280 -0
- package/src/lib/server/admin-reports.js.map +1 -0
- package/src/lib/server/admin-reviews.d.ts +18 -0
- package/src/lib/server/admin-reviews.js +1341 -0
- package/src/lib/server/admin-reviews.js.map +1 -0
- package/src/lib/server/listing-social-card.server.d.ts +24 -0
- package/src/lib/server/listing-social-card.server.js +65 -0
- package/src/lib/server/listing-social-card.server.js.map +1 -0
- package/src/lib/server/publisher-review-email.d.ts +54 -0
- package/src/lib/server/publisher-review-email.js +85 -0
- package/src/lib/server/publisher-review-email.js.map +1 -0
- package/src/lib/server/revoke-cache-drop.d.ts +59 -0
- package/src/lib/server/revoke-cache-drop.js +74 -0
- package/src/lib/server/revoke-cache-drop.js.map +1 -0
- package/src/lib/server.js +47 -0
- package/src/lib/server.js.map +1 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { pluginRequestFromWeb } from "@aglyn/aglyn/server";
|
|
18
|
+
import * as Aglyn from "@aglyn/aglyn/server";
|
|
19
|
+
import { emailUnverifiedResponse, firebaseAdmin, isImpersonationSession } from "@aglyn/tenant-data-admin";
|
|
20
|
+
import { invalidIdTokenResponse } from "@aglyn/tenant-data-admin/server/id-token-refusal";
|
|
21
|
+
/**
|
|
22
|
+
* The staff end of the marketplace report button (AGL-2310), served at
|
|
23
|
+
* `/api/marketplace/admin/reports` for this plugin's Marketplace reports
|
|
24
|
+
* staff page.
|
|
25
|
+
*
|
|
26
|
+
* It was `apps/console/app/api/admin/marketplace-reports/route.ts` until
|
|
27
|
+
* AGL-3080. The console held it because the staff area had no way for a
|
|
28
|
+
* plugin to own a page; it does (`staffPages`, AGL-2939), and the queue reads
|
|
29
|
+
* a collection only this plugin writes.
|
|
30
|
+
*
|
|
31
|
+
* `./report.ts` has written
|
|
32
|
+
* `marketplaceReports` since the button shipped — `reason`, `reporterUid`,
|
|
33
|
+
* `listingName`, `publisherOrgId`, `targetType`, and `status: 'open'` — and
|
|
34
|
+
* NOTHING read it. No `collectionGroup`, no admin surface, no cron. The
|
|
35
|
+
* Firestore rules grant staff read, but rules are not a reader: every report a
|
|
36
|
+
* user filed was stored, acknowledged, and unreachable, and nothing ever moved
|
|
37
|
+
* one off `open` because no queue displayed it.
|
|
38
|
+
*
|
|
39
|
+
* The contrast that made it an omission rather than a design decision:
|
|
40
|
+
* `abuseReports` has the identical shape AND a real staff queue. This mirrors
|
|
41
|
+
* that queue's contract deliberately — list, transition, an `adminAudit` row
|
|
42
|
+
* per transition — rather than inventing a second vocabulary for the same act.
|
|
43
|
+
*
|
|
44
|
+
* ## The reporter's identity is a super-tier fact
|
|
45
|
+
*
|
|
46
|
+
* `reporterUid` follows the same rule the abuse queue applies to a reporter's
|
|
47
|
+
* email: `support` triages without it. A uid is pseudonymous rather than
|
|
48
|
+
* personal, but it joins to a real account in one hop, and the read-only tier
|
|
49
|
+
* has no reason to make that hop. `reporterKnown` stays visible to everyone,
|
|
50
|
+
* because the useful distinction for triage is a report with somebody behind
|
|
51
|
+
* it versus one without — not who.
|
|
52
|
+
*/ /**
|
|
53
|
+
* The reports collection, named once.
|
|
54
|
+
*
|
|
55
|
+
* The audit row's `target` is built from this, so the path staff search for
|
|
56
|
+
* and the document the route actually mutates cannot drift apart.
|
|
57
|
+
*/ const REPORTS_COLLECTION = 'marketplaceReports';
|
|
58
|
+
const REPORT_ID = /^[a-f0-9]{40}$/;
|
|
59
|
+
const asString = (value)=>typeof value === 'string' && value ? value : null;
|
|
60
|
+
const asMillis = (value)=>{
|
|
61
|
+
const candidate = value;
|
|
62
|
+
if (typeof (candidate == null ? void 0 : candidate.toMillis) === 'function') {
|
|
63
|
+
const millis = candidate.toMillis();
|
|
64
|
+
return Number.isFinite(millis) ? millis : null;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Staff below `super` triage without the reporter's account (AGL-1964's rule,
|
|
70
|
+
* applied here for the same reason).
|
|
71
|
+
*
|
|
72
|
+
* A MISSING claim resolves to `support`, never to `super`. Failing open would
|
|
73
|
+
* hand identity to any staff token minted before the RBAC claim existed.
|
|
74
|
+
*/ const identityVisible = (decoded)=>{
|
|
75
|
+
var _decoded_staffRole;
|
|
76
|
+
return String((_decoded_staffRole = decoded['staffRole']) != null ? _decoded_staffRole : 'support') === 'super';
|
|
77
|
+
};
|
|
78
|
+
async function handler(request) {
|
|
79
|
+
var _headers_authorization;
|
|
80
|
+
const { method, query, body, headers: rawHeaders } = await pluginRequestFromWeb(request);
|
|
81
|
+
const headers = rawHeaders;
|
|
82
|
+
const authorization = (_headers_authorization = headers.authorization) != null ? _headers_authorization : '';
|
|
83
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
84
|
+
if (!idToken) return Response.json({
|
|
85
|
+
error: 'Unauthenticated'
|
|
86
|
+
}, {
|
|
87
|
+
status: 401
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
var _ref, _ref1, _ref2, _asString;
|
|
91
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
92
|
+
if (!decoded.email_verified && !isImpersonationSession(decoded)) {
|
|
93
|
+
return emailUnverifiedResponse();
|
|
94
|
+
}
|
|
95
|
+
if (!decoded['staff']) {
|
|
96
|
+
return Response.json({
|
|
97
|
+
error: 'Staff only'
|
|
98
|
+
}, {
|
|
99
|
+
status: 403
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
103
|
+
const collection = firestore.collection(REPORTS_COLLECTION);
|
|
104
|
+
const showIdentity = identityVisible(decoded);
|
|
105
|
+
if (method === 'GET') {
|
|
106
|
+
var _ref3;
|
|
107
|
+
/**
|
|
108
|
+
* Ordered by `updatedAt`, filtered by status in JS.
|
|
109
|
+
*
|
|
110
|
+
* A `where('status','==',…)` beside this ordering is a composite index,
|
|
111
|
+
* and a composite deploys by hand outside the git pipeline — the queue
|
|
112
|
+
* would have shipped ahead of its index and thrown FAILED_PRECONDITION
|
|
113
|
+
* on the one surface that exists to be read. The window is small enough
|
|
114
|
+
* that filtering it here costs nothing.
|
|
115
|
+
*/ const requested = String((_ref3 = query == null ? void 0 : query['status']) != null ? _ref3 : '');
|
|
116
|
+
const snapshot = await collection.orderBy('updatedAt', 'desc').limit(200).get()// Reports written before `updatedAt` existed still have to appear —
|
|
117
|
+
// an ordering that silently drops rows is how a queue reads as empty.
|
|
118
|
+
.catch(()=>collection.limit(200).get());
|
|
119
|
+
const reports = snapshot.docs.map((doc)=>{
|
|
120
|
+
var _asString, _asString1;
|
|
121
|
+
const data = doc.data();
|
|
122
|
+
return {
|
|
123
|
+
id: doc.id,
|
|
124
|
+
status: (_asString = asString(data['status'])) != null ? _asString : 'open',
|
|
125
|
+
targetType: (_asString1 = asString(data['targetType'])) != null ? _asString1 : 'listing',
|
|
126
|
+
listingId: asString(data['listingId']),
|
|
127
|
+
listingName: asString(data['listingName']),
|
|
128
|
+
publisherOrgId: asString(data['publisherOrgId']),
|
|
129
|
+
reviewUid: showIdentity ? asString(data['reviewUid']) : null,
|
|
130
|
+
// The report IS the reason. Everything else on the row is context
|
|
131
|
+
// for it, which is why it is never redacted by tier.
|
|
132
|
+
reason: asString(data['reason']),
|
|
133
|
+
// Told apart from a redacted one, so `support` can still see that
|
|
134
|
+
// there IS somebody to follow up with (AGL-1964's distinction).
|
|
135
|
+
reporterKnown: Boolean(asString(data['reporterUid'])),
|
|
136
|
+
reporterUid: showIdentity ? asString(data['reporterUid']) : null,
|
|
137
|
+
resolution: asString(data['resolution']),
|
|
138
|
+
resolvedByEmail: asString(data['resolvedByEmail']),
|
|
139
|
+
createdAtMs: asMillis(data['createdAt']),
|
|
140
|
+
updatedAtMs: asMillis(data['updatedAt'])
|
|
141
|
+
};
|
|
142
|
+
}).filter((report)=>!requested || report.status === requested);
|
|
143
|
+
return Response.json({
|
|
144
|
+
reports,
|
|
145
|
+
actorRole: showIdentity ? 'super' : 'support',
|
|
146
|
+
identityVisible: showIdentity,
|
|
147
|
+
statuses: Aglyn.ABUSE_REPORT_STATUSES
|
|
148
|
+
}, {
|
|
149
|
+
status: 200,
|
|
150
|
+
headers: {
|
|
151
|
+
'Cache-Control': 'no-store'
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (method !== 'POST') {
|
|
156
|
+
return Response.json({
|
|
157
|
+
error: 'Method not allowed'
|
|
158
|
+
}, {
|
|
159
|
+
status: 405
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const id = String((_ref = body == null ? void 0 : body['id']) != null ? _ref : '').trim();
|
|
163
|
+
if (!REPORT_ID.test(id)) {
|
|
164
|
+
return Response.json({
|
|
165
|
+
error: 'id is missing or malformed'
|
|
166
|
+
}, {
|
|
167
|
+
status: 400
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const status = String((_ref1 = body == null ? void 0 : body['status']) != null ? _ref1 : '');
|
|
171
|
+
if (!Aglyn.isAbuseReportStatus(status)) {
|
|
172
|
+
return Response.json({
|
|
173
|
+
error: `status must be one of ${Aglyn.ABUSE_REPORT_STATUSES.join(', ')}`
|
|
174
|
+
}, {
|
|
175
|
+
status: 400
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// Required to CLOSE, optional otherwise — the abuse queue's rule, for the
|
|
179
|
+
// abuse queue's reason: "actioned" with no note is the row that, months
|
|
180
|
+
// later, nobody can act on or defend.
|
|
181
|
+
const resolution = String((_ref2 = body == null ? void 0 : body['resolution']) != null ? _ref2 : '').trim().slice(0, 2000);
|
|
182
|
+
const closing = status === 'actioned' || status === 'dismissed';
|
|
183
|
+
if (closing && !resolution) {
|
|
184
|
+
return Response.json({
|
|
185
|
+
error: 'Say what you did — a closed report with no note is unreadable ' + 'later'
|
|
186
|
+
}, {
|
|
187
|
+
status: 400
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const ref = collection.doc(id);
|
|
191
|
+
const before = await ref.get();
|
|
192
|
+
if (!before.exists) {
|
|
193
|
+
return Response.json({
|
|
194
|
+
error: 'No such report'
|
|
195
|
+
}, {
|
|
196
|
+
status: 404
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const beforeStatus = (_asString = asString(before.get('status'))) != null ? _asString : 'open';
|
|
200
|
+
// Present only when a REVIEW was reported; a reported listing has none.
|
|
201
|
+
const reportedReviewUid = asString(before.get('reviewUid'));
|
|
202
|
+
const FieldValue = firebaseAdmin.firestore.FieldValue;
|
|
203
|
+
await ref.set({
|
|
204
|
+
status,
|
|
205
|
+
resolution: resolution || null,
|
|
206
|
+
resolvedByUid: closing ? decoded.uid : null,
|
|
207
|
+
resolvedByEmail: closing && decoded.email ? String(decoded.email) : null,
|
|
208
|
+
resolvedAt: closing ? FieldValue.serverTimestamp() : null,
|
|
209
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
210
|
+
}, {
|
|
211
|
+
merge: true
|
|
212
|
+
});
|
|
213
|
+
/**
|
|
214
|
+
* The audit row carries BOTH statuses.
|
|
215
|
+
*
|
|
216
|
+
* A row saying only "someone set this to dismissed" cannot answer the
|
|
217
|
+
* question anyone actually asks later — what was it before, and who
|
|
218
|
+
* decided. `before`/`after` here match the abuse queue's shape so the two
|
|
219
|
+
* queues read the same way in one audit log.
|
|
220
|
+
*/ /*
|
|
221
|
+
* `target` AND `scope`, the same two fields the abuse queue writes.
|
|
222
|
+
*
|
|
223
|
+
* An entry nobody can retrieve is indistinguishable from one that was
|
|
224
|
+
* never written. `target` is what every reader of this collection looks
|
|
225
|
+
* up by — the audit log page filters on it and exports it, and the org
|
|
226
|
+
* page matches it by substring — so a row without one is recorded and
|
|
227
|
+
* unreachable by the thing it acted on. It is the document this route
|
|
228
|
+
* mutates: the report. The listing stays beside it, because the row has
|
|
229
|
+
* to still mean something a year later.
|
|
230
|
+
*
|
|
231
|
+
* `targetType`/`targetId` are gone rather than kept alongside. Nothing
|
|
232
|
+
* read either, and `targetType` here said `marketplaceReport` while
|
|
233
|
+
* `targetType` on the report document says `listing` or `review` — the
|
|
234
|
+
* same key naming two different things one hop apart.
|
|
235
|
+
*/ await firestore.collection('adminAudit').add(_extends({
|
|
236
|
+
action: 'marketplace-report-status',
|
|
237
|
+
actorUid: decoded.uid,
|
|
238
|
+
actorEmail: decoded.email ? String(decoded.email) : null,
|
|
239
|
+
scope: 'marketplaceReport',
|
|
240
|
+
target: `${REPORTS_COLLECTION}/${id}`
|
|
241
|
+
}, reportedReviewUid ? {
|
|
242
|
+
subjectUid: reportedReviewUid
|
|
243
|
+
} : {}, {
|
|
244
|
+
listingId: asString(before.get('listingId')),
|
|
245
|
+
before: {
|
|
246
|
+
status: beforeStatus
|
|
247
|
+
},
|
|
248
|
+
after: {
|
|
249
|
+
status,
|
|
250
|
+
resolution: resolution || null
|
|
251
|
+
},
|
|
252
|
+
at: FieldValue.serverTimestamp()
|
|
253
|
+
}));
|
|
254
|
+
return Response.json({
|
|
255
|
+
ok: true,
|
|
256
|
+
id,
|
|
257
|
+
status,
|
|
258
|
+
previousStatus: beforeStatus
|
|
259
|
+
}, {
|
|
260
|
+
status: 200,
|
|
261
|
+
headers: {
|
|
262
|
+
'Cache-Control': 'no-store'
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
// An unverifiable credential is a 401, not a fault of ours
|
|
267
|
+
// (AGL-1993). Null for anything else, so a real failure keeps its 500.
|
|
268
|
+
const unauthenticated = invalidIdTokenResponse(error);
|
|
269
|
+
if (unauthenticated) return unauthenticated;
|
|
270
|
+
console.error(error);
|
|
271
|
+
return Response.json({
|
|
272
|
+
error: 'Marketplace reports failed'
|
|
273
|
+
}, {
|
|
274
|
+
status: 500
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
export { handler as marketplaceAdminReports };
|
|
279
|
+
|
|
280
|
+
//# sourceMappingURL=admin-reports.js.map
|
|
@@ -0,0 +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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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
declare function handler(request: Request): Promise<Response>;
|
|
18
|
+
export { handler as marketplaceAdminReviews };
|