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
10 changes: 10 additions & 0 deletions .changeset/registry-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@emdash-cms/admin": minor
"emdash": minor
---

Plugins installed from the experimental registry can now be uninstalled and updated from the admin, the same way marketplace plugins always could. The "uninstall is not yet available for registry plugins" placeholder is gone — registry plugin rows now show the same Uninstall and Update buttons.

The Plugins page's "updates available" indicator now covers registry plugins too. If the registry aggregator is unreachable, marketplace updates still load (and vice versa).

Updates that need newly-declared permissions, or that newly expose a public (unauthenticated) route, prompt for re-consent before installing the new version — matching the gate that marketplace updates already have.
24 changes: 23 additions & 1 deletion packages/admin/src/components/CapabilityConsentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface CapabilityConsentDialogProps {
allowedHosts?: string[];
/** New capabilities added in an update (highlighted differently) */
newCapabilities?: string[];
/** Routes that change from private to public in an update. */
newlyPublicRoutes?: string[];
/** Audit verdict badge */
auditVerdict?: "pass" | "warn" | "fail";
/** Whether the action is in progress */
Expand All @@ -44,6 +46,7 @@ export function CapabilityConsentDialog({
capabilities,
allowedHosts,
newCapabilities = [],
newlyPublicRoutes = [],
auditVerdict,
isPending = false,
error,
Expand All @@ -52,7 +55,7 @@ export function CapabilityConsentDialog({
}: CapabilityConsentDialogProps) {
const { t } = useLingui();
const newSet = new Set(newCapabilities);
const isUpdate = mode === "update" || newCapabilities.length > 0;
const isUpdate = mode === "update" || newCapabilities.length > 0 || newlyPublicRoutes.length > 0;

return (
<div
Expand Down Expand Up @@ -106,6 +109,25 @@ export function CapabilityConsentDialog({
);
})}

{newlyPublicRoutes.length > 0 && (
<div className="rounded-md border border-warning/30 bg-warning/10 p-3 text-sm">
<div className="flex items-center gap-2 font-medium text-warning">
<Warning className="h-4 w-4 shrink-0" />
{t`New public routes`}
</div>
<p className="mt-1 text-xs text-kumo-subtle">
{t`This update exposes the following routes without authentication:`}
</p>
<ul className="mt-2 space-y-1 ps-5 text-xs">
{newlyPublicRoutes.map((route) => (
<li key={route} className="list-disc font-mono">
{route}
</li>
))}
</ul>
</div>
)}

{/* Audit verdict banner */}
{auditVerdict && auditVerdict !== "pass" && (
<div
Expand Down
83 changes: 64 additions & 19 deletions packages/admin/src/components/PluginManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ import {
uninstallMarketplacePlugin,
type PluginUpdateInfo,
} from "../lib/api/marketplace.js";
import {
RegistryUpdateEscalationError,
uninstallRegistryPlugin,
updateRegistryPlugin,
type RegistryUpdateOpts,
} from "../lib/api/registry.js";
import { safeIconUrl } from "../lib/url.js";
import { cn } from "../lib/utils";
import { CaretNext } from "./ArrowIcons.js";
Expand Down Expand Up @@ -118,7 +124,9 @@ export function PluginManager({ manifest }: PluginManagerProps) {
return new Map(updates.map((u) => [u.pluginId, u]));
}, [updates]);

const hasMarketplacePlugins = plugins?.some((p) => p.source === "marketplace");
const hasUpdatableSources = plugins?.some(
(p) => p.source === "marketplace" || p.source === "registry",
);

if (isLoading) {
return (
Expand All @@ -143,7 +151,7 @@ export function PluginManager({ manifest }: PluginManagerProps) {
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">{t`Plugins`}</h1>
<div className="flex items-center gap-3">
{hasMarketplacePlugins && (
{hasUpdatableSources && (
<Button
variant="ghost"
onClick={() => void refetchUpdates()}
Expand Down Expand Up @@ -225,6 +233,8 @@ function PluginCard({
const [expanded, setExpanded] = React.useState(false);
const [showUpdateConsent, setShowUpdateConsent] = React.useState(false);
const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false);
const [registryEscalation, setRegistryEscalation] =
React.useState<RegistryUpdateEscalationError | null>(null);
const queryClient = useQueryClient();
const toastManager = Toast.useToastManager();

Expand All @@ -233,9 +243,13 @@ function PluginCard({
const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;

const updateMutation = useMutation({
mutationFn: () => updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
mutationFn: (opts: RegistryUpdateOpts) =>
isRegistry
? updateRegistryPlugin(plugin.id, opts)
: updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
Comment on lines 245 to +249
onSuccess: () => {
setShowUpdateConsent(false);
setRegistryEscalation(null);
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
void queryClient.invalidateQueries({ queryKey: ["plugin-updates"] });
void queryClient.invalidateQueries({ queryKey: ["manifest"] });
Expand All @@ -244,10 +258,44 @@ function PluginCard({
description: t`${plugin.name} updated to v${updateInfo?.latest}`,
});
},
onError: (err) => {
if (err instanceof RegistryUpdateEscalationError) {
setRegistryEscalation(err);
setShowUpdateConsent(true);
}
},
});

const handleUpdateClick = () => {
if (isRegistry) {
// Preflight without confirm flags. Server returns the real
// capability / route-visibility diff (or just updates if there
// is none); `onError` opens the consent dialog populated with
// the actual diff.
setRegistryEscalation(null);
updateMutation.mutate({});
} else {
setShowUpdateConsent(true);
}
};

const handleUpdateConfirm = () => {
if (isRegistry) {
const opts: RegistryUpdateOpts = { confirmCapabilityChanges: true };
if (registryEscalation?.code === "ROUTE_VISIBILITY_ESCALATION") {
opts.confirmRouteVisibilityChanges = true;
}
updateMutation.mutate(opts);
} else {
updateMutation.mutate({});
}
};

const uninstallMutation = useMutation({
mutationFn: (deleteData: boolean) => uninstallMarketplacePlugin(plugin.id, { deleteData }),
mutationFn: (deleteData: boolean) =>
isRegistry
? uninstallRegistryPlugin(plugin.id, { deleteData })
: uninstallMarketplacePlugin(plugin.id, { deleteData }),
onSuccess: () => {
setShowUninstallConfirm(false);
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
Expand Down Expand Up @@ -359,7 +407,7 @@ function PluginCard({
<Button
variant="outline"
size="sm"
onClick={() => setShowUpdateConsent(true)}
onClick={handleUpdateClick}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? t`Updating...` : t`Update to v${updateInfo.latest}`}
Expand Down Expand Up @@ -482,8 +530,8 @@ function PluginCard({
)}
</div>

{/* Uninstall button for marketplace plugins */}
{isMarketplace && (
{/* Uninstall button for any sandboxed source (marketplace + registry). */}
{(isMarketplace || isRegistry) && (
<div className="pt-2 border-t">
<Button
variant="ghost"
Expand All @@ -496,15 +544,6 @@ function PluginCard({
</Button>
</div>
)}

{/* Registry plugins have an install path but no uninstall
handler yet. Tell the admin so they don't think the
plugin is permanent or fall back to editing the DB. */}
{isRegistry && (
<div className="pt-2 border-t text-xs text-kumo-subtle">
{t`Uninstall is not yet available for registry plugins. Disable the plugin to stop it from running; full uninstall will land in a follow-up.`}
</div>
)}
</div>
)}
</div>
Expand All @@ -515,12 +554,18 @@ function PluginCard({
mode="update"
pluginName={plugin.name}
capabilities={plugin.capabilities}
newCapabilities={[]} // WS3 will populate this from the diff
newCapabilities={registryEscalation?.capabilityChanges.added ?? []}
newlyPublicRoutes={registryEscalation?.routeVisibilityChanges?.newlyPublic ?? []}
isPending={updateMutation.isPending}
error={getMutationError(updateMutation.error)}
onConfirm={() => updateMutation.mutate()}
error={
updateMutation.error instanceof RegistryUpdateEscalationError
? null
: getMutationError(updateMutation.error)
}
onConfirm={handleUpdateConfirm}
onCancel={() => {
setShowUpdateConsent(false);
setRegistryEscalation(null);
updateMutation.reset();
}}
/>
Expand Down
139 changes: 139 additions & 0 deletions packages/admin/src/lib/api/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,3 +440,142 @@ export async function installRegistryPlugin(
const json = (await response.json()) as { data: RegistryInstallResult };
return json.data;
}

// ---------------------------------------------------------------------------
// Lifecycle: update + uninstall
// ---------------------------------------------------------------------------

export interface RegistryUpdateOpts {
version?: string;
confirmCapabilityChanges?: boolean;
confirmRouteVisibilityChanges?: boolean;
}

export interface RegistryUninstallOpts {
deleteData?: boolean;
}

/**
* Server-side escalation gate raised by the update endpoint when the
* target version widens the trust contract. Carries the diff the user
* needs to see in the consent dialog before the call is retried with the
* matching `confirm*` flag.
*/
export class RegistryUpdateEscalationError extends Error {
readonly code: "CAPABILITY_ESCALATION" | "ROUTE_VISIBILITY_ESCALATION";
readonly capabilityChanges: { added: string[]; removed: string[] };
readonly routeVisibilityChanges?: { newlyPublic: string[] };
constructor(
code: "CAPABILITY_ESCALATION" | "ROUTE_VISIBILITY_ESCALATION",
message: string,
capabilityChanges: { added: string[]; removed: string[] },
routeVisibilityChanges?: { newlyPublic: string[] },
) {
super(message);
this.name = "RegistryUpdateEscalationError";
this.code = code;
this.capabilityChanges = capabilityChanges;
this.routeVisibilityChanges = routeVisibilityChanges;
}
}

/**
* Update a registry-source plugin to a newer version.
* `POST /_emdash/api/admin/plugins/registry/:id/update`
*
* Called without `confirm*` flags first, this throws
* `RegistryUpdateEscalationError` when the target version widens
* permissions; the caller renders a consent dialog populated from the
* error's diff, then re-calls with the matching `confirm*` flag once
* the user agrees.
*/
export async function updateRegistryPlugin(
pluginId: string,
opts: RegistryUpdateOpts = {},
): Promise<void> {
const response = await apiFetch(
`${API_BASE}/admin/plugins/registry/${encodeURIComponent(pluginId)}/update`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
},
);
if (response.ok) return;

const body: unknown = await response
.clone()
.json()
.catch(() => undefined);
const escalation = parseEscalation(body);
if (escalation) throw escalation;
await throwResponseError(response, i18n._(msg`Failed to update plugin`));
}

function parseEscalation(body: unknown): RegistryUpdateEscalationError | 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 !== "CAPABILITY_ESCALATION" && code !== "ROUTE_VISIBILITY_ESCALATION") return null;
const details =
"details" in error && error.details && typeof error.details === "object" ? error.details : {};
const capabilityChanges = normaliseCapabilityChanges(
"capabilityChanges" in details ? details.capabilityChanges : undefined,
);
const routeVisibilityChanges = normaliseRouteVisibilityChanges(
"routeVisibilityChanges" in details ? details.routeVisibilityChanges : undefined,
);
const message =
"message" in error && typeof error.message === "string"
? error.message
: i18n._(msg`Plugin update requires re-consent`);
return new RegistryUpdateEscalationError(
code,
message,
capabilityChanges,
routeVisibilityChanges,
);
}

function normaliseCapabilityChanges(value: unknown): { added: string[]; removed: string[] } {
if (!value || typeof value !== "object") return { added: [], removed: [] };
const v = value as { added?: unknown; removed?: unknown };
return {
added: Array.isArray(v.added) ? v.added.filter((s): s is string => typeof s === "string") : [],
removed: Array.isArray(v.removed)
? v.removed.filter((s): s is string => typeof s === "string")
: [],
};
}

function normaliseRouteVisibilityChanges(value: unknown): { newlyPublic: string[] } | undefined {
if (!value || typeof value !== "object") return undefined;
const v = value as { newlyPublic?: unknown };
if (!Array.isArray(v.newlyPublic)) return undefined;
const newlyPublic = v.newlyPublic.filter((s): s is string => typeof s === "string");
return newlyPublic.length > 0 ? { newlyPublic } : undefined;
}

/**
* Uninstall a registry-source plugin.
* `POST /_emdash/api/admin/plugins/registry/:id/uninstall`
*
* The server refuses to uninstall non-registry sources, so calling this
* with a marketplace or config plugin id is a no-op error rather than a
* destructive cross-source action.
*/
export async function uninstallRegistryPlugin(
pluginId: string,
opts: RegistryUninstallOpts = {},
): Promise<void> {
const response = await apiFetch(
`${API_BASE}/admin/plugins/registry/${encodeURIComponent(pluginId)}/uninstall`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
},
);
if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to uninstall plugin`));
}
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@
"sax": "^1.4.1",
"ulidx": "^2.4.1",
"upng-js": "^2.1.0",
"zod": "catalog:"
"zod": "catalog:",
"@atcute/client": "catalog:"
},
"optionalDependencies": {
"@libsql/kysely-libsql": "^0.4.0",
Expand Down
Loading
Loading