Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 14 additions & 0 deletions .changeset/registry-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@emdash-cms/admin": minor
"emdash": minor
---

Adds the registry plugin lifecycle: uninstall, update (with capability + public-route re-consent), and update check. Closes #1036.

- **`POST /_emdash/api/admin/plugins/registry/:id/uninstall`** removes the R2 bundle, optionally drops `_plugin_storage` rows (`deleteData: true`), and deletes the state row. Refuses non-registry sources so a marketplace plugin sharing the id namespace can't be trashed.
- **`POST /_emdash/api/admin/plugins/registry/:id/update`** re-runs the install pipeline at a newer version. Mirrors the marketplace gates: `CAPABILITY_ESCALATION` when the new version declares new capabilities and the admin has not consented, and `ROUTE_VISIBILITY_ESCALATION` when it newly exposes a public (unauthenticated) route.
- **`GET /_emdash/api/admin/plugins/updates`** is now cross-source: marketplace + registry update-check results are returned in one merged list. Either source's failure is isolated so an aggregator outage does not blank marketplace updates and vice versa.
- The admin plugin manager now renders the uninstall + update buttons for registry-source plugins (the "uninstall not yet available" placeholder is removed).
- The install handler now classifies aggregator-response errors with dedicated codes (`AGGREGATOR_RESPONSE_INVALID` for non-conforming envelopes, `AGGREGATOR_HTTP_ERROR` for non-2xx) instead of folding them into the generic `INSTALL_FAILED`.

Also backfills test coverage deferred from PR #1011: `makeRegistryPluginId` collision resistance + determinism, `verifyChecksum` hex + multibase + algorithm-mismatch paths, plus the new lifecycle handlers' error-path tests.
27 changes: 14 additions & 13 deletions packages/admin/src/components/PluginManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
uninstallMarketplacePlugin,
type PluginUpdateInfo,
} from "../lib/api/marketplace.js";
import { uninstallRegistryPlugin, updateRegistryPlugin } from "../lib/api/registry.js";
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: "Check for updates" button is gated on hasMarketplacePlugins only

Category: Logic Errors
Severity: MEDIUM

Referencing unchanged code at line 122 (hasMarketplacePlugins = plugins?.some((p) => p.source === "marketplace")) and line 147 ({hasMarketplacePlugins && <Button ... onClick={refetchUpdates}>{t\Check for updates`}}`).

This PR teaches /plugins/updates to return registry plugins, but the UI button that triggers it is still rendered only when at least one marketplace plugin is installed. A site whose only plugins are registry-source -- exactly the scenario the registry rollout targets -- has no way to trigger the update check from the Plugins page.

Trigger: install a registry plugin, no marketplace plugins. Open Plugins page. "Check for updates" is never shown, so registry update availability never gets surfaced.

Fix: update the gate alongside this PR -- plugins?.some((p) => p.source === "marketplace" || p.source === "registry"), or rename the derived flag to hasUpdatableSources.

import { safeIconUrl } from "../lib/url.js";
import { cn } from "../lib/utils";
import { CaretNext } from "./ArrowIcons.js";
Expand Down Expand Up @@ -233,7 +234,13 @@ function PluginCard({
const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;

const updateMutation = useMutation({
mutationFn: () => updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
mutationFn: () =>
isRegistry
? updateRegistryPlugin(plugin.id, {
confirmCapabilityChanges: true,
confirmRouteVisibilityChanges: true,
})
: updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
Comment on lines 245 to +249
onSuccess: () => {
setShowUpdateConsent(false);
void queryClient.invalidateQueries({ queryKey: ["plugins"] });
Expand All @@ -247,7 +254,10 @@ function PluginCard({
});

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 @@ -482,8 +492,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 +506,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 Down
64 changes: 64 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,67 @@ export async function installRegistryPlugin(
const json = (await response.json()) as { data: RegistryInstallResult };
return json.data;
}

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

export interface RegistryUpdateOpts {
/** Optional explicit target version; defaults to the aggregator's latest. */
version?: string;
/** Set when the user has consented to widened capabilities. */
confirmCapabilityChanges?: boolean;
/** Set when the user has consented to newly-public routes. */
confirmRouteVisibilityChanges?: boolean;
}

export interface RegistryUninstallOpts {
/** Also drop the plugin's `_plugin_storage` rows. */
deleteData?: boolean;
}

/**
* Update a registry-source plugin to a newer version.
* `POST /_emdash/api/admin/plugins/registry/:id/update`
*
* Server returns `CAPABILITY_ESCALATION` / `ROUTE_VISIBILITY_ESCALATION`
* carrying a diff when the new version widens permissions; re-call with
* the corresponding `confirm*` flag after the user has consented.
*/
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) await throwResponseError(response, i18n._(msg`Failed to update plugin`));
}

/**
* 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
6 changes: 6 additions & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ export {
// Registry handlers (experimental)
export {
handleRegistryInstall,
handleRegistryUninstall,
handleRegistryUpdate,
handleRegistryUpdateCheck,
type RegistryInstallInput,
type RegistryInstallResult,
type RegistryUninstallResult,
type RegistryUpdateCheck,
type RegistryUpdateResult,
} from "./registry.js";
4 changes: 2 additions & 2 deletions packages/core/src/api/handlers/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function getClient(
return createMarketplaceClient(marketplaceUrl, siteOrigin);
}

function diffCapabilities(
export function diffCapabilities(
oldCaps: string[],
newCaps: string[],
): { added: string[]; removed: string[] } {
Expand All @@ -114,7 +114,7 @@ function diffCapabilities(
* Diff route visibility between two manifests.
* Returns routes that changed from private to public (newly exposed).
*/
function diffRouteVisibility(
export function diffRouteVisibility(
oldManifest: PluginManifest | undefined,
newManifest: PluginManifest,
): { newlyPublic: string[] } {
Expand Down
Loading
Loading