Skip to content

Commit 02d72af

Browse files
authored
feat(admin): registry moderation UI and blocked-install gating (#1977)
* feat(admin): show registry moderation state and gate blocked installs The registry browser now evaluates moderation labels client-side: blocked releases stay visible but their install is gated with an explanation panel, warning-labelled releases surface their warnings in the install consent dialog, browse cards mark blocked packages, and the plugin manager flags blocked or warned updates. Each response is evaluated against the accepted-labeler policy that produced it — the atproto-content-labelers header travels with the data via a per-call discovery client. Localized label names come from a descriptor map over the canonical vocabulary, falling back to the raw value for anything unknown. Removes the legacy security:yanked string filter that silently hid yanked releases. * fix(admin): evaluate package labels against their own response policy Adversarial review: package-scope labels ride on the package response while release-scope labels ride on the releases response, each with its own atproto-content-labelers header. Evaluating both against only the releases policy could filter out a package/publisher block whose labeler that response did not report. The two header-derived policies are now unioned. Adds coverage for the header-precedence path and a package-scope block surfaced only via the package response header. * fix(admin): localize the release-holdback duration units Bot review: formatHoldback returned bare English units. Route them through Lingui plurals like the rest of the admin.
1 parent c1c83c4 commit 02d72af

12 files changed

Lines changed: 1408 additions & 147 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@emdash-cms/admin": patch
3+
---
4+
5+
The registry browser now shows moderation state: blocked releases are visibly blocked with an explanation, warning-labelled releases ask for confirmation before install, and the plugin manager flags blocked or warned updates.

packages/admin/src/components/CapabilityConsentDialog.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,29 @@ import { describeCapability } from "../lib/api/marketplace.js";
1515
import { cn } from "../lib/utils.js";
1616
import { DialogError } from "./DialogError.js";
1717

18+
/** A moderation label, pre-resolved to localized display text by the caller. */
19+
export interface ModerationLabelEntry {
20+
value: string;
21+
name: string;
22+
description: string | null;
23+
/** DID of the labeler that issued this label. */
24+
issuerDid: string;
25+
}
26+
1827
export interface CapabilityConsentDialogProps {
1928
/** Dialog mode */
2029
mode?: "install" | "update";
2130
/** Plugin display name */
2231
pluginName: string;
2332
/** Capabilities the plugin requests */
2433
capabilities: string[];
34+
/**
35+
* Active moderation warning labels on the release being installed/updated.
36+
* Shown above the capabilities list whenever non-empty -- including when
37+
* `capabilities` is empty, in which case the dialog shows only this
38+
* section.
39+
*/
40+
moderationWarnings?: ModerationLabelEntry[];
2541
/** Allowed network hosts (for network:fetch capability) */
2642
allowedHosts?: string[];
2743
/** New capabilities added in an update (highlighted differently) */
@@ -44,6 +60,7 @@ export function CapabilityConsentDialog({
4460
mode,
4561
pluginName,
4662
capabilities,
63+
moderationWarnings = [],
4764
allowedHosts,
4865
newCapabilities = [],
4966
newlyPublicRoutes = [],
@@ -81,6 +98,32 @@ export function CapabilityConsentDialog({
8198
</p>
8299
</div>
83100

101+
{/* Moderation warnings */}
102+
{moderationWarnings.length > 0 && (
103+
<div className="px-6 pt-4">
104+
<div
105+
className="rounded-md border border-kumo-warning bg-kumo-warning/10 p-3"
106+
role="status"
107+
>
108+
<div className="flex items-center gap-2 text-sm font-medium text-kumo-warning">
109+
<Warning className="h-4 w-4 shrink-0" />
110+
{t`Moderation warnings`}
111+
</div>
112+
<ul className="mt-2 space-y-2 text-sm">
113+
{moderationWarnings.map((warning) => (
114+
<li key={`${warning.value}-${warning.issuerDid}`}>
115+
<p className="font-medium text-kumo-default">{warning.name}</p>
116+
{warning.description ? (
117+
<p className="text-kumo-subtle">{warning.description}</p>
118+
) : null}
119+
<p className="text-xs text-kumo-subtle">{t`Issued by ${warning.issuerDid}`}</p>
120+
</li>
121+
))}
122+
</ul>
123+
</div>
124+
</div>
125+
)}
126+
84127
{/* Capabilities list */}
85128
<div className="px-6 py-4 space-y-3">
86129
{capabilities.map((cap) => {

packages/admin/src/components/PluginManager.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* update/uninstall for marketplace-installed plugins.
77
*/
88

9-
import { Badge, Button, Checkbox, Switch, Toast } from "@cloudflare/kumo";
9+
import { Badge, Button, Checkbox, Switch, Toast, Tooltip } from "@cloudflare/kumo";
1010
import { plural } from "@lingui/core/macro";
1111
import { useLingui } from "@lingui/react/macro";
1212
import {
@@ -41,6 +41,7 @@ import {
4141
} from "../lib/api/marketplace.js";
4242
import {
4343
RegistryUpdateEscalationError,
44+
describeRegistryModerationError,
4445
uninstallRegistryPlugin,
4546
updateRegistryPlugin,
4647
type RegistryUpdateOpts,
@@ -242,6 +243,13 @@ function PluginCard({
242243
const isMarketplace = plugin.source === "marketplace";
243244
const isRegistry = plugin.source === "registry";
244245
const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;
246+
// Never key off `updateInfo.moderation.eligibility` -- with no accepted
247+
// labeler having passed a release, `eligibility` reads "blocked" even for
248+
// a clean plugin. See the field's origin JSDoc on `RegistryUpdateCheck`
249+
// in packages/core's registry handler.
250+
const moderationBlockingLabels = updateInfo?.moderation?.blockingLabels ?? [];
251+
const moderationWarningLabels = updateInfo?.moderation?.warningLabels ?? [];
252+
const isUpdateModerationBlocked = moderationBlockingLabels.length > 0;
245253

246254
const updateMutation = useMutation({
247255
mutationFn: (opts: RegistryUpdateOpts) =>
@@ -353,11 +361,17 @@ function PluginCard({
353361
<span className="text-xs text-kumo-subtle">v{plugin.version}</span>
354362
{!plugin.enabled && <Badge variant="secondary">{t`Disabled`}</Badge>}
355363
{isMarketplace && <Badge variant="secondary">{t`Marketplace`}</Badge>}
356-
{hasUpdate && (
364+
{hasUpdate && isUpdateModerationBlocked && (
365+
<Badge variant="error">{t`Update blocked`}</Badge>
366+
)}
367+
{hasUpdate && !isUpdateModerationBlocked && (
357368
<Badge variant="outline" className="border-kumo-brand text-kumo-brand">
358369
{t`v${updateInfo.latest} available`}
359370
</Badge>
360371
)}
372+
{hasUpdate && !isUpdateModerationBlocked && moderationWarningLabels.length > 0 && (
373+
<Badge variant="warning">{t`Update has warnings`}</Badge>
374+
)}
361375
</div>
362376

363377
{/* Description */}
@@ -407,7 +421,19 @@ function PluginCard({
407421

408422
{/* Actions */}
409423
<div className="flex items-center gap-2">
410-
{hasUpdate && (
424+
{hasUpdate && isUpdateModerationBlocked && (
425+
<Tooltip
426+
content={t`A moderation label blocks this update. Review the plugin's registry listing for details.`}
427+
render={
428+
<span>
429+
<Button variant="outline" size="sm" disabled>
430+
{t`Update to v${updateInfo.latest}`}
431+
</Button>
432+
</span>
433+
}
434+
/>
435+
)}
436+
{hasUpdate && !isUpdateModerationBlocked && (
411437
<Button
412438
variant="outline"
413439
size="sm"
@@ -564,7 +590,8 @@ function PluginCard({
564590
error={
565591
updateMutation.error instanceof RegistryUpdateEscalationError
566592
? null
567-
: getMutationError(updateMutation.error)
593+
: (describeRegistryModerationError(updateMutation.error) ??
594+
getMutationError(updateMutation.error))
568595
}
569596
onConfirm={handleUpdateConfirm}
570597
onCancel={() => {

packages/admin/src/components/RegistryBrowse.tsx

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@
1313

1414
import { Badge, Input } from "@cloudflare/kumo";
1515
import { useLingui } from "@lingui/react/macro";
16-
import { MagnifyingGlass, PuzzlePiece, ShieldCheck } from "@phosphor-icons/react";
16+
import { MagnifyingGlass, PuzzlePiece, ShieldCheck, Warning } from "@phosphor-icons/react";
1717
import { useInfiniteQuery } from "@tanstack/react-query";
1818
import { Link } from "@tanstack/react-router";
1919
import * as React from "react";
2020

2121
import {
22+
evaluatePackageModeration,
23+
isModerationBlocking,
24+
resolveAcceptedPolicy,
2225
searchRegistryPackages,
26+
type AcceptedLabelerPolicy,
2327
type RegistryClientConfig,
2428
type RegistryPackageView,
2529
} from "../lib/api/registry.js";
@@ -50,7 +54,7 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re
5054

5155
const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } =
5256
useInfiniteQuery({
53-
queryKey: ["registry", "search", config.aggregatorUrl, debouncedQuery],
57+
queryKey: ["registry", "search", config.aggregatorUrl, config.acceptLabelers, debouncedQuery],
5458
queryFn: ({ pageParam }) =>
5559
searchRegistryPackages(config, {
5660
q: debouncedQuery || undefined,
@@ -61,7 +65,18 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re
6165
getNextPageParam: (lastPage) => lastPage.cursor,
6266
});
6367

64-
const packages = data?.pages.flatMap((p) => p.packages);
68+
// Each page carries the `atproto-content-labelers` header the aggregator
69+
// applied to THAT response, so every package in a page is evaluated
70+
// against the accepted policy that actually produced it.
71+
const packages = data?.pages.flatMap((page) =>
72+
page.packages.map((pkg) => ({
73+
pkg,
74+
accepted: resolveAcceptedPolicy({
75+
configuredAcceptLabelers: config.acceptLabelers,
76+
contentLabelersHeader: page.contentLabelers,
77+
}),
78+
})),
79+
);
6580

6681
return (
6782
<div className="space-y-6">
@@ -121,10 +136,11 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re
121136
{/* Grid */}
122137
{packages && packages.length > 0 ? (
123138
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
124-
{packages.map((pkg) => (
139+
{packages.map(({ pkg, accepted }) => (
125140
<RegistryPackageCard
126141
key={pkg.uri}
127142
pkg={pkg}
143+
accepted={accepted}
128144
installed={installedRegistryUris.has(pkg.uri)}
129145
/>
130146
))}
@@ -150,10 +166,12 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re
150166

151167
interface RegistryPackageCardProps {
152168
pkg: RegistryPackageView;
169+
/** Accepted labeler policy resolved from the search response that returned `pkg`. */
170+
accepted: AcceptedLabelerPolicy[];
153171
installed: boolean;
154172
}
155173

156-
function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
174+
function RegistryPackageCard({ pkg, accepted, installed }: RegistryPackageCardProps) {
157175
const { t } = useLingui();
158176
const handleResult = usePublisherHandle(pkg.did, pkg.handle);
159177
// Always link by handle when we have one (cleaner URL), DID
@@ -166,6 +184,10 @@ function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
166184
const description = pkg.profile?.description;
167185
const license = pkg.profile?.license;
168186
const verified = (pkg.labels ?? []).some((l: { val?: string }) => l.val === "verified");
187+
// Package/publisher-scope moderation only -- no release is in view yet on
188+
// a browse card. `verified` above is unrelated: that shield is out of
189+
// moderation scope pending its own ratification.
190+
const blocked = isModerationBlocking(evaluatePackageModeration(pkg, accepted));
169191

170192
return (
171193
<Link
@@ -193,9 +215,17 @@ function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
193215
<p className="mt-2 line-clamp-2 text-sm text-kumo-default">{description}</p>
194216
) : null}
195217
{license ? <p className="mt-2 text-xs text-kumo-subtle">{license}</p> : null}
196-
{installed ? (
197-
<div className="mt-3">
198-
<Badge variant="success">{t`Installed`}</Badge>
218+
{installed || blocked ? (
219+
<div className="mt-3 flex flex-wrap items-center gap-2">
220+
{installed ? <Badge variant="success">{t`Installed`}</Badge> : null}
221+
{blocked ? (
222+
<Badge variant="error">
223+
<span className="flex items-center gap-1">
224+
<Warning className="h-3 w-3" aria-hidden />
225+
{t`Blocked`}
226+
</span>
227+
</Badge>
228+
) : null}
199229
</div>
200230
) : null}
201231
</div>

0 commit comments

Comments
 (0)