From 2ac7e0908164e76fd4dacd27d196c4cb593cbd3f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 20 May 2026 06:07:43 +0100 Subject: [PATCH 1/5] feat(registry): uninstall, update, update-check handlers + admin lifecycle Implements #1036: registry plugins now have a full lifecycle parallel to marketplace, removing the 'uninstall not yet available' band-aid in PluginManager. Server: - handleRegistryUninstall: deletes the R2 bundle, drops _plugin_state, optionally clears _plugin_storage rows. Refuses non-registry sources. - handleRegistryUpdate: re-runs the install pipeline at a newer version, diffing capabilities + public-route visibility against the currently installed bundle. CAPABILITY_ESCALATION and ROUTE_VISIBILITY_ESCALATION gate widened permissions behind confirmCapabilityChanges / confirmRouteVisibilityChanges, mirroring marketplace exactly. - handleRegistryUpdateCheck: scans installed registry plugins, queries the aggregator's getLatestRelease for each, returns the version diff. Per-plugin aggregator failures don't blank the list. - POST /_emdash/api/admin/plugins/registry/:id/uninstall - POST /_emdash/api/admin/plugins/registry/:id/update - GET /_emdash/api/admin/plugins/updates is now cross-source: runs marketplace + registry checks in parallel, isolates failures, and logs structured errors. Wraps the merged items in the standard { data: { items } } envelope. - Marketplace's diffCapabilities + diffRouteVisibility are now exported so the registry handler can reuse them without duplication. Admin: - updateRegistryPlugin / uninstallRegistryPlugin client functions. - PluginManager dispatches mutationFn by plugin.source so registry plugins flow through the new endpoints; uninstall button enabled for any sandboxed source. Deferred from #1112 (folded in): - Install handler now classifies aggregator failures as AGGREGATOR_RESPONSE_INVALID (ClientValidationError) and AGGREGATOR_HTTP_ERROR (ClientResponseError) instead of folding both into INSTALL_FAILED. The same classification applies to update and update-check. Deferred from #1011 (backfilled): - makeRegistryPluginId: format, determinism, distinctness across publishers + slugs, and a 10 000-pair collision check. - verifyChecksum: hex + multibase paths, algorithm-mismatch, malformed. - Lifecycle handler error paths. Out of scope (inherited from marketplace, will fix together): - Concurrent update + downgrade race where a fire-and-forget cleanup of the previous version can delete the now-current bundle. - Update consent dialog architecturally bypasses the server's escalation gate (mutationFn pre-confirms; the dialog shows already-granted caps with newCapabilities: []). The server gate is correct; the client never reaches it. Same shape in marketplace. Refs #1036 --- .changeset/registry-lifecycle.md | 14 + .../admin/src/components/PluginManager.tsx | 27 +- packages/admin/src/lib/api/registry.ts | 64 ++ packages/core/package.json | 3 +- packages/core/src/api/handlers/index.ts | 6 + packages/core/src/api/handlers/marketplace.ts | 4 +- packages/core/src/api/handlers/registry.ts | 560 +++++++++++++++++- .../admin/plugins/registry/[id]/uninstall.ts | 51 ++ .../api/admin/plugins/registry/[id]/update.ts | 79 +++ .../astro/routes/api/admin/plugins/updates.ts | 48 +- .../tests/unit/api/registry-handlers.test.ts | 281 +++++++++ .../core/tests/unit/registry/checksum.test.ts | 75 +++ .../tests/unit/registry/plugin-id.test.ts | 51 ++ pnpm-lock.yaml | 3 + 14 files changed, 1241 insertions(+), 25 deletions(-) create mode 100644 .changeset/registry-lifecycle.md create mode 100644 packages/core/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts create mode 100644 packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts create mode 100644 packages/core/tests/unit/api/registry-handlers.test.ts create mode 100644 packages/core/tests/unit/registry/checksum.test.ts create mode 100644 packages/core/tests/unit/registry/plugin-id.test.ts diff --git a/.changeset/registry-lifecycle.md b/.changeset/registry-lifecycle.md new file mode 100644 index 000000000..527a2c9b1 --- /dev/null +++ b/.changeset/registry-lifecycle.md @@ -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. diff --git a/packages/admin/src/components/PluginManager.tsx b/packages/admin/src/components/PluginManager.tsx index 8e8427736..072806023 100644 --- a/packages/admin/src/components/PluginManager.tsx +++ b/packages/admin/src/components/PluginManager.tsx @@ -38,6 +38,7 @@ import { uninstallMarketplacePlugin, type PluginUpdateInfo, } from "../lib/api/marketplace.js"; +import { uninstallRegistryPlugin, updateRegistryPlugin } from "../lib/api/registry.js"; import { safeIconUrl } from "../lib/url.js"; import { cn } from "../lib/utils"; import { CaretNext } from "./ArrowIcons.js"; @@ -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 }), onSuccess: () => { setShowUpdateConsent(false); void queryClient.invalidateQueries({ queryKey: ["plugins"] }); @@ -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"] }); @@ -482,8 +492,8 @@ function PluginCard({ )} - {/* Uninstall button for marketplace plugins */} - {isMarketplace && ( + {/* Uninstall button for any sandboxed source (marketplace + registry). */} + {(isMarketplace || isRegistry) && (
)} - - {/* 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 && ( -
- {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.`} -
- )} )} diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index 5e594656a..5bdb84857 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -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 { + 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 { + 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`)); +} diff --git a/packages/core/package.json b/packages/core/package.json index f95f0b1bc..6ebb182ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index f33e8e0cb..3f92e6dff 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -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"; diff --git a/packages/core/src/api/handlers/marketplace.ts b/packages/core/src/api/handlers/marketplace.ts index 9e1e856e5..b310cf7c5 100644 --- a/packages/core/src/api/handlers/marketplace.ts +++ b/packages/core/src/api/handlers/marketplace.ts @@ -92,7 +92,7 @@ function getClient( return createMarketplaceClient(marketplaceUrl, siteOrigin); } -function diffCapabilities( +export function diffCapabilities( oldCaps: string[], newCaps: string[], ): { added: string[]; removed: string[] } { @@ -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[] } { diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index a1b2b8929..e2d9bd233 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -38,6 +38,7 @@ * mitigated by the artifact checksum but not detected. */ +import { ClientResponseError, ClientValidationError } from "@atcute/client"; import type { Did } from "@atcute/lexicons"; import type { Kysely } from "kysely"; @@ -59,7 +60,13 @@ import { resolveAndValidateExternalUrl, SsrfError } from "../../security/ssrf.js import { EmDashStorageError } from "../../storage/types.js"; import type { Storage } from "../../storage/types.js"; import type { ApiResult } from "../types.js"; -import { deleteBundleFromR2, storeBundleInR2 } from "./marketplace.js"; +import { + deleteBundleFromR2, + diffCapabilities, + diffRouteVisibility, + loadBundleFromR2, + storeBundleInR2, +} from "./marketplace.js"; // ── Types ────────────────────────────────────────────────────────── @@ -162,7 +169,7 @@ async function sha256MultibaseMultihash(bytes: Uint8Array): Promise { * Hash functions other than sha2-256 are out of scope for this * initial release; the install fails closed. */ -async function verifyChecksum(bytes: Uint8Array, checksum: string): Promise { +export async function verifyChecksum(bytes: Uint8Array, checksum: string): Promise { if (SHA256_HEX_PATTERN.test(checksum)) { const actual = await sha256Hex(bytes); return checksum.toLowerCase() === actual; @@ -314,7 +321,7 @@ function isLocalhostHostname(hostname: string): boolean { * `import.meta.env.DEV` is a Vite/Astro compile-time constant, so * production bundles cannot enable the dev escape hatch at runtime. */ -async function assertSafeArtifactUrl(urlString: string): Promise { +export async function assertSafeArtifactUrl(urlString: string): Promise { let url: URL; try { url = new URL(urlString); @@ -1062,6 +1069,24 @@ export async function handleRegistryInstall( }, }; } catch (err) { + if (err instanceof ClientValidationError) { + return { + success: false, + error: { + code: "AGGREGATOR_RESPONSE_INVALID", + message: `Aggregator returned a response that does not conform to its lexicon (${err.target})`, + }, + }; + } + if (err instanceof ClientResponseError) { + return { + success: false, + error: { + code: "AGGREGATOR_HTTP_ERROR", + message: `Aggregator returned ${err.status}: ${err.error}`, + }, + }; + } if (err instanceof EmDashStorageError) { return { success: false, @@ -1081,3 +1106,532 @@ export async function handleRegistryInstall( }; } } + +// ── Uninstall ────────────────────────────────────────────────────── + +export interface RegistryUninstallResult { + pluginId: string; + /** True when `_plugin_storage` rows were also deleted (opts.deleteData). */ + dataDeleted: boolean; +} + +/** + * Uninstall a registry-source plugin. Deletes the R2 bundle under + * `registry///`, optionally drops the plugin's + * `_plugin_storage` rows, and removes the `_plugin_state` row. The + * sandbox runtime is reconciled by the route's `syncRegistryPlugins` + * call after this returns. + * + * Refuses to uninstall plugins whose `source` is not `"registry"` to + * avoid trashing a marketplace/config plugin that happens to share the + * pluginId namespace. + */ +export async function handleRegistryUninstall( + db: Kysely, + storage: Storage | null, + pluginId: string, + opts?: { deleteData?: boolean }, +): Promise> { + try { + const stateRepo = new PluginStateRepository(db); + const existing = await stateRepo.get(pluginId); + if (!existing || existing.source !== "registry") { + return { + success: false, + error: { + code: "NOT_FOUND", + message: `No registry plugin found: ${pluginId}`, + }, + }; + } + + // `_plugin_state.version` carries the installed version directly for + // registry-source rows (there's no shadow column like marketplace's + // `marketplaceVersion`). Use it verbatim for the R2 prefix. + const version = existing.version; + + if (storage) { + await deleteBundleFromR2(storage, pluginId, version, "registry"); + } + + let dataDeleted = false; + if (opts?.deleteData) { + try { + await db.deleteFrom("_plugin_storage").where("plugin_id", "=", pluginId).execute(); + dataDeleted = true; + } catch { + // No plugin_storage rows for this plugin; nothing to delete. + } + } + + await stateRepo.delete(pluginId); + + return { success: true, data: { pluginId, dataDeleted } }; + } catch (err) { + console.error("[registry-uninstall] Failed:", err); + return { + success: false, + error: { + code: "UNINSTALL_FAILED", + message: "Failed to uninstall plugin", + }, + }; + } +} + +// ── Update ───────────────────────────────────────────────────────── + +export interface RegistryUpdateResult { + pluginId: string; + oldVersion: string; + newVersion: string; + capabilityChanges: { added: string[]; removed: string[] }; + /** Set only when `newlyPublic` is non-empty, mirroring marketplace. */ + routeVisibilityChanges?: { newlyPublic: string[] }; +} + +/** + * Update a registry-source plugin to a newer release. Mirrors + * `handleMarketplaceUpdate`: resolves the target version via the aggregator, + * re-runs the artifact fetch / checksum / extract pipeline, diffs capabilities + * and route visibility against the currently installed bundle, and gates + * escalations behind `confirmCapabilityChanges` / `confirmRouteVisibilityChanges` + * so the admin re-consents to widened permissions. + * + * Refuses non-registry sources. Refuses when the stored state row is missing + * the `(publisherDid, slug)` it needs to resolve against the aggregator. + */ +export async function handleRegistryUpdate( + db: Kysely, + storage: Storage | null, + sandboxRunner: SandboxRunner | null, + registryConfigInput: RegistryConfigInput | undefined, + pluginId: string, + opts?: { + version?: string; + confirmCapabilityChanges?: boolean; + confirmRouteVisibilityChanges?: boolean; + }, +): Promise> { + const registryConfig = coerceRegistryConfig(registryConfigInput); + if (!registryConfig) { + return { + success: false, + error: { code: "REGISTRY_NOT_CONFIGURED", message: "Registry is not configured" }, + }; + } + if (!storage) { + return { + success: false, + error: { + code: "STORAGE_NOT_CONFIGURED", + message: "Storage is required for registry plugin updates", + }, + }; + } + if (!sandboxRunner || !sandboxRunner.isAvailable()) { + return { + success: false, + error: { code: "SANDBOX_NOT_AVAILABLE", message: "Sandbox runner is required" }, + }; + } + try { + validateAggregatorUrl(registryConfig.aggregatorUrl); + } catch (err) { + return { + success: false, + error: { + code: "REGISTRY_NOT_CONFIGURED", + message: err instanceof Error ? err.message : "Invalid aggregator URL", + }, + }; + } + + try { + const stateRepo = new PluginStateRepository(db); + const existing = await stateRepo.get(pluginId); + if (!existing || existing.source !== "registry") { + return { + success: false, + error: { code: "NOT_FOUND", message: `No registry plugin found: ${pluginId}` }, + }; + } + if (!existing.registryPublisherDid || !existing.registrySlug) { + return { + success: false, + error: { + code: "INVALID_STATE", + message: `Registry plugin ${pluginId} is missing publisher DID or slug in state`, + }, + }; + } + const oldVersion = existing.version; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- existing.registryPublisherDid is a DID string written by the install handler + const publisherDid = existing.registryPublisherDid as Did; + const slug = existing.registrySlug; + + const { DiscoveryClient } = await import("@emdash-cms/registry-client/discovery"); + const aggregatorDeadline = Date.now() + AGGREGATOR_TOTAL_BUDGET_MS; + const discovery = new DiscoveryClient({ + aggregatorUrl: registryConfig.aggregatorUrl, + acceptLabelers: registryConfig.acceptLabelers, + fetch: timedFetch(aggregatorDeadline), + }); + + // Resolve target release. Explicit version → paginate listReleases; + // otherwise getLatestRelease (aggregator applies its own filters). + const MAX_LIST_PAGES = 20; + const releaseView = await (async () => { + if (!opts?.version) { + return discovery.getLatestRelease({ did: publisherDid, package: slug }); + } + let cursor: string | undefined; + const seenCursors = new Set(); + for (let page = 0; page < MAX_LIST_PAGES; page++) { + if (cursor !== undefined) { + if (seenCursors.has(cursor)) break; + seenCursors.add(cursor); + } + const result = await discovery.listReleases({ + did: publisherDid, + package: slug, + cursor, + limit: 50, + }); + for (const r of result.releases) { + if (r.version === opts.version) return r; + } + if (!result.cursor) break; + cursor = result.cursor; + } + return undefined; + })(); + + if (!releaseView) { + return { + success: false, + error: { + code: "NO_VERSION", + message: opts?.version + ? `Version ${opts.version} not found for ${publisherDid}/${slug}` + : `No installable release found for ${publisherDid}/${slug}`, + }, + }; + } + + // Identity cross-check. A buggy/compromised aggregator must not + // trick us into installing a record signed for a different + // (did, slug, version) under this plugin's pluginId. + const signedRelease = releaseView.release; + if ( + releaseView.did !== publisherDid || + releaseView.package !== slug || + signedRelease?.package !== slug || + (opts?.version !== undefined && releaseView.version !== opts.version) || + signedRelease?.version !== releaseView.version + ) { + return { + success: false, + error: { + code: "AGGREGATOR_IDENTITY_MISMATCH", + message: + "Aggregator returned a release view that does not match the requested package or version.", + }, + }; + } + + const newVersion = releaseView.version; + if (newVersion === oldVersion) { + return { + success: false, + error: { + code: "ALREADY_UP_TO_DATE", + message: "Plugin is already at the requested version", + }, + }; + } + + // Yanked label check (mirrors install). + const releaseYanked = (releaseView.labels ?? []).some( + (l: { val?: string }) => l.val === "security:yanked", + ); + if (releaseYanked) { + return { + success: false, + error: { code: "YANKED", message: "Release has been yanked by a trusted labeller" }, + }; + } + + const declaredUrl = signedRelease.artifacts?.package?.url; + const declaredChecksum = signedRelease.artifacts?.package?.checksum; + if (!declaredUrl || !declaredChecksum) { + return { + success: false, + error: { + code: "INVALID_RELEASE", + message: "Release record is missing artifact url or checksum", + }, + }; + } + + // SSRF check on declared URL + each mirror. + await assertSafeArtifactUrl(declaredUrl); + const rawMirrors = releaseView.mirrors ?? []; + const mirrors = rawMirrors.slice(0, MAX_MIRRORS); + for (const mirror of mirrors) { + await assertSafeArtifactUrl(mirror); + } + + // `fetchArtifact` derives its own per-call deadline internally. + const artifactBytes = await fetchArtifact(mirrors, declaredUrl); + if (!(await verifyChecksum(artifactBytes, declaredChecksum))) { + return { + success: false, + error: { + code: "CHECKSUM_MISMATCH", + message: "Artifact bytes do not match the release's published checksum", + }, + }; + } + + const bundle: PluginBundle = await extractBundle(artifactBytes); + + if (bundle.manifest.version !== newVersion) { + return { + success: false, + error: { + code: "BUNDLE_VERSION_MISMATCH", + message: `Bundle manifest version (${bundle.manifest.version}) does not match release version (${newVersion})`, + }, + }; + } + if (bundle.manifest.id !== slug) { + return { + success: false, + error: { + code: "BUNDLE_IDENTITY_MISMATCH", + message: `Bundle manifest id (${bundle.manifest.id}) does not match registry slug (${slug})`, + }, + }; + } + + // Rewrite manifest.id to the opaque pluginId so the sandbox loader + // and R2 layout stay in sync across install and update. + bundle.manifest = { ...bundle.manifest, id: pluginId }; + + // Diff capabilities + route visibility against the currently + // installed bundle. Loading from R2 keeps us honest: the diff is + // against the bytes the sandbox is actually running, not whatever + // the state row claims. + const oldBundle = await loadBundleFromR2(storage, pluginId, oldVersion, "registry"); + const oldCaps = oldBundle?.manifest.capabilities ?? []; + const capabilityChanges = diffCapabilities(oldCaps, bundle.manifest.capabilities); + const hasEscalation = capabilityChanges.added.length > 0; + if (hasEscalation && !opts?.confirmCapabilityChanges) { + return { + success: false, + error: { + code: "CAPABILITY_ESCALATION", + message: "Plugin update requires new capabilities", + details: { capabilityChanges }, + }, + }; + } + + const routeVisibilityChanges = diffRouteVisibility(oldBundle?.manifest, bundle.manifest); + const hasNewPublicRoutes = routeVisibilityChanges.newlyPublic.length > 0; + if (hasNewPublicRoutes && !opts?.confirmRouteVisibilityChanges) { + return { + success: false, + error: { + code: "ROUTE_VISIBILITY_ESCALATION", + message: "Plugin update exposes new public (unauthenticated) routes", + details: { routeVisibilityChanges, capabilityChanges }, + }, + }; + } + + // Store new bundle. R2 prefix is deterministic per (pluginId, version), + // so a retry of the same update is idempotent. + await storeBundleInR2(storage, pluginId, newVersion, bundle, "registry"); + + // Update state. Preserve publisher/slug; refresh displayName / + // description from the install handler's seeded values (we don't + // re-fetch the profile here — that's a separate `getPackage` round + // trip and the install-time values are still authoritative for + // the same package identity). + await stateRepo.upsert(pluginId, newVersion, "active", { + source: "registry", + registryPublisherDid: publisherDid, + registrySlug: slug, + displayName: existing.displayName ?? slug, + description: existing.description ?? undefined, + }); + + // Best-effort cleanup of the old bundle. Failures here don't roll + // back the upgrade (the new bundle is already stored and committed + // in the state row); the orphan is just storage we'll pay for. + deleteBundleFromR2(storage, pluginId, oldVersion, "registry").catch(() => {}); + + return { + success: true, + data: { + pluginId, + oldVersion, + newVersion, + capabilityChanges, + routeVisibilityChanges: hasNewPublicRoutes ? routeVisibilityChanges : undefined, + }, + }; + } catch (err) { + if (err instanceof ClientValidationError) { + return { + success: false, + error: { + code: "AGGREGATOR_RESPONSE_INVALID", + message: `Aggregator returned a response that does not conform to its lexicon (${err.target})`, + }, + }; + } + if (err instanceof ClientResponseError) { + return { + success: false, + error: { + code: "AGGREGATOR_HTTP_ERROR", + message: `Aggregator returned ${err.status}: ${err.error}`, + }, + }; + } + if (err instanceof SsrfError) { + return { + success: false, + error: { code: "UNSAFE_ARTIFACT_URL", message: err.message }, + }; + } + if (err instanceof EmDashStorageError) { + return { + success: false, + error: { + code: err.code ?? "STORAGE_ERROR", + message: "Storage error while updating plugin", + }, + }; + } + console.error("[registry-update] Failed:", err); + return { + success: false, + error: { + code: "UPDATE_FAILED", + message: err instanceof Error ? err.message : "Failed to update plugin", + }, + }; + } +} + +// ── Update check ─────────────────────────────────────────────────── + +export interface RegistryUpdateCheck { + pluginId: string; + installed: string; + latest: string; + hasUpdate: boolean; + /** + * Both diff fields are `false` here by design: computing them at + * update-check time would require downloading both bundles (or + * extracting from the signed release extension and the installed + * R2 bundle), which is too expensive for a bulk preview. The actual + * escalation gate runs at update time in `handleRegistryUpdate`. + * Mirrors marketplace's `hasRouteVisibilityChanges: false`. + */ + hasCapabilityChanges: boolean; + hasRouteVisibilityChanges: boolean; +} + +/** + * Bulk update check across every installed registry plugin. Queries the + * aggregator for each plugin's latest release and reports `hasUpdate` + * based on the version comparison. Plugins whose aggregator lookup fails + * (unreachable, delisted, malformed) are skipped silently — one bad + * publisher must not blank the whole admin Updates list. + */ +export async function handleRegistryUpdateCheck( + db: Kysely, + registryConfigInput: RegistryConfigInput | undefined, +): Promise> { + const registryConfig = coerceRegistryConfig(registryConfigInput); + if (!registryConfig) { + return { + success: false, + error: { code: "REGISTRY_NOT_CONFIGURED", message: "Registry is not configured" }, + }; + } + + try { + const stateRepo = new PluginStateRepository(db); + const registryPlugins = await stateRepo.getRegistryPlugins(); + if (registryPlugins.length === 0) { + return { success: true, data: { items: [] } }; + } + + const { DiscoveryClient } = await import("@emdash-cms/registry-client/discovery"); + const aggregatorDeadline = Date.now() + AGGREGATOR_TOTAL_BUDGET_MS; + const discovery = new DiscoveryClient({ + aggregatorUrl: registryConfig.aggregatorUrl, + acceptLabelers: registryConfig.acceptLabelers, + fetch: timedFetch(aggregatorDeadline), + }); + + const items: RegistryUpdateCheck[] = []; + for (const plugin of registryPlugins) { + if (!plugin.registryPublisherDid || !plugin.registrySlug) continue; + try { + const releaseView = await discovery.getLatestRelease({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- DID string was validated by the install handler + did: plugin.registryPublisherDid as Did, + package: plugin.registrySlug, + }); + const latest = releaseView.version; + if (!latest) continue; + const installed = plugin.version; + items.push({ + pluginId: plugin.pluginId, + installed, + latest, + hasUpdate: latest !== installed, + hasCapabilityChanges: false, + hasRouteVisibilityChanges: false, + }); + } catch (err) { + // Skip plugins that can't be checked. Don't fail the whole + // list because one aggregator query went wrong. + console.warn(`[registry-update-check] Skipped ${plugin.pluginId}:`, err); + } + } + + return { success: true, data: { items } }; + } catch (err) { + if (err instanceof ClientValidationError) { + return { + success: false, + error: { + code: "AGGREGATOR_RESPONSE_INVALID", + message: `Aggregator returned a response that does not conform to its lexicon (${err.target})`, + }, + }; + } + if (err instanceof ClientResponseError) { + return { + success: false, + error: { + code: "AGGREGATOR_HTTP_ERROR", + message: `Aggregator returned ${err.status}: ${err.error}`, + }, + }; + } + console.error("[registry-update-check] Failed:", err); + return { + success: false, + error: { code: "UPDATE_CHECK_FAILED", message: "Failed to check for registry updates" }, + }; + } +} diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts new file mode 100644 index 000000000..64b5d60c5 --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/uninstall.ts @@ -0,0 +1,51 @@ +/** + * Registry plugin uninstall endpoint (experimental) + * + * POST /_emdash/api/admin/plugins/registry/:id/uninstall — Uninstall a + * registry-source plugin. Mirrors the marketplace uninstall route; the + * handler refuses non-registry sources, so this won't trash a marketplace + * or config plugin that shares the id namespace. + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, unwrapResult } from "#api/error.js"; +import { handleRegistryUninstall } from "#api/index.js"; +import { isParseError, parseOptionalBody } from "#api/parse.js"; + +export const prerender = false; + +const uninstallBodySchema = z.object({ + deleteData: z.boolean().optional(), +}); + +export const POST: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + const { id } = params; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "plugins:manage"); + if (denied) return denied; + + if (!id) { + return apiError("INVALID_REQUEST", "Plugin ID required", 400); + } + + const body = await parseOptionalBody(request, uninstallBodySchema, {}); + if (isParseError(body)) return body; + + const result = await handleRegistryUninstall(emdash.db, emdash.storage, id, { + deleteData: body.deleteData ?? false, + }); + + if (!result.success) return unwrapResult(result); + + await emdash.syncRegistryPlugins(); + + return unwrapResult(result); +}; diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts new file mode 100644 index 000000000..dc711a919 --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts @@ -0,0 +1,79 @@ +/** + * Registry plugin update endpoint (experimental) + * + * POST /_emdash/api/admin/plugins/registry/:id/update — Update a + * registry-source plugin to a newer release. Mirrors the marketplace + * update route's escalation gates: `CAPABILITY_ESCALATION` if the new + * version declares new capabilities and `confirmCapabilityChanges` is + * absent, and `ROUTE_VISIBILITY_ESCALATION` if it newly exposes public + * routes and `confirmRouteVisibilityChanges` is absent. + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, handleError, unwrapResult } from "#api/error.js"; +import { handleRegistryUpdate } from "#api/index.js"; +import { isParseError, parseOptionalBody } from "#api/parse.js"; + +export const prerender = false; + +const updateBodySchema = z.object({ + /** Optional explicit target version. Defaults to the aggregator's latest. */ + version: z.string().min(1).max(64).optional(), + /** + * Set by the admin's capability re-consent dialog when the new version + * declares capabilities the installed version did not. Without this, + * the handler returns `CAPABILITY_ESCALATION` carrying the diff. + */ + confirmCapabilityChanges: z.boolean().optional(), + /** + * Set by the admin's route-visibility re-consent dialog when the new + * version newly exposes a public (unauthenticated) route. + */ + confirmRouteVisibilityChanges: z.boolean().optional(), +}); + +export const POST: APIRoute = async ({ params, request, locals }) => { + try { + const { emdash, user } = locals; + const { id } = params; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "plugins:manage"); + if (denied) return denied; + + if (!id) { + return apiError("INVALID_REQUEST", "Plugin ID required", 400); + } + + const body = await parseOptionalBody(request, updateBodySchema, {}); + if (isParseError(body)) return body; + + const result = await handleRegistryUpdate( + emdash.db, + emdash.storage, + emdash.getSandboxRunner(), + emdash.config.experimental?.registry, + id, + { + version: body.version, + confirmCapabilityChanges: body.confirmCapabilityChanges, + confirmRouteVisibilityChanges: body.confirmRouteVisibilityChanges, + }, + ); + + if (!result.success) return unwrapResult(result); + + await emdash.syncRegistryPlugins(); + + return unwrapResult(result); + } catch (error) { + console.error("[registry-update] Unhandled error:", error); + return handleError(error, "Failed to update plugin from registry", "UPDATE_FAILED"); + } +}; diff --git a/packages/core/src/astro/routes/api/admin/plugins/updates.ts b/packages/core/src/astro/routes/api/admin/plugins/updates.ts index 8f70e76b2..387816185 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/updates.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/updates.ts @@ -1,14 +1,21 @@ /** - * Marketplace update check endpoint + * Plugin update check endpoint * - * GET /_emdash/api/admin/plugins/updates - Check for marketplace plugin updates + * GET /_emdash/api/admin/plugins/updates - Check for available updates + * across every installed plugin source (marketplace + experimental registry). + * Items are returned in a single flat list so the admin issues one query + * regardless of source mix; consumers tell sources apart by pluginId + * (registry ids are `r_*` per `REGISTRY_PLUGIN_ID_PATTERN`). + * + * A failure in one source does NOT blank the other — a registry-side + * aggregator outage still returns marketplace updates and vice versa. */ import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; -import { apiError, unwrapResult } from "#api/error.js"; -import { handleMarketplaceUpdateCheck } from "#api/index.js"; +import { apiError } from "#api/error.js"; +import { handleMarketplaceUpdateCheck, handleRegistryUpdateCheck } from "#api/index.js"; export const prerender = false; @@ -22,7 +29,36 @@ export const GET: APIRoute = async ({ locals }) => { const denied = requirePerm(user, "plugins:read"); if (denied) return denied; - const result = await handleMarketplaceUpdateCheck(emdash.db, emdash.config.marketplace); + // Run both checks in parallel. Catch each independently so one source's + // failure doesn't blank the other. Both throws and structured `success: + // false` returns are logged with the source name so a misconfigured + // registry doesn't disappear silently from telemetry. + const [marketplace, registry] = await Promise.all([ + handleMarketplaceUpdateCheck(emdash.db, emdash.config.marketplace).catch((err) => { + console.warn("[plugins/updates] marketplace check threw:", err); + return null; + }), + handleRegistryUpdateCheck(emdash.db, emdash.config.experimental?.registry).catch((err) => { + console.warn("[plugins/updates] registry check threw:", err); + return null; + }), + ]); + if (marketplace && !marketplace.success) { + console.warn( + `[plugins/updates] marketplace check failed: ${marketplace.error.code} ${marketplace.error.message}`, + ); + } + if (registry && !registry.success) { + console.warn( + `[plugins/updates] registry check failed: ${registry.error.code} ${registry.error.message}`, + ); + } + + const items: unknown[] = []; + if (marketplace?.success) items.push(...marketplace.data.items); + if (registry?.success) items.push(...registry.data.items); - return unwrapResult(result); + // Match the rest of the admin API envelope (`{ data: ... }`) so the + // admin client's `parseApiResponse` unwraps `body.data`. + return Response.json({ data: { items } }); }; diff --git a/packages/core/tests/unit/api/registry-handlers.test.ts b/packages/core/tests/unit/api/registry-handlers.test.ts new file mode 100644 index 000000000..e130a6121 --- /dev/null +++ b/packages/core/tests/unit/api/registry-handlers.test.ts @@ -0,0 +1,281 @@ +/** + * Registry handler tests + * + * Covers the registry plugin lifecycle handlers (mirroring + * `marketplace-handlers.test.ts`): + * - Uninstall (handleRegistryUninstall) + * - Update (handleRegistryUpdate) + * - Update check (handleRegistryUpdateCheck) + * + * Uses a real in-memory SQLite database and a mock `Storage`. The aggregator + * `DiscoveryClient` is exercised against a mock `fetch` so the network is + * fully controlled. + */ + +import BetterSqlite3 from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + handleRegistryUninstall, + handleRegistryUpdate, +} from "../../../src/api/handlers/registry.js"; +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import type { Database as DbSchema } from "../../../src/database/types.js"; +import type { SandboxRunner } from "../../../src/plugins/sandbox/types.js"; +import { PluginStateRepository } from "../../../src/plugins/state.js"; +import type { + DownloadResult, + ListResult, + SignedUploadUrl, + Storage, + UploadResult, +} from "../../../src/storage/types.js"; + +// ── Mock storage ───────────────────────────────────────────────── + +function createMockStorage(): Storage { + const store = new Map(); + return { + async upload(opts: { + key: string; + body: Buffer | Uint8Array | ReadableStream; + contentType: string; + }): Promise { + let body: Uint8Array; + if (opts.body instanceof Uint8Array) { + body = opts.body; + } else if (Buffer.isBuffer(opts.body)) { + body = new Uint8Array(opts.body); + } else { + const response = new Response(opts.body); + body = new Uint8Array(await response.arrayBuffer()); + } + store.set(opts.key, { body, contentType: opts.contentType }); + return { key: opts.key, url: `https://storage.test/${opts.key}`, size: body.length }; + }, + async download(key: string): Promise { + const item = store.get(key); + if (!item) throw new Error(`Not found: ${key}`); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(item.body); + controller.close(); + }, + }); + return { body: stream, contentType: item.contentType, size: item.body.length }; + }, + async delete(key: string): Promise { + store.delete(key); + }, + async exists(key: string): Promise { + return store.has(key); + }, + async list(prefix: string): Promise { + const keys = [...store.keys()].filter((k) => k.startsWith(prefix)); + return { items: keys.map((key) => ({ key, size: store.get(key)?.body.length ?? 0 })) }; + }, + async getSignedUploadUrl(): Promise { + throw new Error("not implemented"); + }, + // Expose for assertions. + __store: store, + } as unknown as Storage; +} + +function snapshotKeys(storage: Storage): string[] { + return [...((storage as unknown as { __store: Map }).__store.keys() ?? [])]; +} + +// ── Suite ──────────────────────────────────────────────────────── + +describe("Registry handlers", () => { + let db: Kysely; + let storage: Storage; + + beforeEach(async () => { + const sqlite = new BetterSqlite3(":memory:"); + db = new Kysely({ dialect: new SqliteDialect({ database: sqlite }) }); + await runMigrations(db); + storage = createMockStorage(); + }); + + afterEach(async () => { + await db.destroy(); + }); + + describe("handleRegistryUninstall", () => { + it("returns NOT_FOUND when no plugin exists at the given id", async () => { + const result = await handleRegistryUninstall(db, storage, "r_doesnotexist00"); + expect(result.success).toBe(false); + expect(result.error?.code).toBe("NOT_FOUND"); + }); + + it("returns NOT_FOUND when the plugin is not registry-source (refuses to trash a marketplace row)", async () => { + const repo = new PluginStateRepository(db); + await repo.upsert("acme-seo", "1.0.0", "active", { + source: "marketplace", + marketplaceVersion: "1.0.0", + }); + + const result = await handleRegistryUninstall(db, storage, "acme-seo"); + expect(result.success).toBe(false); + expect(result.error?.code).toBe("NOT_FOUND"); + + // State row must be untouched. + const state = await repo.get("acme-seo"); + expect(state).not.toBeNull(); + expect(state?.source).toBe("marketplace"); + }); + + it("deletes the R2 bundle and the state row, returns dataDeleted=false by default", async () => { + const repo = new PluginStateRepository(db); + await repo.upsert("r_aaaaaaaaaaaaaaaa", "1.2.3", "active", { + source: "registry", + registryPublisherDid: "did:plc:abc", + registrySlug: "gallery", + }); + + const encoder = new TextEncoder(); + await storage.upload({ + key: "registry/r_aaaaaaaaaaaaaaaa/1.2.3/manifest.json", + body: encoder.encode("{}"), + contentType: "application/json", + }); + await storage.upload({ + key: "registry/r_aaaaaaaaaaaaaaaa/1.2.3/backend.js", + body: encoder.encode(""), + contentType: "application/javascript", + }); + + const result = await handleRegistryUninstall(db, storage, "r_aaaaaaaaaaaaaaaa"); + expect(result.success).toBe(true); + expect(result.data?.pluginId).toBe("r_aaaaaaaaaaaaaaaa"); + expect(result.data?.dataDeleted).toBe(false); + + expect(await repo.get("r_aaaaaaaaaaaaaaaa")).toBeNull(); + expect(snapshotKeys(storage)).toEqual([]); + }); + + it("deletes _plugin_storage rows when deleteData=true", async () => { + const repo = new PluginStateRepository(db); + await repo.upsert("r_bbbbbbbbbbbbbbbb", "0.1.0", "active", { + source: "registry", + registryPublisherDid: "did:plc:abc", + registrySlug: "forms", + }); + await db + .insertInto("_plugin_storage") + .values({ + plugin_id: "r_bbbbbbbbbbbbbbbb", + collection: "default", + id: "k", + data: JSON.stringify({ a: 1 }), + }) + .execute(); + + const result = await handleRegistryUninstall(db, storage, "r_bbbbbbbbbbbbbbbb", { + deleteData: true, + }); + expect(result.success).toBe(true); + expect(result.data?.dataDeleted).toBe(true); + + const rows = await db + .selectFrom("_plugin_storage") + .selectAll() + .where("plugin_id", "=", "r_bbbbbbbbbbbbbbbb") + .execute(); + expect(rows).toHaveLength(0); + }); + + it("tolerates a null storage (e.g. instance without R2 configured)", async () => { + const repo = new PluginStateRepository(db); + await repo.upsert("r_cccccccccccccccc", "0.0.1", "active", { + source: "registry", + registryPublisherDid: "did:plc:abc", + registrySlug: "nostorage", + }); + + const result = await handleRegistryUninstall(db, null, "r_cccccccccccccccc"); + expect(result.success).toBe(true); + expect(await repo.get("r_cccccccccccccccc")).toBeNull(); + }); + }); + + describe("handleRegistryUpdate", () => { + const stubSandbox: SandboxRunner = { + isAvailable: () => true, + // Update never invokes these in the error-path tests below; cast to + // satisfy the surface without implementing the full runner. + } as unknown as SandboxRunner; + const config = { aggregatorUrl: "https://aggregator.test" }; + + it("returns REGISTRY_NOT_CONFIGURED when no registry config is supplied", async () => { + const result = await handleRegistryUpdate( + db, + storage, + stubSandbox, + undefined, + "r_dddddddddddddddd", + ); + expect(result.success).toBe(false); + expect(result.error?.code).toBe("REGISTRY_NOT_CONFIGURED"); + }); + + it("returns STORAGE_NOT_CONFIGURED when storage is null", async () => { + const result = await handleRegistryUpdate( + db, + null, + stubSandbox, + config, + "r_dddddddddddddddd", + ); + expect(result.success).toBe(false); + expect(result.error?.code).toBe("STORAGE_NOT_CONFIGURED"); + }); + + it("returns SANDBOX_NOT_AVAILABLE when the runner is missing or unavailable", async () => { + const unavailable: SandboxRunner = { + isAvailable: () => false, + } as unknown as SandboxRunner; + const result = await handleRegistryUpdate( + db, + storage, + unavailable, + config, + "r_dddddddddddddddd", + ); + expect(result.success).toBe(false); + expect(result.error?.code).toBe("SANDBOX_NOT_AVAILABLE"); + }); + + it("returns NOT_FOUND for a plugin that is not registry-source", async () => { + const repo = new PluginStateRepository(db); + await repo.upsert("acme-seo", "1.0.0", "active", { + source: "marketplace", + marketplaceVersion: "1.0.0", + }); + const result = await handleRegistryUpdate(db, storage, stubSandbox, config, "acme-seo"); + expect(result.success).toBe(false); + expect(result.error?.code).toBe("NOT_FOUND"); + }); + + it("returns INVALID_STATE for a registry row missing publisher DID or slug", async () => { + const repo = new PluginStateRepository(db); + await repo.upsert("r_eeeeeeeeeeeeeeee", "1.0.0", "active", { + source: "registry", + // Intentionally omit registryPublisherDid + registrySlug to + // simulate a corrupted state row. + }); + const result = await handleRegistryUpdate( + db, + storage, + stubSandbox, + config, + "r_eeeeeeeeeeeeeeee", + ); + expect(result.success).toBe(false); + expect(result.error?.code).toBe("INVALID_STATE"); + }); + }); +}); diff --git a/packages/core/tests/unit/registry/checksum.test.ts b/packages/core/tests/unit/registry/checksum.test.ts new file mode 100644 index 000000000..0b843e6a7 --- /dev/null +++ b/packages/core/tests/unit/registry/checksum.test.ts @@ -0,0 +1,75 @@ +/** + * Tests for `verifyChecksum`: accepts hex SHA-256 + multibase-multihash + * (base32, sha2-256), rejects mismatches and malformed values. Backfills + * coverage deferred from PR #1011. + */ + +import { createHash } from "node:crypto"; + +import { toBase32 } from "@atcute/multibase"; +import { describe, expect, it } from "vitest"; + +import { verifyChecksum } from "../../../src/api/handlers/registry.js"; + +function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** + * Compute the multibase-multihash form atcute uses on the wire: a + * `b`-prefixed base32 string of `[0x12, 0x20, ...sha2-256(bytes)]`. + */ +function sha256Multibase(bytes: Uint8Array): string { + const digest = createHash("sha256").update(bytes).digest(); + const multihash = new Uint8Array(2 + digest.length); + multihash[0] = 0x12; // sha2-256 code + multihash[1] = 0x20; // length (32 bytes) + multihash.set(digest, 2); + return `b${toBase32(multihash)}`; +} + +describe("verifyChecksum", () => { + const bytes = new TextEncoder().encode("hello, registry"); + + it("accepts the correct hex SHA-256 of the bytes", async () => { + expect(await verifyChecksum(bytes, sha256Hex(bytes))).toBe(true); + }); + + it("accepts the hex SHA-256 case-insensitively", async () => { + expect(await verifyChecksum(bytes, sha256Hex(bytes).toUpperCase())).toBe(true); + }); + + it("rejects an incorrect hex SHA-256", async () => { + expect(await verifyChecksum(bytes, "0".repeat(64))).toBe(false); + }); + + it("accepts the multibase-multihash (sha2-256, base32) form", async () => { + expect(await verifyChecksum(bytes, sha256Multibase(bytes))).toBe(true); + }); + + it("rejects multibase encoded over the wrong bytes", async () => { + const wrong = new TextEncoder().encode("hello, different"); + expect(await verifyChecksum(bytes, sha256Multibase(wrong))).toBe(false); + }); + + it("rejects multibase wrapped around a non-sha2-256 algorithm", async () => { + // Forge a multihash header for sha2-512 (code 0x13, length 0x40) + // and check that verifyChecksum refuses it as the wrong family. + const digest = createHash("sha512").update(bytes).digest(); + const multihash = new Uint8Array(2 + digest.length); + multihash[0] = 0x13; + multihash[1] = 0x40; + multihash.set(digest, 2); + // Wrap as multibase but with the wrong inner hash family. The + // outer string length differs (sha2-512 yields a longer multihash) + // so it never passes verifyChecksum's strict 56-char shape check; + // document that as the failure path here. + expect(await verifyChecksum(bytes, `b${toBase32(multihash)}`)).toBe(false); + }); + + it("rejects strings that are neither hex nor valid multibase", async () => { + expect(await verifyChecksum(bytes, "")).toBe(false); + expect(await verifyChecksum(bytes, "not-a-checksum")).toBe(false); + expect(await verifyChecksum(bytes, "0xdeadbeef")).toBe(false); + }); +}); diff --git a/packages/core/tests/unit/registry/plugin-id.test.ts b/packages/core/tests/unit/registry/plugin-id.test.ts new file mode 100644 index 000000000..c8a26d1ff --- /dev/null +++ b/packages/core/tests/unit/registry/plugin-id.test.ts @@ -0,0 +1,51 @@ +/** + * Tests for `makeRegistryPluginId`: collision resistance + determinism + + * format. Backfills the coverage deferred from PR #1011 (the install + * handler shipped without dedicated unit tests for the opaque-id helper). + */ + +import { describe, expect, it } from "vitest"; + +import { + isRegistryPluginId, + makeRegistryPluginId, + REGISTRY_PLUGIN_ID_PATTERN, +} from "../../../src/registry/plugin-id.js"; + +describe("makeRegistryPluginId", () => { + it("produces an id that matches REGISTRY_PLUGIN_ID_PATTERN", async () => { + const id = await makeRegistryPluginId("did:plc:abc123", "gallery"); + expect(REGISTRY_PLUGIN_ID_PATTERN.test(id)).toBe(true); + expect(isRegistryPluginId(id)).toBe(true); + }); + + it("is deterministic — same (did, slug) always produces the same id", async () => { + const a = await makeRegistryPluginId("did:plc:abc123", "gallery"); + const b = await makeRegistryPluginId("did:plc:abc123", "gallery"); + expect(a).toBe(b); + }); + + it("distinguishes different slugs under the same publisher", async () => { + const gallery = await makeRegistryPluginId("did:plc:abc123", "gallery"); + const forms = await makeRegistryPluginId("did:plc:abc123", "forms"); + expect(gallery).not.toBe(forms); + }); + + it("distinguishes the same slug under different publishers", async () => { + const acme = await makeRegistryPluginId("did:plc:acme0001", "forms"); + const corp = await makeRegistryPluginId("did:plc:corp0001", "forms"); + expect(acme).not.toBe(corp); + }); + + it("collision-resistant across 10 000 distinct (did, slug) pairs", async () => { + // 80-bit ids — birthday collision is around 2^40 ≈ 10^12 inputs. + // 10 000 inputs should give zero collisions with overwhelming + // probability (~ 10^-12 chance per pair). + const ids = await Promise.all( + Array.from({ length: 10_000 }, (_, i) => + makeRegistryPluginId(`did:plc:test${i.toString(36).padStart(8, "0")}`, "x"), + ), + ); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ca723bf5..694c1ce19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1359,6 +1359,9 @@ importers: '@astrojs/react': specifier: '>=5.0.0-beta.0' version: 5.0.0-beta.4(@types/node@24.10.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(yaml@2.8.2) + '@atcute/client': + specifier: 'catalog:' + version: 4.2.1 '@atcute/lexicons': specifier: 'catalog:' version: 1.3.0 From 0b2fe1deaee699ca9368b0d8d4ad585f629f4af0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 20 May 2026 06:16:03 +0100 Subject: [PATCH 2/5] docs(changeset): rewrite for users, not implementation --- .changeset/registry-lifecycle.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.changeset/registry-lifecycle.md b/.changeset/registry-lifecycle.md index 527a2c9b1..082af0fa4 100644 --- a/.changeset/registry-lifecycle.md +++ b/.changeset/registry-lifecycle.md @@ -3,12 +3,8 @@ "emdash": minor --- -Adds the registry plugin lifecycle: uninstall, update (with capability + public-route re-consent), and update check. Closes #1036. +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. -- **`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`. +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). -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. +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. From 7d594a641435345e26cc43f4eeb5859e82b14b72 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 20 May 2026 20:04:12 +0100 Subject: [PATCH 3/5] fix(registry): address review feedback - PluginManager: extend the 'Check for updates' button gate from hasMarketplacePlugins to hasUpdatableSources so registry-only sites can actually trigger the merged update check. - handleRegistryUninstall: drop the bare try/catch around the _plugin_storage delete. A DELETE with zero matches doesn't throw, so the bare catch only ever masked real DB errors while still reporting dataDeleted: false. Real errors now propagate to the outer catch and surface as UNINSTALL_FAILED. - handleRegistryUpdate: remove the dead 'err instanceof SsrfError' branch from the catch (assertSafeArtifactUrl wraps SsrfError in a plain Error before rethrowing, so the branch was unreachable). - registry-handlers test header: drop the update-check coverage claim to match the actual test surface. Reworks the update-consent flow to actually consult the server's escalation gate instead of pre-confirming: - apiError / unwrapResult now plumb error.details through to the response body. The CAPABILITY_ESCALATION and ROUTE_VISIBILITY_ ESCALATION responses now carry their diff to the client. - New RegistryUpdateEscalationError carries the diff. updateRegistryPlugin parses CAPABILITY_ESCALATION / ROUTE_VISIBILITY_ESCALATION 403s and throws it instead of a generic Error. - PluginManager preflights the registry update without confirm flags; on escalation the consent dialog opens populated with the real capabilityChanges.added and newlyPublicRoutes; the user's confirm re-calls with confirmCapabilityChanges + confirmRouteVisibilityChanges set. Iterative escalations (capability then route) re-open the dialog with the new diff. - CapabilityConsentDialog: new newlyPublicRoutes prop renders the public-route diff alongside the capability diff. Marketplace's update path is unchanged in this PR (it still pre-confirms; same WS3 TODO it has always had). Registry no longer inherits that bypass. --- .../components/CapabilityConsentDialog.tsx | 24 ++++- .../admin/src/components/PluginManager.tsx | 67 +++++++++++--- packages/admin/src/lib/api/registry.ts | 91 +++++++++++++++++-- packages/core/src/api/error.ts | 21 ++++- packages/core/src/api/handlers/registry.ts | 14 +-- .../tests/unit/api/registry-handlers.test.ts | 19 ++-- 6 files changed, 191 insertions(+), 45 deletions(-) diff --git a/packages/admin/src/components/CapabilityConsentDialog.tsx b/packages/admin/src/components/CapabilityConsentDialog.tsx index c97de8bff..58056de1e 100644 --- a/packages/admin/src/components/CapabilityConsentDialog.tsx +++ b/packages/admin/src/components/CapabilityConsentDialog.tsx @@ -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 */ @@ -44,6 +46,7 @@ export function CapabilityConsentDialog({ capabilities, allowedHosts, newCapabilities = [], + newlyPublicRoutes = [], auditVerdict, isPending = false, error, @@ -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 (
0 && ( +
+
+ + {t`New public routes`} +
+

+ {t`This update exposes the following routes without authentication:`} +

+
    + {newlyPublicRoutes.map((route) => ( +
  • + {route} +
  • + ))} +
+
+ )} + {/* Audit verdict banner */} {auditVerdict && auditVerdict !== "pass" && (
[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 ( @@ -144,7 +151,7 @@ export function PluginManager({ manifest }: PluginManagerProps) {

{t`Plugins`}

- {hasMarketplacePlugins && ( + {hasUpdatableSources && (