From a4e30298ac31942e2fa4e54f19de996490062e87 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 12 Jul 2026 12:39:05 +0100 Subject: [PATCH 1/3] feat(admin): show registry moderation state and gate blocked installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/lucky-labelers-admin-moderation.md | 5 + .../components/CapabilityConsentDialog.tsx | 43 ++ .../admin/src/components/PluginManager.tsx | 35 +- .../admin/src/components/RegistryBrowse.tsx | 46 +- .../src/components/RegistryPluginDetail.tsx | 211 +++++++-- packages/admin/src/lib/api/marketplace.ts | 15 + packages/admin/src/lib/api/registry.ts | 428 ++++++++++++++---- .../CapabilityConsentDialog.test.tsx | 76 ++++ .../tests/components/PluginManager.test.tsx | 103 +++++ .../tests/components/RegistryBrowse.test.tsx | 126 ++++++ .../components/RegistryPluginDetail.test.tsx | 246 +++++++++- .../tests/lib/registry-moderation.test.ts | 92 ++++ 12 files changed, 1288 insertions(+), 138 deletions(-) create mode 100644 .changeset/lucky-labelers-admin-moderation.md create mode 100644 packages/admin/tests/components/RegistryBrowse.test.tsx create mode 100644 packages/admin/tests/lib/registry-moderation.test.ts diff --git a/.changeset/lucky-labelers-admin-moderation.md b/.changeset/lucky-labelers-admin-moderation.md new file mode 100644 index 0000000000..faa7a163d0 --- /dev/null +++ b/.changeset/lucky-labelers-admin-moderation.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +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. diff --git a/packages/admin/src/components/CapabilityConsentDialog.tsx b/packages/admin/src/components/CapabilityConsentDialog.tsx index f97c1eedd8..45435f942e 100644 --- a/packages/admin/src/components/CapabilityConsentDialog.tsx +++ b/packages/admin/src/components/CapabilityConsentDialog.tsx @@ -15,6 +15,15 @@ import { describeCapability } from "../lib/api/marketplace.js"; import { cn } from "../lib/utils.js"; import { DialogError } from "./DialogError.js"; +/** A moderation label, pre-resolved to localized display text by the caller. */ +export interface ModerationLabelEntry { + value: string; + name: string; + description: string | null; + /** DID of the labeler that issued this label. */ + issuerDid: string; +} + export interface CapabilityConsentDialogProps { /** Dialog mode */ mode?: "install" | "update"; @@ -22,6 +31,13 @@ export interface CapabilityConsentDialogProps { pluginName: string; /** Capabilities the plugin requests */ capabilities: string[]; + /** + * Active moderation warning labels on the release being installed/updated. + * Shown above the capabilities list whenever non-empty -- including when + * `capabilities` is empty, in which case the dialog shows only this + * section. + */ + moderationWarnings?: ModerationLabelEntry[]; /** Allowed network hosts (for network:fetch capability) */ allowedHosts?: string[]; /** New capabilities added in an update (highlighted differently) */ @@ -44,6 +60,7 @@ export function CapabilityConsentDialog({ mode, pluginName, capabilities, + moderationWarnings = [], allowedHosts, newCapabilities = [], newlyPublicRoutes = [], @@ -81,6 +98,32 @@ export function CapabilityConsentDialog({

+ {/* Moderation warnings */} + {moderationWarnings.length > 0 && ( +
+
+
+ + {t`Moderation warnings`} +
+
    + {moderationWarnings.map((warning) => ( +
  • +

    {warning.name}

    + {warning.description ? ( +

    {warning.description}

    + ) : null} +

    {t`Issued by ${warning.issuerDid}`}

    +
  • + ))} +
+
+
+ )} + {/* Capabilities list */}
{capabilities.map((cap) => { diff --git a/packages/admin/src/components/PluginManager.tsx b/packages/admin/src/components/PluginManager.tsx index b83450911f..ba600ff052 100644 --- a/packages/admin/src/components/PluginManager.tsx +++ b/packages/admin/src/components/PluginManager.tsx @@ -6,7 +6,7 @@ * update/uninstall for marketplace-installed plugins. */ -import { Badge, Button, Checkbox, Switch, Toast } from "@cloudflare/kumo"; +import { Badge, Button, Checkbox, Switch, Toast, Tooltip } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { @@ -41,6 +41,7 @@ import { } from "../lib/api/marketplace.js"; import { RegistryUpdateEscalationError, + describeRegistryModerationError, uninstallRegistryPlugin, updateRegistryPlugin, type RegistryUpdateOpts, @@ -242,6 +243,13 @@ function PluginCard({ const isMarketplace = plugin.source === "marketplace"; const isRegistry = plugin.source === "registry"; const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest; + // Never key off `updateInfo.moderation.eligibility` -- with no accepted + // labeler having passed a release, `eligibility` reads "blocked" even for + // a clean plugin. See the field's origin JSDoc on `RegistryUpdateCheck` + // in packages/core's registry handler. + const moderationBlockingLabels = updateInfo?.moderation?.blockingLabels ?? []; + const moderationWarningLabels = updateInfo?.moderation?.warningLabels ?? []; + const isUpdateModerationBlocked = moderationBlockingLabels.length > 0; const updateMutation = useMutation({ mutationFn: (opts: RegistryUpdateOpts) => @@ -353,11 +361,17 @@ function PluginCard({ v{plugin.version} {!plugin.enabled && {t`Disabled`}} {isMarketplace && {t`Marketplace`}} - {hasUpdate && ( + {hasUpdate && isUpdateModerationBlocked && ( + {t`Update blocked`} + )} + {hasUpdate && !isUpdateModerationBlocked && ( {t`v${updateInfo.latest} available`} )} + {hasUpdate && !isUpdateModerationBlocked && moderationWarningLabels.length > 0 && ( + {t`Update has warnings`} + )}
{/* Description */} @@ -407,7 +421,19 @@ function PluginCard({ {/* Actions */}
- {hasUpdate && ( + {hasUpdate && isUpdateModerationBlocked && ( + + + + } + /> + )} + {hasUpdate && !isUpdateModerationBlocked && (
) : 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" })); }