Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
82 changes: 63 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,43 @@ 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) {
updateMutation.mutate({
confirmCapabilityChanges: true,
confirmRouteVisibilityChanges: true,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BUG: dialog consents to route-visibility escalation the user was never shown

Category: Security / UX
Severity: HIGH

handleUpdateConfirm sets confirmCapabilityChanges: true and confirmRouteVisibilityChanges: true in the same call, regardless of which escalation the user actually saw. Combined with the server's gate ordering, this means a plugin update with both new capabilities and new public routes silently slips the public-route diff past the user.

Walk the flow for a plugin where v2 adds a new capability and a new public route:

  1. handleUpdateClick -> mutate({}). No confirm flags.
  2. handleRegistryUpdate (registry.ts:1424-1435) checks capability escalation first. It returns CAPABILITY_ESCALATION with details: { capabilityChanges } -- only the capability diff, because the early return at 1434 happens before diffRouteVisibility is even called at 1437.
  3. Client's parseEscalation extracts capabilityChanges, routeVisibilityChanges is undefined.
  4. Dialog renders with newCapabilities = capabilityChanges.added and newlyPublicRoutes = []. User sees only the capability change.
  5. User clicks Confirm -> handleUpdateConfirm -> mutate({ confirmCapabilityChanges: true, confirmRouteVisibilityChanges: true }).
  6. Server: capability check now passes (confirmed); route check at 1439 also passes (also confirmed). Update proceeds. The user has consented to a new public, unauthenticated route they never saw.

The commit message claims iterative escalation re-opens the dialog with the new diff, but iteration never actually happens because the second confirm flag is pre-set on the first user confirmation. This is the same architectural bypass the PR description called out as inherited-from-marketplace and out of scope -- registry was supposed to no longer inherit it, and the body of the fix-commit message claims as much. It still does.

Trigger: any registry update where the new manifest both declares a new capability and flips a route to public: true. The route-exposure diff is what ROUTE_VISIBILITY_ESCALATION exists to surface; this code path silently auto-confirms it.

Fix: either (a) handleUpdateConfirm sets only the flag for the escalation the dialog actually displayed -- e.g. confirmCapabilityChanges: true when registryEscalation?.code === 'CAPABILITY_ESCALATION' -- and relies on the next server roundtrip to surface the route diff, letting onError re-open the dialog (this matches the "iterative escalations re-open the dialog" promise in the commit message); or (b) the server's CAPABILITY_ESCALATION branch at registry.ts:1432 eagerly computes diffRouteVisibility and includes it in details so the first dialog can show both at once. Option (a) is the smaller change and is what the existing onError plumbing already supports.

});
} 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 +406,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 +529,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 +543,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 +553,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