@@ -121,10 +136,11 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re
{/* Grid */}
{packages && packages.length > 0 ? (
- {packages.map((pkg) => (
+ {packages.map(({ pkg, accepted }) => (
))}
@@ -150,10 +166,12 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re
interface RegistryPackageCardProps {
pkg: RegistryPackageView;
+ /** Accepted labeler policy resolved from the search response that returned `pkg`. */
+ accepted: AcceptedLabelerPolicy[];
installed: boolean;
}
-function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
+function RegistryPackageCard({ pkg, accepted, installed }: RegistryPackageCardProps) {
const { t } = useLingui();
const handleResult = usePublisherHandle(pkg.did, pkg.handle);
// Always link by handle when we have one (cleaner URL), DID
@@ -166,6 +184,10 @@ function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
const description = pkg.profile?.description;
const license = pkg.profile?.license;
const verified = (pkg.labels ?? []).some((l: { val?: string }) => l.val === "verified");
+ // Package/publisher-scope moderation only -- no release is in view yet on
+ // a browse card. `verified` above is unrelated: that shield is out of
+ // moderation scope pending its own ratification.
+ const blocked = isModerationBlocking(evaluatePackageModeration(pkg, accepted));
return (
{description}
) : null}
{license ?
{license}
: null}
- {installed ? (
-
-
{t`Installed`}
+ {installed || blocked ? (
+
+ {installed ? {t`Installed`} : null}
+ {blocked ? (
+
+
+
+ {t`Blocked`}
+
+
+ ) : null}
) : null}
diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx
index 53269b23dc..1181cb5287 100644
--- a/packages/admin/src/components/RegistryPluginDetail.tsx
+++ b/packages/admin/src/components/RegistryPluginDetail.tsx
@@ -20,7 +20,7 @@ import { checkEnvCompatibility } from "@emdash-cms/registry-client/env";
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
-import { ShieldCheck, Warning } from "@phosphor-icons/react";
+import { ShieldCheck, ShieldWarning, Warning } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";
@@ -29,23 +29,29 @@ import { fetchManifest } from "../lib/api/client.js";
import {
artifactProxyUrl,
canonicalCapabilitiesForDriftCheck,
+ describeModerationLabel,
+ describeRegistryModerationError,
+ evaluateReleaseViews,
extractMediaArtifacts,
extractSbom,
getRegistryPackage,
hostEnvFromManifest,
installRegistryPlugin,
+ isModerationBlocking,
listRegistryReleases,
presentSections,
releasePassesPolicy,
+ resolveAcceptedPolicy,
resolveRegistryPackage,
sbomDownloadHref,
+ type ReleaseModeration,
type RegistryClientConfig,
type RegistryReleaseView,
type SectionKey,
} from "../lib/api/registry.js";
import { renderMarkdown } from "../lib/markdown.js";
import { ArrowPrev } from "./ArrowIcons.js";
-import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js";
+import { CapabilityConsentDialog, type ModerationLabelEntry } from "./CapabilityConsentDialog.js";
import { getMutationError } from "./DialogError.js";
import { PublisherHandle, usePublisherHandle } from "./PublisherHandle.js";
@@ -98,7 +104,15 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
// When linked by DID, go straight to `getPackage(did, slug)`. Either
// way we end up with the same `RegistryPackageView` shape.
const { data: pkg, isLoading: isLoadingPkg } = useQuery({
- queryKey: ["registry", "package", config.aggregatorUrl, publisher, slug, isDid],
+ queryKey: [
+ "registry",
+ "package",
+ config.aggregatorUrl,
+ config.acceptLabelers,
+ publisher,
+ slug,
+ isDid,
+ ],
queryFn: () =>
isDid
? getRegistryPackage(config, publisher, slug)
@@ -112,13 +126,13 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
// DID, because that's an impersonation risk).
const handleResult = usePublisherHandle(pkg?.did ?? "", pkg?.handle);
- // `listReleases` returns releases in descending semver order. The aggregator
- // strips yanked releases server-side when `acceptLabelers` includes a labeler
- // applying the `security:yanked` label, but sites with no labeler config
- // receive yanked releases interleaved by version. Filter them out client-side
- // as defense in depth so the picker never offers an actively-yanked install.
- // Lexicon-invalid records (`release === null`) are also filtered: they carry
- // no actionable metadata and can't be installed.
+ // `listReleases` returns releases in descending semver order. Every
+ // release renders in the picker, including a moderation-blocked one
+ // (`security-yanked`, `!takedown`, etc.) -- blocking is surfaced via the
+ // moderation panel and a disabled Install button below, not by hiding the
+ // release from the list. Only lexicon-invalid records (`release ===
+ // null`) are filtered here: they carry no actionable metadata and can't
+ // be installed regardless of moderation state.
// `limit: 100` is the lexicon ceiling; one page covers the long tail of
// real packages without needing cursor follow-up. Packages with more than
// 100 releases would still lose access to the oldest, but that's far past
@@ -130,11 +144,41 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
});
const releases = React.useMemo
(
- () => (releasesData?.releases ?? []).filter((r) => r.release !== null && !isYanked(r)),
+ () => (releasesData?.releases ?? []).filter((r) => r.release !== null),
[releasesData],
);
const hasFilteredAllReleases = (releasesData?.releases.length ?? 0) > 0 && releases.length === 0;
+ // Moderation, evaluated per release against the accepted policy that
+ // actually produced the `releasesData` response (its
+ // `atproto-content-labelers` header), not just the site's statically
+ // configured `acceptLabelers` -- the aggregator reports what it actually
+ // applied for this specific request.
+ const acceptedForReleases = React.useMemo(
+ () =>
+ resolveAcceptedPolicy({
+ configuredAcceptLabelers: config.acceptLabelers,
+ contentLabelersHeader: releasesData?.contentLabelers,
+ }),
+ [config.acceptLabelers, releasesData?.contentLabelers],
+ );
+ const moderationByVersion = React.useMemo(() => {
+ const map = new Map();
+ if (!pkg) return map;
+ for (const r of releases) {
+ map.set(
+ r.version,
+ evaluateReleaseViews({
+ packageView: pkg,
+ releaseView: r,
+ publisherDid: pkg.did,
+ accepted: acceptedForReleases,
+ }),
+ );
+ }
+ return map;
+ }, [pkg, releases, acceptedForReleases]);
+
// Default to the highest semver that passes the policy holdback. When every
// release is still inside the holdback window, fall back to the highest
// listed version — installation stays disabled (with the holdback banner)
@@ -178,6 +222,31 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
);
const isPreRelease = release ? isPreReleaseVersion(release.version) : false;
+ // Moderation state of the selected release. Consumers must key off
+ // `isModerationBlocking(...)` / `warningLabels`, never `eligibility`
+ // directly -- see the JSDoc on `RegistryUpdateCheck.moderation` in
+ // packages/core's registry handler for why.
+ const selectedModeration = release ? moderationByVersion.get(release.version) : undefined;
+ const isModerationBlocked = selectedModeration ? isModerationBlocking(selectedModeration) : false;
+ const moderationWarningEntries = React.useMemo(() => {
+ if (!selectedModeration) return [];
+ return selectedModeration.applicableLabels
+ .filter((l) => selectedModeration.warningLabels.includes(l.val))
+ .map((l) => {
+ const { name, description } = describeModerationLabel(l.val);
+ return { value: l.val, name, description, issuerDid: l.src };
+ });
+ }, [selectedModeration]);
+ const moderationBlockingEntries = React.useMemo(() => {
+ if (!selectedModeration) return [];
+ return selectedModeration.applicableLabels
+ .filter((l) => selectedModeration.blockingLabels.includes(l.val))
+ .map((l) => {
+ const { name, description } = describeModerationLabel(l.val);
+ return { value: l.val, name, description, issuerDid: l.src };
+ });
+ }, [selectedModeration]);
+
// `release.extensions[com.emdashcms.experimental.package.releaseExtension]`
// carries the structured `declaredAccess` -- the trust contract. The sandbox
// enforces the legacy `capabilities: string[]` shape, so we derive that list
@@ -512,6 +581,10 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
{ did: pkg.did, slug },
config.policy,
);
+ const releaseModeration = moderationByVersion.get(r.version);
+ const moderationBlocked = releaseModeration
+ ? isModerationBlocking(releaseModeration)
+ : false;
return (
@@ -522,6 +595,9 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
{policyBlocked ? (
{t`(too new)`}
) : null}
+ {moderationBlocked ? (
+ {t`(blocked)`}
+ ) : null}
);
@@ -535,7 +611,13 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
) : (
setShowConsent(true)}
>
{t`Install`}
@@ -569,8 +651,8 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
) : null}
- {/* All releases withdrawn or malformed — the aggregator returned
- records but none survived the yanked + lexicon-validity filter. */}
+ {/* All releases malformed — the aggregator returned records but none
+ survived the lexicon-validity filter. */}
{hasFilteredAllReleases ? (
) : null}
+ {/* Moderation panel for the selected release. Distinguishes a hard
+ block (error styling, install disabled -- already enforced above)
+ from warning-only labels (which stay installable behind the
+ consent dialog's warnings section). `suppressedLabels` / override
+ display is deferred until overrides exist. */}
+ {release && selectedModeration && isModerationBlocked ? (
+
+
+
+
{t`This release is blocked`}
+
+ {t`A moderation label prevents this release from being installed.`}
+
+
+ {moderationBlockingEntries.map((entry) => (
+
+ ))}
+
+
+
+ ) : null}
+
+ {release &&
+ selectedModeration &&
+ !isModerationBlocked &&
+ moderationWarningEntries.length > 0 ? (
+
+
+
+
{t`This release has moderation warnings`}
+
{t`Review these before installing.`}
+
+ {moderationWarningEntries.map((entry) => (
+
+ ))}
+
+
+
+ ) : null}
+
{/* Description */}
{description ?
{description}
: null}
@@ -773,8 +901,12 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
mode="install"
pluginName={displayName ?? slug}
capabilities={capabilities}
+ moderationWarnings={moderationWarningEntries}
isPending={installMutation.isPending}
- error={getMutationError(installMutation.error)}
+ error={
+ describeRegistryModerationError(installMutation.error) ??
+ getMutationError(installMutation.error)
+ }
onConfirm={() => installMutation.mutate()}
onCancel={() => {
setShowConsent(false);
@@ -794,6 +926,36 @@ const SECTION_LABELS: Record
= {
security: msg`Security`,
};
+/**
+ * One row in the moderation panel: label name, description, and a tooltip
+ * trigger naming the issuing labeler DID -- the same icon-plus-tooltip
+ * pattern the verified-publisher shield uses above, so the admin can judge
+ * who issued the label, not just that some labeler did.
+ */
+function ModerationLabelRow({ entry }: { entry: ModerationLabelEntry }) {
+ const { t } = useLingui();
+ return (
+
+
+
+
+ }
+ />
+
+
{entry.name}
+ {entry.description ?
{entry.description}
: null}
+
+
+ );
+}
+
function BackLink() {
const { t } = useLingui();
return (
@@ -830,25 +992,6 @@ function envLabel(key: string): string {
return key.startsWith("env:") ? key.slice("env:".length) : key;
}
-const YANKED_LABEL_VALUE = "security:yanked";
-
-/**
- * Aggregators forward labels applied by their configured labelers. `security:yanked`
- * is a hard-enforcement label that publishers can self-apply (or that a labeler
- * applies on their behalf) to retract a release after publication. Sites whose
- * `acceptLabelers` config includes the labeler never see yanked releases at all
- * (server filtering), but sites without it receive yanked releases interleaved
- * with installable ones — filter them out so they never reach the picker.
- *
- * `neg` (negated labels) is intentionally ignored to match the server install
- * handler, which only checks `l.val === "security:yanked"`. Diverging here would
- * let the UI surface an install affordance the server will reject with
- * `RELEASE_YANKED`. Honoring `neg` on both sides is a separate follow-up.
- */
-function isYanked(release: RegistryReleaseView): boolean {
- return (release.labels ?? []).some((l) => l.val === YANKED_LABEL_VALUE);
-}
-
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString();
diff --git a/packages/admin/src/lib/api/marketplace.ts b/packages/admin/src/lib/api/marketplace.ts
index 21c6375c66..5f691ee99b 100644
--- a/packages/admin/src/lib/api/marketplace.ts
+++ b/packages/admin/src/lib/api/marketplace.ts
@@ -87,6 +87,21 @@ export interface PluginUpdateInfo {
installed: string;
latest: string;
hasCapabilityChanges: boolean;
+ /**
+ * Moderation state of the latest release, present only for registry-sourced
+ * plugins. Additive field on `RegistryUpdateCheck` (packages/core's registry
+ * handler) -- absent for marketplace-sourced plugins and for registry
+ * plugins whose moderation couldn't be evaluated.
+ *
+ * Never key a "blocked" indicator off `eligibility` -- with no accepted
+ * labeler having passed a release, `eligibility` reads "blocked" even for a
+ * clean plugin. Use `blockingLabels.length > 0` instead.
+ */
+ moderation?: {
+ eligibility: "eligible" | "pending" | "error" | "blocked";
+ blockingLabels: string[];
+ warningLabels: string[];
+ };
}
/** Install request body */
diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts
index b586a482e4..1d064615c8 100644
--- a/packages/admin/src/lib/api/registry.ts
+++ b/packages/admin/src/lib/api/registry.ts
@@ -17,6 +17,11 @@
* `@atcute/client` into the admin bundle when the registry path is
* actually exercised. Sites with no `experimental.registry` config never
* pay the cost (verified at ~2 KB gzip when it does load).
+ *
+ * Moderation and env-compat helpers import from `@emdash-cms/registry-client`'s
+ * `/moderation` and `/env` subpaths, not its package root -- the root entry
+ * re-exports the CLI's publishing/credentials surface too, which pulls in
+ * Node-only modules (`node:fs/promises`) that don't exist in the browser.
*/
import type { Did, Handle } from "@atcute/lexicons";
@@ -28,7 +33,15 @@ import type {
} from "@emdash-cms/registry-client/discovery";
import { hostEnvFromVersions } from "@emdash-cms/registry-client/env";
import type { HostEnv } from "@emdash-cms/registry-client/env";
+import {
+ evaluateReleaseViews,
+ isModerationBlocking,
+ resolveAcceptedPolicy,
+ type AcceptedLabelerPolicy,
+ type ReleaseModeration,
+} from "@emdash-cms/registry-client/moderation";
import { i18n } from "@lingui/core";
+import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import {
@@ -41,6 +54,8 @@ import {
export type { Did, Handle };
export type { HostEnv };
+export { evaluateReleaseViews, isModerationBlocking, resolveAcceptedPolicy };
+export type { AcceptedLabelerPolicy, ReleaseModeration };
// ---------------------------------------------------------------------------
// Types
@@ -77,6 +92,167 @@ export interface RegistrySearchOpts {
limit?: number;
}
+// ---------------------------------------------------------------------------
+// Moderation
+// ---------------------------------------------------------------------------
+
+/**
+ * Evaluates a package's package/publisher-scope moderation state without a
+ * specific release in view. Browse cards render every package from one
+ * `searchPackages` response, before any release has been fetched, so there is
+ * no `ValidatedReleaseView` to evaluate against yet.
+ *
+ * Mirrors `@emdash-cms/plugin-cli`'s `evaluatePackageModeration`: the stub
+ * release's `uri`/`cid` can never match a real label's `uri`/`cid`, so
+ * release-scope automated blocks and warnings are excluded by construction,
+ * not by omission.
+ */
+export function evaluatePackageModeration(
+ packageView: RegistryPackageView,
+ accepted: AcceptedLabelerPolicy[],
+): ReleaseModeration {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- stub is never a real record, only compared against label uris that can never equal these placeholders
+ const releaseStub = {
+ uri: "",
+ cid: "",
+ did: packageView.did,
+ package: packageView.slug,
+ version: "",
+ indexedAt: packageView.indexedAt,
+ release: null,
+ } as unknown as RegistryReleaseView;
+
+ return evaluateReleaseViews({
+ packageView,
+ releaseView: releaseStub,
+ publisherDid: packageView.did,
+ accepted,
+ });
+}
+
+/**
+ * Hardcoded display text for the moderation label vocabulary, following the
+ * `CAPABILITY_LABELS` precedent in `marketplace.ts`. Sourced from
+ * `apps/labeler/fixtures/moderation-policy.json`'s label list and
+ * `@emdash-cms/registry-moderation`'s `ModerationLabelValue` union.
+ *
+ * Unknown values (a labeler-issued value this map hasn't been updated for
+ * yet) are data, not an error -- `describeModerationLabel` falls back to the
+ * raw value rather than throwing or hiding the label.
+ */
+export const MODERATION_LABEL_TEXT: Record<
+ string,
+ { name: MessageDescriptor; description: MessageDescriptor }
+> = {
+ "assessment-passed": {
+ name: msg`Assessment passed`,
+ description: msg`This release passed its required moderation assessment.`,
+ },
+ "assessment-overridden": {
+ name: msg`Assessment overridden`,
+ description: msg`A reviewer manually approved this release, superseding the automated outcome.`,
+ },
+ "assessment-pending": {
+ name: msg`Assessment pending`,
+ description: msg`This release's moderation assessment hasn't completed yet.`,
+ },
+ "assessment-error": {
+ name: msg`Assessment error`,
+ description: msg`This release's moderation assessment failed to complete.`,
+ },
+ malware: {
+ name: msg`Malware`,
+ description: msg`The release contains code intentionally designed to cause harm.`,
+ },
+ "data-exfiltration": {
+ name: msg`Data exfiltration`,
+ description: msg`The release sends protected data somewhere it shouldn't.`,
+ },
+ "credential-harvesting": {
+ name: msg`Credential harvesting`,
+ description: msg`The release captures or transmits credentials deceptively.`,
+ },
+ "supply-chain-compromise": {
+ name: msg`Supply-chain compromise`,
+ description: msg`Evidence suggests a dependency or build artifact was tampered with.`,
+ },
+ "critical-vulnerability": {
+ name: msg`Critical vulnerability`,
+ description: msg`A critical security vulnerability was found in this release.`,
+ },
+ "artifact-integrity-failure": {
+ name: msg`Artifact integrity failure`,
+ description: msg`The downloaded bundle doesn't match the checksum in the signed release.`,
+ },
+ "invalid-bundle": {
+ name: msg`Invalid bundle`,
+ description: msg`The installable bundle is malformed, unsafe, or incomplete.`,
+ },
+ "undeclared-access": {
+ name: msg`Undeclared access`,
+ description: msg`The bundle behaves outside of what its declared permissions cover.`,
+ },
+ impersonation: {
+ name: msg`Impersonation`,
+ description: msg`This release impersonates another identity, project, or product.`,
+ },
+ "suspicious-code": {
+ name: msg`Suspicious code`,
+ description: msg`The code shows concerning patterns, though evidence is inconclusive.`,
+ },
+ "obfuscated-code": {
+ name: msg`Obfuscated code`,
+ description: msg`Material parts of the code are intentionally difficult to inspect.`,
+ },
+ "privacy-risk": {
+ name: msg`Privacy risk`,
+ description: msg`The release creates a privacy concern that isn't blocking.`,
+ },
+ "misleading-metadata": {
+ name: msg`Misleading metadata`,
+ description: msg`The listing's metadata or screenshots don't match its actual behavior.`,
+ },
+ "low-quality": {
+ name: msg`Low quality`,
+ description: msg`The release doesn't provide meaningful functionality.`,
+ },
+ "broken-release": {
+ name: msg`Broken release`,
+ description: msg`The bundle is structurally valid but doesn't work as described.`,
+ },
+ "package-disputed": {
+ name: msg`Package disputed`,
+ description: msg`This package has an unresolved ownership or policy dispute.`,
+ },
+ "security-yanked": {
+ name: msg`Security yanked`,
+ description: msg`A reviewer withdrew this release for security reasons.`,
+ },
+ "publisher-compromised": {
+ name: msg`Publisher compromised`,
+ description: msg`This publisher's identity is believed to be compromised.`,
+ },
+ "!takedown": {
+ name: msg`Taken down`,
+ description: msg`An administrator issued an emergency takedown action.`,
+ },
+};
+
+/**
+ * Resolves a moderation label value to its localized display text. Falls
+ * back to the raw value (with no description) for a value this map doesn't
+ * cover -- an unrecognised label is data the UI still must render, not an
+ * error to hide or throw on.
+ */
+export function describeModerationLabel(value: string): {
+ name: string;
+ description: string | null;
+} {
+ const entry = MODERATION_LABEL_TEXT[value];
+ if (!entry) return { name: value, description: null };
+ return { name: i18n._(entry.name), description: i18n._(entry.description) };
+}
+
export interface RegistryInstallRequest {
did: string;
slug: string;
@@ -96,81 +272,54 @@ export interface RegistryInstallResult {
// Discovery client (lazy)
// ---------------------------------------------------------------------------
-interface WrappedDiscoveryClient {
- searchPackages: (opts: RegistrySearchOpts) => Promise;
- resolvePackage: (handle: string, slug: string) => Promise;
- getPackage: (did: string, slug: string) => Promise;
- getLatestRelease: (did: string, slug: string) => Promise;
- listReleases: (
- did: string,
- slug: string,
- opts?: { cursor?: string; limit?: number },
- ) => Promise;
-}
-
-let cachedDiscovery: {
- config: RegistryClientConfig;
- client: WrappedDiscoveryClient;
-} | null = null;
-
-async function getDiscoveryClient(config: RegistryClientConfig): Promise {
- if (
- cachedDiscovery &&
- cachedDiscovery.config.aggregatorUrl === config.aggregatorUrl &&
- cachedDiscovery.config.acceptLabelers === config.acceptLabelers
- ) {
- return cachedDiscovery.client;
- }
+/**
+ * A discovery result paired with the `atproto-content-labelers` header the
+ * aggregator sent back for THIS specific response. Moderation evaluation
+ * must use this header (via `resolveAcceptedPolicy`), not just the site's
+ * statically configured `acceptLabelers` -- the aggregator reports what it
+ * actually applied, which can differ per-request.
+ */
+type WithContentLabelers = T & { contentLabelers?: string };
+
+let cachedDiscoveryModule: typeof import("@emdash-cms/registry-client/discovery") | null = null;
+
+async function loadDiscoveryModule(): Promise<
+ typeof import("@emdash-cms/registry-client/discovery")
+> {
+ cachedDiscoveryModule ??= await import("@emdash-cms/registry-client/discovery");
+ return cachedDiscoveryModule;
+}
- const mod = await import("@emdash-cms/registry-client/discovery");
- const DiscoveryClient = mod.DiscoveryClient;
+/**
+ * Runs one discovery call against a fresh `DiscoveryClient` instance and
+ * pairs its result with the response's `atproto-content-labelers` header.
+ *
+ * A fresh client is constructed per call (cheap -- it only binds a fetch
+ * wrapper) rather than reused from a shared instance: `onResponseMeta` is
+ * fixed at construction, so sharing one client across concurrent calls would
+ * require correlating which invocation a given response belongs to. A
+ * per-call client sidesteps that entirely -- the `contentLabelers` closure
+ * variable can only ever be written by the one request this call made.
+ */
+async function callDiscovery(
+ config: RegistryClientConfig,
+ fn: (
+ client: InstanceType<
+ (typeof import("@emdash-cms/registry-client/discovery"))["DiscoveryClient"]
+ >,
+ ) => Promise,
+): Promise> {
+ const { DiscoveryClient } = await loadDiscoveryModule();
+ let contentLabelers: string | undefined;
const discovery = new DiscoveryClient({
aggregatorUrl: config.aggregatorUrl,
acceptLabelers: config.acceptLabelers,
- });
-
- const wrapped: WrappedDiscoveryClient = {
- async searchPackages(opts: RegistrySearchOpts) {
- return discovery.searchPackages({
- q: opts.q,
- cursor: opts.cursor,
- limit: opts.limit,
- });
- },
- async resolvePackage(handle: string, slug: string) {
- return discovery.resolvePackage({
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did/handle shape validated by aggregator
- handle: handle as Handle,
- slug,
- });
- },
- async getPackage(did: string, slug: string) {
- return discovery.getPackage({
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did shape validated by aggregator
- did: did as Did,
- slug,
- });
- },
- async getLatestRelease(did: string, slug: string) {
- return discovery.getLatestRelease({
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did shape validated by aggregator
- did: did as Did,
- package: slug,
- });
- },
- async listReleases(did: string, slug: string, opts?: { cursor?: string; limit?: number }) {
- return discovery.listReleases({
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did shape validated by aggregator
- did: did as Did,
- package: slug,
- cursor: opts?.cursor,
- limit: opts?.limit,
- });
+ onResponseMeta: (meta) => {
+ contentLabelers = meta.contentLabelers;
},
- };
-
- cachedDiscovery = { config, client: wrapped };
- return wrapped;
+ });
+ const data = await fn(discovery);
+ return { ...data, contentLabelers };
}
// ---------------------------------------------------------------------------
@@ -360,36 +509,52 @@ export function sbomDownloadHref(value: unknown): string | null {
export async function searchRegistryPackages(
config: RegistryClientConfig,
opts: RegistrySearchOpts,
-): Promise {
- const client = await getDiscoveryClient(config);
- return client.searchPackages(opts);
+): Promise> {
+ return callDiscovery(config, (discovery) =>
+ discovery.searchPackages({ q: opts.q, cursor: opts.cursor, limit: opts.limit }),
+ );
}
export async function resolveRegistryPackage(
config: RegistryClientConfig,
handle: string,
slug: string,
-): Promise {
- const client = await getDiscoveryClient(config);
- return client.resolvePackage(handle, slug);
+): Promise> {
+ return callDiscovery(config, (discovery) =>
+ discovery.resolvePackage({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did/handle shape validated by aggregator
+ handle: handle as Handle,
+ slug,
+ }),
+ );
}
export async function getRegistryPackage(
config: RegistryClientConfig,
did: string,
slug: string,
-): Promise {
- const client = await getDiscoveryClient(config);
- return client.getPackage(did, slug);
+): Promise> {
+ return callDiscovery(config, (discovery) =>
+ discovery.getPackage({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did shape validated by aggregator
+ did: did as Did,
+ slug,
+ }),
+ );
}
export async function getLatestRegistryRelease(
config: RegistryClientConfig,
did: string,
slug: string,
-): Promise {
- const client = await getDiscoveryClient(config);
- return client.getLatestRelease(did, slug);
+): Promise> {
+ return callDiscovery(config, (discovery) =>
+ discovery.getLatestRelease({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did shape validated by aggregator
+ did: did as Did,
+ package: slug,
+ }),
+ );
}
export async function listRegistryReleases(
@@ -397,9 +562,16 @@ export async function listRegistryReleases(
did: string,
slug: string,
opts?: { cursor?: string; limit?: number },
-): Promise {
- const client = await getDiscoveryClient(config);
- return client.listReleases(did, slug, opts);
+): Promise> {
+ return callDiscovery(config, (discovery) =>
+ discovery.listReleases({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did shape validated by aggregator
+ did: did as Did,
+ package: slug,
+ cursor: opts?.cursor,
+ limit: opts?.limit,
+ }),
+ );
}
/**
@@ -671,15 +843,83 @@ export function extractMediaArtifacts(artifacts: unknown): MediaArtifacts {
const INSTALL_ENDPOINT = `${API_BASE}/admin/plugins/registry/install`;
+/**
+ * Server-side moderation block raised by the install or update endpoint when
+ * the target release is blocked (`RELEASE_BLOCKED`) or has been withdrawn
+ * (`RELEASE_YANKED`). Carries the reason codes and blocking label values so
+ * the caller can render a localized headline and label list instead of the
+ * raw server message.
+ */
+export class RegistryModerationBlockError extends Error {
+ readonly code: "RELEASE_BLOCKED" | "RELEASE_YANKED";
+ readonly reasonCodes: string[];
+ readonly blockingLabels: string[];
+ constructor(
+ code: "RELEASE_BLOCKED" | "RELEASE_YANKED",
+ message: string,
+ details: { reasonCodes: string[]; blockingLabels: string[] },
+ ) {
+ super(message);
+ this.name = "RegistryModerationBlockError";
+ this.code = code;
+ this.reasonCodes = details.reasonCodes;
+ this.blockingLabels = details.blockingLabels;
+ }
+}
+
+function parseModerationBlock(body: unknown): RegistryModerationBlockError | null {
+ if (!body || typeof body !== "object" || !("error" in body)) return null;
+ const error = body.error;
+ if (!error || typeof error !== "object" || !("code" in error)) return null;
+ const code = error.code;
+ if (code !== "RELEASE_BLOCKED" && code !== "RELEASE_YANKED") return null;
+ const details =
+ "details" in error && error.details && typeof error.details === "object" ? error.details : {};
+ const reasonCodes = normaliseStringArray(
+ "reasonCodes" in details ? details.reasonCodes : undefined,
+ );
+ const blockingLabels = normaliseStringArray(
+ "blockingLabels" in details ? details.blockingLabels : undefined,
+ );
+ const message =
+ "message" in error && typeof error.message === "string"
+ ? error.message
+ : i18n._(msg`This release is blocked`);
+ return new RegistryModerationBlockError(code, message, { reasonCodes, blockingLabels });
+}
+
+function normaliseStringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((s): s is string => typeof s === "string") : [];
+}
+
+/**
+ * Resolves a mutation error into a localized, multi-line moderation message
+ * when it's a `RegistryModerationBlockError`; `null` otherwise, so callers
+ * fall back to their generic error text (`getMutationError`) for every other
+ * error shape, unknown codes included.
+ */
+export function describeRegistryModerationError(error: unknown): string | null {
+ if (!(error instanceof RegistryModerationBlockError)) return null;
+ const headline =
+ error.code === "RELEASE_YANKED"
+ ? i18n._(msg`This release was withdrawn and can't be installed.`)
+ : i18n._(msg`This release is blocked and can't be installed.`);
+ const labelNames = error.blockingLabels.map((value) => describeModerationLabel(value).name);
+ return labelNames.length > 0 ? `${headline}\n${labelNames.join(", ")}` : headline;
+}
+
/**
* Install a plugin from the registry.
*
* Posts to the EmDash server, which re-resolves the same `(handle,
* slug)` against the aggregator, re-verifies the bundle's checksum
* against the signed release record, and writes the install. Surfaces
- * structured error codes (`RELEASE_YANKED`, `CHECKSUM_MISMATCH`,
- * `DECLARED_ACCESS_DRIFT`, etc.) that callers map to localized
- * messages.
+ * structured error codes (`RELEASE_YANKED`, `RELEASE_BLOCKED`,
+ * `CHECKSUM_MISMATCH`, `DECLARED_ACCESS_DRIFT`, etc.); `RELEASE_BLOCKED` /
+ * `RELEASE_YANKED` responses parse into `RegistryModerationBlockError` so
+ * callers can render the localized headline via
+ * `describeRegistryModerationError`. Unknown codes keep the raw server
+ * message via the generic fallback.
*/
export async function installRegistryPlugin(
body: RegistryInstallRequest,
@@ -689,7 +929,15 @@ export async function installRegistryPlugin(
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
- return parseApiResponse(response, i18n._(msg`Failed to install plugin`));
+ if (response.ok) return parseApiResponse(response);
+
+ const body_: unknown = await response
+ .clone()
+ .json()
+ .catch(() => undefined);
+ const moderationBlock = parseModerationBlock(body_);
+ if (moderationBlock) throw moderationBlock;
+ return throwResponseError(response, i18n._(msg`Failed to install plugin`));
}
// ---------------------------------------------------------------------------
@@ -760,6 +1008,8 @@ export async function updateRegistryPlugin(
.catch(() => undefined);
const escalation = parseEscalation(body);
if (escalation) throw escalation;
+ const moderationBlock = parseModerationBlock(body);
+ if (moderationBlock) throw moderationBlock;
await throwResponseError(response, i18n._(msg`Failed to update plugin`));
}
diff --git a/packages/admin/tests/components/CapabilityConsentDialog.test.tsx b/packages/admin/tests/components/CapabilityConsentDialog.test.tsx
index 7b43726078..111f09e451 100644
--- a/packages/admin/tests/components/CapabilityConsentDialog.test.tsx
+++ b/packages/admin/tests/components/CapabilityConsentDialog.test.tsx
@@ -253,4 +253,80 @@ describe("CapabilityConsentDialog", () => {
const dialog = screen.getByRole("dialog");
await expect.element(dialog).toBeInTheDocument();
});
+
+ // -----------------------------------------------------------------------
+ // Moderation warnings
+ // -----------------------------------------------------------------------
+
+ const suspiciousCodeWarning = {
+ value: "suspicious-code",
+ name: "Suspicious code",
+ description: "Concerning behavior lacks enough evidence for a blocking security label.",
+ issuerDid: "did:plc:labeler",
+ };
+
+ it("shows the dialog with only the warnings section when there are no capabilities", async () => {
+ const screen = await render(
+ ,
+ );
+
+ await expect.element(screen.getByRole("dialog")).toBeInTheDocument();
+ await expect
+ .element(screen.getByText("Moderation warnings", { exact: true }))
+ .toBeInTheDocument();
+ await expect.element(screen.getByText("Suspicious code")).toBeInTheDocument();
+ await expect.element(screen.getByText("Issued by did:plc:labeler")).toBeInTheDocument();
+ });
+
+ it("shows both the warnings section and the capabilities list together", async () => {
+ const screen = await render(
+ ,
+ );
+
+ await expect
+ .element(screen.getByText("Moderation warnings", { exact: true }))
+ .toBeInTheDocument();
+ await expect.element(screen.getByText("Suspicious code")).toBeInTheDocument();
+ await expect.element(screen.getByText("Read your content")).toBeInTheDocument();
+ });
+
+ it("proceeds via onConfirm with warnings present", async () => {
+ const screen = await render(
+ ,
+ );
+
+ await screen.getByText("Accept & Install").click();
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+
+ it("renders no warnings section when moderationWarnings is empty", async () => {
+ const screen = await render(
+ ,
+ );
+
+ expect(screen.getByText("Moderation warnings", { exact: true }).query()).toBeNull();
+ });
});
diff --git a/packages/admin/tests/components/PluginManager.test.tsx b/packages/admin/tests/components/PluginManager.test.tsx
index 0a206164bf..43fee7ee0b 100644
--- a/packages/admin/tests/components/PluginManager.test.tsx
+++ b/packages/admin/tests/components/PluginManager.test.tsx
@@ -389,4 +389,107 @@ describe("PluginManager", () => {
// The empty state links to the marketplace
await expect.element(screen.getByText("marketplace", { exact: true })).toBeInTheDocument();
});
+
+ // -----------------------------------------------------------------------
+ // Registry update moderation
+ // -----------------------------------------------------------------------
+
+ it("shows a blocked badge and disables the update action when the update is moderation-blocked", async () => {
+ mockFetchPlugins.mockResolvedValue([
+ makePlugin({
+ id: "reg-plugin",
+ name: "Registry Plugin",
+ version: "1.0.0",
+ source: "registry",
+ }),
+ ]);
+ mockCheckPluginUpdates.mockResolvedValue([
+ {
+ pluginId: "reg-plugin",
+ installed: "1.0.0",
+ latest: "1.1.0",
+ hasCapabilityChanges: false,
+ moderation: { eligibility: "blocked", blockingLabels: ["malware"], warningLabels: [] },
+ },
+ ]);
+
+ const screen = await render(
+
+
+ ,
+ );
+ await expect.element(screen.getByText("Registry Plugin")).toBeInTheDocument();
+ await screen.getByText("Check for updates").click();
+
+ await expect.element(screen.getByText("Update blocked")).toBeInTheDocument();
+ expect(screen.getByText("v1.1.0 available").query()).toBeNull();
+ await expect.element(screen.getByRole("button", { name: "Update to v1.1.0" })).toBeDisabled();
+ });
+
+ it("shows a warning badge and keeps the update action enabled for a warning-only update", async () => {
+ mockFetchPlugins.mockResolvedValue([
+ makePlugin({
+ id: "reg-plugin",
+ name: "Registry Plugin",
+ version: "1.0.0",
+ source: "registry",
+ }),
+ ]);
+ mockCheckPluginUpdates.mockResolvedValue([
+ {
+ pluginId: "reg-plugin",
+ installed: "1.0.0",
+ latest: "1.1.0",
+ hasCapabilityChanges: false,
+ moderation: {
+ eligibility: "eligible",
+ blockingLabels: [],
+ warningLabels: ["suspicious-code"],
+ },
+ },
+ ]);
+
+ const screen = await render(
+
+
+ ,
+ );
+ await expect.element(screen.getByText("Registry Plugin")).toBeInTheDocument();
+ await screen.getByText("Check for updates").click();
+
+ await expect.element(screen.getByText("v1.1.0 available")).toBeInTheDocument();
+ await expect.element(screen.getByText("Update has warnings")).toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("button", { name: "Update to v1.1.0" }))
+ .not.toBeDisabled();
+ });
+
+ it("renders exactly as before when the update has no moderation field", async () => {
+ mockFetchPlugins.mockResolvedValue([
+ makePlugin({
+ id: "reg-plugin",
+ name: "Registry Plugin",
+ version: "1.0.0",
+ source: "registry",
+ }),
+ ]);
+ mockCheckPluginUpdates.mockResolvedValue([
+ { pluginId: "reg-plugin", installed: "1.0.0", latest: "1.1.0", hasCapabilityChanges: false },
+ ]);
+
+ const screen = await render(
+
+
+ ,
+ );
+ await expect.element(screen.getByText("Registry Plugin")).toBeInTheDocument();
+ await screen.getByText("Check for updates").click();
+
+ await expect.element(screen.getByText("v1.1.0 available")).toBeInTheDocument();
+ expect(screen.getByText("Update blocked").query()).toBeNull();
+ expect(screen.getByText("Update has warnings").query()).toBeNull();
+ await expect
+ .element(screen.getByRole("button", { name: "Update to v1.1.0" }))
+ .not.toBeDisabled();
+ });
});
diff --git a/packages/admin/tests/components/RegistryBrowse.test.tsx b/packages/admin/tests/components/RegistryBrowse.test.tsx
new file mode 100644
index 0000000000..cb6fda6288
--- /dev/null
+++ b/packages/admin/tests/components/RegistryBrowse.test.tsx
@@ -0,0 +1,126 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import * as React from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { RegistryClientConfig, RegistryPackageView } from "../../src/lib/api/registry";
+import { render } from "../utils/render.tsx";
+
+vi.mock("@tanstack/react-router", async () => {
+ const actual = await vi.importActual("@tanstack/react-router");
+ return {
+ ...actual,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double, props shape mirrors TanStack's Link
+ Link: ({ children, to, params, ...props }: any) => {
+ const pluginId = params && typeof params === "object" ? params.pluginId : undefined;
+ const href = pluginId ? `${String(to)}/${pluginId}` : String(to ?? "");
+ return (
+
+ {children}
+
+ );
+ },
+ };
+});
+
+const mockSearchRegistryPackages = vi.fn();
+
+vi.mock("../../src/lib/api/registry", async () => {
+ const actual = await vi.importActual(
+ "../../src/lib/api/registry",
+ );
+ return {
+ ...actual,
+ searchRegistryPackages: (...a: unknown[]) => mockSearchRegistryPackages(...a),
+ resolveDidToHandle: vi.fn(async () => ({ status: "ok", handle: "acme.dev" })),
+ };
+});
+
+const { RegistryBrowse } = await import("../../src/components/RegistryBrowse");
+
+function makePackage(overrides: Partial = {}): RegistryPackageView {
+ return {
+ uri: "at://did:plc:acme/com.emdashcms.experimental.package.profile/myplugin",
+ cid: "bafypkgcid",
+ did: "did:plc:acme",
+ handle: "acme.dev",
+ slug: "myplugin",
+ labels: [],
+ profile: { name: "My Plugin", description: "A short description.", license: "MIT" },
+ ...overrides,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixture cast to the validated view shape
+ } as any;
+}
+
+function Wrapper({ children }: { children: React.ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return {children} ;
+}
+
+const CONFIG_WITH_LABELER: RegistryClientConfig = {
+ aggregatorUrl: "https://aggregator.test",
+ acceptLabelers: "did:plc:labeler",
+};
+
+describe("RegistryBrowse moderation", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("shows a blocked indicator on a package a package-scope label blocks", async () => {
+ const blockedPkg = makePackage({
+ slug: "blocked-plugin",
+ uri: "at://did:plc:acme/com.emdashcms.experimental.package.profile/blocked-plugin",
+ profile: { name: "Blocked Plugin" },
+ labels: [
+ {
+ ver: 1,
+ src: "did:plc:labeler",
+ uri: "at://did:plc:acme/com.emdashcms.experimental.package.profile/blocked-plugin",
+ val: "!takedown",
+ cts: "2025-01-01T00:00:00Z",
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- raw label fixture
+ ] as any,
+ });
+ const cleanPkg = makePackage({ slug: "clean-plugin", profile: { name: "Clean Plugin" } });
+ mockSearchRegistryPackages.mockResolvedValue({
+ packages: [blockedPkg, cleanPkg],
+ cursor: undefined,
+ });
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ await expect.element(screen.getByText("Blocked Plugin")).toBeInTheDocument();
+ await expect.element(screen.getByText("Blocked", { exact: true })).toBeInTheDocument();
+ await expect.element(screen.getByText("Clean Plugin")).toBeInTheDocument();
+ // Only one "Blocked" indicator -- the clean package doesn't get one.
+ expect(screen.getByText("Blocked", { exact: true }).all().length).toBe(1);
+ });
+
+ it("carries the configured acceptLabelers in the search query key", async () => {
+ mockSearchRegistryPackages.mockResolvedValue({ packages: [], cursor: undefined });
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ await expect
+ .element(screen.getByText("No plugins have been published to this registry yet."))
+ .toBeInTheDocument();
+
+ const keys = queryClient
+ .getQueryCache()
+ .getAll()
+ .map((q) => q.queryKey);
+ expect(
+ keys.some((key) => Array.isArray(key) && key.includes(CONFIG_WITH_LABELER.acceptLabelers)),
+ ).toBe(true);
+ });
+});
diff --git a/packages/admin/tests/components/RegistryPluginDetail.test.tsx b/packages/admin/tests/components/RegistryPluginDetail.test.tsx
index e2a3e0db84..6a386b6f8a 100644
--- a/packages/admin/tests/components/RegistryPluginDetail.test.tsx
+++ b/packages/admin/tests/components/RegistryPluginDetail.test.tsx
@@ -25,17 +25,27 @@ vi.mock("@tanstack/react-router", async () => {
const mockGetRegistryPackage = vi.fn();
const mockResolveRegistryPackage = vi.fn();
const mockListRegistryReleases = vi.fn();
+// Vitest's browser mode can't `vi.spyOn` a real ESM module namespace ("Module
+// namespace is not configurable in ESM"), so overriding `evaluateReleaseViews`
+// per-test goes through this mock-prefixed wrapper instead, same as the other
+// network-facing exports below. Falls through to the real implementation by
+// default (set once the factory resolves `actual`) so most tests exercise
+// genuine label evaluation.
+const mockEvaluateReleaseViews = vi.fn();
vi.mock("../../src/lib/api/registry", async () => {
const actual = await vi.importActual(
"../../src/lib/api/registry",
);
+ mockEvaluateReleaseViews.mockImplementation(actual.evaluateReleaseViews);
return {
...actual,
getRegistryPackage: (...a: unknown[]) => mockGetRegistryPackage(...a),
resolveRegistryPackage: (...a: unknown[]) => mockResolveRegistryPackage(...a),
listRegistryReleases: (...a: unknown[]) => mockListRegistryReleases(...a),
resolveDidToHandle: vi.fn(async () => ({ status: "ok", handle: "acme.dev" })),
+ evaluateReleaseViews: (...a: Parameters) =>
+ mockEvaluateReleaseViews(...a),
};
});
@@ -57,14 +67,30 @@ const { RegistryPluginDetail } = await import("../../src/components/RegistryPlug
const CONFIG: RegistryClientConfig = { aggregatorUrl: "https://aggregator.test" };
+/** A raw (unsigned) ATProto label, as the aggregator hydrates onto a package/release view. */
+interface RawLabel {
+ ver?: number;
+ src: string;
+ uri: string;
+ val: string;
+ cts?: string;
+ cid?: string;
+}
+
interface PkgOverrides {
sections?: Record;
lastUpdated?: string;
- labels?: { val?: string; src?: string }[];
+ labels?: { val?: string; src?: string }[] | RawLabel[];
+ uri?: string;
+ cid?: string;
}
+const PACKAGE_URI = "at://did:plc:acme/com.emdashcms.experimental.package.profile/myplugin";
+
function makePackage(overrides: PkgOverrides = {}): RegistryPackageView {
return {
+ uri: overrides.uri ?? PACKAGE_URI,
+ cid: overrides.cid ?? "bafypkgcid",
did: "did:plc:acme",
handle: "acme.dev",
slug: "myplugin",
@@ -86,13 +112,24 @@ function makePackage(overrides: PkgOverrides = {}): RegistryPackageView {
interface ReleaseOverrides {
sbom?: { format?: string; url?: string; checksum?: string };
extensions?: Record;
+ version?: string;
+ uri?: string;
+ cid?: string;
+ labels?: RawLabel[];
+}
+
+function releaseUriFor(version: string): string {
+ return `at://did:plc:acme/com.emdashcms.experimental.package.release/myplugin:${version}`;
}
function makeRelease(overrides: ReleaseOverrides = {}): RegistryReleaseView {
+ const version = overrides.version ?? "1.2.3";
return {
- version: "1.2.3",
+ uri: overrides.uri ?? releaseUriFor(version),
+ cid: overrides.cid ?? "bafyrelcid",
+ version,
indexedAt: "2025-03-01T00:00:00Z",
- labels: [],
+ labels: overrides.labels ?? [],
release: {
sbom: overrides.sbom,
extensions: overrides.extensions,
@@ -101,6 +138,17 @@ function makeRelease(overrides: ReleaseOverrides = {}): RegistryReleaseView {
} as any;
}
+/** A well-formed `security-yanked` label applying release-wide (no `cid` -- forbidden by policy). */
+function securityYankedLabel(version: string, src = "did:plc:labeler"): RawLabel {
+ return {
+ ver: 1,
+ src,
+ uri: releaseUriFor(version),
+ val: "security-yanked",
+ cts: "2025-01-01T00:00:00Z",
+ };
+}
+
const RELEASE_EXTENSION_NSID = "com.emdashcms.experimental.package.releaseExtension";
function Wrapper({ children }: { children: React.ReactNode }) {
@@ -274,3 +322,195 @@ describe("RegistryPluginDetail lastUpdated + verified tooltip", () => {
await expect.element(trigger).toHaveAccessibleName(/did:plc:labeler/);
});
});
+
+// ---------------------------------------------------------------------------
+// Moderation
+// ---------------------------------------------------------------------------
+
+function makeModeration(
+ overrides: Partial = {},
+): import("../../src/lib/api/registry").ReleaseModeration {
+ return {
+ eligibility: "eligible",
+ reasonCodes: [],
+ blockingLabels: [],
+ stateLabels: [],
+ warningLabels: [],
+ suppressedLabels: [],
+ applicableLabels: [],
+ redacted: false,
+ ...overrides,
+ };
+}
+
+describe("RegistryPluginDetail moderation", () => {
+ // `vi.clearAllMocks()` only, not `resetAllMocks`/`restoreAllMocks` --
+ // those would also wipe `mockEvaluateReleaseViews`'s base implementation
+ // (the real `evaluateReleaseViews`, set once when the mock factory
+ // resolves), breaking every test after the first that doesn't call
+ // `mockImplementationOnce` itself.
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders an error panel, disables Install, and annotates the blocked option", async () => {
+ setup(makePackage(), [makeRelease(), makeRelease({ version: "1.2.2" })]);
+ mockEvaluateReleaseViews
+ .mockImplementationOnce(() =>
+ makeModeration({
+ eligibility: "blocked",
+ reasonCodes: ["automated-block"],
+ blockingLabels: ["malware"],
+ applicableLabels: [
+ {
+ ver: 1,
+ src: "did:plc:labeler",
+ uri: releaseUriFor("1.2.3"),
+ val: "malware",
+ cts: "2025-01-01T00:00:00Z",
+ },
+ ],
+ }),
+ )
+ .mockImplementationOnce(() => makeModeration({ reasonCodes: ["eligible-assessment-pass"] }));
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ await expect.element(screen.getByText("This release is blocked")).toBeInTheDocument();
+ await expect.element(screen.getByText("Malware")).toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("button", { name: /Issued by labeler did:plc:labeler/ }))
+ .toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "Install" })).toBeDisabled();
+
+ screen.getByRole("combobox", { name: "Version" }).element().click();
+ const blockedOption = screen.getByRole("option", { name: /1\.2\.3/ });
+ await expect.element(blockedOption).toBeInTheDocument();
+ expect(blockedOption.element().textContent).toContain("blocked");
+ const cleanOption = screen.getByRole("option", { name: /1\.2\.2/ });
+ await expect.element(cleanOption).toBeInTheDocument();
+ expect(cleanOption.element().textContent).not.toContain("blocked");
+ });
+
+ it("renders a warning panel, keeps Install enabled, and lists the warning in the consent dialog", async () => {
+ setup(makePackage(), [makeRelease()]);
+ mockEvaluateReleaseViews.mockImplementationOnce(() =>
+ makeModeration({
+ reasonCodes: ["eligible-assessment-pass", "warning-labels"],
+ warningLabels: ["suspicious-code"],
+ applicableLabels: [
+ {
+ ver: 1,
+ src: "did:plc:labeler",
+ uri: releaseUriFor("1.2.3"),
+ val: "suspicious-code",
+ cts: "2025-01-01T00:00:00Z",
+ },
+ ],
+ }),
+ );
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ await expect
+ .element(screen.getByText("This release has moderation warnings"))
+ .toBeInTheDocument();
+ await expect.element(screen.getByText("Suspicious code")).toBeInTheDocument();
+ const installButton = screen.getByRole("button", { name: "Install" });
+ await expect.element(installButton).toBeInTheDocument();
+ await expect.element(installButton).not.toBeDisabled();
+
+ installButton.element().click();
+ await expect.element(screen.getByRole("dialog")).toBeInTheDocument();
+ await expect
+ .element(screen.getByText("Moderation warnings", { exact: true }))
+ .toBeInTheDocument();
+ // Two occurrences: the page's own warning banner (still rendered behind
+ // the modal) plus the consent dialog's warnings section.
+ await expect.element(screen.getByText("Suspicious code").nth(1)).toBeInTheDocument();
+ await expect.element(screen.getByText(/Issued by did:plc:labeler/).nth(0)).toBeInTheDocument();
+ });
+
+ it("renders no moderation panel and keeps Install enabled for a clean release", async () => {
+ setup(makePackage(), [makeRelease()]);
+ const screen = await render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("This release is blocked").query()).toBeNull();
+ expect(screen.getByText("This release has moderation warnings").query()).toBeNull();
+ await expect.element(screen.getByRole("button", { name: "Install" })).not.toBeDisabled();
+ });
+
+ it("falls back to the raw value for a label this admin build doesn't have display text for", async () => {
+ setup(makePackage(), [makeRelease()]);
+ mockEvaluateReleaseViews.mockImplementationOnce(() =>
+ makeModeration({
+ eligibility: "blocked",
+ reasonCodes: ["manual-block"],
+ blockingLabels: ["some-future-block-value"],
+ applicableLabels: [
+ {
+ ver: 1,
+ src: "did:plc:labeler",
+ uri: releaseUriFor("1.2.3"),
+ val: "some-future-block-value",
+ cts: "2025-01-01T00:00:00Z",
+ },
+ ],
+ }),
+ );
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ await expect.element(screen.getByText("This release is blocked")).toBeInTheDocument();
+ await expect.element(screen.getByText("some-future-block-value")).toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "Install" })).toBeDisabled();
+ });
+
+ it("keeps a security-yanked release visible in the picker but blocks it (regression vs the old silent filter)", async () => {
+ // Real evaluation pipeline end to end -- no `evaluateReleaseViews` mock
+ // override -- to prove the deleted `isYanked` colon-value filter isn't
+ // silently hiding this release from the picker anymore.
+ const configWithLabeler: RegistryClientConfig = {
+ aggregatorUrl: "https://aggregator.test",
+ acceptLabelers: "did:plc:labeler",
+ };
+ setup(makePackage(), [
+ makeRelease({ version: "2.0.0", labels: [securityYankedLabel("2.0.0")] }),
+ makeRelease({ version: "1.0.0" }),
+ ]);
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ await expect.element(screen.getByText("This release is blocked")).toBeInTheDocument();
+ await expect.element(screen.getByText("Security yanked")).toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "Install" })).toBeDisabled();
+
+ screen.getByRole("combobox", { name: "Version" }).element().click();
+ const yankedOption = screen.getByRole("option", { name: /2\.0\.0/ });
+ await expect.element(yankedOption).toBeInTheDocument();
+ expect(yankedOption.element().textContent).toContain("blocked");
+ const otherOption = screen.getByRole("option", { name: /1\.0\.0/ });
+ await expect.element(otherOption).toBeInTheDocument();
+ expect(otherOption.element().textContent).not.toContain("blocked");
+ });
+});
diff --git a/packages/admin/tests/lib/registry-moderation.test.ts b/packages/admin/tests/lib/registry-moderation.test.ts
new file mode 100644
index 0000000000..bbe1978fbc
--- /dev/null
+++ b/packages/admin/tests/lib/registry-moderation.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ describeModerationLabel,
+ describeRegistryModerationError,
+ evaluatePackageModeration,
+ isModerationBlocking,
+ RegistryModerationBlockError,
+ type RegistryPackageView,
+} from "../../src/lib/api/registry";
+
+describe("describeModerationLabel", () => {
+ it("returns localized display text for a known value", () => {
+ const { name, description } = describeModerationLabel("malware");
+ expect(name).toBe("Malware");
+ expect(description).toEqual(expect.any(String));
+ expect(description).not.toBeNull();
+ });
+
+ it("falls back to the raw value with no description for an unmapped value", () => {
+ const { name, description } = describeModerationLabel("some-future-label-value");
+ expect(name).toBe("some-future-label-value");
+ expect(description).toBeNull();
+ });
+});
+
+function makePackage(overrides: Partial = {}): RegistryPackageView {
+ return {
+ uri: "at://did:plc:acme/com.emdashcms.experimental.package.profile/myplugin",
+ cid: "bafypkgcid",
+ did: "did:plc:acme",
+ slug: "myplugin",
+ indexedAt: "2025-01-01T00:00:00Z",
+ labels: [],
+ profile: null,
+ ...overrides,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixture cast to the validated view shape
+ } as any;
+}
+
+describe("evaluatePackageModeration", () => {
+ it("blocks a package carrying a publisher-scope takedown label", () => {
+ const pkg = makePackage({
+ labels: [
+ {
+ ver: 1,
+ src: "did:plc:labeler",
+ uri: "did:plc:acme",
+ val: "!takedown",
+ cts: "2025-01-01T00:00:00Z",
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- raw label fixture
+ ] as any,
+ });
+ const moderation = evaluatePackageModeration(pkg, [{ did: "did:plc:labeler", redact: false }]);
+ expect(isModerationBlocking(moderation)).toBe(true);
+ expect(moderation.blockingLabels).toContain("!takedown");
+ });
+
+ it("does not block a clean package", () => {
+ const pkg = makePackage();
+ const moderation = evaluatePackageModeration(pkg, [{ did: "did:plc:labeler", redact: false }]);
+ expect(isModerationBlocking(moderation)).toBe(false);
+ });
+});
+
+describe("describeRegistryModerationError", () => {
+ it("renders a localized headline plus the localized blocking label names for RELEASE_BLOCKED", () => {
+ const error = new RegistryModerationBlockError("RELEASE_BLOCKED", "raw server message", {
+ reasonCodes: ["manual-block"],
+ blockingLabels: ["malware"],
+ });
+ expect(describeRegistryModerationError(error)).toBe(
+ "This release is blocked and can't be installed.\nMalware",
+ );
+ });
+
+ it("renders the RELEASE_YANKED headline", () => {
+ const error = new RegistryModerationBlockError("RELEASE_YANKED", "raw server message", {
+ reasonCodes: ["manual-block"],
+ blockingLabels: ["security-yanked"],
+ });
+ expect(describeRegistryModerationError(error)).toBe(
+ "This release was withdrawn and can't be installed.\nSecurity yanked",
+ );
+ });
+
+ it("returns null for any other error, keeping the generic fallback", () => {
+ expect(describeRegistryModerationError(new Error("network error"))).toBeNull();
+ expect(describeRegistryModerationError(undefined)).toBeNull();
+ });
+});
From d02c647de52a79be2928ca9b12277c18d09b8d18 Mon Sep 17 00:00:00 2001
From: Matt Kane
Date: Sun, 12 Jul 2026 12:50:22 +0100
Subject: [PATCH 2/3] 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.
---
.../src/components/RegistryPluginDetail.tsx | 28 +++++--
packages/admin/src/lib/api/registry.ts | 19 +++++
.../components/RegistryPluginDetail.test.tsx | 82 ++++++++++++++++++-
3 files changed, 116 insertions(+), 13 deletions(-)
diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx
index 1181cb5287..0cff8af828 100644
--- a/packages/admin/src/components/RegistryPluginDetail.tsx
+++ b/packages/admin/src/components/RegistryPluginDetail.tsx
@@ -42,6 +42,7 @@ import {
presentSections,
releasePassesPolicy,
resolveAcceptedPolicy,
+ unionAcceptedPolicies,
resolveRegistryPackage,
sbomDownloadHref,
type ReleaseModeration,
@@ -150,17 +151,26 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
const hasFilteredAllReleases = (releasesData?.releases.length ?? 0) > 0 && releases.length === 0;
// Moderation, evaluated per release against the accepted policy that
- // actually produced the `releasesData` response (its
- // `atproto-content-labelers` header), not just the site's statically
- // configured `acceptLabelers` -- the aggregator reports what it actually
- // applied for this specific request.
+ // actually produced each response's labels -- the aggregator reports what
+ // it applied per request via `atproto-content-labelers`, and package-scope
+ // labels ride on the package response while release-scope labels ride on
+ // the releases response. Both header-derived policies are unioned (a
+ // labeler either response honored is honored here) so a package/publisher
+ // block filtered out of the releases policy is still surfaced; a union can
+ // only add applicable labels' sources, never drop a real block.
const acceptedForReleases = React.useMemo(
() =>
- resolveAcceptedPolicy({
- configuredAcceptLabelers: config.acceptLabelers,
- contentLabelersHeader: releasesData?.contentLabelers,
- }),
- [config.acceptLabelers, releasesData?.contentLabelers],
+ unionAcceptedPolicies(
+ resolveAcceptedPolicy({
+ configuredAcceptLabelers: config.acceptLabelers,
+ contentLabelersHeader: pkg?.contentLabelers,
+ }),
+ resolveAcceptedPolicy({
+ configuredAcceptLabelers: config.acceptLabelers,
+ contentLabelersHeader: releasesData?.contentLabelers,
+ }),
+ ),
+ [config.acceptLabelers, pkg?.contentLabelers, releasesData?.contentLabelers],
);
const moderationByVersion = React.useMemo(() => {
const map = new Map();
diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts
index 1d064615c8..1e92396bc7 100644
--- a/packages/admin/src/lib/api/registry.ts
+++ b/packages/admin/src/lib/api/registry.ts
@@ -57,6 +57,25 @@ export type { HostEnv };
export { evaluateReleaseViews, isModerationBlocking, resolveAcceptedPolicy };
export type { AcceptedLabelerPolicy, ReleaseModeration };
+/**
+ * Union of two accepted-labeler policies, deduped by DID with `redact` OR-ed.
+ * Used when package-scope and release-scope labels arrive on separate
+ * responses (each with its own `atproto-content-labelers` header): a labeler
+ * either response honored is honored for the combined evaluation, so a
+ * package/publisher block filtered out of one response's policy is still
+ * surfaced. A union only adds label sources; it can never drop a real block.
+ */
+export function unionAcceptedPolicies(
+ a: AcceptedLabelerPolicy[],
+ b: AcceptedLabelerPolicy[],
+): AcceptedLabelerPolicy[] {
+ const byDid = new Map();
+ for (const policy of [...a, ...b]) {
+ byDid.set(policy.did, (byDid.get(policy.did) ?? false) || policy.redact);
+ }
+ return Array.from(byDid, ([did, redact]) => ({ did, redact }));
+}
+
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
diff --git a/packages/admin/tests/components/RegistryPluginDetail.test.tsx b/packages/admin/tests/components/RegistryPluginDetail.test.tsx
index 6a386b6f8a..5129b7d93b 100644
--- a/packages/admin/tests/components/RegistryPluginDetail.test.tsx
+++ b/packages/admin/tests/components/RegistryPluginDetail.test.tsx
@@ -158,10 +158,35 @@ function Wrapper({ children }: { children: React.ReactNode }) {
return {children} ;
}
-function setup(pkg: RegistryPackageView, releases: RegistryReleaseView[]) {
- mockGetRegistryPackage.mockResolvedValue(pkg);
- mockResolveRegistryPackage.mockResolvedValue(pkg);
- mockListRegistryReleases.mockResolvedValue({ releases });
+function setup(
+ pkg: RegistryPackageView,
+ releases: RegistryReleaseView[],
+ headers: { packageContentLabelers?: string; releasesContentLabelers?: string } = {},
+) {
+ mockGetRegistryPackage.mockResolvedValue({
+ ...pkg,
+ contentLabelers: headers.packageContentLabelers,
+ });
+ mockResolveRegistryPackage.mockResolvedValue({
+ ...pkg,
+ contentLabelers: headers.packageContentLabelers,
+ });
+ mockListRegistryReleases.mockResolvedValue({
+ releases,
+ contentLabelers: headers.releasesContentLabelers,
+ });
+}
+
+/** A `publisher-compromised` label on the publisher DID (rides on the package
+ * response, applies package-wide). */
+function publisherCompromisedLabel(src = "did:plc:labeler"): RawLabel {
+ return {
+ ver: 1,
+ src,
+ uri: "did:plc:acme",
+ val: "publisher-compromised",
+ cts: "2025-01-01T00:00:00Z",
+ };
}
describe("RegistryPluginDetail sections", () => {
@@ -513,4 +538,53 @@ describe("RegistryPluginDetail moderation", () => {
await expect.element(otherOption).toBeInTheDocument();
expect(otherOption.element().textContent).not.toContain("blocked");
});
+
+ it("honors a labeler named only by the response header, not the configured policy", async () => {
+ // config accepts no labelers; the aggregator reports it applied one via
+ // the releases response's `atproto-content-labelers` header. The
+ // header-precedence path must honor it. Real evaluation pipeline.
+ const configNoLabelers: RegistryClientConfig = {
+ aggregatorUrl: "https://aggregator.test",
+ acceptLabelers: "",
+ };
+ setup(
+ makePackage(),
+ [makeRelease({ version: "2.0.0", labels: [securityYankedLabel("2.0.0")] })],
+ { releasesContentLabelers: "did:plc:labeler" },
+ );
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ await expect.element(screen.getByText("This release is blocked")).toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "Install" })).toBeDisabled();
+ });
+
+ it("surfaces a package-scope block whose labeler only the package response reported", async () => {
+ // publisher-compromised rides on the package response; its labeler is in
+ // the package header but NOT the releases header. Evaluating package
+ // labels against only the releases policy would filter it out — the
+ // unioned policy must still surface the block. Real pipeline.
+ const configNoLabelers: RegistryClientConfig = {
+ aggregatorUrl: "https://aggregator.test",
+ acceptLabelers: "",
+ };
+ setup(
+ makePackage({ labels: [publisherCompromisedLabel()] }),
+ [makeRelease({ version: "2.0.0" })],
+ { packageContentLabelers: "did:plc:labeler", releasesContentLabelers: "" },
+ );
+
+ const screen = await render(
+
+
+ ,
+ );
+
+ await expect.element(screen.getByText("This release is blocked")).toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "Install" })).toBeDisabled();
+ });
});
From 09104bb6cd92068714decb1ced9fd0479b534086 Mon Sep 17 00:00:00 2001
From: Matt Kane
Date: Sun, 12 Jul 2026 13:05:56 +0100
Subject: [PATCH 3/3] 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.
---
.../src/components/RegistryPluginDetail.tsx | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx
index 0cff8af828..82db2efaa7 100644
--- a/packages/admin/src/components/RegistryPluginDetail.tsx
+++ b/packages/admin/src/components/RegistryPluginDetail.tsx
@@ -17,8 +17,9 @@ import { Badge, Button, LinkButton, Select, Tabs, Tooltip } from "@cloudflare/ku
import type { TabsItem } from "@cloudflare/kumo";
import { declaredAccessToCapabilities, type DeclaredAccess } from "@emdash-cms/plugin-types";
import { checkEnvCompatibility } from "@emdash-cms/registry-client/env";
+import { i18n } from "@lingui/core";
import type { MessageDescriptor } from "@lingui/core";
-import { msg } from "@lingui/core/macro";
+import { msg, plural } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { ShieldCheck, ShieldWarning, Warning } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -1075,8 +1076,15 @@ function LicenseBadge({ license }: { license: string }) {
}
function formatHoldback(seconds: number): string {
- if (seconds <= 0) return "0s";
- if (seconds < 60 * 60) return `${Math.round(seconds / 60)} min`;
- if (seconds < 24 * 60 * 60) return `${Math.round(seconds / 60 / 60)} h`;
- return `${Math.round(seconds / 60 / 60 / 24)} d`;
+ if (seconds <= 0) return i18n._(msg`0 seconds`);
+ if (seconds < 60 * 60) {
+ const minutes = Math.round(seconds / 60);
+ return i18n._(plural(minutes, { one: "# minute", other: "# minutes" }));
+ }
+ if (seconds < 24 * 60 * 60) {
+ const hours = Math.round(seconds / 60 / 60);
+ return i18n._(plural(hours, { one: "# hour", other: "# hours" }));
+ }
+ const days = Math.round(seconds / 60 / 60 / 24);
+ return i18n._(plural(days, { one: "# day", other: "# days" }));
}