diff --git a/.changeset/registry-lifecycle.md b/.changeset/registry-lifecycle.md new file mode 100644 index 000000000..082af0fa4 --- /dev/null +++ b/.changeset/registry-lifecycle.md @@ -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. 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:`} +

+ +
+ )} + {/* 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 ( @@ -143,7 +151,7 @@ export function PluginManager({ manifest }: PluginManagerProps) {

{t`Plugins`}

- {hasMarketplacePlugins && ( + {hasUpdatableSources && (
- {/* 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.`} -
- )}
)}
@@ -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(); }} /> diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index 5e594656a..2e6348b5e 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -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 { + 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 { + 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/error.ts b/packages/core/src/api/error.ts index 259c85579..98076a40a 100644 --- a/packages/core/src/api/error.ts +++ b/packages/core/src/api/error.ts @@ -28,8 +28,18 @@ const API_CACHE_HEADERS: HeadersInit = { * Always returns `{ error: { code, message } }` with correct Content-Type. * Use this for all error responses in API routes. */ -export function apiError(code: string, message: string, status: number): Response { - return Response.json({ error: { code, message } }, { status, headers: API_CACHE_HEADERS }); +export function apiError( + code: string, + message: string, + status: number, + details?: Record, +): Response { + const error: { code: string; message: string; details?: Record } = { + code, + message, + }; + if (details !== undefined) error.details = details; + return Response.json({ error }, { status, headers: API_CACHE_HEADERS }); } /** @@ -99,7 +109,12 @@ export function requireDb(db: unknown): Response | null { */ export function unwrapResult(result: ApiResult, successStatus = 200): Response { if (!result.success) { - return apiError(result.error.code, result.error.message, mapErrorStatus(result.error.code)); + return apiError( + result.error.code, + result.error.message, + mapErrorStatus(result.error.code), + result.error.details, + ); } return apiSuccess(result.data, successStatus); } diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts index df3e2f09a..ec1569fb6 100644 --- a/packages/core/src/api/errors.ts +++ b/packages/core/src/api/errors.ts @@ -196,6 +196,9 @@ export const ErrorCode = { INVALID_BUNDLE: "INVALID_BUNDLE", BUNDLE_EXTRACT_FAILED: "BUNDLE_EXTRACT_FAILED", BUNDLE_DOWNLOAD_FAILED: "BUNDLE_DOWNLOAD_FAILED", + AGGREGATOR_RESPONSE_INVALID: "AGGREGATOR_RESPONSE_INVALID", + AGGREGATOR_HTTP_ERROR: "AGGREGATOR_HTTP_ERROR", + AGGREGATOR_NOT_FOUND: "AGGREGATOR_NOT_FOUND", CAPABILITY_ESCALATION: "CAPABILITY_ESCALATION", ROUTE_VISIBILITY_ESCALATION: "ROUTE_VISIBILITY_ESCALATION", INSTALL_FAILED: "INSTALL_FAILED", @@ -396,6 +399,7 @@ export function mapErrorStatus(code: string | undefined): number { case ErrorCode.COLLECTION_NOT_FOUND: case ErrorCode.FILE_NOT_FOUND: case ErrorCode.NO_VERSION: + case ErrorCode.AGGREGATOR_NOT_FOUND: return 404; // 409 Conflict @@ -440,6 +444,8 @@ export function mapErrorStatus(code: string | undefined): number { // 502 Bad Gateway case ErrorCode.BUNDLE_DOWNLOAD_FAILED: + case ErrorCode.AGGREGATOR_RESPONSE_INVALID: + case ErrorCode.AGGREGATOR_HTTP_ERROR: return 502; // 503 Service Unavailable 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..f28a2f59a 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: err.status === 404 ? "AGGREGATOR_NOT_FOUND" : "AGGREGATOR_HTTP_ERROR", + message: `Aggregator returned ${err.status}: ${err.error}`, + }, + }; + } if (err instanceof EmDashStorageError) { return { success: false, @@ -1081,3 +1106,527 @@ 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; + + // Order: optional storage cleanup → bundle delete → state row delete. + // The most failure-prone step runs first so a transient DB error + // (deadlock, contention) cascades to the outer catch with the state + // row and bundle intact — admin retries safely. Bundle delete is + // idempotent on misses. + let dataDeleted = false; + if (opts?.deleteData) { + await db.deleteFrom("_plugin_storage").where("plugin_id", "=", pluginId).execute(); + dataDeleted = true; + } + + if (storage) { + await deleteBundleFromR2(storage, pluginId, version, "registry"); + } + + 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: err.status === 404 ? "AGGREGATOR_NOT_FOUND" : "AGGREGATOR_HTTP_ERROR", + message: `Aggregator returned ${err.status}: ${err.error}`, + }, + }; + } + 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: err.status === 404 ? "AGGREGATOR_NOT_FOUND" : "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..e086f591e 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,22 @@ /** - * 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; admins correlate + * items to plugins by `pluginId` and read `source` from the existing + * `/_emdash/api/admin/plugins` list (the pluginId prefix is not a + * reliable discriminator on its own). + * + * 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 +30,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..451a968aa --- /dev/null +++ b/packages/core/tests/unit/api/registry-handlers.test.ts @@ -0,0 +1,282 @@ +/** + * Registry handler tests (subset) + * + * Covers: + * - Uninstall (handleRegistryUninstall) — happy + sad paths. + * - Update (handleRegistryUpdate) — early error paths (config, state). + * + * Update happy-path and update-check coverage need a mocked DiscoveryClient + * plus a controlled `fetch`; tracked separately. The handler's identity + * check + diff flow mirrors `handleMarketplaceUpdate`, which has full + * coverage in `marketplace-handlers.test.ts`. + * + * Uses a real in-memory SQLite database and a mock `Storage`. + */ + +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