Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bright-plugins-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@emdash-cms/admin": minor
---

Adds public names for registry plugins in the `@publisher.example/plugin-slug` format. Registry results and installed-plugin cards display the verified public name and link to a handle-based detail URL, while exact public-name searches open the matching package.

When a publisher handle conclusively fails identity verification, the admin displays **INVALID HANDLE** and prevents installation. Temporary lookup failures fall back to the stable publisher identifier without marking the handle invalid.
10 changes: 9 additions & 1 deletion docs/src/content/docs/plugins/registry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ The plugin registry is the supported catalog for sandboxed plugins. It lets admi

When `experimental.registry` is configured, the admin panel displays a **Registry** section for browsing and installing plugins.

## Plugin public names

Every registry plugin has a public name made from its publisher's current Atmosphere account handle and its package slug. For example, a package with the slug `my-gallery` published by `example.com` appears as `@example.com/my-gallery`.

The public name appears in registry results and under **Plugins** after installation. Select it to open the plugin detail page, or paste the complete name into registry search to find that exact package. EmDash resolves the handle to the publisher's stable account identifier before loading the package.

If the handle conclusively stops resolving back to the publisher, EmDash displays **INVALID HANDLE** and prevents new installation from the package detail page. Existing installations remain in their current state so an identity lookup cannot disable a running site. Review the installed plugin and contact its publisher before updating it. A temporary lookup failure displays the stable publisher identifier with **Handle unavailable** instead of reporting an invalid handle.

## Enable the registry

To install plugins from the registry, configure storage and an available sandbox runner. Complete the platform-specific [plugin sandbox setup](/deployment/plugin-sandbox/) first.
Expand Down Expand Up @@ -45,7 +53,7 @@ Use the bare hosted registry URL for the normal setup. See the [`experimental.re
<Steps>

1. Open **Registry** in the admin panel.
2. Search for a plugin and open its detail page.
2. Search by title, description, or a complete public name such as `@example.com/my-gallery`, then open the plugin's detail page.
3. Select a release and review its publisher, metadata, requested permissions, and verification status.
4. Select **Install**.
5. Review the verification status and permissions in the consent dialog, then confirm.
Expand Down
18 changes: 16 additions & 2 deletions packages/admin/src/components/PluginManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { ADMIN_NAV_ICONS } from "./admin-navigation-icons.js";
import { CaretNext } from "./ArrowIcons.js";
import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js";
import { DialogError, getMutationError } from "./DialogError.js";
import { RegistryPluginIdentity, useRegistryPluginIdentity } from "./RegistryPluginIdentity.js";
import { RouterLinkButton } from "./RouterLinkButton.js";

export function MarketplaceInstallMessage() {
Expand Down Expand Up @@ -257,6 +258,10 @@ function PluginCard({

const isMarketplace = plugin.source === "marketplace";
const isRegistry = plugin.source === "registry";
const registryIdentity = useRegistryPluginIdentity(
isRegistry ? plugin.registryPublisherDid : undefined,
isRegistry ? plugin.registrySlug : undefined,
);
const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;
const mcpTools = plugin.mcpTools ?? [];

Expand Down Expand Up @@ -368,8 +373,9 @@ function PluginCard({
<>
<div
className={cn(
"rounded-lg border bg-kumo-base transition-colors",
"rounded-lg border border-kumo-border bg-kumo-base transition-colors",
!plugin.enabled && "opacity-75",
registryIdentity?.status === "invalid" && "border-kumo-danger",
)}
>
<div className="flex items-center gap-4 p-4">
Expand Down Expand Up @@ -401,15 +407,23 @@ function PluginCard({
<span className="text-xs text-kumo-subtle">v{plugin.version}</span>
{!plugin.enabled && <Badge variant="secondary">{t`Disabled`}</Badge>}
{isMarketplace && <Badge variant="secondary">{t`Marketplace`}</Badge>}
{isRegistry && <Badge variant="secondary">{t`Registry`}</Badge>}
{hasUpdate && (
<Badge variant="outline" className="border-kumo-brand text-kumo-link">
{t`v${updateInfo.latest} available`}
</Badge>
)}
</div>
{registryIdentity && (
<RegistryPluginIdentity
identity={registryIdentity}
invalidMessage={t`This publisher identity no longer resolves.`}
className="mt-0.5"
/>
)}

{/* Description */}
{plugin.description && (
{plugin.description && registryIdentity?.status !== "invalid" && (
<p className="mt-0.5 text-sm text-kumo-subtle line-clamp-1">{plugin.description}</p>
)}

Expand Down
8 changes: 4 additions & 4 deletions packages/admin/src/components/PublisherHandle.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Publisher identity for ordinary registry views.
* Publisher byline for a registry package detail view.
*
* Mutable handles are outside the package-profile CID's moderation boundary,
* so browse and detail pages render only an approved author name or a stable,
* shortened DID. Routing likewise uses the full DID.
* Public `@handle/slug` names and links are rendered by
* `RegistryPluginIdentity`; this component keeps the separately approved
* author-name or stable-DID fallback used by the "Published by" line.
*/

export interface PublisherIdentityProfile {
Expand Down
59 changes: 43 additions & 16 deletions packages/admin/src/components/RegistryBrowse.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
/**
* Registry Browse
* Registry browse
*
* Grid of plugin cards backed by the experimental decentralized plugin
* registry's aggregator. Search box debounces directly into the
* aggregator's `searchPackages` XRPC -- the aggregator is a public,
* read-only service, so no server proxy is involved.
* registry's public, read-only aggregator.
*
* Cards navigate to `/plugins/marketplace/$pluginId` (the same path the
* marketplace browse uses); the router branches to the registry detail
* component when `manifest.registry` is configured.
* Cards navigate to `/plugins/registry/$publisher/$slug`. A search that
* matches `@handle/slug` resolves that package directly; other input uses
* the aggregator's free-text `searchPackages` endpoint.
*/

import { Badge, Button, Input } from "@cloudflare/kumo";
Expand All @@ -20,12 +18,18 @@ import * as React from "react";

import {
searchRegistryPackages,
resolveRegistryPackageStatus,
registryQueryPolicyKey,
type RegistryClientConfig,
type RegistryPackageView,
} from "../lib/api/registry.js";
import {
parseRegistryPublicName,
registryIdentityPublisherParam,
} from "../lib/registry-identity.js";
import { cn } from "../lib/utils.js";
import { ADMIN_NAV_ICONS } from "./admin-navigation-icons.js";
import { PublisherIdentity } from "./PublisherHandle.js";
import { RegistryPluginIdentity, useRegistryPluginIdentity } from "./RegistryPluginIdentity.js";

export interface RegistryBrowseProps {
/** Resolved manifest.registry block. Required -- caller checks. */
Expand Down Expand Up @@ -66,12 +70,22 @@ export function RegistryBrowse({ config, installedRegistryUris = new Set() }: Re
registryQueryPolicyKey(config),
debouncedQuery,
],
queryFn: ({ pageParam }) =>
searchRegistryPackages(config, {
queryFn: async ({ pageParam }) => {
const publicName = parseRegistryPublicName(debouncedQuery);
if (publicName) {
const result = await resolveRegistryPackageStatus(
config,
publicName.handle,
publicName.slug,
);
return { packages: result.status === "passed" ? [result.value] : [] };
}
return searchRegistryPackages(config, {
q: debouncedQuery || undefined,
cursor: pageParam,
limit: 20,
}),
});
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.cursor,
refetchOnMount: "always",
Expand Down Expand Up @@ -177,6 +191,7 @@ interface RegistryPackageCardProps {

function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
const { t } = useLingui();
const identity = useRegistryPluginIdentity(pkg.did, pkg.slug)!;
// `profile` is lexicon-validated at the DiscoveryClient boundary, so the
// shape is trustworthy (or `null`). These are plain text content
// (React-escaped) — no URL/href, so no scheme allow-list is needed here.
Expand All @@ -186,19 +201,31 @@ function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {

return (
<Link
to="/plugins/marketplace/$pluginId"
params={{ pluginId: `${pkg.did}/${pkg.slug}` }}
className="block rounded-md border border-kumo-border bg-kumo-surface p-4 transition-colors hover:bg-kumo-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand"
to="/plugins/registry/$publisher/$slug"
params={{ publisher: registryIdentityPublisherParam(identity), slug: pkg.slug }}
className={cn(
"block rounded-md border bg-kumo-surface p-4 transition-colors hover:bg-kumo-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand",
identity.status === "invalid" ? "border-kumo-danger" : "border-kumo-border",
)}
>
<div className="flex items-start gap-3">
<div className="mt-1 rounded-md bg-kumo-subtle p-2 text-kumo-subtle">
<ADMIN_NAV_ICONS.plugins className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h2 className="truncate font-semibold">{name ?? pkg.slug}</h2>
<PublisherIdentity did={pkg.did} profile={pkg.profile} variant="card" />
<RegistryPluginIdentity
identity={identity}
invalidMessage={t`The publisher identity cannot be verified.`}
className="mt-0.5"
linked={false}
/>

{description ? (
{identity.status === "invalid" ? (
<p className="mt-2 text-sm font-medium text-kumo-danger">
{t`Installation is unavailable.`}
</p>
) : description ? (
<p className="mt-2 line-clamp-2 text-sm text-kumo-default">{description}</p>
) : null}
{license ? <p className="mt-2 text-xs text-kumo-subtle">{license}</p> : null}
Expand Down
26 changes: 19 additions & 7 deletions packages/admin/src/components/RegistryPluginDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
* which independently verifies publisher records, artifact, manifest, and
* provenance before any write.
*
* Identified in the URL by a `pluginId` that is `${handle}/${slug}`.
* The router wraps this component when `manifest.registry` is set on
* the same route the marketplace detail uses, so existing bookmarks /
* sidebar entries stay stable.
* `pluginId` is `${publisher}/${slug}`, where `publisher` is a verified
* handle (with or without `@`) or a stable DID. The dedicated registry route
* renders this component; the marketplace detail route forwards matching
* legacy DID-based links for backward compatibility.
*/

import { Badge, Button, LinkButton, Select, Tabs } from "@cloudflare/kumo";
Expand Down Expand Up @@ -54,10 +54,12 @@ import {
type SectionKey,
} from "../lib/api/registry.js";
import { renderMarkdown } from "../lib/markdown.js";
import { registryIdentity } from "../lib/registry-identity.js";
import { ArrowPrev } from "./ArrowIcons.js";
import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js";
import { getMutationError } from "./DialogError.js";
import { PublisherIdentity } from "./PublisherHandle.js";
import { RegistryPluginIdentity } from "./RegistryPluginIdentity.js";

export interface RegistryPluginDetailProps {
/** `${handle}/${slug}` -- the pluginId param from the route. */
Expand Down Expand Up @@ -103,7 +105,8 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
// historically, though atproto handles don't; the DID form
// definitely doesn't).
const slashIdx = pluginId.lastIndexOf("/");
const publisher = slashIdx > 0 ? pluginId.slice(0, slashIdx) : "";
const publisherParam = slashIdx > 0 ? pluginId.slice(0, slashIdx) : "";
const publisher = publisherParam.startsWith("@") ? publisherParam.slice(1) : publisherParam;
const slug = slashIdx > 0 ? pluginId.slice(slashIdx + 1) : "";
const isDid = publisher.startsWith("did:");

Expand Down Expand Up @@ -148,6 +151,7 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
// A conclusive round-trip mismatch blocks the UI. Indeterminate lookup failures
// do not participate in trust decisions; the server verifies DID-bound records.
const publisherHandleInvalid = publisherHandleResolution?.status === "invalid";
const publicIdentity = pkg ? registryIdentity(pkg.did, slug, publisherHandleResolution) : null;

// `listReleases` returns releases in descending semver order. The aggregator
// contains only the aggregator's approved projection. Lexicon-invalid records
Expand Down Expand Up @@ -524,6 +528,14 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
</div>
<div className="min-w-0 flex-1">
<h1 className="truncate text-2xl font-semibold">{displayName ?? slug}</h1>
{publicIdentity ? (
<RegistryPluginIdentity
identity={publicIdentity}
invalidMessage={t`The publisher identity cannot be verified.`}
className="mt-1"
linked={false}
/>
) : null}
<p className="text-sm text-kumo-subtle">
<Trans>
Published by{" "}
Expand Down Expand Up @@ -637,9 +649,9 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
>
<Warning className="mt-0.5 h-5 w-5 shrink-0" />
<div>
<p className="font-medium">{t`We couldn't verify this publisher's identity`}</p>
<p className="font-medium">{t`Publisher handle verification failed`}</p>
<p className="mt-1 text-sm text-kumo-default">
{t`This publisher claims a name they couldn't prove they own — possibly impersonating someone else. Install is disabled. If you know the publisher and trust them, ask them to fix their identity setup before retrying.`}
{t`This publisher's handle does not resolve back to its identity. Installation is disabled until the publisher fixes their handle.`}
</p>
</div>
</div>
Expand Down
98 changes: 98 additions & 0 deletions packages/admin/src/components/RegistryPluginIdentity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Badge } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { CheckCircle, WarningCircle } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";

import { resolveDidToHandle } from "../lib/api/registry.js";
import {
registryIdentity,
registryIdentityPublisherParam,
type RegistryIdentity,
} from "../lib/registry-identity.js";
import { cn } from "../lib/utils.js";

export function useRegistryPluginIdentity(
did: string | undefined,
slug: string | undefined,
): RegistryIdentity | null {
const { data } = useQuery({
queryKey: ["registry", "publisher-handle", did],
queryFn: () => resolveDidToHandle(did!),
enabled: Boolean(did && slug),
});
if (!did || !slug) return null;
return registryIdentity(did, slug, data);
}

export interface RegistryPluginIdentityProps {
identity: RegistryIdentity;
invalidMessage: string;
className?: string;
linked?: boolean;
}

export function RegistryPluginIdentity({
identity,
invalidMessage,
className,
linked = true,
}: RegistryPluginIdentityProps) {
const { t } = useLingui();

if (identity.status === "ok") {
const publicName = (
<code className="truncate font-mono text-kumo-link" dir="auto">
{identity.publicName}
</code>
);
return (
<div className={cn("flex min-w-0 items-center gap-1.5 text-sm", className)}>
{linked ? (
<Link
to="/plugins/registry/$publisher/$slug"
params={{
publisher: registryIdentityPublisherParam(identity),
slug: identity.slug,
}}
className="min-w-0 hover:underline"
>
{publicName}
</Link>
) : (
publicName
)}
<CheckCircle
className="h-4 w-4 shrink-0 text-kumo-success"
weight="fill"
aria-label={t`Verified publisher`}
/>
</div>
);
}

if (identity.status === "invalid") {
return (
<div className={cn("mt-1 flex flex-wrap items-center gap-2", className)} role="alert">
<Badge variant="destructive">
<WarningCircle className="me-1 h-3.5 w-3.5" weight="fill" aria-hidden="true" />
{t`INVALID HANDLE`}
</Badge>
<span className="text-sm font-medium text-kumo-danger">{invalidMessage}</span>
</div>
);
}

return (
<div className={cn("text-xs text-kumo-subtle", className)}>
<code className="font-mono" dir="auto">
{identity.did}/{identity.slug}
</code>
{identity.status === "missing" ? (
<span className="ms-2">{t`Handle unavailable`}</span>
) : (
<span className="ms-2">{t`Resolving publisher handle...`}</span>
)}
</div>
);
}
Loading
Loading