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`} +
+ +
+
+ )} + {/* 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 +912,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 +937,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 +1003,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(); @@ -922,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" })); } 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..1e92396bc7 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,27 @@ import { export type { Did, Handle }; 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 @@ -77,6 +111,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 +291,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 }; - const mod = await import("@emdash-cms/registry-client/discovery"); - const DiscoveryClient = mod.DiscoveryClient; +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; +} + +/** + * 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, - }); + onResponseMeta: (meta) => { + contentLabelers = meta.contentLabelers; }, - 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, - }); - }, - }; - - cachedDiscovery = { config, client: wrapped }; - return wrapped; + }); + const data = await fn(discovery); + return { ...data, contentLabelers }; } // --------------------------------------------------------------------------- @@ -360,36 +528,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 +581,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 +862,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 +948,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 +1027,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..5129b7d93b 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 }) { @@ -110,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", () => { @@ -274,3 +347,244 @@ 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"); + }); + + 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(); + }); +}); 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(); + }); +});